Replace the profile tab with prescription management (inline reminders and family invite), add a shared tab top bar, separate notifications from orders, and support any-branch guest checkout with GSH codes. Co-authored-by: Cursor <cursoragent@cursor.com>
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
||
import { router, useLocalSearchParams } from "expo-router";
|
||
import { useMemo, useState } from "react";
|
||
import { Alert, Image, ScrollView, View } from "react-native";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { Card, InfoRow, Section } from "@/components/ui/Card";
|
||
import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
|
||
import { AppHeader } from "@/components/ui/Header";
|
||
import { Screen } from "@/components/ui/Screen";
|
||
import { Text } from "@/components/ui/Text";
|
||
import { products } from "@/lib/data/products";
|
||
import { scheduleDoseReminder } from "@/lib/notifications";
|
||
import { useCartStore } from "@/lib/stores/cartStore";
|
||
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||
import { colors, gutter } from "@/lib/theme";
|
||
import { routes } from "@/lib/routes";
|
||
|
||
const statusTone = {
|
||
submitted: "neutral",
|
||
under_review: "warn",
|
||
approved: "brand",
|
||
queried: "info",
|
||
rejected: "danger",
|
||
filled: "brand",
|
||
} as const;
|
||
|
||
const lineTone = {
|
||
ready: "brand",
|
||
review: "warn",
|
||
unavailable: "danger",
|
||
} as const;
|
||
|
||
export default function PrescriptionDetailScreen() {
|
||
const params = useLocalSearchParams<{ id: string }>();
|
||
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
||
const allReminders = usePrescriptionStore((s) => s.reminders);
|
||
const doseLogs = usePrescriptionStore((s) => s.doseLogs);
|
||
const reminders = useMemo(
|
||
() => allReminders.filter((r) => r.prescriptionId === id),
|
||
[allReminders, id]
|
||
);
|
||
const reminder = reminders[0];
|
||
const reminderId = `rem_${id}`;
|
||
const recentLogs = useMemo(
|
||
() => doseLogs.filter((l) => l.reminderId === reminderId).slice(0, 5),
|
||
[doseLogs, reminderId]
|
||
);
|
||
const markRefilled = usePrescriptionStore((s) => s.markRefilled);
|
||
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
|
||
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
|
||
const addProduct = useCartStore((s) => s.addProduct);
|
||
const pushNotification = useNotificationStore((s) => s.push);
|
||
const [showSetup, setShowSetup] = useState(!reminder?.enabled);
|
||
|
||
if (!rx) {
|
||
return (
|
||
<Screen>
|
||
<AppHeader title="Prescription" />
|
||
<View style={{ padding: gutter }}>
|
||
<Text variant="body" color={colors.muted}>
|
||
Prescription not found.
|
||
</Text>
|
||
</View>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
const dueIn = (() => {
|
||
if (!rx.lastFilledAt) return null;
|
||
const due = new Date(rx.lastFilledAt);
|
||
due.setDate(due.getDate() + rx.daysSupply);
|
||
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||
})();
|
||
|
||
const pendingLog = recentLogs.find((l) => l.status === "pending");
|
||
|
||
return (
|
||
<Screen>
|
||
<AppHeader
|
||
title="Prescription"
|
||
subtitle={rx.patientName}
|
||
right={<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />}
|
||
/>
|
||
<ScrollView
|
||
contentContainerStyle={{
|
||
paddingHorizontal: gutter,
|
||
paddingBottom: 40,
|
||
gap: 14,
|
||
}}
|
||
>
|
||
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
|
||
<View
|
||
style={{
|
||
height: 160,
|
||
backgroundColor: colors.brandSoft,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
{rx.imageUris[0] ? (
|
||
<Image
|
||
source={{ uri: rx.imageUris[0] }}
|
||
style={{ width: "100%", height: "100%" }}
|
||
resizeMode="cover"
|
||
/>
|
||
) : (
|
||
<Ionicons name="document-text" size={48} color={colors.brandText} />
|
||
)}
|
||
</View>
|
||
<View style={{ padding: 16, gap: 10 }}>
|
||
<Text variant="h1">{rx.medicineName}</Text>
|
||
<Text variant="bodySm" color={colors.muted}>
|
||
For {rx.patientName} · {rx.medicines.length} item
|
||
{rx.medicines.length === 1 ? "" : "s"} on this Rx
|
||
</Text>
|
||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||
<Badge
|
||
label={`${rx.daysSupply} day supply`}
|
||
tone="neutral"
|
||
icon="time-outline"
|
||
/>
|
||
{dueIn !== null ? (
|
||
<Badge
|
||
label={dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
||
tone={dueIn <= 3 ? "warn" : "brand"}
|
||
/>
|
||
) : (
|
||
<Badge label="Awaiting first fill" tone="warn" />
|
||
)}
|
||
{reminder?.enabled ? (
|
||
<Badge
|
||
label={reminder.times.join(" · ")}
|
||
tone="brand"
|
||
icon="alarm-outline"
|
||
/>
|
||
) : (
|
||
<Badge label="Reminders off" tone="neutral" icon="alarm-outline" />
|
||
)}
|
||
</View>
|
||
</View>
|
||
</Card>
|
||
|
||
<Section title="Dose calendar & push">
|
||
<Card>
|
||
{reminder?.enabled && !showSetup ? (
|
||
<View style={{ gap: 12 }}>
|
||
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
||
<View
|
||
style={{
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 14,
|
||
backgroundColor: colors.brandSoft,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<Ionicons name="notifications" size={20} color={colors.brandText} />
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text variant="h3">Daily reminders on</Text>
|
||
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
|
||
Push at {reminder.times.join(" · ")}
|
||
{reminder.escalateOnMiss ? " · escalate if missed" : ""}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||
<Button
|
||
title="Edit times"
|
||
size="md"
|
||
fullWidth={false}
|
||
variant="outline"
|
||
onPress={() => setShowSetup(true)}
|
||
/>
|
||
<Button
|
||
title="Open calendar"
|
||
size="md"
|
||
fullWidth={false}
|
||
icon="calendar-outline"
|
||
onPress={() => router.push(routes.rxCalendar(rx.id))}
|
||
/>
|
||
<Button
|
||
title="Test push now"
|
||
size="md"
|
||
fullWidth={false}
|
||
variant="soft"
|
||
icon="flash-outline"
|
||
onPress={async () => {
|
||
await scheduleDoseReminder({
|
||
title: `Time for ${rx.medicineName}`,
|
||
body: "Test dose reminder from Gishen",
|
||
secondsFromNow: 5,
|
||
data: { prescriptionId: rx.id, type: "dose" },
|
||
});
|
||
pushNotification({
|
||
kind: "dose",
|
||
title: "Test dose reminder",
|
||
body: `${rx.medicineName} push scheduled in ~5s`,
|
||
href: routes.rxDetail(rx.id),
|
||
});
|
||
Alert.alert("Scheduled", "Test push in about 5 seconds.");
|
||
}}
|
||
/>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<DoseReminderPanel
|
||
rx={rx}
|
||
reminder={reminder}
|
||
onScheduled={() => setShowSetup(false)}
|
||
/>
|
||
)}
|
||
</Card>
|
||
|
||
{pendingLog ? (
|
||
<Card tone="brand" elevated={false} style={{ marginTop: 10 }}>
|
||
<Text variant="h3">Confirm today’s dose</Text>
|
||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||
A dose is pending confirmation.
|
||
</Text>
|
||
<View style={{ flexDirection: "row", gap: 10, marginTop: 12 }}>
|
||
<Button
|
||
title="Taken"
|
||
style={{ flex: 1 }}
|
||
onPress={() => {
|
||
confirmDose(pendingLog.id);
|
||
Alert.alert("Logged", "Dose marked as taken.");
|
||
}}
|
||
/>
|
||
<Button
|
||
title="Snooze 10m"
|
||
variant="outline"
|
||
style={{ flex: 1 }}
|
||
onPress={() =>
|
||
scheduleDoseReminder({
|
||
title: `Snoozed: ${rx.medicineName}`,
|
||
body: "Take your dose when ready",
|
||
secondsFromNow: 600,
|
||
data: { prescriptionId: rx.id, type: "dose" },
|
||
})
|
||
}
|
||
/>
|
||
</View>
|
||
</Card>
|
||
) : null}
|
||
|
||
{recentLogs.length ? (
|
||
<View style={{ marginTop: 12, gap: 8 }}>
|
||
<Text variant="label" color={colors.muted}>
|
||
Recent dose logs
|
||
</Text>
|
||
{recentLogs.map((l) => (
|
||
<View
|
||
key={l.id}
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 10,
|
||
paddingVertical: 8,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.line,
|
||
}}
|
||
>
|
||
<Badge
|
||
label={l.status.replace("_", " ")}
|
||
tone={
|
||
l.status === "taken"
|
||
? "brand"
|
||
: l.status === "missed_escalated"
|
||
? "danger"
|
||
: "warn"
|
||
}
|
||
/>
|
||
<Text variant="caption" color={colors.subtle} style={{ flex: 1 }}>
|
||
{new Date(l.scheduledAt).toLocaleString()}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
</Section>
|
||
|
||
<Section title="Medicines on this Rx">
|
||
<Card elevated={false} padded={false}>
|
||
{rx.medicines.map((m, i) => (
|
||
<View
|
||
key={m.name}
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
|
||
borderBottomColor: colors.line,
|
||
}}
|
||
>
|
||
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
|
||
<View
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 12,
|
||
backgroundColor: colors.brandSoft,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<Ionicons name="medkit-outline" size={16} color={colors.brandText} />
|
||
</View>
|
||
<Text variant="h3" style={{ flex: 1 }}>
|
||
{m.name}
|
||
</Text>
|
||
</View>
|
||
<Badge label={m.status} tone={lineTone[m.status]} />
|
||
</View>
|
||
))}
|
||
</Card>
|
||
</Section>
|
||
|
||
<Section title="Details">
|
||
<Card elevated={false}>
|
||
<InfoRow label="Days supply" value={`${rx.daysSupply} days`} />
|
||
<InfoRow
|
||
label="Last filled"
|
||
value={
|
||
rx.lastFilledAt
|
||
? new Date(rx.lastFilledAt).toLocaleDateString()
|
||
: "Not filled yet"
|
||
}
|
||
/>
|
||
<InfoRow
|
||
label="Submitted"
|
||
value={new Date(rx.createdAt).toLocaleDateString()}
|
||
last={!rx.notes}
|
||
/>
|
||
{rx.notes ? <InfoRow label="Notes" value={rx.notes} last /> : null}
|
||
</Card>
|
||
</Section>
|
||
|
||
<View style={{ gap: 10 }}>
|
||
<Button
|
||
title="Reorder medicines"
|
||
icon="bag-add-outline"
|
||
onPress={() => {
|
||
const match = products.find((p) =>
|
||
rx.medicineName.toLowerCase().includes(p.name.toLowerCase().split(" ")[0])
|
||
);
|
||
if (match) addProduct(match);
|
||
router.push(routes.cart);
|
||
}}
|
||
/>
|
||
<Button
|
||
title="Mark as refilled"
|
||
variant="outline"
|
||
onPress={() => {
|
||
markRefilled(rx.id);
|
||
Alert.alert("Updated", "Marked as refilled today.");
|
||
}}
|
||
/>
|
||
{rx.status === "under_review" || rx.status === "submitted" ? (
|
||
<Button
|
||
title="Simulate pharmacist approval"
|
||
variant="soft"
|
||
onPress={() => updateRxStatus(rx.id, "approved")}
|
||
/>
|
||
) : null}
|
||
</View>
|
||
</ScrollView>
|
||
</Screen>
|
||
);
|
||
}
|