Skip to main content

OdontoX Mobile — Plan B: Features (Booking + Viewer + Charts + Per-role)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Complete all missing features: appointment booking (all roles), PDF/image viewer, records drill-down, real chart data, past appointments display, per-role appointment status actions, and the receptionist check-in queue. Architecture: SlotGrid calls the existing /available-slots rule-engine endpoint. BookingSheet (patient) and StaffBookingSheet (doctor/receptionist/admin) are @gorhom/bottom-sheet modals with react-hook-form + zod. PdfViewer downloads via expo-file-system with auth headers then renders in WebView (iOS native) or expo-sharing (Android). All four role dashboards use real weekly data from their respective stats endpoints. Prerequisite: Plan A must be completed first (GestureHandlerRootView, Toast, all packages installed). Tech Stack: @gorhom/bottom-sheet v5, react-hook-form, zod, @hookform/resolvers/zod, react-native-webview, react-native-image-viewing, expo-file-system, expo-sharing, react-native-calendars, react-native-toast-message.

File Map

FileActionResponsibility
odontox-app/components/ui/SlotGrid.tsxCreateAvailable-slot grid from rule engine
odontox-app/components/booking/BookingSheet.tsxCreatePatient booking bottom sheet (date → slots → confirm)
odontox-app/components/booking/StaffBookingSheet.tsxCreateStaff booking bottom sheet (patient search + date → slots → confirm)
odontox-app/hooks/useRecentPatients.tsCreateFrequency-ranked + recent patient list with live search
odontox-app/components/viewer/PdfViewer.tsxCreateDownload + WebView PDF renderer (iOS) / Sharing (Android)
odontox-app/components/viewer/ImageViewer.tsxCreateLightbox image viewer wrapping react-native-image-viewing
odontox-app/app/(patient)/appointments/index.tsxModifyShow past appointments section
odontox-app/app/(patient)/appointments/[id].tsxModifyCancel appointment button with rule engine error handling
odontox-app/app/(patient)/index.tsxModifyBook appointment CTA
odontox-app/app/(patient)/records/index.tsxModifyTappable rows route to detail screens
odontox-app/app/(patient)/records/prescription/[id].tsxCreatePrescription detail screen
odontox-app/app/(patient)/records/note/[id].tsxCreateClinical note detail screen
odontox-app/app/(patient)/records/file/[id].tsxCreateFile viewer screen (PdfViewer or ImageViewer)
odontox-app/app/(patient)/bills/index.tsxModifyReplace share action with PdfViewer
odontox-app/app/(doctor)/index.tsxModifyReal weekly chart data from weeklyStats
odontox-app/app/(admin)/index.tsxModifyReal weekly chart data from appointmentData
odontox-app/app/(doctor)/appointments/[id].tsxModifyStatus action buttons (confirm / start / complete / no-show)
odontox-app/app/(receptionist)/appointments/[id].tsxModifyStatus action buttons (confirm / check-in / no-show)
odontox-app/app/(receptionist)/index.tsxModifyToday’s queue sorted by time with check-in action
odontox-app/app/(doctor)/appointments/index.tsxModifyAdd “New Appointment” FAB
odontox-app/app/(receptionist)/appointments/index.tsxModifyAdd “New Appointment” FAB

Task 1: SlotGrid Component

Files:
  • Create: odontox-app/components/ui/SlotGrid.tsx
Calls GET /protected/appointments/available-slots?date=YYYY-MM-DD — this endpoint exists and is the same one the web SlotPicker uses.
  • Step 1: Create the component
// odontox-app/components/ui/SlotGrid.tsx
import { useEffect, useState } from 'react';
import {
  View, Text, Pressable, StyleSheet, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { papi } from '@/lib/api';

interface SlotsResponse {
  slots: string[];
  clinicClosed: boolean;
  doctorOff: boolean;
  clinicPhone: string | null;
}

interface SlotGridProps {
  date: string;          // 'YYYY-MM-DD', empty string = no date yet
  doctorId?: string;
  selected: string;      // currently selected slot 'HH:MM' or ''
  onSelect: (slot: string) => void;
}

export function SlotGrid({ date, doctorId, selected, onSelect }: SlotGridProps) {
  const [data, setData] = useState<SlotsResponse | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!date) { setData(null); return; }
    setLoading(true);
    const params = new URLSearchParams({ date });
    if (doctorId) params.set('doctorId', doctorId);
    papi.get<SlotsResponse>(`/appointments/available-slots?${params}`)
      .then(res => setData(res))
      .catch(() => setData(null))
      .finally(() => setLoading(false));
  }, [date, doctorId]);

  if (!date) {
    return <Text style={s.hint}>Select a date to see available times.</Text>;
  }

  if (loading) {
    return (
      <View style={s.loadingRow}>
        <ActivityIndicator color={Colors.brand} size="small" />
        <Text style={s.hint}>Loading slots</Text>
      </View>
    );
  }

  if (!data) return null;

  if (data.clinicClosed || data.doctorOff || data.slots.length === 0) {
    const reason = data.clinicClosed
      ? 'Clinic is closed on this day.'
      : data.doctorOff
      ? 'Doctor is unavailable on this day.'
      : 'No available slots for this day.';
    return (
      <View style={s.emptyBox}>
        <Ionicons name="calendar-outline" size={20} color={Colors.textMuted} />
        <Text style={s.emptyText}>{reason}</Text>
        {data.clinicPhone ? (
          <Text style={s.phone}>{data.clinicPhone}</Text>
        ) : null}
      </View>
    );
  }

  return (
    <View style={s.grid}>
      {data.slots.map(slot => (
        <Pressable
          key={slot}
          onPress={() => onSelect(slot)}
          style={({ pressed }) => [
            s.slot,
            selected === slot && s.slotSelected,
            pressed && { opacity: 0.7 },
          ]}
        >
          <Text style={[s.slotText, selected === slot && s.slotTextSelected]}>{slot}</Text>
        </Pressable>
      ))}
    </View>
  );
}

const s = StyleSheet.create({
  hint: { fontSize: Typography.sm, color: Colors.textMuted, textAlign: 'center', paddingVertical: Spacing.md },
  loadingRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, paddingVertical: Spacing.md },
  emptyBox: {
    alignItems: 'center',
    gap: Spacing.sm,
    padding: Spacing.lg,
    borderWidth: 1,
    borderStyle: 'dashed',
    borderColor: Colors.border,
    borderRadius: 12,
  },
  emptyText: { fontSize: Typography.sm, color: Colors.textMuted, textAlign: 'center' },
  phone: { fontSize: Typography.sm, fontWeight: '600', color: Colors.brand },
  grid: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm },
  slot: {
    paddingHorizontal: 14,
    paddingVertical: 8,
    borderRadius: 8,
    borderWidth: 1,
    borderColor: Colors.border,
    backgroundColor: Colors.surface,
  },
  slotSelected: { backgroundColor: Colors.brand, borderColor: Colors.brand },
  slotText: { fontSize: Typography.sm, color: Colors.text, fontWeight: '500' },
  slotTextSelected: { color: '#fff' },
});
  • Step 2: Commit
git add odontox-app/components/ui/SlotGrid.tsx
git commit -m "feat(mobile): add SlotGrid component wired to available-slots rule engine"

Task 2: Patient BookingSheet

Files:
  • Create: odontox-app/components/booking/BookingSheet.tsx
  • Step 1: Create the component
// odontox-app/components/booking/BookingSheet.tsx
import { forwardRef, useCallback, useMemo } from 'react';
import {
  View, Text, StyleSheet, Pressable, ScrollView,
  KeyboardAvoidingView, Platform, ActivityIndicator,
} from 'react-native';
import BottomSheet, {
  BottomSheetScrollView,
  BottomSheetBackdrop,
} from '@gorhom/bottom-sheet';
import { Calendar } from 'react-native-calendars';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Toast from 'react-native-toast-message';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { SlotGrid } from '@/components/ui/SlotGrid';
import { papi } from '@/lib/api';

const bookingSchema = z.object({
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Select a date'),
  time: z.string().min(1, 'Select a time slot'),
  appointmentType: z.enum(['checkup', 'cleaning', 'emergency', 'consultation', 'other']),
  notes: z.string().max(500).optional(),
});

type BookingForm = z.infer<typeof bookingSchema>;

const TYPES = [
  { value: 'checkup', label: 'Checkup' },
  { value: 'cleaning', label: 'Cleaning' },
  { value: 'emergency', label: 'Emergency' },
  { value: 'consultation', label: 'Consultation' },
  { value: 'other', label: 'Other' },
] as const;

