CANCELLED
TerminalReachable from any non-terminal state. The cancel-with-reason dialog lives on the calendar day flyout; the flow board cancels through the state machine without capturing a reason.
Operating manual
How a clinic appointment travels from creation to closure: what each role does, where it happens, and which component owns it. Written for receptionists, nurses, providers and admins — and as the integration map for engineers extending the flow.
A single Appointment row whose status advances through a state machine. Three ways in, one path through the clinic.
Internal scheduling and walk-ins skip approval entirely. Only public requests pass through the queue.
Lane changes are hand-offs. The patient physically moves between desks at each one, which is why the flow board is split into three role-specific views rather than one shared board.
When a nurse saves a fresh medical exam report, the patient's active appointment auto-walks READY_FOR_MEDICAL_EXAM → IN_MEDICAL_EXAM → READY_FOR_APPOINTMENT so the board matches reality without a second click. Editing an existing report does not retrigger it.
Drawn separately because they are reachable from nearly everywhere — wiring every edge would make the map unreadable.
CANCELLEDReachable from any non-terminal state. The cancel-with-reason dialog lives on the calendar day flyout; the flow board cancels through the state machine without capturing a reason.
MISSEDSet explicitly during the day on a patient who checked in but left before being seen. Reachable from any state before IN_APPOINTMENT — deliberately not offered once the patient is in the room.
NO_SHOWDecided after the fact on an appointment that never arrived. Set from the calendar, not the flow board. Distinct from MISSED: the patient never checked in at all.
Three routes in, each with different gating.
| Page | /appointments/create |
| Component | AppointmentForm |
| Roles | Admin, receptionist, and all provider roles |
| Pre-conditions | The matched patient exists; the chosen provider has working hours configured, or the slot list comes back empty |
| Result | Status defaults to SCHEDULED; create hook writes an audit row |
Slots come from AppointmentSlotService::availableSlots, which respects working hours, lunch breaks, time off, and existing non-cancelled appointments. The doctor dropdown is filtered by AppointmentType::eligibleProviderRoles().
| Page | /appointments/request — public, outside the auth middleware group |
| Component | Volt appointments.public-request |
| Required | Expediente ID or DUI, plus date of birth, visit type and slot |
| Result | Matched → patient_id set, status REQUESTED. Unmatched → request_payload JSON retained, patient_id null, resolved by reception |
The next-day-noon lock is enforced twice — in slot generation and again as a server-side guard on submit — so a hand-crafted POST cannot bypass it.
Reception board CTA, gated to receptionist and admin only. starts_at rounds up to the next 5-minute boundary, duration defaults to 30 minutes, and the appointment persists directly as CHECKING_IN with notes = '[walk-in]'.
Slot collision is soft-checked. A conflict surfaces a warning listing the clashing appointments, but does not block the save — the receptionist owns the call. This is deliberate; don't harden it into a block.
Only public requests pass through here. Internal bookings and walk-ins skip it entirely.
Reception resolves unmatched patients by linking the row to a real patient record, then either approves — assigning a doctor filtered to those who can host the visit type and have the slot free — or cancels with a reason. Approved appointments appear on the calendar and in the Reception board's "Expected today" lane on the day they fall.
Every action method re-checks canApproveAppointments() server-side, not just on mount.
Anyone with canViewAppointments(). Providers default to their own; admin and reception see all.
Staff use the calendar to see what's scheduled, reschedule through the day flyout, or move statuses before arrival: SCHEDULED → CONFIRMED, and CONFIRMED → COMPLETED | CANCELLED | NO_SHOW. Cancellation captures a free-text reason. Every transition passes through AppointmentStatus::canTransitionTo() and is audit-logged.
The core operational surface. One URL — /appointments/flow — resolves to the right board for the current user's role.
The anchor date defaults to today and persists via ?anchor=YYYY-MM-DD. Every status change stamps stage_entered_at, which drives the "in stage X minutes" display on each card.
| Lane | Holds | Actions available |
|---|---|---|
| Expected today | SCHEDULED, CONFIRMED | Check in · Mark as missed |
| Checking in | CHECKING_IN | Send to nurse · Send to doctor (skip-exam types) · Mark as missed |
| In visit | All four mid-visit states | None — informational only |
| Ready for checkout | READY_FOR_CHECKOUT | Check out → COMPLETED |
Pharmacists land on this same board in read-only mode — same view, no CTA, no action buttons.
Two lanes: READY_FOR_MEDICAL_EXAM and IN_MEDICAL_EXAM. Skip-exam visit types are filtered out of the query entirely, so they never appear here.
Nurses deliberately do not have canManageAppointments() — they cannot create or edit appointment records — but canAdvanceVisitStages() authorises them to move stages on this board. That split is the whole reason the helper exists.
Two lanes: READY_FOR_APPOINTMENT and IN_APPOINTMENT. The query is hard-filtered to doctor_id = Auth::id(), and a defence-in-depth canActOn() check rejects any transition against another provider's row.
The provider's actual work happens off this board. Consultations, prescriptions and exam reports are created from the patient's profile. The flow board is the sequencing layer, not the documentation layer.
READY_FOR_CHECKOUT row. Status becomes COMPLETED and the card leaves the board on next refresh.COMPLETED.reminder_email_sent_at and reminder_whatsapp_sent_at columns exist but nothing writes them — no notification, mailable or job references either one. Schema is ready; the feature is not built.Quick index of every surface in the lifecycle.
| Page | Route | Primary roles |
|---|---|---|
| Appointment list | /appointments | All canViewAppointments |
| Calendar | /appointments/calendar | All canViewAppointments |
| Patient Flow board | /appointments/flow | Role-resolved — reception / nurse / provider |
| New / edit appointment | /appointments/create, /appointments/{id}/edit | canManageAppointments |
| Provider schedule editor | /users/{user}/schedule | canManageOwnSchedule (own) or canManageAllSchedules (any) |
| All-provider schedules | /appointments/schedules | canManageAllSchedules |
| Public request form | /appointments/request | Public |
| Approval queue | /appointments/approval-queue | canApproveAppointments |
Which roles hold which capability. Derived from App\Enums\UserRole — the helpers, never role strings.
| Capability | Admin | Recep. | Doctor | Pediat. | Gynae. | Physio | Gen. cons. | Nutri. | Nurse | Pharm. | Lab tech |
|---|---|---|---|---|---|---|---|---|---|---|---|
canViewAppointments() |
|||||||||||
canManageAppointments() |
|||||||||||
canApproveAppointments() |
|||||||||||
canAdvanceVisitStages() |
|||||||||||
canManageOwnSchedule() |
|||||||||||
canManageAllSchedules() |
The one asymmetry worth remembering: nurses can advance visit stages but cannot create or edit appointment records. That is what canAdvanceVisitStages() exists to express.
Every UI button checks canTransitionTo() before rendering, and every action handler re-checks it server-side. To add a stage: extend the enum, then the color() and label() matches, the canTransitionTo arm, and the relevant board's nextActionsFor() map.
The CHECKING_IN → READY_FOR_APPOINTMENT shortcut is allowed unconditionally by the state machine; the per-call guard in FlowBoard::advanceTo enforces requiresMedicalExam(). That's a visit-type concern, not a state-machine one.
stage_entered_at is bumped only when status is dirty. A notes-only edit doesn't reset it — intentional, and covered by a test. Don't switch the trigger to updated_at.
transition()It's taken on Component for view-transition support. The state-machine method is called advanceTo for that reason — keep any new one similarly renamed.
Lane labels, transition buttons, status labels and empty states all live under appointments.flow.* and appointments.status.* in both resources/lang/en/main.php and resources/lang/es/main.php. Add to both in the same change.