Compare commits
No commits in common. "37693556b371c68e375a4c6282e5391a56836566" and "d2498ee68d0f0309b8387c2c5f24228602d6af13" have entirely different histories.
37693556b3
...
d2498ee68d
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,3 +25,4 @@ dist-ssr
|
||||
|
||||
# vite-plugin-pwa dev output
|
||||
dev-dist
|
||||
.env
|
||||
|
||||
59
src/App.tsx
59
src/App.tsx
@ -2,16 +2,15 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import { LoginPage } from './screens/LoginPage'
|
||||
import { ConsoleLayout } from './screens/ConsoleLayout'
|
||||
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
|
||||
import { useAuth } from './auth/context'
|
||||
import { routeConfig } from './routesConfig'
|
||||
import { OrdersPage } from './screens/OrdersPage'
|
||||
import { CallsPage } from './screens/CallsPage'
|
||||
import { StoresPage } from './screens/StoresPage'
|
||||
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
import { AllDailyLogsPage } from './screens/AllDailyLogsPage'
|
||||
import { AnalyticsPage } from './screens/AnalyticsPage'
|
||||
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
||||
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
||||
|
||||
function RootRedirect() {
|
||||
const { roles, isAdmin } = useAuth();
|
||||
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
@ -19,31 +18,31 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ConsoleLayout />}>
|
||||
{routeConfig.map((route, i) => {
|
||||
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
|
||||
|
||||
if (path === 'admin/datasets') {
|
||||
return (
|
||||
<Route
|
||||
key={i}
|
||||
path={path}
|
||||
element={<ProtectedRoute adminOnly={route.adminOnly} roles={route.roles}>{route.element}</ProtectedRoute>}
|
||||
>
|
||||
<Route path="/daily" element={<DailyLogsPage />} />
|
||||
<Route path="/daily/:instanceId" element={<DailyLogsPage />} />
|
||||
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/orders/:instanceId" element={<OrdersPage />} />
|
||||
|
||||
<Route path="/calls" element={<CallsPage />} />
|
||||
<Route path="/calls/:instanceId" element={<CallsPage />} />
|
||||
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/stores/:instanceId" element={<StoresPage />} />
|
||||
|
||||
<Route path="/all-daily" element={<AllDailyLogsPage />} />
|
||||
<Route path="/all-daily/:instanceId" element={<AllDailyLogsPage />} />
|
||||
|
||||
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/sales-report" element={<DailySalesReportPage />} />
|
||||
|
||||
<Route path="/admin/datasets" element={<DatasetsPage />}>
|
||||
<Route path=":id" element={<DatasetItemsPage />} />
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Route
|
||||
key={i}
|
||||
path={path}
|
||||
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Route>
|
||||
<Route path="*" element={<RootRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/daily" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
|
||||
@ -110,23 +110,6 @@ export class ZinoClient {
|
||||
this.setToken(null);
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const storedUser = localStorage.getItem(TOKEN_KEY + '_user');
|
||||
if (storedUser) {
|
||||
const u = JSON.parse(storedUser);
|
||||
const updated = { ...u, ...res };
|
||||
localStorage.setItem(TOKEN_KEY + '_user', JSON.stringify(updated));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Decode the persisted JWT into a User (no network). */
|
||||
currentUser(): User | null {
|
||||
if (!this.token) return null;
|
||||
@ -167,7 +150,7 @@ export class ZinoClient {
|
||||
...(alias ? { preset_alias: alias } : {}),
|
||||
search_query: {
|
||||
page: params.page ?? 1,
|
||||
limit: params.limit ?? 10,
|
||||
limit: params.limit ?? 50,
|
||||
sort_by: params.sortBy ?? '',
|
||||
sort_dir: params.sortDir ?? 'desc',
|
||||
search: params.search ?? '',
|
||||
|
||||
@ -21,22 +21,15 @@ export function clientFor(slug: WorkflowSlug): ZinoClient {
|
||||
export const orderBookingClient = clientFor('orderBooking');
|
||||
export const storeClient = clientFor('store');
|
||||
export const dailyReportsClient = clientFor('dailyReports');
|
||||
export const userClient = clientFor('user');
|
||||
|
||||
// The user JWT (from /usr/login) is valid across every workflow, but each
|
||||
// client holds its own copy — so auth helpers fan out to all of them.
|
||||
const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient];
|
||||
const ALL = [orderBookingClient, storeClient, dailyReportsClient];
|
||||
|
||||
/** Log in once and share the JWT with every workflow client. */
|
||||
export async function loginAll(email: string, password: string, orgId?: string) {
|
||||
const res = await orderBookingClient.login(email, password, orgId);
|
||||
ALL.forEach((c) => c.setToken(res.token));
|
||||
try {
|
||||
const me = await orderBookingClient.getMe();
|
||||
res.user = { ...res.user, ...me };
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch user profile:', e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@ -170,38 +170,11 @@ export const DAILY_REPORTS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
// --- User Management (src/docs/api/user.md) ---
|
||||
export const USER = {
|
||||
workflowUuid: '9874af12-78b7-4730-834a-19a32221f3aa',
|
||||
versionUuid: '', // No version UUID provided, server will use latest
|
||||
activities: {
|
||||
ADD_USER: {
|
||||
uid: '3a8f132e-8c63-4d4d-b466-67595d345a99', // init
|
||||
fields: {
|
||||
name: 'name', // text
|
||||
email: 'email', // email
|
||||
password: 'password', // password
|
||||
},
|
||||
},
|
||||
EDIT_USER: {
|
||||
uid: '3091530c-4315-42b9-b53b-a148f2c54a81',
|
||||
fields: {
|
||||
name: 'name_3', // text
|
||||
email: 'email_3', // email
|
||||
},
|
||||
},
|
||||
},
|
||||
recordViews: {
|
||||
SALES_OFFICERS: '406f89d7-09f6-42ba-ba35-84334ccb6204',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// All workflows keyed by slug, for generic lookup.
|
||||
export const WORKFLOWS = {
|
||||
orderBooking: ORDER_BOOKING,
|
||||
store: STORE,
|
||||
dailyReports: DAILY_REPORTS,
|
||||
user: USER,
|
||||
} as const;
|
||||
|
||||
// --- Daily Sales Report Constants ---
|
||||
@ -217,17 +190,16 @@ export const SALES_REPORT_ROUTES = [
|
||||
'West-4'
|
||||
];
|
||||
|
||||
export const ROUTE_WISE_DISTRIBUTORS: Record<string, string[]> = {
|
||||
"Krishna": ["Banashree Multi Millet Flour"],
|
||||
"East-1": ["Shiva nandi Enterprises"],
|
||||
"East-2": ["BPK & Co"],
|
||||
"East-3": ["BPK & Co"],
|
||||
"East-4": ["Gaviranga "],
|
||||
"West-1": ["Bhagwan Enterprises"],
|
||||
"West-2": ["Shreshtaa Trading Co "],
|
||||
"West-3": ["Rajarajeshwari Enterprises "],
|
||||
"West-4": ["Byraveshwara Trading Co"],
|
||||
};
|
||||
export const SALES_REPORT_DISTRIBUTORS = [
|
||||
'Banashree Multi Millet Flour',
|
||||
'Shiva nandi Enterprises',
|
||||
'BPK & Co',
|
||||
'Gaviranga',
|
||||
'Bhagwan Enterprises',
|
||||
'Shreshtaa Trading Co',
|
||||
'Rajarajeshwari Enterprises',
|
||||
'Byraveshwara Trading Co'
|
||||
];
|
||||
|
||||
export const SALES_REPORT_SO_NAMES = [
|
||||
'Surya C'
|
||||
@ -237,8 +209,6 @@ export const SALES_REPORT_SO_NAMES = [
|
||||
export const PIPELINE = {
|
||||
endpoints: {
|
||||
nearestStores: '/api/papi2/nearest-stores',
|
||||
dailySalesReport: '/api/papi2/daily-sales-report',
|
||||
productiveCallSummary: '/api/papi2/productive-call-summary',
|
||||
salesOfficers: '/api/papi2/sales-officers'
|
||||
dailySalesReport: '/api/papi2/daily-sales-report'
|
||||
}
|
||||
};
|
||||
|
||||
@ -12,7 +12,6 @@ export interface User {
|
||||
email: string;
|
||||
roles: string[];
|
||||
groups: string[];
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
||||
@ -1,57 +1,19 @@
|
||||
import { useState, type ReactNode, useEffect } from 'react';
|
||||
import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { loginAll, logoutAll, currentToken } from '../api/clients';
|
||||
import { AuthCtx, type AuthValue } from './context';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authed, setAuthed] = useState(() => !!currentToken());
|
||||
const [userEmail, setUserEmail] = useState<string | null>(() => {
|
||||
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_mobile_user_email') : null;
|
||||
});
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(() => {
|
||||
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_mobile_is_admin') === 'true' : false;
|
||||
});
|
||||
const [roles, setRoles] = useState<string[]>(() => {
|
||||
const r = typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_mobile_roles') : null;
|
||||
return r ? JSON.parse(r) : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (authed && (!isAdmin || roles.length === 0)) {
|
||||
orderBookingClient.getMe().then(me => {
|
||||
if (me.is_admin) setIsAdmin(true);
|
||||
if (me.roles) setRoles(me.roles);
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [authed]);
|
||||
|
||||
const value: AuthValue = {
|
||||
authed,
|
||||
isAdmin,
|
||||
roles,
|
||||
userEmail,
|
||||
login: async (email, password, orgId) => {
|
||||
const res = await loginAll(email, password, orgId);
|
||||
await loginAll(email, password, orgId);
|
||||
setAuthed(true);
|
||||
if (res.user?.email) {
|
||||
setUserEmail(res.user.email);
|
||||
localStorage.setItem('krishna_sales_mobile_user_email', res.user.email);
|
||||
}
|
||||
const userRoles = res.user?.roles ?? [];
|
||||
const userIsAdmin = !!res.user?.is_admin;
|
||||
setIsAdmin(userIsAdmin);
|
||||
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
|
||||
setRoles(userRoles);
|
||||
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
|
||||
},
|
||||
logout: () => {
|
||||
logoutAll();
|
||||
setAuthed(false);
|
||||
setUserEmail(null);
|
||||
setIsAdmin(false);
|
||||
setRoles([]);
|
||||
localStorage.removeItem('krishna_sales_mobile_user_email');
|
||||
localStorage.removeItem('krishna_sales_mobile_is_admin');
|
||||
localStorage.removeItem('krishna_sales_mobile_roles');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
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 '/daily';
|
||||
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,9 +2,6 @@ import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AuthValue {
|
||||
authed: boolean;
|
||||
isAdmin: boolean;
|
||||
roles: string[];
|
||||
userEmail: string | null;
|
||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@ -203,7 +203,7 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
|
||||
)}
|
||||
|
||||
{isPunchedIn && onPunchOut && (
|
||||
<div>
|
||||
<div style={{ padding: '16px 6px 0px 6px' }}>
|
||||
<Button
|
||||
variant="danger"
|
||||
full
|
||||
|
||||
@ -61,8 +61,10 @@ export function OrderCard({ row, fields, isDetailView = false }: { row: Record<s
|
||||
const productsSection = gridVal.length > 0 ? (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
marginLeft: '-12px',
|
||||
marginRight: '-12px'
|
||||
backgroundColor: 'var(--tiles-card-bg)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
||||
<div style={{ backgroundColor: '#e6f9ed', padding: '6px', borderRadius: '50%' }}>
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import { User, Edit2 } from 'lucide-react';
|
||||
|
||||
export function UserCard({ row, onEdit }: { row: any; onEdit?: (row: any) => void }) {
|
||||
const name = String(row.name_2 || row.name || 'Unnamed User');
|
||||
const email = row.email_2 || row.email || '';
|
||||
|
||||
// extract initials for a sleek avatar
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card p-4 flex items-center justify-between hover:bg-slate-50 transition-colors cursor-pointer border border-border-subtle rounded-xl shadow-[0_2px_8px_rgba(11,27,59,0.04)]"
|
||||
onClick={() => onEdit?.(row)}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{/* Avatar */}
|
||||
<div className="w-11 h-11 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0 font-bold tracking-tight">
|
||||
{initials || <User size={20} />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-col min-w-0 pr-4">
|
||||
<h3 className="m-0 text-base font-semibold text-strong truncate">{name}</h3>
|
||||
{email && <span className="text-sm text-faint truncate">{email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Action */}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(row);
|
||||
}}
|
||||
className="w-[34px] h-[34px] flex items-center justify-center border border-border-default bg-card shadow-xs text-faint hover:text-primary hover:border-primary hover:bg-primary/5 rounded-md transition-colors shrink-0"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -109,10 +109,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.z-card-footer:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.z-card-footer-item {
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './OrderCard';
|
||||
export * from './CallCard';
|
||||
export * from './StoreCard';
|
||||
export * from './DailyLogCard';
|
||||
export * from './UserCard';
|
||||
@ -405,18 +405,22 @@ export function CallDetail({
|
||||
</div>
|
||||
|
||||
{/* Top Right KPI Grid */}
|
||||
<div className="flex flex-nowrap items-center gap-2 w-full min-w-0 mt-2">
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2.5 text-center flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">TOTAL BAGS</div>
|
||||
<div className="text-lg font-extrabold text-primary leading-tight">{totalBags.toLocaleString()}</div>
|
||||
<div className="flex flex-nowrap items-center gap-3 w-full lg:w-auto overflow-x-auto pb-2 min-w-0">
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[100px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">TOTAL BAGS</div>
|
||||
<div className="text-[20px] font-extrabold text-primary">{totalBags.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2.5 text-center flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">TOTAL KGS</div>
|
||||
<div className="text-lg font-extrabold text-primary leading-tight">{totalKgs.toLocaleString()}</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[100px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">TOTAL KGS</div>
|
||||
<div className="text-[20px] font-extrabold text-primary">{totalKgs.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2.5 text-center flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">ORDER ITEMS</div>
|
||||
<div className="text-lg font-extrabold text-primary leading-tight">{lineItemsCount}</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[100px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">LINE ITEMS</div>
|
||||
<div className="text-[20px] font-extrabold text-primary">{lineItemsCount}</div>
|
||||
</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[110px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">SALES OFFICER</div>
|
||||
<div className="text-sm font-bold text-primary mt-1 whitespace-nowrap overflow-hidden text-ellipsis">{salesOfficer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -589,7 +593,7 @@ export function CallDetail({
|
||||
|
||||
{/* Call Potential Card (Below Orders) */}
|
||||
{callPotentialList.length > 0 && (
|
||||
<div className="bg-[var(--tiles-card-bg)] rounded-2xl border border-slate-200/80 p-5 shadow-sm flex flex-col gap-3">
|
||||
<div className="bg-[var(--tiles-card-bg)] rounded-2xl border border-slate-200/80 p-6 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary-light-bg p-1.5 rounded-full border border-primary-light-border flex items-center justify-center">
|
||||
@ -605,14 +609,16 @@ export function CallDetail({
|
||||
const extraOrdered = callPotentialList.filter(item => item.actualPotential === 0);
|
||||
|
||||
const renderTable = (items: any[]) => (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="bg-primary-light-bg border border-primary-light-border rounded-xl px-3 py-2.5 flex flex-col gap-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-primary font-bold text-[13px] uppercase tracking-wide">{item.name}</span>
|
||||
<div key={idx} className="bg-primary-light-bg border border-primary-light-border rounded-3xl px-5 py-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-primary font-semibold text-[15px]">{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center bg-white rounded-lg py-1.5 px-3 border border-primary-light-border mt-0.5">
|
||||
<div className="flex justify-between items-center bg-white rounded-2xl py-3 px-4 border border-primary-light-border">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-bold text-primary uppercase tracking-wider">Potential</span>
|
||||
<span className="text-sm font-semibold text-primary">{item.actualPotential}</span>
|
||||
@ -641,11 +647,11 @@ export function CallDetail({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
{regularPotential.length > 0 && renderTable(regularPotential)}
|
||||
|
||||
{extraOrdered.length > 0 && (
|
||||
<div className="flex flex-col gap-3 pt-2 border-t border-slate-100 mt-2">
|
||||
<div className="space-y-4 pt-2 border-t border-slate-100 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="text-slate-800" size={14} />
|
||||
<h3 className="text-[11px] font-bold text-slate-800 uppercase tracking-wider">Ordered Outside Potential</h3>
|
||||
|
||||
@ -154,18 +154,18 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 w-full lg:w-auto">
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2 text-center flex flex-col justify-center">
|
||||
<div className="text-[9px] font-bold text-primary uppercase tracking-wider mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">PROD. CALLS</div>
|
||||
<div className="text-[18px] font-extrabold text-primary leading-none mt-0.5">{prodCalls}</div>
|
||||
<div className="flex flex-nowrap items-center gap-3 w-full lg:w-auto overflow-x-auto pb-2 min-w-0">
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[100px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">PROD. CALLS</div>
|
||||
<div className="text-[20px] font-extrabold text-primary">{prodCalls}</div>
|
||||
</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2 text-center flex flex-col justify-center">
|
||||
<div className="text-[9px] font-bold text-primary uppercase tracking-wider mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">NON PROD.</div>
|
||||
<div className="text-[18px] font-extrabold text-primary leading-none mt-0.5">{nonProdCalls}</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[100px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">NON PROD.</div>
|
||||
<div className="text-[20px] font-extrabold text-primary">{nonProdCalls}</div>
|
||||
</div>
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2 text-center flex flex-col justify-center">
|
||||
<div className="text-[9px] font-bold text-primary uppercase tracking-wider mb-0.5 whitespace-nowrap overflow-hidden text-ellipsis">TOTAL CALLS</div>
|
||||
<div className="text-[18px] font-extrabold text-primary leading-none mt-0.5">
|
||||
<div className="bg-primary-light-bg border border-primary-light-border rounded-2xl p-3 text-center min-w-[110px] flex-1">
|
||||
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">TOTAL CALLS</div>
|
||||
<div className="text-sm font-bold text-primary mt-1 whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{Number(prodCalls) + Number(nonProdCalls)}
|
||||
</div>
|
||||
</div>
|
||||
@ -173,8 +173,8 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<div className="flex flex-col gap-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-8 overflow-x-auto pb-2">
|
||||
<div className="flex items-center gap-3 min-w-max">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-xs shrink-0">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
@ -187,7 +187,7 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
|
||||
</div>
|
||||
|
||||
{checkOutTime && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-max">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center text-xs shrink-0">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
|
||||
@ -178,7 +178,55 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
}
|
||||
}
|
||||
|
||||
if (currentActivityId === ORDER_BOOKING.activities.LOG_VISIT.uid || currentActivityId === '42b7f47d-96f0-4289-a6d9-38f455ad57dd') {
|
||||
const dateVal = (defaultValues['date_of_visit'] as string) || (defaultValues['date_of_visit_1'] as string) || new Date().toISOString().split('T')[0];
|
||||
const timeVal = (defaultValues['time_of_visit'] as string) || (defaultValues['time_of_visit_1'] as string) || new Date().toTimeString().split(' ')[0].substring(0, 5);
|
||||
|
||||
if (!defaultValues['date_of_visit']) defaultValues['date_of_visit'] = dateVal;
|
||||
if (!defaultValues['time_of_visit']) defaultValues['time_of_visit'] = timeVal;
|
||||
|
||||
const dailyLogField = res.fields.find(
|
||||
f => f.id === 'daily_log' || f.uid === 'field_1785225403902' || f.name.toLowerCase() === 'daily log'
|
||||
);
|
||||
const dailyLogFieldId = dailyLogField?.uid || dailyLogField?.id || 'field_1785225403902';
|
||||
|
||||
try {
|
||||
const dailyLogLookupRes = await client.wfLookupRecords({
|
||||
activityId: currentActivityId,
|
||||
fieldId: dailyLogFieldId,
|
||||
formData: {
|
||||
date_of_visit: dateVal,
|
||||
time_of_visit: timeVal,
|
||||
...defaultValues
|
||||
},
|
||||
limit: 200
|
||||
});
|
||||
const arr = Array.isArray(dailyLogLookupRes) ? dailyLogLookupRes : (dailyLogLookupRes?.data || dailyLogLookupRes?.records || []);
|
||||
if (arr.length > 0) {
|
||||
const firstLog = arr[0];
|
||||
const dailyLogInstanceId = String(firstLog.instance_id || firstLog.id || '');
|
||||
const routeCode = firstLog.route_code || firstLog.route_code_1 || firstLog.route_code_2 || firstLog.route_code_3 || firstLog.route || '';
|
||||
|
||||
if (dailyLogInstanceId) {
|
||||
defaultValues[dailyLogFieldId] = dailyLogInstanceId;
|
||||
defaultValues['daily_log'] = dailyLogInstanceId;
|
||||
defaultValues['field_1785225403902'] = dailyLogInstanceId;
|
||||
}
|
||||
|
||||
if (routeCode) {
|
||||
console.log("[Log Visit] Initial daily_log lookup route_code fetched:", routeCode);
|
||||
defaultValues['route_code'] = routeCode;
|
||||
defaultValues['field_1785311859486'] = routeCode;
|
||||
const routeField = res.fields.find(f => f.id === 'route_code' || f.uid === 'field_1785311859486');
|
||||
if (routeField) {
|
||||
defaultValues[routeField.id] = routeCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load initial daily_log lookup for Log Visit', e);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(defaultValues);
|
||||
setLoading(false);
|
||||
@ -295,7 +343,30 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ((currentActivityId === ORDER_BOOKING.activities.LOG_VISIT.uid || currentActivityId === '42b7f47d-96f0-4289-a6d9-38f455ad57dd') && (fieldId.includes('date_of_visit') || fieldId.includes('time_of_visit'))) {
|
||||
const updatedFormData = { ...next };
|
||||
client.wfLookupRecords({
|
||||
activityId: currentActivityId,
|
||||
fieldId: 'field_1',
|
||||
formData: updatedFormData,
|
||||
limit: 200
|
||||
}).then(lookupRes => {
|
||||
const arr = Array.isArray(lookupRes) ? lookupRes : (lookupRes?.data || lookupRes?.records || []);
|
||||
if (arr.length > 0) {
|
||||
const firstRow = arr[0];
|
||||
const routeCode = firstRow.route_code || firstRow.route_code_1 || firstRow.route_code_2 || firstRow.route_code_3 || firstRow.route;
|
||||
if (routeCode) {
|
||||
setValues(p => ({
|
||||
...p,
|
||||
route_code: routeCode,
|
||||
route_code_1: routeCode,
|
||||
route_code_2: routeCode,
|
||||
route_code_3: routeCode,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}).catch(e => console.warn('Dynamic route_code lookup failed:', e));
|
||||
}
|
||||
|
||||
const isOrderDetails = fieldId === 'order_details' || getBaseIdForField(fieldId) === 'order_details' || fieldDef?.mapped_workflow_field === 'order_details';
|
||||
|
||||
@ -518,6 +589,19 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLogVisit = currentActivityId === ORDER_BOOKING.activities.LOG_VISIT.uid || currentActivityId === '42b7f47d-96f0-4289-a6d9-38f455ad57dd';
|
||||
const isHiddenLogVisitField = isLogVisit && (
|
||||
f.id === 'daily_log' ||
|
||||
f.uid === 'field_1785225403902' ||
|
||||
f.id === 'route_code' ||
|
||||
f.uid === 'field_1785311859486' ||
|
||||
f.name.toLowerCase() === 'daily log' ||
|
||||
f.name.toLowerCase() === 'route code'
|
||||
);
|
||||
|
||||
if (isHiddenLogVisitField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderField = () => {
|
||||
if (type === 'wf_lookup') {
|
||||
@ -678,8 +762,6 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
};
|
||||
|
||||
const content = renderField();
|
||||
|
||||
|
||||
return isDisabled ? (
|
||||
<fieldset key={f.id} disabled className="opacity-60 pointer-events-none">
|
||||
{content}
|
||||
|
||||
@ -1,520 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { ZinoClient } from '../../api/client';
|
||||
import type { FormScreenResponse } from '../../api/types';
|
||||
import { ORDER_BOOKING } 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,
|
||||
* 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)
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoadingSchema(true);
|
||||
client.formSchema(activityId)
|
||||
.then(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);
|
||||
}
|
||||
|
||||
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,13 +3,11 @@ import { Input } from '../../reusable/Input';
|
||||
export function DateField({
|
||||
label,
|
||||
required,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
@ -17,7 +15,6 @@ export function DateField({
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
type="date"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
|
||||
@ -3,13 +3,11 @@ import { Input } from '../../reusable/Input';
|
||||
export function TimeField({
|
||||
label,
|
||||
required,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
@ -17,7 +15,6 @@ export function TimeField({
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
type="time"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
|
||||
@ -47,7 +47,7 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
||||
|
||||
return (
|
||||
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm">
|
||||
<div className="h-[320px] w-full mt-4 [&_.recharts-wrapper]:!outline-none [&_.recharts-surface]:!outline-none [&_*]:!outline-none">
|
||||
<div className="h-[320px] w-full mt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={formattedRows} margin={{ top: 25, right: 10, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
@ -81,7 +81,6 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
||||
fill={`url(#colorGradient-${idx})`}
|
||||
radius={[8, 8, 0, 0]}
|
||||
maxBarSize={40}
|
||||
activeBar={{ stroke: '#cbd5e1', strokeWidth: 1, fill: `url(#colorGradient-${idx})` }}
|
||||
>
|
||||
<LabelList dataKey="value" position="top" fill="#475569" fontSize={12} fontWeight="bold" />
|
||||
</Bar>
|
||||
|
||||
@ -32,7 +32,7 @@ export function Pagination({ page, pageSize, total, onPage, onPageSizeChange }:
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-5 pb-6 border-t border-gray-100 mt-2">
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-5 pb-24 border-t border-gray-100 mt-2">
|
||||
<div className="flex items-center bg-white shadow-sm border border-gray-200 rounded-full p-1.5 gap-1 overflow-x-auto max-w-full no-scrollbar">
|
||||
<button
|
||||
disabled={safePage <= 1}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, type SelectHTMLAttributes } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, Search, X, Check } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
@ -18,8 +19,6 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
className?: string;
|
||||
/** Disable search header filter if set to false */
|
||||
searchable?: boolean;
|
||||
/** Callback fired when dropdown opens */
|
||||
onDropdownOpen?: () => void;
|
||||
}
|
||||
|
||||
/** Custom searchable select component using React Portal to prevent container clipping. */
|
||||
@ -33,11 +32,11 @@ export function Select({
|
||||
disabled,
|
||||
placeholder,
|
||||
searchable = true,
|
||||
onDropdownOpen,
|
||||
...rest
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
@ -54,7 +53,37 @@ export function Select({
|
||||
o.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = 260; // Max height approximation
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
zIndex: 999999,
|
||||
...(openUpwards
|
||||
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
||||
: { top: `${rect.bottom + 4}px` }),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updatePosition();
|
||||
const handleScrollOrResize = () => updatePosition();
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@ -83,7 +112,7 @@ export function Select({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', isOpen ? 'z-50' : 'z-10', className)}>
|
||||
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}>
|
||||
{label && (
|
||||
<label className="text-sm font-semibold text-slate-700">
|
||||
{label}
|
||||
@ -93,15 +122,7 @@ export function Select({
|
||||
|
||||
{/* Trigger Box */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
const nextState = !isOpen;
|
||||
setIsOpen(nextState);
|
||||
if (nextState && onDropdownOpen) {
|
||||
onDropdownOpen();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
|
||||
disabled && "opacity-60 cursor-not-allowed bg-slate-50",
|
||||
@ -114,11 +135,12 @@ export function Select({
|
||||
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu Overlay */}
|
||||
{isOpen && !disabled && (
|
||||
{/* Portaled Dropdown Menu Overlay (bypasses parent overflow:hidden clipping) */}
|
||||
{isOpen && !disabled && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute left-0 top-[calc(100%+4px)] w-full bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-64 animate-in fade-in-50 duration-100 z-50"
|
||||
style={dropdownStyle}
|
||||
className="bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-64 animate-in fade-in-50 duration-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{searchable && (
|
||||
@ -143,7 +165,7 @@ export function Select({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto overflow-x-auto flex-1 py-1">
|
||||
<div className="overflow-y-auto flex-1 py-1">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => {
|
||||
const isSelected = opt.value === currentValue;
|
||||
@ -152,11 +174,11 @@ export function Select({
|
||||
key={opt.value}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors w-max min-w-full",
|
||||
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors",
|
||||
isSelected ? "bg-navy-50/20 text-navy-700 font-semibold" : "hover:bg-black/5 text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-nowrap leading-tight flex-1 pr-4">{opt.label}</span>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{isSelected && <Check size={14} className="text-navy-600 shrink-0 ml-2" />}
|
||||
</div>
|
||||
);
|
||||
@ -167,7 +189,8 @@ export function Select({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Hidden Native Select for Required/Form Validation */}
|
||||
@ -226,6 +249,7 @@ export function MultiSelect({
|
||||
}: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
const [draftValues, setDraftValues] = useState<string[]>(value ?? []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@ -251,7 +275,37 @@ export function MultiSelect({
|
||||
o.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = 320;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
zIndex: 999999,
|
||||
...(openUpwards
|
||||
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
||||
: { top: `${rect.bottom + 4}px` }),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updatePosition();
|
||||
const handleScrollOrResize = () => updatePosition();
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@ -294,7 +348,7 @@ export function MultiSelect({
|
||||
: `${selectedLabels.length} selected`;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', isOpen ? 'z-50' : 'z-10', className)}>
|
||||
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}>
|
||||
{label && (
|
||||
<label className="text-xs font-bold text-slate-700">
|
||||
{label}
|
||||
@ -317,11 +371,12 @@ export function MultiSelect({
|
||||
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu Overlay */}
|
||||
{isOpen && !disabled && (
|
||||
{/* Portaled Dropdown Menu Overlay */}
|
||||
{isOpen && !disabled && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute left-0 top-[calc(100%+4px)] w-full bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-72 animate-in fade-in-50 duration-100 z-50"
|
||||
style={dropdownStyle}
|
||||
className="bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-72 animate-in fade-in-50 duration-100 z-[999999]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{searchable && (
|
||||
@ -346,7 +401,7 @@ export function MultiSelect({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto overflow-x-auto flex-1 py-1 min-h-[100px]">
|
||||
<div className="overflow-y-auto flex-1 py-1 min-h-[100px]">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => {
|
||||
const isSelected = draftValues.includes(opt.value);
|
||||
@ -355,7 +410,7 @@ export function MultiSelect({
|
||||
key={opt.value}
|
||||
onClick={() => toggleOption(opt.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none w-max min-w-full",
|
||||
"px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none",
|
||||
isSelected ? "bg-navy-50/40 text-navy-900 font-semibold" : "hover:bg-black/5 text-strong"
|
||||
)}
|
||||
>
|
||||
@ -363,9 +418,9 @@ export function MultiSelect({
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="shrink-0 rounded border-slate-300 text-navy-600 focus:ring-navy-500 pointer-events-none h-3.5 w-3.5"
|
||||
className="rounded border-slate-300 text-navy-600 focus:ring-navy-500 pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="whitespace-nowrap leading-tight flex-1 pr-4">{opt.label}</span>
|
||||
<span className="truncate flex-1">{opt.label}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@ -394,7 +449,8 @@ export function MultiSelect({
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{(hint || error) && (
|
||||
|
||||
@ -31,7 +31,7 @@ export function StatsTile({ tile, idx = 0 }: StatsTileProps) {
|
||||
return (
|
||||
<div className={`z-stats-tile z-stats-tile--${colorTheme}`}>
|
||||
<div className="z-stats-icon-wrapper">
|
||||
<Icon size={22} className="z-stats-icon" strokeWidth={2.5} />
|
||||
<Icon size={26} className="z-stats-icon" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="z-stats-content">
|
||||
<span className="z-stats-label">{displayLabel}</span>
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Base Tile */
|
||||
@ -24,7 +25,7 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
padding: 16px 20px;
|
||||
background-color: #FFFBF4;
|
||||
border-radius: var(--z-border-radius-lg, 12px);
|
||||
box-shadow: var(--block-shadow);
|
||||
@ -42,13 +43,13 @@
|
||||
}
|
||||
|
||||
.z-stats-icon-wrapper {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -87,7 +88,7 @@
|
||||
}
|
||||
|
||||
.z-stats-label {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
/* subtle gray */
|
||||
@ -97,7 +98,7 @@
|
||||
}
|
||||
|
||||
.z-stats-value {
|
||||
font-size: 24px;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: var(--primary-color);
|
||||
|
||||
@ -24,7 +24,7 @@ export interface RecordViewProps {
|
||||
columns?: string[];
|
||||
/** Columns to hide. */
|
||||
omitColumns?: string[];
|
||||
/** Rows per page. @default 10 */
|
||||
/** Rows per page. @default 20 */
|
||||
pageSize?: number;
|
||||
/** Click handler — receives the raw row + index. */
|
||||
onRowClick?: (row: Record<string, unknown>, index: number) => void;
|
||||
@ -62,7 +62,7 @@ export function RecordView({
|
||||
title = 'Records',
|
||||
columns,
|
||||
omitColumns,
|
||||
pageSize = 10,
|
||||
pageSize = 20,
|
||||
onRowClick,
|
||||
rowKey,
|
||||
headerActions,
|
||||
@ -187,7 +187,7 @@ export function RecordView({
|
||||
}, [activeFilters]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full max-w-full overflow-x-hidden">
|
||||
<div className="flex flex-col gap-5 w-full max-w-full overflow-x-hidden">
|
||||
{!hideTiles && <StatsTiles tiles={tileValues} />}
|
||||
|
||||
{!hideChart && <AnalyticsChart data={chartData} />}
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
import { userClient } from '../../api/clients';
|
||||
import { USER } from '../../api/config';
|
||||
import { RecordView } from './RecordView';
|
||||
import type { WiredRecordViewProps } from './OrdersView';
|
||||
import { UserCard } from '../cards/UserCard';
|
||||
|
||||
/** Users / Sales Officers record view (User workflow). */
|
||||
export function UsersView({ onRowClick, onEditRow, pageSize, headerActions, refreshKey, initialFilters }: WiredRecordViewProps & { headerActions?: React.ReactNode; onEditRow?: (row: any) => void }) {
|
||||
return (
|
||||
<RecordView
|
||||
client={userClient}
|
||||
rvUid={USER.recordViews.SALES_OFFICERS}
|
||||
title="Sales Officers"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
refreshKey={refreshKey}
|
||||
initialFilters={initialFilters}
|
||||
sortBy="instance_id"
|
||||
sortDir="desc"
|
||||
renderItem={(row) => <UserCard row={row} onEdit={onEditRow} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -5,4 +5,3 @@ export type { WiredRecordViewProps } from './OrdersView';
|
||||
export { CallsView } from './CallsView';
|
||||
export { StoresView } from './StoresView';
|
||||
export { DailyLogsView } from './DailyLogsView';
|
||||
export { UsersView } from './UsersView';
|
||||
@ -1,247 +0,0 @@
|
||||
# Data Collection API
|
||||
|
||||
- core: `https://dev.getzino.in`
|
||||
- view: `https://dev.getzino.in`
|
||||
|
||||
## Activity submission
|
||||
|
||||
Calls that create an instance or submit an activity's form. Init = /start; subsequent = /activity. Upload/OCR fire during fill for file-bearing fields.
|
||||
|
||||
#### Add User
|
||||
|
||||
`POST /app/434/start`
|
||||
|
||||
> Create a new workflow instance and run the init activity.
|
||||
|
||||
**Headers**
|
||||
|
||||
- `Authorization: Bearer <JWT_TOKEN>` (required) — User session JWT from login.
|
||||
- `Content-Type: application/json` (required)
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "3a8f132e-8c63-4d4d-b466-67595d345a99",
|
||||
"data": {
|
||||
"email": "user@example.com",
|
||||
"name": "string_value",
|
||||
"password": "string_value"
|
||||
},
|
||||
"version": 1,
|
||||
"workflow_uuid": "9874af12-78b7-4730-834a-19a32221f3aa"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
- `workflow_uuid` (string) *required* — Clone-portable workflow UID.
|
||||
- `activity_id` (string) *required* — The init activity UID.
|
||||
- `version` (int) — Deployed workflow version.
|
||||
- `data` (object) — Field values keyed by field id.
|
||||
- `name` (text) *required* — "Name" (text)
|
||||
- `email` (email) *required* — "Email" (email)
|
||||
- `password` (password) *required* — "Password" (password)
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"instance_id": 1024,
|
||||
"message": "Activity completed successfully",
|
||||
"status_code": 200,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response fields**
|
||||
|
||||
- `success` (bool) — Whether the activity ran.
|
||||
- `status_code` (int) — HTTP status; mirrors a Response node if the trigger graph has one.
|
||||
- `message` (string) — Human-readable result; from a Response node when present.
|
||||
- `data` (object) — Response-node payload; empty object by default.
|
||||
- `instance_id` (int64) — The workflow instance affected/created.
|
||||
|
||||
**curl**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://dev.getzino.in/app/434/start" \
|
||||
-H "Authorization: Bearer <JWT_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"activity_id": "3a8f132e-8c63-4d4d-b466-67595d345a99",
|
||||
"data": {
|
||||
"email": "user@example.com",
|
||||
"name": "string_value",
|
||||
"password": "string_value"
|
||||
},
|
||||
"version": 1,
|
||||
"workflow_uuid": "9874af12-78b7-4730-834a-19a32221f3aa"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Edit User
|
||||
|
||||
`POST /app/434/activity`
|
||||
|
||||
> Advance an existing instance through this activity (normal submission).
|
||||
|
||||
**Headers**
|
||||
|
||||
- `Authorization: Bearer <JWT_TOKEN>` (required) — User session JWT from login.
|
||||
- `Content-Type: application/json` (required)
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "3091530c-4315-42b9-b53b-a148f2c54a81",
|
||||
"data": {
|
||||
"email_3": "user@example.com",
|
||||
"name_3": "string_value"
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "9874af12-78b7-4730-834a-19a32221f3aa"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
- `workflow_uuid` (string) *required* — Clone-portable workflow UID.
|
||||
- `activity_id` (string) *required* — This activity's UID.
|
||||
- `instance_id` (int64) *required* — Target instance (runtime value).
|
||||
- `data` (object) — Field values keyed by field id.
|
||||
- `name_3` (text) — "Name" (text)
|
||||
- `email_3` (email) — "Email" (email)
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"instance_id": 1024,
|
||||
"message": "Activity completed successfully",
|
||||
"status_code": 200,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response fields**
|
||||
|
||||
- `success` (bool) — Whether the activity ran.
|
||||
- `status_code` (int) — HTTP status; mirrors a Response node if the trigger graph has one.
|
||||
- `message` (string) — Human-readable result; from a Response node when present.
|
||||
- `data` (object) — Response-node payload; empty object by default.
|
||||
- `instance_id` (int64) — The workflow instance affected/created.
|
||||
|
||||
**curl**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://dev.getzino.in/app/434/activity" \
|
||||
-H "Authorization: Bearer <JWT_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"activity_id": "3091530c-4315-42b9-b53b-a148f2c54a81",
|
||||
"data": {
|
||||
"email_3": "user@example.com",
|
||||
"name_3": "string_value"
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "9874af12-78b7-4730-834a-19a32221f3aa"
|
||||
}'
|
||||
```
|
||||
|
||||
## View APIs
|
||||
|
||||
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
|
||||
|
||||
### Record View
|
||||
|
||||
#### Sales Officers
|
||||
|
||||
`POST /app/434/view/recordview`
|
||||
|
||||
> Paginated records + tiles + charts for this record view.
|
||||
|
||||
**Headers**
|
||||
|
||||
- `Authorization: Bearer <JWT_TOKEN>` (required) — User session JWT from login.
|
||||
- `Content-Type: application/json` (required)
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"params": {},
|
||||
"preset_alias": "",
|
||||
"rv_template_uid": "406f89d7-09f6-42ba-ba35-84334ccb6204",
|
||||
"search_query": {
|
||||
"filters": [],
|
||||
"limit": 25,
|
||||
"page": 1,
|
||||
"search": "",
|
||||
"sort_by": "",
|
||||
"sort_dir": "desc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
- `rv_template_uid` (string) *required* — This view's template UID.
|
||||
- `preset_alias` (string) — Optional; selects a named prefilter preset declared on this view.
|
||||
- `params` (object) — Optional; values for the view's declared input params (key→value), consumed by ${input.<key>} prefilter refs.
|
||||
- `search_query.page` (int) — 1-based page.
|
||||
- `search_query.limit` (int) — Default 25, max 200.
|
||||
- `search_query.sort_by` (string) — field_key | created_at | updated_at | instance_id.
|
||||
- `search_query.sort_dir` (string) — asc | desc.
|
||||
- `search_query.search` (string) — ILIKE term across searchable fields.
|
||||
- `search_query.filters` (array) — [{field_key, value, value2?, data_type}].
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_data": [],
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"limit": 25,
|
||||
"page": 1,
|
||||
"total_records": 0
|
||||
},
|
||||
"tile_values": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Response fields**
|
||||
|
||||
- `data` (array) — Companion-table rows.
|
||||
- `tile_values` (object)
|
||||
- `chart_data` (array)
|
||||
- `pagination` (object) — {page, limit, total_records}.
|
||||
|
||||
**curl**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://dev.getzino.in/app/434/view/recordview" \
|
||||
-H "Authorization: Bearer <JWT_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"params": {},
|
||||
"preset_alias": "",
|
||||
"rv_template_uid": "406f89d7-09f6-42ba-ba35-84334ccb6204",
|
||||
"search_query": {
|
||||
"filters": [],
|
||||
"limit": 25,
|
||||
"page": 1,
|
||||
"search": "",
|
||||
"sort_by": "",
|
||||
"sort_dir": "desc"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## In-form activity helper APIs
|
||||
|
||||
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
|
||||
@ -1,93 +0,0 @@
|
||||
import { OrdersPage } from './screens/OrdersPage'
|
||||
import { CallsPage } from './screens/CallsPage'
|
||||
import { StoresPage } from './screens/StoresPage'
|
||||
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
import { AllDailyLogsPage } from './screens/AllDailyLogsPage'
|
||||
import { AnalyticsPage } from './screens/AnalyticsPage'
|
||||
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
||||
import { UsersPage } from './screens/admin/UsersPage'
|
||||
|
||||
export const routeConfig = [
|
||||
// Manager & Admin (and maybe Sales Officer for daily logs, adapting from krishna_sales where my-daily was Sales Officer and daily was Manager/Admin. In mobile we have daily and all-daily)
|
||||
{
|
||||
path: "/daily",
|
||||
element: <DailyLogsPage />,
|
||||
roles: ["Sales Officer", "Manager", "Admin"],
|
||||
},
|
||||
{
|
||||
path: "/daily/:instanceId",
|
||||
element: <DailyLogsPage />,
|
||||
roles: ["Sales Officer", "Manager", "Admin"],
|
||||
},
|
||||
{
|
||||
path: "/all-daily",
|
||||
element: <AllDailyLogsPage />,
|
||||
roles: ["Manager", "Admin"],
|
||||
},
|
||||
{
|
||||
path: "/all-daily/:instanceId",
|
||||
element: <AllDailyLogsPage />,
|
||||
roles: ["Manager", "Admin"],
|
||||
},
|
||||
|
||||
{
|
||||
path: "/orders",
|
||||
element: <OrdersPage />,
|
||||
roles: ["Manager", "Admin", "Sales Officer"],
|
||||
},
|
||||
{
|
||||
path: "/orders/:instanceId",
|
||||
element: <OrdersPage />,
|
||||
roles: ["Manager", "Admin", "Sales Officer"],
|
||||
},
|
||||
{
|
||||
path: "/calls",
|
||||
element: <CallsPage />,
|
||||
roles: ["Manager", "Admin", "Sales Officer"],
|
||||
},
|
||||
{
|
||||
path: "/calls/:instanceId",
|
||||
element: <CallsPage />,
|
||||
roles: ["Manager", "Admin", "Sales Officer"],
|
||||
},
|
||||
|
||||
// Everyone
|
||||
{
|
||||
path: "/stores",
|
||||
element: <StoresPage />,
|
||||
roles: ["Sales Officer", "Manager", "Admin"],
|
||||
},
|
||||
{
|
||||
path: "/stores/:instanceId",
|
||||
element: <StoresPage />,
|
||||
roles: ["Manager", "Admin", "Sales Officer"],
|
||||
},
|
||||
|
||||
// Analytics
|
||||
{
|
||||
path: "/analytics",
|
||||
element: <AnalyticsPage />,
|
||||
roles: ["Manager", "Admin", "Sales Officer"],
|
||||
},
|
||||
|
||||
// Manager + Admin
|
||||
{
|
||||
path: "/sales-report",
|
||||
element: <DailySalesReportPage />,
|
||||
},
|
||||
{
|
||||
path: "/admin/users",
|
||||
element: <UsersPage />,
|
||||
roles: ["Manager", "Admin"],
|
||||
|
||||
},
|
||||
|
||||
// Admin Panel
|
||||
{
|
||||
path: "/admin/datasets",
|
||||
element: <DatasetsPage />,
|
||||
adminOnly: true,
|
||||
},
|
||||
|
||||
];
|
||||
@ -76,11 +76,11 @@ export function AnalyticsPage() {
|
||||
// Decide chart type based on key
|
||||
if (chart.key === 'status_overview') {
|
||||
return (
|
||||
<div key={chart.chart_uid} className="bg-[#FFFBF4] rounded-2xl shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] border border-slate-200 p-5 flex flex-col gap-4">
|
||||
<div key={chart.chart_uid} className="bg-white rounded-2xl shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] border border-slate-100 p-5 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-bold text-slate-800 tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<div className="h-64 w-full [&_.recharts-wrapper]:!outline-none [&_.recharts-surface]:!outline-none [&_*]:!outline-none">
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
@ -108,11 +108,11 @@ export function AnalyticsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={chart.chart_uid} className="bg-[#FFFBF4] rounded-2xl shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] border border-slate-200 p-5 flex flex-col gap-4">
|
||||
<div key={chart.chart_uid} className="bg-white rounded-2xl shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] border border-slate-100 p-5 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-bold text-slate-800 tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<div className="h-64 w-full [&_.recharts-wrapper]:!outline-none [&_.recharts-surface]:!outline-none [&_*]:!outline-none">
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
@ -122,13 +122,7 @@ export function AnalyticsPage() {
|
||||
cursor={{ fill: '#f8fafc' }}
|
||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
barSize={40}
|
||||
activeBar={{ stroke: '#cbd5e1', strokeWidth: 1, fill: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<Bar dataKey="value" fill={COLORS[index % COLORS.length]} radius={[4, 4, 0, 0]} barSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,6 @@ import { CallDetail } from '../components/dv';
|
||||
import { useState } from 'react';
|
||||
import { Phone, Plus } from 'lucide-react';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { LogVisitForm } from '../components/forms/LogVisitForm';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { ORDER_BOOKING } from '../api/config';
|
||||
import { orderBookingClient } from '../api/clients';
|
||||
@ -180,8 +179,9 @@ export function CallsPage() {
|
||||
title={createTitle}
|
||||
width="md"
|
||||
>
|
||||
<LogVisitForm
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
|
||||
@ -1,26 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, Menu, X, Database, Users } from 'lucide-react';
|
||||
import { LogOut, Menu, X, Database } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||
import { SCREENS } from './tabs';
|
||||
import { routeConfig } from '../routesConfig';
|
||||
import './screen.css';
|
||||
|
||||
const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => {
|
||||
const route = routeConfig.find(r => r.path === `/${key}`);
|
||||
if (!route) return true;
|
||||
if (route.adminOnly && !isAdmin) return false;
|
||||
if (!route.adminOnly && route.roles) {
|
||||
return route.roles.some(r => roles.includes(r));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
|
||||
export function ConsoleLayout() {
|
||||
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
|
||||
const { authed, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const user = orderBookingClient.currentUser();
|
||||
|
||||
@ -63,9 +52,9 @@ export function ConsoleLayout() {
|
||||
|
||||
<nav
|
||||
className="print:hidden fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-app border-t border-border-subtle grid shadow-[0_-2px_16px_rgba(11,27,59,0.06)]"
|
||||
style={{ gridTemplateColumns: `repeat(${SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).length}, minmax(0, 1fr))` }}
|
||||
style={{ gridTemplateColumns: `repeat(${SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report').length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
|
||||
{SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report').map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<NavLink
|
||||
@ -123,7 +112,7 @@ export function ConsoleLayout() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{SCREENS.filter(t => canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
|
||||
{SCREENS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<NavLink
|
||||
@ -143,20 +132,6 @@ export function ConsoleLayout() {
|
||||
|
||||
<div className="my-2 border-t border-border-subtle" />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className={({ isActive }) => cn(
|
||||
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item",
|
||||
isActive ? "active" : ""
|
||||
)}
|
||||
>
|
||||
<Users size={20} className="shrink-0" />
|
||||
<span>Sales Officers</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/admin/datasets"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
@ -168,8 +143,6 @@ export function ConsoleLayout() {
|
||||
<Database size={20} className="shrink-0" />
|
||||
<span>Manage Datasets</span>
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]">
|
||||
@ -177,10 +150,7 @@ export function ConsoleLayout() {
|
||||
<div className="flex flex-col px-1">
|
||||
|
||||
<span className="text-sm font-medium text-foreground truncate">{user.name || 'User'}</span>
|
||||
<span className="text-xs text-foreground/70 truncate">{user.email || 'user@email.com'}</span>
|
||||
{userRoles && userRoles.length > 0 && (
|
||||
<span className="text-xs font-semibold text-primary mt-0.5 truncate">{userRoles.join(', ')}</span>
|
||||
)}
|
||||
<span className="font-medium text-foreground truncate">{user.email || 'user@email.com'}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
@ -31,7 +31,10 @@ export function DailyLogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full bg-transparent w-full relative">
|
||||
<div className="flex-1 overflow-y-auto pb-24">
|
||||
|
||||
|
||||
<DailyLogsView
|
||||
hideTiles
|
||||
refreshKey={refreshKey}
|
||||
@ -44,6 +47,7 @@ export function DailyLogsPage() {
|
||||
setPunchOutTitle("Punch Out");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global Floating Action Button for Punch In */}
|
||||
<button
|
||||
@ -91,6 +95,6 @@ export function DailyLogsPage() {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Card, Input, Select } from '../components/reusable';
|
||||
import { Button } from '../components/buttons';
|
||||
import {
|
||||
SALES_REPORT_ROUTES,
|
||||
ROUTE_WISE_DISTRIBUTORS,
|
||||
BASE_URL,
|
||||
PIPELINE
|
||||
SALES_REPORT_DISTRIBUTORS,
|
||||
SALES_REPORT_SO_NAMES
|
||||
} from '../api/config';
|
||||
import { Download, Printer } from 'lucide-react';
|
||||
import jsPDF from 'jspdf';
|
||||
@ -16,36 +15,12 @@ export function DailySalesReportPage() {
|
||||
const [date, setDate] = useState('');
|
||||
const [route, setRoute] = useState('');
|
||||
const [distributor, setDistributor] = useState('');
|
||||
const [soEmail, setSoEmail] = useState('');
|
||||
const [soOptions, setSoOptions] = useState<{value: string, label: string}[]>([]);
|
||||
const [soName, setSoName] = useState('');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reportData, setReportData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSOs() {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${PIPELINE.endpoints.salesOfficers}`, {
|
||||
headers: {
|
||||
'TemplateID': '194',
|
||||
'X-Pipeline-Version': 'latest',
|
||||
'OrgID': '57',
|
||||
'GroupID': '25',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data?.response?.options) {
|
||||
setSoOptions(data.response.options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch SOs', err);
|
||||
}
|
||||
}
|
||||
fetchSOs();
|
||||
}, []);
|
||||
|
||||
const downloadPDF = () => {
|
||||
const doc = new jsPDF('landscape');
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
@ -74,8 +49,7 @@ export function DailySalesReportPage() {
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Distributor Name : ${distributor}`, 14, 40);
|
||||
doc.text(`Date : ${date}`, rightMargin, 40, { align: 'right' });
|
||||
const selectedSo = soOptions.find(o => o.value === soEmail);
|
||||
doc.text(`SO Name : ${selectedSo ? selectedSo.label : soEmail}`, rightMargin, 45, { align: 'right' });
|
||||
doc.text(`SO Name : ${soName}`, rightMargin, 45, { align: 'right' });
|
||||
|
||||
// Table
|
||||
autoTable(doc, {
|
||||
@ -119,8 +93,7 @@ export function DailySalesReportPage() {
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Distributor Name : ${distributor}`, 14, 40);
|
||||
doc.text(`Date : ${date}`, rightMargin, 40, { align: 'right' });
|
||||
const selectedSo = soOptions.find(o => o.value === soEmail);
|
||||
doc.text(`SO Name : ${selectedSo ? selectedSo.label : soEmail}`, rightMargin, 45, { align: 'right' });
|
||||
doc.text(`SO Name : ${soName}`, rightMargin, 45, { align: 'right' });
|
||||
|
||||
// Table
|
||||
autoTable(doc, {
|
||||
@ -176,7 +149,7 @@ export function DailySalesReportPage() {
|
||||
date,
|
||||
route,
|
||||
distributor,
|
||||
so_name: soEmail,
|
||||
so_name: soName,
|
||||
});
|
||||
|
||||
setReportData(data);
|
||||
@ -191,7 +164,7 @@ export function DailySalesReportPage() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<h1 className="m-0 text-xl font-extrabold text-strong tracking-[-0.01em] print:hidden">Daily Sales Report</h1>
|
||||
|
||||
<Card className="print:hidden overflow-visible">
|
||||
<Card className="print:hidden">
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Date"
|
||||
@ -202,23 +175,20 @@ export function DailySalesReportPage() {
|
||||
<Select
|
||||
label="Route"
|
||||
value={route}
|
||||
onChange={(e) => {
|
||||
setRoute(e.target.value);
|
||||
setDistributor('');
|
||||
}}
|
||||
options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES.map(r => ({ value: r, label: r }))]}
|
||||
onChange={(e) => setRoute(e.target.value)}
|
||||
options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES]}
|
||||
/>
|
||||
<Select
|
||||
label="Distributor"
|
||||
value={distributor}
|
||||
onChange={(e) => setDistributor(e.target.value)}
|
||||
options={[{ value: '', label: 'Select Distributor' }, ...(route ? (ROUTE_WISE_DISTRIBUTORS[route] || []) : []).map(d => ({ value: d, label: d }))]}
|
||||
options={[{ value: '', label: 'Select Distributor' }, ...SALES_REPORT_DISTRIBUTORS]}
|
||||
/>
|
||||
<Select
|
||||
label="SO Name"
|
||||
value={soEmail}
|
||||
onChange={(e) => setSoEmail(e.target.value)}
|
||||
options={[{ value: '', label: 'Select SO Name' }, ...soOptions]}
|
||||
value={soName}
|
||||
onChange={(e) => setSoName(e.target.value)}
|
||||
options={[{ value: '', label: 'Select SO Name' }, ...SALES_REPORT_SO_NAMES]}
|
||||
/>
|
||||
|
||||
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
||||
@ -252,7 +222,7 @@ export function DailySalesReportPage() {
|
||||
<div>Distributor Name : {distributor}</div>
|
||||
<div className="text-right flex flex-col gap-1.5">
|
||||
<div>Date : {date}</div>
|
||||
<div>SO Name : {soOptions.find(o => o.value === soEmail)?.label || soEmail}</div>
|
||||
<div>SO Name : {soName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { APP_ID } from '../api/config';
|
||||
import { Button } from '../components/buttons';
|
||||
import { Card, Input } from '../components/reusable';
|
||||
|
||||
@ -10,6 +11,7 @@ export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [orgId, setOrgId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -20,7 +22,7 @@ export function LoginPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(email, password);
|
||||
await login(email, password, orgId || undefined);
|
||||
navigate('/daily', { replace: true });
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message ?? 'Login failed');
|
||||
@ -34,10 +36,12 @@ export function LoginPage() {
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<div className="flex flex-col gap-1 mb-5">
|
||||
<h1 className="m-0 text-2xl font-extrabold text-strong tracking-[-0.02em]">Krishna Sales</h1>
|
||||
<p className="m-0 text-sm text-faint">Field Sales console · Sandbox {APP_ID}</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
<Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<Input label="Org ID" hint="Optional" value={orgId} onChange={(e) => setOrgId(e.target.value)} />
|
||||
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
||||
<Button type="submit" full disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
|
||||
@ -6,7 +6,6 @@ import { useState } from 'react';
|
||||
|
||||
import { Plus, ShoppingBag } from 'lucide-react';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { LogVisitForm } from '../components/forms/LogVisitForm';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { ORDER_BOOKING } from '../api/config';
|
||||
import { orderBookingClient } from '../api/clients';
|
||||
@ -181,13 +180,15 @@ export function OrdersPage() {
|
||||
title={createTitle}
|
||||
width="md"
|
||||
>
|
||||
<LogVisitForm
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onActivityChange={(name) => setCreateTitle(name)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
@ -52,7 +52,8 @@ export function StoresPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full bg-transparent w-full relative">
|
||||
<div className="flex-1 overflow-y-auto pb-24">
|
||||
<StoresView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
@ -64,6 +65,7 @@ export function StoresPage() {
|
||||
setEditTitle("Edit Store");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global Floating Action Button for Create Store */}
|
||||
<button
|
||||
@ -111,6 +113,6 @@ export function StoresPage() {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { UserPlus } from 'lucide-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';
|
||||
|
||||
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}
|
||||
onEditRow={handleEditRow}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="fixed bottom-24 right-6 w-[60px] h-[60px] bg-primary rounded-3xl flex items-center justify-center shadow-lg text-white z-40 hover:opacity-90 transition-transform hover:scale-105 active:scale-95 cursor-pointer"
|
||||
title="Add Sales Officer"
|
||||
>
|
||||
<UserPlus size={24} className="text-white" />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user