feat(onboarding+modules): чеклист новой org, MedSchedule за флагом, удаление Capacitor

Шаги 1.9 и 1.10 плана production-готовности:
- OnboardingChecklist (форма→коллеги→задача) на главной, фикс EmptyState в Tasks
- organizations.medschedule_enabled (миграция 0081), гейт модуля по флагу
- Capacitor-стек удалён (5 зависимостей, push-notifications.ts, capacitor.config)
- check/lint/build чисто, vitest 96/96
This commit is contained in:
2026-09-08 00:27:17 +03:00
parent 960f28d3de
commit b1a3461c33
16 changed files with 213 additions and 921 deletions

View File

@@ -8,7 +8,6 @@ import { useAuthProvider, AuthContext } from "@/hooks/useAuth";
import { ThemeProvider } from "@/themes/ThemeProvider";
import { OfflineSyncProvider } from "@/contexts/OfflineSyncContext";
import { useEffect, lazy, Suspense, useCallback, type ComponentType, type LazyExoticComponent } from "react";
import { initializePushNotifications } from "@/lib/push-notifications";
import { initPWA, initWebPush } from "@/lib/pwa";
import { useToast } from "@/hooks/use-toast";
import { usePrefetchOfflineTasks } from "@/hooks/usePrefetchOfflineTasks";
@@ -284,7 +283,6 @@ function App() {
useEffect(() => {
if (auth.isAuthenticated) {
initializePushNotifications().catch(console.error);
initWebPush().catch(console.error);
}
}, [auth.isAuthenticated]);

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import { Link } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { CheckCircle2, Circle, X, Sparkles } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { apiRequest } from '@/lib/queryClient';
import { cn } from '@/lib/utils';
interface OnboardingStep {
key: string;
label: string;
href: string;
done: boolean;
}
/**
* Ненавязчивый чеклист первых шагов для новой организации.
* Состояние шагов вычисляется из реальных данных (формы / пользователи / задачи).
* Скрывается, когда всё выполнено, либо вручную (localStorage per org).
*/
export function OnboardingChecklist() {
const { user } = useAuth();
const orgId = user?.organization?.id;
const storageKey = orgId ? `onboarding_checklist_hidden_${orgId}` : null;
const [dismissed, setDismissed] = useState(
() => !!storageKey && localStorage.getItem(storageKey) === '1'
);
const { data: formsData, isLoading: formsLoading } = useQuery<{ success: boolean; forms: unknown[] }>({
queryKey: ['/api/forms'],
});
const { data: usersData, isLoading: usersLoading } = useQuery<{ success: boolean; users: unknown[] }>({
queryKey: ['/api/users/list'],
});
const { data: tasksData, isLoading: tasksLoading } = useQuery<{ success: boolean; tasks: unknown[] }>({
queryKey: ['/api/tasks', 'onboarding-first'],
queryFn: () => apiRequest('GET', '/api/tasks?minimal=true&limit=1').then(r => r.json()),
});
if (!storageKey || dismissed) return null;
if (formsLoading || usersLoading || tasksLoading) return null;
const steps: OnboardingStep[] = [
{
key: 'form',
label: 'Создайте первую форму',
href: '/forms',
done: (formsData?.forms?.length ?? 0) > 0,
},
{
key: 'team',
label: 'Пригласите коллег',
href: '/users',
done: (usersData?.users?.length ?? 0) > 1,
},
{
key: 'task',
label: 'Создайте первую задачу',
href: '/tasks',
done: (tasksData?.tasks?.length ?? 0) > 0,
},
];
// Всё выполнено — чеклист больше не нужен
if (steps.every(s => s.done)) return null;
const doneCount = steps.filter(s => s.done).length;
function handleDismiss() {
try { localStorage.setItem(storageKey!, '1'); } catch {}
setDismissed(true);
}
return (
<div className="mx-4 mt-3 rounded-lg border border-border bg-muted/20 px-3 py-2.5 shrink-0">
<div className="flex items-center gap-2">
<Sparkles className="w-3.5 h-3.5 text-primary shrink-0" />
<span className="text-xs font-medium text-foreground flex-1">
Начало работы · {doneCount} из {steps.length}
</span>
<button
onClick={handleDismiss}
className="text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
title="Скрыть чеклист"
aria-label="Скрыть чеклист"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1">
{steps.map((step, i) => (
<Link key={step.key} href={step.href}>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs cursor-pointer transition-colors",
step.done ? "text-muted-foreground" : "text-foreground hover:text-primary"
)}
>
{step.done
? <CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
: <Circle className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />}
<span className={step.done ? "line-through" : undefined}>
{i + 1}. {step.label}
</span>
</span>
</Link>
))}
</div>
</div>
);
}

View File