const today = new Date().toISOString().split('T')[0];

const CAL_THEME = {
  selectedDayBackgroundColor: Colors.brand,
  selectedDayTextColor: '#fff',
  todayTextColor: Colors.brand,
  arrowColor: Colors.brand,
  dotColor: Colors.brand,
};

interface BookingSheetProps {
  onSuccess?: () => void;
}

export const BookingSheet = forwardRef<BottomSheet, BookingSheetProps>(
  function BookingSheet({ onSuccess }, ref) {
    const snapPoints = useMemo(() => ['75%', '95%'], []);

    const { control, handleSubmit, watch, setValue, reset, formState: { isSubmitting, errors } } = useForm<BookingForm>({
      resolver: zodResolver(bookingSchema),
      defaultValues: { date: '', time: '', appointmentType: 'checkup', notes: '' },
    });

    const selectedDate = watch('date');
    const selectedTime = watch('time');

    const renderBackdrop = useCallback(
      (props: any) => <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />,
      [],
    );

    async function onSubmit(data: BookingForm) {
      try {
        await papi.post('/appointments', {
          appointmentDate: data.date,
          appointmentTime: data.time,
          appointmentType: data.appointmentType,
          notes: data.notes || null,
          status: 'requested',
        });
        Toast.show({ type: 'success', text1: 'Appointment requested', text2: 'The clinic will confirm shortly.' });
        reset();
        (ref as any)?.current?.close();
        onSuccess?.();
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : 'Failed to request appointment';
        Toast.show({ type: 'error', text1: 'Could not book', text2: msg });
      }
    }

    return (
      <BottomSheet
        ref={ref}
        index={-1}
        snapPoints={snapPoints}
        enablePanDownToClose
        backdropComponent={renderBackdrop}
        backgroundStyle={{ backgroundColor: Colors.surface }}
        handleIndicatorStyle={{ backgroundColor: Colors.border }}
      >
        <BottomSheetScrollView contentContainerStyle={s.container}>
          <Text style={s.heading}>Book Appointment</Text>

          {/* Date picker */}
          <Text style={s.label}>Select Date</Text>
          <Controller
            control={control}
            name="date"
            render={() => (
              <Calendar
                minDate={today}
                onDayPress={day => {
                  setValue('date', day.dateString, { shouldValidate: true });
                  setValue('time', ''); // reset time when date changes
                }}
                markedDates={selectedDate ? { [selectedDate]: { selected: true, selectedColor: Colors.brand } } : {}}
                theme={CAL_THEME}
                hideExtraDays
                enableSwipeMonths
              />
            )}
          />
          {errors.date && <Text style={s.error}>{errors.date.message}</Text>}

          {/* Time slots */}
          <Text style={[s.label, { marginTop: Spacing.md }]}>Available Times</Text>
          <Controller
            control={control}
            name="time"
            render={() => (
              <SlotGrid
                date={selectedDate}
                selected={selectedTime}
                onSelect={slot => setValue('time', slot, { shouldValidate: true })}
              />
            )}
          />
          {errors.time && <Text style={s.error}>{errors.time.message}</Text>}

          {/* Appointment type */}
          <Text style={[s.label, { marginTop: Spacing.md }]}>Type</Text>
          <Controller
            control={control}
            name="appointmentType"
            render={({ field: { value, onChange } }) => (
              <View style={s.typeRow}>
                {TYPES.map(t => (
                  <Pressable
                    key={t.value}
                    onPress={() => onChange(t.value)}
                    style={[s.typeChip, value === t.value && s.typeChipSelected]}
                  >
                    <Text style={[s.typeText, value === t.value && s.typeTextSelected]}>{t.label}</Text>
                  </Pressable>
                ))}
              </View>
            )}
          />

          {/* Submit */}
          <Pressable
            onPress={handleSubmit(onSubmit)}
            disabled={isSubmitting}
            style={({ pressed }) => [s.btn, pressed && { opacity: 0.8 }, isSubmitting && { opacity: 0.6 }]}
          >
            {isSubmitting
              ? <ActivityIndicator color="#fff" />
              : <Text style={s.btnText}>Request Appointment</Text>}
          </Pressable>
        </BottomSheetScrollView>
      </BottomSheet>
    );
  }
);

const s = StyleSheet.create({
  container: { padding: Spacing.lg, paddingBottom: 60 },
  heading: { fontSize: Typography.xl, fontWeight: '700', color: Colors.text, marginBottom: Spacing.lg },
  label: { fontSize: Typography.sm, fontWeight: '600', color: Colors.textSecondary, marginBottom: Spacing.sm },
  error: { fontSize: Typography.xs, color: Colors.error, marginTop: 4 },
  typeRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm },
  typeChip: {
    paddingHorizontal: 14, paddingVertical: 8,
    borderRadius: 20, borderWidth: 1, borderColor: Colors.border,
    backgroundColor: Colors.surface,
  },
  typeChipSelected: { backgroundColor: Colors.brand, borderColor: Colors.brand },
  typeText: { fontSize: Typography.sm, color: Colors.text },
  typeTextSelected: { color: '#fff', fontWeight: '600' },
  btn: {
    marginTop: Spacing.xl,
    backgroundColor: Colors.brand,
    borderRadius: 12,
    paddingVertical: 16,
    alignItems: 'center',
  },
  btnText: { color: '#fff', fontSize: Typography.base, fontWeight: '700' },
});
  • Step 2: Commit
git add odontox-app/components/booking/BookingSheet.tsx
git commit -m "feat(mobile): add patient BookingSheet with calendar + slot grid + react-hook-form"

Task 3: Recent Patients Hook + Staff BookingSheet

Files:
  • Create: odontox-app/hooks/useRecentPatients.ts
  • Create: odontox-app/components/booking/StaffBookingSheet.tsx
  • Step 1: Create hooks/useRecentPatients.ts
Tracks recently selected patients using SecureStore (max 20, frequency-ranked).
// odontox-app/hooks/useRecentPatients.ts
import { useEffect, useState, useCallback } from 'react';
import * as SecureStore from 'expo-secure-store';
import { papi } from '@/lib/api';

export interface PatientSummary {
  id: string;
  name: string;
  patientNumber: string | null;
}

const STORAGE_KEY = 'odx_recent_patients_v1';
const MAX_RECENT = 20;

async function loadFrequencyMap(): Promise<Record<string, number>> {
  const raw = await SecureStore.getItemAsync(STORAGE_KEY);
  return raw ? (JSON.parse(raw) as Record<string, number>) : {};
}

async function bumpFrequency(patientId: string) {
  const map = await loadFrequencyMap();
  map[patientId] = (map[patientId] ?? 0) + 1;
  // Keep only top MAX_RECENT by frequency
  const sorted = Object.entries(map)
    .sort((a, b) => b[1] - a[1])
    .slice(0, MAX_RECENT);
  await SecureStore.setItemAsync(STORAGE_KEY, JSON.stringify(Object.fromEntries(sorted)));
}

export function useRecentPatients() {
  const [recentIds, setRecentIds] = useState<string[]>([]);
  const [recentPatients, setRecentPatients] = useState<PatientSummary[]>([]);
  const [searchResults, setSearchResults] = useState<PatientSummary[]>([]);
  const [searching, setSearching] = useState(false);

  useEffect(() => {
    loadFrequencyMap().then(map => {
      const sorted = Object.entries(map)
        .sort((a, b) => b[1] - a[1])
        .slice(0, MAX_RECENT)
        .map(([id]) => id);
      setRecentIds(sorted);
    });
  }, []);

  useEffect(() => {
    if (recentIds.length === 0) return;
    // Fetch patient names for recent IDs
    papi.get<{ data: PatientSummary[] }>(`/patients?limit=50`)
      .then(res => {
        const all = res.data ?? (Array.isArray(res) ? res : []);
        const recentMap = new Map(recentIds.map((id, i) => [id, i]));
        const filtered = (all as any[])
          .filter(p => recentMap.has(p.id))
          .sort((a, b) => (recentMap.get(a.id) ?? 99) - (recentMap.get(b.id) ?? 99))
          .map(p => ({
            id: p.id,
            name: `${p.firstName} ${p.lastName}`,
            patientNumber: p.patientNumber ?? null,
          }));
        setRecentPatients(filtered);
      })
      .catch(() => {});
  }, [recentIds]);

  const search = useCallback(async (query: string) => {
    if (query.trim().length < 2) { setSearchResults([]); return; }
    setSearching(true);
    try {
      const res = await papi.get<any>(`/patients?search=${encodeURIComponent(query.trim())}&limit=20`);
      const all = Array.isArray(res) ? res : (res?.data ?? []);
      setSearchResults(all.map((p: any) => ({
        id: p.id,
        name: `${p.firstName} ${p.lastName}`,
        patientNumber: p.patientNumber ?? null,
      })));
    } catch {}
    finally { setSearching(false); }
  }, []);

  return { recentPatients, searchResults, searching, search, bumpFrequency };
}
  • Step 2: Create components/booking/StaffBookingSheet.tsx
