Compare commits
10 Commits
634e43a6c9
...
eaf0ee3d63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf0ee3d63 | ||
|
|
8640629005 | ||
|
|
8f69af24ba | ||
|
|
dc4a119a7a | ||
|
|
f9ea463ea8 | ||
|
|
b6f2872703 | ||
|
|
d98044692f | ||
|
|
30339b0443 | ||
|
|
0ab9be85a6 | ||
|
|
8ad60001fe |
69
src/App.tsx
69
src/App.tsx
@ -2,17 +2,17 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { AuthProvider } from './auth/AuthProvider'
|
import { AuthProvider } from './auth/AuthProvider'
|
||||||
import { LoginPage } from './screens/LoginPage'
|
import { LoginPage } from './screens/LoginPage'
|
||||||
import { ConsoleLayout } from './screens/ConsoleLayout'
|
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 { 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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -21,35 +21,32 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<ConsoleLayout />}>
|
<Route element={<ConsoleLayout />}>
|
||||||
<Route path="orders" element={<OrdersPage />} />
|
{routeConfig.map((route, i) => {
|
||||||
<Route path="orders/:instanceId" element={<OrdersPage />} />
|
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
|
||||||
|
|
||||||
<Route path="my-orders" element={<MyOrdersPage />} />
|
// Maintain nested route for Dataset items specifically
|
||||||
<Route path="my-orders/:instanceId" element={<MyOrdersPage />} />
|
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="calls" element={<CallsPage />} />
|
return (
|
||||||
<Route path="calls/:instanceId" element={<CallsPage />} />
|
<Route
|
||||||
|
key={i}
|
||||||
|
path={path}
|
||||||
|
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Route path="my-calls" element={<MyCallsPage />} />
|
<Route path="*" element={<RootRedirect />} />
|
||||||
<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 />} />
|
|
||||||
|
|
||||||
<Route path="admin/datasets" element={<DatasetsPage />}>
|
|
||||||
<Route path=":id" element={<DatasetItemsPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -122,6 +122,17 @@ export class ZinoClient {
|
|||||||
this.setToken(null);
|
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. */
|
/** Decode the persisted JWT into a User (no network) or use the saved user. */
|
||||||
currentUser(): User | null {
|
currentUser(): User | null {
|
||||||
if (!this.token) return null;
|
if (!this.token) return null;
|
||||||
@ -133,6 +144,7 @@ export class ZinoClient {
|
|||||||
org_id: String(p.org_id ?? ''),
|
org_id: String(p.org_id ?? ''),
|
||||||
name: p.name ?? '',
|
name: p.name ?? '',
|
||||||
email: p.email ?? '',
|
email: p.email ?? '',
|
||||||
|
mobile: String(p.mobile ?? p.mobile_number ?? p.phone ?? p.phone_number ?? p.user_mobile ?? ''),
|
||||||
roles: p.roles ?? [],
|
roles: p.roles ?? [],
|
||||||
groups: p.groups ?? [],
|
groups: p.groups ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,15 +21,22 @@ export function clientFor(slug: WorkflowSlug): ZinoClient {
|
|||||||
export const orderBookingClient = clientFor('orderBooking');
|
export const orderBookingClient = clientFor('orderBooking');
|
||||||
export const storeClient = clientFor('store');
|
export const storeClient = clientFor('store');
|
||||||
export const dailyReportsClient = clientFor('dailyReports');
|
export const dailyReportsClient = clientFor('dailyReports');
|
||||||
|
export const userClient = clientFor('user');
|
||||||
|
|
||||||
// The user JWT (from /usr/login) is valid across every workflow, but each
|
// 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.
|
// 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. */
|
/** Log in once and share the JWT with every workflow client. */
|
||||||
export async function loginAll(email: string, password: string, orgId?: string) {
|
export async function loginAll(email: string, password: string, orgId?: string) {
|
||||||
const res = await orderBookingClient.login(email, password, orgId);
|
const res = await orderBookingClient.login(email, password, orgId);
|
||||||
ALL.forEach((c) => c.setToken(res.token));
|
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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -177,11 +177,38 @@ export const DAILY_REPORTS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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.
|
// All workflows keyed by slug, for generic lookup.
|
||||||
export const WORKFLOWS = {
|
export const WORKFLOWS = {
|
||||||
orderBooking: ORDER_BOOKING,
|
orderBooking: ORDER_BOOKING,
|
||||||
store: STORE,
|
store: STORE,
|
||||||
dailyReports: DAILY_REPORTS,
|
dailyReports: DAILY_REPORTS,
|
||||||
|
user: USER,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// --- Daily Sales Report Constants ---
|
// --- Daily Sales Report Constants ---
|
||||||
@ -217,6 +244,8 @@ export const SALES_REPORT_SO_NAMES = [
|
|||||||
export const PIPELINE = {
|
export const PIPELINE = {
|
||||||
endpoints: {
|
endpoints: {
|
||||||
nearestStores: '/api/papi2/nearest-stores',
|
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;
|
org_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
mobile?: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode, useEffect } from 'react';
|
||||||
import { loginAll, logoutAll, currentToken } from '../api/clients';
|
import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
|
||||||
import { AuthCtx, type AuthValue } from './context';
|
import { AuthCtx, type AuthValue } from './context';
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@ -7,9 +7,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [userEmail, setUserEmail] = useState<string | null>(() => {
|
const [userEmail, setUserEmail] = useState<string | null>(() => {
|
||||||
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_user_email') : 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 = {
|
const value: AuthValue = {
|
||||||
authed,
|
authed,
|
||||||
|
isAdmin,
|
||||||
|
roles,
|
||||||
userEmail,
|
userEmail,
|
||||||
login: async (email, password, orgId) => {
|
login: async (email, password, orgId) => {
|
||||||
const res = await loginAll(email, password, orgId);
|
const res = await loginAll(email, password, orgId);
|
||||||
@ -18,11 +36,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUserEmail(res.user.email);
|
setUserEmail(res.user.email);
|
||||||
localStorage.setItem('krishna_sales_user_email', res.user.email);
|
localStorage.setItem('krishna_sales_user_email', res.user.email);
|
||||||
}
|
}
|
||||||
|
setIsAdmin(!!res.user?.is_admin);
|
||||||
|
setRoles(res.user?.roles ?? []);
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
logoutAll();
|
logoutAll();
|
||||||
setAuthed(false);
|
setAuthed(false);
|
||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setRoles([]);
|
||||||
localStorage.removeItem('krishna_sales_user_email');
|
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 {
|
export interface AuthValue {
|
||||||
authed: boolean;
|
authed: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
roles: string[];
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|||||||
@ -17,7 +17,8 @@ import {
|
|||||||
WfLookupField,
|
WfLookupField,
|
||||||
RadioField,
|
RadioField,
|
||||||
} from './fields';
|
} 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 {
|
export interface DynamicFormProps {
|
||||||
client: ZinoClient;
|
client: ZinoClient;
|
||||||
@ -52,7 +53,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
client.formSchema(currentActivityId, currentInstanceId)
|
client.formSchema(currentActivityId, currentInstanceId)
|
||||||
.then(res => {
|
.then(async (res) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setSchema(res);
|
setSchema(res);
|
||||||
const defaultValues: Record<string, unknown> = {};
|
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)
|
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 (!ignorePrefill) {
|
||||||
if (res.prefill_data) {
|
if (res.prefill_data && Object.keys(res.prefill_data).length > 0) {
|
||||||
Object.entries(res.prefill_data).forEach(([k, v]) => {
|
mapPrefillData(res.prefill_data);
|
||||||
if (!imageFieldIds.has(k)) defaultValues[k] = v;
|
|
||||||
});
|
|
||||||
} else if (res.data) {
|
} else if (res.data) {
|
||||||
Object.entries(res.data).forEach(([k, v]) => {
|
mapPrefillData(res.data);
|
||||||
if (!imageFieldIds.has(k)) defaultValues[k] = v;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +142,81 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
setValues(defaultValues);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (res.activity_name) {
|
if (res.activity_name) {
|
||||||
@ -271,11 +386,11 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
|||||||
|
|
||||||
if (cat && prod) {
|
if (cat && prod) {
|
||||||
const key = `${cat}::${prod}`;
|
const key = `${cat}::${prod}`;
|
||||||
if (indexMap.has(key)) {
|
if (indexMap.has(key) && currentBags > 0) {
|
||||||
const targetIdx = indexMap.get(key)!;
|
const targetIdx = indexMap.get(key)!;
|
||||||
const existingRow = merged[targetIdx];
|
const existingRow = merged[targetIdx];
|
||||||
const existingBags = Number(getGridVal(existingRow, ['bags', 'quantity'])) || 0;
|
const existingBags = Number(getGridVal(existingRow, ['bags', 'quantity'])) || 0;
|
||||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
const addedBags = currentBags;
|
||||||
const newBags = existingBags + addedBags;
|
const newBags = existingBags + addedBags;
|
||||||
const skuVal = Number(getGridVal(existingRow, ['sku'], ['code']) || currentSku || 0);
|
const skuVal = Number(getGridVal(existingRow, ['sku'], ['code']) || currentSku || 0);
|
||||||
|
|
||||||
@ -286,7 +401,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
|||||||
row_kgs: skuVal * newBags,
|
row_kgs: skuVal * newBags,
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else if (!indexMap.has(key)) {
|
||||||
indexMap.set(key, merged.length);
|
indexMap.set(key, merged.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -483,13 +598,113 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
|||||||
payload[f.id] = [fileMeta];
|
payload[f.id] = [fileMeta];
|
||||||
} else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') {
|
} else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') {
|
||||||
const gridRows = Array.isArray(val) ? val : [];
|
const gridRows = Array.isArray(val) ? val : [];
|
||||||
const cleanRows = gridRows.filter(r => {
|
const getFormattedGridVal = (colKey: string, rawVal: unknown, colDef?: FormScreenField) => {
|
||||||
return Object.keys(r).some(k => {
|
if (rawVal === undefined || rawVal === null || String(rawVal).trim() === '') return undefined;
|
||||||
const v = r[k];
|
|
||||||
return v !== undefined && v !== null && String(v).trim() !== '';
|
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;
|
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 {
|
} else {
|
||||||
payload[f.id] = val;
|
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({
|
export function DateField({
|
||||||
label,
|
label,
|
||||||
required,
|
required,
|
||||||
|
disabled,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@ -15,6 +17,7 @@ export function DateField({
|
|||||||
<Input
|
<Input
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
type="date"
|
type="date"
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
|||||||
@ -78,12 +78,12 @@ export function OrderGrid({
|
|||||||
|
|
||||||
if (cat && prod) {
|
if (cat && prod) {
|
||||||
const key = `${cat}::${prod}`;
|
const key = `${cat}::${prod}`;
|
||||||
if (indexMap.has(key)) {
|
if (indexMap.has(key) && currentBags > 0) {
|
||||||
const targetIdx = indexMap.get(key)!;
|
const targetIdx = indexMap.get(key)!;
|
||||||
const existingRow = merged[targetIdx];
|
const existingRow = merged[targetIdx];
|
||||||
|
|
||||||
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
||||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
const addedBags = currentBags;
|
||||||
const newBags = existingBags + addedBags;
|
const newBags = existingBags + addedBags;
|
||||||
const skuVal = Number(existingRow.sku || r.sku || 0);
|
const skuVal = Number(existingRow.sku || r.sku || 0);
|
||||||
|
|
||||||
@ -97,7 +97,7 @@ export function OrderGrid({
|
|||||||
|
|
||||||
merged[targetIdx] = updatedRow;
|
merged[targetIdx] = updatedRow;
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else if (!indexMap.has(key)) {
|
||||||
indexMap.set(key, merged.length);
|
indexMap.set(key, merged.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,6 +206,7 @@ export function OrderGrid({
|
|||||||
|
|
||||||
const isCatField = isCategoryColumn(fieldId, colName);
|
const isCatField = isCategoryColumn(fieldId, colName);
|
||||||
const isProdField = isProductColumn(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) {
|
if (isCatField) {
|
||||||
const catVal = String(val ?? '');
|
const catVal = String(val ?? '');
|
||||||
@ -222,6 +223,10 @@ export function OrderGrid({
|
|||||||
row['br'] = '';
|
row['br'] = '';
|
||||||
row['sku_code'] = '';
|
row['sku_code'] = '';
|
||||||
row['skucode'] = '';
|
row['skucode'] = '';
|
||||||
|
row['bags'] = '';
|
||||||
|
row['quantity'] = '';
|
||||||
|
if (bagsCol) row[bagsCol.id] = '';
|
||||||
|
row['row_kgs'] = 0;
|
||||||
} else if (isProdField) {
|
} else if (isProdField) {
|
||||||
const currentCat = String(row['category'] || row['product_category'] || (catCol ? row[catCol.id] : '') || '');
|
const currentCat = String(row['category'] || row['product_category'] || (catCol ? row[catCol.id] : '') || '');
|
||||||
const prodOptions = getProductsForCategory(currentCat);
|
const prodOptions = getProductsForCategory(currentCat);
|
||||||
@ -242,25 +247,40 @@ export function OrderGrid({
|
|||||||
row['productName'] = selectedName;
|
row['productName'] = selectedName;
|
||||||
if (prodNameCol) row[prodNameCol.id] = setVal;
|
if (prodNameCol) row[prodNameCol.id] = setVal;
|
||||||
|
|
||||||
row['sku'] = sku;
|
row['sku'] = String(sku ?? '');
|
||||||
const skuCol = findColumn(columns, 'sku');
|
const skuCol = findColumn(columns, 'sku');
|
||||||
if (skuCol) row[skuCol.id] = sku;
|
if (skuCol) row[skuCol.id] = String(sku ?? '');
|
||||||
|
|
||||||
row['br'] = br;
|
row['br'] = String(br ?? '');
|
||||||
row['br_code'] = br;
|
row['br_code'] = String(br ?? '');
|
||||||
const brCol = findColumn(columns, 'br_code', '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['skucode'] = String(skucode ?? '');
|
||||||
row['sku_code'] = skucode;
|
row['sku_code'] = String(skucode ?? '');
|
||||||
const skuCodeCol = findColumn(columns, 'sku_code', 'skucode');
|
const skuCodeCol = findColumn(columns, 'sku_code', 'skucode');
|
||||||
if (skuCodeCol) row[skuCodeCol.id] = skucode;
|
if (skuCodeCol) row[skuCodeCol.id] = String(skucode ?? '');
|
||||||
|
|
||||||
if (cat) {
|
if (cat) {
|
||||||
row['category'] = cat;
|
row['category'] = cat;
|
||||||
row['product_category'] = cat;
|
row['product_category'] = cat;
|
||||||
if (catCol) row[catCol.id] = 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 {
|
} else {
|
||||||
row[fieldId] = val;
|
row[fieldId] = val;
|
||||||
}
|
}
|
||||||
@ -278,7 +298,7 @@ export function OrderGrid({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onChange(cleanRows);
|
onChange(cleanRows);
|
||||||
}, [rows, columns, visibleColumns, prodNameCol, catCol, getProductsForCategory, onChange]);
|
}, [rows, columns, visibleColumns, prodNameCol, catCol, bagsCol, getProductsForCategory, onChange]);
|
||||||
|
|
||||||
const { calculatedBags, calculatedKgs } = useMemo(() => {
|
const { calculatedBags, calculatedKgs } = useMemo(() => {
|
||||||
let bagsAcc = 0;
|
let bagsAcc = 0;
|
||||||
|
|||||||
@ -66,12 +66,12 @@ export function StorePotentialGrid({
|
|||||||
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
|
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
|
||||||
|
|
||||||
if (cat) {
|
if (cat) {
|
||||||
if (indexMap.has(cat)) {
|
if (indexMap.has(cat) && currentBags > 0) {
|
||||||
const targetIdx = indexMap.get(cat)!;
|
const targetIdx = indexMap.get(cat)!;
|
||||||
const existingRow = merged[targetIdx];
|
const existingRow = merged[targetIdx];
|
||||||
|
|
||||||
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
||||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
const addedBags = currentBags;
|
||||||
const newBags = existingBags + addedBags;
|
const newBags = existingBags + addedBags;
|
||||||
|
|
||||||
const updatedRow: Record<string, unknown> = {
|
const updatedRow: Record<string, unknown> = {
|
||||||
@ -83,7 +83,7 @@ export function StorePotentialGrid({
|
|||||||
|
|
||||||
merged[targetIdx] = updatedRow;
|
merged[targetIdx] = updatedRow;
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else if (!indexMap.has(cat)) {
|
||||||
indexMap.set(cat, merged.length);
|
indexMap.set(cat, merged.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,19 +124,27 @@ export function StorePotentialGrid({
|
|||||||
const colDef = columns.find((c) => c.id === fieldId);
|
const colDef = columns.find((c) => c.id === fieldId);
|
||||||
const colName = colDef?.name || '';
|
const colName = colDef?.name || '';
|
||||||
const isCatField = isCategoryColumn(fieldId, colName);
|
const isCatField = isCategoryColumn(fieldId, colName);
|
||||||
|
const isBagsField = colDef?.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || colName.toLowerCase().includes('bags');
|
||||||
|
|
||||||
if (isCatField) {
|
if (isCatField) {
|
||||||
const catVal = String(val ?? '');
|
const catVal = String(val ?? '');
|
||||||
row[fieldId] = catVal;
|
row[fieldId] = catVal;
|
||||||
row['category'] = catVal;
|
row['category'] = catVal;
|
||||||
row['product_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 {
|
} else {
|
||||||
row[fieldId] = val;
|
row[fieldId] = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
next[rowIdx] = row;
|
next[rowIdx] = row;
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}, [rows, columns, onChange]);
|
}, [rows, columns, bagsCol, onChange]);
|
||||||
|
|
||||||
return (
|
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">
|
<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({
|
export function TimeField({
|
||||||
label,
|
label,
|
||||||
required,
|
required,
|
||||||
|
disabled,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@ -15,6 +17,7 @@ export function TimeField({
|
|||||||
<Input
|
<Input
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
type="time"
|
type="time"
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(e.target.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 { createPortal } from 'react-dom';
|
||||||
import { ChevronDown, Search, X, Check } from 'lucide-react';
|
import { ChevronDown, Search, X, Check } from 'lucide-react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
@ -20,6 +20,7 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Disable search header filter if set to false */
|
/** Disable search header filter if set to false */
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
|
onDropdownOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Custom searchable select component using React Portal to prevent container clipping. */
|
/** Custom searchable select component using React Portal to prevent container clipping. */
|
||||||
@ -33,6 +34,7 @@ export function Select({
|
|||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
|
onDropdownOpen,
|
||||||
...rest
|
...rest
|
||||||
}: SelectProps) {
|
}: SelectProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@ -61,10 +63,17 @@ export function Select({
|
|||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
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({
|
setDropdownStyle({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: `${rect.left}px`,
|
left: `${leftPos}px`,
|
||||||
width: `${rect.width}px`,
|
width: `${calculatedWidth}px`,
|
||||||
|
maxWidth: 'calc(100vw - 24px)',
|
||||||
zIndex: 999999,
|
zIndex: 999999,
|
||||||
...(openUpwards
|
...(openUpwards
|
||||||
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
||||||
@ -73,7 +82,7 @@ export function Select({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updatePosition();
|
updatePosition();
|
||||||
const handleScrollOrResize = () => updatePosition();
|
const handleScrollOrResize = () => updatePosition();
|
||||||
@ -83,6 +92,8 @@ export function Select({
|
|||||||
window.removeEventListener('resize', handleScrollOrResize);
|
window.removeEventListener('resize', handleScrollOrResize);
|
||||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
setDropdownStyle({});
|
||||||
}
|
}
|
||||||
}, [isOpen, updatePosition]);
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
@ -100,7 +111,26 @@ export function Select({
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
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) {
|
if (rest.onChange) {
|
||||||
const syntheticEvent = {
|
const syntheticEvent = {
|
||||||
target: { value: val, name: rest.name, id: rest.id },
|
target: { value: val, name: rest.name, id: rest.id },
|
||||||
@ -123,7 +153,7 @@ export function Select({
|
|||||||
|
|
||||||
{/* Trigger Box */}
|
{/* Trigger Box */}
|
||||||
<div
|
<div
|
||||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
onClick={handleOpenToggle}
|
||||||
className={cn(
|
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",
|
"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",
|
disabled && "opacity-60 cursor-not-allowed bg-slate-50",
|
||||||
@ -137,7 +167,7 @@ export function Select({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Portaled Dropdown Menu Overlay */}
|
{/* Portaled Dropdown Menu Overlay */}
|
||||||
{isOpen && !disabled && createPortal(
|
{isOpen && !disabled && Boolean(dropdownStyle.position) && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
@ -158,7 +188,11 @@ export function Select({
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
@ -174,13 +208,14 @@ export function Select({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => handleSelect(opt.value)}
|
onClick={(e) => handleSelect(opt.value, e)}
|
||||||
|
title={opt.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors",
|
"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"
|
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" />}
|
{isSelected && <Check size={14} className="text-blue-600 shrink-0 ml-2" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -263,13 +298,21 @@ export function MultiSelect({
|
|||||||
|
|
||||||
const currentValues = Array.isArray(value) ? value : [];
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
const handleOpenToggle = () => {
|
const handleOpenToggle = (e?: React.MouseEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
if (!isOpen) {
|
const next = !isOpen;
|
||||||
|
if (next) {
|
||||||
setDraftValues(currentValues);
|
setDraftValues(currentValues);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
updatePosition();
|
||||||
|
} else {
|
||||||
|
setDropdownStyle({});
|
||||||
}
|
}
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(next);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -284,10 +327,17 @@ export function MultiSelect({
|
|||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
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({
|
setDropdownStyle({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: `${rect.left}px`,
|
left: `${leftPos}px`,
|
||||||
width: `${rect.width}px`,
|
width: `${calculatedWidth}px`,
|
||||||
|
maxWidth: 'calc(100vw - 24px)',
|
||||||
zIndex: 999999,
|
zIndex: 999999,
|
||||||
...(openUpwards
|
...(openUpwards
|
||||||
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
||||||
@ -296,7 +346,7 @@ export function MultiSelect({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updatePosition();
|
updatePosition();
|
||||||
const handleScrollOrResize = () => updatePosition();
|
const handleScrollOrResize = () => updatePosition();
|
||||||
@ -306,6 +356,8 @@ export function MultiSelect({
|
|||||||
window.removeEventListener('resize', handleScrollOrResize);
|
window.removeEventListener('resize', handleScrollOrResize);
|
||||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
setDropdownStyle({});
|
||||||
}
|
}
|
||||||
}, [isOpen, updatePosition]);
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
@ -323,18 +375,30 @@ export function MultiSelect({
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleOption = (val: string) => {
|
const toggleOption = (val: string, e?: React.MouseEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
setDraftValues((prev) =>
|
setDraftValues((prev) =>
|
||||||
prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val]
|
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);
|
onChange?.(draftValues);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = (e?: React.MouseEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
setDraftValues([]);
|
setDraftValues([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -374,7 +438,7 @@ export function MultiSelect({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Portaled Dropdown Menu Overlay */}
|
{/* Portaled Dropdown Menu Overlay */}
|
||||||
{isOpen && !disabled && createPortal(
|
{isOpen && !disabled && Boolean(dropdownStyle.position) && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
@ -395,7 +459,11 @@ export function MultiSelect({
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
@ -411,9 +479,10 @@ export function MultiSelect({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => toggleOption(opt.value)}
|
onClick={(e) => toggleOption(opt.value, e)}
|
||||||
|
title={opt.label}
|
||||||
className={cn(
|
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"
|
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"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => {}}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Spinner } from '../reusable/Spinner';
|
|||||||
import { EmptyState } from '../reusable/EmptyState';
|
import { EmptyState } from '../reusable/EmptyState';
|
||||||
import { StatsTiles } from '../reusable/StatsTiles';
|
import { StatsTiles } from '../reusable/StatsTiles';
|
||||||
import { AnalyticsChart } from '../reusable/AnalyticsChart';
|
import { AnalyticsChart } from '../reusable/AnalyticsChart';
|
||||||
import { Select, MultiSelect } from '../reusable/Select';
|
import { MultiSelect } from '../reusable/Select';
|
||||||
|
|
||||||
export interface RecordViewProps {
|
export interface RecordViewProps {
|
||||||
/** Workflow-bound client (see api/clients.ts). */
|
/** 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 { CallsView } from './CallsView';
|
||||||
export { StoresView } from './StoresView';
|
export { StoresView } from './StoresView';
|
||||||
export { DailyLogsView } from './DailyLogsView';
|
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 { useEffect, useState } from 'react';
|
||||||
import { Button } from '../components/buttons/Button';
|
import { Button } from '../components/buttons/Button';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
import { DynamicForm, LogVisitForm } from '../components/forms';
|
||||||
import { ORDER_BOOKING } from '../api/config';
|
import { ORDER_BOOKING } from '../api/config';
|
||||||
import { orderBookingClient } from '../api/clients';
|
import { orderBookingClient } from '../api/clients';
|
||||||
|
|
||||||
@ -185,10 +185,8 @@ export function CallsPage() {
|
|||||||
title={createTitle}
|
title={createTitle}
|
||||||
width="md"
|
width="md"
|
||||||
>
|
>
|
||||||
<DynamicForm
|
<LogVisitForm
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
|
||||||
initialActivityName="Log Visit"
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
|
|||||||
@ -1,17 +1,30 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
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 { cn } from '../lib/cn';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||||
|
|
||||||
|
|
||||||
import { SCREENS } from './tabs';
|
|
||||||
import { REPORT_MAP } from './ReportPage';
|
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>. */
|
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
||||||
export function ConsoleLayout() {
|
export function ConsoleLayout() {
|
||||||
const { authed, logout } = useAuth();
|
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = orderBookingClient.currentUser();
|
const user = orderBookingClient.currentUser();
|
||||||
@ -28,18 +41,35 @@ export function ConsoleLayout() {
|
|||||||
|
|
||||||
if (!authed) return <Navigate to="/login" replace />;
|
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 (
|
return (
|
||||||
<div className="h-screen bg-[var(--secondary-color)] flex flex-col">
|
<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">
|
<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>
|
<span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4">
|
<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 (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={t.key}
|
key={key}
|
||||||
to={`/${t.key}`}
|
to={route.path}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
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',
|
'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>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="relative group flex items-center h-full">
|
{canSeeReports && (
|
||||||
<button className={cn(
|
<div className="relative group flex items-center h-full">
|
||||||
"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",
|
<button className={cn(
|
||||||
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
|
"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" />
|
Reports
|
||||||
</button>
|
<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">
|
<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]) => (
|
{Object.entries(REPORT_MAP).map(([key, report]) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={key}
|
key={key}
|
||||||
to={`/reports/${key}`}
|
to={`/reports/${key}`}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
|
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
|
||||||
isActive && "bg-gray-100 font-semibold"
|
isActive && "bg-gray-100 font-semibold"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{report.title}
|
{report.title}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
|
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
|
||||||
<button
|
<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-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>
|
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
|
{isAdmin && (
|
||||||
<NavLink
|
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
|
||||||
to="/admin/datasets"
|
<NavLink
|
||||||
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")}
|
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
|
<Database size={16} />
|
||||||
</NavLink>
|
Manage Datasets
|
||||||
</div>
|
</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">
|
<div className="px-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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 { Card, Input, Select } from '../components/reusable';
|
||||||
import { Button } from '../components/buttons/Button';
|
import { Button } from '../components/buttons/Button';
|
||||||
import {
|
import {
|
||||||
SALES_REPORT_ROUTES,
|
SALES_REPORT_ROUTES,
|
||||||
ROUTE_WISE_DISTRIBUTORS,
|
ROUTE_WISE_DISTRIBUTORS,
|
||||||
SALES_REPORT_SO_NAMES
|
BASE_URL,
|
||||||
|
PIPELINE
|
||||||
} from '../api/config';
|
} from '../api/config';
|
||||||
import { Download, Printer } from 'lucide-react';
|
import { Download, Printer } from 'lucide-react';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
@ -14,12 +15,36 @@ export function DailySalesReportPage() {
|
|||||||
const [date, setDate] = useState('');
|
const [date, setDate] = useState('');
|
||||||
const [route, setRoute] = useState('');
|
const [route, setRoute] = useState('');
|
||||||
const [distributor, setDistributor] = 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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [reportData, setReportData] = useState<any>(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 downloadPDF = () => {
|
||||||
const doc = new jsPDF({ orientation: 'landscape' });
|
const doc = new jsPDF({ orientation: 'landscape' });
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
@ -48,7 +73,8 @@ export function DailySalesReportPage() {
|
|||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text(`Distributor Name : ${distributor}`, 14, 40);
|
doc.text(`Distributor Name : ${distributor}`, 14, 40);
|
||||||
doc.text(`Date : ${date}`, rightX, 40, { align: 'right' });
|
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
|
// Table
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
@ -90,7 +116,8 @@ export function DailySalesReportPage() {
|
|||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text(`Distributor Name : ${distributor}`, 14, 40);
|
doc.text(`Distributor Name : ${distributor}`, 14, 40);
|
||||||
doc.text(`Date : ${date}`, rightX, 40, { align: 'right' });
|
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
|
// Table
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
@ -124,7 +151,7 @@ export function DailySalesReportPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setReportData(null);
|
setReportData(null);
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'TemplateID': '157',
|
'TemplateID': '157',
|
||||||
@ -135,7 +162,7 @@ export function DailySalesReportPage() {
|
|||||||
date,
|
date,
|
||||||
route,
|
route,
|
||||||
distributor,
|
distributor,
|
||||||
so_name: soName,
|
so_name: soEmail,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -181,9 +208,9 @@ export function DailySalesReportPage() {
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="SO Name"
|
label="SO Name"
|
||||||
value={soName}
|
value={soEmail}
|
||||||
onChange={(e) => setSoName(e.target.value)}
|
onChange={(e) => setSoEmail(e.target.value)}
|
||||||
options={[{ value: '', label: 'Select SO Name' }, ...SALES_REPORT_SO_NAMES.map(s => ({ value: s, label: s }))]}
|
options={[{ value: '', label: 'Select SO Name' }, ...soOptions]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
{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>Distributor Name : {distributor}</div>
|
||||||
<div className="text-right flex flex-col gap-1.5">
|
<div className="text-right flex flex-col gap-1.5">
|
||||||
<div>Date : {date}</div>
|
<div>Date : {date}</div>
|
||||||
<div>SO Name : {soName}</div>
|
<div>SO Name : {soOptions.find(o => o.value === soEmail)?.label || soEmail}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { CallDetail } from '../components/dv';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button } from '../components/buttons/Button';
|
import { Button } from '../components/buttons/Button';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
import { DynamicForm, LogVisitForm } from '../components/forms';
|
||||||
import { ORDER_BOOKING } from '../api/config';
|
import { ORDER_BOOKING } from '../api/config';
|
||||||
import { orderBookingClient } from '../api/clients';
|
import { orderBookingClient } from '../api/clients';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
@ -190,10 +190,8 @@ export function MyCallsPage() {
|
|||||||
title={createTitle}
|
title={createTitle}
|
||||||
width="md"
|
width="md"
|
||||||
>
|
>
|
||||||
<DynamicForm
|
<LogVisitForm
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
|
||||||
initialActivityName="Log Visit"
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import { OrderDetail } from '../components/dv';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '../components/buttons/Button';
|
import { Button } from '../components/buttons/Button';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
import { LogVisitForm } from '../components/forms';
|
||||||
import { ORDER_BOOKING } from '../api/config';
|
|
||||||
import { orderBookingClient } from '../api/clients';
|
import { orderBookingClient } from '../api/clients';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
|
|
||||||
@ -46,10 +45,8 @@ export function MyOrdersPage() {
|
|||||||
title={createTitle}
|
title={createTitle}
|
||||||
width="md"
|
width="md"
|
||||||
>
|
>
|
||||||
<DynamicForm
|
<LogVisitForm
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
|
||||||
initialActivityName="Place Order"
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import { OrderDetail } from '../components/dv';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '../components/buttons/Button';
|
import { Button } from '../components/buttons/Button';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
import { LogVisitForm } from '../components/forms';
|
||||||
import { ORDER_BOOKING } from '../api/config';
|
|
||||||
import { orderBookingClient } from '../api/clients';
|
import { orderBookingClient } from '../api/clients';
|
||||||
|
|
||||||
export function OrdersPage() {
|
export function OrdersPage() {
|
||||||
@ -40,10 +39,8 @@ export function OrdersPage() {
|
|||||||
title={createTitle}
|
title={createTitle}
|
||||||
width="md"
|
width="md"
|
||||||
>
|
>
|
||||||
<DynamicForm
|
<LogVisitForm
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
|
||||||
initialActivityName="Place Order"
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { orderBookingClient } from '../../api/clients';
|
import { orderBookingClient } from '../../api/clients';
|
||||||
import type { Dataset } from '../../api/types';
|
import type { Dataset } from '../../api/types';
|
||||||
import { Button } from '../../components/buttons/Button';
|
import { Button } from '../../components/buttons/Button';
|
||||||
import { Spinner } from '../../components/reusable/Spinner';
|
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() {
|
export function DatasetItemsPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [dataset, setDataset] = useState<Dataset | null>(null);
|
const [dataset, setDataset] = useState<Dataset | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@ -2,21 +2,29 @@ import { useEffect, useState } from 'react';
|
|||||||
import { orderBookingClient } from '../../api/clients';
|
import { orderBookingClient } from '../../api/clients';
|
||||||
import type { Dataset } from '../../api/types';
|
import type { Dataset } from '../../api/types';
|
||||||
import { Spinner } from '../../components/reusable/Spinner';
|
import { Spinner } from '../../components/reusable/Spinner';
|
||||||
import { Database, LayoutList, Table2 } from 'lucide-react';
|
import { Database } from 'lucide-react';
|
||||||
import { useNavigate, useLocation, Outlet, useParams } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet, useParams, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../auth/context';
|
||||||
|
|
||||||
export function DatasetsPage() {
|
export function DatasetsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDatasets();
|
if (isAdmin) {
|
||||||
}, []);
|
fetchDatasets();
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/orders" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchDatasets = async () => {
|
const fetchDatasets = async () => {
|
||||||
try {
|
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,
|
DailyLogDetail,
|
||||||
type WiredDetailViewProps,
|
type WiredDetailViewProps,
|
||||||
} from '../components/dv';
|
} from '../components/dv';
|
||||||
|
import { routeConfig } from '../routesConfig';
|
||||||
|
|
||||||
export type ScreenKey = 'orders' | 'my-orders' | 'calls' | 'my-calls' | 'stores' | 'daily' | 'my-daily' | 'sales-report';
|
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;
|
Detail: (p: WiredDetailViewProps) => React.JSX.Element;
|
||||||
/** Singular noun for the detail title. */
|
/** Singular noun for the detail title. */
|
||||||
noun: string;
|
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[] = [
|
export const SCREENS: ScreenDef[] = [
|
||||||
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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 {
|
export function screenByKey(key: string | undefined): ScreenDef | undefined {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user