detailview ui improved

This commit is contained in:
suryacp23 2026-07-21 12:10:23 +05:30
parent 4d7beaef0f
commit 930809a581
32 changed files with 2270 additions and 331 deletions

View File

@ -8,6 +8,7 @@ import { CallsPage } from './screens/CallsPage'
import { StoresPage } from './screens/StoresPage' import { StoresPage } from './screens/StoresPage'
import { DailyLogsPage } from './screens/DailyLogsPage' import { DailyLogsPage } from './screens/DailyLogsPage'
import { DailySalesReportPage } from './screens/DailySalesReportPage' import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { ReportPage } from './screens/ReportPage'
function App() { function App() {
return ( return (
@ -31,6 +32,7 @@ function App() {
<Route path="daily" element={<DailyLogsPage />} /> <Route path="daily" element={<DailyLogsPage />} />
<Route path="daily/:instanceId" element={<DailyLogsPage />} /> <Route path="daily/:instanceId" element={<DailyLogsPage />} />
<Route path="reports/:reportType" element={<ReportPage />} />
<Route path="sales-report" element={<DailySalesReportPage />} /> <Route path="sales-report" element={<DailySalesReportPage />} />
<Route path="*" element={<Navigate to="/orders" replace />} /> <Route path="*" element={<Navigate to="/orders" replace />} />
</Route> </Route>

View File

@ -125,6 +125,7 @@ export class ZinoClient {
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> { recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, { return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid, rv_template_uid: rvUid,
...(params.presetAlias ? { preset_alias: params.presetAlias } : {}),
search_query: { search_query: {
page: params.page ?? 1, page: params.page ?? 1,
limit: params.limit ?? 50, limit: params.limit ?? 50,

View File

@ -65,6 +65,15 @@ export const ORDER_BOOKING = {
recordViews: { recordViews: {
ORDERS: '63714ac2-c9fb-40e2-abba-d4081a70b768', ORDERS: '63714ac2-c9fb-40e2-abba-d4081a70b768',
CALLS: '56b21b46-6d2c-4e10-b5a3-c63d058d0afa', CALLS: '56b21b46-6d2c-4e10-b5a3-c63d058d0afa',
PRODUCT_WISE_ORDERS:'f1b372de-9e00-4cc6-81b5-1e1a98e7daef',
ROUTE_WISE_ORDERS:'bfb346b6-b512-4870-946b-a6f2e621870e',
SO_WISE_ORDERS:'c155d266-4eef-420d-9fb8-cd6ace43c569',
SO_WISE_VISITS:'08767452-6f0d-46cc-ae7f-ddf2cb480159',
YEARLY_ORDERS:'b8fd04da-f076-4e8d-b711-cf9282bf450a',
MONTHLY_ORDERS:'6d4ce86a-7a05-446b-8c81-cd0b98b6b244',
WEEKLY_ORDERS:'c6e2c57f-ed17-4c92-a2de-189c61c89422',
STORE_WISE_PRODUCTS:'f8d71b1d-dffd-450a-b9c9-3a63db8992c5'
}, },
detailViews: { detailViews: {
ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d', ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d',

View File

@ -53,6 +53,7 @@ export interface RecordViewParams {
sortDir?: 'asc' | 'desc'; sortDir?: 'asc' | 'desc';
search?: string; search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>; filters?: Array<{ field_key: string; value: string; data_type?: string }>;
presetAlias?: string;
} }
// --- Form schema (POST /app/{appId}/view/form-screens) --- // --- Form schema (POST /app/{appId}/view/form-screens) ---

View File

@ -1,8 +1,9 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
import './style.css'
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger'; export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger' | 'outline';
export type ButtonSize = 'sm' | 'md' | 'lg'; export type ButtonSize = 'sm' | 'md' | 'lg' | 'fab';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style. @default "primary" */ /** Visual style. @default "primary" */
@ -19,14 +20,16 @@ const SIZES: Record<ButtonSize, string> = {
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm', sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
md: 'h-[42px] px-[18px] text-base rounded-md', md: 'h-[42px] px-[18px] text-base rounded-md',
lg: 'h-[50px] px-6 text-md rounded-md', lg: 'h-[50px] px-6 text-md rounded-md',
fab: 'h-[56px] w-[56px] text-sm rounded-full',
}; };
const VARIANTS: Record<ButtonVariant, string> = { const VARIANTS: Record<ButtonVariant, string> = {
primary: 'bg-sunrise text-white border border-transparent shadow-sunrise', primary: 'z-btn border border-transparent shadow-blue-600/30',
navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm', navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm',
secondary: 'bg-card text-strong border border-border-default shadow-xs', secondary: 'bg-card text-strong border border-border-default shadow-xs',
ghost: 'bg-transparent text-body border border-transparent', ghost: 'bg-transparent text-body border border-transparent',
danger: 'bg-ruby-600 text-white border border-transparent shadow-sm', danger: 'bg-ruby-600 text-white border border-transparent shadow-sm',
outline: 'z-btn-outline'
}; };
/** /**
@ -52,7 +55,7 @@ export function Button({
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100', 'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
SIZES[size], SIZES[size],
VARIANTS[variant], VARIANTS[variant],
full ? 'w-full' : 'w-auto', full ? 'w-full' : '',
className, className,
)} )}
{...rest} {...rest}

View File

@ -0,0 +1,45 @@
.z-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--z-border-radius-md);
font-size: var(--z-font-sm);
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
line-height: 1.4;
background: var(--z-btn-primary-bg);
color: #ffffff;
}
.z-btn-primary {
border: none;
}
.z-btn:hover {
background: var(--primary-btn-active);
}
.z-btn-secondary {
background-color: var(--z-block-bg);
color: var(--z-text-primary);
border: 1px solid var(--z-block-border);
}
.z-btn-secondary:hover {
background-color: var(--z-body-bg);
}
.z-btn-outline {
background: transparent;
color: var(--z-btn-outline-color);
border: 1px solid var(--z-btn-outline-border);
}
.z-btn-outline:hover {
background-color: var(--z-color-primary-light);
}

View File

@ -1,17 +1,371 @@
import { useEffect, useState, type ReactNode } from 'react';
import { orderBookingClient } from '../../api/clients'; import { orderBookingClient } from '../../api/clients';
import { ORDER_BOOKING } from '../../api/config'; import { ORDER_BOOKING, APP_ID } from '../../api/config';
import { DetailView } from './DetailView'; import { Card } from '../reusable/Card';
import type { WiredDetailViewProps } from './OrderDetail'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
import { formatValue } from '../../lib/format';
import { ShoppingCart, Store, User, ClipboardList, TrendingUp } from 'lucide-react';
import { PotentialMiningTable, type MiningItem } from './PotentialMiningTable';
export interface CallDetailProps {
instanceId: number | string;
selectedRow?: Record<string, any>;
potentialMiningAction?: ReactNode;
placeOrderAction?: ReactNode;
}
/** Custom Call detail view matching specific groupings. */
export function CallDetail({ instanceId, selectedRow, potentialMiningAction, placeOrderAction }: CallDetailProps) {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await orderBookingClient.detailView(ORDER_BOOKING.detailViews.CALLS, instanceId);
if (live) {
setData(r.data || {});
}
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
if (live) setLoading(false);
}
}
run();
return () => {
live = false;
};
}, [instanceId]);
if (error) {
return (
<Card title="Call Details">
<EmptyState title="Couldnt load record" hint={error} />
</Card>
);
}
if (loading) {
return (
<Card title="Call Details">
<div className="py-8 flex justify-center">
<Spinner label="Loading details…" />
</div>
</Card>
);
}
if (!data || Object.keys(data).length === 0) {
return (
<Card title="Call Details">
<EmptyState title="No details found" />
</Card>
);
}
// Identify state
const rowSrc = selectedRow || data;
const stateName = String(rowSrc.current_state_name || rowSrc.current_state_name_ || rowSrc.current_state || rowSrc.status || 'Unknown');
const valLower = stateName.toLowerCase();
const isSuccess = valLower.includes('approve') || valLower.includes('complete') || valLower.includes('success') || valLower.includes('active') || valLower.includes('productive') || valLower.includes('ordered');
const isWarning = valLower.includes('pending') || valLower.includes('draft') || valLower.includes('hold');
const isDanger = valLower.includes('reject') || valLower.includes('fail') || valLower.includes('cancel');
let colorClass = "text-blue-500";
if (isSuccess) colorClass = "text-emerald-500";
else if (isWarning) colorClass = "text-amber-500";
else if (isDanger) colorClass = "text-red-500";
// Data Extraction and Fallback
const remainingData = { ...data };
const extract = (key: string, obj: any = data) => {
if (obj && key in obj) {
const val = obj[key];
if (obj === remainingData) {
delete remainingData[key];
}
return val;
}
return '-';
};
const selectStore = (data.select_store as any) || {};
if ('select_store' in remainingData) {
delete remainingData.select_store;
}
// Also remove current_state to not render them again
delete remainingData.current_state_id;
delete remainingData.current_state_name;
let orderIdVal = extract('order_id', remainingData);
let instanceIdVal = extract('instance_id', remainingData);
const orderId = orderIdVal !== '-' ? orderIdVal : instanceIdVal;
const orderDate = extract('date_of_order_3', remainingData);
const totalBags = extract('total_bags_3', remainingData);
const totalKgs = extract('total_kgs_3', remainingData);
const orderDetailsGrid = extract('order_details_3', remainingData);
let potentialGrid: any[] = [];
if ('potential' in remainingData) {
const pot = remainingData.potential as any;
if (Array.isArray(pot)) {
potentialGrid = pot;
} else if (pot && typeof pot === 'object' && Array.isArray(pot.potential)) {
potentialGrid = pot.potential;
}
delete remainingData.potential;
}
const miningData: MiningItem[] = potentialGrid.map((item: any, index: number) => {
let badgeType: 'error' | 'success' | 'info' | 'warning' | 'default' = 'default';
const reason = item.reason || '';
if (reason.toLowerCase().includes('quality')) badgeType = 'error';
else if (reason.toLowerCase().includes('stock')) badgeType = 'success';
else if (reason.toLowerCase().includes('price')) badgeType = 'warning';
else if (reason.toLowerCase().includes('competitive')) badgeType = 'info';
return {
id: String(item.id || index),
productName: item.product_category || item.product_category_ || '-',
badge: reason ? { label: reason, type: badgeType } : undefined,
potentialKgs: Number(item.actual_potential || item.store_potential || 0),
orderedKgs: Number(item.total_ordered || 0),
};
});
const storeName = selectStore.business_name || selectStore.store_name || '-';
const storeLocation = selectStore.area || selectStore.location || '-';
const dateOfVisit = extract('date_of_visit', remainingData);
const customerId = selectStore.store_code || selectStore.customer_id || '-';
const customerName = selectStore.owner_name || selectStore.customer_name || '-';
let phoneNumber = '-';
if (selectStore.phone_number) {
phoneNumber = typeof selectStore.phone_number === 'object'
? selectStore.phone_number.phone_with_dial_code || selectStore.phone_number.phone
: selectStore.phone_number;
}
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id'));
const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
const userName = userObj && typeof userObj === 'object' ? userObj.name || userObj.email : '-';
const uploadImage = extract('upload_image', remainingData);
// Helper for rendering a row
const Row = ({ label, value }: { label: string, value: any }) => (
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
<span className="text-sm font-bold text-strong sm:w-2/3">{formatValue(value)}</span>
</div>
);
/** Call detail view (Order Booking workflow). */
export function CallDetail({ instanceId, columns }: WiredDetailViewProps) {
return ( return (
<DetailView <div className="flex flex-col gap-6">
client={orderBookingClient}
dvUid={ORDER_BOOKING.detailViews.CALLS} {/* 1. Status Card */}
instanceId={instanceId} <Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
title="Call" <div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
columns={columns} <div>
/> <div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
{stateName}
</div>
</div>
{placeOrderAction && (
<div>{placeOrderAction}</div>
)}
</div>
</Card>
{/* 2. Order Details Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<ShoppingCart className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Details</h3>
</div>
<div className="p-5 flex flex-col">
<div className="flex flex-col mb-4">
<Row label="Order ID" value={orderId} />
<Row label="Order Date" value={orderDate} />
<Row label="Total Bags" value={totalBags} />
<Row label="Total Kgs" value={totalKgs} />
</div>
{Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && (
<div className="mt-4 flex flex-col gap-3">
<h4 className="text-[11px] font-bold text-[var(--z-bg-primary)] opacity-80 uppercase tracking-widest mb-1">Line Items</h4>
<div className="overflow-x-auto rounded-lg border border-border-subtle shadow-sm bg-white">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-slate-50 border-b border-border-subtle text-xs uppercase text-muted tracking-wider">
<tr>
<th className="px-4 py-3 font-semibold">Product Name</th>
<th className="px-4 py-3 font-semibold">SKU</th>
<th className="px-4 py-3 font-semibold">SKU Code</th>
<th className="px-4 py-3 font-semibold">BR Code</th>
<th className="px-4 py-3 font-semibold">Bags</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle text-strong">
{orderDetailsGrid.map((item: any, idx: number) => {
const skuCode = item.sku_code_3 || '-';
let chipColor = 'bg-slate-50 text-slate-700 border-slate-200';
if (skuCode !== '-') {
const hash = skuCode.split('').reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0);
const colors = [
'bg-blue-50 text-blue-700 border-blue-200',
'bg-emerald-50 text-emerald-700 border-emerald-200',
'bg-amber-50 text-amber-700 border-amber-200',
'bg-rose-50 text-rose-700 border-rose-200',
'bg-purple-50 text-purple-700 border-purple-200',
'bg-indigo-50 text-indigo-700 border-indigo-200',
'bg-pink-50 text-pink-700 border-pink-200',
'bg-cyan-50 text-cyan-700 border-cyan-200'
];
chipColor = colors[hash % colors.length];
}
return (
<tr key={idx} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3 font-medium">{item.product_name_3 || item.product_category_3 || '-'}</td>
<td className="px-4 py-3">{item.sku_3 || '-'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border shadow-sm ${chipColor}`}>
{skuCode}
</span>
</td>
<td className="px-4 py-3">{item.br_code_3 || '-'}</td>
<td className="px-4 py-3 font-semibold">{item.bags_3 || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{potentialMiningAction && (
<div className="flex justify-center mt-6">
{potentialMiningAction}
</div>
)}
<div className="flex flex-col mt-4 pt-4 border-t border-border-subtle">
<Row label="Order Created By" value={userName} />
<Row label="Order Created At" value={createdAt} />
</div>
</div>
</Card>
{/* 3. Store Details Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Store className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Store Name" value={storeName} />
<Row label="Store Location" value={storeLocation} />
<Row label="Date Of Visit" value={dateOfVisit} />
</div>
</Card>
{/* 4. Customer Details Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<User className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Customer Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Customer ID" value={customerId} />
<Row label="Customer Name" value={customerName} />
<Row label="Phone Number" value={phoneNumber} />
</div>
</Card>
{/* 5. Potential Mining Card */}
{miningData.length > 0 && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<TrendingUp className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Potential Mining</h3>
</div>
<div className="p-0 sm:p-5">
<PotentialMiningTable data={miningData} />
</div>
</Card>
)}
{/* 6. Log Visit Details Fallback */}
{Object.keys(remainingData).length > 0 && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Log Visit Details</h3>
</div>
<div className="p-5 flex flex-col">
{Object.entries(remainingData).map(([key, value]) => {
const lowerKey = key.toLowerCase();
if (lowerKey.includes('uuid') || /^\d+$/.test(key)) return null;
// we just render them all as rows for simplicity in this custom view
let finalValue = value;
if (finalValue && typeof finalValue === 'object') {
try {
finalValue = JSON.stringify(finalValue);
} catch (e) {
finalValue = '-';
}
}
// format label to be readable
const label = key.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
.replace(/^\s+|\s+$/g, '');
if (!label) return null;
return <Row key={key} label={label} value={finalValue} />;
})}
{Array.isArray(uploadImage) && uploadImage.length > 0 && (
<div className="flex flex-col mt-4 pt-4 border-t border-border-subtle">
<span className="text-sm text-muted mb-2 font-medium">Uploaded Image:</span>
<div className="flex flex-wrap gap-4">
{uploadImage.map((file: any, idx: number) => {
const previewUrl = `${orderBookingClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
return (
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
<img
src={previewUrl}
alt={file.original_name || 'Upload'}
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
}}
/>
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
</div>
);
})}
</div>
</div>
)}
</div>
</Card>
)}
</div>
); );
} }

View File

@ -1,17 +1,242 @@
import { useEffect, useState } from 'react';
import { dailyReportsClient } from '../../api/clients'; import { dailyReportsClient } from '../../api/clients';
import { DAILY_REPORTS } from '../../api/config'; import { DAILY_REPORTS, APP_ID } from '../../api/config';
import { DetailView } from './DetailView'; import { Card } from '../reusable/Card';
import type { WiredDetailViewProps } from './OrderDetail'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
import { formatValue } from '../../lib/format';
import { Button } from '../buttons/Button';
import { Map, User, ClipboardList, Camera, Clock } from 'lucide-react';
export interface DailyLogDetailProps {
instanceId: number | string;
onPunchOut?: () => void;
}
export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps) {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await dailyReportsClient.detailView(DAILY_REPORTS.detailViews.DAILY_LOGS, instanceId);
if (live) setData(r.data || {});
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
if (live) setLoading(false);
}
}
run();
return () => { live = false; };
}, [instanceId]);
if (error) {
return (
<Card title="Daily Log Details">
<EmptyState title="Couldnt load record" hint={error} />
</Card>
);
}
if (loading) {
return (
<Card title="Daily Log Details">
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
</Card>
);
}
if (!data || Object.keys(data).length === 0) {
return (
<Card title="Daily Log Details">
<EmptyState title="No details found" />
</Card>
);
}
const remainingData = { ...data };
const extract = (key: string, obj: any = data) => {
if (obj && key in obj) {
const val = obj[key];
delete obj[key];
return val;
}
return '-';
};
// State
const stateName = extract('current_state_name', remainingData) as string;
delete remainingData.current_state_id;
const isPunchedIn = stateName.toLowerCase().includes('punched in');
const isPunchedOut = stateName.toLowerCase().includes('punched out');
const colorClass = isPunchedOut ? 'text-emerald-500' : (isPunchedIn ? 'text-blue-500' : 'text-slate-500');
// General Info
const date = extract('date', remainingData);
const time = extract('time', remainingData);
const routeCode = extract('route_code', remainingData);
const subRoute = extract('sub_route', remainingData);
const dayPlanNotes = extract('day_plan_notes', remainingData);
// Officer Info
const officerNameRaw = extract('sales_officer_name', remainingData);
let officerName = '-';
let officerEmail = '-';
if (officerNameRaw && typeof officerNameRaw === 'object') {
officerName = (officerNameRaw as any).name || '-';
officerEmail = (officerNameRaw as any).email || '-';
}
// Image
const storeImage = extract('store_image', remainingData);
// EOD Info
const totalProductive = extract('total_productive_calls', remainingData);
const totalNonProductive = extract('total_non_productive_calls', remainingData);
const eodNotes = extract('eod_notes_remarks', remainingData);
// Meta
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
// Helper Row
const Row = ({ label, value }: { label: string, value: any }) => {
let finalValue = value;
if (finalValue && typeof finalValue === 'object') {
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
}
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span>
</div>
);
};
/** Daily Log detail view (Daily Reports workflow). */
export function DailyLogDetail({ instanceId, columns }: WiredDetailViewProps) {
return ( return (
<DetailView <div className="flex flex-col gap-6">
client={dailyReportsClient}
dvUid={DAILY_REPORTS.detailViews.DAILY_LOGS} {/* 1. Status Card */}
instanceId={instanceId} <Card pad={false} className={`border-t-[4px] shadow-md ${isPunchedOut ? 'border-emerald-500' : 'border-[var(--z-bg-primary)]'}`}>
title="Daily Log" <div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
columns={columns} <div>
/> <div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Current State</div>
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
{stateName}
</div>
</div>
{isPunchedIn && onPunchOut && (
<Button onClick={onPunchOut}>Punch Out</Button>
)}
</div>
</Card>
{/* 2. Route & Plan Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Map className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Day Plan & Route</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Date" value={date} />
<Row label="Time" value={time} />
<Row label="Route Code" value={routeCode} />
<Row label="Sub Route" value={subRoute} />
<Row label="Day Plan Notes" value={dayPlanNotes} />
</div>
</Card>
{/* 3. Officer Info */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<User className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Officer Name" value={officerName} />
<Row label="Officer Email" value={officerEmail} />
</div>
</Card>
{/* 4. EOD Details (Only if not punched in) */}
{!isPunchedIn && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">End of Day Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Total Productive Calls" value={totalProductive} />
<Row label="Total Non-Productive Calls" value={totalNonProductive} />
<Row label="EOD Notes (Remarks)" value={eodNotes} />
</div>
</Card>
)}
{/* 5. Image Card */}
{Array.isArray(storeImage) && storeImage.length > 0 && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Camera className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3>
</div>
<div className="p-5">
<div className="flex flex-wrap gap-4">
{storeImage.map((file: any, idx: number) => {
const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
return (
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
<img
src={previewUrl}
alt={file.original_name || 'Upload'}
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
}}
/>
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
</div>
);
})}
</div>
</div>
</Card>
)}
{/* 6. Other Details */}
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0) && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Clock className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Performed At" value={createdAt} />
{Object.entries(remainingData).map(([key, value]) => {
const lowerKey = key.toLowerCase();
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
const label = key.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
.replace(/^\s+|\s+$/g, '');
if (!label) return null;
return <Row key={key} label={label} value={value} />;
})}
</div>
</Card>
)}
</div>
); );
} }

View File

@ -2,8 +2,7 @@ import { useEffect, useState } from 'react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format'; import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client'; import type { ZinoClient } from '../../api/client';
import type { AuditEntry } from '../../api/types'; import { APP_ID } from '../../api/config';
import { WORKFLOWS } from '../../api/config';
import { Card } from '../reusable/Card'; import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState'; import { EmptyState } from '../reusable/EmptyState';
@ -11,7 +10,7 @@ import { EmptyState } from '../reusable/EmptyState';
export interface DetailViewProps { export interface DetailViewProps {
/** Workflow-bound client (see api/clients.ts). */ /** Workflow-bound client (see api/clients.ts). */
client: ZinoClient; client: ZinoClient;
/** detailview template uid (deprecated, using audit endpoint now). */ /** detailview template uid. */
dvUid?: string; dvUid?: string;
/** Instance to render. */ /** Instance to render. */
instanceId: number | string; instanceId: number | string;
@ -23,29 +22,13 @@ export interface DetailViewProps {
columns?: 1 | 2 | 3; columns?: 1 | 2 | 3;
} }
/**
* Helper to resolve a human-readable activity name from its UID.
*/
function getActivityName(uid: string): string | undefined {
for (const wf of Object.values(WORKFLOWS)) {
for (const [actName, actDef] of Object.entries(wf.activities)) {
if (actDef.uid === uid) {
return actName
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
}
}
return undefined;
}
/** /**
* Generic Zino detail view. Fetches `GET /app/{id}/view/audit` and * Generic Zino detail view. Fetches `GET /app/{id}/view/detailview/{dvUid}` and
* renders the audit trail as a labeled definition grid. * renders the data as a labeled definition grid.
*/ */
export function DetailView({ client, instanceId, title, columns = 2 }: DetailViewProps) { export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
const [entries, setEntries] = useState<AuditEntry[]>([]); const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -55,8 +38,11 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const r = await client.audit(instanceId); if (!dvUid) throw new Error("dvUid is required to fetch detail view data.");
if (live) setEntries(r); const r = await client.detailView(dvUid, instanceId);
if (live) {
setData(r.data || {});
}
} catch (e) { } catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally { } finally {
@ -67,7 +53,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
return () => { return () => {
live = false; live = false;
}; };
}, [client, instanceId]); }, [client, dvUid, instanceId]);
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns]; const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
@ -89,29 +75,27 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
); );
} }
if (entries.length === 0) { if (!data || Object.keys(data).length === 0) {
return ( return (
<Card title={title}> <Card title={title}>
<EmptyState title="No history found" /> <EmptyState title="No details found" />
</Card> </Card>
); );
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{entries.map((entry, i) => { <Card
const actName = getActivityName(entry.activity_id); title={title || 'Details'}
const headerTitle = actName || (i === 0 && title ? title : 'Update'); className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md"
const updatedAt = new Date(entry.created_at).toLocaleString(); bodyClassName="bg-slate-50/30"
>
<dl className={cn('grid gap-x-6 gap-y-6', gridCols)}>
{Object.entries(data).map(([key, value]) => {
if (fields && !fields.includes(key)) return null;
const lowerKey = key.toLowerCase();
if (lowerKey.includes('uuid') || /^\d+$/.test(key)) return null;
return (
<Card
key={entry.id}
title={headerTitle}
action={<span className="text-xs font-medium text-faint">{updatedAt}</span>}
>
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
{Object.entries(entry.data).map(([key, value]) => {
const isGridArray = const isGridArray =
Array.isArray(value) && Array.isArray(value) &&
value.length > 0 && value.length > 0 &&
@ -127,7 +111,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
return ( return (
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4"> <div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint"> <dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
{key.replace(/_/g, ' ')} {key.replace(/_/g, ' ')}
</dt> </dt>
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm"> <dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
@ -146,7 +130,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
<tr key={idx} className="hover:bg-slate-50/50"> <tr key={idx} className="hover:bg-slate-50/50">
{headers.map(h => ( {headers.map(h => (
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong"> <td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
{formatValue(row[h], h)} {formatValue(row[h])}
</td> </td>
))} ))}
</tr> </tr>
@ -158,21 +142,60 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
); );
} }
const isFileArray =
Array.isArray(value) &&
value.length > 0 &&
typeof value[0] === 'object' &&
value[0] !== null &&
('original_name' in value[0] || 'uuid' in value[0]);
if (isFileArray) {
return (
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
<dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
{key.replace(/_/g, ' ')}
</dt>
<dd className="m-0 flex flex-wrap gap-4">
{(value as any[]).map((file, idx) => {
const previewUrl = `${client.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
return (
<div key={file.uuid || idx} className="relative w-32 h-32 rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
<img
src={previewUrl}
alt={file.original_name || 'Attached File'}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
}}
/>
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="absolute inset-0 z-10"
></a>
</div>
);
})}
</dd>
</div>
);
}
return ( return (
<div key={key} className="flex flex-col gap-1 min-w-0"> <div key={key} className="flex flex-col gap-1.5 min-w-0">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint"> <dt className="text-[11px] font-bold uppercase tracking-[0.08em] text-[var(--z-bg-primary)] opacity-80">
{key.replace(/_/g, ' ')} {key.replace(/_/g, ' ')}
</dt> </dt>
<dd className="m-0 text-sm text-strong font-medium break-words"> <dd className="m-0 text-sm text-strong font-medium break-words">
{formatValue(value, key)} {formatValue(value)}
</dd> </dd>
</div> </div>
); );
})} })}
</dl> </dl>
</Card> </Card>
);
})}
</div> </div>
); );
} }

