diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5a500e..f53fcfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - **Count production from MQTT machines onto a line/step** *(admin / connectivity)* — a machine/MQTT device can be **assigned to a production line**, and a new **"Count at Station / Step"** topic-mapping action turns each sensor pulse (e.g. a break-beam sensor: one unit leaving a station) into `+1` on the addressed step of the line's **currently running** work order — no per-order configuration. The per-step throughput is tracked in a new `passed_qty` counter; a mapping flagged as the finished-goods counting point also feeds the work order's `produced_qty` (through the shared machine-count path, so `counting_source` and auto start/complete are honoured — no double counting). `update_work_order_qty` can now also target a line directly (the running order) instead of a fixed order number. The device form gains an **Assigned line** picker and the topic-mapping editor a guided **Line + Station/Step** form for the count action (no more hand-written JSON). - **Traceability when editing an in-use process template** *(admin)* — editing a template's steps (add / rename / delete / reorder) still mutates the current template in place, and running work orders correctly keep their frozen snapshot — but that used to happen silently. Now the template page shows a **warning banner** when the template backs active (non-finished) work orders, destructive step edits ask for confirmation while it's in use, and **every step change is written to the immutable audit log** (before/after shape) so the previous version is never lost. No change to how orders resolve their steps. +### Added +- **Add maintenance to the planner** *(admin)* — a new **+ Maintenance** button on the schedule planner opens a modal to place a **defined maintenance** (a maintenance schedule, which pre-fills its title / type / line) or an ad-hoc one onto a line at a chosen date, time and duration. It lands as a **distinct yellow tile** in the line's maintenance strip (maintenance tiles are now yellow instead of purple, so they stand out from work orders). Backed by `POST /admin/schedule/maintenance`. + ### Added - **Plant timezone is changeable after installation** *(admin)* — Settings → System → General now carries a timezone picker (region + zone), writing the same `system_settings` row the installer's step does; the wizard already promised this was possible. The chosen zone is re-applied per request and before each queued job, so on Octane a change reaches every worker immediately instead of waiting for a container restart. Saving reloads the page so every displayed time switches over at once. - **Product types as Bill-of-Materials components** *(admin)* — a BOM line can now be a manufactured **product type** (a sub-assembly), not only a material. In the BOM editor a Material / Product type switch picks the component kind; product-type lines carry the same quantity-per-unit, step, scrap %, consumption timing and notes as materials. A product type can't be a component of itself, and each appears once per template. Lines are captured in the work-order snapshot as sub-assembly references; they're a simple component reference (they don't explode into their own BOM) and are skipped by the material stock/consumption engine. Additive — existing material BOMs are unaffected. diff --git a/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php b/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php index f2fb47eb..b3900157 100644 --- a/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php +++ b/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Web\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Web\Admin\StoreMaintenanceEventRequest; use App\Models\ScheduleChangeLog; use App\Models\WorkOrder; use App\Services\Schedule\SchedulePlannerService; @@ -158,6 +159,17 @@ public function resizeOrder(Request $request, WorkOrder $workOrder) ]); } + /** + * Place a maintenance event on the planner (the "Add maintenance" modal). A + * defined maintenance schedule can pre-fill it, or an ad-hoc title/type. + */ + public function storeMaintenance(StoreMaintenanceEventRequest $request) + { + $this->planner->createMaintenanceEvent($request->validated()); + + return back()->with('success', __('Maintenance added to the planner.')); + } + public function checkUpdates(Request $request) { $lastUpdated = WorkOrder::max('updated_at'); diff --git a/backend/app/Http/Requests/Web/Admin/StoreMaintenanceEventRequest.php b/backend/app/Http/Requests/Web/Admin/StoreMaintenanceEventRequest.php new file mode 100644 index 00000000..bac8bfee --- /dev/null +++ b/backend/app/Http/Requests/Web/Admin/StoreMaintenanceEventRequest.php @@ -0,0 +1,37 @@ + [ + 'nullable', 'integer', + Rule::exists('maintenance_schedules', 'id')->where('is_active', true), + ], + 'title' => ['required_without:schedule_id', 'nullable', 'string', 'max:255'], + 'event_type' => ['nullable', 'in:planned,corrective,inspection'], + 'line_id' => ['required', 'integer', 'exists:lines,id'], + 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], + 'scheduled_at' => ['required', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'description' => ['nullable', 'string'], + ]; + } +} diff --git a/backend/app/Services/Schedule/SchedulePlannerService.php b/backend/app/Services/Schedule/SchedulePlannerService.php index 04490410..0de82187 100644 --- a/backend/app/Services/Schedule/SchedulePlannerService.php +++ b/backend/app/Services/Schedule/SchedulePlannerService.php @@ -236,6 +236,19 @@ public function board(array $params = []): array : 60, 'description' => $m->description, ])->values()->all(), + // Defined maintenance (schedules) offered in the planner's "Add + // maintenance" modal — drop one onto a line/day as a yellow tile. + 'maintenanceSchedules' => MaintenanceSchedule::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'event_type', 'line_id', 'workstation_id', 'description']) + ->map(fn ($s) => [ + 'id' => $s->id, + 'name' => $s->name, + 'event_type' => $s->event_type, + 'line_id' => $s->line_id, + 'workstation_id' => $s->workstation_id, + 'description' => $s->description, + ])->values()->all(), 'realtimeMode' => $realtimeMode, 'overdueImportant' => [ 'count' => $importantOverdueCount, @@ -251,6 +264,35 @@ public function board(array $params = []): array * * @return array{conflict:bool, message?:string, warnings?:array} */ + /** + * Place a maintenance event on the planner (the "Add maintenance" modal). A + * defined schedule (schedule_id) pre-fills the title / type / line; a bare + * title + event_type works too. Lands as a pending tile at the chosen slot. + * + * @param array $input + */ + public function createMaintenanceEvent(array $input): MaintenanceEvent + { + $schedule = ! empty($input['schedule_id']) + ? MaintenanceSchedule::find($input['schedule_id']) + : null; + + $scheduledAt = Carbon::parse($input['scheduled_at']); + $duration = (int) ($input['duration_minutes'] ?? 60); + + return MaintenanceEvent::create([ + 'title' => $input['title'] ?? $schedule?->name ?? 'Maintenance', + 'event_type' => $input['event_type'] ?? $schedule?->event_type ?? MaintenanceEvent::TYPE_PLANNED, + 'status' => MaintenanceEvent::STATUS_PENDING, + 'line_id' => $input['line_id'] ?? $schedule?->line_id, + 'workstation_id' => $input['workstation_id'] ?? $schedule?->workstation_id, + 'schedule_id' => $schedule?->id, + 'scheduled_at' => $scheduledAt, + 'scheduled_end_at' => $scheduledAt->copy()->addMinutes(max(1, $duration)), + 'description' => $input['description'] ?? $schedule?->description, + ]); + } + public function updateOrder(WorkOrder $workOrder, array $input, bool $force = false): array { $data = []; diff --git a/backend/lang/en.json b/backend/lang/en.json index 71986bad..1c4c35e0 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -5670,6 +5670,15 @@ "Select timezone": "Select timezone", "Plant timezone. Every timestamp, report boundary and shift edge in the app is expressed in it.": "Plant timezone. Every timestamp, report boundary and shift edge in the app is expressed in it.", "Changing the timezone reloads the page so every displayed time switches over at once.": "Changing the timezone reloads the page so every displayed time switches over at once.", + "Add maintenance": "Add maintenance", + "Defined maintenance": "Defined maintenance", + "— None (custom) —": "— None (custom) —", + "e.g. Lubrication": "e.g. Lubrication", + "Minutes": "Minutes", + "Add to planner": "Add to planner", + "Pick a line, a date and a maintenance (defined or a title).": "Pick a line, a date and a maintenance (defined or a title).", + "Maintenance added to the planner.": "Maintenance added to the planner.", + "Could not add maintenance.": "Could not add maintenance.", "Choose your setup": "Choose your setup", "Before the setup wizard": "Before the setup wizard", "First pick the feature set you want. Core areas (Dashboard, Orders, Production, Admin) are always on. You can change this anytime in Settings → System → Modules. Next, the setup wizard will walk you through your first line, product, process template and work order.": "First pick the feature set you want. Core areas (Dashboard, Orders, Production, Admin) are always on. You can change this anytime in Settings → System → Modules. Next, the setup wizard will walk you through your first line, product, process template and work order.", @@ -5682,5 +5691,9 @@ "Editing steps here does not affect those running orders — they keep the frozen snapshot taken when they were created. Changes are not versioned; the previous shape is kept only in the audit log. For a controlled change to a specific order, use its change request instead.": "Editing steps here does not affect those running orders — they keep the frozen snapshot taken when they were created. Changes are not versioned; the previous shape is kept only in the audit log. For a controlled change to a specific order, use its change request instead.", "Edit a template that is in use?": "Edit a template that is in use?", "This template backs :count active work order(s). They keep their frozen snapshot and are unaffected, but this change is not versioned — only the audit log keeps the previous shape.": "This template backs :count active work order(s). They keep their frozen snapshot and are unaffected, but this change is not versioned — only the audit log keeps the previous shape.", - "Action parameters must be a JSON object.": "Action parameters must be a JSON object." + "Action parameters must be a JSON object.": "Action parameters must be a JSON object.", + "Could not add the maintenance.": "Could not add the maintenance.", + "Pick a maintenance for this slot": "Pick a maintenance for this slot", + "Search maintenance": "Search maintenance", + "No maintenance schedules.": "No maintenance schedules." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 77c5dce1..0a6e0ac6 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -5670,6 +5670,15 @@ "Select timezone": "Wybierz strefę czasową", "Plant timezone. Every timestamp, report boundary and shift edge in the app is expressed in it.": "Strefa czasowa zakładu. Wszystkie znaczniki czasu, granice raportów i zmian w aplikacji są w niej wyrażone.", "Changing the timezone reloads the page so every displayed time switches over at once.": "Zmiana strefy czasowej przeładowuje stronę, aby wszystkie wyświetlane godziny zmieniły się naraz.", + "Add maintenance": "Dodaj utrzymanie", + "Defined maintenance": "Zdefiniowane utrzymanie", + "— None (custom) —": "— Brak (własne) —", + "e.g. Lubrication": "np. Smarowanie", + "Minutes": "Minuty", + "Add to planner": "Dodaj do planera", + "Pick a line, a date and a maintenance (defined or a title).": "Wybierz linię, datę i utrzymanie (zdefiniowane lub tytuł).", + "Maintenance added to the planner.": "Utrzymanie dodane do planera.", + "Could not add maintenance.": "Nie udało się dodać utrzymania.", "Choose your setup": "Wybierz konfigurację", "Before the setup wizard": "Przed kreatorem konfiguracji", "First pick the feature set you want. Core areas (Dashboard, Orders, Production, Admin) are always on. You can change this anytime in Settings → System → Modules. Next, the setup wizard will walk you through your first line, product, process template and work order.": "Najpierw wybierz zestaw funkcji. Obszary podstawowe (Pulpit, Zlecenia, Produkcja, Admin) są zawsze włączone. Możesz to zmienić w każdej chwili w Ustawienia → System → Moduły. Następnie kreator konfiguracji przeprowadzi Cię przez pierwszą linię, produkt, szablon procesu i zlecenie.", @@ -5682,5 +5691,9 @@ "Editing steps here does not affect those running orders — they keep the frozen snapshot taken when they were created. Changes are not versioned; the previous shape is kept only in the audit log. For a controlled change to a specific order, use its change request instead.": "Edycja kroków tutaj nie wpływa na trwające zlecenia — zachowują one zamrożony snapshot z chwili utworzenia. Zmiany nie są wersjonowane; poprzedni kształt pozostaje wyłącznie w dzienniku audytu. Dla kontrolowanej zmiany konkretnego zlecenia użyj jego wniosku o zmianę.", "Edit a template that is in use?": "Edytować szablon będący w użyciu?", "This template backs :count active work order(s). They keep their frozen snapshot and are unaffected, but this change is not versioned — only the audit log keeps the previous shape.": "Ten szablon obsługuje :count aktywnych zleceń. Zachowują one zamrożony snapshot i pozostają nienaruszone, ale ta zmiana nie jest wersjonowana — poprzedni kształt zachowuje tylko dziennik audytu.", - "Action parameters must be a JSON object.": "Parametry akcji muszą być obiektem JSON." + "Action parameters must be a JSON object.": "Parametry akcji muszą być obiektem JSON.", + "Could not add the maintenance.": "Nie udało się dodać przeglądu.", + "Pick a maintenance for this slot": "Wybierz przegląd dla tego slotu", + "Search maintenance": "Szukaj przeglądu", + "No maintenance schedules.": "Brak harmonogramów przeglądów." } diff --git a/backend/resources/js/Pages/admin/schedule/Planner.jsx b/backend/resources/js/Pages/admin/schedule/Planner.jsx index eeaf412d..72da7ea1 100644 --- a/backend/resources/js/Pages/admin/schedule/Planner.jsx +++ b/backend/resources/js/Pages/admin/schedule/Planner.jsx @@ -13,6 +13,7 @@ import { HourlyView, MonthlyView } from './planner/views2'; import { Toolbar, BacklogRail } from './planner/panels'; import { OrderEditSheet, AssignPopup, ConflictDialog, LiveTrackingBar, Toasts, SavingOverlay, + AddMaintenanceModal, } from './planner/modals'; // Hover affordances for the design's work-order blocks (brightness + reveal ✕). @@ -40,7 +41,7 @@ export default function Planner() { workOrders = [], lines = [], allLines = [], shifts = [], viewMode = 'weekly', shiftsPerDay = 1, slotMinutes = 15, showWeekends = true, startDate, rangeStart, rangeEnd, navPrev, navNext, - backlogOrders = [], maintenanceEvents = [], realtimeMode = 'polling', + backlogOrders = [], maintenanceEvents = [], maintenanceSchedules = [], realtimeMode = 'polling', overdueImportant = { count: 0, orders: [] }, } = usePage().props; @@ -65,6 +66,7 @@ export default function Planner() { const [conflict, setConflict] = useState(null); // { apply } const [confirmBox, setConfirmBox] = useState(null); // { title, body, confirmLabel, apply } const [trackingData, setTrackingData] = useState(null); + const [maintOpen, setMaintOpen] = useState(false); const draggingRef = useRef(false); const toast = useCallback((msg, kind = 'success') => { @@ -362,6 +364,13 @@ export default function Planner() { goTo(navPrev)} onNext={() => goTo(navNext)} onToday={() => nav({ view_mode: viewMode, line_id: lineId })} rangeLabel={rangeLabel} /> +
+ +
+
{viewMode === 'weekly' && } @@ -374,7 +383,30 @@ export default function Planner() { {selected && setSelected(null)} onSave={saveEdit} onUnassign={unassign} />} - {assignTarget && setAssignTarget(null)} onPick={(wo, target) => { setAssignTarget(null); dropToCell(wo, target); }} />} + {assignTarget && ( + setAssignTarget(null)} + onPick={(wo, target) => { setAssignTarget(null); dropToCell(wo, target); }} + onPickMaintenance={(s, target) => { + setAssignTarget(null); + router.post('/admin/schedule/maintenance', { + schedule_id: s.id, + title: s.name || null, + event_type: s.event_type || 'planned', + line_id: target.lineId, + scheduled_at: `${target.date} 08:00`, + duration_minutes: s.duration_minutes || 60, + }, { + preserveScroll: true, + onSuccess: () => toast(__('Maintenance added to the planner.')), + onError: (errors) => toast(Object.values(errors || {})[0] || __('Could not add maintenance.'), 'error'), + }); + }} + /> + )} {conflict && setConflict(null)} onConfirm={() => { conflict.apply(); setConflict(null); }} />} {confirmBox && ( setConfirmBox(null)} @@ -383,6 +415,16 @@ export default function Planner() { {confirmBox.body} )} + {maintOpen && ( + setMaintOpen(false)} + onCreated={() => { setMaintOpen(false); toast(__('Maintenance added to the planner.')); }} + onError={(msg) => toast(msg || __('Could not add maintenance.'), 'error')} + /> + )} {saving && } diff --git a/backend/resources/js/Pages/admin/schedule/planner/modals.jsx b/backend/resources/js/Pages/admin/schedule/planner/modals.jsx index 7e30f9cd..25b97458 100644 --- a/backend/resources/js/Pages/admin/schedule/planner/modals.jsx +++ b/backend/resources/js/Pages/admin/schedule/planner/modals.jsx @@ -1,7 +1,7 @@ // Edit panel, assign popup, new-order modal, conflict dialog, live tracking, // toast — styled to the OpenMES Schedule design. import { useState } from 'react'; -import { usePage } from '@inertiajs/react'; +import { usePage, router } from '@inertiajs/react'; import { Dropdown, DatePicker } from '@openmes/ui'; import { __ } from '../../../../lib/i18n'; import DueCountdown from '../../../../components/DueCountdown'; @@ -11,6 +11,117 @@ import { StatusPill } from './OrderCard'; const lblStyle = { fontFamily: MONO, fontSize: 8.5, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--om-faint)', marginBottom: 5 }; +const inputStyle = { width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--om-line)', borderRadius: 8, background: 'var(--om-card)', color: 'var(--om-ink)' }; + +/** + * "Add maintenance" — place a defined maintenance (a MaintenanceSchedule) or an + * ad-hoc one onto a line/day as a yellow tile. Posts to the planner; the board + * reloads with the new tile. + */ +export function AddMaintenanceModal({ lines = [], schedules = [], startDate, onClose, onCreated, onError }) { + const [scheduleId, setScheduleId] = useState(''); + const [title, setTitle] = useState(''); + const [eventType, setEventType] = useState('planned'); + const [lineId, setLineId] = useState(lines[0] ? String(lines[0].id) : ''); + const [date, setDate] = useState(startDate || ''); + const [time, setTime] = useState('08:00'); + const [duration, setDuration] = useState('60'); + const [busy, setBusy] = useState(false); + + const pickSchedule = (v) => { + setScheduleId(v); + const s = schedules.find((x) => String(x.id) === String(v)); + if (s) { + setTitle(s.name || ''); + if (s.event_type) setEventType(s.event_type); + if (s.line_id) setLineId(String(s.line_id)); + } + }; + + const submit = () => { + if (!lineId || !date || (!scheduleId && !title.trim())) { + onError(__('Pick a line, a date and a maintenance (defined or a title).')); + return; + } + setBusy(true); + router.post('/admin/schedule/maintenance', { + schedule_id: scheduleId || null, + title: title || null, + event_type: eventType, + line_id: Number(lineId), + scheduled_at: `${date} ${time || '00:00'}`, + duration_minutes: Number(duration) || 60, + }, { + preserveScroll: true, + onSuccess: () => onCreated(), + onError: (errors) => onError(Object.values(errors || {})[0] || __('Could not add the maintenance.')), + onFinish: () => setBusy(false), + }); + }; + + return ( + +
+
+ +