// odontox-app/components/booking/StaffBookingSheet.tsx
import { forwardRef, useCallback, useMemo, useState } from 'react';
import {
  View, Text, StyleSheet, Pressable, TextInput,
  FlatList, ActivityIndicator,
} from 'react-native';
import BottomSheet, { BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { Calendar } from 'react-native-calendars';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Toast from 'react-native-toast-message';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { SlotGrid } from '@/components/ui/SlotGrid';
import { useRecentPatients, type PatientSummary } from '@/hooks/useRecentPatients';
import { papi } from '@/lib/api';
import { useAuthStore } from '@/store/auth';

const staffBookingSchema = z.object({
  patientId: z.string().min(1, 'Select a patient'),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Select a date'),
  time: z.string().min(1, 'Select a time'),
  appointmentType: z.enum(['checkup', 'cleaning', 'emergency', 'consultation', 'other']),
  notes: z.string().max(500).optional(),
});

type StaffBookingForm = z.infer<typeof staffBookingSchema>;

const TYPES = [
  { value: 'checkup', label: 'Checkup' },
  { value: 'cleaning', label: 'Cleaning' },
  { value: 'emergency', label: 'Emergency' },
  { value: 'consultation', label: 'Consultation' },
  { value: 'other', label: 'Other' },
] as const;

const today = new Date().toISOString().split('T')[0];

const CAL_THEME = {
  selectedDayBackgroundColor: Colors.brand,
  selectedDayTextColor: '#fff',
  todayTextColor: Colors.brand,
  arrowColor: Colors.brand,
};

interface StaffBookingSheetProps {
  onSuccess?: () => void;
}

export const StaffBookingSheet = forwardRef<BottomSheet, StaffBookingSheetProps>(
  function StaffBookingSheet({ onSuccess }, ref) {
    const { user } = useAuthStore();
    const snapPoints = useMemo(() => ['90%', '95%'], []);
    const [searchQuery, setSearchQuery] = useState('');
    const [selectedPatient, setSelectedPatient] = useState<PatientSummary | null>(null);

    const { recentPatients, searchResults, searching, search, bumpFrequency } = useRecentPatients();

    const { control, handleSubmit, watch, setValue, reset, formState: { isSubmitting, errors } } = useForm<StaffBookingForm>({
      resolver: zodResolver(staffBookingSchema),
      defaultValues: { patientId: '', date: '', time: '', appointmentType: 'checkup', notes: '' },
    });

    const selectedDate = watch('date');
    const selectedTime = watch('time');

    // Staff (doctor) see their own ID as doctorId for slot filtering
    const doctorIdForSlots = user?.role === 'doctor' ? user.id : undefined;

    const renderBackdrop = useCallback(
      (props: any) => <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />,
      [],
    );

    function selectPatient(patient: PatientSummary) {
      setSelectedPatient(patient);
      setValue('patientId', patient.id, { shouldValidate: true });
      setSearchQuery('');
    }

    async function onSubmit(data: StaffBookingForm) {
      try {
        await papi.post('/appointments', {
          patientId: data.patientId,
          appointmentDate: data.date,
          appointmentTime: data.time,
          appointmentType: data.appointmentType,
          notes: data.notes || null,
          status: 'scheduled',
          ...(user?.role === 'doctor' ? { doctorId: user.id } : {}),
        });
        await bumpFrequency(data.patientId);
        Toast.show({ type: 'success', text1: 'Appointment scheduled' });
        reset();
        setSelectedPatient(null);
        setSearchQuery('');
        (ref as any)?.current?.close();
        onSuccess?.();
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : 'Failed to create appointment';
        Toast.show({ type: 'error', text1: 'Could not schedule', text2: msg });
      }
    }

    const displayList = searchQuery.length >= 2 ? searchResults : recentPatients;
    const listLabel = searchQuery.length >= 2 ? 'Search results' : 'Recent patients';

    return (
      <BottomSheet
        ref={ref}
        index={-1}
        snapPoints={snapPoints}
        enablePanDownToClose
        backdropComponent={renderBackdrop}
        backgroundStyle={{ backgroundColor: Colors.surface }}
        handleIndicatorStyle={{ backgroundColor: Colors.border }}
        keyboardBehavior="interactive"
        keyboardBlurBehavior="restore"
      >
        <BottomSheetScrollView contentContainerStyle={s.container} keyboardShouldPersistTaps="handled">
          <Text style={s.heading}>New Appointment</Text>

          {/* Patient selection */}
          <Text style={s.label}>Patient</Text>
          {selectedPatient ? (
            <Pressable style={s.selectedPatient} onPress={() => { setSelectedPatient(null); setValue('patientId', ''); }}>
              <View style={{ flex: 1 }}>
                <Text style={s.patientName}>{selectedPatient.name}</Text>
                {selectedPatient.patientNumber && (
                  <Text style={s.patientNum}>#{selectedPatient.patientNumber}</Text>
                )}
              </View>
              <Ionicons name="close-circle" size={20} color={Colors.textMuted} />
            </Pressable>
          ) : (
            <>
              <TextInput
                style={s.searchInput}
                placeholder="Search by name…"
                placeholderTextColor={Colors.textMuted}
                value={searchQuery}
                onChangeText={q => { setSearchQuery(q); search(q); }}
                autoCapitalize="words"
                returnKeyType="search"
              />
              <Text style={s.listLabel}>{listLabel}</Text>
              {searching && <ActivityIndicator color={Colors.brand} style={{ marginVertical: 8 }} />}
              {displayList.map(p => (
                <Pressable
                  key={p.id}
                  style={({ pressed }) => [s.patientRow, pressed && { opacity: 0.7 }]}
                  onPress={() => selectPatient(p)}
                >
                  <Text style={s.patientRowName}>{p.name}</Text>
                  {p.patientNumber && <Text style={s.patientNum}>#{p.patientNumber}</Text>}
                </Pressable>
              ))}
            </>
          )}
          {errors.patientId && <Text style={s.error}>{errors.patientId.message}</Text>}

          {/* Date */}
          <Text style={[s.label, { marginTop: Spacing.md }]}>Date</Text>
          <Controller
            control={control}
            name="date"
            render={() => (
              <Calendar
                minDate={today}
                onDayPress={day => {
                  setValue('date', day.dateString, { shouldValidate: true });
                  setValue('time', '');
                }}
                markedDates={selectedDate ? { [selectedDate]: { selected: true, selectedColor: Colors.brand } } : {}}
                theme={CAL_THEME}
                hideExtraDays
                enableSwipeMonths
              />
            )}
          />
          {errors.date && <Text style={s.error}>{errors.date.message}</Text>}

          {/* Slots */}
          <Text style={[s.label, { marginTop: Spacing.md }]}>Time</Text>
          <Controller
            control={control}
            name="time"
            render={() => (
              <SlotGrid
                date={selectedDate}
                doctorId={doctorIdForSlots}
                selected={selectedTime}
                onSelect={slot => setValue('time', slot, { shouldValidate: true })}
              />
            )}
          />
          {errors.time && <Text style={s.error}>{errors.time.message}</Text>}

          {/* Type */}
          <Text style={[s.label, { marginTop: Spacing.md }]}>Type</Text>
          <Controller
            control={control}
            name="appointmentType"
            render={({ field: { value, onChange } }) => (
              <View style={s.typeRow}>
                {TYPES.map(t => (
                  <Pressable
                    key={t.value}
                    onPress={() => onChange(t.value)}
                    style={[s.typeChip, value === t.value && s.typeChipSelected]}
                  >
                    <Text style={[s.typeText, value === t.value && s.typeTextSelected]}>{t.label}</Text>
                  </Pressable>
                ))}
              </View>
            )}
          />

          <Pressable
            onPress={handleSubmit(onSubmit)}
            disabled={isSubmitting}
            style={({ pressed }) => [s.btn, pressed && { opacity: 0.8 }, isSubmitting && { opacity: 0.6 }]}
          >
            {isSubmitting
              ? <ActivityIndicator color="#fff" />
              : <Text style={s.btnText}>Schedule Appointment</Text>}
          </Pressable>
        </BottomSheetScrollView>
      </BottomSheet>
    );
  }
);

const s = StyleSheet.create({
  container: { padding: Spacing.lg, paddingBottom: 80 },
  heading: { fontSize: Typography.xl, fontWeight: '700', color: Colors.text, marginBottom: Spacing.lg },
  label: { fontSize: Typography.sm, fontWeight: '600', color: Colors.textSecondary, marginBottom: Spacing.sm },
  error: { fontSize: Typography.xs, color: Colors.error, marginTop: 4 },
  searchInput: {
    backgroundColor: Colors.surface,
    borderWidth: 1,
    borderColor: Colors.border,
    borderRadius: 10,
    paddingHorizontal: Spacing.md,
    paddingVertical: 10,
    fontSize: Typography.base,
    color: Colors.text,
    marginBottom: Spacing.sm,
  },
  listLabel: { fontSize: Typography.xs, color: Colors.textMuted, marginBottom: 4 },
  patientRow: {
    paddingVertical: 10,
    paddingHorizontal: Spacing.sm,
    borderBottomWidth: 1,
    borderBottomColor: Colors.border,
  },
  patientRowName: { fontSize: Typography.base, color: Colors.text },
  patientNum: { fontSize: Typography.xs, color: Colors.textMuted },
  selectedPatient: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: Colors.brandSurface,
    borderRadius: 10,
    padding: Spacing.md,
    borderWidth: 1,
    borderColor: Colors.brand + '40',
  },
  patientName: { fontSize: Typography.base, fontWeight: '600', color: Colors.text },
  typeRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm },
  typeChip: {
    paddingHorizontal: 14, paddingVertical: 8,
    borderRadius: 20, borderWidth: 1, borderColor: Colors.border,
    backgroundColor: Colors.surface,
  },
  typeChipSelected: { backgroundColor: Colors.brand, borderColor: Colors.brand },
  typeText: { fontSize: Typography.sm, color: Colors.text },
  typeTextSelected: { color: '#fff', fontWeight: '600' },
  btn: {
    marginTop: Spacing.xl,
    backgroundColor: Colors.brand,
    borderRadius: 12,
    paddingVertical: 16,
    alignItems: 'center',
  },
  btnText: { color: '#fff', fontSize: Typography.base, fontWeight: '700' },
});
  • Step 3: Commit