View File

@ -0,0 +1,75 @@
import { Pencil, Trash2 } from 'lucide-react';
import type { FormScreenField } from '../../api/types';
export interface GridTableProps {
data: Record<string, unknown>[];
columns: FormScreenField[];
onEdit?: (idx: number) => void;
onDelete?: (idx: number) => void;
}
export function GridTable({ data, columns, onEdit, onDelete }: GridTableProps) {
return (
<div className="w-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-x-auto">
<table className="w-full text-sm text-left whitespace-nowrap">
<thead className="bg-white border-b border-slate-100 text-[11px] font-bold text-slate-500 uppercase tracking-wider">
<tr>
{columns.map((col, idx) => (
<th key={col.id || idx} className={`px-6 py-4 ${col.data_type === 'number' ? 'text-center' : ''}`}>{col.name}</th>
))}
{(onEdit || onDelete) && (
<th className="px-6 py-4 text-center w-24">Actions</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.map((row, rowIdx) => (
<tr key={rowIdx} className="hover:bg-slate-50 transition-colors bg-white group">
{columns.map((col, colIdx) => {
const val = row[col.id];
let displayVal = String(val ?? '-');
if (col.data_type === 'select' || col.data_type === 'multiselect') {
const opt = col.properties?.options?.find((o: any) => String(o.value) === String(val));
if (opt) displayVal = opt.label;
}
const isNumeric = col.data_type === 'number';
return (
<td key={col.id || colIdx} className={`px-6 py-4 font-bold text-slate-800 ${isNumeric ? 'text-center text-[15px]' : 'text-[14.5px]'}`}>
{displayVal}
</td>
);
})}
{(onEdit || onDelete) && (
<td className="px-6 py-4">
<div className="flex items-center justify-center gap-3 opacity-80 group-hover:opacity-100 transition-opacity">
{onEdit && (
<button
type="button"
onClick={() => onEdit(rowIdx)}
className="p-2 text-blue-500 hover:text-blue-600 hover:bg-blue-50 rounded border border-blue-100 transition-colors shadow-sm"
title="Edit"
>
<Pencil size={15} />
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => onDelete(rowIdx)}
className="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 rounded border border-red-100 transition-colors shadow-sm"
title="Delete"
>
<Trash2 size={15} />
</button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -1,21 +1,248 @@
import { useEffect, useState } from 'react';
import { orderBookingClient } from '../../api/clients'; import { orderBookingClient } from '../../api/clients';
import { ORDER_BOOKING } from '../../api/config'; import { ORDER_BOOKING } from '../../api/config';
import { DetailView } from './DetailView'; import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
import { formatValue } from '../../lib/format';
import { ShoppingCart, Store, User, Package, Clock, ClipboardList } from 'lucide-react';
import { GridTable } from './GridTable';
export interface WiredDetailViewProps { export interface WiredDetailViewProps {
instanceId: number | string; instanceId: number | string;
columns?: 1 | 2 | 3; columns?: 1 | 2 | 3;
} }
/** Order detail view (Order Booking workflow). */ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
export function OrderDetail({ instanceId, columns }: WiredDetailViewProps) { const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await orderBookingClient.detailView(ORDER_BOOKING.detailViews.ORDERS, instanceId);
if (live) setData(r.data || {});
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
if (live) setLoading(false);
}
}
run();
return () => { live = false; };
}, [instanceId]);
if (error) {
return (
<Card title="Order Details">
<EmptyState title="Couldnt load record" hint={error} />
</Card>
);
}
if (loading) {
return (
<Card title="Order Details">
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
</Card>
);
}
if (!data || Object.keys(data).length === 0) {
return (
<Card title="Order Details">
<EmptyState title="No details found" />
</Card>
);
}
const remainingData = { ...data };
const extract = (key: string, obj: any = data) => {
if (obj && key in obj) {
const val = obj[key];
delete obj[key];
return val;
}
return '-';
};
// State
const stateName = extract('current_state_name', remainingData);
delete remainingData.current_state_id;
const isSuccess = ['ordered', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s));
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500';
// Order Details
const orderId = extract('order_id', remainingData);
const dateOfOrder = extract('date_of_order_3', remainingData);
// SO Info
const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'so_name';
const soRaw = extract(soKey, remainingData);
let soName = '-';
let soEmail = '-';
if (soRaw && typeof soRaw === 'object') {
soName = (soRaw as any).name || '-';
soEmail = (soRaw as any).email || '-';
}
// Store Info
const storeRaw = extract('select_store', remainingData);
let storeName = '-';
let distributorName = '-';
let routeName = '-';
let routeCode = '-';
if (storeRaw && typeof storeRaw === 'object') {
storeName = (storeRaw as any).business_name || '-';
distributorName = (storeRaw as any).distributor_name || '-';
routeName = (storeRaw as any).route_name || '-';
routeCode = (storeRaw as any).route_code || '-';
}
// Line Items
const orderDetailsGrid = extract('order_details_3', remainingData);
// Totals
const totalBags = extract('total_bags_3', remainingData);
const totalKgs = extract('total_kgs_3', remainingData);
// Meta
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
// Helper Row
const Row = ({ label, value, highlight = false }: { label: string, value: any, highlight?: boolean }) => {
let finalValue = value;
if (finalValue && typeof finalValue === 'object') {
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
}
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
<span className={`text-sm ${highlight ? 'font-black text-[var(--z-bg-primary)] text-base' : 'font-bold text-slate-800'} sm:w-2/3`}>
{formatValue(finalValue)}
</span>
</div>
);
};
return ( return (
<DetailView <div className="flex flex-col gap-6">
client={orderBookingClient}
dvUid={ORDER_BOOKING.detailViews.ORDERS} {/* 1. Status Card */}
instanceId={instanceId} <Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
title="Order" <div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
columns={columns} <div>
/> <div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
{stateName}
</div>
</div>
</div>
</Card>
{/* 2. Order Summary Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<ShoppingCart className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Summary</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Order ID" value={orderId} />
<Row label="Date of Order" value={dateOfOrder} />
</div>
</Card>
{/* 3. Store details Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Store className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Business Name" value={storeName} />
<Row label="Distributor" value={distributorName} />
<Row label="Route" value={`${routeName} (${routeCode})`} />
</div>
</Card>
{/* 4. Sales Officer Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<User className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Name" value={soName} />
<Row label="Email" value={soEmail} />
</div>
</Card>
{/* 5. Line Items Grid */}
{Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && (
<Card pad={false} className="shadow-md overflow-hidden">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Package className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Line Items</h3>
</div>
<div className="p-0 sm:p-5">
<GridTable
data={orderDetailsGrid}
columns={[
{ id: 'product_category_3', uid: '', name: 'Product Category', data_type: 'text' },
{ id: 'product_name_3', uid: '', name: 'Product Name', data_type: 'text' },
{ id: 'sku_3', uid: '', name: 'SKU', data_type: 'text' },
{ id: 'sku_code_3', uid: '', name: 'SKU Code', data_type: 'text' },
{ id: 'br_code_3', uid: '', name: 'BR Code', data_type: 'text' },
{ id: 'bags_3', uid: '', name: 'Bags', data_type: 'number' }
]}
/>
</div>
</Card>
)}
{/* 6. Order Totals */}
<Card pad={false} className="shadow-md bg-blue-50/30">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-blue-100/30">
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Totals</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Total Bags" value={totalBags} highlight />
<Row label="Total KGs" value={totalKgs} highlight />
</div>
</Card>
{/* 7. Log Visit Details Fallback & Meta */}
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0 || createdAt !== '-') && (
<Card pad={false} className="shadow-md mt-6">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Clock className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Created At" value={createdAt} />
{Object.entries(remainingData).map(([key, value]) => {
const lowerKey = key.toLowerCase();
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
const label = key.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
.replace(/^\s+|\s+$/g, '');
if (!label) return null;
return <Row key={key} label={label} value={value} />;
})}
</div>
</Card>
)}
</div>
); );
} }

