386 lines
22 KiB
TypeScript
386 lines
22 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { dailyReportsClient } from '../../api/clients';
|
|
import { DAILY_REPORTS, APP_ID } from '../../api/config';
|
|
import { Card } from '../reusable/Card';
|
|
import { Spinner } from '../reusable/Spinner';
|
|
import { EmptyState } from '../reusable/EmptyState';
|
|
import { Calendar, Clock, Map, User, ClipboardList, Camera, MapPin, Hash, CheckCircle, XCircle, Activity, ArrowLeft, Mail } from 'lucide-react';
|
|
import { Button } from '../buttons/Button';
|
|
|
|
export interface DailyLogDetailProps {
|
|
instanceId: number | string;
|
|
refreshKey?: number;
|
|
onPunchOut?: () => void;
|
|
onBack?: () => void;
|
|
}
|
|
|
|
export function DailyLogDetail({ instanceId, refreshKey, onPunchOut, onBack }: 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, refreshKey]);
|
|
|
|
if (error) {
|
|
return (
|
|
<Card title={`Daily Log #${instanceId}`}>
|
|
<EmptyState title={`Couldn't load record`} hint={error} />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card title={`Daily Log #${instanceId}`}>
|
|
<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 #${instanceId}`}>
|
|
<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];
|
|
return val;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// State
|
|
const stateName = extract('current_state_name', remainingData) as string || '-';
|
|
const isPunchedIn = stateName.toLowerCase().includes('punched in');
|
|
|
|
// Date and Time
|
|
let dateRaw = extract('date', remainingData);
|
|
let timeRaw = extract('time', remainingData);
|
|
let formattedDate = '-';
|
|
if (dateRaw) {
|
|
formattedDate = new Date(dateRaw as string).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
}
|
|
|
|
// Format punch in text
|
|
const punchInTimeText = timeRaw ? `Punch-in ${timeRaw}` : 'Punch-in time unknown';
|
|
|
|
// Route Info
|
|
const routeCode = extract('route_code', remainingData) || '-';
|
|
const subRoute = extract('sub_route', remainingData) || '-';
|
|
|
|
// Notes & Details
|
|
const dayPlanNotes = extract('day_plan_notes', remainingData) || '-';
|
|
const eodNotes = extract('eod_notes_remarks', remainingData) || '-';
|
|
|
|
// KPIs
|
|
const prodCalls = extract('total_productive_calls', remainingData) || 0;
|
|
const nonProdCalls = extract('total_non_productive_calls', remainingData) || 0;
|
|
const totalCalls = Number(prodCalls) + Number(nonProdCalls);
|
|
const productivity = totalCalls > 0 ? Math.round((Number(prodCalls) / totalCalls) * 100) : 0;
|
|
|
|
// SO Info
|
|
const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'sales_officer_name';
|
|
const soRaw = extract(soKey, remainingData);
|
|
let soName = '-';
|
|
let soEmail = '-';
|
|
let soId = '-';
|
|
if (soRaw && typeof soRaw === 'object') {
|
|
soName = (soRaw as any).name || '-';
|
|
soEmail = (soRaw as any).email || '-';
|
|
soId = (soRaw as any).user_id || (soRaw as any).id || (soRaw as any).uid || '-';
|
|
}
|
|
const soInitials = soName.length > 2 ? soName.substring(0, 2).toUpperCase() : 'SO';
|
|
|
|
// Meta
|
|
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
|
const createdAtRaw = createdAtKey ? extract(createdAtKey, remainingData) : null;
|
|
let createdAt = '-';
|
|
if (createdAtRaw) {
|
|
createdAt = new Date(createdAtRaw as string).toLocaleString('en-US');
|
|
}
|
|
|
|
// Image
|
|
const storeImage = extract('store_image', remainingData);
|
|
const imageFiles = Array.isArray(storeImage) ? storeImage : [];
|
|
|
|
return (
|
|
<div className="w-full max-w-[1000px] mx-auto flex flex-col gap-6 pb-12">
|
|
|
|
{/* Top Header Card */}
|
|
<div className="border border-slate-200 rounded-xl p-6 shadow-sm bg-[var(--tiles-card-bg)]">
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
|
<div className="flex items-start gap-4">
|
|
{onBack && (
|
|
<button
|
|
onClick={onBack}
|
|
className="w-10 h-10 flex mt-1 items-center justify-center rounded-full bg-slate-50 hover:bg-slate-100 transition-colors text-slate-600 shrink-0 border border-slate-200 shadow-sm"
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</button>
|
|
)}
|
|
<div>
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Day Plan</div>
|
|
<div className="text-3xl font-black text-slate-900 mb-2">{formattedDate}</div>
|
|
<div className="flex items-center gap-4 text-xs text-slate-500 font-medium">
|
|
<span className="flex items-center gap-1.5"><Clock size={14} /> {punchInTimeText}</span>
|
|
<span className="flex items-center gap-1.5"><MapPin size={14} /> Route {String(routeCode)} - {String(subRoute)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-slate-900 text-white flex items-center justify-center font-bold text-sm shrink-0">
|
|
{soInitials}
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-bold text-slate-900">{String(soName)}</div>
|
|
<div className="text-[10px] text-slate-500">Sales Officer - ID {String(soId)}</div>
|
|
</div>
|
|
</div>
|
|
{isPunchedIn && onPunchOut && (
|
|
<Button onClick={onPunchOut}>
|
|
Punch Out
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Row & Progress Bar (Only show if Punched Out) */}
|
|
{!isPunchedIn && (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-emerald-500 uppercase tracking-wider mb-3">
|
|
<CheckCircle size={14} /> Productive Calls
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900">{String(prodCalls)}</div>
|
|
</div>
|
|
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-rose-500 uppercase tracking-wider mb-3">
|
|
<XCircle size={14} /> Non Productive
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900">{String(nonProdCalls)}</div>
|
|
</div>
|
|
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-3">
|
|
<Activity size={14} /> Total Calls
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900">{totalCalls}</div>
|
|
</div>
|
|
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-blue-500 uppercase tracking-wider mb-3">
|
|
<Activity size={14} /> Productivity
|
|
</div>
|
|
<div className="text-2xl font-bold text-slate-900">{productivity}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-[10px] text-slate-500 mb-2">
|
|
<span>Productive vs Non Productive</span>
|
|
<span>{String(prodCalls)} / {totalCalls}</span>
|
|
</div>
|
|
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex">
|
|
{totalCalls > 0 && (
|
|
<>
|
|
<div className="h-full bg-emerald-500 transition-all duration-500" style={{ width: `${productivity}%` }}></div>
|
|
<div className="h-full bg-rose-400 transition-all duration-500" style={{ width: `${100 - productivity}%` }}></div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Split Content */}
|
|
<div className="flex flex-col lg:flex-row gap-6">
|
|
|
|
{/* Left Column */}
|
|
<div className="flex-[2] flex flex-col gap-6">
|
|
{/* Timeline */}
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-6">
|
|
<Calendar size={14} className="text-slate-500" /> Day Timeline
|
|
</div>
|
|
|
|
<div className="relative pl-6 flex flex-col gap-8 border-l border-slate-200 ml-3">
|
|
{/* Punched In */}
|
|
<div className="relative">
|
|
<div className="absolute -left-[31px] bg-[var(--tiles-card-bg)] rounded-full p-0.5">
|
|
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
|
<Clock size={10} className="text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="text-sm font-bold text-slate-900 -mt-0.5">Punched In</div>
|
|
<div className="text-[10px] text-slate-500 mt-1">{formattedDate} {timeRaw ? `- ${timeRaw}` : ''}</div>
|
|
</div>
|
|
|
|
{/* Day Plan */}
|
|
<div className="relative">
|
|
<div className="absolute -left-[31px] bg-[var(--tiles-card-bg)] rounded-full p-0.5">
|
|
<div className="w-4 h-4 rounded-full border-2 border-slate-300 bg-[var(--tiles-card-bg)] flex items-center justify-center"></div>
|
|
</div>
|
|
<div className="text-sm font-bold text-slate-900 -mt-0.5">Day Plan Recorded</div>
|
|
<div className="text-[10px] text-slate-600 mt-1">{String(dayPlanNotes)}</div>
|
|
</div>
|
|
|
|
{/* EOD Submitted */}
|
|
<div className="relative">
|
|
<div className="absolute -left-[31px] bg-[var(--tiles-card-bg)] rounded-full p-0.5">
|
|
<div className={`w-4 h-4 rounded-full flex items-center justify-center ${isPunchedIn ? 'bg-slate-300' : 'bg-slate-900'}`}>
|
|
<ClipboardList size={10} className="text-white" />
|
|
</div>
|
|
</div>
|
|
<div className={`text-sm font-bold -mt-0.5 ${isPunchedIn ? 'text-slate-400' : 'text-slate-900'}`}>EOD Submitted</div>
|
|
{!isPunchedIn && (
|
|
<div className="text-[10px] text-slate-500 mt-1">{createdAt}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes Row */}
|
|
<div className={`grid grid-cols-1 ${!isPunchedIn ? 'md:grid-cols-2' : ''} gap-6`}>
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6 flex flex-col">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
|
<ClipboardList size={14} className="text-slate-500" /> Day Plan Notes
|
|
</div>
|
|
<div className="text-xs text-slate-700 leading-relaxed whitespace-pre-wrap">{String(dayPlanNotes)}</div>
|
|
</div>
|
|
{!isPunchedIn && (
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6 flex flex-col">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
|
<ClipboardList size={14} className="text-slate-500" /> EOD Remarks
|
|
</div>
|
|
<div className="text-xs text-slate-700 leading-relaxed whitespace-pre-wrap">{String(eodNotes)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Route Assignment */}
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-6">
|
|
<Map size={14} className="text-slate-500" /> Route Assignment
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="border border-slate-100 rounded-xl p-4 bg-slate-50 flex flex-col justify-between">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">Route Code</div>
|
|
<div className="text-lg font-bold text-slate-900">{String(routeCode)}</div>
|
|
</div>
|
|
<div className="border border-slate-100 rounded-xl p-4 bg-slate-50 flex flex-col justify-between">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">Sub Route</div>
|
|
<div className="text-lg font-bold text-slate-900">{String(subRoute)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column */}
|
|
<div className="flex-1 flex flex-col gap-6 min-w-[280px]">
|
|
{/* Store Image */}
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
|
<Camera size={14} className="text-slate-500" /> Store Image
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{imageFiles.length > 0 ? imageFiles.map((file: any, idx: number) => {
|
|
const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
|
return (
|
|
<div key={file.uuid || idx} className="rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
|
|
<img src={previewUrl} alt="Store" className="w-full h-auto object-cover" />
|
|
</div>
|
|
);
|
|
}) : (
|
|
<div className="w-full h-32 rounded-lg bg-slate-50 border border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
|
|
<Camera size={24} className="mb-2 opacity-50" />
|
|
<span className="text-xs">No image uploaded</span>
|
|
</div>
|
|
)}
|
|
<div className="text-[10px] text-slate-400 mt-2">Uploaded at punch in.</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SO Card */}
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-5">
|
|
<User size={14} className="text-slate-500" /> Sales Officer
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
|
<User size={12} className="text-slate-500" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Name</div>
|
|
<div className="text-xs font-semibold text-slate-900">{String(soName)}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
|
<Mail size={12} className="text-slate-500" />
|
|
</div>
|
|
<div className="overflow-hidden">
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Email</div>
|
|
<div className="text-xs font-semibold text-slate-900 truncate">{String(soEmail)}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
|
<Hash size={12} className="text-slate-500" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">User ID</div>
|
|
<div className="text-xs font-semibold text-slate-900">{String(soId)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta Card */}
|
|
<div className="border border-slate-200 rounded-xl bg-[var(--tiles-card-bg)] shadow-sm overflow-hidden p-6">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
|
<Hash size={14} className="text-slate-500" /> Meta
|
|
</div>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-slate-500">Instance</span>
|
|
<span className="font-bold text-slate-900">#{instanceId}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-slate-500">State</span>
|
|
<span className="font-bold text-slate-900">{stateName}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs">
|
|
<span className="text-slate-500">Performed At</span>
|
|
<span className="font-bold text-slate-900">{createdAt}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|