This commit is contained in:
171
frontend/src/components/crm/ClientsSection.jsx
Normal file
171
frontend/src/components/crm/ClientsSection.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
// src/components/crm/ClientsSection.jsx
|
||||
// Prikaz liste klijenata i njihovih dizalica unutar dashboard sekcije.
|
||||
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { $clients, $clientsLoading, fetchClients } from '../../stores/clientStore';
|
||||
import { $cranes } from '../../stores/fleetDashboardStore';
|
||||
import AnimatedDataTable from '../ui/AnimatedDataTable';
|
||||
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
|
||||
export default function ClientsSection({ onSetServiceContext = null }) {
|
||||
const clients = useStore($clients);
|
||||
const loading = useStore($clientsLoading);
|
||||
const cranes = useStore($cranes);
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [selectedClientCraneId, setSelectedClientCraneId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMounted) return;
|
||||
if (clients.length === 0) fetchClients();
|
||||
}, [hasMounted, clients.length]);
|
||||
|
||||
function cranesForClient(clientId) {
|
||||
return cranes.filter((item) => String(item.client) === String(clientId));
|
||||
}
|
||||
|
||||
const clientTransitions = useTransition(clients, {
|
||||
keys: (client) => client.id,
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
trail: 30,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
|
||||
function handleSetServiceContext() {
|
||||
if (!selectedClientCraneId) {
|
||||
showToast('Prvo označite redak dizalice.', 'warning');
|
||||
return;
|
||||
}
|
||||
onSetServiceContext?.(selectedClientCraneId);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||
<h3 className="font-semibold text-text-main">Klijenti</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<ServiceRecordCreateButton
|
||||
onClick={handleSetServiceContext}
|
||||
label="Postavi servisni kontekst"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchClients()}
|
||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Osvježi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasMounted && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">Učitavanje…</p>
|
||||
)}
|
||||
|
||||
{hasMounted && loading && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">Učitavanje…</p>
|
||||
)}
|
||||
|
||||
{hasMounted && !loading && clients.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
Nema klijenata. Dodajte klijente u Django admin ili koristite CRM API.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMounted && !loading && clients.length > 0 && (
|
||||
<div className="divide-y divide-border-hairline">
|
||||
{clientTransitions((style, client) => {
|
||||
const cvs = cranesForClient(client.id);
|
||||
const isOpen = expandedId === client.id;
|
||||
const craneColumns = [
|
||||
{ key: 'registration', label: 'Oznaka', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'model', label: 'Make / Model', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'km', label: 'Km', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'nextService', label: 'Sljedeći servis', className: 'py-2 pr-4 text-left' },
|
||||
{ key: 'type', label: 'Vrsta', className: 'py-2 text-left' },
|
||||
];
|
||||
return (
|
||||
<animated.div key={client.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedId(isOpen ? null : client.id);
|
||||
setSelectedClientCraneId(null);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-text-main">{client.name}</span>
|
||||
<span className="ml-2 text-xs text-text-muted">{client.tax_id}</span>
|
||||
<span className="ml-3 text-xs text-text-muted">{client.city}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
||||
{cvs.length} dizalica
|
||||
</span>
|
||||
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
||||
{cvs.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
Nema dizalica dodijeljenih ovom klijentu.
|
||||
Dodajte dizalicu u Django admin i povežite je s klijentom.
|
||||
</p>
|
||||
) : (
|
||||
<AnimatedDataTable
|
||||
columns={craneColumns}
|
||||
rows={cvs}
|
||||
rowKey={(item) => item.id}
|
||||
headClassName="text-xs uppercase tracking-wide text-text-muted"
|
||||
bodyClassName="divide-y divide-border-hairline"
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
tableClassName="min-w-full text-sm"
|
||||
wrapperClassName="overflow-x-auto"
|
||||
renderRow={(item) => {
|
||||
const isSelected = String(item.id) === String(selectedClientCraneId);
|
||||
const cellClassName = isSelected ? 'bg-emerald-50' : '';
|
||||
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
|
||||
return (
|
||||
<>
|
||||
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
{item.service_interval_km
|
||||
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
<span className={item.is_company_vehicle
|
||||
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
|
||||
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
|
||||
}>
|
||||
{item.is_company_vehicle ? 'Fleet' : 'Kupac'}
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
);}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user