View File

@ -0,0 +1,136 @@
import { ArrowUpRight, ArrowDownRight, Pencil, Trash2 } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface MiningItem {
id: string;
productName: string;
badge?: {
label: string;
type: 'error' | 'success' | 'info' | 'warning' | 'default';
};
status?: string;
potentialKgs: number;
orderedKgs: number;
}
export interface PotentialMiningTableProps {
data: MiningItem[];
onEdit?: (item: MiningItem) => void;
onDelete?: (item: MiningItem) => void;
hideOrderDetails?: boolean;
}
export function PotentialMiningTable({ data, onEdit, onDelete, hideOrderDetails }: PotentialMiningTableProps) {
return (
<div className="w-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-x-auto">
<table className="w-full text-sm text-left whitespace-nowrap">
<thead className="bg-white border-b border-slate-100 text-[11px] font-bold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Product</th>
<th className="px-6 py-4 text-center">Potential (Kgs)</th>
{!hideOrderDetails && (
<>
<th className="px-6 py-4 text-center">Ordered (Kgs)</th>
<th className="px-6 py-4 text-center">Difference</th>
</>
)}
{(onEdit || onDelete) && (
<th className="px-6 py-4 text-center">Actions</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.map((item) => {
const difference = item.orderedKgs - item.potentialKgs;
const isSurplus = difference >= 0;
const absDiff = Math.abs(difference);
return (
<tr key={item.id} className="hover:bg-slate-50 transition-colors bg-white group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="font-bold text-slate-800 text-sm">{item.productName}</span>
{item.badge && (
<span className={cn(
"px-2 py-0.5 rounded text-[11px] font-semibold whitespace-nowrap",
item.badge.type === 'error' && "bg-red-50 text-red-500",
item.badge.type === 'success' && "bg-emerald-50 text-emerald-600",
item.badge.type === 'info' && "bg-blue-50 text-blue-500",
item.badge.type === 'warning' && "bg-amber-50 text-amber-600",
item.badge.type === 'default' && "bg-slate-100 text-slate-600"
)}>
{item.badge.label}
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-center text-slate-700 font-medium text-base">
{item.potentialKgs}
</td>
{!hideOrderDetails && (
<>
<td className="px-6 py-4 text-center text-slate-700 font-medium text-base">
{item.orderedKgs}
</td>
<td className="px-6 py-4">
<div className="flex justify-center">
<div className={cn(
"flex flex-col items-center justify-center min-w-[110px] px-3 py-1.5 rounded-md border",
isSurplus ? "bg-emerald-50/50 border-emerald-100" : "bg-red-50/50 border-red-100"
)}>
<div className={cn(
"flex items-center gap-1 font-bold text-sm",
isSurplus ? "text-emerald-700" : "text-red-600"
)}>
{isSurplus ? (
<ArrowUpRight size={16} strokeWidth={2.5} />
) : (
<ArrowDownRight size={16} strokeWidth={2.5} />
)}
<span>{absDiff} Kgs</span>
</div>
<span className={cn(
"text-[11px] font-medium mt-0.5",
isSurplus ? "text-emerald-600/80" : "text-red-500/80"
)}>
{isSurplus ? 'Surplus' : 'Shortfall'}
</span>
</div>
</div>
</td>
</>
)}
{(onEdit || onDelete) && (
<td className="px-6 py-4">
<div className="flex items-center justify-center gap-3 opacity-80 group-hover:opacity-100 transition-opacity">
{onEdit && (
<button
type="button"
onClick={() => onEdit(item)}
className="p-2 text-blue-500 hover:text-blue-600 hover:bg-blue-50 rounded border border-blue-100 transition-colors shadow-sm"
title="Edit"
>
<Pencil size={15} />
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => onDelete(item)}
className="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 rounded border border-red-100 transition-colors shadow-sm"
title="Delete"
>
<Trash2 size={15} />
</button>
)}
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -1,17 +1,319 @@
import { useEffect, useState } from 'react';
import { storeClient } from '../../api/clients'; import { storeClient } from '../../api/clients';
import { STORE } from '../../api/config'; import { STORE, APP_ID } from '../../api/config';
import { DetailView } from './DetailView'; import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
import { formatValue } from '../../lib/format';
import { Store, User, Truck, MapPin, TrendingUp, Camera, ClipboardList } from 'lucide-react';
import { GridTable } from './GridTable';
import type { WiredDetailViewProps } from './OrderDetail'; import type { WiredDetailViewProps } from './OrderDetail';
/** Store detail view (Store workflow). */ export function StoreDetail({ instanceId }: WiredDetailViewProps) {
export function StoreDetail({ instanceId, columns }: WiredDetailViewProps) { const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await storeClient.detailView(STORE.detailViews.STORE, instanceId);
if (live) setData(r.data || {});
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
if (live) setLoading(false);
}
}
run();
return () => { live = false; };
}, [instanceId]);
if (error) {
return (
<Card title="Store Details">
<EmptyState title="Couldnt load record" hint={error} />
</Card>
);
}
if (loading) {
return (
<Card title="Store Details">
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
</Card>
);
}
if (!data || Object.keys(data).length === 0) {
return (
<Card title="Store Details">
<EmptyState title="No details found" />
</Card>
);
}
const remainingData = { ...data };
const extract = (key: string, obj: any = data) => {
if (obj && key in obj) {
const val = obj[key];
delete obj[key];
return val;
}
return '-';
};
// State
const stateName = extract('current_state_name', remainingData);
delete remainingData.current_state_id;
const isSuccess = ['created', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s));
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500';
// Store Overview
const storeCode = extract('store_code', remainingData);
const businessName = extract('business_name', remainingData);
const area = extract('area', remainingData);
const completeAddress = extract('complete_address', remainingData);
const pinCode = extract('pin_code', remainingData);
const notes = extract('notes', remainingData);
// Owner Info
const ownerName = extract('owner_name', remainingData);
const email = extract('email', remainingData);
let phoneNumber = extract('phone_number', remainingData);
if (typeof phoneNumber === 'object' && phoneNumber !== null) {
phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-';
}
// Distributor Info
const distributorName = extract('distributor_name', remainingData);
const distributorOwnerName = extract('distributor_owner_name', remainingData);
const distributorEmail = extract('distributor_email', remainingData);
let distributorPhone = extract('distributor_phone_number', remainingData);
if (typeof distributorPhone === 'object' && distributorPhone !== null) {
distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-';
}
// Route Info
const routeCode = extract('route_code', remainingData);
const routeName = extract('route_name', remainingData);
const subRoute = extract('sub_route', remainingData);
// Location
const storeLocation = extract('store_location', remainingData);
let lat = null;
let lng = null;
if (storeLocation) {
let locObj = storeLocation;
if (typeof locObj === 'string') {
try { locObj = JSON.parse(locObj); } catch (e) {}
}
if (locObj && typeof locObj === 'object') {
lat = (locObj as any).latitude;
lng = (locObj as any).longitude;
}
}
// Potential
const potential = extract('potential', remainingData);
// Image
const storeImage = extract('store_image', remainingData);
// Meta
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id'));
const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-';
// Helper Row
const Row = ({ label, value }: { label: string, value: any }) => {
let finalValue = value;
if (finalValue && typeof finalValue === 'object') {
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
}
return (
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span>
</div>
);
};
return ( return (
<DetailView <div className="flex flex-col gap-6">
client={storeClient}
dvUid={STORE.detailViews.STORE} {/* 1. Status Card */}
instanceId={instanceId} <Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
title="Store" <div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
columns={columns} <div>
/> <div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
{stateName}
</div>
</div>
</div>
</Card>
{/* 2. Store Overview Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Store className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Overview</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Store Code" value={storeCode} />
<Row label="Business Name" value={businessName} />
<Row label="Area" value={area} />
<Row label="Complete Address" value={completeAddress} />
<Row label="PIN Code" value={pinCode} />
<Row label="Notes" value={notes} />
</div>
</Card>
{/* 3. Owner Information Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<User className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Owner Information</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Owner Name" value={ownerName} />
<Row label="Phone Number" value={phoneNumber} />
<Row label="Email" value={email} />
</div>
</Card>
{/* 4. Distributor Details Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Truck className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Distributor Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Distributor Name" value={distributorName} />
<Row label="Distributor Owner Name" value={distributorOwnerName} />
<Row label="Phone Number" value={distributorPhone} />
<Row label="Email" value={distributorEmail} />
</div>
</Card>
{/* 5. Route Details Card */}
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<MapPin className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Route Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Route Name" value={routeName} />
<Row label="Route Code" value={routeCode} />
<Row label="Sub Route" value={subRoute} />
</div>
</Card>
{/* 6. Potential Card */}
{Array.isArray(potential) && potential.length > 0 && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<TrendingUp className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Potential</h3>
</div>
<div className="p-0 sm:p-5">
<GridTable
data={potential}
columns={[
{ id: 'product_category', uid: '', name: 'Product Category', data_type: 'text' },
{ id: 'quantity', uid: '', name: 'Quantity (Kgs)', data_type: 'number' }
]}
/>
</div>
</Card>
)}
{/* 7. Store Image Card */}
{Array.isArray(storeImage) && storeImage.length > 0 && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<Camera className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3>
</div>
<div className="p-5">
<div className="flex flex-wrap gap-4">
{storeImage.map((file: any, idx: number) => {
const previewUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
return (
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
<img
src={previewUrl}
alt={file.original_name || 'Store'}
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
}}
/>
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
</div>
);
})}
</div>
</div>
</Card>
)}
{/* 7.5 Store Location Map */}
{lat && lng && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<MapPin className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Location</h3>
</div>
<div className="p-0">
<iframe
title="Store Location Map"
width="100%"
height="350"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
src={`https://maps.google.com/maps?q=${lat},${lng}&hl=en&z=15&output=embed`}
></iframe>
</div>
</Card>
)}
{/* 8. Log Visit Details Fallback & Meta */}
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k)).length > 0 || userName !== '-') && (
<Card pad={false} className="shadow-md">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
</div>
<div className="p-5 flex flex-col">
<Row label="Created By" value={userName} />
<Row label="Created At" value={createdAt} />
{Object.entries(remainingData).map(([key, value]) => {
const lowerKey = key.toLowerCase();
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
const label = key.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
.replace(/^\s+|\s+$/g, '');
if (!label) return null;
return <Row key={key} label={label} value={value} />;
})}
</div>
</Card>
)}
</div>
); );
} }

