first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
export type AddressLabel = 'HOME' | 'WORK' | 'OTHER';
export const ADDRESS_LABELS: Record<AddressLabel, { title: string; shortTitle: string }> = {
HOME: { title: 'Дом', shortTitle: 'Дом' },
WORK: { title: 'Работа', shortTitle: 'Работа' },
OTHER: { title: 'Другой адрес', shortTitle: 'Другие' }
};
export function formatAddressLine(address: {
fullAddress?: string | null;
city?: string;
street?: string;
house?: string;
apartment?: string | null;
}) {
if (address.fullAddress?.trim()) return address.fullAddress.trim();
const parts = [`г. ${address.city}`, `${address.street}, ${address.house}`];
if (address.apartment?.trim()) parts.push(`кв. ${address.apartment.trim()}`);
return parts.filter(Boolean).join(', ');
}
export function getPrimaryAddress(addresses: { label: string }[], label: AddressLabel) {
return addresses.find((item) => item.label === label) ?? null;
}
export function getOtherAddresses<T extends { label: string }>(addresses: T[]) {
return addresses.filter((item) => item.label === 'OTHER');
}

582
apps/frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,582 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export interface PublicUser {
id: string;
email?: string | null;
phone?: string | null;
backupEmail?: string | null;
backupPhone?: string | null;
displayName: string;
username?: string | null;
avatarUrl?: string | null;
hasAvatar?: boolean;
isSuperAdmin: boolean;
status: string;
roles?: string[];
permissions?: string[];
canAccessAdmin?: boolean;
canManageRoles?: boolean;
canManageOAuth?: boolean;
canManageUsers?: boolean;
canManageSettings?: boolean;
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
}
export interface AdminUser {
id: string;
email?: string | null;
phone?: string | null;
displayName: string;
username?: string | null;
isSuperAdmin: boolean;
status: string;
createdAt: string;
roles: string[];
}
export interface AdminRole {
id: string;
slug: string;
name: string;
description?: string;
permissions: { id: string; slug: string; name: string; description?: string }[];
}
export interface AdminPermission {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthScope {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthClient {
id: string;
name: string;
clientId: string;
redirectUris: string[];
scopes: string[];
isActive: boolean;
type: string;
clientSecret?: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: string;
pinVerified: boolean;
user: PublicUser;
sessionId: string;
}
export interface PasswordlessAuthResponse {
requiresPassword: boolean;
tempAuthToken?: string;
auth?: AuthTokens;
}
export interface LoginMethod {
kind: 'password' | 'otp';
channel: string;
masked: string;
}
export interface IdentifyResponse {
exists: boolean;
hasPassword: boolean;
isPinEnabled: boolean;
methods?: LoginMethod[];
}
export interface OtpSendResponse {
sent: boolean;
expiresAt: string;
maskedTarget: string;
}
export interface PinVerificationResponse {
accessToken: string;
pinVerified: boolean;
}
export interface RefreshSessionResponse {
requiresPin: boolean;
accessToken?: string;
sessionId?: string;
pinVerified: boolean;
user?: PublicUser;
}
export interface AuthSessionResponse {
requiresPin: boolean;
sessionId?: string | null;
pinVerified: boolean;
user: PublicUser;
}
export const AUTH_TOKEN_KEY = 'lendry_access_token';
export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
export class ApiError extends Error {
status: number;
code?: string;
sessionId?: string;
constructor(message: string, status: number, code?: string, sessionId?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.sessionId = sessionId;
}
}
type PinRequiredHandler = (sessionId: string) => void;
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
pinRequiredHandler = handler;
if (!handler) {
pinNotificationSent = false;
}
}
interface ErrorBody {
message?: string | string[];
error?: string;
code?: string;
sessionId?: string;
}
async function parseErrorResponse(response: Response): Promise<ApiError> {
try {
const body = (await response.clone().json()) as ErrorBody;
const message = Array.isArray(body.message)
? body.message.join('\n')
: (body.message ?? body.error ?? 'Не удалось выполнить запрос');
const pinRequired =
body.code === 'PIN_REQUIRED' ||
(response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin'));
return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId);
} catch {
return new ApiError('Не удалось выполнить запрос', response.status);
}
}
export function isPinRequiredError(error: unknown): error is ApiError {
return error instanceof ApiError && error.code === 'PIN_REQUIRED';
}
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null;
}
return error instanceof Error ? error.message : fallback;
}
let pinRequiredHandler: PinRequiredHandler | null = null;
let pinNotificationSent = false;
export function resetPinRequiredNotification() {
pinNotificationSent = false;
}
function notifyPinRequired(sessionId: string) {
if (!pinRequiredHandler || pinNotificationSent) return;
pinNotificationSent = true;
pinRequiredHandler(sessionId);
}
export async function fetchAuthSession(token?: string | null): Promise<AuthSessionResponse> {
return apiFetch<AuthSessionResponse>('/auth/session', {}, token);
}
export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
if (typeof window === 'undefined') {
throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH');
}
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY);
if (!refreshToken) {
throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH');
}
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return response.json() as Promise<RefreshSessionResponse>;
}
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
try {
const refreshed = await refreshAuthSession();
if (refreshed.requiresPin) {
notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return refreshed;
}
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.user) {
window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed }));
}
}
return refreshed;
} catch {
return null;
}
}
export interface UserDocument {
id: string;
userId: string;
type: string;
number: string;
issuedAt?: string;
expiresAt?: string;
metadataJson?: string;
}
export interface SystemSetting {
id: string;
key: string;
value: string;
description?: string | null;
isSecret: boolean;
}
export interface SocialProvider {
id: string;
providerName: string;
clientId: string;
isEnabled: boolean;
}
export interface ActiveDevice {
id: string;
name: string;
type: string;
lastIp?: string | null;
lastSeenAt: string;
trusted: boolean;
}
export interface SignInEvent {
id: string;
success: boolean;
reason?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: string;
}
export interface ActiveSession {
id: string;
userId: string;
deviceId?: string | null;
pinVerified: boolean;
status: string;
ipAddress?: string | null;
userAgent?: string | null;
expiresAt: string;
createdAt: string;
updatedAt: string;
}
async function parseError(response: Response): Promise<string> {
const error = await parseErrorResponse(response);
return error.message;
}
export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | 'value' | 'description' | 'isSecret'>) {
return {
key: setting.key,
value: setting.value,
description: setting.description ?? undefined,
isSecret: setting.isSecret
};
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
...options.headers
}
});
if (!response.ok) {
const error = await parseErrorResponse(response);
if (error.code === 'PIN_REQUIRED') {
notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
throw error;
}
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) {
return apiFetch<T>(path, options, refreshed.accessToken, false);
}
if (refreshed?.requiresPin) {
throw error;
}
}
throw error;
}
return response.json() as Promise<T>;
}
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export interface MediaAccessResponse {
accessUrl: string;
expiresAt: string;
}
export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) {
const query = new URLSearchParams({ storageKey });
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
}
export async function softDeleteProfile(userId: string, token?: string | null) {
return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token);
}
export interface UserAddress {
id: string;
userId: string;
label: 'HOME' | 'WORK' | 'OTHER';
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
createdAt: string;
updatedAt: string;
}
export async function fetchUserAddresses(userId: string, token?: string | null) {
return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token);
}
export async function upsertUserAddress(
userId: string,
body: {
addressId?: string;
label: UserAddress['label'];
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
},
token?: string | null
) {
return apiFetch<UserAddress>(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token);
}
export interface FamilyMember {
id: string;
userId: string;
role: string;
displayName: string;
hasAvatar: boolean;
}
export interface FamilyGroup {
id: string;
ownerId: string;
name: string;
hasAvatar: boolean;
members?: FamilyMember[];
}
export interface FamilyInvite {
id: string;
groupId: string;
groupName: string;
inviterId: string;
inviterName: string;
inviteeUserId?: string;
targetEmail?: string;
targetPhone?: string;
status: string;
expiresAt: string;
}
export interface AppNotification {
id: string;
type: string;
title: string;
message: string;
payloadJson?: string;
isRead: boolean;
createdAt: string;
}
export interface ChatPollOption {
id: string;
text: string;
voteCount: number;
votedByMe: boolean;
}
export interface ChatMessage {
id: string;
roomId: string;
senderId: string;
senderName: string;
senderHasAvatar: boolean;
type: string;
content?: string;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
createdAt: string;
poll?: {
id: string;
question: string;
allowsMultiple: boolean;
isAnonymous: boolean;
options: ChatPollOption[];
};
}
export interface ChatRoom {
id: string;
groupId: string;
type: string;
name: string;
hasAvatar: boolean;
updatedAt: string;
members: Array<{ id: string; userId: string; displayName: string; hasAvatar: boolean; notificationsMuted: boolean }>;
lastMessage?: ChatMessage;
}
export async function fetchFamilyGroups(userId: string, token?: string | null) {
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token);
}
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token);
}
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token);
}
export async function sendFamilyInvite(groupId: string, target: string, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify({ target }) }, token);
}
export async function fetchFamilyInvites(token?: string | null) {
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
}
export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token);
}
export async function fetchNotifications(token?: string | null, unreadOnly = false) {
return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token);
}
export async function fetchUnreadNotificationCount(token?: string | null) {
return apiFetch<{ count: number }>('/notifications/unread-count', {}, token);
}
export async function markNotificationRead(notificationId: string, token?: string | null) {
return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token);
}
export async function markAllNotificationsRead(token?: string | null) {
return apiFetch('/notifications/read-all', { method: 'POST' }, token);
}
export async function fetchChatRooms(groupId: string, token?: string | null) {
return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token);
}
export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) {
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/rooms`, {
method: 'POST',
body: JSON.stringify({ name, memberUserIds })
}, token);
}
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
}
export async function sendChatMessage(
roomId: string,
body: {
type: string;
content?: string;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
},
token?: string | null
) {
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
}
export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) {
return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token);
}
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws';

View File

@@ -0,0 +1,41 @@
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string) {
const response = await fetch(accessUrl, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
return response.blob();
}
export function createBlobObjectUrl(blob: Blob) {
return URL.createObjectURL(blob);
}
export function revokeBlobObjectUrl(url?: string) {
if (url?.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
}
export function triggerBlobDownload(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
anchor.rel = 'noopener';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
export function mediaExpiresAtMs(expiresAt?: string) {
if (!expiresAt) return Date.now() + 14 * 60_000;
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) ? parsed : Date.now() + 14 * 60_000;
}
export function shouldRefreshMedia(expiresAtMs: number, bufferMs = 60_000) {
return expiresAtMs - Date.now() <= bufferMs;
}

View File

@@ -0,0 +1,115 @@
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE';
const EXTENSION_TO_MIME: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
heic: 'image/heic',
heif: 'image/heif',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
ogg: 'audio/ogg',
opus: 'audio/ogg',
webm: 'audio/webm',
wav: 'audio/wav',
aac: 'audio/aac',
flac: 'audio/flac',
mp4: 'video/mp4',
mov: 'video/quicktime',
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',
csv: 'text/csv',
zip: 'application/zip',
rar: 'application/vnd.rar',
'7z': 'application/x-7z-compressed',
json: 'application/json',
xml: 'application/xml'
};
const MIME_ALIASES: Record<string, string> = {
'audio/x-m4a': 'audio/mp4',
'audio/x-aac': 'audio/aac',
'audio/x-wav': 'audio/wav',
'audio/x-flac': 'audio/flac',
'audio/x-mpeg': 'audio/mpeg',
'audio/mp3': 'audio/mpeg',
'image/jpg': 'image/jpeg',
'image/pjpeg': 'image/jpeg',
'application/x-pdf': 'application/pdf',
'application/x-zip-compressed': 'application/zip'
};
function extractFileExtension(value?: string | null) {
if (!value) return '';
const clean = value.split('?')[0]?.split('#')[0] ?? value;
const parts = clean.split('.');
if (parts.length < 2) return '';
return parts.pop()?.trim().toLowerCase() ?? '';
}
export function resolveChatContentType(file: Pick<File, 'type' | 'name'>) {
const raw = (file.type ?? '').trim().toLowerCase();
const base = raw.split(';')[0]?.trim() ?? '';
const aliased = MIME_ALIASES[base] ?? base;
if (aliased && aliased !== 'application/octet-stream') {
return aliased;
}
const extension = extractFileExtension(file.name);
if (extension && EXTENSION_TO_MIME[extension]) {
return EXTENSION_TO_MIME[extension];
}
return aliased || 'application/octet-stream';
}
export function inferChatMessageType(contentType: string, options?: { voice?: boolean }): ChatAttachmentKind {
const normalized = resolveChatContentType({ type: contentType, name: '' });
if (normalized.startsWith('image/')) return 'IMAGE';
if (normalized.startsWith('video/')) return 'FILE';
if (normalized.startsWith('audio/')) {
return options?.voice ? 'VOICE' : 'AUDIO';
}
return 'FILE';
}
export function detectChatMessageType(file: File, options?: { voice?: boolean }) {
const contentType = resolveChatContentType(file);
return inferChatMessageType(contentType, options);
}
export function formatFileSize(bytes?: number) {
if (!bytes || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
export function parseMessageMetadata(metadataJson?: string) {
if (!metadataJson) return {} as { fileName?: string; fileSize?: number; durationMs?: number };
try {
return JSON.parse(metadataJson) as { fileName?: string; fileSize?: number; durationMs?: number };
} catch {
return {};
}
}
export function fileIconLabel(fileName?: string, mimeType?: string) {
const extension = extractFileExtension(fileName);
if (extension === 'pdf' || mimeType === 'application/pdf') return 'PDF';
if (['doc', 'docx'].includes(extension)) return 'DOC';
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'XLS';
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
if (extension) return extension.toUpperCase().slice(0, 4);
return 'FILE';
}

View File

@@ -0,0 +1,308 @@
import type { LucideIcon } from 'lucide-react';
import {
Baby,
Building2,
Car,
CreditCard,
FileText,
HeartPulse,
Plane,
Stethoscope,
Wallet
} from 'lucide-react';
export type DocumentTypeCode =
| 'PASSPORT_RF'
| 'FOREIGN_PASSPORT'
| 'BIRTH_CERTIFICATE'
| 'DRIVER_LICENSE'
| 'VEHICLE_REGISTRATION'
| 'OMS'
| 'DMS'
| 'INN'
| 'SNILS';
export type FieldKind = 'text' | 'date' | 'gender' | 'checkbox' | 'textarea' | 'categories';
export interface DocumentFieldDef {
key: string;
label: string;
kind: FieldKind;
placeholder?: string;
latinKey?: string;
latinLabel?: string;
}
export interface DocumentTypeDef {
type: DocumentTypeCode;
label: string;
shortLabel: string;
category: 'personal' | 'transport' | 'health' | 'finance';
icon: LucideIcon;
accent: string;
numberKey: string;
fields: DocumentFieldDef[];
}
export const DOCUMENT_CATEGORIES = [
{ id: 'personal' as const, title: 'Личные документы' },
{ id: 'transport' as const, title: 'Транспорт' },
{ id: 'health' as const, title: 'Здоровье' },
{ id: 'finance' as const, title: 'Финансы' }
];
const baseNameFields: DocumentFieldDef[] = [
{ key: 'lastName', label: 'Фамилия', kind: 'text', placeholder: 'Фамилия' },
{ key: 'firstName', label: 'Имя', kind: 'text', placeholder: 'Имя' },
{ key: 'middleName', label: 'Отчество', kind: 'text', placeholder: 'Отчество' },
{ key: 'noMiddleName', label: 'Нет отчества', kind: 'checkbox' }
];
const latinNameFields: DocumentFieldDef[] = [
{ key: 'lastName', label: 'Фамилия', kind: 'text', placeholder: 'Фамилия', latinKey: 'lastNameLatin', latinLabel: 'Фамилия латинскими буквами' },
{ key: 'firstName', label: 'Имя', kind: 'text', placeholder: 'Имя', latinKey: 'firstNameLatin', latinLabel: 'Имя латинскими буквами' },
{ key: 'middleName', label: 'Отчество', kind: 'text', placeholder: 'Отчество', latinKey: 'middleNameLatin', latinLabel: 'Отчество латинскими буквами' },
{ key: 'noMiddleName', label: 'Нет отчества', kind: 'checkbox' }
];
export const DOCUMENT_TYPES: DocumentTypeDef[] = [
{
type: 'PASSPORT_RF',
label: 'Паспорт РФ',
shortLabel: 'Паспорт РФ',
category: 'personal',
icon: FileText,
accent: 'text-red-500 bg-red-50',
numberKey: 'seriesNumber',
fields: [
{ key: 'seriesNumber', label: 'Серия и номер', kind: 'text', placeholder: '0000 000000' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', placeholder: 'Место рождения' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'issuedBy', label: 'Кем выдан', kind: 'text', placeholder: 'Кем выдан' },
{ key: 'departmentCode', label: 'Код подразделения', kind: 'text', placeholder: '000-000' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'registrationAddress', label: 'Адрес регистрации', kind: 'text', placeholder: 'Адрес регистрации' }
]
},
{
type: 'FOREIGN_PASSPORT',
label: 'Загран',
shortLabel: 'Загран',
category: 'personal',
icon: Plane,
accent: 'text-red-500 bg-red-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text', placeholder: 'Номер' },
...latinNameFields,
{ key: 'citizenship', label: 'Гражданство', kind: 'text', placeholder: 'Гражданство', latinKey: 'citizenshipLatin', latinLabel: 'Гражданство латинскими буквами' },
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', placeholder: 'Место рождения', latinKey: 'birthPlaceLatin', latinLabel: 'Место рождения латинскими буквами' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'expiresAt', label: 'Дата окончания срока действия', kind: 'date' }
]
},
{
type: 'BIRTH_CERTIFICATE',
label: 'Свидетельство о рождении',
shortLabel: 'Св-во о рождении',
category: 'personal',
icon: Baby,
accent: 'text-red-500 bg-red-50',
numberKey: 'seriesNumber',
fields: [
{ key: 'series', label: 'Серия', kind: 'text', placeholder: 'Серия' },
{ key: 'number', label: 'Номер', kind: 'text', placeholder: 'Номер' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
{ key: 'recordNumber', label: 'Номер записи акта о рождении', kind: 'text' },
{ key: 'fatherLastName', label: 'Фамилия отца', kind: 'text', placeholder: 'Фамилия' },
{ key: 'fatherFirstName', label: 'Имя отца', kind: 'text', placeholder: 'Имя' },
{ key: 'fatherMiddleName', label: 'Отчество отца', kind: 'text', placeholder: 'Отчество' },
{ key: 'fatherNoMiddleName', label: 'Нет отчества (отец)', kind: 'checkbox' },
{ key: 'fatherCitizenship', label: 'Гражданство отца', kind: 'text' },
{ key: 'fatherBirthDate', label: 'Дата рождения отца', kind: 'date' }
]
},
{
type: 'DRIVER_LICENSE',
label: 'ВУ',
shortLabel: 'ВУ',
category: 'transport',
icon: CreditCard,
accent: 'text-violet-500 bg-violet-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text' },
...latinNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', latinKey: 'birthPlaceLatin', latinLabel: 'Место рождения латинскими буквами' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'expiresAt', label: 'Действует до', kind: 'date' },
{ key: 'issuedBy', label: 'Кем выдано', kind: 'text', latinKey: 'issuedByLatin', latinLabel: 'Кем выдано латинскими буквами' },
{ key: 'issuePlace', label: 'Место выдачи', kind: 'text', latinKey: 'issuePlaceLatin', latinLabel: 'Место выдачи латинскими буквами' },
{ key: 'categories', label: 'Категории', kind: 'categories' },
{ key: 'specialMarks', label: 'Особые отметки', kind: 'textarea' }
]
},
{
type: 'VEHICLE_REGISTRATION',
label: 'СТС / СРТС',
shortLabel: 'СТС / СРТС',
category: 'transport',
icon: Car,
accent: 'text-violet-500 bg-violet-50',
numberKey: 'seriesNumber',
fields: [
{ key: 'seriesNumber', label: 'Серия и номер', kind: 'text' },
{ key: 'plateNumber', label: 'Регистрационный знак', kind: 'text', placeholder: 'X000XX00' },
{ key: 'vin', label: 'Идентификационный номер (VIN)', kind: 'text' },
{ key: 'makeModel', label: 'Марка, модель', kind: 'text', latinKey: 'makeModelLatin', latinLabel: 'Марка, модель латинскими буквами' },
{ key: 'vehicleType', label: 'Тип ТС', kind: 'text' },
{ key: 'vehicleCategory', label: 'Категория ТС', kind: 'text' },
{ key: 'year', label: 'Год выпуска', kind: 'text' },
{ key: 'chassisNumber', label: 'Номер шасси (рамы)', kind: 'text' },
{ key: 'bodyNumber', label: 'Номер кузова (кабины, прицепа)', kind: 'text' },
{ key: 'color', label: 'Цвет', kind: 'text' },
{ key: 'enginePower', label: 'Мощность двигателя, кВт/л.с.', kind: 'text' },
{ key: 'ecoClass', label: 'Экологический класс', kind: 'text' },
{ key: 'maxMass', label: 'Разрешенная максимальная масса, кг', kind: 'text' },
{ key: 'curbWeight', label: 'Масса в снаряженном состоянии, кг', kind: 'text' },
{ key: 'tempRegistration', label: 'Срок временной регистрации', kind: 'text' },
{ key: 'pts', label: 'ПТС', kind: 'text', placeholder: '77TX000006 или 123456789012345' },
...latinNameFields.slice(0, 3),
{ key: 'registrationAddress', label: 'Адрес регистрации', kind: 'text' },
{ key: 'departmentCode', label: 'Код подразделения (выдано ГИБДД)', kind: 'text' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
{ key: 'specialMarks', label: 'Особые отметки', kind: 'textarea' }
]
},
{
type: 'OMS',
label: 'ОМС',
shortLabel: 'ОМС',
category: 'health',
icon: Stethoscope,
accent: 'text-sky-500 bg-sky-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'blankSeriesNumber', label: 'Серия и номер бланка', kind: 'text' }
]
},
{
type: 'DMS',
label: 'ДМС',
shortLabel: 'ДМС',
category: 'health',
icon: HeartPulse,
accent: 'text-sky-500 bg-sky-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'Номер', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'validFrom', label: 'Действует с', kind: 'date' },
{ key: 'validTo', label: 'Действует до', kind: 'date' },
{ key: 'insurer', label: 'Страхователь', kind: 'text' }
]
},
{
type: 'INN',
label: 'ИНН',
shortLabel: 'ИНН',
category: 'finance',
icon: Building2,
accent: 'text-emerald-500 bg-emerald-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'ИНН', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'issuedBy', label: 'Орган, выдавший документ', kind: 'text' },
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' }
]
},
{
type: 'SNILS',
label: 'СНИЛС',
shortLabel: 'СНИЛС',
category: 'finance',
icon: Wallet,
accent: 'text-emerald-500 bg-emerald-50',
numberKey: 'number',
fields: [
{ key: 'number', label: 'СНИЛС', kind: 'text' },
...baseNameFields,
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
{ key: 'gender', label: 'Пол', kind: 'gender' },
{ key: 'registeredAt', label: 'Дата регистрации', kind: 'date' }
]
}
];
export const QUICK_DOCUMENT_TYPES = ['PASSPORT_RF', 'DRIVER_LICENSE', 'FOREIGN_PASSPORT'] as const;
export const LICENSE_CATEGORIES = ['A', 'B', 'C', 'D', 'A1', 'B1', 'C1', 'D1', 'M', 'BE', 'CE', 'DE', 'Tm', 'Tb', 'C1E', 'D1E'];
export function getDocumentType(type: string) {
return DOCUMENT_TYPES.find((item) => item.type === type);
}
export function buildDocumentNumber(type: DocumentTypeDef, values: Record<string, string>) {
if (type.type === 'BIRTH_CERTIFICATE') {
return [values.series, values.number].filter(Boolean).join(' ');
}
if (type.type === 'PASSPORT_RF' || type.type === 'VEHICLE_REGISTRATION') {
return values.seriesNumber || values.number || '—';
}
return values[type.numberKey] || '—';
}
export function parseMetadata(metadataJson?: string) {
if (!metadataJson) return {} as Record<string, unknown>;
try {
return JSON.parse(metadataJson) as Record<string, unknown>;
} catch {
return {} as Record<string, unknown>;
}
}
export function parseDocumentPhotos(metadata: Record<string, unknown>): string[] {
const keys = metadata.photoStorageKeys;
if (Array.isArray(keys)) {
return keys.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
if (typeof metadata.photoStorageKey === 'string' && metadata.photoStorageKey) {
return [metadata.photoStorageKey];
}
return [];
}
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[]) {
const next: Record<string, unknown> = { ...metadata, photoStorageKeys };
delete next.photoStorageKey;
return next;
}
/** Берёт самый свежий документ каждого типа (список с бэкенда отсортирован по updatedAt desc). */
export function indexDocumentsByType<T extends { type: string }>(documents: T[]): Map<string, T> {
const map = new Map<string, T>();
for (const document of documents) {
if (!map.has(document.type)) {
map.set(document.type, document);
}
}
return map;
}

View File

@@ -0,0 +1,76 @@
export interface ParsedAddress {
city: string;
street: string;
house: string;
fullAddress: string;
}
const NOMINATIM_HEADERS = {
'User-Agent': 'LendryID/1.0 (contact@lendry.ru)',
Accept: 'application/json'
};
export async function reverseGeocode(lat: number, lng: number): Promise<ParsedAddress | null> {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(String(lat))}&lon=${encodeURIComponent(String(lng))}&accept-language=ru`;
const response = await fetch(url, { headers: NOMINATIM_HEADERS });
if (!response.ok) return null;
const data = (await response.json()) as {
display_name?: string;
address?: Record<string, string | undefined>;
};
const addr = data.address ?? {};
const city = addr.city || addr.town || addr.village || addr.municipality || addr.state || '';
const street = addr.road || addr.pedestrian || addr.footway || addr.neighbourhood || '';
const house = addr.house_number || '';
return {
city,
street,
house,
fullAddress: data.display_name ?? formatParsed(city, street, house)
};
} catch {
return null;
}
}
export async function forwardGeocode(query: string): Promise<{ lat: number; lng: number; parsed: ParsedAddress } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
try {
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&q=${encodeURIComponent(trimmed)}&limit=1&accept-language=ru`;
const response = await fetch(url, { headers: NOMINATIM_HEADERS });
if (!response.ok) return null;
const data = (await response.json()) as Array<{
lat: string;
lon: string;
display_name?: string;
address?: Record<string, string | undefined>;
}>;
const first = data[0];
if (!first) return null;
const addr = first.address ?? {};
const city = addr.city || addr.town || addr.village || addr.municipality || '';
const street = addr.road || addr.pedestrian || '';
const house = addr.house_number || '';
return {
lat: Number(first.lat),
lng: Number(first.lon),
parsed: {
city,
street,
house,
fullAddress: first.display_name ?? formatParsed(city, street, house)
}
};
} catch {
return null;
}
}
function formatParsed(city: string, street: string, house: string) {
const parts = [];
if (city) parts.push(`г. ${city}`);
if (street || house) parts.push([street, house].filter(Boolean).join(', '));
return parts.join(', ');
}

View File

@@ -0,0 +1,62 @@
export type SystemSettingFieldType = 'text' | 'number' | 'boolean';
export interface SystemSettingMeta {
key: string;
label: string;
group: string;
type: SystemSettingFieldType;
unit?: string;
hint?: string;
}
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
general: 'Проект и брендинг',
pin: 'PIN-код и блокировка сессии',
auth: 'Аутентификация и регистрация',
ldap: 'LDAP / Active Directory',
limits: 'Лимиты и ограничения',
media: 'Медиа и файлы'
};
export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
{ key: 'PROJECT_NAME', label: 'Название проекта', group: 'general', type: 'text', hint: 'Отображается в меню, заголовке и боковой панели' },
{ key: 'PROJECT_TAGLINE', label: 'Слоган проекта', group: 'general', type: 'text' },
{ key: 'PROJECT_DOMAIN', label: 'Домен IdP', group: 'general', type: 'text' },
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' },
{ key: 'PIN_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' },
{ key: 'PIN_REQUIRE_ON_DELETE', label: 'Требовать PIN при удалении', group: 'pin', type: 'boolean' },
{ key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' },
{ key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' },
{ key: 'SESSION_REFRESH_DAYS', label: 'Срок refresh-токена', group: 'auth', type: 'number', unit: 'дн' },
{ key: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' },
{ key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' },
{ key: 'MAINTENANCE_MODE', label: 'Режим обслуживания', group: 'auth', type: 'boolean', hint: 'Блокирует вход для обычных пользователей' },
{ key: 'LDAP_ENABLED', label: 'LDAP включён', group: 'ldap', type: 'boolean', hint: 'Показывает кнопку входа через LDAP на странице авторизации' },
{ key: 'LDAP_USE_LDAPS', label: 'Использовать LDAPS', group: 'ldap', type: 'boolean', hint: 'Шифрованное подключение (порт 636)' },
{ key: 'LDAP_HOST', label: 'Хост LDAP', group: 'ldap', type: 'text', hint: 'DC-1.local,DC-2.local или ldaps://DC-1.local:636' },
{ key: 'LDAP_PORT', label: 'Порт LDAP', group: 'ldap', type: 'number', hint: '636 для LDAPS, 389 для LDAP. Не используйте 640.' },
{ key: 'LDAP_DOMAIN', label: 'Домен AD', group: 'ldap', type: 'text', hint: 'mvkug.local — для bind вида user@domain' },
{ key: 'LDAP_SKIP_TLS_VERIFY', label: 'Не проверять TLS', group: 'ldap', type: 'boolean', hint: 'Только для тестовых окружений' },
{ key: 'LDAP_BASE_DN', label: 'Base DN (поиск пользователей)', group: 'ldap', type: 'text', hint: 'OU или CN контейнера с пользователями. Напр. OU=Lugansk,DC=mvkug,DC=local. Если CN не работает — попробуйте OU' },
{ key: 'LDAP_BIND_USERNAME', label: 'Логин сервисной УЗ', group: 'ldap', type: 'text', hint: 'mvkadmin или mvkug\\mvkadmin — учётная запись для подключения к AD' },
{ key: 'LDAP_BIND_DN', label: 'Bind DN (опционально)', group: 'ldap', type: 'text', hint: 'Полный DN сервисной УЗ. Оставьте пустым, если указан логин выше' },
{ key: 'LDAP_BIND_PASSWORD', label: 'Пароль сервисной УЗ', group: 'ldap', type: 'text', hint: 'Секретное значение' },
{ key: 'LDAP_USER_FILTER', label: 'Фильтр пользователя', group: 'ldap', type: 'text', hint: 'Placeholder {username}' },
{ key: 'LDAP_USERNAME_ATTR', label: 'Атрибут логина', group: 'ldap', type: 'text' },
{ key: 'LDAP_EMAIL_ATTR', label: 'Атрибут почты', group: 'ldap', type: 'text' },
{ key: 'LDAP_DISPLAY_NAME_ATTR', label: 'Атрибут имени', group: 'ldap', type: 'text' },
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
];
export function getSettingMeta(key: string) {
return SYSTEM_SETTING_CATALOG.find((item) => item.key === key);
}
export function sortSettingsByCatalog<T extends { key: string }>(settings: T[]) {
const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index]));
return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999));
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}