Compare commits
3 Commits
45142bba4b
...
e8272a00bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8272a00bb | ||
|
|
d49ec15cc7 | ||
|
|
1d7d631fbd |
@@ -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:
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function Navbar({ minimal = false }) {
|
|||||||
const currentItem = ITEMS[activeIndex];
|
const currentItem = ITEMS[activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
|
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur overflow-visible">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{/* Logo + korisnik */}
|
{/* Logo + korisnik */}
|
||||||
<a href="/" className="flex flex-col hover:opacity-80">
|
<a href="/" className="flex flex-col hover:opacity-80">
|
||||||
@@ -135,10 +135,11 @@ export default function Navbar({ minimal = false }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Mobilni dropdown */}
|
{/* Mobilni dropdown */}
|
||||||
<details className="relative sm:hidden">
|
<details className="relative sm:hidden">
|
||||||
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
|
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline bg-canvas-base px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep select-none">
|
||||||
{currentItem?.label || 'Izbornik'} ▾
|
☰ {currentItem?.label || 'Izbornik'}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
|
{/* Dropdown je positioniran fixed-like kroz visoki z-index kako ne bi bio odsječen */}
|
||||||
|
<div className="absolute left-0 top-full mt-1 z-50 min-w-52 rounded-lg border border-border-hairline bg-canvas-elevated shadow-xl">
|
||||||
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
|
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
Navigacija
|
Navigacija
|
||||||
</div>
|
</div>
|
||||||
@@ -149,10 +150,11 @@ export default function Navbar({ minimal = false }) {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={
|
className={
|
||||||
pathname === item.href
|
pathname === item.href
|
||||||
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
|
? 'flex items-center gap-2 bg-indigo-50 px-3 py-2.5 text-sm font-medium text-indigo-700'
|
||||||
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
|
: 'flex items-center gap-2 px-3 py-2.5 text-sm text-text-main hover:bg-canvas-deep'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{pathname === item.href && <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />}
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { showToast } from '../stores/toastStore';
|
|
||||||
|
|
||||||
export default function ToastTrigger() {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
|
|
||||||
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
|
|
||||||
>
|
|
||||||
Test Toast
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
{/* Otvoreni nalozi */}
|
||||||
<p className="text-sm text-text-muted">Otvoreni nalozi</p>
|
<a
|
||||||
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
href="/putni-nalozi"
|
||||||
|
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-text-muted group-hover:text-indigo-700">Otvoreni nalozi</p>
|
||||||
|
<p className="text-xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
||||||
|
{hydratedSelectedVehicleId && contextStats !== null && (
|
||||||
|
<p className="text-[10px] text-indigo-600">{contextStats.openWorkOrders} u kontekstu</p>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Servisi danas */}
|
||||||
|
<a
|
||||||
|
href="/servisni-zapisi"
|
||||||
|
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-text-muted group-hover:text-indigo-700">Servisi danas</p>
|
||||||
|
<p className="text-xl font-semibold text-text-main">{stats.servicesToday}</p>
|
||||||
|
{hydratedSelectedVehicleId && contextStats !== null && (
|
||||||
|
<p className="text-[10px] text-indigo-600">{contextStats.servicesToday} u kontekstu</p>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Bez putnog naloga */}
|
||||||
|
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||||
|
<p className="text-xs text-text-muted">Bez put. naloga</p>
|
||||||
|
<p className={`text-xl font-semibold ${stats.tasksWithoutWorkOrder > 0 ? 'text-amber-600' : 'text-text-main'}`}>
|
||||||
|
{stats.tasksWithoutWorkOrder}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-text-muted">aktivnih zadataka</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
|
||||||
<p className="text-sm text-text-muted">Servisi danas</p>
|
{/* Dovršeni nalozi */}
|
||||||
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p>
|
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||||
|
<p className="text-xs text-text-muted">Dovršeni nalozi</p>
|
||||||
|
<p className="text-xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
||||||
|
{stats.totalWorkOrders > 0 && (
|
||||||
|
<p className="text-[10px] text-text-muted">
|
||||||
|
{Math.round((stats.closedWorkOrders / stats.totalWorkOrders) * 100)}% / {stats.totalWorkOrders} ukupno
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
|
||||||
<p className="text-sm text-text-muted">Upozorenja</p>
|
{/* Moji zadaci danas */}
|
||||||
<p className="mt-2 text-2xl font-semibold text-amber-600">{stats.warnings}</p>
|
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||||
</article>
|
<p className="text-xs text-text-muted">Moji danas</p>
|
||||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
<p className={`text-xl font-semibold ${myTasksToday > 0 ? 'text-indigo-600' : 'text-text-main'}`}>
|
||||||
<p className="text-sm text-text-muted">Dovršeni nalozi</p>
|
{hasMounted ? myTasksToday : '—'}
|
||||||
<p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
</p>
|
||||||
|
<p className="text-[10px] 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">
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { animated, useTransition } from '@react-spring/web';
|
import { animated, useTransition } from '@react-spring/web';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
function SkeletonRows({ columns, rows = 4 }) {
|
||||||
|
return Array.from({ length: rows }).map((_, rowIdx) => (
|
||||||
|
<tr key={rowIdx} className="animate-pulse">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className={col.className}>
|
||||||
|
<div className="h-3 rounded bg-canvas-deep" style={{ width: `${55 + ((rowIdx * 17 + col.key.length * 7) % 30)}%` }} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnimatedDataTable({
|
export default function AnimatedDataTable({
|
||||||
columns = [],
|
columns = [],
|
||||||
@@ -14,17 +26,43 @@ export default function AnimatedDataTable({
|
|||||||
bodyClassName = 'divide-y divide-border-hairline',
|
bodyClassName = 'divide-y divide-border-hairline',
|
||||||
rowClassName = 'hover:bg-canvas-deep',
|
rowClassName = 'hover:bg-canvas-deep',
|
||||||
isRowSelected = () => false,
|
isRowSelected = () => false,
|
||||||
wrapperClassName = 'overflow-x-auto',
|
wrapperClassName = '',
|
||||||
trail = 35,
|
trail = 35,
|
||||||
|
ariaLabel,
|
||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const [scrollState, setScrollState] = useState({ left: false, right: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Prati scroll poziciju da bi se prikazali fade indikatori s lijeva/desna
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
function updateScrollState() {
|
||||||
|
setScrollState({
|
||||||
|
left: el.scrollLeft > 4,
|
||||||
|
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollState();
|
||||||
|
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||||
|
const ro = new ResizeObserver(updateScrollState);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', updateScrollState);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [mounted, rows.length]);
|
||||||
|
|
||||||
const transitions = useTransition(rows, {
|
const transitions = useTransition(rows, {
|
||||||
keys: (row) => rowKey(row),
|
keys: (row) => rowKey(row),
|
||||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
from: { opacity: 0, transform: 'translate3d(0,6px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||||
enter: (row) => ({
|
enter: (row) => ({
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transform: 'translate3d(0,0,0)',
|
transform: 'translate3d(0,0,0)',
|
||||||
@@ -37,43 +75,55 @@ export default function AnimatedDataTable({
|
|||||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||||
}),
|
}),
|
||||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
leave: { opacity: 0, transform: 'translate3d(0,-4px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||||
trail,
|
trail,
|
||||||
config: { tension: 230, friction: 26 },
|
config: { tension: 260, friction: 28 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const colSpan = Math.max(1, columns.length);
|
const colSpan = Math.max(1, columns.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClassName}>
|
<div className={`relative ${wrapperClassName}`}>
|
||||||
<table className={tableClassName}>
|
{/* Fade gradijent — lijeva strana (kad se može scrollati ulijevo) */}
|
||||||
|
{scrollState.left && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-canvas-elevated to-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Fade gradijent — desna strana (kad postoji sadržaj desno) */}
|
||||||
|
{scrollState.right && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-canvas-elevated to-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="overflow-x-auto"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'thin' }}
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
className={tableClassName}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-busy={loading ? 'true' : undefined}
|
||||||
|
>
|
||||||
<thead className={headClassName}>
|
<thead className={headClassName}>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<th key={column.key} className={column.className}>
|
<th key={column.key} scope="col" className={column.className}>
|
||||||
{column.label}
|
{column.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className={bodyClassName}>
|
<tbody className={bodyClassName}>
|
||||||
{!mounted && (
|
{!mounted && <SkeletonRows columns={columns} rows={3} />}
|
||||||
<tr>
|
{mounted && loading && <SkeletonRows columns={columns} rows={3} />}
|
||||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
|
||||||
{loadingMessage}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{mounted && loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
|
||||||
{loadingMessage}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{mounted && !loading && rows.length === 0 && (
|
{mounted && !loading && rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-text-muted">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -90,5 +140,6 @@ export default function AnimatedDataTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import '../styles/global.css';
|
|||||||
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
|
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
|
||||||
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
|
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
|
||||||
import Toast from '../components/ui/Toast.jsx';
|
import Toast from '../components/ui/Toast.jsx';
|
||||||
import ToastTrigger from '../components/ToastTrigger.jsx';
|
|
||||||
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
||||||
import Navbar from '../components/Navbar.jsx';
|
import Navbar from '../components/Navbar.jsx';
|
||||||
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
|
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
|
||||||
@@ -18,7 +17,6 @@ interface Props {
|
|||||||
|
|
||||||
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
|
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
|
||||||
const isProd = import.meta.env.PROD;
|
const isProd = import.meta.env.PROD;
|
||||||
const isDev = import.meta.env.DEV;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -57,7 +55,6 @@ const isDev = import.meta.env.DEV;
|
|||||||
|
|
||||||
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
|
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
|
||||||
<Toast client:only="preact" transition:persist="toast" />
|
<Toast client:only="preact" transition:persist="toast" />
|
||||||
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
|
||||||
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
|
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
|
||||||
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
|
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user