Amba-Agent-App/app/(root)/(screens)/schedulesall.tsx
2026-01-16 00:22:35 +03:00

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>
);
}