riseup-squad20/susconecta/components/features/forms/calendar-registration-form.tsx

1305 lines
64 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades, listarExcecoes, listarAgendamentos } from "@/lib/api";
import { toast } from '@/hooks/use-toast';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { listAssignmentsForPatient } from "@/lib/assignment";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Calendar, Search, ChevronDown, X } from "lucide-react";
interface FormData {
patientName?: string;
cpf?: string;
rg?: string;
birthDate?: string;
phoneCode?: string;
phoneNumber?: string;
email?: string;
convenio?: string;
matricula?: string;
validade?: string;
documentos?: string;
professionalName?: string;
patientId?: string | null;
doctorId?: string | null;
unit?: string;
appointmentDate?: string;
startTime?: string;
endTime?: string;
requestingProfessional?: string;
appointmentType?: string;
notes?: string;
// API-editable appointment fields
status?: string;
duration_minutes?: number;
chief_complaint?: string;
patient_notes?: string;
insurance_provider?: string;
checked_in_at?: string; // ISO datetime
completed_at?: string; // ISO datetime
cancelled_at?: string; // ISO datetime
cancellation_reason?: string;
}
interface CalendarRegistrationFormProperties {
formData: FormData;
onFormChange: (data: FormData) => void;
createMode?: boolean; // when true, enable fields needed to create a new appointment
}
const formatValidityDate = (value: string) => {
const cleaned = value.replaceAll(/\D/g, "");
if (cleaned.length > 4) {
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}/${cleaned.slice(4, 8)}`;
}
if (cleaned.length > 2) {
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}`;
}
return cleaned;
};
export function CalendarRegistrationForm({ formData, onFormChange, createMode = false }: CalendarRegistrationFormProperties) {
const [isAdditionalInfoOpen, setIsAdditionalInfoOpen] = useState(false);
const [patientDetails, setPatientDetails] = useState<any | null>(null);
const [loadingPatient, setLoadingPatient] = useState(false);
const [doctorOptions, setDoctorOptions] = useState<any[]>([]);
const [filteredDoctorOptions, setFilteredDoctorOptions] = useState<any[] | null>(null);
const [patientOptions, setPatientOptions] = useState<any[]>([]);
const [patientSearch, setPatientSearch] = useState('');
const searchTimerRef = useRef<any>(null);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [loadingPatients, setLoadingPatients] = useState(false);
const [loadingAssignedDoctors, setLoadingAssignedDoctors] = useState(false);
const [loadingPatientsForDoctor, setLoadingPatientsForDoctor] = useState(false);
const [availableSlots, setAvailableSlots] = useState<Array<{ datetime: string; available: boolean; slot_minutes?: number }>>([]);
const [availabilityWindows, setAvailabilityWindows] = useState<Array<{ winStart: Date; winEnd: Date; slotMinutes?: number }>>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false);
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(null);
const [showDatePicker, setShowDatePicker] = useState(false);
const [bookedSlots, setBookedSlots] = useState<Set<string>>(new Set()); // ISO datetimes of already booked appointments
// Helpers to convert between ISO (server) and input[type=datetime-local] value
const isoToDatetimeLocal = (iso?: string | null) => {
if (!iso) return "";
try {
let s = String(iso).trim();
// normalize common variants: space between date/time -> T
s = s.replace(" ", "T");
// If no timezone info (no Z or +/-), try treating as UTC by appending Z
if (!/[zZ]$/.test(s) && !/[+-]\d{2}:?\d{2}$/.test(s)) {
// try parse first; if invalid, append Z
const tryParse = Date.parse(s);
if (isNaN(tryParse)) {
s = s + "Z";
}
}
const d = new Date(s);
if (isNaN(d.getTime())) return "";
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
} catch (e) {
return "";
}
};
const datetimeLocalToIso = (value: string) => {
if (!value) return null;
// value expected: YYYY-MM-DDTHH:MM or with seconds
try {
// If the browser gives a value without seconds, Date constructor will treat as local when we split
const [datePart, timePart] = value.split("T");
if (!datePart || !timePart) return null;
const [y, m, d] = datePart.split("-").map((s) => parseInt(s, 10));
const timeParts = timePart.split(":");
const hh = parseInt(timeParts[0], 10);
const min = parseInt(timeParts[1] || "0", 10);
const sec = parseInt(timeParts[2] || "0", 10);
if ([y, m, d, hh, min, sec].some((n) => Number.isNaN(n))) return null;
const dt = new Date(y, m - 1, d, hh, min, sec, 0);
return dt.toISOString();
} catch (e) {
return null;
}
};
// Automatically fetch patient details when the form receives a patientId
useEffect(() => {
const maybeId = (formData as any).patientId || (formData as any).patient_id || null;
if (!maybeId) {
setPatientDetails(null);
return;
}
let mounted = true;
setLoadingPatient(true);
buscarPacientePorId(maybeId)
.then((p) => {
if (!mounted) return;
setPatientDetails(p);
})
.catch((e) => {
if (!mounted) return;
setPatientDetails({ error: String(e) });
})
.finally(() => {
if (!mounted) return;
setLoadingPatient(false);
});
return () => {
mounted = false;
};
}, [(formData as any).patientId, (formData as any).patient_id]);
// Load doctor suggestions (simple listing) when the component mounts
useEffect(() => {
let mounted = true;
(async () => {
setLoadingDoctors(true);
try {
// listarMedicos returns a paginated list of doctors; request a reasonable limit
const docs = await listarMedicos({ limit: 200 });
if (!mounted) return;
setDoctorOptions(docs || []);
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar médicos', e);
} finally {
if (!mounted) return;
setLoadingDoctors(false);
}
})();
return () => { mounted = false; };
}, []);
// Preload patients so the patient <select> always has options on open
useEffect(() => {
let mounted = true;
(async () => {
setLoadingPatients(true);
try {
// request a reasonable number of patients for the select; adjust limit if needed
const pats = await listarPacientes({ limit: 200 });
if (!mounted) return;
setPatientOptions(pats || []);
} catch (err) {
console.warn('[CalendarRegistrationForm] falha ao carregar pacientes', err);
if (!mounted) return;
setPatientOptions([]);
} finally {
if (!mounted) return;
setLoadingPatients(false);
}
})();
return () => { mounted = false; };
}, []);
// When a patient is selected, filter doctorOptions to only doctors assigned to that patient
useEffect(() => {
const patientId = (formData as any).patientId || (formData as any).patient_id || null;
if (!patientId) {
// clear filter when no patient selected
setFilteredDoctorOptions(null);
setLoadingAssignedDoctors(false);
return;
}
let mounted = true;
setLoadingAssignedDoctors(true);
(async () => {
try {
// listAssignmentsForPatient returns rows with user_id (the auth user assigned)
const assignments = await listAssignmentsForPatient(String(patientId));
if (!mounted) return;
const userIds = Array.from(new Set((assignments || []).map((a: any) => a.user_id).filter(Boolean)));
if (!userIds.length) {
// no assignments -> fallback to full list but keep a non-null marker
setFilteredDoctorOptions([]);
return;
}
// Filter already-loaded doctorOptions by matching user_id
// If doctorOptions isn't loaded yet, we'll wait for it (doctorOptions effect will run first on mount)
const matched = (doctorOptions || []).filter((d) => userIds.includes(String(d.user_id)));
if (mounted) setFilteredDoctorOptions(matched);
} catch (err) {
console.warn('[CalendarRegistrationForm] falha ao carregar médicos atribuídos ao paciente', err);
if (mounted) setFilteredDoctorOptions([]);
} finally {
if (mounted) setLoadingAssignedDoctors(false);
}
})();
return () => { mounted = false; };
}, [(formData as any).patientId, (formData as any).patient_id, doctorOptions]);
// When doctorId changes, load patients assigned to that doctor
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
if (!docId) {
setPatientOptions([]);
return;
}
let mounted = true;
setLoadingPatientsForDoctor(true);
(async () => {
try {
const pats = await buscarPacientesPorMedico(String(docId));
if (!mounted) return;
setPatientOptions(pats || []);
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar pacientes por médico', e);
if (!mounted) return;
setPatientOptions([]);
} finally {
if (!mounted) return;
setLoadingPatientsForDoctor(false);
}
})();
return () => { mounted = false; };
}, [(formData as any).doctorId, (formData as any).doctor_id]);
// When doctor or date changes, fetch available slots
// Keep a mounted ref to avoid setting state after unmount when reused
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
// Extract the availability + exceptions logic into a reusable function so we
// can call it both when dependencies change and once at mount-time when the
// form already contains doctor/date (covering the edit flow).
const fetchExceptionsAndSlots = async (docIdArg?: string | null, dateArg?: string | null) => {
const docId = docIdArg ?? ((formData as any).doctorId || (formData as any).doctor_id || null);
const date = dateArg ?? ((formData as any).appointmentDate || null);
if (!docId || !date) {
if (mountedRef.current) setAvailableSlots([]);
return;
}
if (mountedRef.current) setLoadingSlots(true);
try {
// Skip exception checking - all dates are available for admin now
// NOTE: Exception checking disabled per user request
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
// Build local start/end for the day
let start: Date;
let end: Date;
try {
const parts = String(date).split('-').map((p) => Number(p));
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
const [y, m, d] = parts;
start = new Date(y, m - 1, d, 0, 0, 0, 0);
end = new Date(y, m - 1, d, 23, 59, 59, 999);
} else {
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
} catch (err) {
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' });
if (!mountedRef.current) return;
console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av);
// Try to restrict the returned slots to the doctor's public availability windows
try {
const disponibilidades = await listarDisponibilidades({ doctorId: String(docId) }).catch(() => []);
const weekdayNumber = start.getDay();
const weekdayNames: Record<number, string[]> = {
0: ['0', 'sun', 'sunday', 'domingo'],
1: ['1', 'mon', 'monday', 'segunda', 'segunda-feira'],
2: ['2', 'tue', 'tuesday', 'terca', 'terça', 'terça-feira'],
3: ['3', 'wed', 'wednesday', 'quarta', 'quarta-feira'],
4: ['4', 'thu', 'thursday', 'quinta', 'quinta-feira'],
5: ['5', 'fri', 'friday', 'sexta', 'sexta-feira'],
6: ['6', 'sat', 'saturday', 'sabado', 'sábado']
};
const allowed = (weekdayNames[weekdayNumber] || []).map((s) => String(s).toLowerCase());
const matched = (disponibilidades || []).filter((d: any) => {
try {
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase();
if (!raw) return false;
if (allowed.includes(raw)) return true;
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true;
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true;
return false;
} catch (e) { return false; }
});
if (matched && matched.length) {
const windows = matched.map((d: any) => {
const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 };
const parts = String(t).split(':').map((p) => Number(p));
return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 };
};
const s = parseTime(d.start_time);
const e2 = parseTime(d.end_time);
const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0);
const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999);
const slotMinutes = (() => { const n = Number(d.slot_minutes ?? d.slot_minutes_minutes ?? NaN); return Number.isFinite(n) ? n : undefined; })();
return { winStart, winEnd, slotMinutes };
});
try {
// persist windows so UI can apply duration-fit filtering
if (mountedRef.current) setAvailabilityWindows(windows);
const candidate = windows.find((w: any) => w.slotMinutes && Number.isFinite(Number(w.slotMinutes)));
if (candidate) {
const durationVal = Number(candidate.slotMinutes);
if ((formData as any).duration_minutes !== durationVal) {
onFormChange({ ...formData, duration_minutes: durationVal });
}
try { setLockedDurationFromSlot(true); } catch (e) {}
} else {
try { setLockedDurationFromSlot(false); } catch (e) {}
}
} catch (e) {
console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e);
}
const existingInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
const slotMinutes = sd.getHours() * 60 + sd.getMinutes();
return windows.some((w: any) => {
const ws = w.winStart;
const we = w.winEnd;
const winStartMinutes = ws.getHours() * 60 + ws.getMinutes();
const winEndMinutes = we.getHours() * 60 + we.getMinutes();
return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes;
});
} catch (e) { return false; }
});
let stepMinutes = 30;
try {
const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
const diffs: number[] = [];
for (let i = 1; i < times.length; i++) {
const d = Math.round((times[i] - times[i - 1]) / 60000);
if (d > 0) diffs.push(d);
}
if (diffs.length) stepMinutes = Math.min(...diffs);
} catch (e) {}
const generatedSet = new Set<string>();
// Helper to create ISO-like string without timezone conversion
const toLocalISOString = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
};
windows.forEach((w: any) => {
try {
const perWindowStep = Number(w.slotMinutes) || stepMinutes;
const startMs = w.winStart.getTime();
const endMs = w.winEnd.getTime();
const lastStartMs = endMs - perWindowStep * 60000;
// Always generate slots from the start of the window to the end
// This ensures slots start at the configured availability start time
let cursorMs = startMs;
while (cursorMs <= lastStartMs) {
generatedSet.add(toLocalISOString(new Date(cursorMs)));
cursorMs += perWindowStep * 60000;
}
} catch (e) {}
});
const mergedMap = new Map<string, { datetime: string; available: boolean; slot_minutes?: number }>();
const findWindowSlotMinutes = (isoDt: string) => {
try {
const sd = new Date(isoDt);
const sm = sd.getHours() * 60 + sd.getMinutes();
const w = windows.find((win: any) => {
const ws = win.winStart;
const we = win.winEnd;
const winStartMinutes = ws.getHours() * 60 + ws.getMinutes();
const winEndMinutes = we.getHours() * 60 + we.getMinutes();
return sm >= winStartMinutes && sm <= winEndMinutes;
});
return w && w.slotMinutes ? Number(w.slotMinutes) : null;
} catch (e) { return null; }
};
// Use only generated slots based on availability windows
Array.from(generatedSet).forEach((dt) => {
const sm = findWindowSlotMinutes(dt) || stepMinutes;
mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm });
});
const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
if (mountedRef.current) setAvailableSlots(merged || []);
} else {
if (mountedRef.current) {
setAvailabilityWindows([]);
setAvailableSlots(av.slots || []);
}
}
} catch (e) {
console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e);
if (mountedRef.current) setAvailableSlots(av.slots || []);
}
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e);
if (mountedRef.current) setAvailableSlots([]);
} finally {
if (mountedRef.current) setLoadingSlots(false);
}
};
// Call when doctor/date/appointmentType change
useEffect(() => {
fetchExceptionsAndSlots();
// note: we intentionally keep the same dependency list to preserve existing behaviour
}, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate, (formData as any).appointmentType]);
// Also attempt a mount-time call to cover the case where the form is mounted
// with doctor/date already present (edit flow). This ensures parity with
// the create flow which triggers the requests during user interaction.
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
const date = (formData as any).appointmentDate || null;
console.debug('[CalendarRegistrationForm] mount-time check formData doctor/date', { doctorId: docId, doctor_id: (formData as any).doctor_id, appointmentDate: date, sampleFormData: formData });
const profName = (formData as any).professionalName || (formData as any).professional || '';
// If we don't have an id but have a professional name, try to find the id from loaded options
if (!docId && profName && doctorOptions && doctorOptions.length) {
const found = doctorOptions.find((d: any) => {
const name = (d.full_name || d.name || '').toLowerCase();
return name && profName.toLowerCase() && (name === profName.toLowerCase() || name.includes(profName.toLowerCase()));
});
if (found) {
// set doctorId on form so the normal effect will run
try { onFormChange({ ...formData, doctorId: String(found.id) }); } catch (e) {}
// Also proactively fetch availability using the discovered id
if (date) {
Promise.resolve().then(() => { if (mountedRef.current) fetchExceptionsAndSlots(String(found.id), date); });
} else {
Promise.resolve().then(() => { if (mountedRef.current) fetchExceptionsAndSlots(String(found.id)); });
}
}
}
if ((docId || ((doctorOptions || []).find((d: any) => (d.full_name || d.name || '').toLowerCase().includes(((formData as any).professionalName || '').toLowerCase())))) && date) {
// schedule microtask so mount effects ordering won't conflict with parent
Promise.resolve().then(() => {
if (mountedRef.current) fetchExceptionsAndSlots();
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load already booked appointments for the selected doctor and date to prevent double-booking
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
const date = (formData as any).appointmentDate || null;
if (!docId || !date) {
setBookedSlots(new Set());
return;
}
let mounted = true;
(async () => {
try {
// Query appointments for this doctor on the selected date
// Format: YYYY-MM-DD
const [y, m, d] = String(date).split('-').map(n => Number(n));
const dateStart = new Date(y, m - 1, d, 0, 0, 0, 0).toISOString();
const dateEnd = new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
const query = `doctor_id=eq.${docId}&scheduled_at=gte.${dateStart}&scheduled_at=lte.${dateEnd}&select=scheduled_at`;
const appointments = await listarAgendamentos(query).catch(() => []);
if (!mounted) return;
// Extract booked datetime slots - store as HH:MM format for easier comparison
const booked = new Set<string>();
(appointments || []).forEach((appt: any) => {
if (appt && appt.scheduled_at) {
try {
const dt = new Date(appt.scheduled_at);
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
const timeKey = `${hh}:${mm}`;
booked.add(timeKey);
console.debug('[CalendarRegistrationForm] booked time:', timeKey, 'from', appt.scheduled_at);
} catch (e) {
console.warn('[CalendarRegistrationForm] erro parsing scheduled_at', appt.scheduled_at, e);
}
}
});
if (mounted) {
setBookedSlots(booked);
console.debug('[CalendarRegistrationForm] total booked slots:', booked.size, 'slots:', Array.from(booked));
}
} catch (e) {
console.warn('[CalendarRegistrationForm] erro ao carregar agendamentos existentes', e);
if (mounted) setBookedSlots(new Set());
}
})();
return () => { mounted = false; };
}, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate]);
// Filter available slots: if date is today, only show future times, AND remove already booked slots
const filteredAvailableSlots = (() => {
try {
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const selectedDateStr = (formData as any).appointmentDate || null;
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentTimeInMinutes = currentHours * 60 + currentMinutes;
let filtered = availableSlots || [];
if (selectedDateStr === todayStr) {
// Today: filter out past times (add 30-minute buffer for admin to schedule)
filtered = (availableSlots || []).filter((s) => {
try {
const slotDate = new Date(s.datetime);
const slotHours = slotDate.getHours();
const slotMinutes = slotDate.getMinutes();
const slotTimeInMinutes = slotHours * 60 + slotMinutes;
// Keep slots that are at least 30 minutes in the future
return slotTimeInMinutes >= currentTimeInMinutes + 30;
} catch (e) {
return true;
}
});
} else if (selectedDateStr && selectedDateStr > todayStr) {
// Future date: show all slots
filtered = availableSlots || [];
} else {
// Past date: no slots
return [];
}
// Remove already booked slots - compare by HH:MM format
return filtered.filter((s) => {
try {
const dt = new Date(s.datetime);
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
const timeKey = `${hh}:${mm}`;
const isBooked = bookedSlots.has(timeKey);
if (isBooked) {
console.debug('[CalendarRegistrationForm] filtering out booked slot:', timeKey);
}
return !isBooked;
} catch (e) {
console.warn('[CalendarRegistrationForm] erro filtering booked slot', e);
return true;
}
});
} catch (e) {
return availableSlots || [];
}
})();
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target;
// map datetime-local fields to ISO before sending up
if (name === 'checked_in_at' || name === 'completed_at' || name === 'cancelled_at') {
const iso = datetimeLocalToIso(value as string);
onFormChange({ ...formData, [name]: iso });
return;
}
if (name === 'validade') {
const formattedValue = formatValidityDate(value);
onFormChange({ ...formData, [name]: formattedValue });
return;
}
// if selecting a patientId from the select, also populate patientName
if (name === 'patientId') {
// event.target is a select; get the selected option text
const sel = event.target as HTMLSelectElement;
const selectedText = sel.options[sel.selectedIndex]?.text ?? '';
onFormChange({ ...formData, patientId: value || null, patientName: selectedText });
return;
}
// ensure duration is stored as a number
if (name === 'duration_minutes') {
const n = Number(value);
onFormChange({ ...formData, duration_minutes: Number.isNaN(n) ? undefined : n });
return;
}
// If user edits endTime manually, accept the value and clear lastAutoEndRef so auto-calc won't overwrite
if (name === 'endTime') {
// store as-is (HH:MM)
try {
// clear auto flag so user edits persist
(lastAutoEndRef as any).current = null;
} catch (e) {}
onFormChange({ ...formData, endTime: value });
return;
}
onFormChange({ ...formData, [name]: value });
};
// Auto-calculate endTime from startTime + duration_minutes
const lastAutoEndRef = useRef<string | null>(null);
useEffect(() => {
const start = (formData as any).startTime;
const dur = (formData as any).duration_minutes;
const date = (formData as any).appointmentDate; // YYYY-MM-DD
if (!start) return;
// if duration is not a finite number, don't compute
const minutes = typeof dur === 'number' && Number.isFinite(dur) ? dur : 0;
// build a Date from appointmentDate + startTime; fall back to today if appointmentDate missing
const datePart = date || new Date().toISOString().slice(0, 10);
const [y, m, d] = String(datePart).split('-').map((s) => parseInt(s, 10));
const [hh, mm] = String(start).split(':').map((s) => parseInt(s, 10));
if ([y, m, d, hh, mm].some((n) => Number.isNaN(n))) return;
const dt = new Date(y, m - 1, d, hh, mm, 0, 0);
const dt2 = new Date(dt.getTime() + minutes * 60000);
const newEnd = `${String(dt2.getHours()).padStart(2, '0')}:${String(dt2.getMinutes()).padStart(2, '0')}`;
const currentEnd = (formData as any).endTime;
// Only overwrite if endTime is empty or it was the previously auto-calculated value
if (!currentEnd || currentEnd === lastAutoEndRef.current) {
lastAutoEndRef.current = newEnd;
onFormChange({ ...formData, endTime: newEnd });
}
}, [(formData as any).startTime, (formData as any).duration_minutes, (formData as any).appointmentDate]);
// derive the doctor options to show based on selected patient (inverse lookup)
const patientSelected = (formData as any).patientId || (formData as any).patient_id;
let effectiveDoctorOptions = doctorOptions || [];
if (patientSelected) {
if (filteredDoctorOptions && filteredDoctorOptions.length) effectiveDoctorOptions = filteredDoctorOptions;
else effectiveDoctorOptions = doctorOptions || [];
}
// derive displayed slots by filtering availableSlots to those that can fit the
// desired duration within any availability window. If no windows are present,
// fall back to availableSlots as-is.
const displayedSlots = (() => {
try {
const duration = Number((formData as any).duration_minutes) || 0;
if (!availabilityWindows || !availabilityWindows.length) return availableSlots || [];
// For each slot, check whether slot start + duration <= window.winEnd
return (availableSlots || []).filter((s) => {
try {
const sd = new Date(s.datetime);
const slotStartMs = sd.getTime();
const slotEndMs = slotStartMs + (duration || (s.slot_minutes || 0)) * 60000;
// find a window that contains the entire appointment (start..end)
return availabilityWindows.some((w) => {
return slotStartMs >= w.winStart.getTime() && slotEndMs <= w.winEnd.getTime();
});
} catch (e) { return false; }
});
} catch (e) {
return availableSlots || [];
}
})();
// Ensure the currently-selected startTime (from formData) is present in the list
// so that editing an existing appointment can keep its original time even if
// the server availability doesn't return it (historic booking).
try {
const date = (formData as any).appointmentDate;
const start = (formData as any).startTime;
if (date && start) {
const [y, m, d] = String(date).split('-').map((n) => Number(n));
const [hh, mm] = String(start).split(':').map((n) => Number(n));
if (![y, m, d, hh, mm].some((n) => Number.isNaN(n))) {
const iso = new Date(y, m - 1, d, hh, mm, 0, 0).toISOString();
const present = (displayedSlots || []).some((s) => s.datetime === iso);
if (!present) {
// find in availableSlots if exists to copy slot_minutes, else synthesize
const found = (availableSlots || []).find((s) => {
try { return new Date(s.datetime).toISOString() === iso; } catch (e) { return false; }
});
const toAdd = found ? { ...found } : { datetime: iso, available: false, slot_minutes: (formData as any).duration_minutes || undefined };
// prepend so current appointment time appears first
displayedSlots.unshift(toAdd as any);
}
}
}
} catch (e) {}
// ref to the appointment date input
const appointmentDateRef = useRef<HTMLInputElement | null>(null);
return (
<form className="space-y-8">
{/* Exception dialog shown when a blocking exception exists for selected date */}
<AlertDialog open={exceptionDialogOpen} onOpenChange={(open) => { if (!open) { setExceptionDialogOpen(false); setExceptionDialogMessage(null); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data indisponível</AlertDialogTitle>
<AlertDialogDescription>
{exceptionDialogMessage ?? 'Não será possível agendar uma consulta nesta data/horário.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Fechar</AlertDialogCancel>
<AlertDialogAction onClick={() => { setExceptionDialogOpen(false); setExceptionDialogMessage(null); }}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="border border-border rounded-md p-6 space-y-4 bg-card">
<h2 className="font-medium text-foreground">Informações do paciente</h2>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-6 space-y-2">
<Label className="text-[13px]">Nome</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
{createMode ? (
<div className="flex items-center gap-2">
<Select
value={(formData as any).patientId || (formData as any).patient_id || ''}
onValueChange={(value) => {
const val = value || null;
const selected = (patientOptions || []).find((p) => p.id === val) || null;
onFormChange({ ...formData, patientId: val, patientName: selected ? (selected.full_name || selected.id) : '' });
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-8 text-[13px]">
<SelectValue placeholder="Selecione um paciente" />
</SelectTrigger>
<SelectContent>
{loadingPatients || loadingPatientsForDoctor ? (
<SelectItem value="__loading_patients__" disabled>Carregando pacientes...</SelectItem>
) : (
(patientOptions || []).map((p) => (
<SelectItem key={p.id} value={p.id}>{(p.full_name || p.nome || p.id) + (p.cpf ? ` - CPF: ${p.cpf}` : '')}</SelectItem>
))
)}
</SelectContent>
</Select>
{((formData as any).patientId || (formData as any).patient_id) && (
<button
type="button"
title="Limpar seleção"
className="h-10 w-10 flex items-center justify-center rounded-md bg-muted/10 text-foreground/90"
onClick={async () => {
try {
// clear patient selection and also clear doctor/date/time and slots
setFilteredDoctorOptions(null);
setAvailableSlots([]);
const newData: any = { ...formData };
newData.patientId = null;
newData.patientName = '';
newData.doctorId = null;
newData.professionalName = '';
newData.appointmentDate = null;
newData.startTime = '';
newData.endTime = '';
// update form first so dependent effects (doctor->patients) run
onFormChange(newData);
// then repopulate the patientOptions (fetch may be async)
const pats = await listarPacientes({ limit: 200 }).catch(() => []);
setPatientOptions(pats || []);
} catch (e) {}
}}
>
<X size={16} />
</button>
)}
</div>
) : (
<Input
name="patientName"
placeholder="Nome do paciente"
className="h-11 pl-8 rounded-md transition-colors bg-muted/10"
value={formData.patientName || ''}
disabled
/>
)}
</div>
</div>
<div className="md:col-span-6 flex items-start justify-end">
<div className="text-right text-sm">
{loadingPatient ? (
<div className="text-muted-foreground">Carregando dados do paciente...</div>
) : patientDetails ? (
patientDetails.error ? (
<div className="text-red-500">Erro ao carregar paciente: {String(patientDetails.error)}</div>
) : (
<div className="grid grid-cols-1 gap-2 bg-muted/30 p-4 rounded-lg border border-border">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">CPF:</span>
<span className="text-sm font-medium text-foreground">{patientDetails.cpf || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Telefone:</span>
<span className="text-sm font-medium text-foreground">{patientDetails.phone_mobile || patientDetails.telefone || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">E-mail:</span>
<span className="text-sm font-medium text-foreground">{patientDetails.email || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Data de nascimento:</span>
<span className="text-sm font-medium text-foreground">
{patientDetails.birth_date
? new Date(patientDetails.birth_date + 'T00:00:00').toLocaleDateString('pt-BR')
: '-'
}
</span>
</div>
</div>
)
) : (
<div className="text-xs text-muted-foreground">Paciente não vinculado</div>
)}
<div className="mt-2 text-xs text-muted-foreground italic">Para editar os dados do paciente, acesse a ficha do paciente.</div>
</div>
</div>
</div>
</div>
<div className="border border-border rounded-md p-6 space-y-4 bg-card">
<h2 className="font-medium text-foreground">Informações do atendimento</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-[13px]">Nome do profissional *</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
{createMode ? (
<div className="flex items-center gap-2">
<Select
value={(formData as any).doctorId || (formData as any).doctor_id || ''}
onValueChange={(value) => {
// synthesize a change event compatible with existing handler
const fake = { target: { name: 'doctorId', value } } as unknown as React.ChangeEvent<HTMLSelectElement>;
handleChange(fake);
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-8 text-[13px]">
<SelectValue placeholder="Selecione um médico" />
</SelectTrigger>
<SelectContent>
{loadingAssignedDoctors ? (
<SelectItem value="__loading_doctors__" disabled>Carregando médicos atribuídos...</SelectItem>
) : (
(effectiveDoctorOptions || []).map((d) => (
<SelectItem key={d.id} value={d.id}>{d.full_name || d.name || d.id}</SelectItem>
))
)}
</SelectContent>
</Select>
{((formData as any).doctorId || (formData as any).doctor_id) && (
<button
type="button"
title="Limpar seleção"
className="h-10 w-10 flex items-center justify-center rounded-md bg-muted/10 text-foreground/90"
onClick={async () => {
try {
// clear doctor selection and also clear patient/date/time and slots
setAvailableSlots([]);
setFilteredDoctorOptions(null);
const newData2: any = { ...formData };
newData2.doctorId = null;
newData2.professionalName = '';
newData2.patientId = null;
newData2.patientName = '';
newData2.appointmentDate = null;
newData2.startTime = '';
newData2.endTime = '';
// update form first so effects that clear options don't erase our repopulation
onFormChange(newData2);
// then repopulate patients list
const pats = await listarPacientes({ limit: 200 }).catch(() => []);
setPatientOptions(pats || []);
} catch (e) {}
}}
>
<X size={16} />
</button>
)}
</div>
) : (
<Input name="professionalName" className="h-11 w-full rounded-md pl-8 pr-12 text-[13px] transition-colors hover:bg-muted/30" value={formData.professionalName || ''} disabled />
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-[13px]">Data *</Label>
<button
type="button"
aria-label="Abrir seletor de data"
onClick={() => setShowDatePicker(!showDatePicker)}
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer"
>
<Calendar className="h-4 w-4" />
</button>
</div>
<div className="relative">
<Input
ref={appointmentDateRef as any}
name="appointmentDate"
type="text"
placeholder="DD/MM/AAAA"
className="h-11 w-full rounded-md pl-3 pr-3 text-[13px] transition-colors hover:bg-muted/30"
value={formData.appointmentDate ? (() => {
try {
const [y, m, d] = String(formData.appointmentDate).split('-');
return `${d}/${m}/${y}`;
} catch (e) {
return '';
}
})() : ''}
readOnly
/>
{showDatePicker && (
<div className="absolute top-full left-0 mt-1 z-50 bg-card border border-border rounded-md shadow-lg p-3">
<CalendarComponent
mode="single"
selected={formData.appointmentDate ? (() => {
try {
const [y, m, d] = String(formData.appointmentDate).split('-').map(Number);
return new Date(y, m - 1, d);
} catch (e) {
return undefined;
}
})() : undefined}
onSelect={(date) => {
if (date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const dateStr = `${y}-${m}-${d}`;
onFormChange({ ...formData, appointmentDate: dateStr });
setShowDatePicker(false);
}
}}
disabled={(date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
}}
/>
</div>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label className="text-[13px]">Início *</Label>
{createMode ? (
<Select
value={((): string => {
// try to find a matching slot ISO for the current formData appointmentDate + startTime
try {
const date = (formData as any).appointmentDate || '';
const time = (formData as any).startTime || '';
if (!date || !time) return '';
const match = (availableSlots || []).find((s) => {
try {
const d = new Date(s.datetime);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
// Use local date components instead of toISOString to avoid timezone conversion
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const dateOnly = `${year}-${month}-${day}`;
return dateOnly === date && `${hh}:${mm}` === time;
} catch (e) {
return false;
}
});
return match ? match.datetime : '';
} catch (e) { return ''; }
})()
}
onValueChange={(value) => {
// value is the slot ISO datetime
try {
const dt = new Date(value);
if (isNaN(dt.getTime())) {
// clear
onFormChange({ ...formData, appointmentDate: (formData as any).appointmentDate || null, startTime: '' });
return;
}
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
// Keep the existing appointmentDate, don't override it
const currentDate = (formData as any).appointmentDate;
// set duration from slot if available
const sel = (availableSlots || []).find((s) => s.datetime === value) as any;
const slotMinutes = sel && sel.slot_minutes ? Number(sel.slot_minutes) : null;
// compute endTime from duration (slotMinutes or existing duration)
const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
const endH = String(endDt.getHours()).padStart(2, '0');
const endM = String(endDt.getMinutes()).padStart(2, '0');
const endStr = `${endH}:${endM}`;
if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes, endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else {
onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
}
} catch (e) {
// noop
}
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-3 text-[13px]">
<SelectValue placeholder="--:--" />
</SelectTrigger>
<SelectContent>
{loadingSlots ? (
<SelectItem value="__loading_slots__" disabled>Carregando horários...</SelectItem>
) : (availableSlots && availableSlots.length ? (
(availableSlots || []).map((s) => {
const d = new Date(s.datetime);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const label = `${hh}:${mm}`;
return <SelectItem key={s.datetime} value={s.datetime}>{label}</SelectItem>;
})
) : (
<SelectItem value="__no_slots__" disabled>Nenhum horário disponível</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input name="startTime" type="time" className="h-11 w-full rounded-md px-3 text-[13px] transition-colors hover:bg-muted/30" value={formData.startTime || ''} onChange={handleChange} />
)}
</div>
<div className="space-y-2">
<Label className="text-[13px]">Término *</Label>
{/*
When creating a new appointment from a predefined slot, the end time
is derived from the slot's start + duration and therefore cannot be
edited. We disable/readOnly the input in create mode when either a
slot is selected (startTime corresponds to an availableSlots entry)
or the duration was locked from a slot (lockedDurationFromSlot).
*/}
<Input
name="endTime"
type="time"
className="h-11 w-full rounded-md px-3 text-[13px] transition-colors hover:bg-muted/30"
value={formData.endTime || ''}
onChange={handleChange}
readOnly={createMode && (lockedDurationFromSlot || Boolean(((): boolean => {
try {
const date = (formData as any).appointmentDate || '';
const time = (formData as any).startTime || '';
if (!date || !time) return false;
// Check if startTime matches one of the availableSlots (meaning slot-driven)
return (availableSlots || []).some((s) => {
try {
const d = new Date(s.datetime);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const dateOnly = d.toISOString().split('T')[0];
return dateOnly === date && `${hh}:${mm}` === time;
} catch (e) { return false; }
});
} catch (e) { return false; }
})()))}
disabled={createMode && (lockedDurationFromSlot || Boolean(((): boolean => {
try {
const date = (formData as any).appointmentDate || '';
const time = (formData as any).startTime || '';
if (!date || !time) return false;
return (availableSlots || []).some((s) => {
try {
const d = new Date(s.datetime);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const dateOnly = d.toISOString().split('T')[0];
return dateOnly === date && `${hh}:${mm}` === time;
} catch (e) { return false; }
});
} catch (e) { return false; }
})()))}
/>
</div>
{/* Profissional solicitante removed per user request */}
</div>
{/* Available slots area (createMode only) */}
{createMode && (
<div className="mt-3">
<Label className="text-[13px]">Horários disponíveis</Label>
<div className="mt-2 grid grid-cols-3 gap-2">
{loadingSlots ? (
<div className="col-span-3">Carregando horários...</div>
) : filteredAvailableSlots && filteredAvailableSlots.length ? (
filteredAvailableSlots.map((s) => {
const dt = new Date(s.datetime);
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
const label = `${hh}:${mm}`;
return (
<button
key={s.datetime}
type="button"
className={`h-10 rounded-md border ${formData.startTime === `${hh}:${mm}` ? 'bg-blue-600 text-white' : 'bg-background'}`}
onClick={() => {
// when selecting a slot, keep the existing appointmentDate and only update time
const currentDate = (formData as any).appointmentDate;
const slotMinutes = s.slot_minutes || null;
// compute endTime based on duration
const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
const endH = String(endDt.getHours()).padStart(2, '0');
const endM = String(endDt.getMinutes()).padStart(2, '0');
const endStr = `${endH}:${endM}`;
if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes), endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else {
onFormChange({ ...formData, appointmentDate: currentDate, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
}
}}
>
{label}
</button>
);
})
) : (
<div className="col-span-3 text-sm text-muted-foreground">Nenhum horário disponível para o médico nesta data.</div>
)}
</div>
</div>
)}
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[13px]">Tipo de atendimento *</Label>
</div>
<div className="relative mt-1">
{/* Non-searchable select with allowed values only */}
<Select
value={(formData as any).appointmentType || (formData as any).appointment_type || ''}
onValueChange={(value) => {
try {
const newData: any = { ...formData, appointmentType: value };
newData.appointment_type = value;
onFormChange(newData);
} catch (e) {}
}}
>
<SelectTrigger className="h-11 w-full rounded-md pl-3 text-[13px]">
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial</SelectItem>
<SelectItem value="telemedicina">Telemedicina</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-[13px]">Status</Label>
<select name="status" className="h-11 w-full rounded-md border border-gray-300 dark:border-input bg-background text-foreground pr-3 text-[13px]" value="confirmed" onChange={handleChange} disabled>
<option value="confirmed">Confirmado</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-[13px]">Duração (min)</Label>
<Input name="duration_minutes" type="number" min={1} className="h-11 w-full rounded-md" value={formData.duration_minutes ?? ''} onChange={handleChange} readOnly={lockedDurationFromSlot} disabled={lockedDurationFromSlot} />
</div>
<div className="space-y-1">
<Label className="text-[13px]">Convênio</Label>
<Input name="insurance_provider" placeholder="Operadora" className="h-11 w-full rounded-md" value={formData.insurance_provider || ''} onChange={handleChange} />
</div>
</div>
</div>
<div className="space-y-2 mt-4">
<div className="flex items-center justify-between">
<Label className="text-[13px]">Observações</Label>
</div>
<Textarea name="notes" rows={4} className="text-[13px] min-h-[80px] resize-none rounded-md transition-colors hover:bg-muted/30" value={formData.notes || ''} onChange={handleChange} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="space-y-1">
<Label className="text-[13px]">Queixa principal</Label>
<Textarea name="chief_complaint" rows={3} className="text-[13px] rounded-md" value={formData.chief_complaint || ''} onChange={handleChange} />
</div>
<div className="space-y-1">
<Label className="text-[13px]">Notas do paciente</Label>
<Textarea name="patient_notes" rows={3} className="text-[13px] rounded-md" value={formData.patient_notes || ''} onChange={handleChange} />
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="space-y-1">
<Label className="text-[13px]">Horário de check-in</Label>
<Input name="checked_in_at" type="datetime-local" className="h-11 w-full rounded-md" value={isoToDatetimeLocal(formData.checked_in_at as any)} onChange={handleChange} />
</div>
<div className="space-y-1">
<Label className="text-[13px]">Concluído em</Label>
<Input name="completed_at" type="datetime-local" className="h-11 w-full rounded-md" value={isoToDatetimeLocal(formData.completed_at as any)} onChange={handleChange} />
</div>
<div className="space-y-1">
<Label className="text-[13px]">Cancelado em</Label>
<Input name="cancelled_at" type="datetime-local" className="h-11 w-full rounded-md" value={isoToDatetimeLocal(formData.cancelled_at as any)} onChange={handleChange} />
</div>
</div>
<div className="mt-4 space-y-1">
<Label className="text-[13px]">Motivo do cancelamento</Label>
<Input name="cancellation_reason" className="h-11 w-full rounded-md" value={formData.cancellation_reason || ''} onChange={handleChange} />
</div>
</div>
</div>
</div>
</div>
</form>
);
}