sites to fetch from ERP
This commit is contained in:
parent
0467034acb
commit
44bb94ded8
@ -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
|
|
||||||
|
type CrmProject = {
|
||||||
|
name: string; // e.g. PROJ-0008 (siteId)
|
||||||
|
project_name: string;
|
||||||
|
status?: string | null;
|
||||||
|
modified?: string | null;
|
||||||
|
customer?: string | null;
|
||||||
|
project_type?: string | null;
|
||||||
|
custom_address?: string | null;
|
||||||
|
custom_email?: string | null;
|
||||||
|
custom_mobile_phone_no?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
|
||||||
|
|
||||||
const SitesPage = () => {
|
const SitesPage = () => {
|
||||||
// Helper function to determine status (can be externalized if used elsewhere)
|
const [projects, setProjects] = useState<CrmProject[]>([]);
|
||||||
const getSiteStatus = (siteName: SiteName): string => {
|
const [loading, setLoading] = useState(true);
|
||||||
const statusMap: Record<SiteName, string> = {
|
const [err, setErr] = useState<string | null>(null);
|
||||||
'Site A': 'Active',
|
const [q, setQ] = useState(''); // search filter
|
||||||
'Site B': 'Inactive',
|
|
||||||
'Site C': 'Faulty',
|
// pagination
|
||||||
};
|
const [page, setPage] = useState(1);
|
||||||
return statusMap[siteName];
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="p-6 space-y-6">
|
<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="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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="flex items-center gap-3">
|
||||||
{/* Iterate over the keys of mockSiteData (which are your SiteNames) */}
|
<input
|
||||||
{Object.keys(mockSiteData).map((siteNameKey) => {
|
value={q}
|
||||||
const siteName = siteNameKey as SiteName; // Cast to SiteName type
|
onChange={e => setQ(e.target.value)}
|
||||||
const siteDetails = mockSiteData[siteName];
|
placeholder="Search by name / ID / customer"
|
||||||
const siteStatus = getSiteStatus(siteName);
|
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"
|
||||||
|
|
||||||
return (
|
|
||||||
<SiteCard
|
|
||||||
key={siteName} // Important for React list rendering
|
|
||||||
siteName={siteName}
|
|
||||||
details={siteDetails}
|
|
||||||
status={siteStatus}
|
|
||||||
/>
|
/>
|
||||||
);
|
<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>
|
||||||
</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>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SitesPage;
|
export default SitesPage;
|
||||||
|
|
||||||
|
@ -1,26 +1,111 @@
|
|||||||
// 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
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { formatAddress } from '@/app/utils/formatAddress';
|
||||||
|
import { formatCrmTimestamp } from '@/app/utils/datetime';
|
||||||
|
|
||||||
|
type CrmProject = {
|
||||||
|
name: string; // e.g. PROJ-0008 (siteId)
|
||||||
|
project_name: string;
|
||||||
|
status?: string;
|
||||||
|
percent_complete?: number | null;
|
||||||
|
owner?: string | null;
|
||||||
|
modified?: string | null;
|
||||||
|
customer?: string | null;
|
||||||
|
project_type?: string | null;
|
||||||
|
custom_address?: string | null;
|
||||||
|
custom_email?: string | null;
|
||||||
|
custom_mobile_phone_no?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface SiteCardProps {
|
interface SiteCardProps {
|
||||||
siteName: SiteName;
|
siteId: string; // CRM Project "name" (canonical id)
|
||||||
details: SiteDetails;
|
className?: string; // optional styling hook
|
||||||
status: string;
|
fallbackStatus?: string; // optional backup status if CRM is missing it
|
||||||
}
|
}
|
||||||
|
|
||||||
const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
|
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 =
|
const statusColorClass =
|
||||||
status === 'Active' ? 'text-green-500' :
|
status === 'Active' ? 'text-green-500' :
|
||||||
status === 'Inactive' ? 'text-orange-500' :
|
status === 'Inactive' ? 'text-orange-500' :
|
||||||
'text-red-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 (
|
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">
|
<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">
|
<h3 className="text-xl font-bold text-primary-600 dark:text-primary-400 border-b pb-2 mb-2">
|
||||||
{siteName}
|
{project?.project_name || siteId}
|
||||||
</h3>
|
</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">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Status:</p>
|
||||||
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
|
<p className={`font-semibold ${statusColorClass}`}>{status}</p>
|
||||||
@ -28,31 +113,29 @@ const SiteCard: React.FC<SiteCardProps> = ({ siteName, details, status }) => {
|
|||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Location:</p>
|
||||||
<p className="font-semibold">{details.location}</p>
|
<p className="font-medium whitespace-pre-line text-right">{niceAddress}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Inverter Provider:</p>
|
||||||
<p className="font-semibold">{details.inverterProvider}</p>
|
<p className="font-medium">{inverterProvider}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Emergency Contact:</p>
|
||||||
<p className="font-semibold">{details.emergencyContact}</p>
|
<p className="font-medium text-right">{emergencyContact}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
|
<p className="text-gray-600 dark:text-gray-400 font-medium">Last Sync:</p>
|
||||||
<p className="font-semibold">{details.lastSyncTimestamp}</p>
|
<p className="font-medium">{lastSync}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* New: View Dashboard Button */}
|
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{ pathname: '/adminDashboard', query: { site: siteId } }}
|
||||||
pathname: '/adminDashboard', // Path to your AdminDashboard page
|
className="mt-4 w-full text-center text-sm btn-primary"
|
||||||
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
|
View Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user