{__('Add maintenance')}

+
+
+
+
{__('Defined maintenance')}
+ ({ value: String(s.id), label: s.name }))]} + className="w-full" /> +
+
+
{__('Title')}
+ setTitle(e.target.value)} placeholder={__('e.g. Lubrication')} style={inputStyle} /> +
+
+
+
{__('Type')}
+ +
+
+
{__('Line')}
+ ({ value: String(l.id), label: l.code ? `${l.code} · ${l.name}` : l.name }))} + className="w-full" /> +
+
+
+
+
{__('Date')}
+ setDate(e.target.value)} style={inputStyle} /> +
+
+
{__('Time')}
+ setTime(e.target.value)} style={inputStyle} /> +
+
+
{__('Minutes')}
+ setDuration(e.target.value)} style={inputStyle} /> +
+
+
+
+ + +
+
+
+ ); +} + function Backdrop({ children, onClose }) { return (
@@ -134,28 +245,48 @@ export function OrderEditSheet({ wo, ctx, onClose, onSave, onUnassign }) { ); } -// Assign popup — pick a backlog order for an empty cell. -export function AssignPopup({ target, ctx, onClose, onPick }) { +// Assign popup — pick a backlog order OR a defined maintenance for an empty cell. +export function AssignPopup({ target, ctx, schedules = [], onClose, onPick, onPickMaintenance }) { const { data } = ctx; const [q, setQ] = useState(''); + const [tab, setTab] = useState('orders'); const line = data.allLines.find((l) => l.id === target.lineId); - const items = data.backlog.filter((o) => q === '' || o.order_no.toLowerCase().includes(q.toLowerCase()) || (o.product_name || '').toLowerCase().includes(q.toLowerCase())); + const orders = data.backlog.filter((o) => q === '' || o.order_no.toLowerCase().includes(q.toLowerCase()) || (o.product_name || '').toLowerCase().includes(q.toLowerCase())); + const maints = schedules.filter((s) => q === '' || (s.name || '').toLowerCase().includes(q.toLowerCase())); + + const tabBtn = (key, label) => ( + + ); + return (
{__('Assign to')} {line?.code} · {target.date}
-
{__('Pick a backlog order for this slot')}
+
+ {tab === 'orders' ? __('Pick a backlog order for this slot') : __('Pick a maintenance for this slot')} +
-
-
+ {onPickMaintenance && ( +
+ {tabBtn('orders', __('Orders'))} + {tabBtn('maintenance', __('Maintenance'))} +
+ )} +
+
- setQ(e.target.value)} placeholder={__('Search order or product')} autoFocus + setQ(e.target.value)} placeholder={tab === 'orders' ? __('Search order or product') : __('Search maintenance')} autoFocus className="flex-1 min-w-0 outline-none" style={{ border: 'none', background: 'transparent', fontSize: 12.5, color: 'var(--om-ink)' }} />
- {items.map((wo) => ( + {tab === 'orders' && orders.map((wo) => (
onPick(wo, target)} className="flex items-center gap-3" style={{ padding: '11px 12px', border: '1px solid var(--om-line)', borderRadius: 9, marginBottom: 8, cursor: 'pointer' }}>
@@ -165,7 +296,22 @@ export function AssignPopup({ target, ctx, onClose, onPick }) {
))} - {items.length === 0 &&
{__('No matching orders.')}
} + {tab === 'orders' && orders.length === 0 &&
{__('No matching orders.')}
} + + {tab === 'maintenance' && maints.map((s) => ( +
onPickMaintenance(s, target)} className="flex items-center gap-3" + style={{ padding: '11px 12px', border: '1px solid var(--om-line)', borderRadius: 9, marginBottom: 8, cursor: 'pointer' }}> + +
+
{s.name}
+
+ {__(s.event_type === 'corrective' ? 'Corrective' : s.event_type === 'inspection' ? 'Inspection' : 'Planned')} + {s.line_id ? ' · ' + (data.allLines.find((l) => l.id === s.line_id)?.code ?? '') : ''} +
+
+
+ ))} + {tab === 'maintenance' && maints.length === 0 &&
{__('No maintenance schedules.')}
}
diff --git a/backend/resources/js/Pages/admin/schedule/planner/views.jsx b/backend/resources/js/Pages/admin/schedule/planner/views.jsx index a0c73f7c..674f886f 100644 --- a/backend/resources/js/Pages/admin/schedule/planner/views.jsx +++ b/backend/resources/js/Pages/admin/schedule/planner/views.jsx @@ -25,12 +25,13 @@ const MAINT_H = 17; const fmtDow = (d) => formatDate(parseDate(d), { weekday: 'short' }); const fmtDayMon = (d) => formatDate(parseDate(d), { day: '2-digit', month: 'short' }); +// Maintenance tiles are a distinct yellow so they stand out from work orders. function MaintPill({ m }) { return ( -
- - {m.title} + + {m.title}
); } diff --git a/backend/routes/web.php b/backend/routes/web.php index 4e3087ea..e3cbc445 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -472,6 +472,7 @@ Route::post('/schedule/changes/{change}/undo', [SchedulePlannerController::class, 'undoChange'])->name('schedule.changes.undo'); Route::put('/schedule/{workOrder}', [SchedulePlannerController::class, 'updateOrder'])->name('schedule.update'); Route::put('/schedule/{workOrder}/resize', [SchedulePlannerController::class, 'resizeOrder'])->name('schedule.resize'); + Route::post('/schedule/maintenance', [SchedulePlannerController::class, 'storeMaintenance'])->name('schedule.maintenance.store'); // Schedule · Employees (tachograph-style day/team/month planner) Route::get('/schedule/employees', [\App\Http\Controllers\Web\Admin\EmployeeScheduleController::class, 'index'])->name('schedule.employees'); diff --git a/backend/tests/Feature/Schedule/PlannerMaintenanceTest.php b/backend/tests/Feature/Schedule/PlannerMaintenanceTest.php new file mode 100644 index 00000000..f222e9e9 --- /dev/null +++ b/backend/tests/Feature/Schedule/PlannerMaintenanceTest.php @@ -0,0 +1,121 @@ +admin = tap(User::factory()->create(), fn ($u) => $u->assignRole('Admin')); + $this->operator = tap(User::factory()->create(), fn ($u) => $u->assignRole('Operator')); + } + + public function test_admin_can_place_a_defined_maintenance_from_the_planner(): void + { + $line = Line::factory()->create(); + $schedule = MaintenanceSchedule::factory()->create([ + 'name' => 'Weekly lube', + 'event_type' => 'planned', + 'line_id' => $line->id, + ]); + + $this->actingAs($this->admin)->post(route('admin.schedule.maintenance.store'), [ + 'schedule_id' => $schedule->id, + 'line_id' => $line->id, + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + 'duration_minutes' => 90, + ])->assertRedirect(); + + $this->assertDatabaseHas('maintenance_events', [ + 'title' => 'Weekly lube', + 'event_type' => 'planned', + 'line_id' => $line->id, + 'schedule_id' => $schedule->id, + 'status' => MaintenanceEvent::STATUS_PENDING, + ]); + } + + public function test_admin_can_place_an_adhoc_maintenance(): void + { + $line = Line::factory()->create(); + + $this->actingAs($this->admin)->post(route('admin.schedule.maintenance.store'), [ + 'title' => 'Belt swap', + 'event_type' => 'corrective', + 'line_id' => $line->id, + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + 'duration_minutes' => 30, + ])->assertRedirect(); + + $event = MaintenanceEvent::firstWhere('title', 'Belt swap'); + $this->assertNotNull($event); + $this->assertSame($line->id, $event->line_id); + $this->assertSame(30, (int) $event->scheduled_at->diffInMinutes($event->scheduled_end_at)); + } + + public function test_line_and_a_maintenance_are_required(): void + { + $this->actingAs($this->admin)->post(route('admin.schedule.maintenance.store'), [ + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + ])->assertSessionHasErrors(['line_id', 'title']); + } + + public function test_operator_cannot_place_maintenance(): void + { + $line = Line::factory()->create(); + + $this->actingAs($this->operator)->post(route('admin.schedule.maintenance.store'), [ + 'title' => 'X', 'event_type' => 'planned', 'line_id' => $line->id, + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + ])->assertForbidden(); + + $this->assertDatabaseCount('maintenance_events', 0); + } + + public function test_guest_cannot_place_maintenance(): void + { + $line = Line::factory()->create(); + + $this->post(route('admin.schedule.maintenance.store'), [ + 'title' => 'X', 'event_type' => 'planned', 'line_id' => $line->id, + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + ])->assertRedirect(route('login')); + + $this->assertDatabaseCount('maintenance_events', 0); + } + + public function test_an_inactive_schedule_is_rejected(): void + { + $line = Line::factory()->create(); + $schedule = MaintenanceSchedule::factory()->create(['is_active' => false]); + + $this->actingAs($this->admin)->post(route('admin.schedule.maintenance.store'), [ + 'schedule_id' => $schedule->id, 'line_id' => $line->id, + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + ])->assertSessionHasErrors('schedule_id'); + + $this->assertDatabaseCount('maintenance_events', 0); + } +}