git add odontox-app/hooks/useRecentPatients.ts odontox-app/components/booking/StaffBookingSheet.tsx
git commit -m "feat(mobile): add StaffBookingSheet with recent/search patient selector"

Task 4: Wire BookingSheet Into Patient Screens

Files:
  • Modify: odontox-app/app/(patient)/index.tsx
  • Modify: odontox-app/app/(patient)/appointments/index.tsx
  • Modify: odontox-app/app/(patient)/appointments/[id].tsx
  • Step 1: Add “Book Appointment” CTA to patient home screen
Open odontox-app/app/(patient)/index.tsx. Add at the top:
import { useRef, useCallback } from 'react';
import type BottomSheet from '@gorhom/bottom-sheet';
import { BookingSheet } from '@/components/booking/BookingSheet';
Inside PatientHome, add:
const bookingRef = useRef<BottomSheet>(null);
const openBooking = useCallback(() => bookingRef.current?.snapToIndex(0), []);
const { refetch } = /* existing load call */ ;
In the JSX, after the balance card (or as the last child of ScrollView), add a “Book Appointment” button. If summary?.metrics.nextAppointment is null, show it prominently above the stats:
{!summary?.metrics.nextAppointment && (
  <Pressable
    onPress={openBooking}
    style={({ pressed }) => [s.bookBtn, pressed && { opacity: 0.8 }]}
  >
    <Ionicons name="calendar-plus-outline" size={20} color="#fff" />
    <Text style={s.bookBtnText}>Book Appointment</Text>
  </Pressable>
)}
Add to StyleSheet:
bookBtn: {
  backgroundColor: Colors.brand,
  borderRadius: 14,
  padding: Spacing.md,
  flexDirection: 'row',
  alignItems: 'center',
  justifyContent: 'center',
  gap: Spacing.sm,
},
bookBtnText: { color: '#fff', fontSize: Typography.base, fontWeight: '700' },
At the end of the root View JSX (sibling to ScrollView), add:
<BookingSheet ref={bookingRef} onSuccess={() => load(true)} />
  • Step 2: Add past appointments section + “Book” FAB to appointments screen
Open odontox-app/app/(patient)/appointments/index.tsx. Add imports:
import { useRef, useCallback } from 'react';
import type BottomSheet from '@gorhom/bottom-sheet';
import { BookingSheet } from '@/components/booking/BookingSheet';
Add ref and open handler inside the component:
const bookingRef = useRef<BottomSheet>(null);
const openBooking = useCallback(() => bookingRef.current?.snapToIndex(0), []);
In the renderItem function, keep existing appointment row. Change listData so that when viewMode === 'upcoming', the FlatList renders BOTH upcoming and past in sections. The cleanest way is to use a single data array:
// Replace: const listData = viewMode === 'upcoming' ? upcoming : dayAppointments;
// With:
const listData = viewMode === 'upcoming'
  ? [
      ...upcoming.map(a => ({ ...a, _section: 'upcoming' as const })),
      ...past.map(a => ({ ...a, _section: 'past' as const })),
    ]
  : dayAppointments.map(a => ({ ...a, _section: 'day' as const }));
Update renderItem to show a section divider between upcoming and past:
function renderItem({ item, index }: { item: typeof listData[0]; index: number }) {
  const prevItem = index > 0 ? listData[index - 1] : null;
  const showPastDivider = item._section === 'past' && prevItem?._section !== 'past';

  return (
    <>
      {showPastDivider && (
        <Text style={s.sectionDivider}>Past Appointments</Text>
      )}
      <Pressable
        onPress={() => router.push({ pathname: '/(patient)/appointments/[id]', params: { id: item.id } })}
        style={({ pressed }) => [s.row, item._section === 'past' && { opacity: 0.6 }, pressed && { opacity: 0.5 }]}
      >
        <View style={{ flex: 1 }}>
          <Text style={s.rowDate}>{item.appointmentDate}</Text>
          {item.appointmentTime ? <Text style={s.rowSub}>{item.appointmentTime}</Text> : null}
          {item.appointmentType ? <Text style={s.rowType}>{item.appointmentType}</Text> : null}
        </View>
        <StatusBadge status={item.status} />
        <Ionicons name="chevron-forward" size={16} color={Colors.textMuted} />
      </Pressable>
    </>
  );
}
Add to StyleSheet:
sectionDivider: {
  fontSize: Typography.xs,
  fontWeight: '700',
  color: Colors.textMuted,
  textTransform: 'uppercase',
  letterSpacing: 0.8,
  paddingVertical: Spacing.sm,
  marginTop: Spacing.sm,
},
Add FAB button and BookingSheet at the bottom of the return JSX (inside SafeAreaView, after FlatList):
<Pressable
  onPress={openBooking}
  style={({ pressed }) => [s.fab, pressed && { opacity: 0.8 }]}
>
  <Ionicons name="add" size={28} color="#fff" />
</Pressable>
<BookingSheet ref={bookingRef} onSuccess={() => loadUpcoming(true)} />
Add to StyleSheet:
fab: {
  position: 'absolute',
  bottom: 24,
  right: 24,
  width: 56,
  height: 56,
  borderRadius: 28,
  backgroundColor: Colors.brand,
  alignItems: 'center',
  justifyContent: 'center',
  shadowColor: '#000',
  shadowOffset: { width: 0, height: 4 },
  shadowOpacity: 0.2,
  shadowRadius: 8,
  elevation: 8,
},
  • Step 3: Add cancel button to patient appointment detail
