From 0c9e1b7d50aa62460fcaeb9a998ec147ea794bf5 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Sun, 19 Jul 2026 14:52:10 +0200 Subject: [PATCH] feat: dodaj scheduled_date na Task i kalendar widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Novo polje Task.scheduled_date (DateField, nullable, db_index) za planiranje - Migracija 0009_task_scheduled_date - Celery task notify_upcoming_tasks: dnevno u 08:00 šalje Pusher + email za sutrašnje zadatke - TaskCalendarWidget: react-spring slide panel s lijeve strane, grid prikaz mjeseca, dot indikatori, klik otvara TaskServiceRecordsModal - FleetDashboardShell: import + render kalendara, Planirano stupac u tablici zadataka - TaskCreateModal + TaskServiceRecordsModal: scheduled_date input i prikaz - Admin + serializer + views: scheduled_date filter i prikaz - 47 backend testova prolaze (uključuje 8 novih za notify_upcoming_tasks) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/core/settings/base.py | 4 + backend/modules/__init__.py | 0 backend/modules/fleet/models.py | 1 + backend/modules/task_management/admin.py | 4 +- .../migrations/0009_task_scheduled_date.py | 22 ++ backend/modules/task_management/models.py | 7 + .../modules/task_management/serializers.py | 2 + backend/modules/task_management/tasks.py | 58 +++- .../modules/task_management/tests/__init__.py | 0 .../task_management/tests/test_models.py | 24 +- .../task_management/tests/test_serializers.py | 39 ++- .../tests/test_upcoming_notifications.py | 133 +++++++++ backend/modules/task_management/views.py | 3 + .../dashboard/FleetDashboardShell.jsx | 17 ++ .../dashboard/TaskCalendarWidget.jsx | 267 ++++++++++++++++++ .../components/dashboard/TaskCreateModal.jsx | 15 + .../dashboard/TaskServiceRecordsModal.jsx | 21 +- 17 files changed, 610 insertions(+), 7 deletions(-) create mode 100644 backend/modules/__init__.py create mode 100644 backend/modules/task_management/migrations/0009_task_scheduled_date.py create mode 100644 backend/modules/task_management/tests/__init__.py create mode 100644 backend/modules/task_management/tests/test_upcoming_notifications.py create mode 100644 frontend/src/components/dashboard/TaskCalendarWidget.jsx diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py index dbe7df4..8a72846 100644 --- a/backend/core/settings/base.py +++ b/backend/core/settings/base.py @@ -175,6 +175,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task', 'schedule': crontab(minute=0), }, + 'notify-upcoming-tasks-daily': { + 'task': 'modules.task_management.tasks.notify_upcoming_tasks', + 'schedule': crontab(hour=8, minute=0), + }, } # URL kojim će preglednik dohvaćati statiku diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 0651725..fa5755e 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -250,6 +250,7 @@ class WorkOrder(BaseModel): ('defektaza', _("Defektaža")), ('kontrola', _("Kontrola")), ('redovni_pregled', _("Redovni pregled")), + ('popravak', _("Popravak")), ] vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo")) diff --git a/backend/modules/task_management/admin.py b/backend/modules/task_management/admin.py index ed3b8aa..b950b2f 100644 --- a/backend/modules/task_management/admin.py +++ b/backend/modules/task_management/admin.py @@ -26,8 +26,8 @@ class TaskTemplateEntryInline(admin.TabularInline): @admin.register(Task) class TaskAdmin(admin.ModelAdmin): - list_display = ('title', 'status', 'assigned_to', 'vehicle', 'work_order', 'created_at') - list_filter = ('status', 'created_at', 'work_order') + list_display = ('title', 'status', 'scheduled_date', 'assigned_to', 'vehicle', 'work_order', 'created_at') + list_filter = ('status', 'scheduled_date', 'created_at', 'work_order') search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number') readonly_fields = ('id', 'created_at', 'updated_at') inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline) diff --git a/backend/modules/task_management/migrations/0009_task_scheduled_date.py b/backend/modules/task_management/migrations/0009_task_scheduled_date.py new file mode 100644 index 0000000..b1884a6 --- /dev/null +++ b/backend/modules/task_management/migrations/0009_task_scheduled_date.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0008_task_add_ready_for_completion_status'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='scheduled_date', + field=models.DateField( + null=True, + blank=True, + db_index=True, + verbose_name='Planirani datum izvršenja', + help_text='Datum kada je planirano izvršenje radnog zadatka.', + ), + ), + ] diff --git a/backend/modules/task_management/models.py b/backend/modules/task_management/models.py index ca155cd..de248df 100644 --- a/backend/modules/task_management/models.py +++ b/backend/modules/task_management/models.py @@ -47,6 +47,13 @@ class Task(BaseModel): related_name='work_order_tasks', verbose_name=_("Putni nalog"), ) + scheduled_date = models.DateField( + null=True, + blank=True, + db_index=True, + verbose_name=_("Planirani datum izvršenja"), + help_text=_("Datum kada je planirano izvršenje radnog zadatka."), + ) class Meta: ordering = ['-created_at'] diff --git a/backend/modules/task_management/serializers.py b/backend/modules/task_management/serializers.py index 0ddc95a..2a03fdb 100644 --- a/backend/modules/task_management/serializers.py +++ b/backend/modules/task_management/serializers.py @@ -96,6 +96,7 @@ class TaskSerializer(serializers.ModelSerializer): 'assigned_to', 'assigned_to_name', 'vehicle', 'vehicle_registration', 'work_order', 'work_order_label', + 'scheduled_date', 'template_id', 'auto_close_work_order', 'created_at', ] @@ -106,6 +107,7 @@ class TaskSerializer(serializers.ModelSerializer): 'status': {'required': False}, 'work_order': {'required': False, 'allow_null': True}, 'vehicle': {'required': False, 'allow_null': True}, + 'scheduled_date': {'required': False, 'allow_null': True}, } def validate_title(self, value): diff --git a/backend/modules/task_management/tasks.py b/backend/modules/task_management/tasks.py index 58eb627..a869c26 100644 --- a/backend/modules/task_management/tasks.py +++ b/backend/modules/task_management/tasks.py @@ -1,10 +1,64 @@ # backend/modules/task_management/tasks.py +import logging from celery import shared_task from .models import Task from django.contrib.auth import get_user_model from modules.fleet.models import Vehicle, WorkOrder +logger = logging.getLogger(__name__) + + +@shared_task +def notify_upcoming_tasks(): + """ + Dnevna Celery periodik task koji šalje Pusher + email notifikacije + za radne zadatke čiji je planirani datum izvršenja sutra. + """ + from datetime import date, timedelta + from modules.fleet.services import NotificationService + + tomorrow = date.today() + timedelta(days=1) + upcoming = Task.objects.filter( + is_active=True, + scheduled_date=tomorrow, + status__in=('aktivan', 'servis'), + ).select_related('assigned_to', 'vehicle') + + notified = 0 + for task in upcoming: + recipient = task.assigned_to + if not recipient: + continue + vehicle_label = ( + task.vehicle.registration_number if task.vehicle else 'nepoznato' + ) + try: + NotificationService.create_notification( + recipient=recipient, + title=f"Sutra planirani zadatak: {task.title}", + message=( + f"Radni zadatak '{task.title}' na dizalici {vehicle_label} " + f"je planiran za sutra ({tomorrow.strftime('%d.%m.%Y')})." + ), + level="info", + send_email=True, + metadata={ + "entity_type": "task", + "task_id": str(task.id), + "vehicle_id": str(task.vehicle_id) if task.vehicle_id else None, + "section": "dashboard", + "scheduled_date": tomorrow.isoformat(), + }, + ) + notified += 1 + except Exception: + logger.exception("Greška pri slanju notifikacije za task %s", task.id) + + logger.info("notify_upcoming_tasks: obaviješteno %d servisera za datum %s", notified, tomorrow) + return {"notified": notified, "date": tomorrow.isoformat()} + + @shared_task def create_task_from_invoice(invoice_data): """ @@ -25,7 +79,7 @@ def create_task_from_invoice(invoice_data): vehicle = work_order.vehicle if vehicle is None and vehicle_id: vehicle = Vehicle.objects.filter(pk=vehicle_id).first() - + if amount > 1000: Task.objects.create( title=f"Provjera računa: {invoice_data['invoice_number']}", @@ -34,4 +88,4 @@ def create_task_from_invoice(invoice_data): status='aktivan', vehicle=vehicle, work_order=work_order, - ) \ No newline at end of file + ) diff --git a/backend/modules/task_management/tests/__init__.py b/backend/modules/task_management/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/task_management/tests/test_models.py b/backend/modules/task_management/tests/test_models.py index 821cfb2..e733835 100644 --- a/backend/modules/task_management/tests/test_models.py +++ b/backend/modules/task_management/tests/test_models.py @@ -1,5 +1,6 @@ from django.test import TestCase from django.contrib.auth import get_user_model +import datetime from modules.task_management.models import Task @@ -9,4 +10,25 @@ class TaskModelTests(TestCase): user = get_user_model().objects.create_user(username="u1", email="u1@example.test", password="pass") task = Task.objects.create(title="My Task", description="Desc", assigned_to=user) assert str(task).startswith("My Task") - assert "aktivan" in str(task) or "(" in str(task) # basic sanity \ No newline at end of file + assert "aktivan" in str(task) or "(" in str(task) + + def test_scheduled_date_defaults_to_null(self): + user = get_user_model().objects.create_user(username="u2", email="u2@example.test", password="pass") + task = Task.objects.create(title="Task bez datuma", assigned_to=user) + assert task.scheduled_date is None + + def test_scheduled_date_is_saved_and_retrieved(self): + user = get_user_model().objects.create_user(username="u3", email="u3@example.test", password="pass") + target = datetime.date(2026, 8, 15) + task = Task.objects.create(title="Planirani task", assigned_to=user, scheduled_date=target) + task.refresh_from_db() + assert task.scheduled_date == target + + def test_scheduled_date_can_be_updated_to_null(self): + user = get_user_model().objects.create_user(username="u4", email="u4@example.test", password="pass") + target = datetime.date(2026, 8, 15) + task = Task.objects.create(title="Task s datumom", assigned_to=user, scheduled_date=target) + task.scheduled_date = None + task.save() + task.refresh_from_db() + assert task.scheduled_date is None \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_serializers.py b/backend/modules/task_management/tests/test_serializers.py index fdd3819..513a367 100644 --- a/backend/modules/task_management/tests/test_serializers.py +++ b/backend/modules/task_management/tests/test_serializers.py @@ -43,4 +43,41 @@ class TaskSerializerTests(TestCase): task = Task.objects.create(title="Task 4", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle) serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True) - assert serializer.is_valid(), serializer.errors \ No newline at end of file + assert serializer.is_valid(), serializer.errors + + def test_scheduled_date_is_optional_on_create(self): + serializer = TaskSerializer(data={"title": "Task planned", "status": "aktivan", "vehicle": str(self.vehicle.id)}) + assert serializer.is_valid(), serializer.errors + assert 'scheduled_date' not in serializer.errors + + def test_scheduled_date_is_included_in_serialized_output(self): + import datetime + task = Task.objects.create( + title="Task with date", + assigned_to=self.user, + vehicle=self.vehicle, + scheduled_date=datetime.date(2026, 9, 1), + ) + serializer = TaskSerializer(instance=task) + assert 'scheduled_date' in serializer.data + assert str(serializer.data['scheduled_date']) == '2026-09-01' + + def test_scheduled_date_can_be_null_in_serialized_output(self): + task = Task.objects.create(title="Task no date", assigned_to=self.user, vehicle=self.vehicle) + serializer = TaskSerializer(instance=task) + assert serializer.data['scheduled_date'] is None + + def test_scheduled_date_accepts_valid_date(self): + serializer = TaskSerializer( + data={"title": "Task mit datum", "status": "aktivan", + "vehicle": str(self.vehicle.id), "scheduled_date": "2026-12-25"} + ) + assert serializer.is_valid(), serializer.errors + + def test_scheduled_date_rejects_invalid_date_string(self): + serializer = TaskSerializer( + data={"title": "Task bad date", "status": "aktivan", + "vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"} + ) + assert not serializer.is_valid() + assert 'scheduled_date' in serializer.errors \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_upcoming_notifications.py b/backend/modules/task_management/tests/test_upcoming_notifications.py new file mode 100644 index 0000000..195442f --- /dev/null +++ b/backend/modules/task_management/tests/test_upcoming_notifications.py @@ -0,0 +1,133 @@ +import datetime +from unittest.mock import patch +from django.test import TestCase +from django.contrib.auth import get_user_model +from modules.task_management.tasks import notify_upcoming_tasks +from modules.task_management.models import Task +from modules.fleet.models import Vehicle, WorkOrder + + +class NotifyUpcomingTasksTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="serviser-notif", + email="serviser@example.test", + password="pass", + ) + self.vehicle = Vehicle.objects.create( + registration_number="ZG-NOTIF-01", + asset_type='crane', + ) + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_notifies_for_task_scheduled_tomorrow(self, mock_notify): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + Task.objects.create( + title="Sutrašnji zadatak", + assigned_to=self.user, + vehicle=self.vehicle, + status='aktivan', + scheduled_date=tomorrow, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 1) + mock_notify.assert_called_once() + call_kwargs = mock_notify.call_args.kwargs + self.assertEqual(call_kwargs['recipient'], self.user) + self.assertIn('Sutrašnji zadatak', call_kwargs['title']) + self.assertEqual(call_kwargs['level'], 'info') + self.assertTrue(call_kwargs.get('send_email')) + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_notifies_multiple_upcoming_tasks(self, mock_notify): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + Task.objects.create(title="Task A", assigned_to=self.user, vehicle=self.vehicle, + status='aktivan', scheduled_date=tomorrow) + Task.objects.create(title="Task B", assigned_to=self.user, vehicle=self.vehicle, + status='servis', scheduled_date=tomorrow) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 2) + self.assertEqual(mock_notify.call_count, 2) + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_skips_task_without_scheduled_date(self, mock_notify): + Task.objects.create( + title="Bez datuma", assigned_to=self.user, vehicle=self.vehicle, + status='aktivan', scheduled_date=None, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 0) + mock_notify.assert_not_called() + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_skips_task_scheduled_today(self, mock_notify): + today = datetime.date.today() + Task.objects.create( + title="Danas", assigned_to=self.user, vehicle=self.vehicle, + status='aktivan', scheduled_date=today, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 0) + mock_notify.assert_not_called() + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_skips_task_scheduled_in_past(self, mock_notify): + yesterday = datetime.date.today() - datetime.timedelta(days=1) + Task.objects.create( + title="Jučer", assigned_to=self.user, vehicle=self.vehicle, + status='aktivan', scheduled_date=yesterday, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 0) + mock_notify.assert_not_called() + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_skips_finished_task(self, mock_notify): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + vehicle2 = Vehicle.objects.create(registration_number="ZG-NOTIF-02", asset_type='crane') + work_order = WorkOrder.objects.create( + vehicle=vehicle2, creator=self.user, purpose='defektaza', + status='closed', start_mileage=100, end_mileage=200, + ) + Task.objects.create( + title="Završeni", assigned_to=self.user, vehicle=vehicle2, + status='zavrsen', scheduled_date=tomorrow, work_order=work_order, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 0) + mock_notify.assert_not_called() + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_skips_cancelled_task(self, mock_notify): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + vehicle3 = Vehicle.objects.create(registration_number="ZG-NOTIF-03", asset_type='crane') + work_order = WorkOrder.objects.create( + vehicle=vehicle3, creator=self.user, purpose='kontrola', + status='closed', start_mileage=50, end_mileage=100, + ) + Task.objects.create( + title="Otkazani", assigned_to=self.user, vehicle=vehicle3, + status='neaktivan', scheduled_date=tomorrow, work_order=work_order, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 0) + mock_notify.assert_not_called() + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_skips_task_without_assigned_user(self, mock_notify): + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + Task.objects.create( + title="Bez servisera", assigned_to=None, vehicle=self.vehicle, + status='aktivan', scheduled_date=tomorrow, + ) + result = notify_upcoming_tasks() + self.assertEqual(result['notified'], 0) + mock_notify.assert_not_called() + + @patch('modules.fleet.services.NotificationService.create_notification') + def test_result_includes_date_string(self, mock_notify): + result = notify_upcoming_tasks() + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + self.assertEqual(result['date'], tomorrow.isoformat()) + self.assertEqual(result['notified'], 0) diff --git a/backend/modules/task_management/views.py b/backend/modules/task_management/views.py index 83b9a04..7a0eb6c 100644 --- a/backend/modules/task_management/views.py +++ b/backend/modules/task_management/views.py @@ -27,6 +27,9 @@ class TaskViewSet(viewsets.ModelViewSet): vehicle_id = self.request.query_params.get('vehicle') if vehicle_id: qs = qs.filter(vehicle_id=vehicle_id) + scheduled_date = self.request.query_params.get('scheduled_date') + if scheduled_date: + qs = qs.filter(scheduled_date=scheduled_date) return qs def perform_create(self, serializer): diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx index 01da981..a2df441 100644 --- a/frontend/src/components/dashboard/FleetDashboardShell.jsx +++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx @@ -6,6 +6,7 @@ import WorkOrderModal from './WorkOrderModal'; import TaskCreateModal from './TaskCreateModal'; import TaskServiceRecordsModal from './TaskServiceRecordsModal'; import WorkOrderDetailModal from './WorkOrderDetailModal'; +import TaskCalendarWidget from './TaskCalendarWidget'; import SyncLogPanel from './SyncLogPanel'; import ServiceRecordForm from '../fleet/ServiceRecordForm'; import ServicePhotoUpload from '../fleet/ServicePhotoUpload'; @@ -578,6 +579,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page const taskColumns = [ { key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' }, { key: 'status', label: 'Status', className: 'px-4 py-3' }, + { key: 'scheduledDate', label: 'Planirano', className: 'px-4 py-3' }, { key: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' }, { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' }, { key: 'workOrder', label: 'Putni nalog', className: 'px-4 py-3' }, @@ -591,6 +593,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page return ( + {hasMounted && ( + setSelectedTask(task)} + /> + )}
+ + {task.scheduled_date ? ( + + {formatDate(task.scheduled_date)} + + ) : ( + - + )} + new Date().getFullYear()); + const [viewMonth, setViewMonth] = useState(() => new Date().getMonth()); + const [selectedDay, setSelectedDay] = useState(null); + + const panelSpring = useSpring({ + transform: open ? 'translate3d(0%,0,0)' : `translate3d(-100%,0,0)`, + config: { tension: 260, friction: 28 }, + }); + + const buttonSpring = useSpring({ + left: open ? PANEL_WIDTH : 0, + config: { tension: 260, friction: 28 }, + }); + + // Map: "YYYY-MM-DD" -> task[] + const tasksByDate = useMemo(() => { + const map = {}; + for (const task of tasks) { + const key = toIsoDateKey(task.scheduled_date); + if (!key) continue; + if (!map[key]) map[key] = []; + map[key].push(task); + } + return map; + }, [tasks]); + + // Calendar grid cells for current month + const calendarDays = useMemo(() => { + const firstDay = new Date(viewYear, viewMonth, 1); + let startDow = firstDay.getDay(); // 0=Sun + startDow = (startDow + 6) % 7; // shift to Mon=0 + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + const cells = []; + for (let i = 0; i < startDow; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + return cells; + }, [viewYear, viewMonth]); + + const selectedDayKey = selectedDay + ? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}` + : null; + const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : []; + const today = new Date(); + + function prevMonth() { + setSelectedDay(null); + if (viewMonth === 0) { setViewYear((y) => y - 1); setViewMonth(11); } + else setViewMonth((m) => m - 1); + } + function nextMonth() { + setSelectedDay(null); + if (viewMonth === 11) { setViewYear((y) => y + 1); setViewMonth(0); } + else setViewMonth((m) => m + 1); + } + + function handleDayClick(day) { + setSelectedDay((prev) => (prev === day ? null : day)); + } + + function handleTaskClick(task) { + onTaskClick?.(task); + setOpen(false); + } + + const totalScheduled = Object.values(tasksByDate).flat().length; + + return ( + <> + {/* Backdrop */} + {open && ( +
setOpen(false)} + /> + )} + + {/* Sliding panel */} + + {/* Header */} +
+

