fix and update
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,14 @@ interface RequesterProfile {
|
||||
canViewUserDocuments?: boolean;
|
||||
}
|
||||
|
||||
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
|
||||
async function getRequesterProfile(
|
||||
jwt: JwtService,
|
||||
core: CoreGrpcService,
|
||||
authorization?: string,
|
||||
touchActivity = true
|
||||
): Promise<RequesterProfile> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AccessTokenPayload> {
|
||||
const payload = await verifyAccessToken(jwt, authorization);
|
||||
await assertSessionUnlocked(core, payload);
|
||||
await assertSessionUnlocked(core, payload, touchActivity);
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" aria-label="Действия с пользователем" disabled={actionLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 rounded-xl">
|
||||
{canInspect ? (
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenInspector}>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
Журнал и чаты
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<>
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onResetPassword}>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Сбросить пароль
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status === 'SUSPENDED'} onClick={onSuspend}>
|
||||
<Ban className="h-4 w-4" />
|
||||
Заблокировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status !== 'SUSPENDED'} onClick={onUnsuspend}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Разблокировать
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
{canDocuments ? (
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenDocuments}>
|
||||
<FileText className="h-4 w-4" />
|
||||
Документы
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDisableTotp ? (
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onDisableTotp}>
|
||||
<ShieldOff className="h-4 w-4" />
|
||||
Отключить 2FA
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canVerify ? (
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenVerification}>
|
||||
<BadgeCheck className="h-4 w-4" />
|
||||
{user.isVerified ? 'Изменить верификацию' : 'Верифицировать'}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canRoles ? (
|
||||
<>
|
||||
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenRoles}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
Роли и доступ
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 rounded-lg"
|
||||
disabled={user.id === currentUser?.id}
|
||||
onClick={onToggleSuperAdmin}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
{user.isSuperAdmin ? 'Снять супер-админа' : 'Назначить супер-админом'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const router = useRouter();
|
||||
const { token, user: currentUser } = useAuth();
|
||||
@@ -367,98 +480,28 @@ export default function AdminUsersPage() {
|
||||
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2" onClick={(event) => event.stopPropagation()}>
|
||||
{(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Журнал и чаты"
|
||||
onClick={() => setInspectorUser(user)}
|
||||
>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.canManageUsers ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Сбросить пароль"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setPassword('');
|
||||
setDialog('password');
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" aria-label="Разблокировать" disabled={actionLoading || user.status !== 'SUSPENDED'} onClick={() => void handleUnsuspend(user)}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{currentUser?.canViewUserDocuments && !user.isBot ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Документы пользователя"
|
||||
disabled={actionLoading}
|
||||
onClick={() => setDocumentsUser(user)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.isSuperAdmin && !user.isBot ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Отключить 2FA"
|
||||
disabled={actionLoading}
|
||||
onClick={() => void handleAdminDisableTotp(user)}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.canVerifyUsers ? (
|
||||
<Button
|
||||
variant={user.isVerified ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
aria-label="Верификация"
|
||||
disabled={actionLoading}
|
||||
onClick={() => openVerificationDialog(user)}
|
||||
>
|
||||
<BadgeCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.canManageRoles ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Управление ролями"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setDialog('roles');
|
||||
}}
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={user.isSuperAdmin ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
aria-label="Супер-админ"
|
||||
disabled={actionLoading || user.id === currentUser?.id}
|
||||
onClick={() => void handleToggleSuperAdmin(user)}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
|
||||
<UserActionsMenu
|
||||
user={user}
|
||||
currentUser={currentUser}
|
||||
actionLoading={actionLoading}
|
||||
onOpenInspector={() => 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)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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 (
|
||||
<div key={document.id}>
|
||||
<Row
|
||||
@@ -334,7 +336,7 @@ export default function DataPage() {
|
||||
/>
|
||||
{photoKeys.length > 0 && userId && token ? (
|
||||
<div className="border-b border-[#eceef4] px-1 pb-4">
|
||||
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} />
|
||||
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -401,7 +403,7 @@ export default function DataPage() {
|
||||
</section>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
|
||||
@@ -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 (
|
||||
<div key={item.type} className="border-b border-white/70 last:border-b-0">
|
||||
<button
|
||||
@@ -115,7 +117,7 @@ export default function DocumentsPage() {
|
||||
</button>
|
||||
{existing && photoKeys.length > 0 && user && token ? (
|
||||
<div className="px-4 pb-4">
|
||||
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} />
|
||||
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -126,7 +128,7 @@ export default function DocumentsPage() {
|
||||
))}
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveType(null);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#667085]">
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
Период просмотра
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
||||
С даты и времени
|
||||
</label>
|
||||
<Input
|
||||
id="insights-from"
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(event) => onFromChange(event.target.value)}
|
||||
/>
|
||||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition hover:bg-[#f4f5f8]"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<CalendarRange className="h-4 w-4 shrink-0 text-[#667085]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-[#667085]">Период просмотра</p>
|
||||
<p className="truncate text-sm text-[#1f2430]">{periodLabel}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-to">
|
||||
По дату и время
|
||||
</label>
|
||||
<Input
|
||||
id="insights-to"
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(event) => onToChange(event.target.value)}
|
||||
/>
|
||||
<ChevronDown className={cn('h-4 w-4 shrink-0 text-[#667085] transition', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-[#eceef4] px-3 pb-3 pt-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
||||
С даты и времени
|
||||
</label>
|
||||
<Input
|
||||
id="insights-from"
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(event) => onFromChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-to">
|
||||
По дату и время
|
||||
</label>
|
||||
<Input
|
||||
id="insights-to"
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(event) => onToChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#667085]">
|
||||
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#667085]">
|
||||
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
apps/frontend/components/documents/document-dialog.tsx
Normal file
80
apps/frontend/components/documents/document-dialog.tsx
Normal file
@@ -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<void>;
|
||||
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 (
|
||||
<DocumentViewDialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
documentType={documentType}
|
||||
userId={userId}
|
||||
token={token}
|
||||
document={existing}
|
||||
readOnly={readOnly}
|
||||
onEdit={readOnly ? undefined : () => setMode('edit')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentFormDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<Record<string, string>>({});
|
||||
const draftDocumentIdRef = useRef<string | undefined>(undefined);
|
||||
const photoStorageKeysRef = useRef<string[]>([]);
|
||||
const attachmentMetaRef = useRef<Record<string, DocumentAttachmentMeta>>({});
|
||||
const wasOpenRef = useRef(false);
|
||||
const [formInstanceKey, setFormInstanceKey] = useState(0);
|
||||
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
|
||||
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
|
||||
const [attachmentMeta, setAttachmentMeta] = useState<Record<string, DocumentAttachmentMeta>>({});
|
||||
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
|
||||
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<string, string>, photos: string[]) {
|
||||
function buildMetadataPayload(
|
||||
source: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
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<string, string>, photos: string[]) {
|
||||
function buildSaveBody(
|
||||
currentValues: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
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<string, string>, photos: string[]) {
|
||||
async function patchDocument(
|
||||
documentId: string,
|
||||
currentValues: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
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<string, string>, photos: string[]) {
|
||||
async function ensureDocumentId(
|
||||
currentValues: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
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<PresignedUploadResponse>(
|
||||
`/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<HTMLInputElement>) {
|
||||
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<UserDocument>(
|
||||
`/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({
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[420px]">
|
||||
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{showBackToView && onBackToView ? (
|
||||
<button type="button" onClick={onBackToView} className="rounded-full p-2 hover:bg-[#f4f5f8]" aria-label="Назад к просмотру">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<DialogTitle className="truncate text-xl font-semibold">{config.label}</DialogTitle>
|
||||
</div>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -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 ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
|
||||
{photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'}
|
||||
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
|
||||
{photoStorageKeys.length > 0 ? 'Добавить ещё файл' : 'Добавить фото или документ'}
|
||||
</button>
|
||||
<input ref={photoInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoChange} />
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept={DOCUMENT_FILE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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<string, DocumentAttachmentMeta>;
|
||||
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<Record<string, string>>({});
|
||||
const [attachments, setAttachments] = React.useState<ResolvedDocumentAttachment[]>([]);
|
||||
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(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<string, string> = {};
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3',
|
||||
className
|
||||
)}
|
||||
className={cn(isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3', className)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{storageKeys.map((storageKey) => (
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
||||
<DocumentPhotoThumbnail
|
||||
url={urls[storageKey]}
|
||||
loading={loadingKeys.has(storageKey)}
|
||||
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
||||
onClick={() => 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 (
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
||||
<DocumentPhotoThumbnail
|
||||
url={resolved?.url}
|
||||
loading={loading}
|
||||
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
||||
onClick={() => openViewer(storageKey)}
|
||||
/>
|
||||
{!readOnly && onRemove ? (
|
||||
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-36 shrink-0')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(storageKey);
|
||||
}}
|
||||
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||
aria-label="Удалить фото"
|
||||
onClick={() => (resolved ? openViewer(storageKey) : undefined)}
|
||||
className={cn(
|
||||
'flex h-full min-h-[96px] w-full flex-col items-start justify-between rounded-2xl border border-[#eceef4] bg-white p-3 text-left transition hover:border-[#20212b]',
|
||||
isStrip && 'min-h-[80px]'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#f4f5f8] text-[#667085]">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-5 w-5" />}
|
||||
</div>
|
||||
<span className="rounded-lg bg-[#20212b] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white">
|
||||
{attachmentFileExtension(contentType, fileName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#1f2430]">{fileName}</p>
|
||||
<p className="mt-0.5 text-xs text-[#667085]">{isPdfAttachment(contentType) ? 'Открыть PDF' : 'Скачать файл'}</p>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
{loadingKeys.has(storageKey) ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{resolved ? (
|
||||
<a
|
||||
href={`${resolved.url}${resolved.url.includes('?') ? '&' : '?'}download=1`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||
aria-label="Скачать файл"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{!readOnly && onRemove ? (
|
||||
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} className="right-2 top-2" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DocumentFullscreenViewer
|
||||
<DocumentAttachmentViewer
|
||||
open={viewerOpen}
|
||||
onOpenChange={setViewerOpen}
|
||||
initialIndex={viewerIndex}
|
||||
images={resolvedImages}
|
||||
attachments={attachments}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveAttachmentButton({
|
||||
storageKey,
|
||||
onRemove,
|
||||
className
|
||||
}: {
|
||||
storageKey: string;
|
||||
onRemove: (storageKey: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(storageKey);
|
||||
}}
|
||||
className={cn('absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80', className)}
|
||||
aria-label="Удалить файл"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentPhotosInlineProps {
|
||||
userId: string;
|
||||
token: string | null;
|
||||
storageKeys: string[];
|
||||
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
||||
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 (
|
||||
<DocumentPhotoGallery
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={storageKeys}
|
||||
attachmentMeta={attachmentMeta}
|
||||
readOnly
|
||||
layout="strip"
|
||||
className={className}
|
||||
|
||||
@@ -2,19 +2,26 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Download, X, ZoomIn } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import { isImageAttachment, isPdfAttachment } from '@/lib/document-attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ResolvedDocumentAttachment } from './document-photo-gallery';
|
||||
|
||||
interface DocumentFullscreenViewerProps {
|
||||
images: { storageKey: string; url: string }[];
|
||||
interface DocumentAttachmentViewerProps {
|
||||
attachments: ResolvedDocumentAttachment[];
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => 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(
|
||||
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-white">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">Фото документа</p>
|
||||
<p className="truncate text-sm font-medium">{current.fileName}</p>
|
||||
<p className="text-xs text-white/60">
|
||||
{index + 1} из {images.length}
|
||||
{index + 1} из {attachments.length}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Закрыть"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Скачать"
|
||||
asChild
|
||||
>
|
||||
<a href={downloadUrl} target="_blank" rel="noreferrer">
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Закрыть"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
|
||||
{images.length > 1 ? (
|
||||
{attachments.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
@@ -81,51 +103,73 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index <= 0 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Предыдущее фото"
|
||||
aria-label="Предыдущий файл"
|
||||
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.url}
|
||||
alt="Фото документа"
|
||||
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
|
||||
/>
|
||||
{isImageAttachment(current.contentType) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.fileName}
|
||||
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
|
||||
/>
|
||||
) : isPdfAttachment(current.contentType) ? (
|
||||
<iframe
|
||||
title={current.fileName}
|
||||
src={current.url}
|
||||
className="h-[calc(100vh-140px)] w-full max-w-5xl rounded-2xl bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-w-md rounded-[24px] bg-white/10 p-8 text-center text-white backdrop-blur-sm">
|
||||
<p className="text-lg font-medium">{current.fileName}</p>
|
||||
<p className="mt-2 text-sm text-white/70">Предпросмотр недоступен для этого типа файла</p>
|
||||
<Button className="mt-6 rounded-xl" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noreferrer">
|
||||
Скачать файл
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 1 ? (
|
||||
{attachments.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index >= images.length - 1 && 'pointer-events-none opacity-30'
|
||||
index >= attachments.length - 1 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Следующее фото"
|
||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
||||
aria-label="Следующий файл"
|
||||
onClick={() => setIndex((value) => Math.min(attachments.length - 1, value + 1))}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
{attachments.length > 1 ? (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
|
||||
{images.map((image, imageIndex) => (
|
||||
{attachments.map((attachment, attachmentIndex) => (
|
||||
<button
|
||||
key={image.storageKey}
|
||||
key={attachment.storageKey}
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2',
|
||||
imageIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||
'h-14 min-w-[56px] shrink-0 overflow-hidden rounded-lg border-2 px-2 text-xs text-white',
|
||||
attachmentIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||
)}
|
||||
onClick={() => setIndex(imageIndex)}
|
||||
onClick={() => setIndex(attachmentIndex)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
||||
{isImageAttachment(attachment.contentType) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={attachment.url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center">{attachment.fileName.slice(0, 8)}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -135,6 +179,27 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Используйте DocumentAttachmentViewer */
|
||||
export function DocumentFullscreenViewer({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
images: { storageKey: string; url: string }[];
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const attachments: ResolvedDocumentAttachment[] = images.map((image) => ({
|
||||
storageKey: image.storageKey,
|
||||
url: image.url,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'Фото документа'
|
||||
}));
|
||||
return <DocumentAttachmentViewer attachments={attachments} initialIndex={initialIndex} open={open} onOpenChange={onOpenChange} />;
|
||||
}
|
||||
|
||||
interface DocumentPhotoThumbnailProps {
|
||||
url?: string;
|
||||
loading?: boolean;
|
||||
@@ -156,7 +221,7 @@ export function DocumentPhotoThumbnail({ url, loading, className, onClick }: Doc
|
||||
{!loading && url ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url} alt="Фото документа" className="h-full w-full object-cover" />
|
||||
<img src={url} alt="Файл документа" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20">
|
||||
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
|
||||
</div>
|
||||
|
||||
270
apps/frontend/components/documents/document-view-card.tsx
Normal file
270
apps/frontend/components/documents/document-view-card.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import {
|
||||
buildDocumentNumber,
|
||||
DOCUMENT_TYPES,
|
||||
getDocumentType,
|
||||
LICENSE_CATEGORIES,
|
||||
parseMetadata,
|
||||
type DocumentTypeCode,
|
||||
type DocumentTypeDef
|
||||
} from '@/lib/document-catalog';
|
||||
import { UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDateValue(value?: string) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return format(date, 'd MMMM yyyy', { locale: ru });
|
||||
}
|
||||
|
||||
function formatFieldValue(fieldKind: string, value: string) {
|
||||
if (!value) return '—';
|
||||
if (fieldKind === 'date') return formatDateValue(value);
|
||||
if (fieldKind === 'gender') return value === 'male' ? 'Мужской' : value === 'female' ? 'Женский' : value;
|
||||
if (fieldKind === 'checkbox') return value === 'true' ? 'Да' : '—';
|
||||
if (fieldKind === 'categories') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fullName(values: Record<string, string>) {
|
||||
const parts = [values.lastName, values.firstName, values.middleName].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '—';
|
||||
}
|
||||
|
||||
function latinFullName(values: Record<string, string>) {
|
||||
const parts = [values.lastNameLatin, values.firstNameLatin, values.middleNameLatin].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '';
|
||||
}
|
||||
|
||||
function DocumentField({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
if (!value || value === '—') return null;
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] opacity-70">{label}</p>
|
||||
<p className="mt-0.5 text-sm font-medium leading-snug">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassportRfCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#6b1d24]/20 bg-[linear-gradient(145deg,#7a2430_0%,#5c1820_55%,#3d1016_100%)] p-5 text-[#fff6eb] shadow-[0_24px_60px_rgba(92,24,32,0.35)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#f6d9a8]">Российская Федерация</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-[#f6d9a8]/40 bg-[#f6d9a8]/10 text-xs font-semibold text-[#f6d9a8]">
|
||||
РФ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[18px] bg-black/15 p-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#f6d9a8]/80">Серия и номер</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || buildDocumentNumber(config, values)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="ФИО" value={fullName(values)} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
|
||||
<DocumentField label="Пол" value={formatFieldValue('gender', values.gender ?? '')} />
|
||||
</div>
|
||||
<DocumentField label="Место рождения" value={values.birthPlace ?? ''} />
|
||||
<DocumentField label="Кем выдан" value={values.issuedBy ?? ''} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
<DocumentField label="Код подразделения" value={values.departmentCode ?? ''} />
|
||||
</div>
|
||||
<DocumentField label="Адрес регистрации" value={values.registrationAddress ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForeignPassportCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
const latin = latinFullName(values);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#5a1830]/20 bg-[linear-gradient(145deg,#6d2138_0%,#451222_100%)] p-5 text-[#fff4f0] shadow-[0_24px_60px_rgba(69,18,34,0.35)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#ffd3bf]">Passport</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-widest text-[#ffd3bf]">P</div>
|
||||
</div>
|
||||
<div className="mt-5 rounded-[18px] bg-black/15 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] opacity-70">No.</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || values.number || '—'}</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="ФИО" value={fullName(values)} />
|
||||
{latin ? <DocumentField label="Name" value={latin} className="font-mono" /> : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Гражданство" value={values.citizenship ?? ''} />
|
||||
<DocumentField label="Пол" value={formatFieldValue('gender', values.gender ?? '')} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
</div>
|
||||
<DocumentField label="Действует до" value={formatFieldValue('date', values.expiresAt ?? document.expiresAt?.slice(0, 10) ?? '')} />
|
||||
<DocumentField label="Место рождения" value={values.birthPlace ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverLicenseCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
const categories = formatFieldValue('categories', values.categories ?? '');
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#7c5cff]/20 bg-[linear-gradient(145deg,#f3ecff_0%,#ddd0ff_45%,#c8b6ff_100%)] p-5 text-[#2f2450] shadow-[0_24px_60px_rgba(124,92,255,0.18)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6b4fd8]">Водительское удостоверение</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#6b4fd8] px-3 py-2 text-xs font-bold uppercase tracking-widest text-white">RU</div>
|
||||
</div>
|
||||
<div className="mt-5 rounded-[18px] bg-white/70 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#6b4fd8]">Номер</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || values.number || '—'}</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="ФИО" value={fullName(values)} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
</div>
|
||||
<DocumentField label="Действует до" value={formatFieldValue('date', values.expiresAt ?? document.expiresAt?.slice(0, 10) ?? '')} />
|
||||
<DocumentField label="Категории" value={categories} />
|
||||
{values.specialMarks ? <DocumentField label="Особые отметки" value={values.specialMarks} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleRegistrationCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#3b82f6]/15 bg-[linear-gradient(145deg,#eff6ff_0%,#dbeafe_100%)] p-5 text-[#1e3a5f] shadow-[0_24px_60px_rgba(59,130,246,0.12)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#2563eb]">Свидетельство о регистрации</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#2563eb] px-3 py-2 text-xs font-bold uppercase tracking-widest text-white">СТС</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-[18px] bg-white/80 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#2563eb]">Серия и номер</p>
|
||||
<p className="mt-1 font-mono text-xl font-semibold">{document.number || values.seriesNumber || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-[18px] bg-white/80 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#2563eb]">Госномер</p>
|
||||
<p className="mt-1 font-mono text-xl font-semibold uppercase">{values.plateNumber || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="Марка, модель" value={values.makeModel ?? ''} />
|
||||
<DocumentField label="VIN" value={values.vin ?? ''} className="font-mono" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Год выпуска" value={values.year ?? ''} />
|
||||
<DocumentField label="Цвет" value={values.color ?? ''} />
|
||||
</div>
|
||||
<DocumentField label="Владелец" value={fullName(values)} />
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericDocumentCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
const Icon = config.icon;
|
||||
const visibleFields = config.fields
|
||||
.map((field) => ({
|
||||
label: field.label,
|
||||
value: formatFieldValue(field.kind, values[field.key] ?? (field.key === 'issuedAt' ? document.issuedAt?.slice(0, 10) ?? '' : field.key === 'expiresAt' ? document.expiresAt?.slice(0, 10) ?? '' : ''))
|
||||
}))
|
||||
.filter((field) => field.value && field.value !== '—');
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-[24px] border border-[#eceef4] bg-white p-5 shadow-[0_20px_50px_rgba(31,36,48,0.08)]')}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn('flex h-14 w-14 items-center justify-center rounded-2xl', config.accent)}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#667085]">Документ</p>
|
||||
<p className="mt-1 text-xl font-semibold text-[#1f2430]">{config.label}</p>
|
||||
<p className="mt-2 font-mono text-lg text-[#3390ec]">{document.number || buildDocumentNumber(config, values)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{visibleFields.map((field) => (
|
||||
<DocumentField key={field.label} label={field.label} value={field.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentViewCard({
|
||||
documentType,
|
||||
document
|
||||
}: {
|
||||
documentType: DocumentTypeCode;
|
||||
document: UserDocument;
|
||||
}) {
|
||||
const config = getDocumentType(documentType) ?? DOCUMENT_TYPES.find((item) => item.type === documentType);
|
||||
if (!config) return null;
|
||||
|
||||
const metadata = parseMetadata(document.metadataJson);
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of config.fields) {
|
||||
const raw = metadata[field.key];
|
||||
values[field.key] = typeof raw === 'string' ? raw : '';
|
||||
if (field.latinKey) {
|
||||
const latinRaw = metadata[field.latinKey];
|
||||
values[field.latinKey] = typeof latinRaw === 'string' ? latinRaw : '';
|
||||
}
|
||||
}
|
||||
if (document.issuedAt) values.issuedAt = document.issuedAt.slice(0, 10);
|
||||
if (document.expiresAt) values.expiresAt = document.expiresAt.slice(0, 10);
|
||||
|
||||
switch (documentType) {
|
||||
case 'PASSPORT_RF':
|
||||
return <PassportRfCard config={config} values={values} document={document} />;
|
||||
case 'FOREIGN_PASSPORT':
|
||||
return <ForeignPassportCard config={config} values={values} document={document} />;
|
||||
case 'DRIVER_LICENSE':
|
||||
return <DriverLicenseCard config={config} values={values} document={document} />;
|
||||
case 'VEHICLE_REGISTRATION':
|
||||
return <VehicleRegistrationCard config={config} values={values} document={document} />;
|
||||
default:
|
||||
return <GenericDocumentCard config={config} values={values} document={document} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function DriverLicenseCategoryBadges({ value }: { value: string }) {
|
||||
const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean));
|
||||
if (!selected.size) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LICENSE_CATEGORIES.filter((category) => selected.has(category)).map((category) => (
|
||||
<span key={category} className="rounded-lg bg-[#20212b] px-2 py-1 text-xs font-medium text-white">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/frontend/components/documents/document-view-dialog.tsx
Normal file
75
apps/frontend/components/documents/document-view-dialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
|
||||
import { DocumentViewCard } from '@/components/documents/document-view-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getDocumentType, parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { UserDocument } from '@/lib/api';
|
||||
|
||||
export function DocumentViewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
userId,
|
||||
token,
|
||||
document,
|
||||
readOnly,
|
||||
onEdit
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentTypeCode;
|
||||
userId: string;
|
||||
token: string | null;
|
||||
document: UserDocument;
|
||||
readOnly?: boolean;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const config = getDocumentType(documentType);
|
||||
const metadata = parseMetadata(document.metadataJson);
|
||||
const storageKeys = parseDocumentPhotos(metadata);
|
||||
const attachmentMeta = parseAttachmentMeta(metadata);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[480px]">
|
||||
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]" aria-label="Закрыть">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-5 pb-5 pt-4">
|
||||
<DocumentViewCard documentType={documentType} document={document} />
|
||||
|
||||
{storageKeys.length > 0 ? (
|
||||
<section>
|
||||
<p className="mb-3 text-sm font-medium text-[#1f2430]">Прикреплённые файлы</p>
|
||||
<DocumentPhotoGallery
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={storageKeys}
|
||||
attachmentMeta={attachmentMeta}
|
||||
readOnly
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!readOnly && onEdit ? (
|
||||
<Button type="button" className="w-full rounded-[18px] py-6" onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Редактировать
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FileText } 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getDocumentType, indexDocumentsByType, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { getDocumentType, indexDocumentsByType, parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { apiFetch, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -70,6 +70,7 @@ export function UserDocumentsDialog({
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
|
||||
return (
|
||||
<div key={document.id}>
|
||||
<button
|
||||
@@ -88,7 +89,7 @@ export function UserDocumentsDialog({
|
||||
</button>
|
||||
{photoKeys.length > 0 && token ? (
|
||||
<div className="mt-2 px-1">
|
||||
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} />
|
||||
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -99,7 +100,7 @@ export function UserDocumentsDialog({
|
||||
</Dialog>
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
|
||||
import { readStoredFamilyGroupId, writeStoredFamilyGroupId } from '@/lib/selected-family-storage';
|
||||
|
||||
type FamilyOverlayContextValue = {
|
||||
openMiniChat: () => void;
|
||||
@@ -14,6 +13,7 @@ type FamilyOverlayContextValue = {
|
||||
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
|
||||
clearPending: () => void;
|
||||
selectedGroupId: string | null;
|
||||
isSelectionHydrated: boolean;
|
||||
setSelectedGroupId: (groupId: string | null) => void;
|
||||
};
|
||||
|
||||
@@ -24,23 +24,20 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
||||
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
|
||||
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
|
||||
const [isSelectionHydrated, setIsSelectionHydrated] = useState(false);
|
||||
const pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||
const selectionHydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionHydratedRef.current) return;
|
||||
selectionHydratedRef.current = true;
|
||||
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY);
|
||||
if (stored) setSelectedGroupIdState(stored);
|
||||
const stored = readStoredFamilyGroupId();
|
||||
if (stored) {
|
||||
setSelectedGroupIdState(stored);
|
||||
}
|
||||
setIsSelectionHydrated(true);
|
||||
}, []);
|
||||
|
||||
const setSelectedGroupId = useCallback((groupId: string | null) => {
|
||||
setSelectedGroupIdState(groupId);
|
||||
if (groupId) {
|
||||
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
|
||||
} else {
|
||||
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
|
||||
}
|
||||
writeStoredFamilyGroupId(groupId);
|
||||
}, []);
|
||||
|
||||
const openMiniChat = useCallback(() => {
|
||||
@@ -86,11 +83,13 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
||||
pendingMember,
|
||||
clearPending,
|
||||
selectedGroupId,
|
||||
isSelectionHydrated,
|
||||
setSelectedGroupId
|
||||
}),
|
||||
[
|
||||
clearPending,
|
||||
closeMiniChat,
|
||||
isSelectionHydrated,
|
||||
miniChatOpen,
|
||||
openChatRoom,
|
||||
openChatWithMember,
|
||||
|
||||
@@ -456,6 +456,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
void refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionReady || isPinLocked) return;
|
||||
if (pathname.startsWith('/auth/')) return;
|
||||
const accessToken = token ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
if (!accessToken) return;
|
||||
|
||||
void fetchAuthSession(accessToken)
|
||||
.then((session) => {
|
||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isPinRequiredError(error)) {
|
||||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||
}
|
||||
});
|
||||
}, [activatePinLock, clearPinLock, isPinLocked, isSessionReady, pathname, token]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
fetchFamilyPresence,
|
||||
getAccessToken
|
||||
} from '@/lib/api';
|
||||
import { resolveFamilyGroupId } from '@/lib/selected-family-storage';
|
||||
|
||||
export function useSelectedFamily(enabled = true) {
|
||||
const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
|
||||
const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay();
|
||||
const { selectedGroupId, setSelectedGroupId, isSelectionHydrated } = useFamilyOverlay();
|
||||
const userId = user?.id;
|
||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||
const [group, setGroup] = useState<FamilyGroup | null>(null);
|
||||
@@ -33,6 +34,8 @@ export function useSelectedFamily(enabled = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pendingHydration = false;
|
||||
|
||||
try {
|
||||
const groupsResponse = await fetchFamilyGroups(userId, accessToken);
|
||||
const list = groupsResponse.groups ?? [];
|
||||
@@ -46,8 +49,11 @@ export function useSelectedFamily(enabled = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveId =
|
||||
selectedGroupId && list.some((item) => item.id === selectedGroupId) ? selectedGroupId : list[0]!.id;
|
||||
const effectiveId = resolveFamilyGroupId(list, selectedGroupId, isSelectionHydrated);
|
||||
if (!effectiveId) {
|
||||
pendingHydration = !isSelectionHydrated;
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveId !== selectedGroupId) {
|
||||
setSelectedGroupId(effectiveId);
|
||||
@@ -63,9 +69,11 @@ export function useSelectedFamily(enabled = true) {
|
||||
} catch {
|
||||
// Фоновая ошибка не должна сбрасывать уже показанную семью в сайдбаре.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!pendingHydration) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [enabled, isContentReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, userId]);
|
||||
}, [enabled, isContentReady, isLoading, isPinLocked, isSelectionHydrated, selectedGroupId, setSelectedGroupId, token, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !userId || !token || isPinLocked || isLoading || !isContentReady) {
|
||||
@@ -78,7 +86,7 @@ export function useSelectedFamily(enabled = true) {
|
||||
}
|
||||
setLoading(!hasLoadedRef.current);
|
||||
void refresh();
|
||||
}, [enabled, isContentReady, isLoading, isPinLocked, refresh, token, userId]);
|
||||
}, [enabled, isContentReady, isLoading, isPinLocked, isSelectionHydrated, refresh, token, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
|
||||
@@ -1007,6 +1007,7 @@ export async function apiFetch<T>(
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
||||
...(behavior.silent ? { 'X-Id-Passive-Activity': '1' } : {}),
|
||||
...(options.headers as Record<string, string> | undefined)
|
||||
};
|
||||
|
||||
@@ -1075,8 +1076,14 @@ export interface MediaAccessResponse {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) {
|
||||
export async function fetchDocumentPhotoUrl(
|
||||
userId: string,
|
||||
storageKey: string,
|
||||
token?: string | null,
|
||||
fileName?: string
|
||||
) {
|
||||
const query = new URLSearchParams({ storageKey });
|
||||
if (fileName) query.set('fileName', fileName);
|
||||
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
|
||||
}
|
||||
|
||||
|
||||
76
apps/frontend/lib/document-attachments.ts
Normal file
76
apps/frontend/lib/document-attachments.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type DocumentAttachmentMeta = {
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export const DOCUMENT_FILE_ACCEPT =
|
||||
'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.odt,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
rtf: 'application/rtf',
|
||||
odt: 'application/vnd.oasis.opendocument.text'
|
||||
};
|
||||
|
||||
export function resolveDocumentFileContentType(file: File) {
|
||||
const fromType = file.type?.trim();
|
||||
if (fromType && fromType !== 'application/octet-stream') {
|
||||
return fromType;
|
||||
}
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
return EXTENSION_TO_MIME[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function inferAttachmentContentType(storageKey: string, meta?: DocumentAttachmentMeta) {
|
||||
if (meta?.contentType) return meta.contentType;
|
||||
const extension = storageKey.split('.').pop()?.toLowerCase() ?? '';
|
||||
return EXTENSION_TO_MIME[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function isImageAttachment(contentType: string) {
|
||||
return contentType.startsWith('image/');
|
||||
}
|
||||
|
||||
export function isPdfAttachment(contentType: string) {
|
||||
return contentType === 'application/pdf';
|
||||
}
|
||||
|
||||
export function attachmentKindLabel(contentType: string) {
|
||||
if (isImageAttachment(contentType)) return 'Изображение';
|
||||
if (isPdfAttachment(contentType)) return 'PDF';
|
||||
if (contentType.includes('word') || contentType === 'application/msword') return 'Word';
|
||||
if (contentType.includes('excel') || contentType === 'application/vnd.ms-excel') return 'Excel';
|
||||
if (contentType.includes('powerpoint') || contentType === 'application/vnd.ms-powerpoint') return 'PowerPoint';
|
||||
if (contentType === 'text/plain') return 'Текст';
|
||||
return 'Файл';
|
||||
}
|
||||
|
||||
export function attachmentDisplayName(storageKey: string, meta?: DocumentAttachmentMeta) {
|
||||
if (meta?.fileName?.trim()) return meta.fileName.trim();
|
||||
const parts = storageKey.split('/');
|
||||
return parts[parts.length - 1] ?? 'Файл';
|
||||
}
|
||||
|
||||
export function attachmentFileExtension(contentType: string, fileName?: string) {
|
||||
const fromName = fileName?.split('.').pop()?.toUpperCase();
|
||||
if (fromName && fromName.length <= 5) return fromName;
|
||||
if (isPdfAttachment(contentType)) return 'PDF';
|
||||
if (contentType.includes('word')) return 'DOC';
|
||||
if (contentType.includes('excel')) return 'XLS';
|
||||
if (contentType.includes('powerpoint')) return 'PPT';
|
||||
if (contentType === 'text/plain') return 'TXT';
|
||||
if (isImageAttachment(contentType)) return 'IMG';
|
||||
return 'FILE';
|
||||
}
|
||||
@@ -290,9 +290,34 @@ export function parseDocumentPhotos(metadata: Record<string, unknown>): string[]
|
||||
return [];
|
||||
}
|
||||
|
||||
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[]) {
|
||||
export type DocumentAttachmentMeta = {
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export function parseAttachmentMeta(metadata: Record<string, unknown>): Record<string, DocumentAttachmentMeta> {
|
||||
const raw = metadata.attachmentMeta;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, DocumentAttachmentMeta> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const entry = value as Record<string, unknown>;
|
||||
result[key] = {
|
||||
fileName: typeof entry.fileName === 'string' ? entry.fileName : undefined,
|
||||
contentType: typeof entry.contentType === 'string' ? entry.contentType : undefined
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[], attachmentMeta?: Record<string, DocumentAttachmentMeta>) {
|
||||
const next: Record<string, unknown> = { ...metadata, photoStorageKeys };
|
||||
delete next.photoStorageKey;
|
||||
if (attachmentMeta && Object.keys(attachmentMeta).length > 0) {
|
||||
next.attachmentMeta = attachmentMeta;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
43
apps/frontend/lib/selected-family-storage.ts
Normal file
43
apps/frontend/lib/selected-family-storage.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
|
||||
|
||||
export function readStoredFamilyGroupId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY);
|
||||
return stored?.trim() ? stored : null;
|
||||
}
|
||||
|
||||
export function writeStoredFamilyGroupId(groupId: string | null) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (groupId) {
|
||||
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
|
||||
} else {
|
||||
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFamilyGroupId(
|
||||
groups: Array<{ id: string }>,
|
||||
selectedGroupId: string | null,
|
||||
isSelectionHydrated: boolean
|
||||
): string | null {
|
||||
if (!groups.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: Array<string | null> = [selectedGroupId];
|
||||
if (!isSelectionHydrated) {
|
||||
candidates.unshift(readStoredFamilyGroupId());
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && groups.some((group) => group.id === candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return isSelectionHydrated ? groups[0]!.id : null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Session" ADD COLUMN "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
UPDATE "Session" SET "lastActivityAt" = "updatedAt";
|
||||
@@ -132,6 +132,7 @@ model Session {
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
expiresAt DateTime
|
||||
lastActivityAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -800,13 +800,13 @@ export class AuthGrpcController {
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl')
|
||||
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string }) {
|
||||
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType);
|
||||
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string; fileName?: string }) {
|
||||
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl')
|
||||
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string }) {
|
||||
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey);
|
||||
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string; fileName?: string }) {
|
||||
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'ResolveStreamToken')
|
||||
|
||||
@@ -115,9 +115,10 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string) {
|
||||
async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string, fileName?: string) {
|
||||
let normalizedContentType: string;
|
||||
try {
|
||||
this.minio.assertImageContentType(contentType);
|
||||
normalizedContentType = this.minio.assertDocumentAttachmentContentType(contentType, fileName);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
@@ -128,9 +129,9 @@ export class MediaService {
|
||||
throw new NotFoundException('Документ не найден');
|
||||
}
|
||||
|
||||
const extension = this.minio.extensionForContentType(contentType);
|
||||
const extension = this.minio.extensionForDocumentContentType(normalizedContentType, fileName);
|
||||
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
|
||||
return this.createUploadTarget(userId, storageKey, contentType);
|
||||
return this.createUploadTarget(userId, storageKey, normalizedContentType);
|
||||
}
|
||||
|
||||
async resolveStreamToken(token: string, requesterId?: string) {
|
||||
@@ -177,18 +178,19 @@ export class MediaService {
|
||||
);
|
||||
}
|
||||
|
||||
async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string) {
|
||||
async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string, fileName?: string) {
|
||||
await this.access.assertCanViewUserDocuments(requesterId, targetUserId);
|
||||
|
||||
if (!storageKey.startsWith(`documents/${targetUserId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ фото документа');
|
||||
throw new BadRequestException('Некорректный ключ файла документа');
|
||||
}
|
||||
|
||||
const token = await this.jwt.signAsync(
|
||||
{
|
||||
purpose: 'media-stream',
|
||||
objectKey: storageKey,
|
||||
ownerId: targetUserId
|
||||
ownerId: targetUserId,
|
||||
fileName: fileName?.trim() || undefined
|
||||
} satisfies MediaStreamPayload,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
|
||||
@@ -169,6 +169,7 @@ export class PinService {
|
||||
data: {
|
||||
pinVerified: true,
|
||||
status: SessionStatus.ACTIVE,
|
||||
lastActivityAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,7 +47,8 @@ export class SessionService {
|
||||
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
|
||||
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
||||
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
||||
if (session.updatedAt < threshold) {
|
||||
const lastActivityAt = session.lastActivityAt ?? session.updatedAt;
|
||||
if (lastActivityAt < threshold) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
||||
@@ -76,7 +77,7 @@ export class SessionService {
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { updatedAt: new Date() }
|
||||
data: { lastActivityAt: new Date(), updatedAt: new Date() }
|
||||
});
|
||||
|
||||
return state;
|
||||
@@ -160,7 +161,7 @@ export class SessionService {
|
||||
return this.prisma.session.updateMany({
|
||||
where: {
|
||||
pinVerified: true,
|
||||
updatedAt: { lt: threshold },
|
||||
lastActivityAt: { lt: threshold },
|
||||
status: SessionStatus.ACTIVE,
|
||||
user: { pinCode: { isEnabled: true } }
|
||||
},
|
||||
|
||||
141
apps/sso-core/src/infra/document-media.util.ts
Normal file
141
apps/sso-core/src/infra/document-media.util.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
const BLOCKED_DOCUMENT_TYPES = new Set([
|
||||
'application/javascript',
|
||||
'application/x-javascript',
|
||||
'text/javascript',
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'application/x-httpd-php',
|
||||
'application/x-sh'
|
||||
]);
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
rtf: 'application/rtf',
|
||||
odt: 'application/vnd.oasis.opendocument.text'
|
||||
};
|
||||
|
||||
const ALLOWED_DOCUMENT_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'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'
|
||||
]);
|
||||
|
||||
const MIME_ALIASES: Record<string, string> = {
|
||||
'image/jpg': 'image/jpeg',
|
||||
'image/pjpeg': 'image/jpeg',
|
||||
'application/x-pdf': 'application/pdf'
|
||||
};
|
||||
|
||||
function extractFileExtension(fileName?: string | null) {
|
||||
const trimmed = (fileName ?? '').trim().toLowerCase();
|
||||
const dotIndex = trimmed.lastIndexOf('.');
|
||||
if (dotIndex <= 0) return '';
|
||||
return trimmed.slice(dotIndex + 1);
|
||||
}
|
||||
|
||||
export function normalizeDocumentContentType(contentType?: string | null, fileName?: string | null) {
|
||||
const raw = (contentType ?? '').trim().toLowerCase();
|
||||
const base = raw.split(';')[0]?.trim() ?? '';
|
||||
const aliased = MIME_ALIASES[base] ?? base;
|
||||
|
||||
if (aliased && aliased !== 'application/octet-stream' && ALLOWED_DOCUMENT_MIME.has(aliased)) {
|
||||
return aliased;
|
||||
}
|
||||
|
||||
const extension = extractFileExtension(fileName);
|
||||
if (extension && EXTENSION_TO_MIME[extension]) {
|
||||
return EXTENSION_TO_MIME[extension];
|
||||
}
|
||||
|
||||
return aliased || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function assertDocumentAttachmentContentType(contentType: string, fileName?: string) {
|
||||
const normalized = normalizeDocumentContentType(contentType, fileName);
|
||||
if (BLOCKED_DOCUMENT_TYPES.has(normalized)) {
|
||||
throw new Error('Тип файла не поддерживается');
|
||||
}
|
||||
if (!ALLOWED_DOCUMENT_MIME.has(normalized)) {
|
||||
throw new Error('Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function extensionForDocumentContentType(contentType: string, fileName?: string) {
|
||||
const normalized = normalizeDocumentContentType(contentType, fileName);
|
||||
switch (normalized) {
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'application/pdf':
|
||||
return 'pdf';
|
||||
case 'application/msword':
|
||||
return 'doc';
|
||||
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
return 'docx';
|
||||
case 'application/vnd.ms-excel':
|
||||
return 'xls';
|
||||
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
|
||||
return 'xlsx';
|
||||
case 'application/vnd.ms-powerpoint':
|
||||
return 'ppt';
|
||||
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
return 'pptx';
|
||||
case 'text/plain':
|
||||
return 'txt';
|
||||
case 'application/rtf':
|
||||
return 'rtf';
|
||||
case 'application/vnd.oasis.opendocument.text':
|
||||
return 'odt';
|
||||
default: {
|
||||
const extension = extractFileExtension(fileName);
|
||||
return extension || 'bin';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contentTypeForDocumentKey(storageKey: string) {
|
||||
const lower = storageKey.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.doc')) return 'application/msword';
|
||||
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
|
||||
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
|
||||
if (lower.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
if (lower.endsWith('.rtf')) return 'application/rtf';
|
||||
if (lower.endsWith('.odt')) return 'application/vnd.oasis.opendocument.text';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
extensionForChatContentType,
|
||||
normalizeChatContentType
|
||||
} from './chat-media.util';
|
||||
import {
|
||||
assertDocumentAttachmentContentType as assertDocumentAttachment,
|
||||
contentTypeForDocumentKey as resolveDocumentContentType,
|
||||
extensionForDocumentContentType as mapDocumentExtension,
|
||||
normalizeDocumentContentType
|
||||
} from './document-media.util';
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
@@ -89,6 +95,18 @@ export class MinioService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
assertDocumentAttachmentContentType(contentType: string, fileName?: string) {
|
||||
return assertDocumentAttachment(contentType, fileName);
|
||||
}
|
||||
|
||||
extensionForDocumentContentType(contentType: string, fileName?: string) {
|
||||
return mapDocumentExtension(normalizeDocumentContentType(contentType, fileName), fileName);
|
||||
}
|
||||
|
||||
contentTypeForDocumentKey(storageKey: string) {
|
||||
return resolveDocumentContentType(storageKey);
|
||||
}
|
||||
|
||||
assertImageContentType(contentType: string) {
|
||||
if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
|
||||
throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF');
|
||||
@@ -133,6 +151,9 @@ export class MinioService implements OnModuleInit {
|
||||
if (storageKey.startsWith('chat/')) {
|
||||
return contentTypeForStorageKey(storageKey);
|
||||
}
|
||||
if (storageKey.startsWith('documents/')) {
|
||||
return this.contentTypeForDocumentKey(storageKey);
|
||||
}
|
||||
if (storageKey.endsWith('.png')) return 'image/png';
|
||||
if (storageKey.endsWith('.webp')) return 'image/webp';
|
||||
if (storageKey.endsWith('.gif')) return 'image/gif';
|
||||
|
||||
@@ -38,12 +38,14 @@ message DocumentPhotoUploadRequest {
|
||||
string userId = 1;
|
||||
string documentId = 2;
|
||||
string contentType = 3;
|
||||
optional string fileName = 4;
|
||||
}
|
||||
|
||||
message DocumentPhotoAccessRequest {
|
||||
string requesterId = 1;
|
||||
string targetUserId = 2;
|
||||
string storageKey = 3;
|
||||
optional string fileName = 4;
|
||||
}
|
||||
|
||||
message PresignedUploadResponse {
|
||||
|
||||
Reference in New Issue
Block a user