Open odontox-app/app/(patient)/appointments/[id].tsx. Add at the top:
import { Alert } from 'react-native';
import Toast from 'react-native-toast-message';
After the load function, add:
async function handleCancel() {
  Alert.alert(
    'Cancel Appointment',
    'Are you sure you want to cancel this appointment?',
    [
      { text: 'Keep', style: 'cancel' },
      {
        text: 'Cancel Appointment',
        style: 'destructive',
        onPress: async () => {
          try {
            await papi.patch(`/appointments/${id}/status`, { status: 'cancelled' });
            Toast.show({ type: 'success', text1: 'Appointment cancelled' });
            load(true);
          } catch (e: unknown) {
            const msg = e instanceof Error ? e.message : 'Could not cancel';
            Toast.show({ type: 'error', text1: 'Cancellation failed', text2: msg });
          }
        },
      },
    ],
  );
}
Note: papi.patch is already in lib/api.ts (added earlier if it was missing). In the ScrollView JSX, after the notes/invoice sections, conditionally show the cancel button. Only show it when data.status is one of ['requested', 'scheduled', 'confirmed']:
{data && ['requested', 'scheduled', 'confirmed'].includes(data.status) && (
  <Pressable
    onPress={handleCancel}
    style={({ pressed }) => [s.cancelBtn, pressed && { opacity: 0.7 }]}
  >
    <Text style={s.cancelBtnText}>Cancel Appointment</Text>
  </Pressable>
)}
Add to StyleSheet:
cancelBtn: {
  marginTop: Spacing.sm,
  borderRadius: 12,
  borderWidth: 1,
  borderColor: Colors.error,
  paddingVertical: 14,
  alignItems: 'center',
},
cancelBtnText: { color: Colors.error, fontSize: Typography.base, fontWeight: '600' },
  • Step 4: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
Expected: no errors.
  • Step 5: Commit
git add "odontox-app/app/(patient)/index.tsx" \
  "odontox-app/app/(patient)/appointments/index.tsx" \
  "odontox-app/app/(patient)/appointments/[id].tsx"
git commit -m "feat(mobile): patient booking CTA, past appointments, cancel with rule-engine errors"

Task 5: Staff Booking FAB (Doctor, Receptionist, Admin)

Files:
  • Modify: odontox-app/app/(doctor)/appointments/index.tsx
  • Modify: odontox-app/app/(receptionist)/appointments/index.tsx
  • Modify: odontox-app/app/(admin)/appointments/index.tsx
  • Step 1: Add StaffBookingSheet + FAB to doctor appointments screen
Open odontox-app/app/(doctor)/appointments/index.tsx. Add at the top:
import { useRef, useCallback } from 'react';
import type BottomSheet from '@gorhom/bottom-sheet';
import { StaffBookingSheet } from '@/components/booking/StaffBookingSheet';
Inside the component, add:
const bookingRef = useRef<BottomSheet>(null);
const openBooking = useCallback(() => bookingRef.current?.snapToIndex(0), []);
At the end of the return JSX (inside SafeAreaView), add the FAB and sheet:
<Pressable
  onPress={openBooking}
  style={({ pressed }) => [s.fab, pressed && { opacity: 0.8 }]}
>
  <Ionicons name="add" size={28} color="#fff" />
</Pressable>
<StaffBookingSheet ref={bookingRef} onSuccess={() => loadDay(selectedDate)} />
Add s.fab to StyleSheet (same as patient version):
fab: {
  position: 'absolute', bottom: 24, right: 24,
  width: 56, height: 56, borderRadius: 28, backgroundColor: Colors.brand,
  alignItems: 'center', justifyContent: 'center',
  shadowColor: '#000', shadowOffset: { width: 0, height: 4 },
  shadowOpacity: 0.2, shadowRadius: 8, elevation: 8,
},
  • Step 2: Apply the same pattern to receptionist and admin appointments screens
Repeat Step 1 exactly for:
  • odontox-app/app/(receptionist)/appointments/index.tsx
  • odontox-app/app/(admin)/appointments/index.tsx
The code is identical — copy exactly.
  • Step 3: Commit
git add "odontox-app/app/(doctor)/appointments/index.tsx" \
  "odontox-app/app/(receptionist)/appointments/index.tsx" \
  "odontox-app/app/(admin)/appointments/index.tsx"
git commit -m "feat(mobile): add new appointment FAB + StaffBookingSheet for doctor/receptionist/admin"

Task 6: PdfViewer Component

Files:
  • Create: odontox-app/components/viewer/PdfViewer.tsx
  • Step 1: Create the component
// odontox-app/components/viewer/PdfViewer.tsx
import { useState, useEffect } from 'react';
import {
  View, Text, StyleSheet, Pressable, ActivityIndicator,
  Platform, SafeAreaView,
} from 'react-native';
import { WebView } from 'react-native-webview';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { BASE_URL } from '@/lib/api';
import { getJwt } from '@/lib/secure-storage';

interface PdfViewerProps {
  /** Full protected path like /protected/invoices/:id/pdf */
  path: string;
  title: string;
  onClose: () => void;
}

export function PdfViewer({ path, title, onClose }: PdfViewerProps) {
  const [localUri, setLocalUri] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [downloading, setDownloading] = useState(true);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let cancelled = false;

    async function download() {
      setDownloading(true);
      setError(null);
      try {
        const jwt = await getJwt();
        const cacheUri = FileSystem.cacheDirectory + `odx_${Date.now()}.pdf`;

        const downloadResumable = FileSystem.createDownloadResumable(
          `${BASE_URL}${path}`,
          cacheUri,
          { headers: { Authorization: `Bearer ${jwt ?? ''}` } },
          ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
            if (totalBytesExpectedToWrite > 0) {
              setProgress(totalBytesWritten / totalBytesExpectedToWrite);
            }
          },
        );

        const result = await downloadResumable.downloadAsync();
        if (cancelled) return;

        if (!result || result.status < 200 || result.status >= 300) {
          throw new Error(`Download failed: HTTP ${result?.status ?? 'unknown'}`);
        }

        if (Platform.OS === 'android') {
          // Android WebView cannot render local PDFs — use system viewer via Sharing
          await Sharing.shareAsync(result.uri, { mimeType: 'application/pdf' });
          onClose(); // close this screen after handing off to system viewer
          return;
        }

        setLocalUri(result.uri);
      } catch (e: unknown) {
        if (!cancelled) setError(e instanceof Error ? e.message : 'Download failed');
      } finally {
        if (!cancelled) setDownloading(false);
      }
    }

    download();
    return () => { cancelled = true; };
  }, [path]);

  async function handleShare() {
    if (!localUri) return;
    await Sharing.shareAsync(localUri, { mimeType: 'application/pdf' });
  }

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: '#000' }}>
      {/* Header */}
      <View style={s.header}>
        <Pressable onPress={onClose} hitSlop={8} style={s.headerBtn}>
          <Ionicons name="close" size={24} color="#fff" />
        </Pressable>
        <Text style={s.headerTitle} numberOfLines={1}>{title}</Text>
        <Pressable onPress={handleShare} hitSlop={8} style={s.headerBtn} disabled={!localUri}>
          <Ionicons name="share-outline" size={22} color={localUri ? '#fff' : '#666'} />
        </Pressable>
      </View>

      {/* Body */}
      {downloading && (
        <View style={s.center}>
          <ActivityIndicator color={Colors.brand} size="large" />
          <Text style={s.progressText}>
            {progress > 0 ? `${Math.round(progress * 100)}%` : 'Loading…'}
          </Text>
        </View>
      )}

      {error && (
        <View style={s.center}>
          <Ionicons name="alert-circle-outline" size={40} color={Colors.error} />
          <Text style={s.errorText}>{error}</Text>
          <Pressable onPress={onClose} style={s.closeBtn}>
            <Text style={{ color: Colors.brand }}>Go back</Text>
          </Pressable>
        </View>
      )}

      {!downloading && !error && localUri && Platform.OS === 'ios' && (
        <WebView
          source={{ uri: localUri }}
          style={{ flex: 1, backgroundColor: '#fff' }}
          onError={() => setError('Could not display PDF')}
        />
      )}
    </SafeAreaView>
  );
}

const s = StyleSheet.create({
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: Spacing.md,
    paddingVertical: 12,
    backgroundColor: '#1a1a1a',
  },
  headerBtn: { padding: 4, minWidth: 36 },
  headerTitle: {
    flex: 1,
    textAlign: 'center',
    color: '#fff',
    fontSize: Typography.base,
    fontWeight: '600',
    marginHorizontal: 8,
  },
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#111', gap: 12 },
  progressText: { color: '#aaa', fontSize: Typography.sm },
  errorText: { color: Colors.error, fontSize: Typography.sm, textAlign: 'center', paddingHorizontal: 24 },
  closeBtn: { marginTop: 8, padding: 8 },
});
  • Step 2: Commit
git add odontox-app/components/viewer/PdfViewer.tsx
git commit -m "feat(mobile): add PdfViewer (WebView/iOS + Sharing/Android)"

Task 7: ImageViewer Component

Files:
  • Create: odontox-app/components/viewer/ImageViewer.tsx
  • Step 1: Create the component
// odontox-app/components/viewer/ImageViewer.tsx
import { useState } from 'react';
import { Pressable, View, Text, StyleSheet } from 'react-native';
import RNImageViewing from 'react-native-image-viewing';
import * as Sharing from 'expo-sharing';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';