@@ -1314,7 +1314,9 @@ function MedScheduleNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/med' || location.startsWith('/med/');
if (user?.organization?.slug !== 'medschedule' && !isAdmin(user)) return null;
// Модуль виден, если у организации включён флаг medschedule_enabled
// (см. миграцию 0081) либо пользователь — админ приложения
if (!user?.organization?.medscheduleEnabled && !isAdmin(user)) return null;
if (collapsed) {
return (

View File

@@ -1,93 +0,0 @@
import { PushNotifications, Token, PushNotificationSchema, ActionPerformed } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
import { apiRequest } from './queryClient';
async function registerTokenOnServer(token: string, platform: string): Promise<void> {
try {
await apiRequest('POST', '/api/devices/register', {
token,
platform,
deviceInfo: {
model: navigator.userAgent,
timestamp: new Date().toISOString(),
}
});
console.log('Device token registered successfully');
} catch (error) {
console.error('Error registering device token:', error);
}
}
async function sendHeartbeat(): Promise<void> {
try {
await apiRequest('POST', '/api/devices/heartbeat', {});
} catch (error) {
console.error('Heartbeat failed:', error);
}
}
export async function initializePushNotifications(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
console.log('Push notifications only available on native platforms');
return;
}
try {
const permStatus = await PushNotifications.checkPermissions();
if (permStatus.receive === 'prompt') {
const result = await PushNotifications.requestPermissions();
if (result.receive !== 'granted') {
console.log('Push notification permission denied');
return;
}
} else if (permStatus.receive !== 'granted') {
console.log('Push notification permission not granted');
return;
}
await PushNotifications.register();
PushNotifications.addListener('registration', async (token: Token) => {
console.log('Push registration success, token:', token.value);
const platform = Capacitor.getPlatform();
await registerTokenOnServer(token.value, platform);
});
PushNotifications.addListener('registrationError', (error: any) => {
console.error('Push registration error:', error);
});
PushNotifications.addListener('pushNotificationReceived', (notification: PushNotificationSchema) => {
console.log('Push notification received:', notification);
});
PushNotifications.addListener('pushNotificationActionPerformed', (notification: ActionPerformed) => {
console.log('Push notification action performed:', notification);
const taskId = notification.notification.data?.taskId;
if (taskId) {
window.location.href = `/tasks/${taskId}`;
}
});
setInterval(sendHeartbeat, 60000);
sendHeartbeat();
console.log('Push notifications initialized');
} catch (error) {
console.error('Error initializing push notifications:', error);
}
}
export async function unregisterPushNotifications(): Promise<void> {
if (!Capacitor.isNativePlatform()) return;
try {
await PushNotifications.removeAllListeners();
console.log('Push notifications unregistered');
} catch (error) {
console.error('Error unregistering push notifications:', error);
}
}

View File

@@ -31,6 +31,7 @@ import { useInboxWithCache } from '@/hooks/useInboxWithCache';
import { patchTaskCache } from '@/lib/tasksCache';
import { HomeGlobalSearch } from '@/components/HomeGlobalSearch';
import { QrScannerButton } from '@/components/QrScannerButton';
import { OnboardingChecklist } from '@/components/OnboardingChecklist';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@@ -1431,6 +1432,9 @@ export default function HomePage() {
</div>
</div>
{/* Чеклист первых шагов для новой организации */}
<OnboardingChecklist />
{/* Desktop tabs */}
<div className="hidden md:flex items-center gap-0 px-4 border-b border-border shrink-0">
{(['inbox', 'recent', 'snoozed', 'hidden'] as Tab[]).map(t => (

View File

@@ -173,16 +173,36 @@ function EmptyState({ forms, onCreateClick }: { forms: FormItem[]; onCreateClick
<div className="space-y-3">
<div className="border border-border rounded p-6 flex flex-col items-center gap-2 text-center">
<CheckSquare className="w-8 h-8 text-muted-foreground/40" />
<div>
<p className="text-xs font-medium">Задач пока нет</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Выберите форму и создайте первую задачу
</p>
</div>
<Button className="h-7 px-2.5 text-xs mt-1" onClick={onCreateClick} disabled={forms.length === 0}>
<Plus className="w-3.5 h-3.5 mr-1" />
Создать задачу
</Button>
{forms.length === 0 ? (
<>
<div>
<p className="text-xs font-medium">Пока нет ни одной формы</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Задачи создаются внутри форм — начните с создания первой формы
</p>
</div>
<Link href="/forms">
<Button className="h-7 px-2.5 text-xs mt-1">
<Plus className="w-3.5 h-3.5 mr-1" />
Создать форму
<ArrowRight className="w-3 h-3 ml-1" />
</Button>
</Link>
</>
) : (
<>
<div>
<p className="text-xs font-medium">Задач пока нет</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Выберите форму и создайте первую задачу
</p>
</div>
<Button className="h-7 px-2.5 text-xs mt-1" onClick={onCreateClick}>
<Plus className="w-3.5 h-3.5 mr-1" />
Создать задачу
</Button>
</>
)}
</div>
{forms.length > 0 && (

View File

@@ -9,6 +9,8 @@ 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 { useAuth } from "@/hooks/useAuth";
import { isAdmin } from "@/lib/permissions";
import { apiRequest } from "@/lib/queryClient";
const TABS = [
@@ -22,19 +24,39 @@ const TABS = [
export default function MedSchedulePage() {
const [location, setLocation] = useLocation();
const { toast } = useToast();
const { user } = useAuth();
const queryClient = useQueryClient();
const match = location.match(/\/med\/(.*)/);
const activeTab = match?.[1] || "schedule";
// Модуль доступен только организациям с флагом medschedule_enabled (миграция 0081)
// и администраторам приложения — серверный гейт в server/medschedule/routes.ts
const moduleEnabled = !!user?.organization?.medscheduleEnabled || isAdmin(user);
const { data: familyData } = useQuery<any>({
queryKey: ["/api/med/family"],
retry: false,
enabled: moduleEnabled,
});
const families: any[] = familyData?.groups || [];
const family = families[0];
const onTabChange = (value: string) => setLocation(`/med/${value}`);
if (!moduleEnabled) {
return (
<Layout>
<div className="container mx-auto p-4 max-w-5xl">
<Card>
<CardContent className="p-8 text-center">
<p className="text-muted-foreground">Модуль MedSchedule не подключён для вашей организации.</p>
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container mx-auto p-4 max-w-5xl">

View File

@@ -18,6 +18,7 @@ export interface User {
slug: string;
displayName: string;
activeSkinId?: string;
medscheduleEnabled?: boolean;
};
}