sites to fetch from ERP

This commit is contained in:
Syasya 2025-08-15 09:20:13 +08:00
parent 0467034acb
commit 44bb94ded8
2 changed files with 322 additions and 98 deletions

View File

@ -1,46 +1,187 @@
'use client'; 'use client';
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import DashboardLayout from '../adminDashboard/dashlayout'; import DashboardLayout from '../adminDashboard/dashlayout';
import SiteCard from '@/components/dashboards/SiteCard'; // Import the new SiteCard component import SiteCard from '@/components/dashboards/SiteCard';
import { mockSiteData, SiteName } from '@/types/SiteData'; // Import your mock data and SiteName type
const SitesPage = () => { type CrmProject = {
// Helper function to determine status (can be externalized if used elsewhere) name: string; // e.g. PROJ-0008 (siteId)
const getSiteStatus = (siteName: SiteName): string => { project_name: string;
const statusMap: Record<SiteName, string> = { status?: string | null;
'Site A': 'Active', modified?: string | null;
'Site B': 'Inactive', customer?: string | null;
'Site C': 'Faulty', project_type?: string | null;
}; custom_address?: string | null;
return statusMap[siteName]; custom_email?: string | null;
}; custom_mobile_phone_no?: string | null;
return (
<DashboardLayout>
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold mb-6 dark:text-white-light">All Sites Overview</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Iterate over the keys of mockSiteData (which are your SiteNames) */}
{Object.keys(mockSiteData).map((siteNameKey) => {
const siteName = siteNameKey as SiteName; // Cast to SiteName type
const siteDetails = mockSiteData[siteName];
const siteStatus = getSiteStatus(siteName);
return (
<SiteCard
key={siteName} // Important for React list rendering
siteName={siteName}
details={siteDetails}
status={siteStatus}
/>
);
})}
</div>
</div>
</DashboardLayout>
);
}; };
export default SitesPage; const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
const SitesPage = () => {
const [projects, setProjects] = useState<CrmProject[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [q, setQ] = useState(''); // search filter
// pagination
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(6); // tweak as you like
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setErr(null);
try {
const res = await fetch(`${API}/crm/projects?limit=0`);
if (!res.ok) throw new Error(await res.text());
const json = await res.json();
const data: CrmProject[] = json?.data ?? [];
if (!cancelled) setProjects(data);
} catch (e: any) {
if (!cancelled) setErr(e?.message ?? 'Failed to load CRM projects');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => { cancelled = true; };
}, []);
// Reset to first page whenever search or pageSize changes
useEffect(() => {
setPage(1);
}, [q, pageSize]);
const filtered = useMemo(() => {
if (!q.trim()) return projects;
const needle = q.toLowerCase();
return projects.filter(p =>
(p.project_name || '').toLowerCase().includes(needle) ||
(p.name || '').toLowerCase().includes(needle) ||
(p.customer || '').toLowerCase().includes(needle)
);
}, [projects, q]);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const startIdx = (safePage - 1) * pageSize;
const endIdx = Math.min(startIdx + pageSize, total);
const pageItems = filtered.slice(startIdx, endIdx);
const goPrev = () => setPage(p => Math.max(1, p - 1));
const goNext = () => setPage(p => Math.min(totalPages, p + 1));
return (
<DashboardLayout>
<div className="p-6 space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<h1 className="text-2xl font-bold dark:text-white-light">All Sites Overview</h1>
<div className="flex items-center gap-3">
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Search by name / ID / customer"
className="w-64 max-w-full px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
/>
<select
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
className="px-3 py-2 rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 dark:text-white"
aria-label="Items per page"
>
<option value={6}>6 / page</option>
<option value={9}>9 / page</option>
<option value={12}>12 / page</option>
</select>
</div>
</div>
{loading && (
<div className="text-gray-600 dark:text-gray-400">Loading CRM projects</div>
)}
{err && (
<div className="text-red-600">Error: {err}</div>
)}
{!loading && !err && total === 0 && (
<div className="text-amber-600">No sites found.</div>
)}
{!loading && !err && total > 0 && (
<>
{/* Pagination header */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
Showing <span className="font-semibold">{startIdx + 1}</span><span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
</div>
<div className="flex items-center gap-2 dark:text-white">
<button
onClick={goPrev}
disabled={safePage <= 1}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page <span className="font-semibold">{safePage}</span> / {totalPages}
</span>
<button
onClick={goNext}
disabled={safePage >= totalPages}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 '}`}
>
Next
</button>
</div>
</div>
{/* Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pageItems.map(p => (
<SiteCard
key={p.name}
siteId={p.name} // SiteCard self-fetches details
fallbackStatus={p.status ?? undefined}
/>
))}
</div>
{/* Pagination footer mirrors header for convenience */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
Showing <span className="font-semibold">{startIdx + 1}</span><span className="font-semibold">{endIdx}</span> of <span className="font-semibold">{total}</span>
</div>
<div className="flex items-center gap-2 dark:text-white">
<button
onClick={goPrev}
disabled={safePage <= 1}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page <span className="font-semibold">{safePage}</span> / {totalPages}
</span>
<button
onClick={goNext}
disabled={safePage >= totalPages}
className={`px-3 py-1.5 rounded-md border dark:border-gray-700 ${safePage >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
Next
</button>
</div>
</div>
</>
)}
</div>
</DashboardLayout>
);
};
export default SitesPage;

View File

@ -1,63 +1,146 @@
// components/dashboards/SiteCard.tsx // components/dashboards/SiteCard.tsx
import React from 'react'; 'use client';
import Link from 'next/link'; // Import Link from Next.js
import { SiteName, SiteDetails } from '@/types/SiteData'; // Adjust path as necessary
interface SiteCardProps { import React, { useEffect, useMemo, useState } from 'react';
siteName: SiteName; import Link from 'next/link';
details: SiteDetails; import { formatAddress } from '@/app/utils/formatAddress';
status: string; import { formatCrmTimestamp } from '@/app/utils/datetime';
}
const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => { type CrmProject = {
const statusColorClass = name: string; // e.g. PROJ-0008 (siteId)
status === 'Active' ? 'text-green-500' : project_name: string;
status === 'Inactive' ? 'text-orange-500' : status?: string;
'text-red-500'; percent_complete?: number | null;
owner?: string | null;
return ( modified?: string | null;
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2"> customer?: string | null;
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2"> project_type?: string | null;
{siteName} custom_address?: string | null;
</h3> custom_email?: string | null;
custom_mobile_phone_no?: string | null;
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
<p className="font-semibold">{details.location}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
<p className="font-semibold">{details.inverterProvider}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
<p className="font-semibold">{details.emergencyContact}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
<p className="font-semibold">{details.lastSyncTimestamp}</p>
</div>
{/* New: View Dashboard Button */}
<Link
href={{
pathname: '/adminDashboard', // Path to your AdminDashboard page
query: { site: siteName }, // Pass the siteName as a query parameter
}}
className="mt-4 w-full text-center text-sm btn-primary" // Tailwind classes for basic button styling
>
View Dashboard
</Link>
</div>
);
}; };
export default SiteCard; interface SiteCardProps {
siteId: string; // CRM Project "name" (canonical id)
className?: string; // optional styling hook
fallbackStatus?: string; // optional backup status if CRM is missing it
}
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
const [project, setProject] = useState<CrmProject | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchProject = async () => {
setLoading(true);
setErr(null);
try {
// ---- Try a single-project endpoint first (best) ----
// e.g. GET /crm/projects/PROJ-0008
const single = await fetch(`${API}/crm/projects/${encodeURIComponent(siteId)}`);
if (single.ok) {
const pj = await single.json();
if (!cancelled) setProject(pj?.data ?? pj ?? null);
} else {
// ---- Fallback: fetch all and find by name (works with your existing API) ----
const list = await fetch(`${API}/crm/projects?limit=0`);
if (!list.ok) throw new Error(await list.text());
const json = await list.json();
const found = (json?.data ?? []).find((p: CrmProject) => p.name === siteId) ?? null;
if (!cancelled) setProject(found);
}
} catch (e: any) {
if (!cancelled) setErr(e?.message ?? 'Failed to load CRM project');
} finally {
if (!cancelled) setLoading(false);
}
};
fetchProject();
return () => { cancelled = true; };
}, [siteId]);
const status = project?.status || fallbackStatus || 'Unknown';
const statusColorClass =
status === 'Active' ? 'text-green-500' :
status === 'Inactive' ? 'text-orange-500' :
'text-red-500';
const niceAddress = useMemo(() => {
if (!project?.custom_address) return 'N/A';
return formatAddress(project.custom_address).multiLine;
}, [project?.custom_address]);
const lastSync = useMemo(() => {
return formatCrmTimestamp(project?.modified, { includeSeconds: true }) || 'N/A';
}, [project?.modified]);
const inverterProvider = project?.project_type || 'N/A';
const emergencyContact =
project?.custom_mobile_phone_no ||
project?.custom_email ||
project?.customer ||
'N/A';
return (
<div className={`bg-white p-4 rounded-lg shadow-md dark:bg-gray-800 dark:text-white-light flex flex-col space-y-2 ${className}`}>
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
{project?.project_name || siteId}
</h3>
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-36 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
) : err ? (
<div className="text-red-500 text-sm">Failed to load CRM: {err}</div>
) : !project ? (
<div className="text-amber-500 text-sm">No CRM project found for <span className="font-semibold">{siteId}</span>.</div>
) : (
<>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
<p className="font-medium whitespace-pre-line text-right">{niceAddress}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
<p className="font-medium">{inverterProvider}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
<p className="font-medium text-right">{emergencyContact}</p>
</div>
<div className="flex justify-between items-center">
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
<p className="font-medium">{lastSync}</p>
</div>
</>
)}
<Link
href={{ pathname: '/adminDashboard', query: { site: siteId } }}
className="mt-4 w-full text-center text-sm btn-primary"
>
View Dashboard
</Link>
</div>
);
};
export default SiteCard;