540 lines
17 KiB
TypeScript
540 lines
17 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 } 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 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("[AllSchedules] Failed to sync schedule to calendar", error);
|
|
showToast(
|
|
"Calendar error",
|
|
"We couldn't add this schedule to your calendar.",
|
|
"error"
|
|
);
|
|
}
|
|
};
|
|
|
|
export default function AllSchedulesScreen() {
|
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
const [selected, setSelected] = useState<Schedule | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState<"all" | ScheduleStatus>(
|
|
"all"
|
|
);
|
|
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(
|
|
"[AllSchedules] 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 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 filteredSchedules =
|
|
statusFilter === "all"
|
|
? schedules
|
|
: schedules.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">
|
|
All Schedules
|
|
</Text>
|
|
<Text className="text-sm font-dmsans text-gray-500 mb-4">
|
|
Full list of your recurring payment schedules.
|
|
</Text>
|
|
{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>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
<BottomSheet
|
|
visible={!!selected}
|
|
onClose={() => setSelected(null)}
|
|
maxHeightRatio={0.6}
|
|
>
|
|
{selected && (
|
|
<View className="w-full px-5 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>
|
|
);
|
|
}
|