📅 Kalendar zadataka

+ +
+ + {/* Month navigation */} +
+ + + {MONTH_NAMES[viewMonth]} {viewYear} + + +
+ + {/* Calendar grid */} +
+
+ {WEEKDAY_LABELS.map((label) => ( +
+ {label} +
+ ))} +
+
+ {calendarDays.map((day, idx) => { + if (!day) return
; + const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const hasTasks = Boolean(tasksByDate[key]?.length); + const taskCount = tasksByDate[key]?.length ?? 0; + const isToday = + today.getFullYear() === viewYear && + today.getMonth() === viewMonth && + today.getDate() === day; + const isSelected = selectedDay === day; + return ( + + ); + })} +
+
+ + {/* Divider */} +
+ + {/* Task list for selected day OR hint */} +
+ {selectedDay ? ( + <> +

+ {formatDayLabel(viewYear, viewMonth, selectedDay)} +

+ {selectedDayTasks.length === 0 ? ( +

Nema zadataka za odabrani datum.

+ ) : ( +
    + {selectedDayTasks.map((task) => ( +
  • + +
  • + ))} +
+ )} + + ) : ( + <> +

+ Odaberite datum u kalendaru za prikaz zadataka. +

+ {totalScheduled > 0 && ( +

+ {totalScheduled} zadatak/a s planiranim datumom. +

+ )} + {totalScheduled === 0 && ( +

+ Nijedan zadatak nema postavljen planirani datum. +

+ )} + + )} +
+ + + {/* Toggle tab button — slides right as panel opens */} + `${v}px`) }} + onClick={() => setOpen((o) => !o)} + className="fixed top-1/2 z-[47] -translate-y-1/2 rounded-r-xl border border-l-0 border-border-hairline bg-canvas-elevated px-1.5 py-4 shadow-md hover:bg-indigo-50 focus:outline-none" + aria-label={open ? 'Zatvori kalendar' : 'Otvori kalendar'} + title="Kalendar zadataka" + > + + 📅 Kalendar + + + + ); +} diff --git a/frontend/src/components/dashboard/TaskCreateModal.jsx b/frontend/src/components/dashboard/TaskCreateModal.jsx index ffca22d..b1485d6 100644 --- a/frontend/src/components/dashboard/TaskCreateModal.jsx +++ b/frontend/src/components/dashboard/TaskCreateModal.jsx @@ -10,6 +10,7 @@ const INITIAL_FORM = { description: '', status: 'aktivan', work_order: '', + scheduled_date: '', }; export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) { @@ -50,6 +51,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = description: selectedTemplate.description || '', status: 'aktivan', work_order: '', + // scheduled_date is kept so user can set it in template mode too })); }, [selectedTemplate]); @@ -98,6 +100,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = template_id: templateId, vehicle: contextCrane.id, work_order: null, + scheduled_date: form.scheduled_date || null, }; } else { const title = String(form.title || '').trim(); @@ -115,6 +118,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = status: form.status || 'aktivan', vehicle: contextCrane.id, work_order: form.work_order || null, + scheduled_date: form.scheduled_date || null, }; } @@ -231,6 +235,17 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane = + + + {taskError ? (
{taskError} @@ -277,6 +295,7 @@ export default function TaskServiceRecordsModal({ title: String(task.title || ''), description: String(task.description || ''), status: String(task.status || 'aktivan'), + scheduled_date: String(task.scheduled_date || ''), }); }} disabled={updatingTask}