Compare commits

...

10 Commits

Author SHA1 Message Date
37693556b3 fix(maps): use org zino-cloud Maps key (authorized for preview hosts) 2026-07-30 11:58:05 +00:00
suryacp23
eb4b25f327 made call details kpi less gap 2026-07-30 16:36:00 +05:30
suryacp23
cee2f5d94f select field fix 2026-07-30 15:42:24 +05:30
suryacp23
3fa30015bb padding correction made in all screens 2026-07-30 15:40:23 +05:30
suryacp23
f8c3658243 made styling the cards stat tiles 2026-07-30 15:28:48 +05:30
suryacp23
d1ef7f4838 added dsr option according to the database 2026-07-30 13:17:10 +05:30
suryacp23
f8b6de86f2 users added 2026-07-30 12:18:29 +05:30
suryacp23
6c9a6d60a2 roles based access added 2026-07-30 11:56:55 +05:30
suryacp23
90d892090d roles based access given 2026-07-30 11:54:40 +05:30
suryacp23
5097d183fa routewise store selection is done 2026-07-29 15:43:01 +05:30
40 changed files with 1426 additions and 348 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_GOOGLE_MAPS_API_KEY=AIzaSyDng5p2m-P4Zry2g0x6F6cbf1ZFdJevtHE

3
.gitignore vendored
View File

@ -24,5 +24,4 @@ dist-ssr
*.sw? *.sw?
# vite-plugin-pwa dev output # vite-plugin-pwa dev output
dev-dist dev-dist
.env

View File

