Refactor dashboard sections: KPI cards, filters, fallbacks

- Replace 4 static KPI cards with 5 interactive cards
  - 'Otvoreni nalozi' and 'Servisi danas' are now clickable links
  - Added context-aware subtotals (u kontekstu) when crane context is set
  - Replaced 'Upozorenja' (mileage-based, irrelevant for cranes) with 'Bez putnog naloga'
  - 'Dovrseni nalozi' now shows completion percentage
  - Added new card 'Moji zadaci danas' (tasks assigned to current user, today)
- Added crane context filter toggle on the tasks table (Sve dizalice / Odabrani kontekst)
- Service records page now shows last 10 records as fallback when no crane context is selected
- Moved SyncLogPanel into a collapsible <details> element
- Removed dead code: PAGE_MODE_TO_SECTION, activeSection/setActiveSection state,
  navbar:navigate event handler, and DashboardTopbar nav tab block (showSectionNav
  was always false; navigation is handled by Navbar.jsx)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-02 23:06:33 +02:00
parent 45142bba4b
commit 1d7d631fbd
4 changed files with 211 additions and 102 deletions

View File

@@ -50,10 +50,29 @@ docker exec 004erpdb pg_dump \
-f /tmp/prod_dump.dump -f /tmp/prod_dump.dump
``` ```
```bash
docker exec 004erpdb sh -c 'pg_dump
-U "$TVOJ_USER"
-d "$TVOJA_BAZA"
--no-owner
--no-acl
--exclude-table-data='log_*'
-Fc
-f /tmp/prod_dump.dump'
```
--- ---
## Korak 2 — Kopiraj dump na lokalni PC ## Korak 2 — Kopiraj dump na lokalni PC
Iz docker servera na host server:
```bash
docker cp 004erpdb:/tmp/prod_dump.dump /tmp/prod_dump.dump
```
```bash
Iz lokalnog PowerShell terminala: Iz lokalnog PowerShell terminala:
```powershell ```powershell
@@ -103,13 +122,13 @@ docker exec 004erpdb dropdb -U TVOJ_USER TVOJA_BAZA --if-exists
docker exec 004erpdb createdb -U TVOJ_USER TVOJA_BAZA docker exec 004erpdb createdb -U TVOJ_USER TVOJA_BAZA
# Restoriraj # Restoriraj
docker exec 004erpdb pg_restore ` docker exec 004erpdb sh -c 'pg_restore
-U TVOJ_USER ` -U "$TVOJ_USER"
-d TVOJA_BAZA ` -d "$TVOJA_BAZA"
--no-owner ` --no-owner
--no-acl ` --no-acl
-Fc ` -Fc
/tmp/prod_dump.dump -f /tmp/prod_dump.dump'
``` ```
> ⚠️ Zamijeni `TVOJ_USER` i `TVOJA_BAZA` s vrijednostima iz lokalnog `.env` fajla: > ⚠️ Zamijeni `TVOJ_USER` i `TVOJA_BAZA` s vrijednostima iz lokalnog `.env` fajla:

View File

@@ -1,32 +1,18 @@
import { showToast } from '../../stores/toastStore';
import ServiceContextSelector from './ServiceContextSelector'; import ServiceContextSelector from './ServiceContextSelector';
const NAV_ITEMS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'work-orders', label: 'Putni nalozi' },
{ key: 'service-records', label: 'Servisni zapisi' },
{ key: 'cranes', label: 'Dizalice' },
{ key: 'clients', label: 'Klijenti' },
];
export default function DashboardTopbar({ export default function DashboardTopbar({
activeSection,
onSectionChange,
onOpenWorkOrderModal, onOpenWorkOrderModal,
onOpenTaskModal, onOpenTaskModal,
pendingSyncCount, pendingSyncCount,
onNewServiceRecord, onNewServiceRecord,
showSectionNav = true,
}) { }) {
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
return ( return (
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur"> <header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
{/* ── Gornji red ───────────────────────────────────────── */} {/* ── Gornji red ───────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6"> <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div> <div>
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p> <p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2> <h2 className="text-xl font-semibold text-text-main">ERP</h2>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{pendingSyncCount > 0 && ( {pendingSyncCount > 0 && (
@@ -53,31 +39,6 @@ export default function DashboardTopbar({
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */} {/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} /> <ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
{/* ── Navigacijski tabovi ──────────────────────────────── */}
{showSectionNav && (
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
{NAV_ITEMS.map((item) => (
<li key={item.key}>
<button
type="button"
onClick={() => {
onSectionChange(item.key);
}}
className={
activeSection === item.key
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
)}
</header> </header>
); );
} }

View File

@@ -136,17 +136,7 @@ function SortableCodeLabel({ title, direction, onToggle }) {
); );
} }
const PAGE_MODE_TO_SECTION = { export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
dashboard: 'dashboard',
'work-orders': 'work-orders',
'service-records': 'service-records',
vehicles: 'cranes',
clients: 'clients',
};
export default function FleetDashboardShell({ initialSection = 'dashboard', pageMode = 'dashboard' }) {
const resolvedInitialSection = initialSection || PAGE_MODE_TO_SECTION[pageMode] || 'dashboard';
const [activeSection, setActiveSection] = useState(resolvedInitialSection);
const [isWorkOrderModalOpen, setIsWorkOrderModalOpen] = useState(false); const [isWorkOrderModalOpen, setIsWorkOrderModalOpen] = useState(false);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [selectedWorkOrder, setSelectedWorkOrder] = useState(null); const [selectedWorkOrder, setSelectedWorkOrder] = useState(null);
@@ -185,6 +175,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null); const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null); const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
const [taskCraneFilter, setTaskCraneFilter] = useState('all');
useEffect(() => { useEffect(() => {
setHasMounted(true); setHasMounted(true);
@@ -270,26 +261,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}; };
}, [authReady]); }, [authReady]);
useEffect(() => {
function handleNavbarNavigate(event) {
const section = event?.detail;
if (typeof section === 'string' && section.length > 0) {
setActiveSection(section);
}
}
window.addEventListener('navbar:navigate', handleNavbarNavigate);
return () => window.removeEventListener('navbar:navigate', handleNavbarNavigate);
}, []);
useEffect(() => { useEffect(() => {
function handleNotificationOpenEntity(event) { function handleNotificationOpenEntity(event) {
const detail = event?.detail || {}; const detail = event?.detail || {};
const section = detail.section;
if (typeof section === 'string' && section.length > 0) {
setActiveSection(section);
}
if (detail.entityType === 'work_order' && detail.entityId) { if (detail.entityType === 'work_order' && detail.entityId) {
if (detail.vehicleId) { if (detail.vehicleId) {
const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId)); const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
@@ -352,6 +326,43 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
if (serial) return `SN ${serial}`; if (serial) return `SN ${serial}`;
return hydratedContextCrane.registration_number || '-'; return hydratedContextCrane.registration_number || '-';
}, [hydratedContextCrane]); }, [hydratedContextCrane]);
// KPI: context-aware counts (null kad kontekst nije postavljen)
const contextStats = useMemo(() => {
if (!hydratedSelectedVehicleId) return null;
const ctxWoIds = new Set(
tasks
.filter((t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId) && t.work_order)
.map((t) => String(t.work_order))
);
const ctxWorkOrders = workOrders.filter(
(o) => String(o.vehicle) === String(hydratedSelectedVehicleId) || ctxWoIds.has(String(o.id))
);
const today = new Date().toISOString().slice(0, 10);
return {
openWorkOrders: ctxWorkOrders.filter((o) => o.status !== 'closed').length,
servicesToday: tasks.filter(
(t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId)
&& t.scheduled_date === today
&& (t.status === 'aktivan' || t.status === 'servis')
).length,
};
}, [tasks, workOrders, hydratedSelectedVehicleId]);
// KPI: moji zadaci planirani danas
const myTasksToday = useMemo(() => {
if (!user?.id || !hasMounted) return 0;
const today = new Date().toISOString().slice(0, 10);
return tasks.filter(
(t) => String(t.assigned_to || '') === String(user.id)
&& t.scheduled_date === today
&& isTaskActive(t)
).length;
}, [tasks, user?.id, hasMounted]);
// Zadnji servisni zapisi za fallback prikaz (kad kontekst nije postavljen)
const recentServiceRecords = useMemo(() => (
[...serviceRecords]
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')))
.slice(0, 10)
), [serviceRecords]);
const serviceRecordsForContext = useMemo(() => { const serviceRecordsForContext = useMemo(() => {
if (!selectedVehicleId) return []; if (!selectedVehicleId) return [];
return [...serviceRecords] return [...serviceRecords]
@@ -550,14 +561,23 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
return filteredByServicer; return filteredByServicer;
})(); })();
return [...filteredByStatus].sort((left, right) => ( const filteredByCrane = (() => {
if (taskCraneFilter !== 'context' || !hydratedSelectedVehicleId) {
return filteredByStatus;
}
return filteredByStatus.filter(
(task) => String(task.vehicle || '') === String(hydratedSelectedVehicleId)
);
})();
return [...filteredByCrane].sort((left, right) => (
compareWorkOrderCodesByDate( compareWorkOrderCodesByDate(
left?.work_order_label || '', left?.work_order_label || '',
right?.work_order_label || '', right?.work_order_label || '',
taskWorkOrderSortDirection taskWorkOrderSortDirection
) )
)); ));
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection]); }, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection, taskCraneFilter, hydratedSelectedVehicleId]);
const selectedTaskServiceRecords = useMemo(() => { const selectedTaskServiceRecords = useMemo(() => {
if (!selectedTask?.id) return []; if (!selectedTask?.id) return [];
return [...serviceRecords] return [...serviceRecords]
@@ -734,9 +754,6 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<AnimatedPage className="min-h-screen"> <AnimatedPage className="min-h-screen">
<main className="min-w-0"> <main className="min-w-0">
<DashboardTopbar <DashboardTopbar
activeSection={activeSection}
onSectionChange={setActiveSection}
showSectionNav={false}
onOpenWorkOrderModal={async () => { onOpenWorkOrderModal={async () => {
try { try {
const availableCranes = await ensureVehiclesCatalog(); const availableCranes = await ensureVehiclesCatalog();
@@ -769,30 +786,92 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
)} )}
{isDashboardPage && ( {isDashboardPage && (
<div id="section-dashboard" className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div id="section-dashboard" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm"> {/* Otvoreni nalozi — klik vodi na /putni-nalozi */}
<p className="text-sm text-text-muted">Otvoreni nalozi</p> <a
href="/putni-nalozi"
className="group rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
>
<p className="text-sm text-text-muted group-hover:text-indigo-700">Otvoreni nalozi</p>
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p> <p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p>
</article> {hydratedSelectedVehicleId && contextStats !== null && (
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm"> <p className="mt-1 text-xs text-indigo-600">{contextStats.openWorkOrders} u kontekstu</p>
<p className="text-sm text-text-muted">Servisi danas</p> )}
</a>
{/* Servisi danas — klik vodi na /servisni-zapisi */}
<a
href="/servisni-zapisi"
className="group rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
>
<p className="text-sm text-text-muted group-hover:text-indigo-700">Servisi danas</p>
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p> <p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p>
</article> {hydratedSelectedVehicleId && contextStats !== null && (
<p className="mt-1 text-xs text-indigo-600">{contextStats.servicesToday} u kontekstu</p>
)}
</a>
{/* Aktivni zadaci bez putnog naloga */}
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm"> <article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Upozorenja</p> <p className="text-sm text-text-muted">Bez putnog naloga</p>
<p className="mt-2 text-2xl font-semibold text-amber-600">{stats.warnings}</p> <p className={`mt-2 text-2xl font-semibold ${stats.tasksWithoutWorkOrder > 0 ? 'text-amber-600' : 'text-text-main'}`}>
{stats.tasksWithoutWorkOrder}
</p>
<p className="mt-1 text-xs text-text-muted">aktivnih zadataka</p>
</article> </article>
{/* Dovršeni nalozi s postotkom */}
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm"> <article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Dovršeni nalozi</p> <p className="text-sm text-text-muted">Dovršeni nalozi</p>
<p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p> <p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
{stats.totalWorkOrders > 0 && (
<p className="mt-1 text-xs text-text-muted">
{Math.round((stats.closedWorkOrders / stats.totalWorkOrders) * 100)}% od ukupno {stats.totalWorkOrders}
</p>
)}
</article>
{/* Moji zadaci danas */}
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Moji zadaci danas</p>
<p className={`mt-2 text-2xl font-semibold ${myTasksToday > 0 ? 'text-indigo-600' : 'text-text-main'}`}>
{hasMounted ? myTasksToday : '—'}
</p>
<p className="mt-1 text-xs text-text-muted">planirani na mene</p>
</article> </article>
</div> </div>
)} )}
{isDashboardPage && ( {isDashboardPage && (
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm"> <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"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Radni zadaci</h3> <h3 className="font-semibold text-text-main">Radni zadaci</h3>
<div className="flex items-center gap-2 text-xs"> <div className="flex flex-wrap items-center gap-2 text-xs">
{/* Crane context filter */}
{hasMounted && hydratedSelectedVehicleId && (
<>
<span className="text-text-muted">|</span>
<button
type="button"
onClick={() => setTaskCraneFilter('all')}
className={taskCraneFilter === 'all'
? 'rounded-md bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700'
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
>
Sve dizalice
</button>
<button
type="button"
onClick={() => setTaskCraneFilter('context')}
className={taskCraneFilter === 'context'
? 'rounded-md bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700'
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
title={contextCraneLabel ?? undefined}
>
Odabrani kontekst
</button>
<span className="text-text-muted">|</span>
</>
)}
{hasMounted && isSupervisorNotServiser && ( {hasMounted && isSupervisorNotServiser && (
<label className="flex items-center gap-2 text-text-main"> <label className="flex items-center gap-2 text-text-main">
<span>Serviser</span> <span>Serviser</span>
@@ -917,9 +996,15 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</article> </article>
)} )}
{isDashboardPage && ( {isDashboardPage && (
<div> <details className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
<summary className="flex cursor-pointer select-none items-center justify-between px-4 py-3 text-sm font-medium text-text-muted hover:bg-canvas-deep">
<span>Sync log</span>
<span className="text-xs opacity-60"></span>
</summary>
<div className="border-t border-border-hairline">
<SyncLogPanel /> <SyncLogPanel />
</div> </div>
</details>
)} )}
{showWorkOrders && ( {showWorkOrders && (
@@ -1131,9 +1216,50 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</div> </div>
</div> </div>
{!hydratedSelectedVehicleId && ( {!hydratedSelectedVehicleId && (
<p className="px-4 py-6 text-center text-sm text-text-muted"> <div>
Odaberite dizalicu za prikaz servisnih zapisa. <p className="border-b border-border-hairline px-4 py-3 text-xs text-text-muted">
Odaberite dizalicu u servisnom kontekstu za prikaz zapisa po radnim zadacima.
Ispod su prikazani zadnji servisni zapisi.
</p> </p>
{recentServiceRecords.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-text-muted">Nema servisnih zapisa.</p>
) : (
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={recentServiceRecords}
rowKey={(r) => r.id}
rowClassName="hover:bg-canvas-deep"
renderRow={(record) => (
<>
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
<td className="max-w-xs truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-4 py-3">{formatCost(record.cost)}</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(null);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
</td>
</>
)}
/>
)}
</div>
)} )}
{hydratedSelectedVehicleId && loading && ( {hydratedSelectedVehicleId && loading && (
<p className="px-4 py-6 text-center text-sm text-text-muted"> <p className="px-4 py-6 text-center text-sm text-text-muted">

View File

@@ -464,16 +464,19 @@ export const $dashboardStats = computed(
const servicesToday = tasks.filter( const servicesToday = tasks.filter(
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis') (t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
).length; ).length;
const warnings = serviceRecords.filter((record) => { const totalWorkOrders = workOrders.length;
if (record.next_service_due_at == null || record.mileage == null) return false; // Aktivni taskovi bez dodijeljenog putnog naloga
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000; const tasksWithoutWorkOrder = tasks.filter(
}).length; (t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak')
&& !t.work_order
).length;
return { return {
openWorkOrders, openWorkOrders,
closedWorkOrders, closedWorkOrders,
totalWorkOrders,
servicesToday, servicesToday,
warnings, tasksWithoutWorkOrder,
}; };
} }
); );