interface ImageViewerProps {
  images: Array<{ uri: string; title?: string }>;
  initialIndex?: number;
  visible: boolean;
  onClose: () => void;
}

export function ImageViewer({ images, initialIndex = 0, visible, onClose }: ImageViewerProps) {
  const [currentIndex, setCurrentIndex] = useState(initialIndex);

  async function handleShare() {
    const uri = images[currentIndex]?.uri;
    if (uri) {
      await Sharing.shareAsync(uri).catch(() => {});
    }
  }

  return (
    <RNImageViewing
      images={images.map(img => ({ uri: img.uri }))}
      imageIndex={initialIndex}
      visible={visible}
      onRequestClose={onClose}
      onImageIndexChange={setCurrentIndex}
      HeaderComponent={() => (
        <View style={s.header}>
          <Pressable onPress={onClose} style={s.btn} hitSlop={8}>
            <Ionicons name="close" size={24} color="#fff" />
          </Pressable>
          <Text style={s.title} numberOfLines={1}>
            {images[currentIndex]?.title ?? `${currentIndex + 1} / ${images.length}`}
          </Text>
          <Pressable onPress={handleShare} style={s.btn} hitSlop={8}>
            <Ionicons name="share-outline" size={22} color="#fff" />
          </Pressable>
        </View>
      )}
    />
  );
}

const s = StyleSheet.create({
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: Spacing.md,
    paddingTop: 56,
    paddingBottom: 12,
  },
  btn: { padding: 4, minWidth: 36 },
  title: {
    flex: 1,
    textAlign: 'center',
    color: '#fff',
    fontSize: Typography.base,
    fontWeight: '600',
    marginHorizontal: 8,
  },
});
  • Step 2: Commit
git add odontox-app/components/viewer/ImageViewer.tsx
git commit -m "feat(mobile): add ImageViewer wrapping react-native-image-viewing"

Task 8: Records Drill-Down Screens

Files:
  • Modify: odontox-app/app/(patient)/records/index.tsx
  • Create: odontox-app/app/(patient)/records/prescription/[id].tsx
  • Create: odontox-app/app/(patient)/records/note/[id].tsx
  • Create: odontox-app/app/(patient)/records/file/[id].tsx
  • Step 1: Make records list items tappable
Open odontox-app/app/(patient)/records/index.tsx. Find the renderItem (or FlatList renderItem). Currently items are displayed but not tappable. Wrap each in a Pressable that routes based on item type. The records array items have a type field ('prescription' | 'file' | 'clinical_note'). Update the render:
import { useRouter } from 'expo-router';

// Inside the component:
const router = useRouter();

function routeForItem(item: RecordItem) {
  switch (item.type) {
    case 'prescription':
      return { pathname: '/(patient)/records/prescription/[id]', params: { id: item.id } };
    case 'file':
      return { pathname: '/(patient)/records/file/[id]', params: { id: item.id } };
    case 'clinical_note':
      return { pathname: '/(patient)/records/note/[id]', params: { id: item.id } };
    default:
      return null;
  }
}
Wrap each row in a Pressable:
<Pressable
  onPress={() => { const route = routeForItem(item); if (route) router.push(route as never); }}
  style={({ pressed }) => [s.row, pressed && { opacity: 0.7 }]}
>
  {/* existing row content unchanged */}
  <Ionicons name="chevron-forward" size={16} color={Colors.textMuted} />
</Pressable>
Read the actual RecordItem type shape by opening the file first and adjusting the type definition accordingly.
  • Step 2: Create app/(patient)/records/prescription/[id].tsx
// odontox-app/app/(patient)/records/prescription/[id].tsx
import { useEffect, useState } from 'react';
import {
  View, Text, ScrollView, SafeAreaView, Pressable,
  ActivityIndicator, RefreshControl, StyleSheet,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { papi } from '@/lib/api';

interface PrescriptionItem {
  medicationName: string;
  dosage: string | null;
  frequency: string | null;
  duration: string | null;
  quantity: number | null;
  instructions: string | null;
}

interface Prescription {
  id: string;
  prescriptionDate: string;
  diagnosis: string | null;
  notes: string | null;
  items: PrescriptionItem[];
}

export default function PrescriptionDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const router = useRouter();
  const [data, setData] = useState<Prescription | null>(null);
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);

  async function load(isRefresh = false) {
    if (isRefresh) setRefreshing(true); else setLoading(true);
    try {
      const res = await papi.get<Prescription>(`/prescriptions/${id}`);
      setData(res);
    } catch {}
    finally { setLoading(false); setRefreshing(false); }
  }

  useEffect(() => { if (id) load(); }, [id]);

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={s.header}>
        <Pressable onPress={() => router.back()} hitSlop={8}>
          <Ionicons name="chevron-back" size={24} color={Colors.text} />
        </Pressable>
        <Text style={s.headerTitle}>Prescription</Text>
        <View style={{ width: 32 }} />
      </View>

      {loading ? (
        <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
          <ActivityIndicator color={Colors.brand} />
        </View>
      ) : !data ? (
        <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
          <Text style={{ color: Colors.textMuted }}>Not found</Text>
        </View>
      ) : (
        <ScrollView
          contentContainerStyle={{ padding: Spacing.lg, gap: Spacing.md }}
          refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => load(true)} tintColor={Colors.brand} />}
        >
          <View style={s.card}>
            <Text style={s.dateLabel}>{data.prescriptionDate}</Text>
            {data.diagnosis && <Text style={s.diagnosis}>{data.diagnosis}</Text>}
          </View>

          <Text style={s.sectionTitle}>Medications</Text>
          {data.items.map((item, i) => (
            <View key={i} style={s.medCard}>
              <Text style={s.medName}>{item.medicationName}</Text>
              {item.dosage && <Text style={s.medDetail}>Dose: {item.dosage}</Text>}
              {item.frequency && <Text style={s.medDetail}>Frequency: {item.frequency}</Text>}
              {item.duration && <Text style={s.medDetail}>Duration: {item.duration}</Text>}
              {item.quantity && <Text style={s.medDetail}>Qty: {item.quantity}</Text>}
              {item.instructions && (
                <Text style={[s.medDetail, { marginTop: 4, fontStyle: 'italic' }]}>{item.instructions}</Text>
              )}
            </View>
          ))}

          {data.notes && (
            <View style={s.card}>
              <Text style={s.sectionTitle}>Notes</Text>
              <Text style={{ color: Colors.textSecondary, lineHeight: 22 }}>{data.notes}</Text>
            </View>
          )}
        </ScrollView>
      )}
    </SafeAreaView>
  );
}

const s = StyleSheet.create({
  header: {
    flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
    paddingHorizontal: Spacing.lg, paddingVertical: Spacing.md,
  },
  headerTitle: { fontSize: Typography.md, fontWeight: '600', color: Colors.text },
  card: {
    backgroundColor: Colors.surface, borderRadius: 12, padding: Spacing.md,
    borderWidth: 1, borderColor: Colors.border,
  },
  dateLabel: { fontSize: Typography.sm, color: Colors.textMuted },
  diagnosis: { fontSize: Typography.base, fontWeight: '600', color: Colors.text, marginTop: 4 },
  sectionTitle: { fontSize: Typography.sm, fontWeight: '700', color: Colors.textSecondary, textTransform: 'uppercase', letterSpacing: 0.5 },
  medCard: {
    backgroundColor: Colors.surface, borderRadius: 12, padding: Spacing.md,
    borderWidth: 1, borderColor: Colors.border,
  },
  medName: { fontSize: Typography.base, fontWeight: '700', color: Colors.text, marginBottom: 4 },
  medDetail: { fontSize: Typography.sm, color: Colors.textSecondary },
});
  • Step 3: Create app/(patient)/records/note/[id].tsx