View File

@ -5,3 +5,7 @@ export type { WiredDetailViewProps } from './OrderDetail';
export { CallDetail } from './CallDetail'; export { CallDetail } from './CallDetail';
export { StoreDetail } from './StoreDetail'; export { StoreDetail } from './StoreDetail';
export { DailyLogDetail } from './DailyLogDetail'; export { DailyLogDetail } from './DailyLogDetail';
export { PotentialMiningTable } from './PotentialMiningTable';
export { GridTable } from './GridTable';
export type { GridTableProps } from './GridTable';
export type { MiningItem, PotentialMiningTableProps } from './PotentialMiningTable';

View File

@ -191,12 +191,12 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) { } else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
const uploadedFiles = []; const uploadedFiles = [];
for (const file of val) { for (const file of val) {
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id }); const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
uploadedFiles.push(fileMeta); uploadedFiles.push(fileMeta);
} }
payload[f.id] = uploadedFiles; payload[f.id] = uploadedFiles;
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) { } else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id }); const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
payload[f.id] = [fileMeta]; payload[f.id] = [fileMeta];
} else { } else {
payload[f.id] = val; payload[f.id] = val;

View File

@ -2,8 +2,10 @@ import { useState, useRef } from 'react';
import { Button } from '../../buttons/Button'; import { Button } from '../../buttons/Button';
import { Select } from '../../reusable/Select'; import { Select } from '../../reusable/Select';
import { Input } from '../../reusable/Input'; import { Input } from '../../reusable/Input';
import { Trash2, Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import type { FormScreenField } from '../../../api/types'; import type { FormScreenField } from '../../../api/types';
import { PotentialMiningTable } from '../../dv/PotentialMiningTable';
import { GridTable } from '../../dv/GridTable';
export function SmartGridField({ export function SmartGridField({
label, label,
@ -43,6 +45,9 @@ export function SmartGridField({
setEditingIdx(idx); setEditingIdx(idx);
setNewRow({ ...value[idx] }); setNewRow({ ...value[idx] });
setIsModalOpen(true); setIsModalOpen(true);
setTimeout(() => {
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 150);
}; };
const startAdd = () => { const startAdd = () => {
@ -101,6 +106,18 @@ export function SmartGridField({
row['row_kgs'] = skuVal * bagsVal; row['row_kgs'] = skuVal * bagsVal;
} }
// Auto-calculate difference for potential mining
const potentialCol = columns.find(c => c.id.toLowerCase().includes('potential') || c.name.toLowerCase().includes('potential'));
const totalOrderedCol = columns.find(c => c.id.toLowerCase().includes('total_ordered') || c.name.toLowerCase().includes('total ordered'));
if (potentialCol && totalOrderedCol) {
const p = Number(row[potentialCol.id]) || 0;
const t = Number(row[totalOrderedCol.id]) || 0;
const diffCol = columns.find(c => c.id.toLowerCase().includes('difference') || c.name.toLowerCase().includes('difference'));
if (diffCol) {
row[diffCol.id] = t - p;
}
}
setNewRow(row); setNewRow(row);
}; };
@ -152,82 +169,59 @@ export function SmartGridField({
setEditingIdx(null); setEditingIdx(null);
}; };
const isPotentialGrid = label.toLowerCase().includes('potential');
const hasOrderedColumn = columns.some(c => c.id.toLowerCase().includes('ordered') || (c.name || '').toLowerCase().includes('ordered'));
return ( return (
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50"> <div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
<span className="text-sm font-semibold text-strong mb-2">{label}</span> <span className="text-sm font-semibold text-strong mb-2">{label}</span>
{value.length === 0 ? ( {value.length === 0 ? (
<span className="text-sm text-faint italic">No rows added.</span> <span className="text-sm text-faint italic">No rows added.</span>
) : ( ) : isPotentialGrid ? (
<div className="flex flex-col gap-3"> <PotentialMiningTable
{value.map((row, i) => { hideOrderDetails={!hasOrderedColumn}
let productName = 'Unknown Product'; data={value.map((row, i) => {
let bags = '0'; const reason = String(row.reason || row.Reason || row.potential_remarks || '');
let badgeType: 'error' | 'success' | 'info' | 'warning' | 'default' = 'default';
const lowerReason = reason.toLowerCase();
if (lowerReason.includes('stock')) badgeType = 'success';
else if (lowerReason.includes('quality')) badgeType = 'error';
else if (lowerReason.includes('price')) badgeType = 'warning';
else if (lowerReason.includes('competitive') || lowerReason.includes('loyalty')) badgeType = 'info';
let productName = 'Unknown Product';
visibleColumns.forEach(col => { visibleColumns.forEach(col => {
const isName = col.id.toLowerCase().includes('name') || col.name.toLowerCase().includes('name') || (col.id.toLowerCase().includes('category') && productName === 'Unknown Product'); const isName = col.id.toLowerCase().includes('name') || col.name.toLowerCase().includes('name') || (col.id.toLowerCase().includes('category') && productName === 'Unknown Product');
const isBags = col.id.toLowerCase().includes('bags') || col.name.toLowerCase().includes('bags') || col.id.toLowerCase().includes('quantity'); if (isName) {
if (isName || isBags) {
const val = row[col.id]; const val = row[col.id];
let displayVal = String(val ?? '-'); let displayVal = String(val ?? '-');
if (col.data_type === 'select' || col.data_type === 'multiselect') { if (col.data_type === 'select' || col.data_type === 'multiselect') {
const opt = col.properties?.options?.find(o => String(o.value) === String(val)); const opt = col.properties?.options?.find(o => String(o.value) === String(val));
if (opt) displayVal = opt.label; if (opt) displayVal = opt.label;
} }
productName = displayVal;
if (isName && (productName === 'Unknown Product' || col.id.toLowerCase().includes('name'))) {
productName = displayVal;
}
if (isBags) bags = displayVal;
} }
}); });
return ( return {
<div key={i} className="bg-slate-50 border border-slate-200 rounded-xl p-3 shadow-sm flex items-center justify-between"> id: String(i),
<div className="flex flex-col min-w-0 pr-4 w-full"> productName,
<span className="font-bold text-slate-800 text-[14px] truncate">{productName}</span> badge: reason ? { label: reason, type: badgeType } : undefined,
{label.toLowerCase().includes('potential') ? ( potentialKgs: Number(row.actual_potential ?? row.Actual_Potential ?? row.store_potential ?? row.Store_Potential ?? row.quantity ?? row.Quantity ?? 0),
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1.5 text-[12px]"> orderedKgs: Number(row.total_ordered ?? row.Total_Ordered ?? 0),
<div className="flex justify-between"> };
<span className="text-slate-500">Total Ordered:</span>
<span className="font-bold text-slate-700">{String(row.total_ordered ?? row.Total_Ordered ?? '-')}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Potential:</span>
<span className="font-bold text-slate-700">{String(row.actual_potential ?? row.Actual_Potential ?? row.store_potential ?? row.Store_Potential ?? '-')}</span>
</div>
<div className="flex justify-between col-span-2 border-t border-slate-100 pt-1 mt-0.5">
<span className="text-slate-500">Difference:</span>
{(() => {
const diff = Number(row.difference ?? row.Difference);
if (isNaN(diff)) return <span className="font-bold text-slate-700">-</span>;
return (
<span className={`font-bold ${diff < 0 ? 'text-red-600' : 'text-green-600'}`}>
{diff < 0 ? `${Math.abs(diff)} Kgs Less Ordered` : `${diff} Kgs More Ordered`}
</span>
);
})()}
</div>
</div>
) : (
<span className="font-extrabold text-indigo-600 text-[13px] mt-0.5">{bags} Bags</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0 pl-2">
<button type="button" onClick={() => startEdit(i)} className="text-indigo-600 bg-white border border-indigo-100 hover:bg-indigo-50 p-1.5 rounded-lg transition-colors flex items-center justify-center shadow-sm" title="Edit">
<Pencil size={14} />
</button>
<button type="button" onClick={() => removeRow(i)} className="text-red-600 bg-white border border-red-100 hover:bg-red-50 p-1.5 rounded-lg transition-colors flex items-center justify-center shadow-sm" title="Remove">
<Trash2 size={14} />
</button>
</div>
</div>
);
})} })}
</div> onEdit={(item) => startEdit(Number(item.id))}
onDelete={(item) => removeRow(Number(item.id))}
/>
) : (
<GridTable
data={value}
columns={visibleColumns}
onEdit={startEdit}
onDelete={removeRow}
/>
)} )}
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">

View File

@ -6,12 +6,14 @@ import {
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer ResponsiveContainer,
Legend
} from 'recharts'; } from 'recharts';
export interface ChartRow { export interface ChartRow {
dimension: string; dimension: string;
value: number; value: number;
series?: string;
} }
export interface ChartConfig { export interface ChartConfig {
@ -37,19 +39,42 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
.replace(/_/g, ' ') .replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase()); .replace(/\b\w/g, c => c.toUpperCase());
// Safely format dimension string (fallbacks for empty strings) const hasSeries = chart.rows?.some(r => r.series);
const formattedRows = (chart.rows || []).map(r => ({ let finalData: any[] = [];
...r, let seriesKeys: string[] = [];
dimension: String(r.dimension || '').trim() || 'Unknown'
}));
if (formattedRows.length === 0) return null; if (hasSeries) {
const grouped = new Map<string, any>();
const sKeys = new Set<string>();
chart.rows?.forEach(r => {
const dim = String(r.dimension || '').trim() || 'Unknown';
const s = String(r.series || '').trim() || 'Unknown';
sKeys.add(s);
if (!grouped.has(dim)) {
grouped.set(dim, { dimension: dim });
}
const entry = grouped.get(dim);
entry[s] = Number(r.value || 0);
});
finalData = Array.from(grouped.values());
seriesKeys = Array.from(sKeys).sort(); // Sort series keys for consistency
} else {
finalData = (chart.rows || []).map(r => ({
...r,
dimension: String(r.dimension || '').trim() || 'Unknown',
value: Number(r.value || 0)
}));
seriesKeys = ['value'];
}
if (finalData.length === 0) return null;
return ( return (
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm border-t-4 border-t-indigo-500"> <Card key={chart.chart_uid || idx} title={title} className="shadow-sm border-t-4 border-t-indigo-500">
<div className="h-[320px] w-full mt-4"> <div className="h-[320px] w-full mt-4">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={formattedRows} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}> <BarChart data={finalData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" />
<XAxis <XAxis
dataKey="dimension" dataKey="dimension"
@ -69,13 +94,17 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }} contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }} labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
/> />
<Bar {hasSeries && <Legend wrapperStyle={{ paddingTop: '20px' }} />}
dataKey="value" {seriesKeys.map((key, i) => (
name="Value" <Bar
fill={colors[idx % colors.length]} key={key}
radius={[4, 4, 0, 0]} dataKey={key}
maxBarSize={40} name={hasSeries ? key : "Value"}
/> fill={colors[i % colors.length]}
radius={[4, 4, 0, 0]}
maxBarSize={40}
/>
))}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@ -40,15 +40,15 @@ export function Card({
return ( return (
<section <section
className={cn( className={cn(
'bg-card rounded-lg border border-border-subtle overflow-hidden font-sans', 'bg-[var(--z-block-bg)] rounded-lg border border-[var(--z-block-border)] overflow-hidden font-sans',
SHADOW[elevation], SHADOW[elevation],
className, className,
)} )}
style={style} style={style}
> >
{title && ( {title && (
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle"> <header className="flex items-center justify-between px-5 py-4 border-b border-[var(--z-block-border)]">
<h3 className="m-0 text-md font-semibold text-strong">{title}</h3> <h3 className="m-0 text-md font-semibold text-[var(--z-text-default)]">{title}</h3>
{action} {action}
</header> </header>
)} )}

View File

@ -5,12 +5,6 @@ import { cn } from '../../lib/cn';
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl'; export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
const WIDTH: Record<ModalWidth, string> = {
sm: 'max-w-[480px]',
md: 'max-w-[640px]',
lg: 'max-w-[820px]',
xl: 'max-w-[1000px]',
};
export interface ModalProps { export interface ModalProps {
open: boolean; open: boolean;
@ -23,6 +17,13 @@ export interface ModalProps {
children?: ReactNode; children?: ReactNode;
} }
const WIDTH_CLASSES: Record<ModalWidth, string> = {
sm: 'sm:w-[30vw]',
md: 'sm:w-[50vw]',
lg: 'sm:w-[70vw]',
xl: 'sm:w-[90vw]',
};
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */ /** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) { export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
useEffect(() => { useEffect(() => {
@ -43,21 +44,30 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-[10000] flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8" className="fixed inset-0 z-[10000] flex items-start justify-end overflow-hidden bg-black/10 transition-opacity"
onMouseDown={(e) => { onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<style>{`
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.animate-slide-in-right {
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
`}</style>
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className={cn( className={cn(
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto', 'w-full bg-[var(--z-block-bg)] shadow-[auto_0_30px_rgba(0,0,0,0.1)] my-0',
'flex flex-col max-h-[85vh]', WIDTH_CLASSES[width],
WIDTH[width], 'flex flex-col h-[100dvh] rounded-l-2xl rounded-r-none animate-slide-in-right border-l border-[var(--z-border-default)]',
)} )}
> >
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle"> <header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--z-border-default)]">
<div className="min-w-0"> <div className="min-w-0">
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>} {title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>} {subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}

View File

@ -1,5 +1,6 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '../buttons/Button'; import { Button } from '../buttons/Button';
import { useState, useEffect } from 'react';
export interface PaginationProps { export interface PaginationProps {
/** 1-based current page. */ /** 1-based current page. */
@ -8,33 +9,83 @@ export interface PaginationProps {
/** Total rows across all pages. */ /** Total rows across all pages. */
total: number; total: number;
onPage: (page: number) => void; onPage: (page: number) => void;
onPageSizeChange?: (size: number) => void;
} }
/** Pager: "Showing ab of N" + Prev/Next. Renders nothing when the set fits on /** Pager: "Showing ab of N" + Rows per page + Jump to + Prev/Next.
* one page. Shared by the record-view tables. */ * Shared by the record-view tables. */
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) { export function Pagination({ page, pageSize, total, onPage, onPageSizeChange }: PaginationProps) {
const [jumpPage, setJumpPage] = useState(String(page));
useEffect(() => {
setJumpPage(String(page));
}, [page]);
if (total <= 0) return null; if (total <= 0) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages); const safePage = Math.min(Math.max(page, 1), totalPages);
const start = (safePage - 1) * pageSize; const start = (safePage - 1) * pageSize;
const handleJump = () => {
const p = parseInt(jumpPage, 10);
if (!isNaN(p) && p >= 1 && p <= totalPages) {
onPage(p);
} else {
setJumpPage(String(safePage));
}
};
return ( return (
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border-subtle px-[18px] py-3">
<span className="text-xs text-faint"> <div className="flex flex-wrap items-center gap-4">
Showing {start + 1}{Math.min(start + pageSize, total)} of {total} <span className="text-xs text-faint whitespace-nowrap">
</span> Showing {start + 1}{Math.min(start + pageSize, total)} of {total}
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
<ChevronLeft size={15} />
Prev
</Button>
<span className="px-1 text-xs font-medium text-muted nums">
{safePage} / {totalPages}
</span> </span>
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}> {onPageSizeChange && (
Next <div className="flex items-center gap-2">
<ChevronRight size={15} /> <span className="text-xs text-muted">Rows per page:</span>
</Button> <select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-7 rounded border border-border-default bg-card px-1 py-0 text-xs text-strong outline-none focus-ring cursor-pointer"
>
{[10, 25, 50, 100].map(sz => (
<option key={sz} value={sz}>{sz}</option>
))}
</select>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Jump to:</span>
<input
type="number"
min={1}
max={totalPages}
value={jumpPage}
onChange={e => setJumpPage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleJump()}
onBlur={handleJump}
className="w-14 h-7 rounded border border-border-default bg-card px-2 py-1 text-xs text-strong text-center outline-none focus-ring"
style={{ MozAppearance: 'textfield' }} // best effort hide spinner on FF
/>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
<ChevronLeft size={15} />
Prev
</Button>
<span className="px-1 text-xs font-medium text-muted nums">
{safePage} / {totalPages}
</span>
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}>
Next
<ChevronRight size={15} />
</Button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,19 +1,10 @@
import { Card } from "./Card"; import { Phone, TrendingUp, Activity, BarChart2, ShoppingCart, ShoppingBag, Scale } from 'lucide-react';
import type { TileItem } from "../../api/types"; import type { TileItem } from "../../api/types";
export interface StatsTilesProps { export interface StatsTilesProps {
tiles?: TileItem[]; tiles?: TileItem[];
} }
const colors = [
"from-blue-500 to-cyan-500",
"from-emerald-500 to-green-500",
"from-orange-500 to-amber-500",
"from-violet-500 to-fuchsia-500",
"from-pink-500 to-rose-500",
"from-indigo-500 to-blue-500",
];
export function StatsTiles({ tiles }: StatsTilesProps) { export function StatsTiles({ tiles }: StatsTilesProps) {
if (!tiles?.length) return null; if (!tiles?.length) return null;
@ -24,35 +15,41 @@ export function StatsTiles({ tiles }: StatsTilesProps) {
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()); .replace(/\b\w/g, (c) => c.toUpperCase());
const gradient = colors[idx % colors.length]; const lowerKey = String(tile.key).toLowerCase();
let Icon = BarChart2;
if (lowerKey.includes('call') || lowerKey.includes('total')) Icon = Phone;
if (lowerKey.includes('productive')) Icon = TrendingUp;
if (lowerKey.includes('order') || lowerKey.includes('cart')) Icon = ShoppingCart;
if (lowerKey.includes('bag')) Icon = ShoppingBag;
if (lowerKey.includes('kg') || lowerKey.includes('weight')) Icon = Scale;
if (lowerKey.includes('active')) Icon = Activity;
const isDanger = lowerKey.includes('no_order');
return ( return (
<Card <div
key={tile.tile_uid || idx} key={tile.tile_uid || idx}
className="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" className={`flex flex-col justify-between w-full h-[100px] p-4 rounded-lg bg-[var(--z-bg-neutral-100)] border border-[var(--z-border-neutral-300)] border-t-4 shadow-sm transition-transform duration-200 hover:-translate-y-1 ${
isDanger ? 'border-t-[var(--z-text-danger-400)]' : 'border-t-[var(--z-text-primary)]'
}`}
> >
{/* Top Gradient */} <div className="flex justify-between items-center">
<div <span className={`text-[10px] font-bold uppercase tracking-wider whitespace-nowrap overflow-hidden text-ellipsis max-w-[80%] ${
className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`} isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-neutral-500)]'
/> }`}>
{/* Background Decoration */}
<div
className={`absolute -right-6 -top-6 h-24 w-24 rounded-full bg-gradient-to-br ${gradient} opacity-10 transition-all duration-300 group-hover:scale-125`}
/>
<div className="relative flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-gray-500">
{displayLabel} {displayLabel}
</p> </span>
<Icon size={16} className={`${
<h2 className="text-4xl font-bold tracking-tight text-gray-900"> isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-success-400)]'
{(tile.value as React.ReactNode) ?? "-"} }`} />
</h2>
<div className="h-1 w-12 rounded-full bg-gray-200 transition-all duration-300 group-hover:w-20 group-hover:bg-blue-500" />
</div> </div>
</Card>
<p className={`text-[30px] font-extrabold leading-tight tabular-nums ${
isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-primary)]'
}`}>
{(tile.value as React.ReactNode) ?? "-"}
</p>
</div>
); );
})} })}
</div> </div>

View File

@ -4,7 +4,7 @@ import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView'; import type { WiredRecordViewProps } from './OrdersView';
/** Daily Logs record view (Daily Reports workflow). */ /** Daily Logs record view (Daily Reports workflow). */
export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) { export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, presetAlias }: WiredRecordViewProps) {
return ( return (
<RecordView <RecordView
client={dailyReportsClient} client={dailyReportsClient}
@ -15,6 +15,7 @@ export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions,
headerActions={headerActions} headerActions={headerActions}
rowActions={rowActions} rowActions={rowActions}
refreshKey={refreshKey} refreshKey={refreshKey}
presetAlias={presetAlias}
/> />
); );
} }

View File

@ -9,10 +9,11 @@ export interface WiredRecordViewProps {
rowActions?: (row: Record<string, unknown>) => React.ReactNode; rowActions?: (row: Record<string, unknown>) => React.ReactNode;
refreshKey?: number; refreshKey?: number;
initialFilters?: Record<string, string>; initialFilters?: Record<string, string>;
presetAlias?: string;
} }
/** Orders record view (Order Booking workflow). */ /** Orders record view (Order Booking workflow). */
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters }: WiredRecordViewProps) { export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters, presetAlias }: WiredRecordViewProps) {
return ( return (
<RecordView <RecordView
client={orderBookingClient} client={orderBookingClient}
@ -27,6 +28,7 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re
sortDir="desc" sortDir="desc"
omitColumns={['created_at', 'instance_id']} omitColumns={['created_at', 'instance_id']}
initialFilters={initialFilters} initialFilters={initialFilters}
presetAlias={presetAlias}
/> />
); );
} }

View File

@ -41,6 +41,8 @@ export interface RecordViewProps {
sortDir?: 'asc' | 'desc'; sortDir?: 'asc' | 'desc';
/** Default filters to apply initially. */ /** Default filters to apply initially. */
initialFilters?: Record<string, string>; initialFilters?: Record<string, string>;
/** Custom preset alias to send with the request (e.g. preset_alias: my_orders) */
presetAlias?: string;
} }
/** /**
@ -53,7 +55,7 @@ export function RecordView({
rvUid, rvUid,
title = 'Records', title = 'Records',
columns, columns,
pageSize = 50, pageSize = 25,
onRowClick, onRowClick,
rowKey, rowKey,
headerActions, headerActions,
@ -63,7 +65,9 @@ export function RecordView({
sortDir, sortDir,
omitColumns, omitColumns,
initialFilters = {}, initialFilters = {},
presetAlias,
}: RecordViewProps) { }: RecordViewProps) {
const [internalPageSize, setInternalPageSize] = useState(pageSize);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
@ -103,11 +107,12 @@ export function RecordView({
try { try {
const r = await client.recordView(rvUid, { const r = await client.recordView(rvUid, {
page, page,
limit: pageSize, limit: internalPageSize,
search: debounced, search: debounced,
filters: filtersParam, filters: filtersParam,
sortBy, sortBy,
sortDir sortDir,
presetAlias
}); });
console.log("RECORD VIEW RESP:", r); console.log("RECORD VIEW RESP:", r);
if (live) setResp(r); if (live) setResp(r);
@ -121,7 +126,7 @@ export function RecordView({
return () => { return () => {
live = false; live = false;
}; };
}, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]); }, [client, rvUid, page, internalPageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]);
const fields: RecordViewField[] = useMemo(() => { const fields: RecordViewField[] = useMemo(() => {
const all = resp?.config.fields ?? []; const all = resp?.config.fields ?? [];
@ -157,7 +162,7 @@ export function RecordView({
action={headerActions} action={headerActions}
pad={false} pad={false}
> >
<div className="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-border-subtle bg-slate-50/50"> <div className="flex flex-wrap items-center justify-between gap-4 p-4 border-b border-[var(--z-block-border)] bg-[var(--z-rv-table-header)]">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{(() => { {(() => {
if (!resp?.config) return null; if (!resp?.config) return null;
@ -232,7 +237,7 @@ export function RecordView({
<div className="overflow-x-auto scrollbar-slim"> <div className="overflow-x-auto scrollbar-slim">
<table className="w-full border-collapse min-w-[640px]"> <table className="w-full border-collapse min-w-[640px]">
<thead> <thead>
<tr className="bg-slate-50 border-b border-border-subtle"> <tr className="bg-[var(--z-rv-table-header)] border-b border-[var(--z-rv-table-border)]">
{fields.map((f) => ( {fields.map((f) => (
<th <th
key={f.field_key} key={f.field_key}
@ -254,15 +259,69 @@ export function RecordView({
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)} key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
onClick={onRowClick ? () => onRowClick(row, i) : undefined} onClick={onRowClick ? () => onRowClick(row, i) : undefined}
className={cn( className={cn(
'bg-surface border-b border-border-subtle transition-colors duration-150', 'bg-[var(--z-rv-table-bg)] border-b border-[var(--z-rv-table-border)] transition-colors duration-150',
onRowClick && 'cursor-pointer hover:bg-slate-50', onRowClick && 'cursor-pointer hover:bg-[var(--table-row-bg-select)]',
)} )}
> >
{fields.map((f) => ( {fields.map((f) => {
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap"> const lowerKey = f.field_key.toLowerCase();
{formatValue(row[f.field_key], f.field_key)} const isSku = lowerKey === 'sku_code';
</td> const isStatus = lowerKey.includes('status') || lowerKey.includes('state');
))} const valStr = formatValue(row[f.field_key], f.field_key);
let content = <>{valStr}</>;
if (valStr !== '—' && valStr !== '') {
if (isSku || isStatus) {
const palettes = [
"bg-blue-50 text-blue-700 border-blue-200",
"bg-purple-50 text-purple-700 border-purple-200",
"bg-pink-50 text-pink-700 border-pink-200",
"bg-indigo-50 text-indigo-700 border-indigo-200",
"bg-cyan-50 text-cyan-700 border-cyan-200",
"bg-rose-50 text-rose-700 border-rose-200",
"bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200",
"bg-teal-50 text-teal-700 border-teal-200",
];
let hash = 0;
for (let i = 0; i < valStr.length; i++) {
hash = valStr.charCodeAt(i) + ((hash << 5) - hash);
}
const colorIndex = Math.abs(hash) % palettes.length;
let colorClass = palettes[colorIndex];
if (isSku) {
content = (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[12px] font-mono font-medium border shadow-sm ${colorClass}`}>
{valStr}
</span>
);
} else if (isStatus) {
const valLower = valStr.toLowerCase();
const isSuccess = valLower.includes('approve') || valLower.includes('complete') || valLower.includes('success') || valLower.includes('active');
const isWarning = valLower.includes('pending') || valLower.includes('draft') || valLower.includes('hold');
const isDanger = valLower.includes('reject') || valLower.includes('fail') || valLower.includes('cancel');
if (isSuccess) colorClass = "bg-emerald-50 text-emerald-700 border-emerald-200";
else if (isWarning) colorClass = "bg-amber-50 text-amber-700 border-amber-200";
else if (isDanger) colorClass = "bg-red-50 text-red-700 border-red-200";
content = (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-bold uppercase tracking-wider border shadow-sm ${colorClass}`}>
{valStr}
</span>
);
}
}
}
return (
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
{content}
</td>
);
})}
{rowActions && ( {rowActions && (
<td className="sticky right-0 bg-inherit px-4 py-3 whitespace-nowrap border-b border-border-subtle z-10"> <td className="sticky right-0 bg-inherit px-4 py-3 whitespace-nowrap border-b border-border-subtle z-10">
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
@ -276,7 +335,16 @@ export function RecordView({
</table> </table>
</div> </div>
)} )}
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} /> <Pagination
page={page}
pageSize={internalPageSize}
total={total}
onPage={setPage}
onPageSizeChange={(sz) => {
setInternalPageSize(sz);
setPage(1);
}}
/>
</Card> </Card>
</div> </div>
); );

View File

@ -117,44 +117,50 @@ export function CallsPage() {
onClose={() => navigate(`/calls`)} onClose={() => navigate(`/calls`)}
title={instanceId != null ? `Call #${instanceId}` : undefined} title={instanceId != null ? `Call #${instanceId}` : undefined}
width="lg" width="lg"
actions={
<>
<Button size="sm" variant="secondary" onClick={handlePotentialMiningClick} disabled={miningLoading}>
{miningLoading ? 'Loading...' : 'Potential Mining'}
</Button>
{(() => {
const stateName = String(selectedRow?.current_state_name || selectedRow?.current_state_name_ || selectedRow?.current_state || selectedRow?.status || '').toLowerCase();
if (stateName.includes('ordered') || stateName === 'ordered' || stateName.includes('order')) {
return (
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.EDIT_ORDER.uid, name: 'Edit Order' })}>
Edit Order
</Button>
);
} else if (stateName.includes('productive')) {
return (
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
);
}
return (
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
);
})()}
</>
}
> >
{instanceId != null && <CallDetail instanceId={instanceId} />} {instanceId != null && selectedRow && (
<div className="pb-4">
<CallDetail
instanceId={instanceId}
selectedRow={selectedRow}
potentialMiningAction={
<Button size="sm" variant="secondary" className="w-48 max-w-full" onClick={handlePotentialMiningClick} disabled={miningLoading}>
{miningLoading ? 'Loading...' : 'Potential Mining'}
</Button>
}
placeOrderAction={
(() => {
const stateName = String(selectedRow.current_state_name || selectedRow.current_state_name_ || selectedRow.current_state || selectedRow.status || '').toLowerCase();
if (stateName.includes('ordered') || stateName === 'ordered' || stateName.includes('order')) {
return (
<Button onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.EDIT_ORDER.uid, name: 'Edit Order' })}>
Edit Order
</Button>
);
} else if (stateName.includes('productive')) {
return (
<Button onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
);
}
return (
<Button onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
);
})()
}
/>
</div>
)}
</Modal> </Modal>
<Modal <Modal
open={activeActivity != null} open={activeActivity != null}
onClose={() => setActiveActivity(null)} onClose={() => setActiveActivity(null)}
title={activeActivity?.name} title={activeActivity?.name}
width="md" width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'}
> >
{activeActivity && instanceId != null && ( {activeActivity && instanceId != null && (
<DynamicForm <DynamicForm

View File

@ -1,19 +1,23 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { LogOut } from 'lucide-react'; import { LogOut, FileText, ChevronDown } 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 { APP_ID } from '../api/config';
import { Button } from '../components/buttons'; import { Button } from '../components/buttons';
import { SCREENS } from './tabs'; import { SCREENS } from './tabs';
import { REPORT_MAP } from './ReportPage';
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */ /** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
export function ConsoleLayout() { export function ConsoleLayout() {
const { authed, logout } = useAuth(); const { authed, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const user = orderBookingClient.currentUser(); const user = orderBookingClient.currentUser();
const isReportsActive = location.pathname.startsWith('/reports');
// A 401 from any workflow client bounces back to login. // A 401 from any workflow client bounces back to login.
useEffect(() => { useEffect(() => {
onAuthErrorAll(() => { onAuthErrorAll(() => {
@ -26,10 +30,9 @@ export function ConsoleLayout() {
return ( return (
<div className="h-screen bg-app flex flex-col"> <div className="h-screen bg-app flex flex-col">
<header className="sticky top-0 z-10 shrink-0 flex items-center justify-between gap-3 px-6 h-[60px] bg-navy-grad border-b border-border-navy"> <header className="sticky top-0 z-10 shrink-0 flex items-center justify-between gap-3 px-6 h-[60px] bg-[var(--z-bg-primary)] border-b border-[var(--z-bg-primary)] shadow-sm">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-md font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span> <span className="text-md font-extrabold text-white tracking-[-0.01em] leading-none">Krishna Sales</span>
<span className="text-2xs text-on-navy-muted">Field Sales · Sandbox {APP_ID}</span>
</div> </div>
<nav className="flex items-center gap-1 h-full"> <nav className="flex items-center gap-1 h-full">
{SCREENS.map((t) => { {SCREENS.map((t) => {
@ -41,7 +44,7 @@ export function ConsoleLayout() {
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150', 'flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150',
isActive ? 'bg-white/10 text-white' : 'text-on-navy-muted hover:text-white hover:bg-white/5', isActive ? 'bg-white text-[var(--z-text-primary)]' : 'text-white hover:text-[var(--z-text-primary)] hover:bg-white',
) )
} }
> >
@ -50,9 +53,37 @@ export function ConsoleLayout() {
</NavLink> </NavLink>
); );
})} })}
<div className="relative group flex items-center h-full">
<button className={cn(
"flex items-center gap-1.5 no-underline font-sans text-sm font-medium px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
isReportsActive ? "bg-white text-[var(--z-text-primary)]" : "text-white hover:text-[var(--z-text-primary)] hover:bg-white"
)}>
<FileText size={16} />
Reports
<ChevronDown size={14} className="ml-0.5" />
</button>
<div className="absolute top-[80%] left-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
{Object.entries(REPORT_MAP).map(([key, report]) => (
<NavLink
key={key}
to={`/reports/${key}`}
className={({ isActive }) =>
cn(
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
isActive && "bg-gray-100 font-semibold"
)
}
>
{report.title}
</NavLink>
))}
</div>
</div>
</nav> </nav>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user?.name && <span className="text-sm text-on-navy hidden sm:inline">{user.name}</span>} {user?.name && <span className="text-sm text-white hidden sm:inline">{user.name}</span>}
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"

View File

@ -21,6 +21,7 @@ export function DailyLogsPage() {
<> <>
<DailyLogsView <DailyLogsView
refreshKey={refreshKey} refreshKey={refreshKey}
presetAlias="my_logs"
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(`/daily/${id}`); if (id != null) navigate(`/daily/${id}`);
@ -30,11 +31,17 @@ export function DailyLogsPage() {
Punch In Punch In
</Button> </Button>
} }
rowActions={(row) => ( rowActions={(row) => {
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}> const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase();
Punch Out // Show only for Punched In, hide for Punched Out or others
</Button> if (state.includes('out') || !state.includes('in')) return null;
)}
return (
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
Punch Out
</Button>
);
}}
/> />
<Modal <Modal
@ -80,7 +87,7 @@ export function DailyLogsPage() {
title={instanceId != null ? `Daily Log #${instanceId}` : undefined} title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
width="lg" width="lg"
> >
{instanceId != null && <DailyLogDetail instanceId={instanceId} />} {instanceId != null && <DailyLogDetail instanceId={instanceId} onPunchOut={() => setPunchOutInstanceId(instanceId)} />}
</Modal> </Modal>
</> </>
); );

View File

@ -97,22 +97,6 @@ export function DailySalesReportPage() {
const blob = doc.output('blob'); const blob = doc.output('blob');
// Attempt mobile native share (which includes OS-level Print)
if (navigator.share && navigator.canShare) {
const file = new File([blob], `Daily_Sales_Report_${date || 'Draft'}.pdf`, { type: 'application/pdf' });
try {
if (navigator.canShare({ files: [file] })) {
await navigator.share({
files: [file],
title: 'Daily Sales Report',
});
return;
}
} catch (err) {
console.warn('Share failed or was cancelled', err);
}
}
// Fallback for PC / unsupported browsers // Fallback for PC / unsupported browsers
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');

View File

@ -23,6 +23,7 @@ export function MyOrdersPage() {
<> <>
<OrdersView <OrdersView
refreshKey={refreshKey} refreshKey={refreshKey}
presetAlias="my_orders"
initialFilters={userEmail ? { performed_by_email: userEmail } : undefined} initialFilters={userEmail ? { performed_by_email: userEmail } : undefined}
onRowClick={(row) => { onRowClick={(row) => {
const id = row.instance_id as number | string | undefined; const id = row.instance_id as number | string | undefined;

View File

@ -0,0 +1,33 @@
import { useParams } from 'react-router-dom';
import { RecordView } from '../components/rv/RecordView';
import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
export const REPORT_MAP: Record<string, { uid: string; title: string }> = {
'product-wise-orders': { uid: ORDER_BOOKING.recordViews.PRODUCT_WISE_ORDERS, title: 'Product Wise Orders' },
'route-wise-orders': { uid: ORDER_BOOKING.recordViews.ROUTE_WISE_ORDERS, title: 'Route Wise Orders' },
'so-wise-orders': { uid: ORDER_BOOKING.recordViews.SO_WISE_ORDERS, title: 'SO Wise Orders' },
'so-wise-visits': { uid: ORDER_BOOKING.recordViews.SO_WISE_VISITS, title: 'SO Wise Visits' },
'yearly-orders': { uid: ORDER_BOOKING.recordViews.YEARLY_ORDERS, title: 'Yearly Orders' },
'monthly-orders': { uid: ORDER_BOOKING.recordViews.MONTHLY_ORDERS, title: 'Monthly Orders' },
'weekly-orders': { uid: ORDER_BOOKING.recordViews.WEEKLY_ORDERS, title: 'Weekly Orders' },
'store-wise-products': { uid: ORDER_BOOKING.recordViews.STORE_WISE_PRODUCTS, title: 'Store Wise Products' },
};
export function ReportPage() {
const { reportType } = useParams<{ reportType: string }>();
const report = reportType ? REPORT_MAP[reportType] : null;
if (!report) {
return <div className="p-4">Report not found</div>;
}
return (
<RecordView
key={report.uid}
client={orderBookingClient}
rvUid={report.uid}
title={report.title}
/>
);
}

View File

@ -65,19 +65,19 @@
--color-slate-50: var(--slate-50); --color-slate-50: var(--slate-50);
/* ---- Semantic aliases (short, readable utility names) ---- */ /* ---- Semantic aliases (short, readable utility names) ---- */
--color-app: var(--surface-app); --color-app: var(--z-body-bg);
--color-card: var(--surface-card); --color-card: var(--z-block-bg);
--color-sunk: var(--surface-sunk); --color-sunk: var(--z-bg-plain);
--color-strong: var(--text-strong); --color-strong: var(--z-text-default);
--color-body: var(--text-body); --color-body: var(--z-text-default);
--color-muted: var(--text-muted); --color-muted: var(--z-text-muted);
--color-faint: var(--text-faint); --color-faint: var(--z-text-secondary);
--color-on-navy: var(--text-on-navy); --color-on-navy: var(--z-text-inverse);
--color-on-navy-muted: var(--text-on-navy-muted); --color-on-navy-muted: var(--z-text-secondary);
--color-accent: var(--text-accent); --color-accent: var(--z-text-primary);
--color-link: var(--text-link); --color-link: var(--z-text-primary);
--color-border-subtle: var(--border-subtle); --color-border-subtle: var(--z-border-default);
--color-border-default: var(--border-default); --color-border-default: var(--z-border-secondary);
/* ---- Radii ---- */ /* ---- Radii ---- */
--radius-xs: var(--radius-xs); --radius-xs: var(--radius-xs);
@ -148,9 +148,9 @@
.nums { .nums {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Sunrise focus ring on inputs/selects. */ /* Primary focus ring on inputs/selects. */
.focus-ring:focus-within { .focus-ring:focus-within {
border-color: var(--sunrise-500); border-color: var(--z-btn-primary-bg);
box-shadow: var(--shadow-focus); box-shadow: var(--shadow-focus);
} }
/* Slim, theme-tinted scrollbar for in-panel scroll areas. */ /* Slim, theme-tinted scrollbar for in-panel scroll areas. */
@ -175,3 +175,321 @@
background-color: var(--text-faint); background-color: var(--text-faint);
} }
} }
/* root variable */
:root {
--primary-color: #1d4ed8;
--secondary-color: #f9fafb;
--font-color: #10182b;
--font-family: "IBM Plex Sans", serif;
--light-font-color: #667085;
--body-bg: #f0f4f8;
--bg-plain: #fff;
--bg-active: #00000014;
--border: #e5e7eb;
--shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3);
--hover: #e4e4e4;
--hover-font: #fff;
--hover-bg: #f9fafb;
--hover-border: #667085;
--menu-item-bg: rgba(255, 255, 255, 0.1);
/* Navbar background color */
--nav-bg-color: #fff;
--nav-item-color: #10182b;
--nav-item-active: #1b84ff;
--nav-item-active-bg: #c0d4ed45;
--nav-item-border-bottom: transparent;
--nav-dropdown-bg: #fff;
/* Matching the navbar color */
--nav-dropdown-color: #4B5675;
--nav-dropdown-active: #2e56e1;
--nav-dropdown-active-bg: #F9F9F9;
--nav-dropdown-border: none;
--nav-dropdown-radius: 4px;
--nav-dropdown-padding: 10px;
--menu-item-radius: 4px;
--p-8: 0px;
--nav-border: rgba(255, 255, 255, 0.1);
--nav-left-separator-line: #484848;
/* tiles card */
--tiles-card-bg: #fff;
--tiles-card-border: #e5e7eb;
--tiles-font-color: #10182b;
--tiles-font-size: 16px;
--tiles-count-size: 20px;
/* button */
--z-btn-primary-bg: #0058be;
--z-btn-primary-color: #fff;
--primary-btn-active: #196ed0;
--primary-btn-activeClr: #fff;
--z-btn-secondary-bg: #f4f4f4;
--z-btn-secondary-color: #10182b;
--secondary-btn-active-bg: #eaeaea;
--z-btn-outline-color: #0058be;
--z-btn-outline-border: #0058be;
--button-height: 36px;
--z-btn-sm-height: 32px;
--z-btn-md-height: 40px;
--z-btn-lg-height: 48px;
/* blocks */
--block-bg: #fff;
--block-padding: 20px;
--block-radius: 20px;
--block-border: #e5e7eb;
/* --block-shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3); */
--block-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);
/* button or input */
--button-radius: 8px;
--input-shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3);
--input-border: #e5e7eb;
/* font-size */
--small-font: 14px;
--medium-font: 16px;
--large-font: 20px;
--z-btn-font-sm: 12px;
--z-btn-font-md: 14px;
--z-btn-font-lg: 16px;
--small-icon: 24px;
/* record view table color */
--rv-table-bg: #fff;
--rv-table-header: #f9fafb;
--table-row-bg-select: #fafafa;
--table-tr-active: #f9fafb;
--rv-table-height: 40px;
--rv-table-btn-height: 36px;
--table-td-padding: 8px 20px;
/* form input tags */
--form-input-height: 40px;
--form-bg: #fff;
--form-input-bg: #fff;
--inputBox-border: ##e5e7eb;
--inputBox-border-active: #a2acbf;
--disable-bg: #fafafa;
--disable-color: #98a2b3;
--toolTip-color: #2185d0;
--toolTip-active-clr: #208fe3;
/* new template style variables */
--z-body-bg: #f0f4f8;
--z-bg-plain: #fff;
--z-bg-primary: #0058be;
--z-bg-secondary: #f4f4f4;
--z-bg-success: #1fc16b;
--z-bg-error: #fa3748;
--z-bg-light: #ffffff;
--z-bg-dark: #1e2227;
--z-text-default: #10182b;
--z-text-primary: #0058be;
--z-text-secondary: #6c757d;
--z-text-success: #fff;
--z-text-error: #fff;
--z-text-muted: #6c757d;
--z-text-inverse: #ffffff;
--z-text-plain: #fff;
--z-bg-active: #3d94fb;
--z-border-primary: #0058be;
--z-border-secondary: #ced4da;
--z-border-success: #1fc16b;
--z-border-error: #fa3748;
--z-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--z-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--z-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15);
--z-font-size-xs: 10px;
--z-font-size-sm: 12px;
--z-font-size-md: 14px;
--z-font-size-lg: 16px;
--z-font-size-xl: 20px;
--z-border-size-sm: 1px;
--z-border-size-md: 2px;
--z-border-size-lg: 4px;
--z-border-radius-sm: 4px;
--z-border-radius-md: 8px;
--z-border-radius-lg: 16px;
--z-border-radius-full: 50%;
--z-border-radius-pill: 30px;
/* app default text */
--z-font-sm: 14px;
--z-font-md: 16px;
--z-font-lg: 20px;
/* button style */
--z-btn-radius: 8px;
--z-btn-height: 44px;
/* block and section styles */
--z-block-bg: #fff;
--z-block-radius: 8px;
--z-block-border: #e5e7eb;
--z-block-shadow: box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
/* record view table */
--z-rv-table-header: #fff;
--z-rv-td-padding: 8px 20px;
--z-rv-table-border: #e5e7eb;
--z-rv-td-height: 40px;
--z-tile-bg: #fff;
--z-tile-border: #e5e7eb;
/*-----------------New BG Color Palatte----------------*/
/* Primary Colors */
--z-bg-primary-100: #c0d4ed;
--z-bg-primary-200: #83b6f4;
--z-bg-primary-300: #5aa2f8;
--z-bg-primary-400: #0058be;
/* Secondary Colors */
--z-bg-secondary-100: #ffce8d;
--z-bg-secondary-200: #ffbb62;
--z-bg-secondary-300: #faa73b;
--z-bg-secondary-400: #f5f6f7;
/* Gradient Colors */
--z-bg-gradient-1: linear-gradient(90deg, #0058be 0%, #104F99 100%);
--z-bg-gradient-2: linear-gradient(90deg, #1C38A7 0%, #2E55DF 100%);
--z-bg-gradient-3: linear-gradient(94.13deg, #83B6F4 2.64%, #2B6BBA 97.36%);
--z-bg-gradient-4: linear-gradient(99.74deg, #A0C5F4 4.16%, #EFF3F8 97.03%);
/* Neutral Colors */
--z-bg-neutral-100: #ffffff;
--z-bg-neutral-200: #f9fafb;
--z-bg-neutral-300: #e5e7eb;
--z-bg-neutral-400: #d0d5dd;
--z-bg-neutral-500: #98a2b3;
--z-bg-neutral-600: #667085;
--z-bg-neutral-700: #475467;
--z-bg-neutral-800: #1d2939;
--z-bg-neutral-900: #10182b;
--z-bg-neutral-1000: #0c1323;
/* Danger Colors */
--z-bg-danger-100: #fecdca;
--z-bg-danger-200: #fda29b;
--z-bg-danger-300: #f97066;
--z-bg-danger-400: #f04438;
/* Warning Colors */
--z-bg-warning-100: #ffdf89;
--z-bg-warning-200: #fec84b;
--z-bg-warning-300: #ffdb43;
--z-bg-warning-400: #dfb400;
/* Success Colors */
--z-bg-success-100: oklch(97.9% .021 166.113);
--z-bg-success-200: #6ce9a6;
--z-bg-success-300: #32d584;
--z-bg-success-400: #12b76a;
/*-----------------New Text Color Palatte----------------*/
/* Primary Colors */
--z-text-primary-100: #c0d4ed;
--z-text-primary-200: #83b6f4;
--z-text-primary-300: #5aa2f8;
--z-text-primary-400: #0058be;
/* Secondary Colors */
--z-text-secondary-100: #ffce8d;
--z-text-secondary-200: #ffbb62;
--z-text-secondary-300: #faa73b;
--z-text-secondary-400: #f79009;
/* Neutral Colors */
--z-text-neutral-100: #ffffff;
--z-text-neutral-200: #f9fafb;
--z-text-neutral-300: #e5e7eb;
--z-text-neutral-400: #d0d5dd;
--z-text-neutral-500: #98a2b3;
--z-text-neutral-600: #667085;
--z-text-neutral-700: #475467;
--z-text-neutral-800: #1d2939;
--z-text-neutral-900: #10182b;
--z-text-neutral-1000: #0c1323;
/* Danger Colors */
--z-text-danger-100: #fecdca;
--z-text-danger-200: #fda29b;
--z-text-danger-300: #f97066;
--z-text-danger-400: #f04438;
/* Warning Colors */
--z-text-warning-100: #ffdf89;
--z-text-warning-200: #fec84b;
--z-text-warning-300: #ffdb43;
--z-text-warning-400: #dfb400;
/* Success Colors */
--z-text-success-100: #a6f4c5;
--z-text-success-200: #6ce9a6;
--z-text-success-300: #32d584;
--z-text-success-400: #12b76a;
/*-----------------New Border Color Palatte----------------*/
/* Primary Colors */
--z-border-default: #e5e7eb;
--z-border-primary-100: #c0d4ed;
--z-border-primary-200: #83b6f4;
--z-border-primary-300: #5aa2f8;
--z-border-primary-400: #0058be;
/* Secondary Colors */
--z-border-secondary-100: #ffce8d;
--z-border-secondary-200: #f3cfc4;
--z-border-secondary-300: #ebaf9d;
--z-border-secondary-400: #f79009;
/* Neutral Colors */
--z-border-neutral-100: #ffffff;
--z-border-neutral-200: #f9fafb;
--z-border-neutral-300: #e5e7eb;
--z-border-neutral-400: #d0d5dd;
--z-border-neutral-500: #98a2b3;
--z-border-neutral-600: #667085;
--z-border-neutral-700: #475467;
--z-border-neutral-800: #1d2939;
--z-border-neutral-900: #10182b;
--z-border-neutral-1000: #0c1323;
/* Danger Colors */
--z-border-danger-100: #fecdca;
--z-border-danger-200: #fda29b;
--z-border-danger-300: #f97066;
--z-border-danger-400: #f04438;
/* Warning Colors */
--z-border-warning-100: #ffdf89;
--z-border-warning-200: #fec84b;
--z-border-warning-300: #ffdb43;
--z-border-warning-400: #dfb400;
/* Success Colors */
--z-border-success-100: #a6f4c5;
--z-border-success-200: #6ce9a6;
--z-border-success-300: #32d584;
--z-border-success-400: #12b76a;
}

View File

@ -97,7 +97,7 @@
--action-primary-hover: var(--sunrise-600); --action-primary-hover: var(--sunrise-600);
--action-navy: var(--navy-900); --action-navy: var(--navy-900);
--action-navy-hover: var(--navy-800); --action-navy-hover: var(--navy-800);
--focus-ring: rgba(242,107,58,0.45); --focus-ring: rgba(0, 88, 190, 0.45);
/* Status */ /* Status */
--status-success: var(--emerald-600); --status-success: var(--emerald-600);