cards design polished
This commit is contained in:
parent
3eb445d506
commit
aced981fae
148
src/components/cards/CallCard.tsx
Normal file
148
src/components/cards/CallCard.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
|
||||||
|
export function CallCard({ row }: { row: Record<string, any> }) {
|
||||||
|
const instanceId = row.instance_id || '—';
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// Store Details
|
||||||
|
const storeName = formatValue(row.store || row.store_name || row.customer_name || 'Unknown Store');
|
||||||
|
const storeCode = formatValue(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);
|
||||||
|
|
||||||
|
// Route & Assignment
|
||||||
|
const routeName = formatValue(row.route_name || row.route || '—');
|
||||||
|
const areaName = formatValue(row.area || row.area_name || '—');
|
||||||
|
|
||||||
|
// User
|
||||||
|
const userName = formatValue(row.sales_officer || row.user_name || '—');
|
||||||
|
const userEmail = formatValue(row.performed_by_email || row.user_email || '—');
|
||||||
|
const initials = userName !== '—' ? String(userName).substring(0, 2).toUpperCase() : 'SO';
|
||||||
|
|
||||||
|
// Date & Time
|
||||||
|
let dateStr = '—';
|
||||||
|
let timeStr = '—';
|
||||||
|
|
||||||
|
if (row.visit_date) {
|
||||||
|
try {
|
||||||
|
const d = new Date(row.visit_date as string);
|
||||||
|
if (!isNaN(d.getTime())) dateStr = d.toISOString().split('T')[0];
|
||||||
|
} catch {
|
||||||
|
dateStr = String(row.visit_date).split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.visit_time) {
|
||||||
|
try {
|
||||||
|
const t = String(row.visit_time);
|
||||||
|
if (t.includes('T')) {
|
||||||
|
timeStr = t.split('T')[1].replace('Z', '').split('.')[0];
|
||||||
|
} else {
|
||||||
|
timeStr = t;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
timeStr = String(row.visit_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
<span className="text-[11px] font-mono bg-white border border-slate-200 text-slate-600 px-2.5 py-1 rounded-md font-medium shadow-sm">
|
||||||
|
ID: {instanceId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
{/* Store Details */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Logistics Grid */}
|
||||||
|
<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>
|
||||||
|
</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 */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/cards/DailyLogCard.tsx
Normal file
140
src/components/cards/DailyLogCard.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
import { FileImage } from 'lucide-react';
|
||||||
|
|
||||||
|
export function DailyLogCard({ row, onPunchOut }: { row: Record<string, any>; onPunchOut?: (row: any) => void }) {
|
||||||
|
const stateName = String(row.status || row.current_state_name || 'Logged');
|
||||||
|
|
||||||
|
// User details
|
||||||
|
const userObj = (row.sales_officer_name || row.user_id || row.user) as Record<string, any> | null;
|
||||||
|
const userName = formatValue(userObj?.name || row.user_name || 'Unknown User');
|
||||||
|
const userEmail = formatValue(userObj?.email || row.user_email || '—');
|
||||||
|
const initials = userName !== 'Unknown User' && userName !== '—' ? String(userName).substring(0, 2).toUpperCase() : 'US';
|
||||||
|
|
||||||
|
// Punch Details
|
||||||
|
let dateStr = '—';
|
||||||
|
let timeStr = '—';
|
||||||
|
|
||||||
|
if (row.date) dateStr = String(row.date).split('T')[0];
|
||||||
|
else if (row.created_at) dateStr = String(row.created_at).split('T')[0];
|
||||||
|
|
||||||
|
if (row.time) timeStr = String(row.time).split('T')[1]?.split('.')[0] || String(row.time);
|
||||||
|
else if (row.created_at) timeStr = String(row.created_at).split('T')[1]?.split('.')[0] || '—';
|
||||||
|
|
||||||
|
const routeCode = formatValue(row.route_code || row.route || '—');
|
||||||
|
const dayPlanNotes = formatValue(row.day_plan_notes || row.notes || row.plan || 'No notes provided.');
|
||||||
|
|
||||||
|
// Store Image / Verification Media
|
||||||
|
const imageArray = row.store_image || row.image || row.verification_media;
|
||||||
|
const imgData = Array.isArray(imageArray) && imageArray.length > 0 ? imageArray[0] : null;
|
||||||
|
|
||||||
|
let imgName = 'None attached';
|
||||||
|
let imgSize = '—';
|
||||||
|
let imgMime = '—';
|
||||||
|
|
||||||
|
if (imgData && typeof imgData === 'object') {
|
||||||
|
imgName = imgData.original_name || imgData.name || 'attachment.png';
|
||||||
|
const sizeBytes = imgData.size_bytes || imgData.size || 0;
|
||||||
|
imgSize = sizeBytes > 0 ? `${(sizeBytes / 1024).toFixed(1)} KB` : 'Unknown size';
|
||||||
|
imgMime = imgData.mime_type || imgData.type || 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasImage = imgName !== 'None attached';
|
||||||
|
|
||||||
|
// Dynamic styling based on status
|
||||||
|
const isPunchedIn = stateName.toLowerCase().includes('in') || stateName.toLowerCase().includes('active');
|
||||||
|
const statusBg = isPunchedIn ? 'bg-amber-50' : 'bg-slate-50';
|
||||||
|
const statusBorder = isPunchedIn ? 'border-amber-100' : 'border-slate-200';
|
||||||
|
const statusDot = isPunchedIn ? 'bg-amber-500 animate-pulse' : 'bg-slate-400';
|
||||||
|
const statusText = isPunchedIn ? 'text-amber-800' : 'text-slate-600';
|
||||||
|
|
||||||
|
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 duration-300 hover:shadow-[0_8px_24px_-8px_rgba(0,0,0,0.12)]">
|
||||||
|
|
||||||
|
{/* Attendance Status Header */}
|
||||||
|
<div className={`${statusBg} px-6 py-4 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-sm font-semibold ${statusText} tracking-wide uppercase`}>{stateName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Profile and Account Info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-indigo-50 rounded-full flex items-center justify-center font-bold text-base text-indigo-600 uppercase border border-indigo-100 shadow-inner">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 leading-tight truncate">{userName}</h3>
|
||||||
|
{userEmail !== '—' && (
|
||||||
|
<p className="text-xs text-slate-400 font-mono mt-0.5 truncate">{userEmail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Punch Details Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 bg-slate-50 p-4 rounded-xl border border-slate-100/80 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">Punch Date</span>
|
||||||
|
<p className="font-semibold text-slate-800 mt-1">{dateStr}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">Punch Time</span>
|
||||||
|
<p className="font-semibold text-slate-800 mt-1">{timeStr}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 pt-2.5 border-t border-slate-200/60 flex justify-between items-center">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 tracking-wide">Route Assigned</span>
|
||||||
|
<span className="bg-slate-200/70 text-slate-700 text-xs font-bold px-2.5 py-0.5 rounded-full uppercase">
|
||||||
|
Route: {routeCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Plan & Notes */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">Day Plan Summary</span>
|
||||||
|
<div className="bg-indigo-50/40 border border-indigo-100/40 p-3.5 rounded-xl">
|
||||||
|
<p className="text-sm font-medium text-indigo-900 italic leading-relaxed">
|
||||||
|
"{dayPlanNotes}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attached Verification Media */}
|
||||||
|
{hasImage && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">Verification Image</span>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="text-indigo-500 bg-indigo-50 p-1.5 rounded-lg border border-indigo-100/50">
|
||||||
|
<FileImage size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<p className="text-xs font-bold text-slate-800 truncate max-w-[180px]">{imgName}</p>
|
||||||
|
<p className="text-[10px] text-slate-400 font-mono mt-0.5">{imgSize}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100 uppercase">
|
||||||
|
MIME: {imgMime.split('/')[1] || 'Media'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline Punch Out Action */}
|
||||||
|
{onPunchOut && isPunchedIn && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPunchOut(row); }}
|
||||||
|
className="w-full bg-slate-800 text-white text-[13px] font-bold py-2.5 rounded-xl hover:bg-slate-700 transition-colors shadow-sm flex justify-center items-center gap-2 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Punch Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/cards/StoreCard.tsx
Normal file
202
src/components/cards/StoreCard.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { formatValue } from '../../lib/format';
|
||||||
|
import { Edit2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function StoreCard({ row, fields, onEdit }: { row: Record<string, any>; fields: any[]; onEdit?: (row: any) => void }) {
|
||||||
|
// Status
|
||||||
|
const stateName = String(row.current_state_name || row.status || 'Active');
|
||||||
|
const isActive = stateName.toLowerCase().includes('active') || stateName.toLowerCase().includes('created') || stateName.toLowerCase().includes('approved');
|
||||||
|
|
||||||
|
// Store Details
|
||||||
|
const storeName = formatValue(row.business_name || row.store_name || row.name || 'Unknown Store');
|
||||||
|
const storeCode = formatValue(row.store_code || row.code || '—');
|
||||||
|
const ownerName = formatValue(row.owner_name || row.contact_person || '—');
|
||||||
|
const phoneObj = row.phone_number as Record<string, unknown> | null;
|
||||||
|
const phone = formatValue(phoneObj?.phone_with_dial_code || phoneObj?.phone || row.phone || row.mobile || '—');
|
||||||
|
|
||||||
|
// Route Info
|
||||||
|
const routeName = formatValue(row.route_name || row.route || '—');
|
||||||
|
const subRouteName = formatValue(row.sub_route || row.sub_route_name || '—');
|
||||||
|
const routeCode = formatValue(row.route_code || routeName.charAt(0) || 'R');
|
||||||
|
|
||||||
|
// Distributor Info
|
||||||
|
const distName = formatValue(row.distributor_name || '—');
|
||||||
|
const distContact = formatValue(row.distributor_owner_name || '—');
|
||||||
|
const distPhoneObj = row.distributor_phone_number as Record<string, unknown> | null;
|
||||||
|
const distPhone = formatValue(distPhoneObj?.phone_with_dial_code || distPhoneObj?.phone || row.distributor_phone || '—');
|
||||||
|
|
||||||
|
// Address
|
||||||
|
const address = formatValue(row.complete_address || row.address || '—');
|
||||||
|
const pin = formatValue(row.pin_code || row.pincode || '—');
|
||||||
|
const locObj = row.store_location as Record<string, unknown> | null;
|
||||||
|
const lat = formatValue(locObj?.latitude || row.latitude || '—');
|
||||||
|
const lng = formatValue(locObj?.longitude || row.longitude || '—');
|
||||||
|
const areaName = formatValue(row.area || row.area_name || '—');
|
||||||
|
|
||||||
|
// User
|
||||||
|
const userKey = Object.keys(row).find(k => k.includes('user_id') || k.includes('created_by') || k.includes('sales_officer'));
|
||||||
|
const userObj = userKey ? (typeof row[userKey] === 'object' ? row[userKey] : null) : null;
|
||||||
|
const userName = formatValue(userObj?.name || row.sales_officer || row.user_name || '—');
|
||||||
|
const userEmail = formatValue(userObj?.email || row.user_email || '—');
|
||||||
|
const initials = userName !== '—' ? String(userName).substring(0, 2).toUpperCase() : 'SO';
|
||||||
|
|
||||||
|
// Date
|
||||||
|
let dateStr = '—';
|
||||||
|
const dateKey = Object.keys(row).find(k => k.includes('created_at'));
|
||||||
|
const rawDate = dateKey ? row[dateKey] : (row.created_at || row.date || row.registration_date);
|
||||||
|
if (rawDate) {
|
||||||
|
try {
|
||||||
|
const d = new Date(rawDate as string);
|
||||||
|
if (!isNaN(d.getTime())) dateStr = d.toISOString().split('T')[0];
|
||||||
|
else dateStr = String(rawDate).split('T')[0];
|
||||||
|
} catch {
|
||||||
|
dateStr = String(rawDate).split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Potential (grid)
|
||||||
|
const potentialKey = Object.keys(row).find(k => k.includes('potential') || k.includes('inventory'));
|
||||||
|
let potentials: any[] = [];
|
||||||
|
if (potentialKey && Array.isArray(row[potentialKey])) {
|
||||||
|
potentials = row[potentialKey];
|
||||||
|
} else {
|
||||||
|
// try to find any grid
|
||||||
|
const gridField = fields.find(f => f.data_type === 'grid');
|
||||||
|
if (gridField && Array.isArray(row[gridField.field_key])) {
|
||||||
|
potentials = row[gridField.field_key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBg = isActive ? 'bg-blue-50' : 'bg-slate-50';
|
||||||
|
const statusBorder = isActive ? 'border-blue-100' : 'border-slate-200';
|
||||||
|
const statusDot = isActive ? 'bg-blue-500 animate-pulse' : 'bg-slate-400';
|
||||||
|
const statusText = isActive ? 'text-blue-700' : 'text-slate-600';
|
||||||
|
|
||||||
|
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`}>{storeCode}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit?.(row);
|
||||||
|
}}
|
||||||
|
className={`w-7 h-7 rounded-full flex items-center justify-center bg-white border border-slate-200 shadow-sm ${statusText} hover:bg-slate-50 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-200 focus:ring-offset-1`}
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
{/* Store Identity */}
|
||||||
|
<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 break-words w-full">{storeName}</h2>
|
||||||
|
|
||||||
|
{/* Status Badge for Route/Sub-route */}
|
||||||
|
{(routeName !== '—' || subRouteName !== '—') && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="inline-block bg-slate-100 text-slate-700 text-[11px] font-bold px-2.5 py-1 rounded-md">
|
||||||
|
R: {routeName} <span className="uppercase opacity-50">({String(routeCode).substring(0, 1)})</span>
|
||||||
|
{subRouteName !== '—' && ` • Sub: ${subRouteName}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
{(ownerName !== '—' || phone !== '—') && (
|
||||||
|
<div className="mt-3 space-y-1.5 text-[13px] text-slate-600">
|
||||||
|
{ownerName !== '—' && (
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-slate-400 text-[10px] w-12 uppercase tracking-wider">Owner</span>
|
||||||
|
<span className="text-slate-700 font-semibold truncate">{ownerName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{phone !== '—' && (
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-slate-400 text-[10px] w-12 uppercase tracking-wider">Phone</span>
|
||||||
|
<span className="font-mono text-slate-700">{phone}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fulfillment / Distributor */}
|
||||||
|
{distName !== '—' && (
|
||||||
|
<div className="bg-slate-50 p-3.5 rounded-xl border border-slate-100 space-y-2">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Assigned Distributor</p>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[14px] font-bold text-slate-800 leading-tight">{distName}</h4>
|
||||||
|
{distContact !== '—' && <p className="text-[12px] font-medium text-slate-500 mt-0.5">Contact: {distContact}</p>}
|
||||||
|
{distPhone !== '—' && <p className="text-[12px] font-mono text-slate-600 mt-0.5">{distPhone}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Potential Breakdown */}
|
||||||
|
{potentials.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-2">Inventory Potential</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{potentials.map((item, idx) => {
|
||||||
|
const prodName = formatValue(item.product_name || item.product_category || item.product || `Item ${idx+1}`);
|
||||||
|
const qty = formatValue(item.quantity || item.qty || item.potential || item.bags || 0);
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex justify-between items-center text-[13px] px-2.5 py-1.5 rounded-lg bg-indigo-50/50 border border-indigo-100/40">
|
||||||
|
<span className="font-semibold text-slate-700 truncate mr-2">{prodName}</span>
|
||||||
|
<span className="font-bold text-indigo-700 shrink-0">{qty} Qty</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logistics & Address */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm border-t border-slate-100 pt-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Address</p>
|
||||||
|
<p className="text-slate-700 mt-0.5 font-medium text-[13px] leading-tight">{address}</p>
|
||||||
|
{(pin !== '—' || lat !== '—') && (
|
||||||
|
<p className="text-[11px] font-mono text-slate-400 mt-1">
|
||||||
|
{pin !== '—' && `PIN: ${pin}`} {pin !== '—' && lat !== '—' && '|'} {lat !== '—' && `Loc: ${lat}, ${lng}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Created By</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Timestamp */}
|
||||||
|
<div className="bg-slate-50/70 px-5 py-3 border-t border-slate-100 flex justify-between items-center 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>Registered: {dateStr}</span>
|
||||||
|
</div>
|
||||||
|
{areaName !== '—' && (
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase font-mono tracking-wider truncate max-w-[120px] text-right">
|
||||||
|
Area: {areaName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { orderBookingClient } from '../../api/clients';
|
|||||||
import { ORDER_BOOKING } from '../../api/config';
|
import { ORDER_BOOKING } from '../../api/config';
|
||||||
import { RecordView } from './RecordView';
|
import { RecordView } from './RecordView';
|
||||||
import type { WiredRecordViewProps } from './OrdersView';
|
import type { WiredRecordViewProps } from './OrdersView';
|
||||||
|
import { CallCard } from '../cards/CallCard';
|
||||||
|
|
||||||
/** Calls record view (Calls/Visits workflow). */
|
/** Calls record view (Calls/Visits workflow). */
|
||||||
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||||
@ -15,6 +16,9 @@ export function CallsView({ onRowClick, pageSize, headerActions, rowActions, ref
|
|||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
sortBy="instance_id"
|
||||||
|
sortDir="desc"
|
||||||
|
renderItem={(row) => <CallCard row={row} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { dailyReportsClient } from '../../api/clients';
|
|||||||
import { DAILY_REPORTS } from '../../api/config';
|
import { DAILY_REPORTS } from '../../api/config';
|
||||||
import { RecordView } from './RecordView';
|
import { RecordView } from './RecordView';
|
||||||
import type { WiredRecordViewProps } from './OrdersView';
|
import type { WiredRecordViewProps } from './OrdersView';
|
||||||
|
import { DailyLogCard } from '../cards/DailyLogCard';
|
||||||
|
|
||||||
/** Daily Logs record view (Daily Reports workflow). */
|
/** Daily Logs record view (Daily Reports workflow). */
|
||||||
export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
export function DailyLogsView({ onRowClick, onPunchOutRow, pageSize, headerActions, refreshKey }: WiredRecordViewProps & { onPunchOutRow?: (row: any) => void }) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={dailyReportsClient}
|
client={dailyReportsClient}
|
||||||
@ -13,8 +14,10 @@ export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions,
|
|||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
sortBy="instance_id"
|
||||||
|
sortDir="desc"
|
||||||
|
renderItem={(row) => <DailyLogCard row={row} onPunchOut={onPunchOutRow} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re
|
|||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
sortBy="instance_id"
|
||||||
|
sortDir="desc"
|
||||||
renderItem={(row, fields) => <OrderCard row={row} fields={fields} />}
|
renderItem={(row, fields) => <OrderCard row={row} fields={fields} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -34,6 +34,10 @@ export interface RecordViewProps {
|
|||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
/** Custom render function for the entire row card body */
|
/** Custom render function for the entire row card body */
|
||||||
renderItem?: (row: Record<string, unknown>, fields: RecordViewField[]) => React.ReactNode;
|
renderItem?: (row: Record<string, unknown>, fields: RecordViewField[]) => React.ReactNode;
|
||||||
|
/** Sort by column key */
|
||||||
|
sortBy?: string;
|
||||||
|
/** Sort direction */
|
||||||
|
sortDir?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,6 +57,8 @@ export function RecordView({
|
|||||||
rowActions,
|
rowActions,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
}: RecordViewProps) {
|
}: RecordViewProps) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -91,7 +97,7 @@ export function RecordView({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam });
|
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam, sortBy, sortDir });
|
||||||
if (live) setResp(r);
|
if (live) setResp(r);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||||
@ -103,7 +109,7 @@ export function RecordView({
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey]);
|
}, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]);
|
||||||
|
|
||||||
const fields: RecordViewField[] = useMemo(() => {
|
const fields: RecordViewField[] = useMemo(() => {
|
||||||
const all = resp?.config.fields ?? [];
|
const all = resp?.config.fields ?? [];
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { storeClient } from '../../api/clients';
|
|||||||
import { STORE } from '../../api/config';
|
import { STORE } from '../../api/config';
|
||||||
import { RecordView } from './RecordView';
|
import { RecordView } from './RecordView';
|
||||||
import type { WiredRecordViewProps } from './OrdersView';
|
import type { WiredRecordViewProps } from './OrdersView';
|
||||||
|
import { StoreCard } from '../cards/StoreCard';
|
||||||
|
|
||||||
/** Store record view (Store workflow). */
|
/** Store record view (Store workflow). */
|
||||||
export function StoresView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode }) {
|
export function StoresView({ onRowClick, onEditRow, pageSize, headerActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode; onEditRow?: (row: any) => void }) {
|
||||||
return (
|
return (
|
||||||
<RecordView
|
<RecordView
|
||||||
client={storeClient}
|
client={storeClient}
|
||||||
@ -13,8 +14,10 @@ export function StoresView({ onRowClick, pageSize, headerActions, rowActions, re
|
|||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
rowActions={rowActions}
|
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
sortBy="instance_id"
|
||||||
|
sortDir="desc"
|
||||||
|
renderItem={(row, fields) => <StoreCard row={row} fields={fields} onEdit={onEditRow} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,11 +30,7 @@ export function DailyLogsPage() {
|
|||||||
Punch In
|
Punch In
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
rowActions={(row) => (
|
onPunchOutRow={(row) => setPunchOutInstanceId(row.instance_id as string | number)}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
|
|
||||||
Punch Out
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -30,11 +30,7 @@ export function StoresPage() {
|
|||||||
Create Store
|
Create Store
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
rowActions={(row) => (
|
onEditRow={(row) => setEditingInstanceId(row.instance_id as string | number)}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditingInstanceId(row.instance_id as string | number)}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user