tiles and card design in calls done

This commit is contained in:
suryacp23 2026-07-16 10:59:31 +05:30
parent e8732bf436
commit da013c6bac
14 changed files with 770 additions and 191 deletions

View File

@ -1,18 +1,70 @@
import { Store, MapPin, TrendingUp, Calendar, Clock, Info } from 'lucide-react';
import { formatValue } from '../../lib/format';
import { BASE_URL, APP_ID } from '../../api/config';
import './card.css';
// {
// "42b7f47d-96f0-4289-a6d9-38f455ad57dd__user_id": {
// "email": "surya.c@getzino.com",
// "job_title": "",
// "name": "Surya C",
// "user_id": 29113
// },
// "current_state_id": "33d7ab1f-ddf4-4549-ac99-7d23172c4ca5",
// "current_state_name": "Productive Call",
// "date_of_visit": "2026-07-15",
// "instance_id": 6592,
// "is_productive_call": "yes",
// "select_store": {
// "area": "Dummy Area",
// "business_name": "Business Name 777",
// "complete_address": "Dummy notes for Complete Address",
// "distributor_email": "test@example.com",
// "distributor_name": "Banashree Multi Millet Flour",
// "distributor_owner_name": "Dummy Distributor Owner Name",
// "distributor_phone_number": {
// "dial_code": "+91",
// "phone": "9876543210",
// "phone_with_dial_code": "+919876543210"
// },
// "email": "test@example.com",
// "instance_id": 6118,
// "notes": "Dummy notes for Notes",
// "owner_name": "Dummy Owner Name",
// "phone_number": {
// "dial_code": "+91",
// "phone": "9876543210",
// "phone_with_dial_code": "+919876543210"
// },
// "pin_code": "100000",
// "potential": "[{\"row_kgs\": 0, \"quantity\": 20, \"product_category\": \"MAIDA SUPER VALUE\"}, {\"row_kgs\": 0, \"quantity\": 30, \"product_category\": \"SUJI\"}, {\"row_kgs\": 0, \"quantity\": 10, \"product_category\": \"FORTIFIED MAIDA\"}, {\"row_kgs\": 0, \"quantity\": 300, \"product_category\": \"ATTA REGULAR\"}]",
// "route_code": "k",
// "route_name": "Krishna",
// "store_code": "STR-00024",
// "store_image": "[{\"uuid\": \"a449f6cb-0dce-4e21-9599-ab4d3e47042b\", \"blob_path\": \"434/a449f6cb-0dce-4e21-9599-ab4d3e47042b.png\", \"mime_type\": \"image/png\", \"size_bytes\": 32174, \"original_name\": \"profile.png\"}]",
// "store_location": "{\"latitude\":12.9716,\"longitude\":77.5946}",
// "sub_route": "A"
// },
// "time_of_visit": "13:02:00",
// "upload_image": null
// }
export function CallCard({ row, isDetailView = false }: { row: Record<string, any>; isDetailView?: boolean }) {
// Status
const stateName = String(row.status || row.current_state_name || 'Pending');
const isProductive = stateName.toLowerCase().includes('productive') || stateName.toLowerCase().includes('completed') || stateName.toLowerCase().includes('success');
// Status Mapping
const stateName = row.current_state_name;
const lowerState = stateName.toLowerCase();
// Image
let imageObj = null;
const imgData = row.upload_image || row.store_image || row.image;
if (Array.isArray(imgData) && imgData.length > 0) {
imageObj = imgData[0];
} else if (imgData && typeof imgData === 'object' && imgData.blob_path) {
imageObj = imgData;
let statusClass = 'z-card-status--neutral';
if (lowerState.includes('productive call')) {
statusClass = 'z-card-status--success';
} else if (lowerState.includes('close order') || lowerState.includes('completed') || lowerState.includes('success')) {
statusClass = 'z-card-status--success';
} else if (lowerState.includes('no order')) {
statusClass = 'z-card-status--danger';
} else if (lowerState.includes('visited')) {
statusClass = 'z-card-status--primary';
} else {
statusClass = 'z-card-status--primary'; // default fallback for other active states
}
// Extract nested objects if they exist
@ -20,14 +72,43 @@ export function CallCard({ row, isDetailView = false }: { row: Record<string, an
const userKey = Object.keys(row).find(k => k.includes('user_id'));
const userObj = userKey ? row[userKey] : null;
// Image
let imageObj = null;
let imgData = row.upload_image || storeObj?.store_image || row.store_image || row.image;
if (typeof imgData === 'string' && imgData.startsWith('[')) {
try { imgData = JSON.parse(imgData); } catch (e) { }
}
if (Array.isArray(imgData) && imgData.length > 0) {
imageObj = imgData[0];
} else if (imgData && typeof imgData === 'object' && imgData.blob_path) {
imageObj = imgData;
}
// Store Details
const storeName = formatValue(storeObj?.business_name || storeObj?.distributor_name || row.store_name || row.customer_name || (typeof row.store === 'string' ? row.store : 'Unknown Store'));
const storeCode = formatValue(storeObj?.store_code || row.store_code || '—');
// Grid Details
const totalWeight = formatValue(row.total_kgs || row.total_weight || row.weight || 0);
const totalQuantity = formatValue(row.total_bags || row.total_quantity || row.quantity || 0);
const totalOrders = formatValue(row.order_count || row.total_orders || 0);
// Grid Details (Calculate from potential if missing)
let totalWeightVal = Number(row.total_kgs || row.total_weight || row.weight || 0);
let totalQuantityVal = Number(row.total_bags || row.total_quantity || row.quantity || 0);
let totalOrdersVal = Number(row.order_count || row.total_orders || 0);
if (totalWeightVal === 0 && totalQuantityVal === 0 && storeObj?.potential) {
try {
const pot = typeof storeObj.potential === 'string' ? JSON.parse(storeObj.potential) : storeObj.potential;
if (Array.isArray(pot)) {
totalOrdersVal = pot.length;
pot.forEach((p: any) => {
totalWeightVal += Number(p.row_kgs || 0);
totalQuantityVal += Number(p.quantity || 0);
});
}
} catch (e) { }
}
const totalWeight = formatValue(totalWeightVal);
const totalQuantity = formatValue(totalQuantityVal);
const totalOrders = formatValue(totalOrdersVal);
// Route & Assignment
const routeName = formatValue(storeObj?.route_name || storeObj?.route || row.route_name || row.route || '—');
@ -46,8 +127,9 @@ export function CallCard({ row, isDetailView = false }: { row: Record<string, an
try {
const dVal = String(row.date_of_visit || row.visit_date);
const d = new Date(dVal);
if (!isNaN(d.getTime())) dateStr = d.toISOString().split('T')[0];
else dateStr = dVal.split('T')[0];
if (!isNaN(d.getTime())) {
dateStr = d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, '-');
} else dateStr = dVal.split('T')[0];
} catch {
dateStr = String(row.date_of_visit || row.visit_date).split('T')[0];
}
@ -56,8 +138,18 @@ export function CallCard({ row, isDetailView = false }: { row: Record<string, an
if (row.time_of_visit || row.visit_time) {
try {
const t = String(row.time_of_visit || row.visit_time);
let timeParts = t.split(':');
if (t.includes('T')) {
timeStr = t.split('T')[1].replace('Z', '').split('.')[0];
timeParts = t.split('T')[1].replace('Z', '').split(':');
}
if (timeParts.length >= 2) {
let h = parseInt(timeParts[0]);
const m = timeParts[1];
const ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12;
h = h ? h : 12;
const hStr = h < 10 ? '0' + h : h;
timeStr = `${hStr}:${m} ${ampm}`;
} else {
timeStr = t;
}
@ -66,114 +158,127 @@ export function CallCard({ row, isDetailView = false }: { row: Record<string, an
}
}
// Choose styling based on status
const statusBg = isProductive ? 'bg-emerald-50' : 'bg-amber-50';
const statusBorder = isProductive ? 'border-emerald-100' : 'border-amber-100';
const statusDot = isProductive ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500';
const statusText = isProductive ? 'text-emerald-700' : 'text-amber-700';
return (
<div className="w-full bg-white rounded-2xl shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] border border-slate-100 overflow-hidden font-sans transition-all hover:shadow-[0_8px_24px_-8px_rgba(0,0,0,0.12)]">
{/* Header Status Bar */}
<div className={`${statusBg} px-5 py-3.5 border-b ${statusBorder} flex justify-between items-center`}>
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${statusDot}`}></span>
<span className={`text-[13px] font-bold ${statusText} tracking-wide uppercase`}>{stateName}</span>
<div className="z-card">
{/* Top Row */}
<div className="z-card-header">
<div className="z-card-header-icon">
<TrendingUp size={16} />
<span>{storeCode}</span>
</div>
<span className={`z-card-status ${statusClass}`}>
{stateName}
</span>
</div>
{/* Store Name & Location */}
<div className="z-card-store-row">
<Store size={18} className="z-card-store-icon" />
<h2 className="z-card-store-name">{storeName}</h2>
</div>
<div className="z-card-area-row">
<MapPin size={16} className="z-card-area-icon" />
<p className="z-card-area-name">{areaName}</p>
</div>
{/* Divider */}
<div className="z-card-divider"></div>
{/* Date & Time */}
<div className="z-card-footer">
<div className="z-card-footer-item">
<div className="z-card-footer-label"><Calendar size={14} /> DATE</div>
<span className="z-card-footer-value">{dateStr}</span>
</div>
<div className="z-card-footer-item" style={{ textAlign: 'left', width: '96px' }}>
<div className="z-card-footer-label"><Clock size={14} /> TIME</div>
<span className="z-card-footer-value">{timeStr}</span>
</div>
</div>
{/* Main Content */}
<div className="p-5 space-y-5">
{/* Store Details */}
<div className="flex justify-between items-start gap-4">
<div>
<span className="text-[10px] font-bold text-indigo-600 tracking-wider uppercase">Store Details</span>
<h2 className="text-[17px] font-bold text-slate-800 mt-0.5 leading-tight">{storeName}</h2>
{storeCode !== '—' && (
<p className="text-[13px] text-slate-500 mt-0.5">
Code: <span className="font-mono font-medium text-slate-700">{storeCode}</span>
</p>
)}
</div>
{/* Button (List View Only) */}
{!isDetailView && (
<div className="z-card-action-btn">
<Info size={16} />
<span>See More Details</span>
</div>
)}
{isDetailView && imageObj && imageObj.uuid && (
<div className="w-16 h-16 shrink-0 rounded-lg overflow-hidden border border-slate-200 shadow-sm bg-slate-100 flex items-center justify-center">
{/* Detail View Additions */}
{isDetailView && (
<div className="z-card-details">
{storeCode !== '—' && (
<p className="z-card-meta">
Code: <span className="z-card-meta-code">{storeCode}</span>
</p>
)}
{imageObj && imageObj.uuid && (
<div className="z-card-image-box">
<img
src={`${BASE_URL}/app/${APP_ID}/view/files/${imageObj.uuid}/preview`}
alt="Store"
className="w-full h-full object-cover"
className="z-card-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
{/* Logistics Grid */}
{isDetailView && (
<div className="grid grid-cols-2 gap-4 bg-slate-50 p-4 rounded-xl border border-slate-100/80">
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Total Weight</p>
<p className="text-[17px] font-bold text-slate-800 mt-0.5 leading-none">
{totalWeight} <span className="text-[12px] font-medium text-slate-500">Kgs</span>
</p>
{/* Logistics Grid */}
<div className="z-card-grid">
<div>
<p className="z-card-grid-item-label">Total Weight</p>
<p className="z-card-grid-item-val">
{totalWeight} <span className="z-card-grid-item-unit">Kgs</span>
</p>
</div>
<div>
<p className="z-card-grid-item-label">Total Quantity</p>
<p className="z-card-grid-item-val">
{totalQuantity} <span className="z-card-grid-item-unit">Bags</span>
</p>
</div>
<div className="z-card-grid-footer">
<span className="z-card-grid-footer-label">Total Orders</span>
<span className="z-card-grid-footer-badge">
{totalOrders} {Number(totalOrders) === 1 ? 'Order' : 'Orders'}
</span>
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Total Quantity</p>
<p className="text-[17px] font-bold text-slate-800 mt-0.5 leading-none">
{totalQuantity} <span className="text-[12px] font-medium text-slate-500">Bags</span>
</p>
</div>
<div className="col-span-2 pt-3 border-t border-slate-200/60 flex justify-between items-center">
<span className="text-[12px] font-medium text-slate-500">Total Orders</span>
<span className="bg-indigo-50 text-indigo-700 border border-indigo-100/50 text-[11px] font-bold px-2.5 py-0.5 rounded-full">
{totalOrders} {Number(totalOrders) === 1 ? 'Order' : 'Orders'}
</span>
</div>
</div>
)}
{/* Route & Assignment */}
{isDetailView && (
<div className="grid grid-cols-2 gap-y-4 text-sm border-t border-slate-100 pt-4">
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Route</p>
<p className="font-semibold text-slate-700 mt-0.5 text-[13px]">{routeName}</p>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Area</p>
<p className="font-semibold text-slate-700 mt-0.5 text-[13px]">{areaName}</p>
</div>
<div className="col-span-2">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Sales Officer</p>
<div className="flex items-center gap-2 mt-1.5">
<div className="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center font-bold text-[11px] text-slate-600 uppercase shadow-inner">
{initials}
</div>
<div className="flex flex-col min-w-0">
<p className="font-semibold text-[13px] text-slate-800 leading-tight truncate">{userName}</p>
{userEmail !== '—' && (
<p className="text-[11px] text-slate-500 font-mono truncate">{userEmail}</p>
)}
{/* Route & Assignment */}
<div className="z-card-route">
<div>
<p className="z-card-grid-item-label">Route</p>
<p className="z-card-route-val">{routeName}</p>
</div>
<div>
<p className="z-card-grid-item-label">Area</p>
<p className="z-card-route-val">{areaName}</p>
</div>
<div className="z-card-so-box">
<p className="z-card-grid-item-label">Sales Officer</p>
<div className="z-card-so-info">
<div className="z-card-so-avatar">
{initials}
</div>
<div className="z-card-so-details">
<p className="z-card-so-name">{userName}</p>
{userEmail !== '—' && (
<p className="z-card-so-email">{userEmail}</p>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer Timeline */}
<div className="bg-slate-50/70 px-5 py-3 border-t border-slate-100 flex justify-between text-[11px] text-slate-500 font-medium">
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
<span>{dateStr}</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>{timeStr}</span>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,318 @@
.z-card {
position: relative;
width: 100%;
background-color: var(--z-bg-neutral-100);
border-radius: var(--block-radius);
padding: var(--block-padding);
box-shadow: var(--block-shadow);
transition: all 0.2s ease-in-out;
border: 1px solid var(--z-border-neutral-300);
font-family: var(--font-sans);
}
.z-card:hover {
box-shadow: var(--z-shadow-md);
}
.z-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.z-card-header-icon {
display: flex;
align-items: center;
gap: 6px;
color: var(--z-text-primary-300);
font-weight: 700;
font-size: var(--z-font-sm);
}
.z-card-status {
padding: 4px 12px;
border-radius: var(--z-border-radius-pill);
font-size: var(--z-font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.z-card-status--success {
background-color: var(--z-bg-success-100);
color: var(--z-text-success-400);
}
.z-card-status--warning {
background-color: var(--z-bg-warning-100);
color: var(--z-text-warning-400);
}
.z-card-status--danger {
background-color: var(--z-bg-danger-100);
color: var(--z-text-danger-400);
}
.z-card-status--primary {
background-color: var(--z-bg-primary-100);
color: var(--z-text-primary-400);
}
.z-card-status--neutral {
background-color: var(--z-bg-neutral-200);
color: var(--z-text-neutral-600);
}
.z-card-store-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.z-card-store-icon {
color: var(--z-text-neutral-500);
}
.z-card-store-name {
font-size: 17px;
font-weight: 700;
color: var(--z-text-neutral-900);
line-height: 1.2;
}
.z-card-area-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.z-card-area-icon {
color: var(--z-text-neutral-700);
}
.z-card-area-name {
font-size: var(--z-font-sm);
color: var(--z-text-neutral-700);
}
.z-card-divider {
width: 100%;
height: 1px;
background-color: var(--z-border-neutral-300);
margin-bottom: 16px;
}
.z-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.z-card-footer-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.z-card-footer-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--z-text-neutral-600);
font-size: var(--z-font-size-sm);
font-weight: 600;
text-transform: uppercase;
}
.z-card-footer-value {
color: var(--z-text-neutral-900);
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
}
.z-card-action-btn {
width: 100%;
background-color: var(--z-bg-neutral-200);
border-radius: var(--z-border-radius-lg);
padding: 10px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--z-text-primary-400);
font-weight: 600;
font-size: var(--z-font-sm);
transition: background-color 0.2s ease;
cursor: pointer;
}
.z-card-action-btn:hover {
background-color: var(--z-bg-neutral-300);
}
.z-card-details {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 12px;
}
.z-card-meta {
font-size: 13px;
color: var(--z-text-neutral-500);
margin-top: 2px;
}
.z-card-meta-code {
font-family: var(--font-mono);
font-weight: 500;
color: var(--z-text-neutral-700);
}
.z-card-image-box {
width: 100%;
height: 192px;
border-radius: var(--z-border-radius-md);
overflow: hidden;
border: 1px solid var(--z-border-neutral-300);
box-shadow: var(--z-shadow-sm);
background-color: var(--z-bg-neutral-200);
display: flex;
align-items: center;
justify-content: center;
}
.z-card-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.z-card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
background-color: var(--z-bg-neutral-200);
padding: 16px;
border-radius: var(--z-border-radius-lg);
border: 1px solid var(--z-border-neutral-300);
}
.z-card-grid-item-label {
font-size: 10px;
font-weight: 700;
color: var(--z-text-neutral-400);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.z-card-grid-item-val {
font-size: 17px;
font-weight: 700;
color: var(--z-text-neutral-900);
margin-top: 2px;
line-height: 1;
}
.z-card-grid-item-unit {
font-size: 12px;
font-weight: 500;
color: var(--z-text-neutral-500);
}
.z-card-grid-footer {
grid-column: span 2;
padding-top: 12px;
border-top: 1px solid var(--z-border-neutral-300);
display: flex;
justify-content: space-between;
align-items: center;
}
.z-card-grid-footer-label {
font-size: 12px;
font-weight: 500;
color: var(--z-text-neutral-500);
}
.z-card-grid-footer-badge {
background-color: var(--z-bg-primary-100);
color: var(--z-text-primary-400);
border: 1px solid var(--z-border-primary-100);
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: var(--z-border-radius-full);
}
.z-card-route {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
font-size: var(--z-font-sm);
border-top: 1px solid var(--z-border-neutral-300);
padding-top: 16px;
}
.z-card-route-val {
font-weight: 600;
color: var(--z-text-neutral-700);
margin-top: 2px;
font-size: 13px;
}
.z-card-so-box {
grid-column: span 2;
}
.z-card-so-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.z-card-so-avatar {
width: 32px;
height: 32px;
background-color: var(--z-bg-neutral-300);
border-radius: var(--z-border-radius-full);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 11px;
color: var(--z-text-neutral-600);
text-transform: uppercase;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
}
.z-card-so-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.z-card-so-name {
font-weight: 600;
font-size: 13px;
color: var(--z-text-neutral-900);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.z-card-so-email {
font-size: 11px;
color: var(--z-text-neutral-500);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -27,9 +27,10 @@ export interface DynamicFormProps {
onCancel?: () => void;
ignorePrefill?: boolean;
customPrefillData?: Record<string, unknown>;
onActivityChange?: (activityName: string) => void;
}
export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel, ignorePrefill, customPrefillData }: DynamicFormProps) {
export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel, ignorePrefill, customPrefillData, onActivityChange }: DynamicFormProps) {
const [currentActivityId, setCurrentActivityId] = useState(initialActivityId);
const [currentInstanceId, setCurrentInstanceId] = useState<number | string | undefined>(initialInstanceId);
const [chainQueue, setChainQueue] = useState<Array<{ activity_uid: string; activity_name: string }>>([]);
@ -209,6 +210,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
setChainQueue(pending);
setCurrentActivityId(nextActivity.activity_uid);
setCurrentInstanceId(res.instance_id ?? currentInstanceId);
onActivityChange?.(nextActivity.activity_name);
} else {
onSuccess?.();
}

View File

@ -24,7 +24,7 @@ export function SmartGridField({
const visibleColumns = columns.filter(c => {
const normalized = c.id.toLowerCase().replace(/[^a-z]/g, '');
// Use a blacklist so we don't accidentally hide important columns from other grids
return !['sku', 'rowkgs', 'brcode'].includes(normalized);
return !['sku', 'skucode', 'rowkgs', 'brcode'].includes(normalized);
});
const removeRow = (idx: number) => {

View File

@ -0,0 +1,41 @@
import type { TileItem } from "../../api/types";
import { Phone, TrendingUp, Activity, BarChart2, ShoppingCart, ShoppingBag, Scale } from 'lucide-react';
import './reusable.css';
import '../../styles/styles.css';
export interface StatsTileProps {
tile: TileItem;
idx?: number;
}
export function StatsTile({ tile, idx = 0 }: StatsTileProps) {
const displayLabel = (tile.key || `tile_${idx}`)
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
const lowerKey = String(tile.key).toLowerCase();
// Try to pick a relevant icon
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 (
<div className="z-stats-tile z-stats-tile--secondary">
<div className="z-stats-header">
<span className={`z-stats-label`}>{displayLabel}</span>
<Icon size={16} className={`z-stats-icon ${isDanger ? 'z-stats-icon--danger' : ''}`} />
</div>
<p className={`z-stats-value nums ${isDanger ? 'z-stats-value--danger' : ''}`}>
{(tile.value as React.ReactNode) ?? "-"}
</p>
</div>
);
}

View File

@ -1,45 +1,20 @@
import type { TileItem } from "../../api/types";
import { StatsTile } from "./StatsTile";
import './reusable.css';
import '../../styles/styles.css';
export interface StatsTilesProps {
tiles?: TileItem[];
}
const TONES = [
"bg-sunrise-500",
"bg-emerald-500",
"bg-sky-500",
"bg-amber-500",
"bg-navy-600",
"bg-ruby-500",
];
export function StatsTiles({ tiles }: StatsTilesProps) {
if (!tiles?.length) return null;
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
{tiles.map((tile, idx) => {
const displayLabel = (tile.key || `tile_${idx}`)
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div
key={tile.tile_uid || idx}
className="relative overflow-hidden rounded-xl border border-border-subtle bg-card p-4 shadow-sm"
>
<span
className={`absolute inset-x-0 top-0 h-1 ${TONES[idx % TONES.length]}`}
/>
<p className="text-2xs font-semibold uppercase tracking-[0.12em] text-muted">
{displayLabel}
</p>
<p className="mt-2 font-numeric text-3xl font-bold leading-none tracking-tight text-strong nums">
{(tile.value as React.ReactNode) ?? "-"}
</p>
</div>
);
})}
<div className="z-stats-container hide-scrollbar">
{tiles.map((tile, idx) => (
<StatsTile key={tile.tile_uid || idx} tile={tile} idx={idx} />
))}
</div>
);
}

View File

@ -0,0 +1,113 @@
.tile {
box-shadow: var(--block-shadow);
}
/* Hide scrollbar for horizontal scroll containers */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Horizontal Scroll Container */
.z-stats-container {
display: flex;
overflow-x: auto;
gap: 12px;
padding-bottom: 8px;
/* For box-shadow clipping */
scroll-snap-type: x mandatory;
}
/* Base Tile */
.z-stats-tile {
flex: 0 0 auto;
width: 160px;
height: 100px;
border-radius: var(--z-border-radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
scroll-snap-align: start;
transition: transform 0.2s ease;
box-shadow: var(--block-shadow);
font-family: var(--font-sans);
}
/* Secondary Tile (White) */
.z-stats-tile--secondary {
background-color: var(--z-bg-neutral-100);
border: 1px solid var(--z-border-neutral-300);
color: var(--z-text-neutral-900);
}
.z-stats-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.z-stats-label {
font-size: var(--z-font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
}
.z-stats-tile--secondary .z-stats-label {
color: var(--z-text-neutral-500);
}
.z-stats-tile--secondary .z-stats-label.z-stats-label--danger {
color: var(--z-text-danger-400);
}
.z-stats-tile--secondary .z-stats-icon {
color: var(--z-text-success-400);
}
.z-stats-tile--secondary .z-stats-icon.z-stats-icon--danger {
color: var(--z-text-danger-400);
}
.z-stats-value {
font-size: 30px;
font-weight: 800;
line-height: 1.1;
font-family: var(--font-numeric);
}
.z-stats-tile--secondary .z-stats-value {
color: var(--z-text-primary-400);
}
.z-stats-tile--secondary .z-stats-value.z-stats-value--danger {
color: var(--z-text-danger-400);
}
.z-stats-footer {
margin-top: auto;
}
.z-stats-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
border-radius: var(--z-border-radius-pill);
}
.z-stats-tile--secondary .z-stats-badge {
padding: 0;
color: var(--z-text-success-400);
text-transform: uppercase;
}

View File

@ -18,6 +18,7 @@ export function CallsView({ onRowClick, pageSize, headerActions, rowActions, ref
refreshKey={refreshKey}
sortBy="instance_id"
sortDir="desc"
hideChart={true}
renderItem={(row) => <CallCard row={row} />}
/>
);

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search, Filter } from 'lucide-react';
import { Search, SlidersHorizontal } from 'lucide-react';
import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client';
@ -40,6 +40,8 @@ export interface RecordViewProps {
sortBy?: string;
/** Sort direction */
sortDir?: 'asc' | 'desc';
/** If true, the analytics chart will not be rendered even if data is returned */
hideChart?: boolean;
}
/**
@ -61,6 +63,7 @@ export function RecordView({
renderItem,
sortBy,
sortDir,
hideChart = false,
}: RecordViewProps) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
@ -139,7 +142,7 @@ export function RecordView({
<div className="flex flex-col gap-5 w-full">
<StatsTiles tiles={tileValues} />
<AnalyticsChart data={chartData} />
{!hideChart && <AnalyticsChart data={chartData} />}
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-bold tracking-tight text-strong">{title}</h2>
@ -164,16 +167,13 @@ export function RecordView({
setShowFilters(true);
}}
className={cn(
"flex items-center justify-center h-11 px-4 rounded-pill border transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-slate-300",
Object.values(activeFilters).some(Boolean)
? "bg-slate-800 text-white border-slate-800"
: "bg-white text-slate-700 border-border-subtle hover:bg-slate-50"
"relative flex items-center justify-center w-11 h-11 rounded-lg transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-[var(--z-bg-primary-400)]",
"bg-[var(--z-bg-primary-400)] text-white border border-[var(--z-bg-primary-400)] hover:opacity-90"
)}
>
<Filter size={16} className={cn("mr-2", Object.values(activeFilters).some(Boolean) ? "text-slate-300" : "text-faint")} />
<span className="font-semibold text-[13px]">Filter</span>
<SlidersHorizontal size={16} className="text-white" />
{Object.values(activeFilters).filter(Boolean).length > 0 && (
<span className="ml-1.5 bg-indigo-500 text-white text-[10px] w-4 h-4 flex items-center justify-center rounded-full font-bold">
<span className="absolute -top-1 -right-1 bg-white text-[var(--z-text-primary-400)] text-[10px] w-4 h-4 flex items-center justify-center rounded-full font-bold shadow-sm border border-[var(--z-bg-primary-400)]">
{Object.values(activeFilters).filter(Boolean).length}
</span>
)}
@ -187,19 +187,19 @@ export function RecordView({
title="Filters"
width="sm"
actions={
Object.values(pendingFilters).some(Boolean) && (
<button
onClick={() => {
setPendingFilters({});
setActiveFilters({});
setPage(1);
setShowFilters(false);
}}
className="text-xs font-semibold text-ruby-600 hover:text-ruby-700 bg-ruby-50 px-3 py-1.5 rounded-md"
>
Clear All
</button>
)
Object.values(pendingFilters).some(Boolean) && (
<button
onClick={() => {
setPendingFilters({});
setActiveFilters({});
setPage(1);
setShowFilters(false);
}}
className="text-xs font-semibold text-ruby-600 hover:text-ruby-700 bg-ruby-50 px-3 py-1.5 rounded-md"
>
Clear All
</button>
)
}
>
<div className="flex flex-col gap-5">

View File

@ -4,7 +4,7 @@ import { CallsView } from '../components/rv';
import { CallDetail } from '../components/dv';
import { useState } from 'react';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { Phone, Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
@ -14,6 +14,7 @@ export function CallsPage() {
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [createTitle, setCreateTitle] = useState("Log Visit");
const [refreshKey, setRefreshKey] = useState(0);
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
const [isFabOpen, setIsFabOpen] = useState(false);
@ -75,17 +76,20 @@ export function CallsPage() {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/calls/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
Log Visit
</Button>
}
/>
{/* Global Floating Action Button for Add Call */}
<button
onClick={() => { setIsCreating(true); setCreateTitle("Log Visit"); }}
className="fixed bottom-24 right-6 w-[60px] h-[60px] bg-[var(--z-bg-primary-400)] rounded-3xl flex items-center justify-center shadow-[0_4px_12px_rgba(var(--z-bg-primary-400-rgb),0.4)] text-white z-40 hover:opacity-90 transition-transform hover:scale-105 active:scale-95"
>
<Phone size={20} className="text-white" />
</button>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Log Visit"
title={createTitle}
width="md"
>
<DynamicForm
@ -96,6 +100,7 @@ export function CallsPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
onActivityChange={(name) => setCreateTitle(name)}
/>
</Modal>
@ -136,7 +141,7 @@ export function CallsPage() {
title={activeActivity?.name}
width="md"
>
{activeActivity && instanceId != null && (
{activeActivity && (
<DynamicForm
client={orderBookingClient}
activityId={activeActivity.id}
@ -148,6 +153,7 @@ export function CallsPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setActiveActivity(null)}
onActivityChange={(name) => setActiveActivity(prev => prev ? { ...prev, name } : null)}
/>
)}
</Modal>

View File

@ -14,7 +14,9 @@ export function DailyLogsPage() {
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [createTitle, setCreateTitle] = useState("Punch In");
const [punchOutInstanceId, setPunchOutInstanceId] = useState<number | string | null>(null);
const [punchOutTitle, setPunchOutTitle] = useState("Punch Out");
const [refreshKey, setRefreshKey] = useState(0);
return (
@ -26,17 +28,20 @@ export function DailyLogsPage() {
if (id != null) navigate(`/daily/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => { setIsCreating(true); setCreateTitle("Punch In"); }}>
Punch In
</Button>
}
onPunchOutRow={(row) => setPunchOutInstanceId(row.instance_id as string | number)}
onPunchOutRow={(row) => {
setPunchOutInstanceId(row.instance_id as string | number);
setPunchOutTitle("Punch Out");
}}
/>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Punch In"
title={createTitle}
width="md"
>
<DynamicForm
@ -47,13 +52,14 @@ export function DailyLogsPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
onActivityChange={(name) => setCreateTitle(name)}
/>
</Modal>
<Modal
open={punchOutInstanceId != null}
onClose={() => setPunchOutInstanceId(null)}
title="Punch Out"
title={punchOutTitle}
width="md"
>
{punchOutInstanceId != null && (
@ -66,6 +72,7 @@ export function DailyLogsPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setPunchOutInstanceId(null)}
onActivityChange={(name) => setPunchOutTitle(name)}
/>
)}
</Modal>

View File

@ -14,6 +14,7 @@ export function OrdersPage() {
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [createTitle, setCreateTitle] = useState("Place Order");
const [refreshKey, setRefreshKey] = useState(0);
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
@ -26,7 +27,7 @@ export function OrdersPage() {
if (id != null) navigate(`/orders/${id}`);
}}
headerActions={
<Button size="sm" variant={'outline'} iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
<Button size="sm" variant={'outline'} iconLeft={<Plus size={14} />} onClick={() => { setIsCreating(true); setCreateTitle("Place Order"); }}>
Place Order
</Button>
}
@ -35,7 +36,7 @@ export function OrdersPage() {
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Place Order"
title={createTitle}
width="md"
>
<DynamicForm
@ -46,6 +47,7 @@ export function OrdersPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
onActivityChange={(name) => setCreateTitle(name)}
/>
</Modal>
@ -75,6 +77,7 @@ export function OrdersPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setActiveActivity(null)}
onActivityChange={(name) => setActiveActivity(prev => prev ? { ...prev, name } : null)}
/>
)}
</Modal>

View File

@ -14,7 +14,9 @@ export function StoresPage() {
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [createTitle, setCreateTitle] = useState("Create Store");
const [editingInstanceId, setEditingInstanceId] = useState<number | string | null>(null);
const [editTitle, setEditTitle] = useState("Edit Store");
const [refreshKey, setRefreshKey] = useState(0);
return (
@ -26,17 +28,20 @@ export function StoresPage() {
if (id != null) navigate(`/stores/${id}`);
}}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => { setIsCreating(true); setCreateTitle("Create Store"); }}>
Create Store
</Button>
}
onEditRow={(row) => setEditingInstanceId(row.instance_id as string | number)}
onEditRow={(row) => {
setEditingInstanceId(row.instance_id as string | number);
setEditTitle("Edit Store");
}}
/>
<Modal
open={isCreating}
onClose={() => setIsCreating(false)}
title="Create Store"
title={createTitle}
width="md"
>
<DynamicForm
@ -47,13 +52,14 @@ export function StoresPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setIsCreating(false)}
onActivityChange={(name) => setCreateTitle(name)}
/>
</Modal>
<Modal
open={editingInstanceId != null}
onClose={() => setEditingInstanceId(null)}
title="Edit Store"
title={editTitle}
width="md"
>
{editingInstanceId != null && (
@ -66,6 +72,7 @@ export function StoresPage() {
setRefreshKey(k => k + 1);
}}
onCancel={() => setEditingInstanceId(null)}
onActivityChange={(name) => setEditTitle(name)}
/>
)}
</Modal>

View File

@ -192,7 +192,7 @@
/* root variable */
:root {
--primary-color: #0058be;
--primary-color: #1d4ed8;
--secondary-color: #f9fafb;
--font-color: #10182b;
--font-family: "IBM Plex Sans", serif;
@ -257,7 +257,8 @@
--block-padding: 20px;
--block-radius: 20px;
--block-border: #e5e7eb;
--block-shadow: 0px 4px 8px 0px rgba(228, 231, 236, 0.3);
/* --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;
@ -327,7 +328,7 @@
--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: 8px;
--z-font-size-xs: 10px;
--z-font-size-sm: 12px;
--z-font-size-md: 14px;
--z-font-size-lg: 16px;
@ -412,7 +413,7 @@
--z-bg-warning-400: #dfb400;
/* Success Colors */
--z-bg-success-100: #a6f4c5;
--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;