@ -2,15 +2,16 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider' import { AuthProvider } from './auth/AuthProvider'
import { LoginPage } from './screens/LoginPage' import { LoginPage } from './screens/LoginPage'
import { ConsoleLayout } from './screens/ConsoleLayout' import { ConsoleLayout } from './screens/ConsoleLayout'
import { OrdersPage } from './screens/OrdersPage' import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
import { CallsPage } from './screens/CallsPage' import { useAuth } from './auth/context'
import { StoresPage } from './screens/StoresPage' import { routeConfig } from './routesConfig'
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' import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
function RootRedirect() {
const { roles, isAdmin } = useAuth();
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
}
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
@ -18,31 +19,31 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ConsoleLayout />}> <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=":id" element={<DatasetItemsPage />} />
</Route>
);
}
<Route path="/daily" element={<DailyLogsPage />} /> return (
<Route path="/daily/:instanceId" element={<DailyLogsPage />} /> <Route
key={i}
<Route path="/orders" element={<OrdersPage />} /> path={path}
<Route path="/orders/:instanceId" element={<OrdersPage />} /> element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
/>
<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>
</Route> </Route>
<Route path="*" element={<Navigate to="/daily" replace />} /> <Route path="*" element={<RootRedirect />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>

View File

@ -110,6 +110,23 @@ export class ZinoClient {
this.setToken(null); this.setToken(null);
} }
async getMe(): Promise<User> {
const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`);
if (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). */ /** Decode the persisted JWT into a User (no network). */
currentUser(): User | null { currentUser(): User | null {
if (!this.token) return null; if (!this.token) return null;
@ -150,7 +167,7 @@ export class ZinoClient {
...(alias ? { preset_alias: alias } : {}), ...(alias ? { preset_alias: alias } : {}),
search_query: { search_query: {
page: params.page ?? 1, page: params.page ?? 1,
limit: params.limit ?? 50, limit: params.limit ?? 10,
sort_by: params.sortBy ?? '', sort_by: params.sortBy ?? '',
sort_dir: params.sortDir ?? 'desc', sort_dir: params.sortDir ?? 'desc',
search: params.search ?? '', search: params.search ?? '',

View File

@ -21,15 +21,22 @@ export function clientFor(slug: WorkflowSlug): ZinoClient {
export const orderBookingClient = clientFor('orderBooking'); export const orderBookingClient = clientFor('orderBooking');
export const storeClient = clientFor('store'); export const storeClient = clientFor('store');
export const dailyReportsClient = clientFor('dailyReports'); export const dailyReportsClient = clientFor('dailyReports');
export const userClient = clientFor('user');
// The user JWT (from /usr/login) is valid across every workflow, but each // The user JWT (from /usr/login) is valid across every workflow, but each
// client holds its own copy — so auth helpers fan out to all of them. // client holds its own copy — so auth helpers fan out to all of them.
const ALL = [orderBookingClient, storeClient, dailyReportsClient]; const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient];
/** Log in once and share the JWT with every workflow client. */ /** Log in once and share the JWT with every workflow client. */
export async function loginAll(email: string, password: string, orgId?: string) { export async function loginAll(email: string, password: string, orgId?: string) {
const res = await orderBookingClient.login(email, password, orgId); const res = await orderBookingClient.login(email, password, orgId);
ALL.forEach((c) => c.setToken(res.token)); ALL.forEach((c) => c.setToken(res.token));
try {
const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me };
} catch (e) {
console.error('Failed to fetch user profile:', e);
}
return res; return res;
} }

View File

@ -170,11 +170,38 @@ export const DAILY_REPORTS = {
}, },
} as const; } as const;
// --- User Management (src/docs/api/user.md) ---
export const USER = {
workflowUuid: '9874af12-78b7-4730-834a-19a32221f3aa',
versionUuid: '', // No version UUID provided, server will use latest
activities: {
ADD_USER: {
uid: '3a8f132e-8c63-4d4d-b466-67595d345a99', // init
fields: {
name: 'name', // text
email: 'email', // email
password: 'password', // password
},
},
EDIT_USER: {
uid: '3091530c-4315-42b9-b53b-a148f2c54a81',
fields: {
name: 'name_3', // text
email: 'email_3', // email
},
},
},
recordViews: {
SALES_OFFICERS: '406f89d7-09f6-42ba-ba35-84334ccb6204',
},
} as const;
// All workflows keyed by slug, for generic lookup. // All workflows keyed by slug, for generic lookup.
export const WORKFLOWS = { export const WORKFLOWS = {
orderBooking: ORDER_BOOKING, orderBooking: ORDER_BOOKING,
store: STORE, store: STORE,
dailyReports: DAILY_REPORTS, dailyReports: DAILY_REPORTS,
user: USER,
} as const; } as const;
// --- Daily Sales Report Constants --- // --- Daily Sales Report Constants ---
@ -190,16 +217,17 @@ export const SALES_REPORT_ROUTES = [
'West-4' 'West-4'
]; ];
export const SALES_REPORT_DISTRIBUTORS = [ export const ROUTE_WISE_DISTRIBUTORS: Record<string, string[]> = {
'Banashree Multi Millet Flour', "Krishna": ["Banashree Multi Millet Flour"],
'Shiva nandi Enterprises', "East-1": ["Shiva nandi Enterprises"],
'BPK & Co', "East-2": ["BPK & Co"],
'Gaviranga', "East-3": ["BPK & Co"],
'Bhagwan Enterprises', "East-4": ["Gaviranga "],
'Shreshtaa Trading Co', "West-1": ["Bhagwan Enterprises"],
'Rajarajeshwari Enterprises', "West-2": ["Shreshtaa Trading Co "],
'Byraveshwara Trading Co' "West-3": ["Rajarajeshwari Enterprises "],
]; "West-4": ["Byraveshwara Trading Co"],
};
export const SALES_REPORT_SO_NAMES = [ export const SALES_REPORT_SO_NAMES = [
'Surya C' 'Surya C'
@ -209,6 +237,8 @@ export const SALES_REPORT_SO_NAMES = [
export const PIPELINE = { export const PIPELINE = {
endpoints: { endpoints: {
nearestStores: '/api/papi2/nearest-stores', nearestStores: '/api/papi2/nearest-stores',
dailySalesReport: '/api/papi2/daily-sales-report' dailySalesReport: '/api/papi2/daily-sales-report',
productiveCallSummary: '/api/papi2/productive-call-summary',
salesOfficers: '/api/papi2/sales-officers'
} }
}; };

View File

@ -12,6 +12,7 @@ export interface User {
email: string; email: string;
roles: string[]; roles: string[];
groups: string[]; groups: string[];
is_admin?: boolean;
} }
export interface LoginResponse { export interface LoginResponse {

View File

@ -1,19 +1,57 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken } from '../api/clients'; import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
import { AuthCtx, type AuthValue } from './context'; import { AuthCtx, type AuthValue } from './context';
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState(() => !!currentToken()); 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 = { const value: AuthValue = {
authed, authed,
isAdmin,
roles,
userEmail,
login: async (email, password, orgId) => { login: async (email, password, orgId) => {
await loginAll(email, password, orgId); const res = await loginAll(email, password, orgId);
setAuthed(true); 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: () => { logout: () => {
logoutAll(); logoutAll();
setAuthed(false); 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');
}, },
}; };

View File

@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom';
import { ShieldAlert } from 'lucide-react';
import { useAuth } from './context';
import { Button } from '../components/buttons';
import { Card } from '../components/reusable';
export function getDefaultRoute(roles: string[], isAdmin: boolean) {
if (isAdmin) return '/orders';
if (roles.includes('Manager')) return '/orders';
if (roles.includes('Sales Officer')) return '/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}</>;
}

View File

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

View File

@ -203,7 +203,7 @@ export function DailyLogCard({ row, onPunchOut, isDetailView = false }: { row: R
)} )}
{isPunchedIn && onPunchOut && ( {isPunchedIn && onPunchOut && (
<div style={{ padding: '16px 6px 0px 6px' }}> <div>
<Button <Button
variant="danger" variant="danger"
full full

View File

@ -60,11 +60,9 @@ export function OrderCard({ row, fields, isDetailView = false }: { row: Record<s
const productsSection = gridVal.length > 0 ? ( const productsSection = gridVal.length > 0 ? (
<div style={{ <div style={{
marginTop: '16px', marginTop: '16px',
backgroundColor: 'var(--tiles-card-bg)', marginLeft: '-12px',
border: '1px solid #e2e8f0', marginRight: '-12px'
borderRadius: '16px',
padding: '16px'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
<div style={{ backgroundColor: '#e6f9ed', padding: '6px', borderRadius: '50%' }}> <div style={{ backgroundColor: '#e6f9ed', padding: '6px', borderRadius: '50%' }}>

View File

@ -0,0 +1,48 @@
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>
);
}

View File

@ -109,7 +109,10 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; }
.z-card-footer:not(:last-child) {
margin-bottom: 16px;
} }
.z-card-footer-item { .z-card-footer-item {

View File

@ -0,0 +1,5 @@
export * from './OrderCard';
export * from './CallCard';
export * from './StoreCard';
export * from './DailyLogCard';
export * from './UserCard';

View File

@ -405,22 +405,18 @@ export function CallDetail({
</div> </div>
{/* Top Right KPI Grid */} {/* Top Right KPI Grid */}
<div className="flex flex-nowrap items-center gap-3 w-full lg:w-auto overflow-x-auto pb-2 min-w-0"> <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-3 text-center min-w-[100px] flex-1"> <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">TOTAL BAGS</div> <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-[20px] font-extrabold text-primary">{totalBags.toLocaleString()}</div> <div className="text-lg font-extrabold text-primary leading-tight">{totalBags.toLocaleString()}</div>
</div> </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="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">TOTAL KGS</div> <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-[20px] font-extrabold text-primary">{totalKgs.toLocaleString()}</div> <div className="text-lg font-extrabold text-primary leading-tight">{totalKgs.toLocaleString()}</div>
</div> </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="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">LINE ITEMS</div> <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-[20px] font-extrabold text-primary">{lineItemsCount}</div> <div className="text-lg font-extrabold text-primary leading-tight">{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> </div>
</div> </div>
@ -593,7 +589,7 @@ export function CallDetail({
{/* Call Potential Card (Below Orders) */} {/* Call Potential Card (Below Orders) */}
{callPotentialList.length > 0 && ( {callPotentialList.length > 0 && (
<div className="bg-[var(--tiles-card-bg)] rounded-2xl border border-slate-200/80 p-6 shadow-sm space-y-4"> <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="flex items-center justify-between border-b border-slate-100 pb-3"> <div className="flex items-center justify-between border-b border-slate-100 pb-3">
<div className="flex items-center gap-2"> <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"> <div className="bg-primary-light-bg p-1.5 rounded-full border border-primary-light-border flex items-center justify-center">
@ -609,16 +605,14 @@ export function CallDetail({
const extraOrdered = callPotentialList.filter(item => item.actualPotential === 0); const extraOrdered = callPotentialList.filter(item => item.actualPotential === 0);
const renderTable = (items: any[]) => ( const renderTable = (items: any[]) => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2.5">
{items.map((item, idx) => ( {items.map((item, idx) => (
<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 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-start gap-4"> <div className="flex justify-between items-center">
<div className="flex flex-col gap-1.5"> <span className="text-primary font-bold text-[13px] uppercase tracking-wide">{item.name}</span>
<span className="text-primary font-semibold text-[15px]">{item.name}</span>
</div>
</div> </div>
<div className="flex justify-between items-center bg-white rounded-2xl py-3 px-4 border border-primary-light-border"> <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 flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold text-primary uppercase tracking-wider">Potential</span> <span className="text-[10px] font-bold text-primary uppercase tracking-wider">Potential</span>
<span className="text-sm font-semibold text-primary">{item.actualPotential}</span> <span className="text-sm font-semibold text-primary">{item.actualPotential}</span>
@ -647,11 +641,11 @@ export function CallDetail({
); );
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-4">
{regularPotential.length > 0 && renderTable(regularPotential)} {regularPotential.length > 0 && renderTable(regularPotential)}
{extraOrdered.length > 0 && ( {extraOrdered.length > 0 && (
<div className="space-y-4 pt-2 border-t border-slate-100 mt-4"> <div className="flex flex-col gap-3 pt-2 border-t border-slate-100 mt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShoppingCart className="text-slate-800" size={14} /> <ShoppingCart className="text-slate-800" size={14} />
<h3 className="text-[11px] font-bold text-slate-800 uppercase tracking-wider">Ordered Outside Potential</h3> <h3 className="text-[11px] font-bold text-slate-800 uppercase tracking-wider">Ordered Outside Potential</h3>

View File

@ -154,18 +154,18 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
</div> </div>
</div> </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="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-3 text-center min-w-[100px] flex-1"> <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-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">PROD. CALLS</div> <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-[20px] font-extrabold text-primary">{prodCalls}</div> <div className="text-[18px] font-extrabold text-primary leading-none mt-0.5">{prodCalls}</div>
</div> </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="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2 text-center flex flex-col justify-center">
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">NON PROD.</div> <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-[20px] font-extrabold text-primary">{nonProdCalls}</div> <div className="text-[18px] font-extrabold text-primary leading-none mt-0.5">{nonProdCalls}</div>
</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="bg-primary-light-bg border border-primary-light-border rounded-2xl p-2 text-center flex flex-col justify-center">
<div className="text-[10px] font-bold text-primary uppercase tracking-wider mb-0.5">TOTAL CALLS</div> <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-sm font-bold text-primary mt-1 whitespace-nowrap overflow-hidden text-ellipsis"> <div className="text-[18px] font-extrabold text-primary leading-none mt-0.5">
{Number(prodCalls) + Number(nonProdCalls)} {Number(prodCalls) + Number(nonProdCalls)}
</div> </div>
</div> </div>
@ -173,8 +173,8 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
</div> </div>
<div className="border-t border-slate-100 pt-4"> <div className="border-t border-slate-100 pt-4">
<div className="flex items-center gap-8 overflow-x-auto pb-2"> <div className="flex flex-col gap-4 pb-2">
<div className="flex items-center gap-3 min-w-max"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-xs shrink-0"> <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} /> <Clock size={16} />
</div> </div>
@ -187,7 +187,7 @@ export function DailyLogDetail({ instanceId, onBack }: DailyLogDetailProps) {
</div> </div>
{checkOutTime && ( {checkOutTime && (
<div className="flex items-center gap-3 min-w-max"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center text-xs shrink-0"> <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} /> <Clock size={16} />
</div> </div>

View File

@ -178,55 +178,7 @@ 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); setValues(defaultValues);
setLoading(false); setLoading(false);
@ -343,30 +295,7 @@ 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'; const isOrderDetails = fieldId === 'order_details' || getBaseIdForField(fieldId) === 'order_details' || fieldDef?.mapped_workflow_field === 'order_details';
@ -589,19 +518,6 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
return null; 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 = () => { const renderField = () => {
if (type === 'wf_lookup') { if (type === 'wf_lookup') {
@ -762,6 +678,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
}; };
const content = renderField(); const content = renderField();
return isDisabled ? ( return isDisabled ? (
<fieldset key={f.id} disabled className="opacity-60 pointer-events-none"> <fieldset key={f.id} disabled className="opacity-60 pointer-events-none">
{content} {content}

View File

@ -0,0 +1,520 @@
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>
);
}

View File

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

View File

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

View File

@ -47,7 +47,7 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
return ( return (
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm"> <Card key={chart.chart_uid || idx} title={title} className="shadow-sm">
<div className="h-[320px] w-full mt-4"> <div className="h-[320px] w-full mt-4 [&_.recharts-wrapper]:!outline-none [&_.recharts-surface]:!outline-none [&_*]:!outline-none">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={formattedRows} margin={{ top: 25, right: 10, left: -20, bottom: 0 }}> <BarChart data={formattedRows} margin={{ top: 25, right: 10, left: -20, bottom: 0 }}>
<defs> <defs>
@ -81,6 +81,7 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
fill={`url(#colorGradient-${idx})`} fill={`url(#colorGradient-${idx})`}
radius={[8, 8, 0, 0]} radius={[8, 8, 0, 0]}
maxBarSize={40} maxBarSize={40}
activeBar={{ stroke: '#cbd5e1', strokeWidth: 1, fill: `url(#colorGradient-${idx})` }}
> >
<LabelList dataKey="value" position="top" fill="#475569" fontSize={12} fontWeight="bold" /> <LabelList dataKey="value" position="top" fill="#475569" fontSize={12} fontWeight="bold" />
</Bar> </Bar>

View File

@ -32,7 +32,7 @@ export function Pagination({ page, pageSize, total, onPage, onPageSizeChange }:
}; };
return ( return (
<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 flex-col items-center justify-center gap-4 pt-5 pb-6 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"> <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 <button
disabled={safePage <= 1} disabled={safePage <= 1}

View File

@ -1,5 +1,4 @@
import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react'; import { useState, useRef, useEffect, type SelectHTMLAttributes } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search, X, Check } from 'lucide-react'; import { ChevronDown, Search, X, Check } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
@ -19,6 +18,8 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
className?: string; className?: string;
/** Disable search header filter if set to false */ /** Disable search header filter if set to false */
searchable?: boolean; searchable?: boolean;
/** Callback fired when dropdown opens */
onDropdownOpen?: () => void;
} }
/** Custom searchable select component using React Portal to prevent container clipping. */ /** Custom searchable select component using React Portal to prevent container clipping. */
@ -32,11 +33,11 @@ export function Select({
disabled, disabled,
placeholder, placeholder,
searchable = true, searchable = true,
onDropdownOpen,
...rest ...rest
}: SelectProps) { }: SelectProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -53,37 +54,7 @@ export function Select({
o.label.toLowerCase().includes(searchQuery.toLowerCase()) 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(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
@ -112,7 +83,7 @@ export function Select({
}; };
return ( return (
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}> <div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', isOpen ? 'z-50' : 'z-10', className)}>
{label && ( {label && (
<label className="text-sm font-semibold text-slate-700"> <label className="text-sm font-semibold text-slate-700">
{label} {label}
@ -122,7 +93,15 @@ export function Select({
{/* Trigger Box */} {/* Trigger Box */}
<div <div
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={() => {
if (!disabled) {
const nextState = !isOpen;
setIsOpen(nextState);
if (nextState && onDropdownOpen) {
onDropdownOpen();
}
}
}}
className={cn( className={cn(
"relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none", "relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
disabled && "opacity-60 cursor-not-allowed bg-slate-50", disabled && "opacity-60 cursor-not-allowed bg-slate-50",
@ -135,12 +114,11 @@ export function Select({
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} /> <ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
</div> </div>
{/* Portaled Dropdown Menu Overlay (bypasses parent overflow:hidden clipping) */} {/* Dropdown Menu Overlay */}
{isOpen && !disabled && createPortal( {isOpen && !disabled && (
<div <div
ref={dropdownRef} ref={dropdownRef}
style={dropdownStyle} 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"
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()} onClick={(e) => e.stopPropagation()}
> >
{searchable && ( {searchable && (
@ -165,7 +143,7 @@ export function Select({
</div> </div>
)} )}
<div className="overflow-y-auto flex-1 py-1"> <div className="overflow-y-auto overflow-x-auto flex-1 py-1">
{filteredOptions.length > 0 ? ( {filteredOptions.length > 0 ? (
filteredOptions.map((opt) => { filteredOptions.map((opt) => {
const isSelected = opt.value === currentValue; const isSelected = opt.value === currentValue;
@ -174,11 +152,11 @@ export function Select({
key={opt.value} key={opt.value}
onClick={() => handleSelect(opt.value)} onClick={() => handleSelect(opt.value)}
className={cn( className={cn(
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors", "px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors w-max min-w-full",
isSelected ? "bg-navy-50/20 text-navy-700 font-semibold" : "hover:bg-black/5 text-foreground" isSelected ? "bg-navy-50/20 text-navy-700 font-semibold" : "hover:bg-black/5 text-foreground"
)} )}
> >
<span className="truncate">{opt.label}</span> <span className="whitespace-nowrap leading-tight flex-1 pr-4">{opt.label}</span>
{isSelected && <Check size={14} className="text-navy-600 shrink-0 ml-2" />} {isSelected && <Check size={14} className="text-navy-600 shrink-0 ml-2" />}
</div> </div>
); );
@ -189,8 +167,7 @@ export function Select({
</div> </div>
)} )}
</div> </div>
</div>, </div>
document.body
)} )}
{/* Hidden Native Select for Required/Form Validation */} {/* Hidden Native Select for Required/Form Validation */}
@ -249,9 +226,8 @@ export function MultiSelect({
}: MultiSelectProps) { }: MultiSelectProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const [draftValues, setDraftValues] = useState<string[]>(value ?? []); const [draftValues, setDraftValues] = useState<string[]>(value ?? []);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -275,37 +251,7 @@ export function MultiSelect({
o.label.toLowerCase().includes(searchQuery.toLowerCase()) 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(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
@ -348,7 +294,7 @@ export function MultiSelect({
: `${selectedLabels.length} selected`; : `${selectedLabels.length} selected`;
return ( return (
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}> <div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', isOpen ? 'z-50' : 'z-10', className)}>
{label && ( {label && (
<label className="text-xs font-bold text-slate-700"> <label className="text-xs font-bold text-slate-700">
{label} {label}
@ -371,12 +317,11 @@ export function MultiSelect({
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} /> <ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
</div> </div>
{/* Portaled Dropdown Menu Overlay */} {/* Dropdown Menu Overlay */}
{isOpen && !disabled && createPortal( {isOpen && !disabled && (
<div <div
ref={dropdownRef} ref={dropdownRef}
style={dropdownStyle} 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"
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()} onClick={(e) => e.stopPropagation()}
> >
{searchable && ( {searchable && (
@ -401,7 +346,7 @@ export function MultiSelect({
</div> </div>
)} )}
<div className="overflow-y-auto flex-1 py-1 min-h-[100px]"> <div className="overflow-y-auto overflow-x-auto flex-1 py-1 min-h-[100px]">
{filteredOptions.length > 0 ? ( {filteredOptions.length > 0 ? (
filteredOptions.map((opt) => { filteredOptions.map((opt) => {
const isSelected = draftValues.includes(opt.value); const isSelected = draftValues.includes(opt.value);
@ -410,7 +355,7 @@ export function MultiSelect({
key={opt.value} key={opt.value}
onClick={() => toggleOption(opt.value)} onClick={() => toggleOption(opt.value)}
className={cn( className={cn(
"px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none", "px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none w-max min-w-full",
isSelected ? "bg-navy-50/40 text-navy-900 font-semibold" : "hover:bg-black/5 text-strong" isSelected ? "bg-navy-50/40 text-navy-900 font-semibold" : "hover:bg-black/5 text-strong"
)} )}
> >
@ -418,9 +363,9 @@ export function MultiSelect({
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => {}} onChange={() => {}}
className="rounded border-slate-300 text-navy-600 focus:ring-navy-500 pointer-events-none h-3.5 w-3.5" className="shrink-0 rounded border-slate-300 text-navy-600 focus:ring-navy-500 pointer-events-none h-3.5 w-3.5"
/> />
<span className="truncate flex-1">{opt.label}</span> <span className="whitespace-nowrap leading-tight flex-1 pr-4">{opt.label}</span>
</div> </div>
); );
}) })
@ -449,8 +394,7 @@ export function MultiSelect({
Apply Apply
</button> </button>
</div> </div>
</div>, </div>
document.body
)} )}
{(hint || error) && ( {(hint || error) && (

View File

@ -31,7 +31,7 @@ export function StatsTile({ tile, idx = 0 }: StatsTileProps) {
return ( return (
<div className={`z-stats-tile z-stats-tile--${colorTheme}`}> <div className={`z-stats-tile z-stats-tile--${colorTheme}`}>
<div className="z-stats-icon-wrapper"> <div className="z-stats-icon-wrapper">
<Icon size={26} className="z-stats-icon" strokeWidth={2.5} /> <Icon size={22} className="z-stats-icon" strokeWidth={2.5} />
</div> </div>
<div className="z-stats-content"> <div className="z-stats-content">
<span className="z-stats-label">{displayLabel}</span> <span className="z-stats-label">{displayLabel}</span>

View File

@ -17,7 +17,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
margin-bottom: 16px;
} }
/* Base Tile */ /* Base Tile */
@ -25,7 +24,7 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 16px 20px; padding: 12px 16px;
background-color: #FFFBF4; background-color: #FFFBF4;
border-radius: var(--z-border-radius-lg, 12px); border-radius: var(--z-border-radius-lg, 12px);
box-shadow: var(--block-shadow); box-shadow: var(--block-shadow);
@ -43,13 +42,13 @@
} }
.z-stats-icon-wrapper { .z-stats-icon-wrapper {
width: 60px; width: 44px;
height: 60px; height: 44px;
border-radius: 14px; border-radius: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 16px; margin-right: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -88,7 +87,7 @@
} }
.z-stats-label { .z-stats-label {
font-size: 11px; font-size: 10px;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-color);
/* subtle gray */ /* subtle gray */
@ -98,7 +97,7 @@
} }
.z-stats-value { .z-stats-value {
font-size: 32px; font-size: 24px;
font-weight: 800; font-weight: 800;
line-height: 1.1; line-height: 1.1;
color: var(--primary-color); color: var(--primary-color);

View File

@ -24,7 +24,7 @@ export interface RecordViewProps {
columns?: string[]; columns?: string[];
/** Columns to hide. */ /** Columns to hide. */
omitColumns?: string[]; omitColumns?: string[];
/** Rows per page. @default 20 */ /** Rows per page. @default 10 */
pageSize?: number; pageSize?: number;
/** Click handler — receives the raw row + index. */ /** Click handler — receives the raw row + index. */
onRowClick?: (row: Record<string, unknown>, index: number) => void; onRowClick?: (row: Record<string, unknown>, index: number) => void;
@ -62,7 +62,7 @@ export function RecordView({
title = 'Records', title = 'Records',
columns, columns,
omitColumns, omitColumns,
pageSize = 20, pageSize = 10,
onRowClick, onRowClick,
rowKey, rowKey,
headerActions, headerActions,
@ -187,7 +187,7 @@ export function RecordView({
}, [activeFilters]); }, [activeFilters]);
return ( return (
<div className="flex flex-col gap-5 w-full max-w-full overflow-x-hidden"> <div className="flex flex-col gap-4 w-full max-w-full overflow-x-hidden">
{!hideTiles && <StatsTiles tiles={tileValues} />} {!hideTiles && <StatsTiles tiles={tileValues} />}
{!hideChart && <AnalyticsChart data={chartData} />} {!hideChart && <AnalyticsChart data={chartData} />}

View File

@ -0,0 +1,24 @@
import { userClient } from '../../api/clients';
import { USER } from '../../api/config';
import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
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} />}
/>
);
}

View File

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

247
src/docs/api/user.md Normal file
View File

@ -0,0 +1,247 @@
# 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.

93
src/routesConfig.tsx Normal file
View File

@ -0,0 +1,93 @@
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,
},
];

View File

@ -76,11 +76,11 @@ export function AnalyticsPage() {
// Decide chart type based on key // Decide chart type based on key
if (chart.key === 'status_overview') { if (chart.key === 'status_overview') {
return ( return (
<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 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> <div>
<h2 className="text-[15px] font-bold text-slate-800 tracking-tight">{title}</h2> <h2 className="text-[15px] font-bold text-slate-800 tracking-tight">{title}</h2>
</div> </div>
<div className="h-64 w-full"> <div className="h-64 w-full [&_.recharts-wrapper]:!outline-none [&_.recharts-surface]:!outline-none [&_*]:!outline-none">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
@ -108,11 +108,11 @@ export function AnalyticsPage() {
} }
return ( return (
<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 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> <div>
<h2 className="text-[15px] font-bold text-slate-800 tracking-tight">{title}</h2> <h2 className="text-[15px] font-bold text-slate-800 tracking-tight">{title}</h2>
</div> </div>
<div className="h-64 w-full"> <div className="h-64 w-full [&_.recharts-wrapper]:!outline-none [&_.recharts-surface]:!outline-none [&_*]:!outline-none">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}> <BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
@ -122,7 +122,13 @@ export function AnalyticsPage() {
cursor={{ fill: '#f8fafc' }} cursor={{ fill: '#f8fafc' }}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} 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} /> <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] }}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

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

View File

@ -1,15 +1,26 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { LogOut, Menu, X, Database } from 'lucide-react'; import { LogOut, Menu, X, Database, Users } from 'lucide-react';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { SCREENS } from './tabs'; import { SCREENS } from './tabs';
import { routeConfig } from '../routesConfig';
import './screen.css'; 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>. */ /** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
export function ConsoleLayout() { export function ConsoleLayout() {
const { authed, logout } = useAuth(); const { authed, logout, isAdmin, roles: userRoles } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const user = orderBookingClient.currentUser(); const user = orderBookingClient.currentUser();
@ -52,9 +63,9 @@ export function ConsoleLayout() {
<nav <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)]" 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').length}, minmax(0, 1fr))` }} 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))` }}
> >
{SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report').map((t) => { {SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
const Icon = t.icon; const Icon = t.icon;
return ( return (
<NavLink <NavLink
@ -112,7 +123,7 @@ export function ConsoleLayout() {
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
{SCREENS.map((t) => { {SCREENS.filter(t => canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
const Icon = t.icon; const Icon = t.icon;
return ( return (
<NavLink <NavLink
@ -132,17 +143,33 @@ export function ConsoleLayout() {
<div className="my-2 border-t border-border-subtle" /> <div className="my-2 border-t border-border-subtle" />
<NavLink {isAdmin && (
to="/admin/datasets" <>
onClick={() => setIsMenuOpen(false)} <NavLink
className={({ isActive }) => cn( to="/admin/users"
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item", onClick={() => setIsMenuOpen(false)}
isActive ? "active" : "" 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" : ""
<Database size={20} className="shrink-0" /> )}
<span>Manage Datasets</span> >
</NavLink> <Users size={20} className="shrink-0" />
<span>Sales Officers</span>
</NavLink>
<NavLink
to="/admin/datasets"
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" : ""
)}
>
<Database size={20} className="shrink-0" />
<span>Manage Datasets</span>
</NavLink>
</>
)}
</div> </div>
<div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]"> <div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]">
@ -150,7 +177,10 @@ export function ConsoleLayout() {
<div className="flex flex-col px-1"> <div className="flex flex-col px-1">
<span className="text-sm font-medium text-foreground truncate">{user.name || 'User'}</span> <span className="text-sm font-medium text-foreground truncate">{user.name || 'User'}</span>
<span className="font-medium text-foreground truncate">{user.email || 'user@email.com'}</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>
)}
</div> </div>
)} )}
<button <button

View File

@ -31,11 +31,8 @@ export function DailyLogsPage() {
} }
return ( return (
<div className="flex flex-col h-full bg-transparent w-full relative"> <>
<div className="flex-1 overflow-y-auto pb-24"> <DailyLogsView
<DailyLogsView
hideTiles hideTiles
refreshKey={refreshKey} refreshKey={refreshKey}
onRowClick={(row) => { onRowClick={(row) => {
@ -47,7 +44,6 @@ export function DailyLogsPage() {
setPunchOutTitle("Punch Out"); setPunchOutTitle("Punch Out");
}} }}
/> />
</div>
{/* Global Floating Action Button for Punch In */} {/* Global Floating Action Button for Punch In */}
<button <button
@ -95,6 +91,6 @@ export function DailyLogsPage() {
/> />
)} )}
</Modal> </Modal>
</div> </>
); );
} }

View File

@ -1,10 +1,11 @@
import { useState, type FormEvent } from 'react'; import { useState, useEffect, type FormEvent } from 'react';
import { Card, Input, Select } from '../components/reusable'; import { Card, Input, Select } from '../components/reusable';
import { Button } from '../components/buttons'; import { Button } from '../components/buttons';
import { import {
SALES_REPORT_ROUTES, SALES_REPORT_ROUTES,
SALES_REPORT_DISTRIBUTORS, ROUTE_WISE_DISTRIBUTORS,
SALES_REPORT_SO_NAMES BASE_URL,
PIPELINE
} from '../api/config'; } from '../api/config';
import { Download, Printer } from 'lucide-react'; import { Download, Printer } from 'lucide-react';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
@ -15,12 +16,36 @@ export function DailySalesReportPage() {
const [date, setDate] = useState(''); const [date, setDate] = useState('');
const [route, setRoute] = useState(''); const [route, setRoute] = useState('');
const [distributor, setDistributor] = useState(''); const [distributor, setDistributor] = useState('');
const [soName, setSoName] = useState(''); const [soEmail, setSoEmail] = useState('');
const [soOptions, setSoOptions] = useState<{value: string, label: string}[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [reportData, setReportData] = useState<any>(null); const [reportData, setReportData] = useState<any>(null);
useEffect(() => {
async function fetchSOs() {
try {
const res = await fetch(`${BASE_URL}${PIPELINE.endpoints.salesOfficers}`, {
headers: {
'TemplateID': '194',
'X-Pipeline-Version': 'latest',
'OrgID': '57',
'GroupID': '25',
'Content-Type': 'application/json'
}
});
const data = await res.json();
if (data?.response?.options) {
setSoOptions(data.response.options);
}
} catch (err) {
console.error('Failed to fetch SOs', err);
}
}
fetchSOs();
}, []);
const downloadPDF = () => { const downloadPDF = () => {
const doc = new jsPDF('landscape'); const doc = new jsPDF('landscape');
const pageWidth = doc.internal.pageSize.getWidth(); const pageWidth = doc.internal.pageSize.getWidth();
@ -49,7 +74,8 @@ export function DailySalesReportPage() {
doc.setFontSize(10); doc.setFontSize(10);
doc.text(`Distributor Name : ${distributor}`, 14, 40); doc.text(`Distributor Name : ${distributor}`, 14, 40);
doc.text(`Date : ${date}`, rightMargin, 40, { align: 'right' }); doc.text(`Date : ${date}`, rightMargin, 40, { align: 'right' });
doc.text(`SO Name : ${soName}`, rightMargin, 45, { align: 'right' }); const selectedSo = soOptions.find(o => o.value === soEmail);
doc.text(`SO Name : ${selectedSo ? selectedSo.label : soEmail}`, rightMargin, 45, { align: 'right' });
// Table // Table
autoTable(doc, { autoTable(doc, {
@ -93,7 +119,8 @@ export function DailySalesReportPage() {
doc.setFontSize(10); doc.setFontSize(10);
doc.text(`Distributor Name : ${distributor}`, 14, 40); doc.text(`Distributor Name : ${distributor}`, 14, 40);
doc.text(`Date : ${date}`, rightMargin, 40, { align: 'right' }); doc.text(`Date : ${date}`, rightMargin, 40, { align: 'right' });
doc.text(`SO Name : ${soName}`, rightMargin, 45, { align: 'right' }); const selectedSo = soOptions.find(o => o.value === soEmail);
doc.text(`SO Name : ${selectedSo ? selectedSo.label : soEmail}`, rightMargin, 45, { align: 'right' });
// Table // Table
autoTable(doc, { autoTable(doc, {
@ -149,7 +176,7 @@ export function DailySalesReportPage() {
date, date,
route, route,
distributor, distributor,
so_name: soName, so_name: soEmail,
}); });
setReportData(data); setReportData(data);
@ -164,7 +191,7 @@ export function DailySalesReportPage() {
<div className="flex flex-col gap-5"> <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> <h1 className="m-0 text-xl font-extrabold text-strong tracking-[-0.01em] print:hidden">Daily Sales Report</h1>
<Card className="print:hidden"> <Card className="print:hidden overflow-visible">
<form onSubmit={submit} className="flex flex-col gap-4"> <form onSubmit={submit} className="flex flex-col gap-4">
<Input <Input
label="Date" label="Date"
@ -175,20 +202,23 @@ export function DailySalesReportPage() {
<Select <Select
label="Route" label="Route"
value={route} value={route}
onChange={(e) => setRoute(e.target.value)} onChange={(e) => {
options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES]} setRoute(e.target.value);
setDistributor('');
}}
options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES.map(r => ({ value: r, label: r }))]}
/> />
<Select <Select
label="Distributor" label="Distributor"
value={distributor} value={distributor}
onChange={(e) => setDistributor(e.target.value)} onChange={(e) => setDistributor(e.target.value)}
options={[{ value: '', label: 'Select Distributor' }, ...SALES_REPORT_DISTRIBUTORS]} options={[{ value: '', label: 'Select Distributor' }, ...(route ? (ROUTE_WISE_DISTRIBUTORS[route] || []) : []).map(d => ({ value: d, label: d }))]}
/> />
<Select <Select
label="SO Name" label="SO Name"
value={soName} value={soEmail}
onChange={(e) => setSoName(e.target.value)} onChange={(e) => setSoEmail(e.target.value)}
options={[{ value: '', label: 'Select SO Name' }, ...SALES_REPORT_SO_NAMES]} options={[{ value: '', label: 'Select SO Name' }, ...soOptions]}
/> />
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>} {error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
@ -222,7 +252,7 @@ export function DailySalesReportPage() {
<div>Distributor Name : {distributor}</div> <div>Distributor Name : {distributor}</div>
<div className="text-right flex flex-col gap-1.5"> <div className="text-right flex flex-col gap-1.5">
<div>Date : {date}</div> <div>Date : {date}</div>
<div>SO Name : {soName}</div> <div>SO Name : {soOptions.find(o => o.value === soEmail)?.label || soEmail}</div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,6 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { Navigate, useNavigate } from 'react-router-dom'; import { Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { APP_ID } from '../api/config';
import { Button } from '../components/buttons'; import { Button } from '../components/buttons';
import { Card, Input } from '../components/reusable'; import { Card, Input } from '../components/reusable';
@ -11,7 +10,6 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [orgId, setOrgId] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -22,7 +20,7 @@ export function LoginPage() {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await login(email, password, orgId || undefined); await login(email, password);
navigate('/daily', { replace: true }); navigate('/daily', { replace: true });
} catch (err) { } catch (err) {
setError((err as { message?: string })?.message ?? 'Login failed'); setError((err as { message?: string })?.message ?? 'Login failed');
@ -36,12 +34,10 @@ export function LoginPage() {
<Card className="w-full max-w-[400px]"> <Card className="w-full max-w-[400px]">
<div className="flex flex-col gap-1 mb-5"> <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> <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> </div>
<form onSubmit={submit} className="flex flex-col gap-4"> <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="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="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>} {error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
<Button type="submit" full disabled={busy}> <Button type="submit" full disabled={busy}>
{busy ? 'Signing in…' : 'Sign in'} {busy ? 'Signing in…' : 'Sign in'}

View File

@ -6,6 +6,7 @@ import { useState } from 'react';
import { Plus, ShoppingBag } from 'lucide-react'; import { Plus, ShoppingBag } from 'lucide-react';
import { Button } from '../components/buttons/Button'; import { Button } from '../components/buttons/Button';
import { LogVisitForm } from '../components/forms/LogVisitForm';
import { DynamicForm } from '../components/forms/DynamicForm'; import { DynamicForm } from '../components/forms/DynamicForm';
import { ORDER_BOOKING } from '../api/config'; import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients'; import { orderBookingClient } from '../api/clients';
@ -180,15 +181,13 @@ export function OrdersPage() {
title={createTitle} title={createTitle}
width="md" width="md"
> >
<DynamicForm <LogVisitForm
client={orderBookingClient} client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
onSuccess={() => { onSuccess={() => {
setIsCreating(false); setIsCreating(false);
setRefreshKey(k => k + 1); setRefreshKey(k => k + 1);
}} }}
onCancel={() => setIsCreating(false)} onCancel={() => setIsCreating(false)}
onActivityChange={(name) => setCreateTitle(name)}
/> />
</Modal> </Modal>

View File

@ -52,20 +52,18 @@ export function StoresPage() {
} }
return ( return (
<div className="flex flex-col h-full bg-transparent w-full relative"> <>
<div className="flex-1 overflow-y-auto pb-24"> <StoresView
<StoresView refreshKey={refreshKey}
refreshKey={refreshKey} onRowClick={(row) => {
onRowClick={(row) => { const id = row.instance_id as number | string | undefined;
const id = row.instance_id as number | string | undefined; if (id != null) navigate(`/stores/${id}`);
if (id != null) navigate(`/stores/${id}`); }}
}} onEditRow={(row) => {
onEditRow={(row) => { setEditingInstanceId(row.instance_id as string | number);
setEditingInstanceId(row.instance_id as string | number); setEditTitle("Edit Store");
setEditTitle("Edit Store"); }}
}} />
/>
</div>
{/* Global Floating Action Button for Create Store */} {/* Global Floating Action Button for Create Store */}
<button <button
@ -113,6 +111,6 @@ export function StoresPage() {
/> />
)} )}
</Modal> </Modal>
</div> </>
); );
} }

View File

@ -0,0 +1,62 @@
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>
</>
);
}