287 lines
14 KiB
TypeScript
287 lines
14 KiB
TypeScript
import { Feather } from "@expo/vector-icons";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
import { ApiError } from "../../src/api";
|
|
import { useApp } from "../../src/app-context";
|
|
import { ScreenShell } from "../../src/components/ScreenShell";
|
|
import { notificationApi, notificationKeys, uploadDraftApi } from "../../src/notification-api";
|
|
import { formatNotificationPrice, openNewTab, typeMap } from "../../src/notification-presenter";
|
|
import { publicApi } from "../../src/services";
|
|
import { colors, radii, spacing } from "../../src/theme";
|
|
import type { NotificationButton, UploadDraft } from "../../src/types";
|
|
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
|
|
|
export default function NotificationDetailScreen() {
|
|
const { id = "" } = useLocalSearchParams<{ id: string }>();
|
|
const { authStatus } = useApp();
|
|
const authenticated = authStatus === "authenticated";
|
|
const router = useRouter();
|
|
const client = useQueryClient();
|
|
const readSent = useRef(false);
|
|
const [error, setError] = useState<unknown>();
|
|
const detail = useQuery({
|
|
queryKey: notificationKeys.detail(id),
|
|
queryFn: () => notificationApi.detail(id),
|
|
enabled: authenticated && Boolean(id),
|
|
retry: (count, reason) => !(reason instanceof ApiError && reason.status === 404) && count < 1,
|
|
});
|
|
const catalog = useQuery({
|
|
queryKey: notificationKeys.catalog,
|
|
queryFn: notificationApi.catalog,
|
|
staleTime: Infinity,
|
|
});
|
|
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
|
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
|
const type = detail.data ? byCode.get(detail.data.notification_type) : undefined;
|
|
const canUpload = Boolean(detail.data?.details?.send_documents);
|
|
const drafts = useQuery({
|
|
queryKey: ["uploads", "notification", id],
|
|
queryFn: () => uploadDraftApi.list(id),
|
|
enabled: authenticated && Boolean(id) && canUpload,
|
|
});
|
|
const pending = drafts.data ?? detail.data?.details?.pending_documents ?? [];
|
|
const closed = detail.data?.lifecycle_status === "closed";
|
|
|
|
useEffect(() => {
|
|
if (!detail.data || readSent.current || detail.data.is_read !== false) return;
|
|
readSent.current = true;
|
|
void notificationApi.read(id).then((state) => {
|
|
client.setQueryData(notificationKeys.detail(id), { ...detail.data, ...state });
|
|
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
|
}).catch(setError);
|
|
}, [client, detail.data, id]);
|
|
|
|
const pressButton = useMutation({
|
|
mutationFn: (button: NotificationButton) => notificationApi.button(id, button.code),
|
|
onSuccess: async (state) => {
|
|
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
|
await client.invalidateQueries({ queryKey: ["notifications"] });
|
|
router.replace("/notifications");
|
|
},
|
|
onError: setError,
|
|
});
|
|
const removeDraft = useMutation({
|
|
mutationFn: uploadDraftApi.remove,
|
|
onSuccess: () => client.invalidateQueries({ queryKey: ["uploads", "notification", id] }),
|
|
onError: setError,
|
|
});
|
|
|
|
const chooseFile = () => {
|
|
if (Platform.OS !== "web") {
|
|
setError(new Error("Выбор файла в этой тестовой сборке доступен только в web."));
|
|
return;
|
|
}
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.multiple = true;
|
|
input.accept = (config.data?.attachments.allowed_mime_types ?? []).join(",");
|
|
input.onchange = () => {
|
|
const files = Array.from(input.files ?? []);
|
|
if (files.length) void uploadFiles(files);
|
|
};
|
|
input.click();
|
|
};
|
|
|
|
const uploadFiles = async (files: File[]) => {
|
|
const limits = config.data?.attachments;
|
|
const maxBytes = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
|
const allowed = limits?.allowed_mime_types ?? [];
|
|
if (pending.length + files.length > 10) {
|
|
setError(new Error("К одному уведомлению можно приложить не более 10 файлов."));
|
|
return;
|
|
}
|
|
const invalid = files.find((file) => file.size > maxBytes || (allowed.length > 0 && !allowed.includes(file.type)));
|
|
if (invalid) {
|
|
setError(new Error(`Файл «${invalid.name}» имеет недопустимый тип или размер.`));
|
|
return;
|
|
}
|
|
setError(undefined);
|
|
try {
|
|
for (const file of files) await uploadDraftApi.upload(id, file);
|
|
await drafts.refetch();
|
|
await detail.refetch();
|
|
} catch (reason) {
|
|
setError(reason);
|
|
}
|
|
};
|
|
|
|
const download = async (documentId: string) => {
|
|
setError(undefined);
|
|
try {
|
|
const result = await notificationApi.documentUrl(id, documentId);
|
|
openNewTab(result.download_url);
|
|
await Promise.all([detail.refetch(), client.invalidateQueries({ queryKey: ["notifications"] })]);
|
|
} catch (reason) {
|
|
setError(reason);
|
|
}
|
|
};
|
|
|
|
if (!authenticated) {
|
|
return (
|
|
<ScreenShell>
|
|
<DetailHeader title="Уведомление" onBack={() => router.replace("/notifications")} />
|
|
<View style={local.center}>
|
|
<Text style={styles.text}>Для просмотра уведомления требуется авторизация.</Text>
|
|
<Button title="Перейти в Центр" onPress={() => router.replace("/notifications")} />
|
|
</View>
|
|
</ScreenShell>
|
|
);
|
|
}
|
|
|
|
const unavailable = detail.error instanceof ApiError && detail.error.status === 404;
|
|
const notification = detail.data;
|
|
const details = notification?.details;
|
|
const price = formatNotificationPrice(notification?.price);
|
|
const oldPrice = formatNotificationPrice(notification?.old_price);
|
|
|
|
return (
|
|
<ScreenShell>
|
|
<DetailHeader title={type?.label ?? "Уведомление"} onBack={() => router.back()} />
|
|
<ScrollView contentContainerStyle={local.content}>
|
|
{(detail.isLoading || catalog.isLoading) && <Loading />}
|
|
{unavailable ? (
|
|
<View style={local.empty}>
|
|
<Feather name="slash" size={32} color={colors.mutedForeground} />
|
|
<Text style={styles.title}>Уведомление недоступно</Text>
|
|
<Text style={styles.muted}>Возможно, оно уже закрыто или было удалено.</Text>
|
|
</View>
|
|
) : (detail.error || catalog.error) ? (
|
|
<ErrorNotice error={detail.error ?? catalog.error} retry={() => { void detail.refetch(); void catalog.refetch(); }} />
|
|
) : notification ? (
|
|
<>
|
|
{closed && <Text style={styles.error}>Уведомление больше не актуально. Действия недоступны.</Text>}
|
|
{details?.deadline ? (
|
|
<View style={local.deadline}>
|
|
<Feather name="clock" size={16} color={colors.warning} />
|
|
<Text style={styles.text}>Срок: {new Date(details.deadline).toLocaleString("ru-RU")}</Text>
|
|
</View>
|
|
) : null}
|
|
<Text accessibilityRole="header" style={styles.title}>{details?.details_header ?? notification.header}</Text>
|
|
{details?.details_text || notification.text ? <Text style={styles.text}>{details?.details_text ?? notification.text}</Text> : null}
|
|
{price ? (
|
|
<View style={local.priceRow}>
|
|
<Text style={local.price}>{price}</Text>
|
|
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
|
|
</View>
|
|
) : null}
|
|
{details?.todo_header ? <Text style={styles.heading}>{details.todo_header}</Text> : null}
|
|
{details?.todo_plan?.map((step) => (
|
|
<View key={`${step.number}-${step.text}`} style={local.step}>
|
|
<View style={local.stepNumber}><Text style={local.stepNumberText}>{step.number}</Text></View>
|
|
<Text style={[styles.text, { flex: 1 }]}>{step.text}</Text>
|
|
</View>
|
|
))}
|
|
{details?.documents?.length ? (
|
|
<View style={local.block}>
|
|
<Text style={styles.heading}>Документы</Text>
|
|
{details.documents.map((document) => (
|
|
<Pressable key={document.document_id} onPress={() => void download(document.document_id)} style={local.file}>
|
|
<Feather name="file-text" size={20} color={colors.info} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.text}>{document.title}</Text>
|
|
<Text style={styles.muted}>{formatBytes(document.size_bytes)}</Text>
|
|
</View>
|
|
<Feather name="download" size={18} color={colors.foreground} />
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
{canUpload ? (
|
|
<View style={local.block}>
|
|
<Text style={styles.heading}>Приложить документы</Text>
|
|
<Text style={styles.muted}>Черновики сохраняются, пока вы не отправите или не удалите их.</Text>
|
|
{drafts.isLoading && <Loading />}
|
|
{pending.map((draft) => (
|
|
<DraftRow
|
|
key={draft.draft_id}
|
|
draft={draft}
|
|
disabled={removeDraft.isPending || closed}
|
|
onRemove={() => removeDraft.mutate(draft.draft_id)}
|
|
/>
|
|
))}
|
|
<Button title="Добавить файлы" secondary disabled={closed || pending.length >= 10} onPress={chooseFile} />
|
|
</View>
|
|
) : null}
|
|
<View style={local.buttons}>
|
|
{type?.button_primary ? (
|
|
<Button
|
|
title={type.button_primary.label}
|
|
disabled={closed || pressButton.isPending || (type.button_primary.code === "send_docs" && !pending.some((draft) => draft.scan_status === "clean"))}
|
|
onPress={() => pressButton.mutate(type.button_primary!)}
|
|
/>
|
|
) : null}
|
|
{type?.button_secondary ? (
|
|
<Button
|
|
secondary
|
|
title={type.button_secondary.label}
|
|
disabled={closed || pressButton.isPending}
|
|
onPress={() => pressButton.mutate(type.button_secondary!)}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
{error && <ErrorNotice error={error} />}
|
|
</>
|
|
) : null}
|
|
</ScrollView>
|
|
</ScreenShell>
|
|
);
|
|
}
|
|
|
|
function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
|
return (
|
|
<View style={local.header}>
|
|
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={onBack} style={local.back}>
|
|
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
|
</Pressable>
|
|
<Text numberOfLines={1} style={[styles.heading, { flex: 1 }]}>{title}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function DraftRow({ draft, disabled, onRemove }: { draft: UploadDraft; disabled: boolean; onRemove: () => void }) {
|
|
const title = draft.title;
|
|
const status = {
|
|
pending: "Проверяется",
|
|
clean: "Готов к отправке",
|
|
infected: "Файл отклонён",
|
|
failed: "Ошибка проверки",
|
|
}[draft.scan_status];
|
|
return (
|
|
<View style={local.file}>
|
|
<Feather name={draft.scan_status === "clean" ? "check-circle" : "file"} size={20} color={draft.scan_status === "clean" ? colors.success : colors.warning} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.text}>{title}</Text>
|
|
<Text style={styles.muted}>{status} · {formatBytes(draft.size_bytes)}</Text>
|
|
</View>
|
|
<Pressable disabled={disabled} accessibilityLabel={`Удалить ${title}`} onPress={onRemove}>
|
|
<Feather name="trash-2" size={18} color={colors.destructive} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
|
}
|
|
|
|
const local = StyleSheet.create({
|
|
header: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: colors.border },
|
|
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
|
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
|
center: { flex: 1, justifyContent: "center", gap: spacing.md, padding: spacing.lg },
|
|
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
|
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.sm, borderRadius: radii.md, backgroundColor: "#fff7df", padding: spacing.md },
|
|
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
|
price: { fontSize: 22, fontWeight: "700", color: colors.foreground },
|
|
oldPrice: { fontSize: 14, color: colors.mutedForeground, textDecorationLine: "line-through" },
|
|
step: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md },
|
|
stepNumber: { width: 28, height: 28, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
|
|
stepNumberText: { color: colors.primaryForeground, fontSize: 13, fontWeight: "700" },
|
|
block: { gap: spacing.sm, paddingVertical: spacing.sm },
|
|
file: { flexDirection: "row", alignItems: "center", gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, backgroundColor: colors.card },
|
|
buttons: { gap: spacing.sm, paddingTop: spacing.sm },
|
|
});
|