detailview ui improved
This commit is contained in:
parent
4d7beaef0f
commit
930809a581
@ -8,6 +8,7 @@ import { CallsPage } from './screens/CallsPage'
|
||||
import { StoresPage } from './screens/StoresPage'
|
||||
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||
import { ReportPage } from './screens/ReportPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -31,6 +32,7 @@ function App() {
|
||||
<Route path="daily" element={<DailyLogsPage />} />
|
||||
<Route path="daily/:instanceId" element={<DailyLogsPage />} />
|
||||
|
||||
<Route path="reports/:reportType" element={<ReportPage />} />
|
||||
<Route path="sales-report" element={<DailySalesReportPage />} />
|
||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
||||
</Route>
|
||||
|
||||
@ -125,6 +125,7 @@ export class ZinoClient {
|
||||
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
||||
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
||||
rv_template_uid: rvUid,
|
||||
...(params.presetAlias ? { preset_alias: params.presetAlias } : {}),
|
||||
search_query: {
|
||||
page: params.page ?? 1,
|
||||
limit: params.limit ?? 50,
|
||||
|
||||
@ -65,6 +65,15 @@ export const ORDER_BOOKING = {
|
||||
recordViews: {
|
||||
ORDERS: '63714ac2-c9fb-40e2-abba-d4081a70b768',
|
||||
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: {
|
||||
ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d',
|
||||
|
||||
@ -53,6 +53,7 @@ export interface RecordViewParams {
|
||||
sortDir?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
||||
presetAlias?: string;
|
||||
}
|
||||
|
||||
// --- Form schema (POST /app/{appId}/view/form-screens) ---
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import './style.css'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger' | 'outline';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg' | 'fab';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Visual style. @default "primary" */
|
||||
@ -19,14 +20,16 @@ const SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
|
||||
md: 'h-[42px] px-[18px] text-base 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> = {
|
||||
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',
|
||||
secondary: 'bg-card text-strong border border-border-default shadow-xs',
|
||||
ghost: 'bg-transparent text-body border border-transparent',
|
||||
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',
|
||||
SIZES[size],
|
||||
VARIANTS[variant],
|
||||
full ? 'w-full' : 'w-auto',
|
||||
full ? 'w-full' : '',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
45
src/components/buttons/style.css
Normal file
45
src/components/buttons/style.css
Normal 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);
|
||||
}
|
||||
@ -1,17 +1,371 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import { ORDER_BOOKING } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
import type { WiredDetailViewProps } from './OrderDetail';
|
||||
import { ORDER_BOOKING, APP_ID } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
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="Couldn’t 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 (
|
||||
<DetailView
|
||||
client={orderBookingClient}
|
||||
dvUid={ORDER_BOOKING.detailViews.CALLS}
|
||||
instanceId={instanceId}
|
||||
title="Call"
|
||||
columns={columns}
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,242 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { dailyReportsClient } from '../../api/clients';
|
||||
import { DAILY_REPORTS } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
import type { WiredDetailViewProps } from './OrderDetail';
|
||||
import { DAILY_REPORTS, APP_ID } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
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="Couldn’t 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 (
|
||||
<DetailView
|
||||
client={dailyReportsClient}
|
||||
dvUid={DAILY_REPORTS.detailViews.DAILY_LOGS}
|
||||
instanceId={instanceId}
|
||||
title="Daily Log"
|
||||
columns={columns}
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className={`border-t-[4px] shadow-md ${isPunchedOut ? 'border-emerald-500' : 'border-[var(--z-bg-primary)]'}`}>
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,8 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import type { ZinoClient } from '../../api/client';
|
||||
import type { AuditEntry } from '../../api/types';
|
||||
import { WORKFLOWS } from '../../api/config';
|
||||
import { APP_ID } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import { EmptyState } from '../reusable/EmptyState';
|
||||
@ -11,7 +10,7 @@ import { EmptyState } from '../reusable/EmptyState';
|
||||
export interface DetailViewProps {
|
||||
/** Workflow-bound client (see api/clients.ts). */
|
||||
client: ZinoClient;
|
||||
/** detailview template uid (deprecated, using audit endpoint now). */
|
||||
/** detailview template uid. */
|
||||
dvUid?: string;
|
||||
/** Instance to render. */
|
||||
instanceId: number | string;
|
||||
@ -23,29 +22,13 @@ export interface DetailViewProps {
|
||||
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
|
||||
* renders the audit trail as a labeled definition grid.
|
||||
* Generic Zino detail view. Fetches `GET /app/{id}/view/detailview/{dvUid}` and
|
||||
* renders the data as a labeled definition grid.
|
||||
*/
|
||||
export function DetailView({ client, instanceId, title, columns = 2 }: DetailViewProps) {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -55,8 +38,11 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await client.audit(instanceId);
|
||||
if (live) setEntries(r);
|
||||
if (!dvUid) throw new Error("dvUid is required to fetch detail view data.");
|
||||
const r = await client.detailView(dvUid, instanceId);
|
||||
if (live) {
|
||||
setData(r.data || {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
@ -67,7 +53,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
||||
return () => {
|
||||
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];
|
||||
|
||||
@ -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 (
|
||||
<Card title={title}>
|
||||
<EmptyState title="No history found" />
|
||||
<EmptyState title="No details found" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entries.map((entry, i) => {
|
||||
const actName = getActivityName(entry.activity_id);
|
||||
const headerTitle = actName || (i === 0 && title ? title : 'Update');
|
||||
const updatedAt = new Date(entry.created_at).toLocaleString();
|
||||
|
||||
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]) => {
|
||||
<Card
|
||||
title={title || 'Details'}
|
||||
className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md"
|
||||
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;
|
||||
|
||||
const isGridArray =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
@ -127,7 +111,7 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
|
||||
|
||||
return (
|
||||
<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, ' ')}
|
||||
</dt>
|
||||
<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">
|
||||
{headers.map(h => (
|
||||
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
|
||||
{formatValue(row[h], h)}
|
||||
{formatValue(row[h])}
|
||||
</td>
|
||||
))}
|
||||
</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 (
|
||||
<div key={key} className="flex flex-col gap-1 min-w-0">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
|
||||
<div key={key} className="flex flex-col gap-1.5 min-w-0">
|
||||
<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 text-sm text-strong font-medium break-words">
|
||||
{formatValue(value, key)}
|
||||
{formatValue(value)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
75
src/components/dv/GridTable.tsx
Normal file
75
src/components/dv/GridTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,21 +1,248 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
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 {
|
||||
instanceId: number | string;
|
||||
columns?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
/** Order detail view (Order Booking workflow). */
|
||||
export function OrderDetail({ instanceId, columns }: WiredDetailViewProps) {
|
||||
export function OrderDetail({ instanceId }: 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="Couldn’t 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 (
|
||||
<DetailView
|
||||
client={orderBookingClient}
|
||||
dvUid={ORDER_BOOKING.detailViews.ORDERS}
|
||||
instanceId={instanceId}
|
||||
title="Order"
|
||||
columns={columns}
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
136
src/components/dv/PotentialMiningTable.tsx
Normal file
136
src/components/dv/PotentialMiningTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,319 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { storeClient } from '../../api/clients';
|
||||
import { STORE } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
import { STORE, APP_ID } from '../../api/config';
|
||||
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';
|
||||
|
||||
/** Store detail view (Store workflow). */
|
||||
export function StoreDetail({ instanceId, columns }: WiredDetailViewProps) {
|
||||
export function StoreDetail({ instanceId }: 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="Couldn’t 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 (
|
||||
<DetailView
|
||||
client={storeClient}
|
||||
dvUid={STORE.detailViews.STORE}
|
||||
instanceId={instanceId}
|
||||
title="Store"
|
||||
columns={columns}
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,3 +5,7 @@ export type { WiredDetailViewProps } from './OrderDetail';
|
||||
export { CallDetail } from './CallDetail';
|
||||
export { StoreDetail } from './StoreDetail';
|
||||
export { DailyLogDetail } from './DailyLogDetail';
|
||||
export { PotentialMiningTable } from './PotentialMiningTable';
|
||||
export { GridTable } from './GridTable';
|
||||
export type { GridTableProps } from './GridTable';
|
||||
export type { MiningItem, PotentialMiningTableProps } from './PotentialMiningTable';
|
||||
|
||||
@ -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) {
|
||||
const uploadedFiles = [];
|
||||
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);
|
||||
}
|
||||
payload[f.id] = uploadedFiles;
|
||||
} 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];
|
||||
} else {
|
||||
payload[f.id] = val;
|
||||
|
||||
@ -2,8 +2,10 @@ import { useState, useRef } from 'react';
|
||||
import { Button } from '../../buttons/Button';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import { Trash2, Pencil } from 'lucide-react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
import { PotentialMiningTable } from '../../dv/PotentialMiningTable';
|
||||
import { GridTable } from '../../dv/GridTable';
|
||||
|
||||
export function SmartGridField({
|
||||
label,
|
||||
@ -43,6 +45,9 @@ export function SmartGridField({
|
||||
setEditingIdx(idx);
|
||||
setNewRow({ ...value[idx] });
|
||||
setIsModalOpen(true);
|
||||
setTimeout(() => {
|
||||
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const startAdd = () => {
|
||||
@ -101,6 +106,18 @@ export function SmartGridField({
|
||||
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);
|
||||
};
|
||||
|
||||
@ -152,82 +169,59 @@ export function SmartGridField({
|
||||
setEditingIdx(null);
|
||||
};
|
||||
|
||||
const isPotentialGrid = label.toLowerCase().includes('potential');
|
||||
const hasOrderedColumn = columns.some(c => c.id.toLowerCase().includes('ordered') || (c.name || '').toLowerCase().includes('ordered'));
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{value.length === 0 ? (
|
||||
<span className="text-sm text-faint italic">No rows added.</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{value.map((row, i) => {
|
||||
let productName = 'Unknown Product';
|
||||
let bags = '0';
|
||||
) : isPotentialGrid ? (
|
||||
<PotentialMiningTable
|
||||
hideOrderDetails={!hasOrderedColumn}
|
||||
data={value.map((row, i) => {
|
||||
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 => {
|
||||
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 || isBags) {
|
||||
if (isName) {
|
||||
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 => String(o.value) === String(val));
|
||||
if (opt) displayVal = opt.label;
|
||||
}
|
||||
|
||||
if (isName && (productName === 'Unknown Product' || col.id.toLowerCase().includes('name'))) {
|
||||
productName = displayVal;
|
||||
}
|
||||
if (isBags) bags = displayVal;
|
||||
productName = displayVal;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={i} className="bg-slate-50 border border-slate-200 rounded-xl p-3 shadow-sm flex items-center justify-between">
|
||||
<div className="flex flex-col min-w-0 pr-4 w-full">
|
||||
<span className="font-bold text-slate-800 text-[14px] truncate">{productName}</span>
|
||||
{label.toLowerCase().includes('potential') ? (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1.5 text-[12px]">
|
||||
<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>
|
||||
);
|
||||
return {
|
||||
id: String(i),
|
||||
productName,
|
||||
badge: reason ? { label: reason, type: badgeType } : undefined,
|
||||
potentialKgs: Number(row.actual_potential ?? row.Actual_Potential ?? row.store_potential ?? row.Store_Potential ?? row.quantity ?? row.Quantity ?? 0),
|
||||
orderedKgs: Number(row.total_ordered ?? row.Total_Ordered ?? 0),
|
||||
};
|
||||
})}
|
||||
</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">
|
||||
|
||||
@ -6,12 +6,14 @@ import {
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
ResponsiveContainer,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
|
||||
export interface ChartRow {
|
||||
dimension: string;
|
||||
value: number;
|
||||
series?: string;
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
@ -37,19 +39,42 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
// Safely format dimension string (fallbacks for empty strings)
|
||||
const formattedRows = (chart.rows || []).map(r => ({
|
||||
...r,
|
||||
dimension: String(r.dimension || '').trim() || 'Unknown'
|
||||
}));
|
||||
const hasSeries = chart.rows?.some(r => r.series);
|
||||
let finalData: any[] = [];
|
||||
let seriesKeys: string[] = [];
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<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" />
|
||||
<XAxis
|
||||
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' }}
|
||||
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="Value"
|
||||
fill={colors[idx % colors.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
/>
|
||||
{hasSeries && <Legend wrapperStyle={{ paddingTop: '20px' }} />}
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={hasSeries ? key : "Value"}
|
||||
fill={colors[i % colors.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@ -40,15 +40,15 @@ export function Card({
|
||||
return (
|
||||
<section
|
||||
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],
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{title && (
|
||||
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
|
||||
<h3 className="m-0 text-md font-semibold text-strong">{title}</h3>
|
||||
<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-[var(--z-text-default)]">{title}</h3>
|
||||
{action}
|
||||
</header>
|
||||
)}
|
||||
|
||||
@ -5,12 +5,6 @@ import { cn } from '../../lib/cn';
|
||||
|
||||
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 {
|
||||
open: boolean;
|
||||
@ -23,6 +17,13 @@ export interface ModalProps {
|
||||
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. */
|
||||
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
@ -43,21 +44,30 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
|
||||
|
||||
return createPortal(
|
||||
<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) => {
|
||||
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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
|
||||
'flex flex-col max-h-[85vh]',
|
||||
WIDTH[width],
|
||||
'w-full bg-[var(--z-block-bg)] shadow-[auto_0_30px_rgba(0,0,0,0.1)] my-0',
|
||||
WIDTH_CLASSES[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">
|
||||
{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>}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '../buttons/Button';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export interface PaginationProps {
|
||||
/** 1-based current page. */
|
||||
@ -8,33 +9,83 @@ export interface PaginationProps {
|
||||
/** Total rows across all pages. */
|
||||
total: number;
|
||||
onPage: (page: number) => void;
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
}
|
||||
|
||||
/** Pager: "Showing a–b of N" + Prev/Next. Renders nothing when the set fits on
|
||||
* one page. Shared by the record-view tables. */
|
||||
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
||||
/** Pager: "Showing a–b of N" + Rows per page + Jump to + Prev/Next.
|
||||
* Shared by the record-view tables. */
|
||||
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;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||
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 (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3">
|
||||
<span className="text-xs text-faint">
|
||||
Showing {start + 1}–{Math.min(start + pageSize, total)} of {total}
|
||||
</span>
|
||||
<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}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border-subtle px-[18px] py-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="text-xs text-faint whitespace-nowrap">
|
||||
Showing {start + 1}–{Math.min(start + pageSize, total)} of {total}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}>
|
||||
Next
|
||||
<ChevronRight size={15} />
|
||||
</Button>
|
||||
{onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Rows per page:</span>
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
export interface StatsTilesProps {
|
||||
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) {
|
||||
if (!tiles?.length) return null;
|
||||
|
||||
@ -24,35 +15,41 @@ export function StatsTiles({ tiles }: StatsTilesProps) {
|
||||
.replace(/_/g, " ")
|
||||
.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 (
|
||||
<Card
|
||||
<div
|
||||
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={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider whitespace-nowrap overflow-hidden text-ellipsis max-w-[80%] ${
|
||||
isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-neutral-500)]'
|
||||
}`}>
|
||||
{displayLabel}
|
||||
</p>
|
||||
|
||||
<h2 className="text-4xl font-bold tracking-tight text-gray-900">
|
||||
{(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" />
|
||||
</span>
|
||||
<Icon size={16} className={`${
|
||||
isDanger ? 'text-[var(--z-text-danger-400)]' : 'text-[var(--z-text-success-400)]'
|
||||
}`} />
|
||||
</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>
|
||||
|
||||
@ -4,7 +4,7 @@ import { RecordView } from './RecordView';
|
||||
import type { WiredRecordViewProps } from './OrdersView';
|
||||
|
||||
/** 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 (
|
||||
<RecordView
|
||||
client={dailyReportsClient}
|
||||
@ -15,6 +15,7 @@ export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions,
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
presetAlias={presetAlias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,10 +9,11 @@ export interface WiredRecordViewProps {
|
||||
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
||||
refreshKey?: number;
|
||||
initialFilters?: Record<string, string>;
|
||||
presetAlias?: string;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<RecordView
|
||||
client={orderBookingClient}
|
||||
@ -27,6 +28,7 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re
|
||||
sortDir="desc"
|
||||
omitColumns={['created_at', 'instance_id']}
|
||||
initialFilters={initialFilters}
|
||||
presetAlias={presetAlias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -41,6 +41,8 @@ export interface RecordViewProps {
|
||||
sortDir?: 'asc' | 'desc';
|
||||
/** Default filters to apply initially. */
|
||||
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,
|
||||
title = 'Records',
|
||||
columns,
|
||||
pageSize = 50,
|
||||
pageSize = 25,
|
||||
onRowClick,
|
||||
rowKey,
|
||||
headerActions,
|
||||
@ -63,7 +65,9 @@ export function RecordView({
|
||||
sortDir,
|
||||
omitColumns,
|
||||
initialFilters = {},
|
||||
presetAlias,
|
||||
}: RecordViewProps) {
|
||||
const [internalPageSize, setInternalPageSize] = useState(pageSize);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
@ -103,11 +107,12 @@ export function RecordView({
|
||||
try {
|
||||
const r = await client.recordView(rvUid, {
|
||||
page,
|
||||
limit: pageSize,
|
||||
limit: internalPageSize,
|
||||
search: debounced,
|
||||
filters: filtersParam,
|
||||
sortBy,
|
||||
sortDir
|
||||
sortDir,
|
||||
presetAlias
|
||||
});
|
||||
console.log("RECORD VIEW RESP:", r);
|
||||
if (live) setResp(r);
|
||||
@ -121,7 +126,7 @@ export function RecordView({
|
||||
return () => {
|
||||
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 all = resp?.config.fields ?? [];
|
||||
@ -157,7 +162,7 @@ export function RecordView({
|
||||
action={headerActions}
|
||||
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">
|
||||
{(() => {
|
||||
if (!resp?.config) return null;
|
||||
@ -232,7 +237,7 @@ export function RecordView({
|
||||
<div className="overflow-x-auto scrollbar-slim">
|
||||
<table className="w-full border-collapse min-w-[640px]">
|
||||
<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) => (
|
||||
<th
|
||||
key={f.field_key}
|
||||
@ -254,15 +259,69 @@ export function RecordView({
|
||||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||||
className={cn(
|
||||
'bg-surface border-b border-border-subtle transition-colors duration-150',
|
||||
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
||||
'bg-[var(--z-rv-table-bg)] border-b border-[var(--z-rv-table-border)] transition-colors duration-150',
|
||||
onRowClick && 'cursor-pointer hover:bg-[var(--table-row-bg-select)]',
|
||||
)}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
|
||||
{formatValue(row[f.field_key], f.field_key)}
|
||||
</td>
|
||||
))}
|
||||
{fields.map((f) => {
|
||||
const lowerKey = f.field_key.toLowerCase();
|
||||
const isSku = lowerKey === 'sku_code';
|
||||
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 && (
|
||||
<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()}>
|
||||
@ -276,7 +335,16 @@ export function RecordView({
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -117,44 +117,50 @@ export function CallsPage() {
|
||||
onClose={() => navigate(`/calls`)}
|
||||
title={instanceId != null ? `Call #${instanceId}` : undefined}
|
||||
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
|
||||
open={activeActivity != null}
|
||||
onClose={() => setActiveActivity(null)}
|
||||
title={activeActivity?.name}
|
||||
width="md"
|
||||
width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'}
|
||||
>
|
||||
{activeActivity && instanceId != null && (
|
||||
<DynamicForm
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { LogOut, FileText, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||
import { APP_ID } from '../api/config';
|
||||
|
||||
import { Button } from '../components/buttons';
|
||||
import { SCREENS } from './tabs';
|
||||
import { REPORT_MAP } from './ReportPage';
|
||||
|
||||
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
||||
export function ConsoleLayout() {
|
||||
const { authed, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = orderBookingClient.currentUser();
|
||||
|
||||
const isReportsActive = location.pathname.startsWith('/reports');
|
||||
|
||||
// A 401 from any workflow client bounces back to login.
|
||||
useEffect(() => {
|
||||
onAuthErrorAll(() => {
|
||||
@ -26,10 +30,9 @@ export function ConsoleLayout() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="text-md font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span>
|
||||
<span className="text-2xs text-on-navy-muted">Field Sales · Sandbox {APP_ID}</span>
|
||||
<span className="text-md font-extrabold text-white tracking-[-0.01em] leading-none">Krishna Sales</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 h-full">
|
||||
{SCREENS.map((t) => {
|
||||
@ -41,7 +44,7 @@ export function ConsoleLayout() {
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
<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>
|
||||
<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
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@ -21,6 +21,7 @@ export function DailyLogsPage() {
|
||||
<>
|
||||
<DailyLogsView
|
||||
refreshKey={refreshKey}
|
||||
presetAlias="my_logs"
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/daily/${id}`);
|
||||
@ -30,11 +31,17 @@ export function DailyLogsPage() {
|
||||
Punch In
|
||||
</Button>
|
||||
}
|
||||
rowActions={(row) => (
|
||||
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
|
||||
Punch Out
|
||||
</Button>
|
||||
)}
|
||||
rowActions={(row) => {
|
||||
const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase();
|
||||
// Show only for Punched In, hide for Punched Out or others
|
||||
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
|
||||
@ -80,7 +87,7 @@ export function DailyLogsPage() {
|
||||
title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
>
|
||||
{instanceId != null && <DailyLogDetail instanceId={instanceId} />}
|
||||
{instanceId != null && <DailyLogDetail instanceId={instanceId} onPunchOut={() => setPunchOutInstanceId(instanceId)} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -97,22 +97,6 @@ export function DailySalesReportPage() {
|
||||
|
||||
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
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
@ -23,6 +23,7 @@ export function MyOrdersPage() {
|
||||
<>
|
||||
<OrdersView
|
||||
refreshKey={refreshKey}
|
||||
presetAlias="my_orders"
|
||||
initialFilters={userEmail ? { performed_by_email: userEmail } : undefined}
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
|
||||
33
src/screens/ReportPage.tsx
Normal file
33
src/screens/ReportPage.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -65,19 +65,19 @@
|
||||
--color-slate-50: var(--slate-50);
|
||||
|
||||
/* ---- Semantic aliases (short, readable utility names) ---- */
|
||||
--color-app: var(--surface-app);
|
||||
--color-card: var(--surface-card);
|
||||
--color-sunk: var(--surface-sunk);
|
||||
--color-strong: var(--text-strong);
|
||||
--color-body: var(--text-body);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-faint: var(--text-faint);
|
||||
--color-on-navy: var(--text-on-navy);
|
||||
--color-on-navy-muted: var(--text-on-navy-muted);
|
||||
--color-accent: var(--text-accent);
|
||||
--color-link: var(--text-link);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-app: var(--z-body-bg);
|
||||
--color-card: var(--z-block-bg);
|
||||
--color-sunk: var(--z-bg-plain);
|
||||
--color-strong: var(--z-text-default);
|
||||
--color-body: var(--z-text-default);
|
||||
--color-muted: var(--z-text-muted);
|
||||
--color-faint: var(--z-text-secondary);
|
||||
--color-on-navy: var(--z-text-inverse);
|
||||
--color-on-navy-muted: var(--z-text-secondary);
|
||||
--color-accent: var(--z-text-primary);
|
||||
--color-link: var(--z-text-primary);
|
||||
--color-border-subtle: var(--z-border-default);
|
||||
--color-border-default: var(--z-border-secondary);
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-xs: var(--radius-xs);
|
||||
@ -148,9 +148,9 @@
|
||||
.nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Sunrise focus ring on inputs/selects. */
|
||||
/* Primary focus ring on inputs/selects. */
|
||||
.focus-ring:focus-within {
|
||||
border-color: var(--sunrise-500);
|
||||
border-color: var(--z-btn-primary-bg);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
/* Slim, theme-tinted scrollbar for in-panel scroll areas. */
|
||||
@ -175,3 +175,321 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@
|
||||
--action-primary-hover: var(--sunrise-600);
|
||||
--action-navy: var(--navy-900);
|
||||
--action-navy-hover: var(--navy-800);
|
||||
--focus-ring: rgba(242,107,58,0.45);
|
||||
--focus-ring: rgba(0, 88, 190, 0.45);
|
||||
|
||||
/* Status */
|
||||
--status-success: var(--emerald-600);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user