// odontox-app/app/(patient)/records/note/[id].tsx
import { useEffect, useState } from 'react';
import {
  View, Text, ScrollView, SafeAreaView, Pressable,
  ActivityIndicator, RefreshControl, StyleSheet,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { papi } from '@/lib/api';

interface ClinicalNote {
  id: string;
  noteDate: string;
  chiefComplaint: string | null;
  assessment: string | null;
  plan: string | null;
  doctorName: string | null;
}

export default function NoteDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const router = useRouter();
  const [data, setData] = useState<ClinicalNote | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!id) return;
    papi.get<ClinicalNote>(`/clinical-notes/${id}`)
      .then(setData)
      .catch(() => {})
      .finally(() => setLoading(false));
  }, [id]);

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={s.header}>
        <Pressable onPress={() => router.back()} hitSlop={8}>
          <Ionicons name="chevron-back" size={24} color={Colors.text} />
        </Pressable>
        <Text style={s.headerTitle}>Clinical Note</Text>
        <View style={{ width: 32 }} />
      </View>

      {loading ? (
        <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
          <ActivityIndicator color={Colors.brand} />
        </View>
      ) : !data ? (
        <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
          <Text style={{ color: Colors.textMuted }}>Not found</Text>
        </View>
      ) : (
        <ScrollView contentContainerStyle={{ padding: Spacing.lg, gap: Spacing.md }}>
          <View style={s.meta}>
            <Text style={s.date}>{data.noteDate}</Text>
            {data.doctorName && <Text style={s.doctor}>Dr. {data.doctorName}</Text>}
          </View>
          {data.chiefComplaint && (
            <View style={s.section}>
              <Text style={s.sectionLabel}>Chief Complaint</Text>
              <Text style={s.sectionBody}>{data.chiefComplaint}</Text>
            </View>
          )}
          {data.assessment && (
            <View style={s.section}>
              <Text style={s.sectionLabel}>Assessment</Text>
              <Text style={s.sectionBody}>{data.assessment}</Text>
            </View>
          )}
          {data.plan && (
            <View style={s.section}>
              <Text style={s.sectionLabel}>Plan</Text>
              <Text style={s.sectionBody}>{data.plan}</Text>
            </View>
          )}
        </ScrollView>
      )}
    </SafeAreaView>
  );
}

const s = StyleSheet.create({
  header: {
    flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
    paddingHorizontal: Spacing.lg, paddingVertical: Spacing.md,
  },
  headerTitle: { fontSize: Typography.md, fontWeight: '600', color: Colors.text },
  meta: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  date: { fontSize: Typography.sm, color: Colors.textMuted },
  doctor: { fontSize: Typography.sm, color: Colors.brand, fontWeight: '600' },
  section: {
    backgroundColor: Colors.surface, borderRadius: 12, padding: Spacing.md,
    borderWidth: 1, borderColor: Colors.border,
  },
  sectionLabel: {
    fontSize: Typography.xs, fontWeight: '700', color: Colors.textMuted,
    textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: Spacing.sm,
  },
  sectionBody: { fontSize: Typography.base, color: Colors.text, lineHeight: 24 },
});
  • Step 4: Create app/(patient)/records/file/[id].tsx
// odontox-app/app/(patient)/records/file/[id].tsx
import { useEffect, useState } from 'react';
import { View, ActivityIndicator, SafeAreaView } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Colors } from '@/constants/theme';
import { papi } from '@/lib/api';
import { PdfViewer } from '@/components/viewer/PdfViewer';
import { ImageViewer } from '@/components/viewer/ImageViewer';
import { BASE_URL } from '@/lib/api';
import { getJwt } from '@/lib/secure-storage';

interface PatientFile {
  id: string;
  fileName: string;
  mimeType: string | null;
  filePath: string | null;
  fileUrl: string | null;
}

function isPdf(mimeType: string | null, fileName: string) {
  return mimeType === 'application/pdf' || fileName.toLowerCase().endsWith('.pdf');
}

function isImage(mimeType: string | null, fileName: string) {
  if (mimeType?.startsWith('image/')) return true;
  return /\.(jpg|jpeg|png|gif|webp|tiff?)$/i.test(fileName);
}

export default function FileViewer() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const router = useRouter();
  const [file, setFile] = useState<PatientFile | null>(null);
  const [loading, setLoading] = useState(true);
  const [imageVisible, setImageVisible] = useState(false);

  useEffect(() => {
    if (!id) return;
    papi.get<PatientFile>(`/patient-files/${id}`)
      .then(f => { setFile(f); if (isImage(f.mimeType, f.fileName)) setImageVisible(true); })
      .catch(() => {})
      .finally(() => setLoading(false));
  }, [id]);

  if (loading || !file) {
    return (
      <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background, alignItems: 'center', justifyContent: 'center' }}>
        <ActivityIndicator color={Colors.brand} />
      </SafeAreaView>
    );
  }

  if (isPdf(file.mimeType, file.fileName)) {
    return (
      <PdfViewer
        path={`/protected/patient-files/${id}/download`}
        title={file.fileName}
        onClose={() => router.back()}
      />
    );
  }

  if (isImage(file.mimeType, file.fileName)) {
    // Use fileUrl if available (external URL), else construct from filePath
    const uri = file.fileUrl ?? `${BASE_URL}/protected/patient-files/${id}/download`;
    return (
      <ImageViewer
        images={[{ uri, title: file.fileName }]}
        visible={imageVisible}
        onClose={() => router.back()}
      />
    );
  }

  // Unknown type — fallback to PDF viewer attempt
  return (
    <PdfViewer
      path={`/protected/patient-files/${id}/download`}
      title={file.fileName}
      onClose={() => router.back()}
    />
  );
}
Note: This references /protected/patient-files/:id/download which may not exist. Verify by checking server/src/routes/patient-files.ts for a download endpoint. If it doesn’t exist, add it to the server:
// In server/src/routes/patient-files.ts, add before export:
patientFilesRoute.get('/:id/download', async (c) => {
  // Fetch file metadata, get r2 signed URL or stream the file
  // Pattern already exists in public-documents.ts line 244
});
  • Step 5: Update bills PDF viewer to use PdfViewer instead of share
Open odontox-app/app/(patient)/bills/index.tsx. Find the openDocumentPdf calls. Replace the download+share approach with navigation to PdfViewer. Add state for showing PdfViewer:
const [pdfPath, setPdfPath] = useState<string | null>(null);
const [pdfTitle, setPdfTitle] = useState('');
Replace the openDocumentPdf(id, type) calls with:
setPdfPath(`/protected/${type}/${id}/pdf`);
setPdfTitle(`Invoice #${item.invoiceNumber ?? id}`);
At the end of the JSX, add modal PDF viewer:
{pdfPath && (
  <View style={StyleSheet.absoluteFill}>
    <PdfViewer path={pdfPath} title={pdfTitle} onClose={() => setPdfPath(null)} />
  </View>
)}
Import PdfViewer and StyleSheet from react-native.
  • Step 6: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
  • Step 7: Commit
git add "odontox-app/app/(patient)/records/index.tsx" \
  "odontox-app/app/(patient)/records/prescription/[id].tsx" \
  "odontox-app/app/(patient)/records/note/[id].tsx" \
  "odontox-app/app/(patient)/records/file/[id].tsx" \
  "odontox-app/app/(patient)/bills/index.tsx"
git commit -m "feat(mobile): records drill-down, prescription/note/file detail screens, PDF viewer in bills"

Task 9: Real Chart Data (Doctor + Admin)

Files:
  • Modify: odontox-app/app/(doctor)/index.tsx
  • Modify: odontox-app/app/(admin)/index.tsx
  • Step 1: Fix doctor home chart
Open odontox-app/app/(doctor)/index.tsx. The server returns weeklyStats: Array<{ day: string, patients: number, revenue: number }> in the /stats/doctor response. Find the interface for the stats response and add weeklyStats:
interface DoctorStats {
  metrics: {
    todayAppointments: number;
    pendingTreatments: number;
    totalPatients: number;
    thisWeekRevenue: number;
  };
  todayAppointments: Array<{ id: string; patientName: string; time: string; type: string; status: string }>;
  weeklyStats: Array<{ day: string; patients: number; revenue: number }>;
}
Find the hardcoded WEEK_CHART constant and delete it. Instead derive it from the API response inside the component:
const ALL_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

// Inside the component, after summary state:
const chartData = ALL_DAYS.map(day => {
  const found = summary?.weeklyStats?.find(w => w.day === day);
  return { value: found?.patients ?? 0, label: day, frontColor: '#5048E5' };
});
Replace data={WEEK_CHART} in the BarChart component with data={chartData}.
  • Step 2: Fix admin home chart
Open odontox-app/app/(admin)/index.tsx. The server returns appointmentData: Array<{ day: string, appointments: number }> in the /stats/admin response. Update the admin stats interface:
interface AdminStats {
  metrics: {
    scheduledToday: number;
    checkedIn: number;
    totalPatients: number;
    revenue: number;
  };
  recentAppointments: Array<{ id: string; patientName: string; time: string; doctorName: string; status: string }>;
  appointmentData: Array<{ day: string; appointments: number }>;
}
Delete the hardcoded WEEK_CHART and derive from API data:
const ALL_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

