feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI

This commit is contained in:
2026-07-15 09:42:55 +03:00
parent d35d0cd1ef
commit f519ce95db
15 changed files with 2960 additions and 2 deletions

View File

@@ -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} />

View File

@@ -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>

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