fix and update
This commit is contained in:
@@ -16,8 +16,9 @@ export class FamilyController {
|
|||||||
private readonly jwt: JwtService
|
private readonly jwt: JwtService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async auth(authorization?: string) {
|
private async auth(authorization?: string, passiveActivityHeader?: string) {
|
||||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
const touchActivity = passiveActivityHeader !== '1';
|
||||||
|
return getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('groups')
|
@Post('groups')
|
||||||
@@ -33,8 +34,12 @@ export class FamilyController {
|
|||||||
|
|
||||||
@Get('users/:userId/groups')
|
@Get('users/:userId/groups')
|
||||||
@ApiOperation({ summary: 'Список семей пользователя' })
|
@ApiOperation({ summary: 'Список семей пользователя' })
|
||||||
async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
async listGroups(
|
||||||
const requesterId = await this.auth(authorization);
|
@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) {
|
if (requesterId !== userId) {
|
||||||
throw new ForbiddenException('Можно просматривать только свои семьи');
|
throw new ForbiddenException('Можно просматривать только свои семьи');
|
||||||
}
|
}
|
||||||
@@ -50,8 +55,12 @@ export class FamilyController {
|
|||||||
|
|
||||||
@Get('groups/:groupId')
|
@Get('groups/:groupId')
|
||||||
@ApiOperation({ summary: 'Получить семейную группу' })
|
@ApiOperation({ summary: 'Получить семейную группу' })
|
||||||
async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
async getGroup(
|
||||||
const requesterId = await this.auth(authorization);
|
@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 }));
|
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,8 +190,12 @@ export class FamilyController {
|
|||||||
|
|
||||||
@Get('groups/:groupId/presence')
|
@Get('groups/:groupId/presence')
|
||||||
@ApiOperation({ summary: 'Онлайн-статус участников семьи' })
|
@ApiOperation({ summary: 'Онлайн-статус участников семьи' })
|
||||||
async getPresence(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
async getPresence(
|
||||||
const requesterId = await this.auth(authorization);
|
@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 }));
|
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export class MediaController {
|
|||||||
|
|
||||||
@Post('documents/:documentId/photo/upload-url')
|
@Post('documents/:documentId/photo/upload-url')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
|
@ApiOperation({ summary: 'Получить URL для файла документа', description: 'Presigned URL для загрузки скана, фото или файла документа в MinIO.' })
|
||||||
@ApiBody({ type: DocumentPhotoUploadDto })
|
@ApiBody({ type: DocumentPhotoUploadDto })
|
||||||
async createDocumentPhotoUploadUrl(
|
async createDocumentPhotoUploadUrl(
|
||||||
@Headers('authorization') authorization: string | undefined,
|
@Headers('authorization') authorization: string | undefined,
|
||||||
@@ -125,22 +125,31 @@ export class MediaController {
|
|||||||
@Body() dto: DocumentPhotoUploadDto
|
@Body() dto: DocumentPhotoUploadDto
|
||||||
) {
|
) {
|
||||||
const userId = await this.authUserId(authorization);
|
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')
|
@Get('users/:userId/documents/photo-url')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
|
@ApiOperation({ summary: 'Получить ссылку на файл документа', description: 'Временная ссылка на просмотр файла документа (15 минут).' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
|
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
|
||||||
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
|
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
|
||||||
|
@ApiQuery({ name: 'fileName', required: false, description: 'Имя файла для скачивания' })
|
||||||
async getDocumentPhotoUrl(
|
async getDocumentPhotoUrl(
|
||||||
@Headers('authorization') authorization: string | undefined,
|
@Headers('authorization') authorization: string | undefined,
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Query('storageKey') storageKey: string
|
@Query('storageKey') storageKey: string,
|
||||||
|
@Query('fileName') fileName?: string
|
||||||
) {
|
) {
|
||||||
const requesterId = await this.authUserId(authorization);
|
const requesterId = await this.authUserId(authorization);
|
||||||
return firstValueFrom(
|
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')
|
@Get('unread-count')
|
||||||
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
|
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
|
||||||
async unreadCount(@Headers('authorization') authorization: string | undefined) {
|
async unreadCount(
|
||||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
@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 }));
|
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ interface RequesterProfile {
|
|||||||
canViewUserDocuments?: boolean;
|
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);
|
const payload = await verifyAccessToken(jwt, authorization);
|
||||||
await assertSessionUnlocked(core, payload);
|
await assertSessionUnlocked(core, payload, touchActivity);
|
||||||
|
|
||||||
if (payload.isSuperAdmin) {
|
if (payload.isSuperAdmin) {
|
||||||
return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true };
|
return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true };
|
||||||
@@ -51,7 +56,12 @@ export async function assertDocumentsWriteAccess(
|
|||||||
return requester.id;
|
return requester.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) {
|
export async function getAuthorizedUserId(
|
||||||
const requester = await getRequesterProfile(jwt, core, authorization);
|
jwt: JwtService,
|
||||||
|
core: CoreGrpcService,
|
||||||
|
authorization?: string,
|
||||||
|
touchActivity = true
|
||||||
|
) {
|
||||||
|
const requester = await getRequesterProfile(jwt, core, authorization, touchActivity);
|
||||||
return requester.id;
|
return requester.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
|
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 {
|
export class AvatarUploadDto {
|
||||||
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
||||||
@IsString({ message: 'Укажите MIME-тип изображения' })
|
@IsString({ message: 'Укажите MIME-тип изображения' })
|
||||||
@@ -16,10 +31,17 @@ export class ConfirmAvatarDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DocumentPhotoUploadDto {
|
export class DocumentPhotoUploadDto {
|
||||||
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
@ApiProperty({ description: 'MIME-тип файла', example: 'application/pdf', enum: DOCUMENT_ATTACHMENT_TYPES })
|
||||||
@IsString({ message: 'Укажите MIME-тип изображения' })
|
@IsString({ message: 'Укажите MIME-тип файла' })
|
||||||
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
|
@IsIn([...DOCUMENT_ATTACHMENT_TYPES], {
|
||||||
|
message: 'Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT'
|
||||||
|
})
|
||||||
contentType!: string;
|
contentType!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'passport.pdf' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Имя файла должно быть строкой' })
|
||||||
|
fileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatMediaUploadDto {
|
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) {
|
if (!payload.sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,7 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
|
|||||||
core.auth.ValidateSession({
|
core.auth.ValidateSession({
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
touchActivity: true
|
touchActivity
|
||||||
})
|
})
|
||||||
)) as { requiresPin: boolean; sessionId: string };
|
)) as { requiresPin: boolean; sessionId: string };
|
||||||
|
|
||||||
@@ -54,9 +58,10 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
|
|||||||
export async function resolveAuthorizedPayload(
|
export async function resolveAuthorizedPayload(
|
||||||
jwt: JwtService,
|
jwt: JwtService,
|
||||||
core: CoreGrpcService,
|
core: CoreGrpcService,
|
||||||
authorization?: string
|
authorization?: string,
|
||||||
|
touchActivity = true
|
||||||
): Promise<AccessTokenPayload> {
|
): Promise<AccessTokenPayload> {
|
||||||
const payload = await verifyAccessToken(jwt, authorization);
|
const payload = await verifyAccessToken(jwt, authorization);
|
||||||
await assertSessionUnlocked(core, payload);
|
await assertSessionUnlocked(core, payload, touchActivity);
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-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 {
|
a {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { UserInspectorDialog } from '@/components/admin/user-inspector-dialog';
|
||||||
import { VerificationBadge } from '@/components/id/verification-badge';
|
import { VerificationBadge } from '@/components/id/verification-badge';
|
||||||
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
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 { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
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 { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
|
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';
|
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() {
|
export default function AdminUsersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { token, user: currentUser } = useAuth();
|
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>
|
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-end gap-2" onClick={(event) => event.stopPropagation()}>
|
<div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
|
||||||
{(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? (
|
<UserActionsMenu
|
||||||
<Button
|
user={user}
|
||||||
variant="secondary"
|
currentUser={currentUser}
|
||||||
size="icon"
|
actionLoading={actionLoading}
|
||||||
aria-label="Журнал и чаты"
|
onOpenInspector={() => setInspectorUser(user)}
|
||||||
onClick={() => setInspectorUser(user)}
|
onResetPassword={() => {
|
||||||
>
|
setSelectedUser(user);
|
||||||
<ScrollText className="h-4 w-4" />
|
setPassword('');
|
||||||
</Button>
|
setDialog('password');
|
||||||
) : null}
|
}}
|
||||||
{currentUser?.canManageUsers ? (
|
onSuspend={() => void handleSuspend(user)}
|
||||||
<>
|
onUnsuspend={() => void handleUnsuspend(user)}
|
||||||
<Button
|
onOpenDocuments={() => setDocumentsUser(user)}
|
||||||
variant="secondary"
|
onDisableTotp={() => void handleAdminDisableTotp(user)}
|
||||||
size="icon"
|
onOpenVerification={() => openVerificationDialog(user)}
|
||||||
aria-label="Сбросить пароль"
|
onOpenRoles={() => {
|
||||||
disabled={actionLoading}
|
setSelectedUser(user);
|
||||||
onClick={() => {
|
setDialog('roles');
|
||||||
setSelectedUser(user);
|
}}
|
||||||
setPassword('');
|
onToggleSuperAdmin={() => void handleToggleSuperAdmin(user)}
|
||||||
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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
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 { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { ActionTile } from '@/components/id/action-tile';
|
import { ActionTile } from '@/components/id/action-tile';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
@@ -20,6 +20,7 @@ import { useRequireAuth } from '@/hooks/use-require-auth';
|
|||||||
import {
|
import {
|
||||||
getDocumentType,
|
getDocumentType,
|
||||||
indexDocumentsByType,
|
indexDocumentsByType,
|
||||||
|
parseAttachmentMeta,
|
||||||
parseDocumentPhotos,
|
parseDocumentPhotos,
|
||||||
parseMetadata,
|
parseMetadata,
|
||||||
type DocumentTypeCode
|
type DocumentTypeCode
|
||||||
@@ -324,6 +325,7 @@ export default function DataPage() {
|
|||||||
documents.map((document) => {
|
documents.map((document) => {
|
||||||
const config = getDocumentType(document.type);
|
const config = getDocumentType(document.type);
|
||||||
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||||
|
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
|
||||||
return (
|
return (
|
||||||
<div key={document.id}>
|
<div key={document.id}>
|
||||||
<Row
|
<Row
|
||||||
@@ -334,7 +336,7 @@ export default function DataPage() {
|
|||||||
/>
|
/>
|
||||||
{photoKeys.length > 0 && userId && token ? (
|
{photoKeys.length > 0 && userId && token ? (
|
||||||
<div className="border-b border-[#eceef4] px-1 pb-4">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -401,7 +403,7 @@ export default function DataPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{activeDocumentType && user ? (
|
{activeDocumentType && user ? (
|
||||||
<DocumentFormDialog
|
<DocumentDialog
|
||||||
open={Boolean(activeDocumentType)}
|
open={Boolean(activeDocumentType)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setActiveDocumentType(null);
|
if (!open) setActiveDocumentType(null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ChevronRight, Plus } from 'lucide-react';
|
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 { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { IdShell } from '@/components/id/shell';
|
import { IdShell } from '@/components/id/shell';
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DOCUMENT_CATEGORIES,
|
DOCUMENT_CATEGORIES,
|
||||||
DOCUMENT_TYPES,
|
DOCUMENT_TYPES,
|
||||||
getDocumentType,
|
getDocumentType,
|
||||||
|
parseAttachmentMeta,
|
||||||
parseDocumentPhotos,
|
parseDocumentPhotos,
|
||||||
parseMetadata,
|
parseMetadata,
|
||||||
QUICK_DOCUMENT_TYPES,
|
QUICK_DOCUMENT_TYPES,
|
||||||
@@ -97,6 +98,7 @@ export default function DocumentsPage() {
|
|||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const existing = documentsByType.get(item.type);
|
const existing = documentsByType.get(item.type);
|
||||||
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
|
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
|
||||||
|
const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {};
|
||||||
return (
|
return (
|
||||||
<div key={item.type} className="border-b border-white/70 last:border-b-0">
|
<div key={item.type} className="border-b border-white/70 last:border-b-0">
|
||||||
<button
|
<button
|
||||||
@@ -115,7 +117,7 @@ export default function DocumentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
{existing && photoKeys.length > 0 && user && token ? (
|
{existing && photoKeys.length > 0 && user && token ? (
|
||||||
<div className="px-4 pb-4">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,7 @@ export default function DocumentsPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{activeType ? (
|
{activeType ? (
|
||||||
<DocumentFormDialog
|
<DocumentDialog
|
||||||
open={Boolean(activeType)}
|
open={Boolean(activeType)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setActiveType(null);
|
if (!open) setActiveType(null);
|
||||||
|
|||||||
@@ -15,6 +15,29 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
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 {
|
body {
|
||||||
@@ -29,6 +52,10 @@ html {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.rdp-root {
|
.rdp-root {
|
||||||
--rdp-accent-color: #111827;
|
--rdp-accent-color: #111827;
|
||||||
--rdp-accent-background-color: #f4f5f8;
|
--rdp-accent-background-color: #f4f5f8;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react';
|
import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react';
|
||||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
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 { ActionTile, SectionTitle } from '@/components/id/action-tile';
|
||||||
import { AdminBadge } from '@/components/id/admin-badge';
|
import { AdminBadge } from '@/components/id/admin-badge';
|
||||||
import { AvatarDisplay } from '@/components/id/avatar-upload';
|
import { AvatarDisplay } from '@/components/id/avatar-upload';
|
||||||
@@ -124,7 +124,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeDocumentType && user ? (
|
{activeDocumentType && user ? (
|
||||||
<DocumentFormDialog
|
<DocumentDialog
|
||||||
open={Boolean(activeDocumentType)}
|
open={Boolean(activeDocumentType)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setActiveDocumentType(null);
|
if (!open) setActiveDocumentType(null);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function toDatetimeLocalValue(date: Date) {
|
export function toDatetimeLocalValue(date: Date) {
|
||||||
const pad = (value: number) => String(value).padStart(2, '0');
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
@@ -36,6 +38,21 @@ function pluralDays(value: number) {
|
|||||||
return 'дней';
|
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({
|
export function AdminInsightsDateFilter({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -51,45 +68,62 @@ export function AdminInsightsDateFilter({
|
|||||||
onToChange: (value: string) => void;
|
onToChange: (value: string) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const periodLabel = useMemo(() => formatPeriodLabel(from, to), [from, to]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
|
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff]">
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#667085]">
|
<button
|
||||||
<CalendarRange className="h-4 w-4" />
|
type="button"
|
||||||
Период просмотра
|
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition hover:bg-[#f4f5f8]"
|
||||||
</div>
|
onClick={() => setOpen((current) => !current)}
|
||||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
aria-expanded={open}
|
||||||
<div className="space-y-1">
|
>
|
||||||
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
<CalendarRange className="h-4 w-4 shrink-0 text-[#667085]" />
|
||||||
С даты и времени
|
<div className="min-w-0 flex-1">
|
||||||
</label>
|
<p className="text-xs font-medium text-[#667085]">Период просмотра</p>
|
||||||
<Input
|
<p className="truncate text-sm text-[#1f2430]">{periodLabel}</p>
|
||||||
id="insights-from"
|
|
||||||
type="datetime-local"
|
|
||||||
value={from}
|
|
||||||
onChange={(event) => onFromChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<ChevronDown className={cn('h-4 w-4 shrink-0 text-[#667085] transition', open && 'rotate-180')} />
|
||||||
<label className="text-xs text-[#667085]" htmlFor="insights-to">
|
</button>
|
||||||
По дату и время
|
|
||||||
</label>
|
{open ? (
|
||||||
<Input
|
<div className="border-t border-[#eceef4] px-3 pb-3 pt-3">
|
||||||
id="insights-to"
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||||
type="datetime-local"
|
<div className="space-y-1">
|
||||||
value={to}
|
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
||||||
onChange={(event) => onToChange(event.target.value)}
|
С даты и времени
|
||||||
/>
|
</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>
|
||||||
<div className="flex items-end">
|
) : null}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
import { DOCUMENT_FILE_ACCEPT, resolveDocumentFileContentType } from '@/lib/document-attachments';
|
||||||
import {
|
import {
|
||||||
buildDocumentNumber,
|
buildDocumentNumber,
|
||||||
DOCUMENT_TYPES,
|
DOCUMENT_TYPES,
|
||||||
DocumentTypeDef,
|
DocumentTypeDef,
|
||||||
LICENSE_CATEGORIES,
|
LICENSE_CATEGORIES,
|
||||||
|
parseAttachmentMeta,
|
||||||
parseDocumentPhotos,
|
parseDocumentPhotos,
|
||||||
parseMetadata,
|
parseMetadata,
|
||||||
withDocumentPhotos,
|
withDocumentPhotos,
|
||||||
|
type DocumentAttachmentMeta,
|
||||||
type DocumentTypeCode
|
type DocumentTypeCode
|
||||||
} from '@/lib/document-catalog';
|
} from '@/lib/document-catalog';
|
||||||
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
|
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
|
||||||
@@ -106,7 +109,9 @@ export function DocumentFormDialog({
|
|||||||
token,
|
token,
|
||||||
existing,
|
existing,
|
||||||
onSaved,
|
onSaved,
|
||||||
readOnly
|
readOnly,
|
||||||
|
showBackToView,
|
||||||
|
onBackToView
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -116,6 +121,8 @@ export function DocumentFormDialog({
|
|||||||
existing?: UserDocument | null;
|
existing?: UserDocument | null;
|
||||||
onSaved: () => void | Promise<void>;
|
onSaved: () => void | Promise<void>;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
showBackToView?: boolean;
|
||||||
|
onBackToView?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
|
const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@@ -124,10 +131,12 @@ export function DocumentFormDialog({
|
|||||||
const fieldValuesRef = useRef<Record<string, string>>({});
|
const fieldValuesRef = useRef<Record<string, string>>({});
|
||||||
const draftDocumentIdRef = useRef<string | undefined>(undefined);
|
const draftDocumentIdRef = useRef<string | undefined>(undefined);
|
||||||
const photoStorageKeysRef = useRef<string[]>([]);
|
const photoStorageKeysRef = useRef<string[]>([]);
|
||||||
|
const attachmentMetaRef = useRef<Record<string, DocumentAttachmentMeta>>({});
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
const [formInstanceKey, setFormInstanceKey] = useState(0);
|
const [formInstanceKey, setFormInstanceKey] = useState(0);
|
||||||
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
|
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
|
||||||
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
|
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
|
||||||
|
const [attachmentMeta, setAttachmentMeta] = useState<Record<string, DocumentAttachmentMeta>>({});
|
||||||
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
|
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isPhotoUploading, setIsPhotoUploading] = useState(false);
|
const [isPhotoUploading, setIsPhotoUploading] = useState(false);
|
||||||
@@ -138,18 +147,23 @@ export function DocumentFormDialog({
|
|||||||
fieldValuesRef.current = {};
|
fieldValuesRef.current = {};
|
||||||
draftDocumentIdRef.current = undefined;
|
draftDocumentIdRef.current = undefined;
|
||||||
photoStorageKeysRef.current = [];
|
photoStorageKeysRef.current = [];
|
||||||
|
attachmentMetaRef.current = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!config || wasOpenRef.current) return;
|
if (!config || wasOpenRef.current) return;
|
||||||
|
|
||||||
wasOpenRef.current = true;
|
wasOpenRef.current = true;
|
||||||
const initial = buildInitialValues(config, existing);
|
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 };
|
fieldValuesRef.current = { ...initial };
|
||||||
photoStorageKeysRef.current = photos;
|
photoStorageKeysRef.current = photos;
|
||||||
|
attachmentMetaRef.current = meta;
|
||||||
draftDocumentIdRef.current = existing?.id;
|
draftDocumentIdRef.current = existing?.id;
|
||||||
setPickerValues(initial);
|
setPickerValues(initial);
|
||||||
setPhotoStorageKeys(photos);
|
setPhotoStorageKeys(photos);
|
||||||
|
setAttachmentMeta(meta);
|
||||||
setDraftDocumentId(existing?.id);
|
setDraftDocumentId(existing?.id);
|
||||||
setFormInstanceKey((current) => current + 1);
|
setFormInstanceKey((current) => current + 1);
|
||||||
}, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]);
|
}, [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> = {};
|
const metadata: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(source)) {
|
for (const [key, value] of Object.entries(source)) {
|
||||||
if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value;
|
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 {
|
return {
|
||||||
number: buildDocumentNumber(config!, currentValues),
|
number: buildDocumentNumber(config!, currentValues),
|
||||||
issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined,
|
issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined,
|
||||||
expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).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;
|
if (!token || !config) return;
|
||||||
await apiFetch(
|
await apiFetch(
|
||||||
`/documents/users/${userId}/${documentId}`,
|
`/documents/users/${userId}/${documentId}`,
|
||||||
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) },
|
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos, meta)) },
|
||||||
token
|
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) {
|
if (draftDocumentIdRef.current) {
|
||||||
return { id: draftDocumentIdRef.current, created: false };
|
return { id: draftDocumentIdRef.current, created: false };
|
||||||
}
|
}
|
||||||
@@ -240,7 +271,7 @@ export function DocumentFormDialog({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
type: documentType,
|
type: documentType,
|
||||||
...buildSaveBody(currentValues, photos)
|
...buildSaveBody(currentValues, photos, meta)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
token
|
token
|
||||||
@@ -250,36 +281,50 @@ export function DocumentFormDialog({
|
|||||||
return { id: created.id, created: true };
|
return { id: created.id, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadPhoto(file: File) {
|
async function uploadAttachment(file: File) {
|
||||||
if (!token || readOnly) return;
|
if (!token || readOnly) return;
|
||||||
setIsPhotoUploading(true);
|
setIsPhotoUploading(true);
|
||||||
try {
|
try {
|
||||||
await commitFocusedField();
|
await commitFocusedField();
|
||||||
const currentValues = collectFormValues();
|
const currentValues = collectFormValues();
|
||||||
const currentPhotos = photoStorageKeysRef.current;
|
const currentPhotos = photoStorageKeysRef.current;
|
||||||
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos);
|
const currentMeta = attachmentMetaRef.current;
|
||||||
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки фото');
|
const contentType = resolveDocumentFileContentType(file);
|
||||||
|
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos, currentMeta);
|
||||||
|
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки файла');
|
||||||
|
|
||||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||||
`/media/documents/${targetDocumentId}/photo/upload-url`,
|
`/media/documents/${targetDocumentId}/photo/upload-url`,
|
||||||
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ contentType, fileName: file.name })
|
||||||
|
},
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
await uploadMediaObject(presigned, file, file.type);
|
await uploadMediaObject(presigned, file, contentType);
|
||||||
|
|
||||||
const nextPhotos = [...currentPhotos, presigned.storageKey];
|
const nextPhotos = [...currentPhotos, presigned.storageKey];
|
||||||
|
const nextMeta = {
|
||||||
|
...currentMeta,
|
||||||
|
[presigned.storageKey]: {
|
||||||
|
fileName: file.name,
|
||||||
|
contentType
|
||||||
|
}
|
||||||
|
};
|
||||||
photoStorageKeysRef.current = nextPhotos;
|
photoStorageKeysRef.current = nextPhotos;
|
||||||
|
attachmentMetaRef.current = nextMeta;
|
||||||
setPhotoStorageKeys(nextPhotos);
|
setPhotoStorageKeys(nextPhotos);
|
||||||
await patchDocument(targetDocumentId, currentValues, nextPhotos);
|
setAttachmentMeta(nextMeta);
|
||||||
|
await patchDocument(targetDocumentId, currentValues, nextPhotos, nextMeta);
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
await onSaved();
|
await onSaved();
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Фото добавлено');
|
showToast('Файл добавлен');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось загрузить фото');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить файл');
|
||||||
if (message) showToast(message);
|
if (message) showToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsPhotoUploading(false);
|
setIsPhotoUploading(false);
|
||||||
@@ -290,14 +335,18 @@ export function DocumentFormDialog({
|
|||||||
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
|
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const files = Array.from(event.target.files ?? []);
|
const files = Array.from(event.target.files ?? []);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await uploadPhoto(file);
|
await uploadAttachment(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePhoto(storageKey: string) {
|
function removePhoto(storageKey: string) {
|
||||||
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
|
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
|
||||||
|
const nextMeta = { ...attachmentMetaRef.current };
|
||||||
|
delete nextMeta[storageKey];
|
||||||
photoStorageKeysRef.current = nextPhotos;
|
photoStorageKeysRef.current = nextPhotos;
|
||||||
|
attachmentMetaRef.current = nextMeta;
|
||||||
setPhotoStorageKeys(nextPhotos);
|
setPhotoStorageKeys(nextPhotos);
|
||||||
|
setAttachmentMeta(nextMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -307,15 +356,16 @@ export function DocumentFormDialog({
|
|||||||
await commitFocusedField();
|
await commitFocusedField();
|
||||||
const currentValues = collectFormValues();
|
const currentValues = collectFormValues();
|
||||||
const photos = photoStorageKeysRef.current;
|
const photos = photoStorageKeysRef.current;
|
||||||
|
const meta = attachmentMetaRef.current;
|
||||||
const documentId = draftDocumentIdRef.current ?? existing?.id;
|
const documentId = draftDocumentIdRef.current ?? existing?.id;
|
||||||
|
|
||||||
if (documentId) {
|
if (documentId) {
|
||||||
await patchDocument(documentId, currentValues, photos);
|
await patchDocument(documentId, currentValues, photos, meta);
|
||||||
showToast('Документ обновлён');
|
showToast('Документ обновлён');
|
||||||
} else {
|
} else {
|
||||||
const created = await apiFetch<UserDocument>(
|
const created = await apiFetch<UserDocument>(
|
||||||
`/documents/users/${userId}`,
|
`/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
|
token
|
||||||
);
|
);
|
||||||
draftDocumentIdRef.current = created.id;
|
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]">
|
<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">
|
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<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]">
|
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -355,6 +412,7 @@ export function DocumentFormDialog({
|
|||||||
userId={userId}
|
userId={userId}
|
||||||
token={token}
|
token={token}
|
||||||
storageKeys={photoStorageKeys}
|
storageKeys={photoStorageKeys}
|
||||||
|
attachmentMeta={attachmentMeta}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onRemove={readOnly ? undefined : removePhoto}
|
onRemove={readOnly ? undefined : removePhoto}
|
||||||
/>
|
/>
|
||||||
@@ -368,10 +426,17 @@ export function DocumentFormDialog({
|
|||||||
disabled={isPhotoUploading}
|
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]"
|
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" />}
|
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
|
||||||
{photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'}
|
{photoStorageKeys.length > 0 ? 'Добавить ещё файл' : 'Добавить фото или документ'}
|
||||||
</button>
|
</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}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
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 { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import {
|
||||||
|
attachmentDisplayName,
|
||||||
|
attachmentFileExtension,
|
||||||
|
inferAttachmentContentType,
|
||||||
|
isImageAttachment,
|
||||||
|
isPdfAttachment,
|
||||||
|
type DocumentAttachmentMeta
|
||||||
|
} from '@/lib/document-attachments';
|
||||||
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
||||||
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface DocumentPhotoGalleryProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
storageKeys: string[];
|
storageKeys: string[];
|
||||||
|
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onRemove?: (storageKey: string) => void;
|
onRemove?: (storageKey: string) => void;
|
||||||
layout?: 'grid' | 'strip';
|
layout?: 'grid' | 'strip';
|
||||||
@@ -22,20 +38,21 @@ export function DocumentPhotoGallery({
|
|||||||
userId,
|
userId,
|
||||||
token,
|
token,
|
||||||
storageKeys,
|
storageKeys,
|
||||||
|
attachmentMeta = {},
|
||||||
readOnly,
|
readOnly,
|
||||||
onRemove,
|
onRemove,
|
||||||
layout = 'grid',
|
layout = 'grid',
|
||||||
className
|
className
|
||||||
}: DocumentPhotoGalleryProps) {
|
}: DocumentPhotoGalleryProps) {
|
||||||
const { isPinLocked } = useAuth();
|
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 [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
|
||||||
const [viewerOpen, setViewerOpen] = React.useState(false);
|
const [viewerOpen, setViewerOpen] = React.useState(false);
|
||||||
const [viewerIndex, setViewerIndex] = React.useState(0);
|
const [viewerIndex, setViewerIndex] = React.useState(0);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!token || storageKeys.length === 0 || isPinLocked) {
|
if (!token || storageKeys.length === 0 || isPinLocked) {
|
||||||
setUrls({});
|
setAttachments([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,34 +60,43 @@ export function DocumentPhotoGallery({
|
|||||||
setLoadingKeys(new Set(storageKeys));
|
setLoadingKeys(new Set(storageKeys));
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
storageKeys.map(async (storageKey) => {
|
storageKeys.map(async (storageKey) => {
|
||||||
|
const meta = attachmentMeta[storageKey];
|
||||||
|
const contentType = inferAttachmentContentType(storageKey, meta);
|
||||||
|
const fileName = attachmentDisplayName(storageKey, meta);
|
||||||
try {
|
try {
|
||||||
const response = await fetchDocumentPhotoUrl(userId, storageKey, token);
|
const response = await fetchDocumentPhotoUrl(userId, storageKey, token, fileName);
|
||||||
return { storageKey, accessUrl: resolveBrowserMediaUrl(response.accessUrl) };
|
return {
|
||||||
|
storageKey,
|
||||||
|
accessUrl: resolveBrowserMediaUrl(response.accessUrl),
|
||||||
|
contentType,
|
||||||
|
fileName
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { storageKey, accessUrl: '' };
|
return { storageKey, accessUrl: '', contentType, fileName };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const next: Record<string, string> = {};
|
setAttachments(
|
||||||
for (const item of results) {
|
results
|
||||||
if (item.accessUrl) next[item.storageKey] = item.accessUrl;
|
.filter((item) => Boolean(item.accessUrl))
|
||||||
}
|
.map((item) => ({
|
||||||
setUrls(next);
|
storageKey: item.storageKey,
|
||||||
|
url: item.accessUrl,
|
||||||
|
contentType: item.contentType,
|
||||||
|
fileName: item.fileName
|
||||||
|
}))
|
||||||
|
);
|
||||||
setLoadingKeys(new Set());
|
setLoadingKeys(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isPinLocked, storageKeys.join('|'), token, userId]);
|
}, [attachmentMeta, isPinLocked, storageKeys.join('|'), token, userId]);
|
||||||
|
|
||||||
const resolvedImages = storageKeys
|
|
||||||
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
|
|
||||||
.filter((item) => Boolean(item.url));
|
|
||||||
|
|
||||||
function openViewer(storageKey: string) {
|
function openViewer(storageKey: string) {
|
||||||
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
|
const resolvedIndex = attachments.findIndex((item) => item.storageKey === storageKey);
|
||||||
if (resolvedIndex >= 0) {
|
if (resolvedIndex >= 0) {
|
||||||
setViewerIndex(resolvedIndex);
|
setViewerIndex(resolvedIndex);
|
||||||
setViewerOpen(true);
|
setViewerOpen(true);
|
||||||
@@ -84,67 +110,126 @@ export function DocumentPhotoGallery({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3', className)}
|
||||||
isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{storageKeys.map((storageKey) => (
|
{storageKeys.map((storageKey) => {
|
||||||
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
const meta = attachmentMeta[storageKey];
|
||||||
<DocumentPhotoThumbnail
|
const contentType = inferAttachmentContentType(storageKey, meta);
|
||||||
url={urls[storageKey]}
|
const fileName = attachmentDisplayName(storageKey, meta);
|
||||||
loading={loadingKeys.has(storageKey)}
|
const resolved = attachments.find((item) => item.storageKey === storageKey);
|
||||||
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
const loading = loadingKeys.has(storageKey);
|
||||||
onClick={() => openViewer(storageKey)}
|
|
||||||
/>
|
if (isImageAttachment(contentType)) {
|
||||||
{!readOnly && onRemove ? (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={() => (resolved ? openViewer(storageKey) : undefined)}
|
||||||
event.stopPropagation();
|
className={cn(
|
||||||
onRemove(storageKey);
|
'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]'
|
||||||
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
)}
|
||||||
aria-label="Удалить фото"
|
|
||||||
>
|
>
|
||||||
<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>
|
</button>
|
||||||
) : null}
|
{resolved ? (
|
||||||
{loadingKeys.has(storageKey) ? (
|
<a
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40">
|
href={`${resolved.url}${resolved.url.includes('?') ? '&' : '?'}download=1`}
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
|
target="_blank"
|
||||||
</div>
|
rel="noreferrer"
|
||||||
) : null}
|
className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
<DocumentFullscreenViewer
|
<DocumentAttachmentViewer
|
||||||
open={viewerOpen}
|
open={viewerOpen}
|
||||||
onOpenChange={setViewerOpen}
|
onOpenChange={setViewerOpen}
|
||||||
initialIndex={viewerIndex}
|
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 {
|
interface DocumentPhotosInlineProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
storageKeys: string[];
|
storageKeys: string[];
|
||||||
|
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Горизонтальная полоска фото документа для списков (страница «Документы», «Данные»). */
|
/** Горизонтальная полоска файлов документа для списков. */
|
||||||
export function DocumentPhotosInline({ userId, token, storageKeys, className }: DocumentPhotosInlineProps) {
|
export function DocumentPhotosInline({ userId, token, storageKeys, attachmentMeta, className }: DocumentPhotosInlineProps) {
|
||||||
if (!storageKeys.length) return null;
|
if (!storageKeys.length) return null;
|
||||||
return (
|
return (
|
||||||
<DocumentPhotoGallery
|
<DocumentPhotoGallery
|
||||||
userId={userId}
|
userId={userId}
|
||||||
token={token}
|
token={token}
|
||||||
storageKeys={storageKeys}
|
storageKeys={storageKeys}
|
||||||
|
attachmentMeta={attachmentMeta}
|
||||||
readOnly
|
readOnly
|
||||||
layout="strip"
|
layout="strip"
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -2,19 +2,26 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||||
|
import { isImageAttachment, isPdfAttachment } from '@/lib/document-attachments';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ResolvedDocumentAttachment } from './document-photo-gallery';
|
||||||
|
|
||||||
interface DocumentFullscreenViewerProps {
|
interface DocumentAttachmentViewerProps {
|
||||||
images: { storageKey: string; url: string }[];
|
attachments: ResolvedDocumentAttachment[];
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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 [index, setIndex] = React.useState(initialIndex);
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
@@ -31,11 +38,11 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
|||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') onOpenChange(false);
|
if (event.key === 'Escape') onOpenChange(false);
|
||||||
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1));
|
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);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [images.length, onOpenChange, open]);
|
}, [attachments.length, onOpenChange, open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -46,33 +53,48 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [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(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
|
<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="flex items-center justify-between px-4 py-3 text-white">
|
||||||
<div className="min-w-0">
|
<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">
|
<p className="text-xs text-white/60">
|
||||||
{index + 1} из {images.length}
|
{index + 1} из {attachments.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
className="text-white hover:bg-white/10"
|
variant="ghost"
|
||||||
aria-label="Закрыть"
|
className="text-white hover:bg-white/10"
|
||||||
onClick={() => onOpenChange(false)}
|
aria-label="Скачать"
|
||||||
>
|
asChild
|
||||||
<X className="h-5 w-5" />
|
>
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
|
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
|
||||||
{images.length > 1 ? (
|
{attachments.length > 1 ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
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',
|
'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||||
index <= 0 && 'pointer-events-none opacity-30'
|
index <= 0 && 'pointer-events-none opacity-30'
|
||||||
)}
|
)}
|
||||||
aria-label="Предыдущее фото"
|
aria-label="Предыдущий файл"
|
||||||
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-6 w-6" />
|
<ChevronLeft className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{isImageAttachment(current.contentType) ? (
|
||||||
<img
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
src={current.url}
|
<img
|
||||||
alt="Фото документа"
|
src={current.url}
|
||||||
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
'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="Следующее фото"
|
aria-label="Следующий файл"
|
||||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
onClick={() => setIndex((value) => Math.min(attachments.length - 1, value + 1))}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{images.length > 1 ? (
|
{attachments.length > 1 ? (
|
||||||
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
|
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
|
||||||
{images.map((image, imageIndex) => (
|
{attachments.map((attachment, attachmentIndex) => (
|
||||||
<button
|
<button
|
||||||
key={image.storageKey}
|
key={attachment.storageKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2',
|
'h-14 min-w-[56px] shrink-0 overflow-hidden rounded-lg border-2 px-2 text-xs text-white',
|
||||||
imageIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
attachmentIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||||
)}
|
)}
|
||||||
onClick={() => setIndex(imageIndex)}
|
onClick={() => setIndex(attachmentIndex)}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{isImageAttachment(attachment.contentType) ? (
|
||||||
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
// 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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 {
|
interface DocumentPhotoThumbnailProps {
|
||||||
url?: string;
|
url?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -156,7 +221,7 @@ export function DocumentPhotoThumbnail({ url, loading, className, onClick }: Doc
|
|||||||
{!loading && url ? (
|
{!loading && url ? (
|
||||||
<>
|
<>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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">
|
<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" />
|
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
|
||||||
</div>
|
</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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { FileText } from 'lucide-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 { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
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 { apiFetch, UserDocument } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ export function UserDocumentsDialog({
|
|||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||||
|
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
|
||||||
return (
|
return (
|
||||||
<div key={document.id}>
|
<div key={document.id}>
|
||||||
<button
|
<button
|
||||||
@@ -88,7 +89,7 @@ export function UserDocumentsDialog({
|
|||||||
</button>
|
</button>
|
||||||
{photoKeys.length > 0 && token ? (
|
{photoKeys.length > 0 && token ? (
|
||||||
<div className="mt-2 px-1">
|
<div className="mt-2 px-1">
|
||||||
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} />
|
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +100,7 @@ export function UserDocumentsDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{activeType ? (
|
{activeType ? (
|
||||||
<DocumentFormDialog
|
<DocumentDialog
|
||||||
open={Boolean(activeType)}
|
open={Boolean(activeType)}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { readStoredFamilyGroupId, writeStoredFamilyGroupId } from '@/lib/selected-family-storage';
|
||||||
const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
|
|
||||||
|
|
||||||
type FamilyOverlayContextValue = {
|
type FamilyOverlayContextValue = {
|
||||||
openMiniChat: () => void;
|
openMiniChat: () => void;
|
||||||
@@ -14,6 +13,7 @@ type FamilyOverlayContextValue = {
|
|||||||
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
|
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
|
||||||
clearPending: () => void;
|
clearPending: () => void;
|
||||||
selectedGroupId: string | null;
|
selectedGroupId: string | null;
|
||||||
|
isSelectionHydrated: boolean;
|
||||||
setSelectedGroupId: (groupId: string | null) => void;
|
setSelectedGroupId: (groupId: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,23 +24,20 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
|||||||
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
|
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
|
||||||
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: 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 [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 pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||||
const selectionHydratedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectionHydratedRef.current) return;
|
const stored = readStoredFamilyGroupId();
|
||||||
selectionHydratedRef.current = true;
|
if (stored) {
|
||||||
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY);
|
setSelectedGroupIdState(stored);
|
||||||
if (stored) setSelectedGroupIdState(stored);
|
}
|
||||||
|
setIsSelectionHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSelectedGroupId = useCallback((groupId: string | null) => {
|
const setSelectedGroupId = useCallback((groupId: string | null) => {
|
||||||
setSelectedGroupIdState(groupId);
|
setSelectedGroupIdState(groupId);
|
||||||
if (groupId) {
|
writeStoredFamilyGroupId(groupId);
|
||||||
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openMiniChat = useCallback(() => {
|
const openMiniChat = useCallback(() => {
|
||||||
@@ -86,11 +83,13 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
|||||||
pendingMember,
|
pendingMember,
|
||||||
clearPending,
|
clearPending,
|
||||||
selectedGroupId,
|
selectedGroupId,
|
||||||
|
isSelectionHydrated,
|
||||||
setSelectedGroupId
|
setSelectedGroupId
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
clearPending,
|
clearPending,
|
||||||
closeMiniChat,
|
closeMiniChat,
|
||||||
|
isSelectionHydrated,
|
||||||
miniChatOpen,
|
miniChatOpen,
|
||||||
openChatRoom,
|
openChatRoom,
|
||||||
openChatWithMember,
|
openChatWithMember,
|
||||||
|
|||||||
@@ -456,6 +456,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
void refreshProfile();
|
void refreshProfile();
|
||||||
}, [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(() => {
|
React.useEffect(() => {
|
||||||
function handleVisibilityChange() {
|
function handleVisibilityChange() {
|
||||||
if (document.visibilityState !== 'visible') return;
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
fetchFamilyPresence,
|
fetchFamilyPresence,
|
||||||
getAccessToken
|
getAccessToken
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { resolveFamilyGroupId } from '@/lib/selected-family-storage';
|
||||||
|
|
||||||
export function useSelectedFamily(enabled = true) {
|
export function useSelectedFamily(enabled = true) {
|
||||||
const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
|
const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
|
||||||
const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay();
|
const { selectedGroupId, setSelectedGroupId, isSelectionHydrated } = useFamilyOverlay();
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||||
const [group, setGroup] = useState<FamilyGroup | null>(null);
|
const [group, setGroup] = useState<FamilyGroup | null>(null);
|
||||||
@@ -33,6 +34,8 @@ export function useSelectedFamily(enabled = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingHydration = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupsResponse = await fetchFamilyGroups(userId, accessToken);
|
const groupsResponse = await fetchFamilyGroups(userId, accessToken);
|
||||||
const list = groupsResponse.groups ?? [];
|
const list = groupsResponse.groups ?? [];
|
||||||
@@ -46,8 +49,11 @@ export function useSelectedFamily(enabled = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveId =
|
const effectiveId = resolveFamilyGroupId(list, selectedGroupId, isSelectionHydrated);
|
||||||
selectedGroupId && list.some((item) => item.id === selectedGroupId) ? selectedGroupId : list[0]!.id;
|
if (!effectiveId) {
|
||||||
|
pendingHydration = !isSelectionHydrated;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (effectiveId !== selectedGroupId) {
|
if (effectiveId !== selectedGroupId) {
|
||||||
setSelectedGroupId(effectiveId);
|
setSelectedGroupId(effectiveId);
|
||||||
@@ -63,9 +69,11 @@ export function useSelectedFamily(enabled = true) {
|
|||||||
} catch {
|
} catch {
|
||||||
// Фоновая ошибка не должна сбрасывать уже показанную семью в сайдбаре.
|
// Фоновая ошибка не должна сбрасывать уже показанную семью в сайдбаре.
|
||||||
} finally {
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !userId || !token || isPinLocked || isLoading || !isContentReady) {
|
if (!enabled || !userId || !token || isPinLocked || isLoading || !isContentReady) {
|
||||||
@@ -78,7 +86,7 @@ export function useSelectedFamily(enabled = true) {
|
|||||||
}
|
}
|
||||||
setLoading(!hasLoadedRef.current);
|
setLoading(!hasLoadedRef.current);
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [enabled, isContentReady, isLoading, isPinLocked, refresh, token, userId]);
|
}, [enabled, isContentReady, isLoading, isPinLocked, isSelectionHydrated, refresh, token, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||||
|
|||||||
@@ -1007,6 +1007,7 @@ export async function apiFetch<T>(
|
|||||||
? { 'Content-Type': 'application/json' }
|
? { 'Content-Type': 'application/json' }
|
||||||
: {}),
|
: {}),
|
||||||
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
|
||||||
|
...(behavior.silent ? { 'X-Id-Passive-Activity': '1' } : {}),
|
||||||
...(options.headers as Record<string, string> | undefined)
|
...(options.headers as Record<string, string> | undefined)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1075,8 +1076,14 @@ export interface MediaAccessResponse {
|
|||||||
expiresAt: string;
|
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 });
|
const query = new URLSearchParams({ storageKey });
|
||||||
|
if (fileName) query.set('fileName', fileName);
|
||||||
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
|
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 [];
|
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 };
|
const next: Record<string, unknown> = { ...metadata, photoStorageKeys };
|
||||||
delete next.photoStorageKey;
|
delete next.photoStorageKey;
|
||||||
|
if (attachmentMeta && Object.keys(attachmentMeta).length > 0) {
|
||||||
|
next.attachmentMeta = attachmentMeta;
|
||||||
|
}
|
||||||
return next;
|
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?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
lastActivityAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -800,13 +800,13 @@ export class AuthGrpcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl')
|
@GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl')
|
||||||
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string }) {
|
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string; fileName?: string }) {
|
||||||
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType);
|
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType, command.fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl')
|
@GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl')
|
||||||
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string }) {
|
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string; fileName?: string }) {
|
||||||
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey);
|
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey, command.fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('MediaService', 'ResolveStreamToken')
|
@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 {
|
try {
|
||||||
this.minio.assertImageContentType(contentType);
|
normalizedContentType = this.minio.assertDocumentAttachmentContentType(contentType, fileName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||||
}
|
}
|
||||||
@@ -128,9 +129,9 @@ export class MediaService {
|
|||||||
throw new NotFoundException('Документ не найден');
|
throw new NotFoundException('Документ не найден');
|
||||||
}
|
}
|
||||||
|
|
||||||
const extension = this.minio.extensionForContentType(contentType);
|
const extension = this.minio.extensionForDocumentContentType(normalizedContentType, fileName);
|
||||||
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
|
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) {
|
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);
|
await this.access.assertCanViewUserDocuments(requesterId, targetUserId);
|
||||||
|
|
||||||
if (!storageKey.startsWith(`documents/${targetUserId}/`)) {
|
if (!storageKey.startsWith(`documents/${targetUserId}/`)) {
|
||||||
throw new BadRequestException('Некорректный ключ фото документа');
|
throw new BadRequestException('Некорректный ключ файла документа');
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.jwt.signAsync(
|
const token = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
purpose: 'media-stream',
|
purpose: 'media-stream',
|
||||||
objectKey: storageKey,
|
objectKey: storageKey,
|
||||||
ownerId: targetUserId
|
ownerId: targetUserId,
|
||||||
|
fileName: fileName?.trim() || undefined
|
||||||
} satisfies MediaStreamPayload,
|
} satisfies MediaStreamPayload,
|
||||||
{
|
{
|
||||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export class PinService {
|
|||||||
data: {
|
data: {
|
||||||
pinVerified: true,
|
pinVerified: true,
|
||||||
status: SessionStatus.ACTIVE,
|
status: SessionStatus.ACTIVE,
|
||||||
|
lastActivityAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export class SessionService {
|
|||||||
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
|
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
|
||||||
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
||||||
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
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({
|
await this.prisma.session.update({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
||||||
@@ -76,7 +77,7 @@ export class SessionService {
|
|||||||
|
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
data: { updatedAt: new Date() }
|
data: { lastActivityAt: new Date(), updatedAt: new Date() }
|
||||||
});
|
});
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
@@ -160,7 +161,7 @@ export class SessionService {
|
|||||||
return this.prisma.session.updateMany({
|
return this.prisma.session.updateMany({
|
||||||
where: {
|
where: {
|
||||||
pinVerified: true,
|
pinVerified: true,
|
||||||
updatedAt: { lt: threshold },
|
lastActivityAt: { lt: threshold },
|
||||||
status: SessionStatus.ACTIVE,
|
status: SessionStatus.ACTIVE,
|
||||||
user: { pinCode: { isEnabled: true } }
|
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,
|
extensionForChatContentType,
|
||||||
normalizeChatContentType
|
normalizeChatContentType
|
||||||
} from './chat-media.util';
|
} 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']);
|
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) {
|
assertImageContentType(contentType: string) {
|
||||||
if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
|
if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
|
||||||
throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF');
|
throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF');
|
||||||
@@ -133,6 +151,9 @@ export class MinioService implements OnModuleInit {
|
|||||||
if (storageKey.startsWith('chat/')) {
|
if (storageKey.startsWith('chat/')) {
|
||||||
return contentTypeForStorageKey(storageKey);
|
return contentTypeForStorageKey(storageKey);
|
||||||
}
|
}
|
||||||
|
if (storageKey.startsWith('documents/')) {
|
||||||
|
return this.contentTypeForDocumentKey(storageKey);
|
||||||
|
}
|
||||||
if (storageKey.endsWith('.png')) return 'image/png';
|
if (storageKey.endsWith('.png')) return 'image/png';
|
||||||
if (storageKey.endsWith('.webp')) return 'image/webp';
|
if (storageKey.endsWith('.webp')) return 'image/webp';
|
||||||
if (storageKey.endsWith('.gif')) return 'image/gif';
|
if (storageKey.endsWith('.gif')) return 'image/gif';
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ message DocumentPhotoUploadRequest {
|
|||||||
string userId = 1;
|
string userId = 1;
|
||||||
string documentId = 2;
|
string documentId = 2;
|
||||||
string contentType = 3;
|
string contentType = 3;
|
||||||
|
optional string fileName = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DocumentPhotoAccessRequest {
|
message DocumentPhotoAccessRequest {
|
||||||
string requesterId = 1;
|
string requesterId = 1;
|
||||||
string targetUserId = 2;
|
string targetUserId = 2;
|
||||||
string storageKey = 3;
|
string storageKey = 3;
|
||||||
|
optional string fileName = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PresignedUploadResponse {
|
message PresignedUploadResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user