From bcdfbc3861c27fb9544079dd4d6feabd43b39ce3 Mon Sep 17 00:00:00 2001 From: lendry Date: Wed, 1 Jul 2026 23:27:48 +0300 Subject: [PATCH] fix and update --- .../src/controllers/family.controller.ts | 29 +- .../src/controllers/media.controller.ts | 19 +- .../controllers/notifications.controller.ts | 8 +- apps/api-gateway/src/document-access.ts | 18 +- apps/api-gateway/src/dto/media.dto.ts | 28 +- apps/api-gateway/src/session-auth.ts | 13 +- apps/docs/app/globals.css | 25 ++ apps/frontend/app/admin/users/page.tsx | 231 +++++++++------ apps/frontend/app/data/page.tsx | 8 +- apps/frontend/app/documents/page.tsx | 8 +- apps/frontend/app/globals.css | 27 ++ apps/frontend/app/page.tsx | 4 +- .../admin/admin-insights-date-filter.tsx | 108 ++++--- .../components/documents/document-dialog.tsx | 80 ++++++ .../documents/document-form-dialog.tsx | 117 ++++++-- .../documents/document-photo-gallery.tsx | 185 ++++++++---- .../documents/document-photo-viewer.tsx | 147 +++++++--- .../documents/document-view-card.tsx | 270 ++++++++++++++++++ .../documents/document-view-dialog.tsx | 75 +++++ .../documents/user-documents-dialog.tsx | 9 +- .../family/family-overlay-provider.tsx | 23 +- apps/frontend/components/id/auth-provider.tsx | 17 ++ apps/frontend/hooks/use-primary-family.ts | 20 +- apps/frontend/lib/api.ts | 9 +- apps/frontend/lib/document-attachments.ts | 76 +++++ apps/frontend/lib/document-catalog.ts | 27 +- apps/frontend/lib/selected-family-storage.ts | 43 +++ .../migration.sql | 3 + apps/sso-core/prisma/schema.prisma | 1 + .../src/domain/auth-grpc.controller.ts | 8 +- apps/sso-core/src/domain/media.service.ts | 16 +- apps/sso-core/src/domain/pin.service.ts | 1 + apps/sso-core/src/domain/session.service.ts | 7 +- .../sso-core/src/infra/document-media.util.ts | 141 +++++++++ apps/sso-core/src/infra/minio.service.ts | 21 ++ shared/proto/media.proto | 2 + 36 files changed, 1504 insertions(+), 320 deletions(-) create mode 100644 apps/frontend/components/documents/document-dialog.tsx create mode 100644 apps/frontend/components/documents/document-view-card.tsx create mode 100644 apps/frontend/components/documents/document-view-dialog.tsx create mode 100644 apps/frontend/lib/document-attachments.ts create mode 100644 apps/frontend/lib/selected-family-storage.ts create mode 100644 apps/sso-core/prisma/migrations/20260701180000_add_session_last_activity_at/migration.sql create mode 100644 apps/sso-core/src/infra/document-media.util.ts diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts index efc91a0..985ccac 100644 --- a/apps/api-gateway/src/controllers/family.controller.ts +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -16,8 +16,9 @@ export class FamilyController { private readonly jwt: JwtService ) {} - private async auth(authorization?: string) { - return getAuthorizedUserId(this.jwt, this.core, authorization); + private async auth(authorization?: string, passiveActivityHeader?: string) { + const touchActivity = passiveActivityHeader !== '1'; + return getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity); } @Post('groups') @@ -33,8 +34,12 @@ export class FamilyController { @Get('users/:userId/groups') @ApiOperation({ summary: 'Список семей пользователя' }) - async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { - const requesterId = await this.auth(authorization); + async listGroups( + @Headers('authorization') authorization: string | undefined, + @Headers('x-id-passive-activity') passiveActivity: string | undefined, + @Param('userId') userId: string + ) { + const requesterId = await this.auth(authorization, passiveActivity); if (requesterId !== userId) { throw new ForbiddenException('Можно просматривать только свои семьи'); } @@ -50,8 +55,12 @@ export class FamilyController { @Get('groups/:groupId') @ApiOperation({ summary: 'Получить семейную группу' }) - async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { - const requesterId = await this.auth(authorization); + async getGroup( + @Headers('authorization') authorization: string | undefined, + @Headers('x-id-passive-activity') passiveActivity: string | undefined, + @Param('groupId') groupId: string + ) { + const requesterId = await this.auth(authorization, passiveActivity); return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId })); } @@ -181,8 +190,12 @@ export class FamilyController { @Get('groups/:groupId/presence') @ApiOperation({ summary: 'Онлайн-статус участников семьи' }) - async getPresence(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { - const requesterId = await this.auth(authorization); + async getPresence( + @Headers('authorization') authorization: string | undefined, + @Headers('x-id-passive-activity') passiveActivity: string | undefined, + @Param('groupId') groupId: string + ) { + const requesterId = await this.auth(authorization, passiveActivity); return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId })); } diff --git a/apps/api-gateway/src/controllers/media.controller.ts b/apps/api-gateway/src/controllers/media.controller.ts index 1a31d05..d766439 100644 --- a/apps/api-gateway/src/controllers/media.controller.ts +++ b/apps/api-gateway/src/controllers/media.controller.ts @@ -117,7 +117,7 @@ export class MediaController { @Post('documents/:documentId/photo/upload-url') @ApiBearerAuth() - @ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' }) + @ApiOperation({ summary: 'Получить URL для файла документа', description: 'Presigned URL для загрузки скана, фото или файла документа в MinIO.' }) @ApiBody({ type: DocumentPhotoUploadDto }) async createDocumentPhotoUploadUrl( @Headers('authorization') authorization: string | undefined, @@ -125,22 +125,31 @@ export class MediaController { @Body() dto: DocumentPhotoUploadDto ) { const userId = await this.authUserId(authorization); - return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType })); + return firstValueFrom( + this.core.media.CreateDocumentPhotoUploadUrl({ + userId, + documentId, + contentType: dto.contentType, + fileName: dto.fileName + }) + ); } @Get('users/:userId/documents/photo-url') @ApiBearerAuth() - @ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' }) + @ApiOperation({ summary: 'Получить ссылку на файл документа', description: 'Временная ссылка на просмотр файла документа (15 минут).' }) @ApiParam({ name: 'userId', description: 'ID владельца документа' }) @ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' }) + @ApiQuery({ name: 'fileName', required: false, description: 'Имя файла для скачивания' }) async getDocumentPhotoUrl( @Headers('authorization') authorization: string | undefined, @Param('userId') userId: string, - @Query('storageKey') storageKey: string + @Query('storageKey') storageKey: string, + @Query('fileName') fileName?: string ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( - this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey }) + this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey, fileName }) ); } diff --git a/apps/api-gateway/src/controllers/notifications.controller.ts b/apps/api-gateway/src/controllers/notifications.controller.ts index d95142a..235a42b 100644 --- a/apps/api-gateway/src/controllers/notifications.controller.ts +++ b/apps/api-gateway/src/controllers/notifications.controller.ts @@ -40,8 +40,12 @@ export class NotificationsController { @Get('unread-count') @ApiOperation({ summary: 'Количество непрочитанных уведомлений' }) - async unreadCount(@Headers('authorization') authorization: string | undefined) { - const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); + async unreadCount( + @Headers('authorization') authorization: string | undefined, + @Headers('x-id-passive-activity') passiveActivity: string | undefined + ) { + const touchActivity = passiveActivity !== '1'; + const userId = await getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity); return firstValueFrom(this.core.notifications.GetUnreadCount({ userId })); } diff --git a/apps/api-gateway/src/document-access.ts b/apps/api-gateway/src/document-access.ts index 7e23d97..8c6efd3 100644 --- a/apps/api-gateway/src/document-access.ts +++ b/apps/api-gateway/src/document-access.ts @@ -10,9 +10,14 @@ interface RequesterProfile { canViewUserDocuments?: boolean; } -async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise { +async function getRequesterProfile( + jwt: JwtService, + core: CoreGrpcService, + authorization?: string, + touchActivity = true +): Promise { const payload = await verifyAccessToken(jwt, authorization); - await assertSessionUnlocked(core, payload); + await assertSessionUnlocked(core, payload, touchActivity); if (payload.isSuperAdmin) { return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true }; @@ -51,7 +56,12 @@ export async function assertDocumentsWriteAccess( return requester.id; } -export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) { - const requester = await getRequesterProfile(jwt, core, authorization); +export async function getAuthorizedUserId( + jwt: JwtService, + core: CoreGrpcService, + authorization?: string, + touchActivity = true +) { + const requester = await getRequesterProfile(jwt, core, authorization, touchActivity); return requester.id; } diff --git a/apps/api-gateway/src/dto/media.dto.ts b/apps/api-gateway/src/dto/media.dto.ts index 0a8f6b0..b2712d7 100644 --- a/apps/api-gateway/src/dto/media.dto.ts +++ b/apps/api-gateway/src/dto/media.dto.ts @@ -2,6 +2,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional, IsString } from 'class-validator'; const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const; + +const DOCUMENT_ATTACHMENT_TYPES = [ + ...IMAGE_TYPES, + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'application/rtf', + 'application/vnd.oasis.opendocument.text' +] as const; + export class AvatarUploadDto { @ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES }) @IsString({ message: 'Укажите MIME-тип изображения' }) @@ -16,10 +31,17 @@ export class ConfirmAvatarDto { } export class DocumentPhotoUploadDto { - @ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES }) - @IsString({ message: 'Укажите MIME-тип изображения' }) - @IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' }) + @ApiProperty({ description: 'MIME-тип файла', example: 'application/pdf', enum: DOCUMENT_ATTACHMENT_TYPES }) + @IsString({ message: 'Укажите MIME-тип файла' }) + @IsIn([...DOCUMENT_ATTACHMENT_TYPES], { + message: 'Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT' + }) contentType!: string; + + @ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'passport.pdf' }) + @IsOptional() + @IsString({ message: 'Имя файла должно быть строкой' }) + fileName?: string; } export class ChatMediaUploadDto { diff --git a/apps/api-gateway/src/session-auth.ts b/apps/api-gateway/src/session-auth.ts index 8e30c0b..bcf63ea 100644 --- a/apps/api-gateway/src/session-auth.ts +++ b/apps/api-gateway/src/session-auth.ts @@ -28,7 +28,11 @@ export async function verifyAccessToken(jwt: JwtService, authorization?: string) } } -export async function assertSessionUnlocked(core: CoreGrpcService, payload: AccessTokenPayload) { +export async function assertSessionUnlocked( + core: CoreGrpcService, + payload: AccessTokenPayload, + touchActivity = true +) { if (!payload.sessionId) { return; } @@ -37,7 +41,7 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce core.auth.ValidateSession({ userId: payload.sub, sessionId: payload.sessionId, - touchActivity: true + touchActivity }) )) as { requiresPin: boolean; sessionId: string }; @@ -54,9 +58,10 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce export async function resolveAuthorizedPayload( jwt: JwtService, core: CoreGrpcService, - authorization?: string + authorization?: string, + touchActivity = true ): Promise { const payload = await verifyAccessToken(jwt, authorization); - await assertSessionUnlocked(core, payload); + await assertSessionUnlocked(core, payload, touchActivity); return payload; } diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css index ca40162..9847eb1 100644 --- a/apps/docs/app/globals.css +++ b/apps/docs/app/globals.css @@ -27,6 +27,31 @@ body { color: var(--foreground); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; + scrollbar-width: thin; + scrollbar-color: rgb(168 173 188 / 55%) transparent; +} + +* { + scrollbar-width: thin; + scrollbar-color: rgb(168 173 188 / 55%) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgb(168 173 188 / 45%); + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgb(102 112 133 / 65%); } a { diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index d632467..033c98f 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; +import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, MoreVertical, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog'; import { VerificationBadge } from '@/components/id/verification-badge'; import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog'; @@ -10,10 +10,16 @@ import { AdminShell } from '@/components/id/admin-shell'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api'; +import { AdminPermission, AdminRole, AdminUser, PublicUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons'; @@ -34,6 +40,113 @@ const roleLabels: Record = { const DEFAULT_USER_ROLE = 'user'; +function UserActionsMenu({ + user, + currentUser, + actionLoading, + onOpenInspector, + onResetPassword, + onSuspend, + onUnsuspend, + onOpenDocuments, + onDisableTotp, + onOpenVerification, + onOpenRoles, + onToggleSuperAdmin +}: { + user: AdminUser; + currentUser: PublicUser | null; + actionLoading: boolean; + onOpenInspector: () => void; + onResetPassword: () => void; + onSuspend: () => void; + onUnsuspend: () => void; + onOpenDocuments: () => void; + onDisableTotp: () => void; + onOpenVerification: () => void; + onOpenRoles: () => void; + onToggleSuperAdmin: () => void; +}) { + const canInspect = (currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot; + const canManage = Boolean(currentUser?.canManageUsers); + const canDocuments = Boolean(currentUser?.canViewUserDocuments && !user.isBot); + const canDisableTotp = Boolean(currentUser?.isSuperAdmin && !user.isBot); + const canVerify = Boolean(currentUser?.canVerifyUsers); + const canRoles = Boolean(currentUser?.canManageRoles); + + if (!canInspect && !canManage && !canDocuments && !canDisableTotp && !canVerify && !canRoles) { + return null; + } + + return ( + + + + + + {canInspect ? ( + + + Журнал и чаты + + ) : null} + {canManage ? ( + <> + + + Сбросить пароль + + + + Заблокировать + + + + Разблокировать + + + ) : null} + {canDocuments ? ( + + + Документы + + ) : null} + {canDisableTotp ? ( + + + Отключить 2FA + + ) : null} + {canVerify ? ( + + + {user.isVerified ? 'Изменить верификацию' : 'Верифицировать'} + + ) : null} + {canRoles ? ( + <> + + + Роли и доступ + + + + {user.isSuperAdmin ? 'Снять супер-админа' : 'Назначить супер-админом'} + + + ) : null} + + + ); +} + export default function AdminUsersPage() { const router = useRouter(); const { token, user: currentUser } = useAuth(); @@ -367,98 +480,28 @@ export default function AdminUsersPage() { {statusLabels[user.status] ?? user.status} -
event.stopPropagation()}> - {(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? ( - - ) : null} - {currentUser?.canManageUsers ? ( - <> - - - - - ) : null} - {currentUser?.canViewUserDocuments && !user.isBot ? ( - - ) : null} - {currentUser?.isSuperAdmin && !user.isBot ? ( - - ) : null} - {currentUser?.canVerifyUsers ? ( - - ) : null} - {currentUser?.canManageRoles ? ( - <> - - - - ) : null} +
event.stopPropagation()}> + setInspectorUser(user)} + onResetPassword={() => { + setSelectedUser(user); + setPassword(''); + setDialog('password'); + }} + onSuspend={() => void handleSuspend(user)} + onUnsuspend={() => void handleUnsuspend(user)} + onOpenDocuments={() => setDocumentsUser(user)} + onDisableTotp={() => void handleAdminDisableTotp(user)} + onOpenVerification={() => openVerificationDialog(user)} + onOpenRoles={() => { + setSelectedUser(user); + setDialog('roles'); + }} + onToggleSuperAdmin={() => void handleToggleSuperAdmin(user)} + />
diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index 9dfaef1..1036177 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react'; import { AddressQuickSection } from '@/components/addresses/address-quick-section'; -import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; +import { DocumentDialog } from '@/components/documents/document-dialog'; import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { ActionTile } from '@/components/id/action-tile'; import { useAuth } from '@/components/id/auth-provider'; @@ -20,6 +20,7 @@ import { useRequireAuth } from '@/hooks/use-require-auth'; import { getDocumentType, indexDocumentsByType, + parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode @@ -324,6 +325,7 @@ export default function DataPage() { documents.map((document) => { const config = getDocumentType(document.type); const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson)); + const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson)); return (
{photoKeys.length > 0 && userId && token ? (
- +
) : null}
@@ -401,7 +403,7 @@ export default function DataPage() { {activeDocumentType && user ? ( - { if (!open) setActiveDocumentType(null); diff --git a/apps/frontend/app/documents/page.tsx b/apps/frontend/app/documents/page.tsx index ed777d2..dec5f3d 100644 --- a/apps/frontend/app/documents/page.tsx +++ b/apps/frontend/app/documents/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ChevronRight, Plus } from 'lucide-react'; -import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; +import { DocumentDialog } from '@/components/documents/document-dialog'; import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { useAuth } from '@/components/id/auth-provider'; import { IdShell } from '@/components/id/shell'; @@ -12,6 +12,7 @@ import { DOCUMENT_CATEGORIES, DOCUMENT_TYPES, getDocumentType, + parseAttachmentMeta, parseDocumentPhotos, parseMetadata, QUICK_DOCUMENT_TYPES, @@ -97,6 +98,7 @@ export default function DocumentsPage() { const Icon = item.icon; const existing = documentsByType.get(item.type); const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : []; + const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {}; return (
@@ -126,7 +128,7 @@ export default function DocumentsPage() { ))} {activeType ? ( - { if (!open) setActiveType(null); diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index 020e43b..f364395 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -15,6 +15,29 @@ * { box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: rgb(168 173 188 / 55%) transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgb(168 173 188 / 45%); + border-radius: 999px; + border: 1px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgb(102 112 133 / 65%); + background-clip: padding-box; } body { @@ -29,6 +52,10 @@ html { overflow-x: hidden; } +*::-webkit-scrollbar-corner { + background: transparent; +} + .rdp-root { --rdp-accent-color: #111827; --rdp-accent-background-color: #f4f5f8; diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index cf51c9b..e19ce35 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react'; import { AddressQuickSection } from '@/components/addresses/address-quick-section'; -import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; +import { DocumentDialog } from '@/components/documents/document-dialog'; import { ActionTile, SectionTitle } from '@/components/id/action-tile'; import { AdminBadge } from '@/components/id/admin-badge'; import { AvatarDisplay } from '@/components/id/avatar-upload'; @@ -124,7 +124,7 @@ export default function HomePage() {
{activeDocumentType && user ? ( - { if (!open) setActiveDocumentType(null); diff --git a/apps/frontend/components/admin/admin-insights-date-filter.tsx b/apps/frontend/components/admin/admin-insights-date-filter.tsx index 320b4d9..07d3104 100644 --- a/apps/frontend/components/admin/admin-insights-date-filter.tsx +++ b/apps/frontend/components/admin/admin-insights-date-filter.tsx @@ -1,8 +1,10 @@ 'use client'; -import { CalendarRange, RotateCcw } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { CalendarRange, ChevronDown, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; export function toDatetimeLocalValue(date: Date) { const pad = (value: number) => String(value).padStart(2, '0'); @@ -36,6 +38,21 @@ function pluralDays(value: number) { return 'дней'; } +function formatPeriodLabel(from: string, to: string) { + const format = (value: string) => { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(parsed); + }; + return `${format(from)} — ${format(to)}`; +} + export function AdminInsightsDateFilter({ from, to, @@ -51,45 +68,62 @@ export function AdminInsightsDateFilter({ onToChange: (value: string) => void; onReset: () => void; }) { + const [open, setOpen] = useState(false); + const periodLabel = useMemo(() => formatPeriodLabel(from, to), [from, to]); + return ( -
-
- - Период просмотра -
-
-
- - onFromChange(event.target.value)} - /> +
+ + + {open ? ( +
+
+
+ + onFromChange(event.target.value)} + /> +
+
+ + onToChange(event.target.value)} + /> +
+
+ +
+
+

+ Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически. +

-
- -
-
-

- Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически. -

+ ) : null}
); } diff --git a/apps/frontend/components/documents/document-dialog.tsx b/apps/frontend/components/documents/document-dialog.tsx new file mode 100644 index 0000000..74985c1 --- /dev/null +++ b/apps/frontend/components/documents/document-dialog.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; +import { DocumentViewDialog } from '@/components/documents/document-view-dialog'; +import { type DocumentTypeCode } from '@/lib/document-catalog'; +import { UserDocument } from '@/lib/api'; + +export function DocumentDialog({ + open, + onOpenChange, + documentType, + userId, + token, + existing, + onSaved, + readOnly +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + documentType: DocumentTypeCode; + userId: string; + token: string | null; + existing?: UserDocument | null; + onSaved: () => void | Promise; + readOnly?: boolean; +}) { + const [mode, setMode] = useState<'view' | 'edit'>('edit'); + + useEffect(() => { + if (!open) return; + setMode(existing ? 'view' : 'edit'); + }, [existing?.id, open]); + + function handleClose(nextOpen: boolean) { + if (!nextOpen) { + onOpenChange(false); + return; + } + onOpenChange(true); + } + + if (!open) return null; + + if (mode === 'view' && existing) { + return ( + setMode('edit')} + /> + ); + } + + return ( + { + if (!nextOpen && existing) { + setMode('view'); + return; + } + handleClose(nextOpen); + }} + documentType={documentType} + userId={userId} + token={token} + existing={existing} + onSaved={onSaved} + readOnly={readOnly} + showBackToView={Boolean(existing) && !readOnly} + onBackToView={existing ? () => setMode('view') : undefined} + /> + ); +} diff --git a/apps/frontend/components/documents/document-form-dialog.tsx b/apps/frontend/components/documents/document-form-dialog.tsx index fced0d8..7c8df50 100644 --- a/apps/frontend/components/documents/document-form-dialog.tsx +++ b/apps/frontend/components/documents/document-form-dialog.tsx @@ -1,20 +1,23 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { Camera, Loader2, X } from 'lucide-react'; +import { ArrowLeft, Loader2, Paperclip, X } from 'lucide-react'; import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { useToast } from '@/components/id/toast-provider'; +import { DOCUMENT_FILE_ACCEPT, resolveDocumentFileContentType } from '@/lib/document-attachments'; import { buildDocumentNumber, DOCUMENT_TYPES, DocumentTypeDef, LICENSE_CATEGORIES, + parseAttachmentMeta, parseDocumentPhotos, parseMetadata, withDocumentPhotos, + type DocumentAttachmentMeta, type DocumentTypeCode } from '@/lib/document-catalog'; import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api'; @@ -106,7 +109,9 @@ export function DocumentFormDialog({ token, existing, onSaved, - readOnly + readOnly, + showBackToView, + onBackToView }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -116,6 +121,8 @@ export function DocumentFormDialog({ existing?: UserDocument | null; onSaved: () => void | Promise; readOnly?: boolean; + showBackToView?: boolean; + onBackToView?: () => void; }) { const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]); const { showToast } = useToast(); @@ -124,10 +131,12 @@ export function DocumentFormDialog({ const fieldValuesRef = useRef>({}); const draftDocumentIdRef = useRef(undefined); const photoStorageKeysRef = useRef([]); + const attachmentMetaRef = useRef>({}); const wasOpenRef = useRef(false); const [formInstanceKey, setFormInstanceKey] = useState(0); const [pickerValues, setPickerValues] = useState>({}); const [photoStorageKeys, setPhotoStorageKeys] = useState([]); + const [attachmentMeta, setAttachmentMeta] = useState>({}); const [draftDocumentId, setDraftDocumentId] = useState(); const [isSaving, setIsSaving] = useState(false); const [isPhotoUploading, setIsPhotoUploading] = useState(false); @@ -138,18 +147,23 @@ export function DocumentFormDialog({ fieldValuesRef.current = {}; draftDocumentIdRef.current = undefined; photoStorageKeysRef.current = []; + attachmentMetaRef.current = {}; return; } if (!config || wasOpenRef.current) return; wasOpenRef.current = true; const initial = buildInitialValues(config, existing); - const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson)); + const metadata = parseMetadata(existing?.metadataJson); + const photos = parseDocumentPhotos(metadata); + const meta = parseAttachmentMeta(metadata); fieldValuesRef.current = { ...initial }; photoStorageKeysRef.current = photos; + attachmentMetaRef.current = meta; draftDocumentIdRef.current = existing?.id; setPickerValues(initial); setPhotoStorageKeys(photos); + setAttachmentMeta(meta); setDraftDocumentId(existing?.id); setFormInstanceKey((current) => current + 1); }, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]); @@ -200,33 +214,50 @@ export function DocumentFormDialog({ }); } - function buildMetadataPayload(source: Record, photos: string[]) { + function buildMetadataPayload( + source: Record, + photos: string[], + meta: Record + ) { const metadata: Record = {}; for (const [key, value] of Object.entries(source)) { if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value; } - return JSON.stringify(withDocumentPhotos(metadata, photos)); + return JSON.stringify(withDocumentPhotos(metadata, photos, meta)); } - function buildSaveBody(currentValues: Record, photos: string[]) { + function buildSaveBody( + currentValues: Record, + photos: string[], + meta: Record + ) { return { number: buildDocumentNumber(config!, currentValues), issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined, expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).toISOString() : undefined, - metadataJson: buildMetadataPayload(currentValues, photos) + metadataJson: buildMetadataPayload(currentValues, photos, meta) }; } - async function patchDocument(documentId: string, currentValues: Record, photos: string[]) { + async function patchDocument( + documentId: string, + currentValues: Record, + photos: string[], + meta: Record + ) { if (!token || !config) return; await apiFetch( `/documents/users/${userId}/${documentId}`, - { method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) }, + { method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos, meta)) }, token ); } - async function ensureDocumentId(currentValues: Record, photos: string[]) { + async function ensureDocumentId( + currentValues: Record, + photos: string[], + meta: Record + ) { if (draftDocumentIdRef.current) { return { id: draftDocumentIdRef.current, created: false }; } @@ -240,7 +271,7 @@ export function DocumentFormDialog({ method: 'POST', body: JSON.stringify({ type: documentType, - ...buildSaveBody(currentValues, photos) + ...buildSaveBody(currentValues, photos, meta) }) }, token @@ -250,36 +281,50 @@ export function DocumentFormDialog({ return { id: created.id, created: true }; } - async function uploadPhoto(file: File) { + async function uploadAttachment(file: File) { if (!token || readOnly) return; setIsPhotoUploading(true); try { await commitFocusedField(); const currentValues = collectFormValues(); const currentPhotos = photoStorageKeysRef.current; - const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos); - if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки фото'); + const currentMeta = attachmentMetaRef.current; + const contentType = resolveDocumentFileContentType(file); + const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos, currentMeta); + if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки файла'); const presigned = await apiFetch( `/media/documents/${targetDocumentId}/photo/upload-url`, - { method: 'POST', body: JSON.stringify({ contentType: file.type }) }, + { + method: 'POST', + body: JSON.stringify({ contentType, fileName: file.name }) + }, token ); - await uploadMediaObject(presigned, file, file.type); + await uploadMediaObject(presigned, file, contentType); const nextPhotos = [...currentPhotos, presigned.storageKey]; + const nextMeta = { + ...currentMeta, + [presigned.storageKey]: { + fileName: file.name, + contentType + } + }; photoStorageKeysRef.current = nextPhotos; + attachmentMetaRef.current = nextMeta; setPhotoStorageKeys(nextPhotos); - await patchDocument(targetDocumentId, currentValues, nextPhotos); + setAttachmentMeta(nextMeta); + await patchDocument(targetDocumentId, currentValues, nextPhotos, nextMeta); if (created) { await onSaved(); } - showToast('Фото добавлено'); + showToast('Файл добавлен'); } catch (error) { - const message = getApiErrorMessage(error, 'Не удалось загрузить фото'); + const message = getApiErrorMessage(error, 'Не удалось загрузить файл'); if (message) showToast(message); } finally { setIsPhotoUploading(false); @@ -290,14 +335,18 @@ export function DocumentFormDialog({ async function handlePhotoChange(event: React.ChangeEvent) { const files = Array.from(event.target.files ?? []); for (const file of files) { - await uploadPhoto(file); + await uploadAttachment(file); } } function removePhoto(storageKey: string) { const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey); + const nextMeta = { ...attachmentMetaRef.current }; + delete nextMeta[storageKey]; photoStorageKeysRef.current = nextPhotos; + attachmentMetaRef.current = nextMeta; setPhotoStorageKeys(nextPhotos); + setAttachmentMeta(nextMeta); } async function handleSave() { @@ -307,15 +356,16 @@ export function DocumentFormDialog({ await commitFocusedField(); const currentValues = collectFormValues(); const photos = photoStorageKeysRef.current; + const meta = attachmentMetaRef.current; const documentId = draftDocumentIdRef.current ?? existing?.id; if (documentId) { - await patchDocument(documentId, currentValues, photos); + await patchDocument(documentId, currentValues, photos, meta); showToast('Документ обновлён'); } else { const created = await apiFetch( `/documents/users/${userId}`, - { method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos) }) }, + { method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos, meta) }) }, token ); draftDocumentIdRef.current = created.id; @@ -342,7 +392,14 @@ export function DocumentFormDialog({
- {config.label} +
+ {showBackToView && onBackToView ? ( + + ) : null} + {config.label} +
@@ -355,6 +412,7 @@ export function DocumentFormDialog({ userId={userId} token={token} storageKeys={photoStorageKeys} + attachmentMeta={attachmentMeta} readOnly={readOnly} onRemove={readOnly ? undefined : removePhoto} /> @@ -368,10 +426,17 @@ export function DocumentFormDialog({ disabled={isPhotoUploading} className="flex w-full items-center justify-center gap-2 rounded-[20px] bg-[#f4f5f8] px-4 py-4 text-sm font-medium transition hover:bg-[#eceef4]" > - {isPhotoUploading ? : } - {photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'} + {isPhotoUploading ? : } + {photoStorageKeys.length > 0 ? 'Добавить ещё файл' : 'Добавить фото или документ'} - + ) : null} diff --git a/apps/frontend/components/documents/document-photo-gallery.tsx b/apps/frontend/components/documents/document-photo-gallery.tsx index 0707fe2..617bddc 100644 --- a/apps/frontend/components/documents/document-photo-gallery.tsx +++ b/apps/frontend/components/documents/document-photo-gallery.tsx @@ -1,17 +1,33 @@ 'use client'; import * as React from 'react'; -import { Loader2, Trash2 } from 'lucide-react'; +import { Download, FileText, Loader2, Trash2 } from 'lucide-react'; import { useAuth } from '@/components/id/auth-provider'; +import { + attachmentDisplayName, + attachmentFileExtension, + inferAttachmentContentType, + isImageAttachment, + isPdfAttachment, + type DocumentAttachmentMeta +} from '@/lib/document-attachments'; import { fetchDocumentPhotoUrl } from '@/lib/api'; import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch'; import { cn } from '@/lib/utils'; -import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer'; +import { DocumentAttachmentViewer, DocumentPhotoThumbnail } from './document-photo-viewer'; + +export type ResolvedDocumentAttachment = { + storageKey: string; + url: string; + contentType: string; + fileName: string; +}; interface DocumentPhotoGalleryProps { userId: string; token: string | null; storageKeys: string[]; + attachmentMeta?: Record; readOnly?: boolean; onRemove?: (storageKey: string) => void; layout?: 'grid' | 'strip'; @@ -22,20 +38,21 @@ export function DocumentPhotoGallery({ userId, token, storageKeys, + attachmentMeta = {}, readOnly, onRemove, layout = 'grid', className }: DocumentPhotoGalleryProps) { const { isPinLocked } = useAuth(); - const [urls, setUrls] = React.useState>({}); + const [attachments, setAttachments] = React.useState([]); const [loadingKeys, setLoadingKeys] = React.useState>(new Set()); const [viewerOpen, setViewerOpen] = React.useState(false); const [viewerIndex, setViewerIndex] = React.useState(0); React.useEffect(() => { if (!token || storageKeys.length === 0 || isPinLocked) { - setUrls({}); + setAttachments([]); return; } @@ -43,34 +60,43 @@ export function DocumentPhotoGallery({ setLoadingKeys(new Set(storageKeys)); void Promise.all( storageKeys.map(async (storageKey) => { + const meta = attachmentMeta[storageKey]; + const contentType = inferAttachmentContentType(storageKey, meta); + const fileName = attachmentDisplayName(storageKey, meta); try { - const response = await fetchDocumentPhotoUrl(userId, storageKey, token); - return { storageKey, accessUrl: resolveBrowserMediaUrl(response.accessUrl) }; + const response = await fetchDocumentPhotoUrl(userId, storageKey, token, fileName); + return { + storageKey, + accessUrl: resolveBrowserMediaUrl(response.accessUrl), + contentType, + fileName + }; } catch { - return { storageKey, accessUrl: '' }; + return { storageKey, accessUrl: '', contentType, fileName }; } }) ).then((results) => { if (cancelled) return; - const next: Record = {}; - for (const item of results) { - if (item.accessUrl) next[item.storageKey] = item.accessUrl; - } - setUrls(next); + setAttachments( + results + .filter((item) => Boolean(item.accessUrl)) + .map((item) => ({ + storageKey: item.storageKey, + url: item.accessUrl, + contentType: item.contentType, + fileName: item.fileName + })) + ); setLoadingKeys(new Set()); }); return () => { cancelled = true; }; - }, [isPinLocked, storageKeys.join('|'), token, userId]); - - const resolvedImages = storageKeys - .map((storageKey) => ({ storageKey, url: urls[storageKey] })) - .filter((item) => Boolean(item.url)); + }, [attachmentMeta, isPinLocked, storageKeys.join('|'), token, userId]); function openViewer(storageKey: string) { - const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey); + const resolvedIndex = attachments.findIndex((item) => item.storageKey === storageKey); if (resolvedIndex >= 0) { setViewerIndex(resolvedIndex); setViewerOpen(true); @@ -84,67 +110,126 @@ export function DocumentPhotoGallery({ return ( <>
event.stopPropagation()} > - {storageKeys.map((storageKey) => ( -
- openViewer(storageKey)} - /> - {!readOnly && onRemove ? ( + {storageKeys.map((storageKey) => { + const meta = attachmentMeta[storageKey]; + const contentType = inferAttachmentContentType(storageKey, meta); + const fileName = attachmentDisplayName(storageKey, meta); + const resolved = attachments.find((item) => item.storageKey === storageKey); + const loading = loadingKeys.has(storageKey); + + if (isImageAttachment(contentType)) { + return ( +
+ openViewer(storageKey)} + /> + {!readOnly && onRemove ? ( + + ) : null} +
+ ); + } + + return ( +
- ) : null} - {loadingKeys.has(storageKey) ? ( -
- -
- ) : null} -
- ))} + {resolved ? ( + event.stopPropagation()} + > + + + ) : null} + {!readOnly && onRemove ? ( + + ) : null} +
+ ); + })}
- ); } +function RemoveAttachmentButton({ + storageKey, + onRemove, + className +}: { + storageKey: string; + onRemove: (storageKey: string) => void; + className?: string; +}) { + return ( + + ); +} + interface DocumentPhotosInlineProps { userId: string; token: string | null; storageKeys: string[]; + attachmentMeta?: Record; className?: string; } -/** Горизонтальная полоска фото документа для списков (страница «Документы», «Данные»). */ -export function DocumentPhotosInline({ userId, token, storageKeys, className }: DocumentPhotosInlineProps) { +/** Горизонтальная полоска файлов документа для списков. */ +export function DocumentPhotosInline({ userId, token, storageKeys, attachmentMeta, className }: DocumentPhotosInlineProps) { if (!storageKeys.length) return null; return ( void; } -export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) { +export function DocumentAttachmentViewer({ + attachments, + initialIndex = 0, + open, + onOpenChange +}: DocumentAttachmentViewerProps) { const [index, setIndex] = React.useState(initialIndex); const [mounted, setMounted] = React.useState(false); @@ -31,11 +38,11 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe function onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') onOpenChange(false); if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1)); - if (event.key === 'ArrowRight') setIndex((current) => Math.min(images.length - 1, current + 1)); + if (event.key === 'ArrowRight') setIndex((current) => Math.min(attachments.length - 1, current + 1)); } window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [images.length, onOpenChange, open]); + }, [attachments.length, onOpenChange, open]); React.useEffect(() => { if (!open) return; @@ -46,33 +53,48 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe }; }, [open]); - if (!open || images.length === 0 || !mounted) return null; + if (!open || attachments.length === 0 || !mounted) return null; - const current = images[index]; + const current = attachments[index]; + const downloadUrl = `${current.url}${current.url.includes('?') ? '&' : '?'}download=1`; return createPortal(
-

Фото документа

+

{current.fileName}

- {index + 1} из {images.length} + {index + 1} из {attachments.length}

- +
+ + +
- {images.length > 1 ? ( + {attachments.length > 1 ? ( ) : null} - {/* eslint-disable-next-line @next/next/no-img-element */} - Фото документа + {isImageAttachment(current.contentType) ? ( + // eslint-disable-next-line @next/next/no-img-element + {current.fileName} + ) : isPdfAttachment(current.contentType) ? ( +