const chartData = ALL_DAYS.map(day => {
  const found = summary?.appointmentData?.find(d => d.day === day);
  return { value: found?.appointments ?? 0, label: day, frontColor: '#5048E5' };
});
Replace data={WEEK_CHART} with data={chartData}.
  • Step 3: Commit
git add "odontox-app/app/(doctor)/index.tsx" "odontox-app/app/(admin)/index.tsx"
git commit -m "feat(mobile): wire real weekly chart data from stats API for doctor and admin"

Task 10: Appointment Status Actions for Doctor + Receptionist

Files:
  • Modify: odontox-app/app/(doctor)/appointments/[id].tsx
  • Modify: odontox-app/app/(receptionist)/appointments/[id].tsx
  • Step 1: Read both appointment detail files before editing
Read odontox-app/app/(doctor)/appointments/[id].tsx and odontox-app/app/(receptionist)/appointments/[id].tsx fully.
  • Step 2: Add status action helper
This logic applies to both files — define it identically in each. Add at top of each file:
import { Alert } from 'react-native';
import Toast from 'react-native-toast-message';
Add inside the component (after load):
async function changeStatus(newStatus: string) {
  try {
    await papi.patch(`/appointments/${id}/status`, { status: newStatus });
    Toast.show({ type: 'success', text1: `Appointment ${newStatus.replace('_', ' ')}` });
    load(true);
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : 'Action failed';
    Toast.show({ type: 'error', text1: 'Could not update status', text2: msg });
  }
}
  • Step 3: Add action buttons to doctor appointment detail
Doctor role permissions (from rule engine): can set confirmed, in_progress, completed, no_show. In (doctor)/appointments/[id].tsx, after the notes/invoice section in ScrollView, add:
{data && (
  <View style={{ gap: Spacing.sm, marginTop: Spacing.sm }}>
    {data.status === 'scheduled' || data.status === 'requested' ? (
      <Pressable onPress={() => changeStatus('confirmed')} style={[s.actionBtn, { backgroundColor: '#10B981' }]}>
        <Text style={s.actionBtnText}>Confirm Appointment</Text>
      </Pressable>
    ) : null}
    {data.status === 'confirmed' ? (
      <Pressable onPress={() => changeStatus('in_progress')} style={[s.actionBtn, { backgroundColor: Colors.brand }]}>
        <Text style={s.actionBtnText}>Start Session</Text>
      </Pressable>
    ) : null}
    {data.status === 'in_progress' ? (
      <Pressable onPress={() => changeStatus('completed')} style={[s.actionBtn, { backgroundColor: '#10B981' }]}>
        <Text style={s.actionBtnText}>Complete</Text>
      </Pressable>
    ) : null}
    {['scheduled', 'confirmed', 'in_progress'].includes(data.status) ? (
      <Pressable onPress={() => changeStatus('no_show')} style={[s.actionBtn, { backgroundColor: '#F59E0B' }]}>
        <Text style={s.actionBtnText}>Mark No-Show</Text>
      </Pressable>
    ) : null}
  </View>
)}
Add to StyleSheet:
actionBtn: { borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
actionBtnText: { color: '#fff', fontSize: Typography.base, fontWeight: '700' },
  • Step 4: Add action buttons to receptionist appointment detail
Receptionist permissions: can set scheduled, confirmed, no_show, completed. In (receptionist)/appointments/[id].tsx, add the same changeStatus helper and these buttons:
{data && (
  <View style={{ gap: Spacing.sm, marginTop: Spacing.sm }}>
    {['requested', 'scheduled'].includes(data.status) ? (
      <Pressable onPress={() => changeStatus('confirmed')} style={[s.actionBtn, { backgroundColor: '#10B981' }]}>
        <Text style={s.actionBtnText}>Check In</Text>
      </Pressable>
    ) : null}
    {data.status === 'confirmed' ? (
      <Pressable onPress={() => changeStatus('completed')} style={[s.actionBtn, { backgroundColor: Colors.brand }]}>
        <Text style={s.actionBtnText}>Mark Complete</Text>
      </Pressable>
    ) : null}
    {['scheduled', 'confirmed'].includes(data.status) ? (
      <Pressable onPress={() => changeStatus('no_show')} style={[s.actionBtn, { backgroundColor: '#F59E0B' }]}>
        <Text style={s.actionBtnText}>No Show</Text>
      </Pressable>
    ) : null}
  </View>
)}
  • Step 5: Commit
git add "odontox-app/app/(doctor)/appointments/[id].tsx" \
  "odontox-app/app/(receptionist)/appointments/[id].tsx"
git commit -m "feat(mobile): appointment status actions for doctor (confirm/start/complete/no-show) and receptionist"

Task 11: Receptionist Home — Today’s Queue with Check-In

Files:
  • Modify: odontox-app/app/(receptionist)/index.tsx
  • Step 1: Read the current receptionist home screen
Read odontox-app/app/(receptionist)/index.tsx fully.
  • Step 2: Update the today’s appointment list to include check-in action
The receptionist home currently lists today’s appointments. Update the appointment row to include an inline “Check In” button for appointments with status scheduled or requested:
import { Alert } from 'react-native';
import Toast from 'react-native-toast-message';

// Add inside component:
async function checkIn(appointmentId: string) {
  try {
    await papi.patch(`/appointments/${appointmentId}/status`, { status: 'confirmed' });
    Toast.show({ type: 'success', text1: 'Patient checked in' });
    load(true);
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : 'Check-in failed';
    Toast.show({ type: 'error', text1: 'Check-in failed', text2: msg });
  }
}
In the appointment row render, add a check-in button:
// Inside each appointment row:
{['scheduled', 'requested'].includes(item.status) && (
  <Pressable
    onPress={() => checkIn(item.id)}
    style={({ pressed }) => [s.checkInBtn, pressed && { opacity: 0.7 }]}
  >
    <Text style={s.checkInText}>Check In</Text>
  </Pressable>
)}
Add to StyleSheet:
checkInBtn: {
  backgroundColor: '#10B981',
  borderRadius: 8,
  paddingHorizontal: 12,
  paddingVertical: 6,
},
checkInText: { color: '#fff', fontSize: Typography.xs, fontWeight: '700' },
Also ensure the appointment list is sorted by appointmentTime ascending. The API may already do this; if not, sort client-side:
// After setting state from API:
const sorted = (appointments ?? []).sort((a, b) => {
  if (!a.time) return 1;
  if (!b.time) return -1;
  return a.time.localeCompare(b.time);
});
setTodayAppointments(sorted);
  • Step 3: Commit
git add "odontox-app/app/(receptionist)/index.tsx"
git commit -m "feat(mobile): receptionist today queue sorted by time with inline check-in action"

Plan B — Done

At this point the app has:
  • ✅ Appointment booking for patients (request flow, rule engine slot grid)
  • ✅ Appointment scheduling for doctor/receptionist/admin (direct schedule, patient search with recent + frequency)
  • ✅ Past appointments shown for patients with cancel action + rule engine errors surfaced
  • ✅ PDF viewer (WebView on iOS, system viewer on Android)
  • ✅ Image viewer with pinch-zoom and share
  • ✅ Records drill-down: prescription detail, clinical note, file viewer
  • ✅ Bills PDF viewer inline
  • ✅ Real weekly chart data for doctor and admin
  • ✅ Appointment status actions for doctor and receptionist
  • ✅ Receptionist check-in queue sorted by time

Final E2E Test Checklist

[ ] Login as patient → book appointment → slot grid shows correct available times
[ ] Patient cancel appointment > 48h before → success
[ ] Patient cancel appointment < 48h before → error toast with server message
[ ] Patient tap prescription in records → detail screen shows medications
[ ] Patient tap invoice → PDF renders in-app on iOS
[ ] Doctor login → home shows real weekly chart data (not hardcoded)
[ ] Doctor → Appointments → New (+) → patient search → schedule → appears in list
[ ] Doctor appointment detail → Confirm / Start / Complete buttons appear by status
[ ] Receptionist home → today's queue sorted by time → Check In button works
[ ] Admin → home → real weekly appointment bar chart
[ ] Background app → return → mPIN/biometric screen appears
[ ] Receive push notification while app open → in-app toast appears
[ ] Tap notification → navigates to correct screen
[ ] Notifications tab shows notification list with unread dots
[ ] Settings → enable Face ID → toggle visible and works

Build for Device

cd odontox-app

# iOS (TestFlight)
eas build --platform ios --profile preview
eas submit --platform ios --latest

# Android (direct APK for testing)
eas build --platform android --profile preview
# Download APK from EAS dashboard and install directly on device