638 lines
20 KiB
TypeScript
638 lines
20 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
|
import { View, ScrollView, TouchableOpacity, Platform } from "react-native";
|
|
import { Text } from "~/components/ui/text";
|
|
import ScreenWrapper from "~/components/ui/ScreenWrapper";
|
|
import BackButton from "~/components/ui/backButton";
|
|
import BottomSheet from "~/components/ui/bottomSheet";
|
|
import { Button } from "~/components/ui/button";
|
|
import { router } from "expo-router";
|
|
import { ROUTES } from "~/lib/routes";
|
|
import * as Calendar from "expo-calendar";
|
|
import ModalToast from "~/components/ui/toast";
|
|
import Skeleton from "~/components/ui/skeleton";
|
|
import { collection, doc, FieldValue } from "~/lib/firebase";
|
|
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
|
|
|
|
type ScheduleStatus = "active" | "paused" | "stopped";
|
|
|
|
type Schedule = {
|
|
id: string;
|
|
recipientName: string;
|
|
label: string;
|
|
amount: string;
|
|
currency: string;
|
|
nextRunDate: string;
|
|
nextRunLabel: string;
|
|
frequency: string;
|
|
status: ScheduleStatus;
|
|
};
|
|
|
|
const toISODate = (date: Date) => date.toISOString().split("T")[0];
|
|
|
|
const TODAY = new Date();
|
|
|
|
const addDays = (date: Date, days: number) => {
|
|
const d = new Date(date);
|
|
d.setDate(date.getDate() + days);
|
|
return d;
|
|
};
|
|
|
|
const START_OF_WEEK = new Date(TODAY);
|
|
START_OF_WEEK.setDate(TODAY.getDate() - TODAY.getDay());
|
|
|
|
const WEEK_DAYS = Array.from({ length: 7 }).map((_, index) => {
|
|
const day = new Date(START_OF_WEEK);
|
|
day.setDate(START_OF_WEEK.getDate() + index);
|
|
const labels = ["S", "M", "T", "W", "T", "F", "S"];
|
|
return {
|
|
key: toISODate(day),
|
|
label: labels[day.getDay()],
|
|
dateNumber: day.getDate(),
|
|
isToday: day.toDateString() === TODAY.toDateString(),
|
|
};
|
|
});
|
|
|
|
const MOCK_SCHEDULES: Schedule[] = [
|
|
{
|
|
id: "sch-1",
|
|
recipientName: "Abebe Kebede",
|
|
label: "Monthly support",
|
|
amount: "4,500",
|
|
currency: "ETB",
|
|
nextRunDate: toISODate(TODAY),
|
|
nextRunLabel: "Today · 4:00 PM",
|
|
frequency: "Every month",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: "sch-2",
|
|
recipientName: "Sara Alemu",
|
|
label: "Weekly allowance",
|
|
amount: "1,000",
|
|
currency: "ETB",
|
|
nextRunDate: toISODate(addDays(TODAY, 1)),
|
|
nextRunLabel: "Tomorrow · 9:00 AM",
|
|
frequency: "Every week",
|
|
status: "paused",
|
|
},
|
|
{
|
|
id: "sch-3",
|
|
recipientName: "Hope Community Fund",
|
|
label: "Quarterly disbursement",
|
|
amount: "32,000",
|
|
currency: "ETB",
|
|
nextRunDate: toISODate(addDays(TODAY, 2)),
|
|
nextRunLabel: "In 2 days · 2:15 PM",
|
|
frequency: "Every 3 months",
|
|
status: "stopped",
|
|
},
|
|
];
|
|
|
|
const getStatusPillClasses = (status: ScheduleStatus) => {
|
|
switch (status) {
|
|
case "active":
|
|
return "bg-emerald-100 text-emerald-700";
|
|
case "paused":
|
|
return "bg-yellow-100 text-yellow-700";
|
|
case "stopped":
|
|
default:
|
|
return "bg-gray-100 text-gray-600";
|
|
}
|
|
};
|
|
|
|
const isSameDay = (a: Date, b: Date) =>
|
|
a.getFullYear() === b.getFullYear() &&
|
|
a.getMonth() === b.getMonth() &&
|
|
a.getDate() === b.getDate();
|
|
|
|
const buildFrequencyLabel = (
|
|
repeatType?: string,
|
|
interval?: number
|
|
): string => {
|
|
if (!repeatType || repeatType === "none") return "One-time";
|
|
if (repeatType === "every_x_days" && interval) {
|
|
return `Every ${interval} days`;
|
|
}
|
|
return repeatType;
|
|
};
|
|
|
|
const buildNextRunLabel = (dateValue?: any, time?: string): string => {
|
|
if (!dateValue && !time) return "";
|
|
|
|
const today = TODAY;
|
|
const tomorrow = addDays(TODAY, 1);
|
|
|
|
let dateObj: Date | null = null;
|
|
|
|
if (dateValue instanceof Date) {
|
|
dateObj = dateValue;
|
|
} else if (typeof dateValue === "string") {
|
|
const parsed = new Date(dateValue);
|
|
if (!isNaN(parsed.getTime())) {
|
|
dateObj = parsed;
|
|
}
|
|
}
|
|
|
|
let dayLabel = "";
|
|
if (dateObj) {
|
|
if (isSameDay(dateObj, today)) dayLabel = "Today";
|
|
else if (isSameDay(dateObj, tomorrow)) dayLabel = "Tomorrow";
|
|
else {
|
|
dayLabel = dateObj.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (dayLabel && time) return `${dayLabel} · ${time}`;
|
|
return dayLabel || time || "";
|
|
};
|
|
|
|
const parseTimeFromLabel = (label: string) => {
|
|
const parts = label.split("·");
|
|
const rawTime = parts[1]?.trim();
|
|
if (!rawTime) {
|
|
return { hours: 9, minutes: 0 };
|
|
}
|
|
|
|
const match = rawTime.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
|
if (!match) {
|
|
return { hours: 9, minutes: 0 };
|
|
}
|
|
|
|
let hours = parseInt(match[1], 10);
|
|
const minutes = parseInt(match[2], 10);
|
|
const period = match[3].toUpperCase();
|
|
|
|
if (period === "PM" && hours < 12) hours += 12;
|
|
if (period === "AM" && hours === 12) hours = 0;
|
|
|
|
return { hours, minutes };
|
|
};
|
|
|
|
const getDateTimesForSchedule = (schedule: Schedule) => {
|
|
const [year, month, day] = schedule.nextRunDate
|
|
.split("-")
|
|
.map((v) => parseInt(v, 10));
|
|
const { hours, minutes } = parseTimeFromLabel(schedule.nextRunLabel);
|
|
|
|
const startDate = new Date(year, month - 1, day, hours, minutes);
|
|
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000);
|
|
|
|
return { startDate, endDate };
|
|
};
|
|
|
|
const syncScheduleToCalendar = async (
|
|
schedule: Schedule,
|
|
showToast: (
|
|
title: string,
|
|
description?: string,
|
|
variant?: "success" | "error" | "warning" | "info"
|
|
) => void
|
|
) => {
|
|
try {
|
|
const { status } = await Calendar.requestCalendarPermissionsAsync();
|
|
if (status !== "granted") {
|
|
showToast(
|
|
"Calendar permission needed",
|
|
"Allow calendar access to sync schedules.",
|
|
"warning"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let calendarId: string | null = null;
|
|
|
|
if (Platform.OS === "ios") {
|
|
const defaultCal = await Calendar.getDefaultCalendarAsync();
|
|
calendarId = defaultCal?.id ?? null;
|
|
} else {
|
|
const calendars = await Calendar.getCalendarsAsync(
|
|
Calendar.EntityTypes.EVENT
|
|
);
|
|
const editable = calendars.find(
|
|
(c: Calendar.Calendar) => c.allowsModifications
|
|
);
|
|
calendarId = editable?.id ?? calendars[0]?.id ?? null;
|
|
}
|
|
|
|
if (!calendarId) {
|
|
showToast(
|
|
"No calendar found",
|
|
"We couldn't find an editable calendar on this device.",
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { startDate, endDate } = getDateTimesForSchedule(schedule);
|
|
|
|
await Calendar.createEventAsync(calendarId, {
|
|
title: schedule.label || "Scheduled payment",
|
|
notes: `${schedule.recipientName} • ${schedule.currency} ${schedule.amount}`,
|
|
startDate,
|
|
endDate,
|
|
timeZone: undefined,
|
|
});
|
|
showToast(
|
|
"Added to calendar",
|
|
"This schedule was added as an event.",
|
|
"success"
|
|
);
|
|
} catch (error) {
|
|
console.warn("[Schedules] Failed to sync schedule to calendar", error);
|
|
showToast(
|
|
"Calendar error",
|
|
"We couldn't add this schedule to your calendar.",
|
|
"error"
|
|
);
|
|
}
|
|
};
|
|
|
|
export default function SchedulesScreen() {
|
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
const [selected, setSelected] = useState<Schedule | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState<"all" | ScheduleStatus>(
|
|
"all"
|
|
);
|
|
const [selectedDateKey, setSelectedDateKey] = useState<string>(
|
|
WEEK_DAYS.find((d) => d.isToday)?.key || WEEK_DAYS[0].key
|
|
);
|
|
const [loading, setLoading] = useState(true);
|
|
const [toastVisible, setToastVisible] = useState(false);
|
|
const [toastTitle, setToastTitle] = useState("");
|
|
const [toastDescription, setToastDescription] = useState<string | undefined>(
|
|
undefined
|
|
);
|
|
const [toastVariant, setToastVariant] = useState<
|
|
"success" | "error" | "warning" | "info"
|
|
>("info");
|
|
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const { user } = useAuthWithProfile();
|
|
|
|
useEffect(() => {
|
|
const loadSchedules = async () => {
|
|
if (!user?.uid) {
|
|
setSchedules([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
const appointmentsCol: any = collection("appointments");
|
|
const snap = await appointmentsCol
|
|
.where("agentId", "==", user.uid)
|
|
.where("acceptance", "==", "accepted")
|
|
.get();
|
|
|
|
const loaded: Schedule[] = snap.docs.map((docSnap: any) => {
|
|
const data = docSnap.data() || {};
|
|
|
|
const nextRunSource =
|
|
data.nextRunDate ||
|
|
(data.date && data.time
|
|
? `${data.date}T${data.time}:00`
|
|
: undefined);
|
|
|
|
const nextRun = nextRunSource ? new Date(nextRunSource) : TODAY;
|
|
const nextRunDateKey = toISODate(nextRun);
|
|
const nextRunLabel = buildNextRunLabel(nextRun, data.time);
|
|
|
|
const frequency = buildFrequencyLabel(data.repeatType, data.interval);
|
|
|
|
const status: ScheduleStatus =
|
|
data.scheduleStatus === "paused" ||
|
|
data.scheduleStatus === "stopped"
|
|
? data.scheduleStatus
|
|
: "active";
|
|
|
|
return {
|
|
id: String(docSnap.id),
|
|
recipientName:
|
|
data.fullName || data.email || data.phoneNumber || "Unknown",
|
|
label: data.notes || "",
|
|
amount:
|
|
typeof data.amount === "number"
|
|
? data.amount.toString()
|
|
: String(data.amount ?? ""),
|
|
currency: "ETB",
|
|
nextRunDate: nextRunDateKey,
|
|
nextRunLabel,
|
|
frequency,
|
|
status,
|
|
};
|
|
});
|
|
|
|
setSchedules(loaded);
|
|
} catch (error) {
|
|
console.error(
|
|
"[Schedules] Failed to load appointments as schedules",
|
|
error
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadSchedules();
|
|
}, [user]);
|
|
|
|
const showToast = (
|
|
title: string,
|
|
description?: string,
|
|
variant: "success" | "error" | "warning" | "info" = "info"
|
|
) => {
|
|
if (toastTimeoutRef.current) {
|
|
clearTimeout(toastTimeoutRef.current);
|
|
}
|
|
|
|
setToastTitle(title);
|
|
setToastDescription(description);
|
|
setToastVariant(variant);
|
|
setToastVisible(true);
|
|
|
|
toastTimeoutRef.current = setTimeout(() => {
|
|
setToastVisible(false);
|
|
toastTimeoutRef.current = null;
|
|
}, 2500);
|
|
};
|
|
|
|
const handleUpdateStatus = async (id: string, next: ScheduleStatus) => {
|
|
try {
|
|
const ref = doc("appointments", id);
|
|
await ref.update({
|
|
scheduleStatus: next,
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
|
|
setSchedules((prev) =>
|
|
prev.map((sch) => (sch.id === id ? { ...sch, status: next } : sch))
|
|
);
|
|
setSelected((prev) =>
|
|
prev && prev.id === id ? { ...prev, status: next } : prev
|
|
);
|
|
} catch (error) {
|
|
console.error("[Schedules] Failed to update scheduleStatus", id, error);
|
|
}
|
|
};
|
|
|
|
const activeCount = schedules.filter((s) => s.status === "active").length;
|
|
const pausedCount = schedules.filter((s) => s.status === "paused").length;
|
|
const stoppedCount = schedules.filter((s) => s.status === "stopped").length;
|
|
|
|
const dateFiltered = schedules.filter(
|
|
(s) => s.nextRunDate === selectedDateKey
|
|
);
|
|
|
|
const filteredSchedules =
|
|
statusFilter === "all"
|
|
? dateFiltered
|
|
: dateFiltered.filter((s) => s.status === statusFilter);
|
|
|
|
return (
|
|
<ScreenWrapper edges={[]}>
|
|
<BackButton />
|
|
<View className="flex-1 bg-white">
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ paddingBottom: 32 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View className="px-5 pt-4">
|
|
<Text className="text-lg font-dmsans-bold text-primary mb-1">
|
|
Schedules
|
|
</Text>
|
|
<Text className="text-sm font-dmsans text-gray-500 mb-4">
|
|
View and manage your recurring payment schedules.
|
|
</Text>
|
|
|
|
{/* Week day selector */}
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
className="mb-4 -mx-1"
|
|
contentContainerStyle={{ paddingHorizontal: 4 }}
|
|
>
|
|
{WEEK_DAYS.map((day) => {
|
|
const isSelected = day.key === selectedDateKey;
|
|
const hasSchedulesForDay = schedules.some(
|
|
(s) => s.nextRunDate === day.key
|
|
);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={day.key}
|
|
activeOpacity={0.8}
|
|
className={`mx-1 px-3 py-2 rounded-2xl border items-center justify-center min-w-[44px] ${
|
|
isSelected
|
|
? "bg-primary/10 border-primary"
|
|
: "bg-white border-gray-200"
|
|
}`}
|
|
onPress={() => setSelectedDateKey(day.key)}
|
|
>
|
|
<Text
|
|
className={`text-[11px] font-dmsans-medium mb-0.5 ${
|
|
isSelected ? "text-primary" : "text-gray-600"
|
|
}`}
|
|
>
|
|
{day.label}
|
|
</Text>
|
|
<Text
|
|
className={`text-[11px] font-dmsans ${
|
|
isSelected ? "text-primary" : "text-gray-800"
|
|
}`}
|
|
>
|
|
{day.dateNumber}
|
|
</Text>
|
|
<View className="mt-1 h-1.5 items-center justify-center">
|
|
{hasSchedulesForDay && (
|
|
<View className="w-1.5 h-1.5 rounded-full bg-primary" />
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
{loading && (
|
|
<View className="mb-6">
|
|
{[1, 2, 3].map((i) => (
|
|
<View key={i} className="py-2">
|
|
<Skeleton width="100%" height={120} radius={24} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{!loading && filteredSchedules.length === 0 && (
|
|
<View className="items-center justify-center py-10">
|
|
<Text className="text-sm font-dmsans text-gray-400">
|
|
No schedules match your filters.
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{!loading && filteredSchedules.length > 0 && (
|
|
<View className="space-y-4 mb-6">
|
|
{filteredSchedules.map((sch) => {
|
|
return (
|
|
<View
|
|
key={sch.id}
|
|
className="bg-white rounded-3xl mb-2 border border-gray-100"
|
|
style={{
|
|
shadowColor: "#000",
|
|
shadowOpacity: 0.02,
|
|
shadowRadius: 40,
|
|
shadowOffset: { width: 0, height: 8 },
|
|
elevation: 2,
|
|
}}
|
|
>
|
|
<View className="px-4 py-4">
|
|
<View className="flex-row items-center justify-between mb-1.5">
|
|
<View className="flex-1 mr-2">
|
|
<Text className="text-base font-dmsans-bold text-gray-900">
|
|
{sch.recipientName}
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans text-gray-500">
|
|
{sch.label}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="flex-row items-center justify-between mt-2 mb-2">
|
|
<View>
|
|
<Text className="text-[11px] font-dmsans text-gray-500">
|
|
Next run
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans-medium text-gray-900">
|
|
{sch.nextRunLabel}
|
|
</Text>
|
|
</View>
|
|
<View className="items-end">
|
|
<Text className="text-[11px] font-dmsans text-gray-500">
|
|
Amount
|
|
</Text>
|
|
<Text className="text-sm font-dmsans-bold text-gray-900">
|
|
{sch.currency} {sch.amount}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="flex-row items-center justify-between mt-1">
|
|
<View>
|
|
<Text className="text-[11px] font-dmsans text-gray-500">
|
|
Frequency
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans-medium text-gray-900">
|
|
{sch.frequency}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row gap-2">
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
className="px-3 py-1.5 rounded-2xl border border-gray-200 bg-white"
|
|
onPress={() => setSelected(sch)}
|
|
>
|
|
<Text className="text-[11px] font-dmsans-medium text-gray-800">
|
|
View details
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
|
|
<Button
|
|
className="mt-1 mb-6 bg-white border border-gray-200 rounded-2xl h-11"
|
|
onPress={() => router.push(ROUTES.SCHEDULES_ALL)}
|
|
>
|
|
<Text className="text-sm font-dmsans-medium text-gray-800">
|
|
View all schedules
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
<BottomSheet
|
|
visible={!!selected}
|
|
onClose={() => setSelected(null)}
|
|
maxHeightRatio={0.45}
|
|
>
|
|
{selected && (
|
|
<View className="w-full pt-4 pb-6">
|
|
<Text className="text-base font-dmsans-bold text-primary mb-2 text-center">
|
|
Schedule Details
|
|
</Text>
|
|
<Text className="text-lg font-dmsans-bold text-gray-900 text-center mb-1">
|
|
{selected.recipientName}
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans text-gray-500 text-center mb-3">
|
|
{selected.label}
|
|
</Text>
|
|
|
|
<View className="space-y-2 mb-5">
|
|
<View className="flex-row justify-between">
|
|
<Text className="text-[12px] font-dmsans text-gray-500">
|
|
Next run
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans-medium text-gray-900">
|
|
{selected.nextRunLabel}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row justify-between">
|
|
<Text className="text-[12px] font-dmsans text-gray-500">
|
|
Amount
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans-medium text-gray-900">
|
|
{selected.currency} {selected.amount}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row justify-between">
|
|
<Text className="text-[12px] font-dmsans text-gray-500">
|
|
Frequency
|
|
</Text>
|
|
<Text className="text-[12px] font-dmsans-medium text-gray-900">
|
|
{selected.frequency}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="flex-row gap-3 mt-2">
|
|
<TouchableOpacity
|
|
activeOpacity={0.8}
|
|
className="flex-1 bg-primary rounded-2xl py-3 items-center justify-center"
|
|
onPress={() => router.push(ROUTES.SEND_OR_REQUEST_MONEY)}
|
|
>
|
|
<Text className="text-sm font-dmsans-medium text-white">
|
|
Pay now
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<Button
|
|
className="mt-4 bg-primary/10 border border-primary/30 rounded-2xl h-11"
|
|
onPress={() => syncScheduleToCalendar(selected, showToast)}
|
|
>
|
|
<Text className="text-sm font-dmsans-medium text-primary">
|
|
Sync this schedule to calendar
|
|
</Text>
|
|
</Button>
|
|
</View>
|
|
)}
|
|
</BottomSheet>
|
|
<ModalToast
|
|
visible={toastVisible}
|
|
title={toastTitle}
|
|
description={toastDescription}
|
|
variant={toastVariant}
|
|
/>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|