feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI
This commit is contained in:
@@ -77,6 +77,7 @@ const HomePage = safeLazy(() => import("@/pages/Home"));
|
||||
const DashboardPage = safeLazy(() => import("@/pages/DashboardPage"));
|
||||
const CalendarPage = safeLazy(() => import("@/pages/CalendarPage"));
|
||||
const GanttPage = safeLazy(() => import("@/pages/GanttPage"));
|
||||
const MedSchedulePage = safeLazy(() => import("@/pages/medschedule"));
|
||||
const ShareTarget = safeLazy(() => import("@/pages/ShareTarget"));
|
||||
const QrHandler = safeLazy(() => import("@/pages/QrHandler"));
|
||||
const NotFound = safeLazy(() => import("@/pages/not-found"));
|
||||
@@ -127,6 +128,8 @@ function Router() {
|
||||
<Route path="/dashboard" component={DashboardPage} />
|
||||
<Route path="/calendar" component={CalendarPage} />
|
||||
<Route path="/gantt" component={GanttPage} />
|
||||
<Route path="/med" component={MedSchedulePage} />
|
||||
<Route path="/med/:tab" component={MedSchedulePage} />
|
||||
<Route path="/billing" component={Billing} />
|
||||
<Route path="/finance" component={FinanceDashboard} />
|
||||
<Route path="/income" component={IncomeOverview} />
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
PiggyBank,
|
||||
LayoutDashboard,
|
||||
ChartGantt,
|
||||
HeartPulse,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
@@ -1202,6 +1203,46 @@ function GanttNavItem({ collapsed }: { collapsed: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MedScheduleNavItem({ collapsed }: { collapsed: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const [location] = useLocation();
|
||||
const isActive = location === '/med' || location.startsWith('/med/');
|
||||
|
||||
if (user?.organization?.slug !== 'medschedule') return null;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href="/med">
|
||||
<div className={cn(
|
||||
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
|
||||
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
)}>
|
||||
<HeartPulse className="w-4 h-4 shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="text-xs">
|
||||
MedSchedule
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="/med">
|
||||
<div className={cn(
|
||||
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
|
||||
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
)}>
|
||||
<HeartPulse className="w-4 h-4 shrink-0" />
|
||||
<span className="truncate">MedSchedule</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeNavItem({ collapsed }: { collapsed: boolean }) {
|
||||
const [location] = useLocation();
|
||||
const isActive = location === '/home';
|
||||
@@ -1385,6 +1426,7 @@ export function Sidebar({ children }: SidebarProps) {
|
||||
<DashboardNavItem collapsed={collapsed} />
|
||||
<CalendarNavItem collapsed={collapsed} />
|
||||
<GanttNavItem collapsed={collapsed} />
|
||||
<MedScheduleNavItem collapsed={collapsed} />
|
||||
<UsersNavItem collapsed={collapsed} />
|
||||
<FinanceNavItem collapsed={collapsed} />
|
||||
</div>
|
||||
|
||||
302
client/src/pages/medschedule/index.tsx
Normal file
302
client/src/pages/medschedule/index.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Users, Pill, ClipboardList, Stethoscope, MessageCircle } from "lucide-react";
|
||||
import Layout from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
const TABS = [
|
||||
{ value: "schedule", label: "Расписание", icon: Pill },
|
||||
{ value: "family", label: "Семья", icon: Users },
|
||||
{ value: "cabinet", label: "Аптечка", icon: ClipboardList },
|
||||
{ value: "records", label: "Обследования", icon: Stethoscope },
|
||||
{ value: "checkins", label: "Чек-ины", icon: MessageCircle },
|
||||
];
|
||||
|
||||
export default function MedSchedulePage() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const match = location.match(/\/med\/(.*)/);
|
||||
const activeTab = match?.[1] || "schedule";
|
||||
|
||||
const { data: familyData } = useQuery<any>({
|
||||
queryKey: ["/api/med/family"],
|
||||
retry: false,
|
||||
});
|
||||
const families: any[] = familyData?.groups || [];
|
||||
const family = families[0];
|
||||
|
||||
const onTabChange = (value: string) => setLocation(`/med/${value}`);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto p-4 max-w-5xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">MedSchedule</h1>
|
||||
{family && <Badge variant="outline">{family.name}</Badge>}
|
||||
</div>
|
||||
|
||||
{!family && activeTab !== "family" ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">Сначала создайте или вступите в семейную группу.</p>
|
||||
<Button onClick={() => setLocation("/med/family")}>Перейти к семье</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid grid-cols-5 mb-4">
|
||||
{TABS.map((t) => (
|
||||
<TabsTrigger key={t.value} value={t.value} className="gap-1">
|
||||
<t.icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="schedule">
|
||||
<ScheduleTab family={family} />
|
||||
</TabsContent>
|
||||
<TabsContent value="family">
|
||||
<FamilyTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="cabinet">
|
||||
<CabinetTab family={family} />
|
||||
</TabsContent>
|
||||
<TabsContent value="records">
|
||||
<RecordsTab family={family} />
|
||||
</TabsContent>
|
||||
<TabsContent value="checkins">
|
||||
<CheckinsTab family={family} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleTab({ family }: { family?: any }) {
|
||||
const [range, setRange] = useState<{ from: string; to: string }>(() => {
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
||||
const to = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString();
|
||||
return { from, to };
|
||||
});
|
||||
|
||||
const { data } = useQuery<any>({
|
||||
queryKey: ["/api/med/calendar", family?.id, range.from, range.to],
|
||||
enabled: !!family,
|
||||
});
|
||||
const events = data?.events || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Расписание приёма лекарств</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-muted-foreground">Нет запланированных приёмов. Добавьте лекарство через бота: /add название доза время</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{events.map((e: any) => (
|
||||
<div key={e.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{e.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{e.memberName} — {new Date(e.scheduledAt).toLocaleString("ru-RU")}</div>
|
||||
</div>
|
||||
<Badge variant={e.status === "taken" ? "default" : "secondary"}>{e.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [newFamilyName, setNewFamilyName] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [memberName, setMemberName] = useState("");
|
||||
|
||||
const { data: familyData, refetch } = useQuery<any>({ queryKey: ["/api/med/family"] });
|
||||
const families: any[] = familyData?.groups || [];
|
||||
const family = families[0];
|
||||
|
||||
const createFamily = useMutation({
|
||||
mutationFn: (name: string) => apiRequest("POST", "/api/med/family", { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/med/family"] });
|
||||
setNewFamilyName("");
|
||||
toast({ title: "Семья создана" });
|
||||
},
|
||||
});
|
||||
|
||||
const joinFamily = useMutation({
|
||||
mutationFn: (code: string) => apiRequest("POST", "/api/med/family/join", { inviteCode: code }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/med/family"] });
|
||||
setInviteCode("");
|
||||
toast({ title: "Вы вступили в семью" });
|
||||
},
|
||||
});
|
||||
|
||||
const addMember = useMutation({
|
||||
mutationFn: (name: string) => apiRequest("POST", `/api/med/family/${family.id}/members`, { profileName: name }),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
setMemberName("");
|
||||
toast({ title: "Участник добавлен" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!family ? (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Создать или вступить в семью</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Название семьи" value={newFamilyName} onChange={(e) => setNewFamilyName(e.target.value)} />
|
||||
<Button onClick={() => createFamily.mutate(newFamilyName)} disabled={!newFamilyName}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Код приглашения" value={inviteCode} onChange={(e) => setInviteCode(e.target.value)} />
|
||||
<Button onClick={() => joinFamily.mutate(inviteCode)} disabled={!inviteCode}>Вступить</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{family.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">Код приглашения: <code className="font-mono bg-muted px-1 rounded">{family.inviteCode}</code></p>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Имя нового участника (например, ребёнок)" value={memberName} onChange={(e) => setMemberName(e.target.value)} />
|
||||
<Button onClick={() => addMember.mutate(memberName)} disabled={!memberName}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FamilyMembers familyId={family.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyMembers({ familyId }: { familyId: number }) {
|
||||
const { data } = useQuery<any>({ queryKey: [`/api/med/family/${familyId}`] });
|
||||
const members = data?.members || [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Участники</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-muted-foreground">Нет участников.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{members.map((m: any) => (
|
||||
<div key={m.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{m.profileName}</div>
|
||||
<div className="text-sm text-muted-foreground">{m.role} {m.telegramChatId || m.maxChatId ? "· бот подключён" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CabinetTab({ family }: { family?: any }) {
|
||||
const { data, refetch } = useQuery<any>({ queryKey: ["/api/med/cabinet", family?.id], enabled: !!family });
|
||||
const items = data?.items || [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Аптечка</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted-foreground">Аптечка пуста. Добавьте покупку через бота: /buy название количество</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((i: any) => (
|
||||
<div key={i.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{i.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{i.quantity} {i.unit}{i.expiryDate ? ` · годен до ${i.expiryDate}` : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordsTab({ family }: { family?: any }) {
|
||||
const { data } = useQuery<any>({ queryKey: ["/api/med/records", family?.id], enabled: !!family });
|
||||
const records = data?.records || [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{records.map((r: any) => (
|
||||
<Card key={r.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{r.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{r.aiSummary && <p className="text-sm mb-2">{r.aiSummary}</p>}
|
||||
{r.rawText && <p className="text-sm text-muted-foreground whitespace-pre-line">{r.rawText}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{records.length === 0 && <p className="text-muted-foreground">Нет записей. Добавьте через бота: /record описание обследования</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckinsTab({ family }: { family?: any }) {
|
||||
const { data } = useQuery<any>({ queryKey: ["/api/med/checkins", family?.id], enabled: !!family });
|
||||
const checkins = data?.checkins || [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{checkins.map((c: any) => (
|
||||
<Card key={c.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{c.checkInDate}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-medium mb-2">{c.question}</p>
|
||||
{c.answerText ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{c.answerText}</p>
|
||||
{c.aiAlert && <Badge variant="destructive" className="mt-2">{c.aiAlert}</Badge>}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Ожидается ответ через бота: /checkin ваш ответ</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{checkins.length === 0 && <p className="text-muted-foreground">Нет активных чек-инов. Они создаются автоматически каждое утро.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user