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:
@@ -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]);
|
||||
|
||||
111
client/src/components/OnboardingChecklist.tsx
Normal file
111
client/src/components/OnboardingChecklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface User {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
activeSkinId?: string;
|
||||
medscheduleEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user