feat(crm): мультисущностная архитектура, роли, раскладка карточек
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled
CI / E2E Tests (Playwright) (push) Has been cancelled
CI / Deploy to Production (push) Has been cancelled

- packages/crm-ui: переиспользуемые компоненты (EntityKanban, EntityTable,
  EntityCard, EntityFormDialog, StageSwitcher, ActivityList, TimelineFeed,
  FieldManager, PipelineManager, StageBadge)
- Pipeline entityType: воронки привязаны к типу сущности
- Role system: таблица roles + user_roles, multi-role JWT, RolesGuard
- Card layouts: admin-default + user-override раскладка карточек
- Field roleAccess: видимость полей per role (hidden/readonly/editable)
- EntityPermissions: multi-role поддержка (string | string[])
- DnD стадий, произвольный цвет стадий, FieldManager entityType prop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-27 09:27:51 +00:00
parent a10673fdeb
commit 204f8ce396
60 changed files with 5735 additions and 320 deletions

View File

@@ -0,0 +1,6 @@
-- Add entityType and isHidden columns to crm_pipelines
ALTER TABLE "crm_pipelines" ADD COLUMN "entityType" TEXT NOT NULL DEFAULT 'deal';
ALTER TABLE "crm_pipelines" ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false;
-- Index for filtering by club + entityType
CREATE INDEX "crm_pipelines_clubId_entityType_idx" ON "crm_pipelines" ("clubId", "entityType");

View File

@@ -0,0 +1,51 @@
-- CreateTable: roles
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"clubId" TEXT,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"isBuiltIn" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable: user_roles (many-to-many)
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "roles_clubId_idx" ON "roles"("clubId");
CREATE UNIQUE INDEX "roles_clubId_slug_key" ON "roles"("clubId", "slug");
CREATE INDEX "user_roles_userId_idx" ON "user_roles"("userId");
CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId");
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
-- AddForeignKey
ALTER TABLE "roles" ADD CONSTRAINT "roles_clubId_fkey" FOREIGN KEY ("clubId") REFERENCES "clubs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed built-in roles (clubId = NULL = global)
INSERT INTO "roles" ("id", "clubId", "name", "slug", "isBuiltIn", "isActive", "createdAt", "updatedAt") VALUES
(gen_random_uuid(), NULL, 'Суперадминистратор', 'super_admin', true, true, NOW(), NOW()),
(gen_random_uuid(), NULL, 'Админ клуба', 'club_admin', true, true, NOW(), NOW()),
(gen_random_uuid(), NULL, 'Тренер', 'trainer', true, true, NOW(), NOW()),
(gen_random_uuid(), NULL, 'Фитнес-менеджер', 'manager', true, true, NOW(), NOW()),
(gen_random_uuid(), NULL, 'Координатор', 'coordinator', true, true, NOW(), NOW()),
(gen_random_uuid(), NULL, 'Рецепция', 'receptionist', true, true, NOW(), NOW());
-- Migrate existing user.role -> user_roles
INSERT INTO "user_roles" ("id", "userId", "roleId", "createdAt")
SELECT gen_random_uuid(), u.id, r.id, NOW()
FROM "users" u
JOIN "roles" r ON LOWER(u.role::text) = r.slug
WHERE r."clubId" IS NULL;

View File

@@ -0,0 +1,57 @@
-- 00005_card_layouts_role_access
-- Adds roleAccess to crm_user_fields + CrmCardLayout tables
-- 1. Add roleAccess column to crm_user_fields
ALTER TABLE "crm_user_fields" ADD COLUMN "roleAccess" JSONB;
-- 2. Migrate showToTrainer → roleAccess
UPDATE "crm_user_fields" SET "roleAccess" =
CASE WHEN "showToTrainer" = true
THEN '{"trainer":"readonly","manager":"editable","club_admin":"editable","super_admin":"editable"}'::jsonb
ELSE '{"trainer":"hidden","manager":"editable","club_admin":"editable","super_admin":"editable"}'::jsonb
END;
-- 3. CrmCardLayout (admin-default per entity type)
CREATE TABLE "crm_card_layouts" (
"id" TEXT NOT NULL,
"clubId" TEXT,
"entityType" TEXT NOT NULL,
"fields" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crm_card_layouts_pkey" PRIMARY KEY ("id")
);
-- 4. CrmCardLayoutUser (per-user override)
CREATE TABLE "crm_card_layouts_user" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"clubId" TEXT,
"entityType" TEXT NOT NULL,
"useDefault" BOOLEAN NOT NULL DEFAULT true,
"fields" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crm_card_layouts_user_pkey" PRIMARY KEY ("id")
);
-- 5. Unique constraints
CREATE UNIQUE INDEX "crm_card_layouts_clubId_entityType_key" ON "crm_card_layouts"("clubId", "entityType");
CREATE UNIQUE INDEX "crm_card_layouts_user_userId_clubId_entityType_key" ON "crm_card_layouts_user"("userId", "clubId", "entityType");
-- 6. Indexes
CREATE INDEX "crm_card_layouts_clubId_idx" ON "crm_card_layouts"("clubId");
CREATE INDEX "crm_card_layouts_user_userId_idx" ON "crm_card_layouts_user"("userId");
CREATE INDEX "crm_card_layouts_user_clubId_idx" ON "crm_card_layouts_user"("clubId");
-- 7. Foreign keys
ALTER TABLE "crm_card_layouts" ADD CONSTRAINT "crm_card_layouts_clubId_fkey"
FOREIGN KEY ("clubId") REFERENCES "clubs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "crm_card_layouts_user" ADD CONSTRAINT "crm_card_layouts_user_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "crm_card_layouts_user" ADD CONSTRAINT "crm_card_layouts_user_clubId_fkey"
FOREIGN KEY ("clubId") REFERENCES "clubs"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -277,6 +277,11 @@ model Club {
crmLostReasons CrmLostReason[]
crmUserFields CrmUserField[]
crmWebhookEndpoints CrmWebhookEndpoint[]
crmCardLayouts CrmCardLayout[]
crmCardLayoutUsers CrmCardLayoutUser[]
// Roles
roles Role[]
@@map("clubs")
}
@@ -332,6 +337,12 @@ model User {
crmActivitiesCompleted CrmActivity[] @relation("CrmActivityCompleter")
crmStageHistoryMoves CrmStageHistory[] @relation("CrmStageHistoryMover")
// Role system (many-to-many)
userRoles UserRoleMap[]
// Card layouts
crmCardLayoutUsers CrmCardLayoutUser[]
@@unique([clubId, phone])
@@index([clubId])
@@index([departmentId])
@@ -339,6 +350,47 @@ model User {
@@map("users")
}
// ---------------------------------------------------------------------------
// 2a. Role (built-in + custom roles)
// ---------------------------------------------------------------------------
model Role {
id String @id @default(uuid())
clubId String?
name String
slug String
isBuiltIn Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
club Club? @relation(fields: [clubId], references: [id], onDelete: Cascade)
userRoles UserRoleMap[]
@@unique([clubId, slug])
@@index([clubId])
@@map("roles")
}
// ---------------------------------------------------------------------------
// 2b. UserRoleMap (many-to-many user <-> role)
// ---------------------------------------------------------------------------
model UserRoleMap {
id String @id @default(uuid())
userId String
roleId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
@@map("user_roles")
}
// ---------------------------------------------------------------------------
// 3. RefreshToken
// ---------------------------------------------------------------------------
@@ -1248,19 +1300,22 @@ model PlatformTheme {
// 38. CrmPipeline
model CrmPipeline {
id String @id @default(uuid())
clubId String?
name String
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
clubId String?
name String
entityType String @default("deal")
isDefault Boolean @default(false)
isHidden Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
club Club? @relation(fields: [clubId], references: [id], onDelete: Cascade)
stages CrmStage[]
deals CrmDeal[]
@@index([clubId])
@@index([clubId, entityType])
@@map("crm_pipelines")
}
@@ -1482,6 +1537,7 @@ model CrmUserField {
isRequired Boolean @default(false)
isMultiple Boolean @default(false)
showToTrainer Boolean @default(false)
roleAccess Json? // { "trainer": "hidden"|"readonly"|"editable", ... }
position Int @default(0)
defaultValue String?
description String?
@@ -1536,3 +1592,39 @@ model CrmWebhookEndpoint {
@@index([clubId])
@@map("crm_webhook_endpoints")
}
// 49. CrmCardLayout (admin-default card layout per entity type)
model CrmCardLayout {
id String @id @default(uuid())
clubId String?
entityType String
fields Json // [{ fieldKey, source: 'system'|'custom', width: 'full'|'half' }]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
club Club? @relation(fields: [clubId], references: [id], onDelete: Cascade)
@@unique([clubId, entityType])
@@index([clubId])
@@map("crm_card_layouts")
}
// 50. CrmCardLayoutUser (per-user override)
model CrmCardLayoutUser {
id String @id @default(uuid())
userId String
clubId String?
entityType String
useDefault Boolean @default(true)
fields Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
club Club? @relation(fields: [clubId], references: [id], onDelete: Cascade)
@@unique([userId, clubId, entityType])
@@index([userId])
@@index([clubId])
@@map("crm_card_layouts_user")
}

View File

@@ -34,6 +34,7 @@ import { AuditModule } from './modules/audit/audit.module';
import { EmailModule } from './modules/email';
import { ThemesModule } from './modules/themes';
import { CrmModule } from './modules/crm';
import { RolesModule } from './modules/roles/roles.module';
@Module({
imports: [
@@ -74,6 +75,7 @@ import { CrmModule } from './modules/crm';
EmailModule,
ThemesModule,
CrmModule,
RolesModule,
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})

View File

@@ -1,9 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
export interface JwtPayload {
sub: string;
clubId: string;
role: string;
roles: string[];
}
/**
@@ -19,8 +20,8 @@ export interface JwtPayload {
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
const request = ctx.switchToHttp().getRequest<{ user: JwtPayload }>();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -122,6 +122,8 @@ describe('AuthService', () => {
describe('login', () => {
it('generates tokens and stores refresh token', async () => {
const user = makeUser();
// getUserRoles query returns trainer role
mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([{ slug: 'trainer' }]);
mockJwtService.signAsync
.mockResolvedValueOnce('access-token-123')
.mockResolvedValueOnce('refresh-token-456');
@@ -142,6 +144,7 @@ describe('AuthService', () => {
firstName: 'Ivan',
lastName: 'Petrov',
role: 'trainer',
roles: ['trainer'],
clubId: CLUB_ID,
},
});
@@ -150,9 +153,10 @@ describe('AuthService', () => {
sub: USER_ID,
clubId: CLUB_ID,
role: 'trainer',
roles: ['trainer'],
});
expect(mockJwtService.signAsync).toHaveBeenCalledWith(
{ sub: USER_ID, clubId: CLUB_ID, role: 'trainer' },
{ sub: USER_ID, clubId: CLUB_ID, role: 'trainer', roles: ['trainer'] },
expect.objectContaining({
secret: 'refresh-secret',
expiresIn: '30d',
@@ -176,7 +180,10 @@ describe('AuthService', () => {
const payload = { sub: USER_ID, clubId: CLUB_ID, role: 'trainer' };
mockJwtService.verifyAsync.mockResolvedValue(payload);
mockConfigService.get.mockReturnValue('refresh-secret');
mockPrisma.$queryRawUnsafe.mockResolvedValue([{ id: 'token-uuid-001' }]);
// First call: check refresh token exists; second call: getUserRoles
mockPrisma.$queryRawUnsafe
.mockResolvedValueOnce([{ id: 'token-uuid-001' }])
.mockResolvedValueOnce([{ slug: 'trainer' }]);
mockJwtService.signAsync.mockResolvedValue('new-access-token');
const result = await service.refreshToken('valid-refresh-token');
@@ -185,15 +192,11 @@ describe('AuthService', () => {
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-refresh-token', {
secret: 'refresh-secret',
});
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('SELECT id FROM refresh_tokens'),
USER_ID,
'valid-refresh-token',
);
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: USER_ID,
clubId: CLUB_ID,
role: 'trainer',
roles: ['trainer'],
});
});

View File

@@ -1,9 +1,4 @@
import {
Injectable,
NotFoundException,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { Injectable, NotFoundException, UnauthorizedException, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
@@ -26,6 +21,7 @@ export interface AuthUser {
firstName: string;
lastName: string;
role: string;
roles: string[];
clubId: string;
}
@@ -39,6 +35,7 @@ export interface TokenPayload {
sub: string;
clubId: string;
role: string;
roles: string[];
}
@Injectable()
@@ -85,6 +82,35 @@ export class AuthService {
return user;
}
/**
* Fetch role slugs for a user from the user_roles join table.
*/
private async getUserRoles(userId: string): Promise<string[]> {
const rows = await this.prisma.$queryRawUnsafe<{ slug: string }[]>(
`SELECT r.slug FROM user_roles ur JOIN roles r ON r.id = ur."roleId" WHERE ur."userId" = $1`,
userId,
);
return rows.map((r) => r.slug);
}
/**
* Determine primary role for backward compat: highest-privilege role.
*/
private getPrimaryRole(roleSlugs: string[]): string {
const priority = [
'super_admin',
'club_admin',
'manager',
'coordinator',
'receptionist',
'trainer',
];
for (const p of priority) {
if (roleSlugs.includes(p)) return p;
}
return roleSlugs[0] || 'trainer';
}
/**
* Get user profile by ID.
*/
@@ -102,21 +128,38 @@ export class AuthService {
}
const u = users[0];
return { id: u.id, firstName: u.firstName, lastName: u.lastName, role: u.role, phone: u.phone };
const roleSlugs = await this.getUserRoles(u.id);
const primaryRole =
roleSlugs.length > 0 ? this.getPrimaryRole(roleSlugs) : u.role.toLowerCase();
return {
id: u.id,
firstName: u.firstName,
lastName: u.lastName,
role: primaryRole,
roles: roleSlugs.length > 0 ? roleSlugs : [u.role.toLowerCase()],
phone: u.phone,
};
}
/**
* Generate JWT access + refresh tokens for the authenticated user.
*/
async login(user: UserRecord): Promise<AuthTokens> {
// Fetch roles from new table
const roleSlugs = await this.getUserRoles(user.id);
const primaryRole =
roleSlugs.length > 0 ? this.getPrimaryRole(roleSlugs) : user.role.toLowerCase();
const roles = roleSlugs.length > 0 ? roleSlugs : [user.role.toLowerCase()];
const payload: TokenPayload = {
sub: user.id,
clubId: user.clubId,
role: user.role,
role: primaryRole,
roles,
};
const refreshExpiresIn =
this.configService.get<string>('jwt.refreshExpiresIn') || '30d';
const refreshExpiresIn = this.configService.get<string>('jwt.refreshExpiresIn') || '30d';
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
@@ -141,7 +184,8 @@ export class AuthService {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
role: primaryRole,
roles,
clubId: user.clubId,
},
};
@@ -172,15 +216,19 @@ export class AuthService {
);
if (!stored.length) {
throw new UnauthorizedException(
'Refresh token has been revoked or expired',
);
throw new UnauthorizedException('Refresh token has been revoked or expired');
}
// Re-fetch fresh roles on refresh
const roleSlugs = await this.getUserRoles(payload.sub);
const primaryRole = roleSlugs.length > 0 ? this.getPrimaryRole(roleSlugs) : payload.role;
const roles = roleSlugs.length > 0 ? roleSlugs : payload.roles || [payload.role];
const newPayload: TokenPayload = {
sub: payload.sub,
clubId: payload.clubId,
role: payload.role,
role: primaryRole,
roles,
};
const accessToken = await this.jwtService.signAsync(newPayload);
@@ -192,11 +240,7 @@ export class AuthService {
* Store refresh token in the database for revocation tracking.
* Uses ON CONFLICT to handle duplicate tokens (same user, same second).
*/
private async storeRefreshToken(
userId: string,
token: string,
expiresAt: Date,
): Promise<void> {
private async storeRefreshToken(userId: string, token: string, expiresAt: Date): Promise<void> {
await this.prisma.$executeRawUnsafe(
`INSERT INTO refresh_tokens (id, "userId", token, "expiresAt", "createdAt")
VALUES (gen_random_uuid(), $1, $2, $3::timestamp, NOW())

View File

@@ -1,15 +1,10 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, UserRole } from '../../../common/decorators/roles.decorator';
/**
* RolesGuard checks if the authenticated user has one of the required roles.
* Used in combination with @Roles() decorator.
* Supports multi-role: checks both `roles[]` (new) and `role` (backward compat).
*
* @example
* @Roles(UserRole.TRAINER, UserRole.COORDINATOR)
@@ -21,29 +16,34 @@ export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user as { role?: string } | undefined;
const request = context
.switchToHttp()
.getRequest<{ user?: { role?: string; roles?: string[] } }>();
const user = request.user;
if (!user?.role) {
throw new ForbiddenException('User role is not defined');
}
const userRole = user.role.toLowerCase();
const hasRole = requiredRoles.some((role) => role === userRole);
// Multi-role check: use roles[] if available, fallback to single role
const userRoles: string[] =
Array.isArray(user.roles) && user.roles.length > 0
? user.roles.map((r) => r.toLowerCase())
: [user.role.toLowerCase()];
const hasRole = requiredRoles.some((required) => userRoles.includes(required));
if (!hasRole) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
);
throw new ForbiddenException(`Access denied. Required roles: ${requiredRoles.join(', ')}`);
}
return true;

View File

@@ -7,6 +7,7 @@ export interface JwtPayload {
sub: string;
clubId: string;
role: string;
roles: string[];
iat?: number;
exp?: number;
}
@@ -26,10 +27,15 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Invalid token payload');
}
const role = payload.role.toLowerCase();
// Backward compat: if old token has no roles[], derive from role
const roles = Array.isArray(payload.roles) ? payload.roles.map((r) => r.toLowerCase()) : [role];
return {
sub: payload.sub,
clubId: payload.clubId,
role: payload.role.toLowerCase(),
role,
roles,
};
}
}

View File

@@ -0,0 +1,62 @@
import {
Controller,
Get,
Put,
Delete,
Body,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles, UserRole } from '../../../common/decorators/roles.decorator';
import { CurrentUser, type JwtPayload } from '../../../common/decorators/current-user.decorator';
import { CardLayoutService } from '../services/card-layout.service';
import { SetCardLayoutDto } from '../dto/set-card-layout.dto';
@ApiTags('CRM Card Layouts')
@ApiBearerAuth()
@Controller('crm/card-layouts')
@UseGuards(JwtAuthGuard)
export class CardLayoutsController {
constructor(private readonly cardLayouts: CardLayoutService) {}
@Get()
@ApiOperation({ summary: 'Get effective card layout (user override or admin default)' })
@ApiQuery({ name: 'entityType', required: true })
async getEffective(
@CurrentUser() user: JwtPayload,
@Query('entityType') entityType: string,
): Promise<any> {
const clubId = user.role === (UserRole.SUPER_ADMIN as string) ? null : user.clubId;
return this.cardLayouts.getEffectiveLayout(clubId, entityType, user.sub);
}
@Put()
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Set admin-default card layout' })
async setAdminLayout(@CurrentUser() user: JwtPayload, @Body() dto: SetCardLayoutDto) {
const clubId = user.role === (UserRole.SUPER_ADMIN as string) ? null : user.clubId;
return this.cardLayouts.setAdminLayout(clubId, dto);
}
@Put('me')
@ApiOperation({ summary: 'Set personal card layout override' })
async setUserLayout(@CurrentUser() user: JwtPayload, @Body() dto: SetCardLayoutDto) {
const clubId = user.role === (UserRole.SUPER_ADMIN as string) ? null : user.clubId;
return this.cardLayouts.setUserLayout(user.sub, clubId, dto);
}
@Delete('me')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Reset personal layout to admin default' })
@ApiQuery({ name: 'entityType', required: true })
async resetUserLayout(@CurrentUser() user: JwtPayload, @Query('entityType') entityType: string) {
const clubId = user.role === (UserRole.SUPER_ADMIN as string) ? null : user.clubId;
await this.cardLayouts.resetUserLayout(user.sub, clubId, entityType);
}
}

View File

@@ -36,9 +36,9 @@ export class PipelinesController {
@Get()
@ApiOperation({ summary: 'List all pipelines' })
@ApiResponse({ status: 200, description: 'List of pipelines with stages' })
async findAll(@CurrentUser() user: any) {
async findAll(@CurrentUser() user: any, @Query('entityType') entityType?: string) {
const clubId = user.role === UserRole.SUPER_ADMIN ? null : user.clubId;
return this.pipelinesService.findAll(clubId);
return this.pipelinesService.findAll(clubId, entityType);
}
@Post()

View File

@@ -1,17 +1,52 @@
/** Normalize role input to array for multi-role support */
export function normalizeRoles(role: string | string[]): string[] {
return Array.isArray(role) ? role : [role];
}
/** Check if any of the user's roles is in the allowed list */
export function hasAnyRole(userRoles: string[], allowed: string[]): boolean {
return userRoles.some((r) => allowed.includes(r));
}
/** Get the highest-privilege role from a list */
export function getPrimaryRole(roles: string[]): string {
const priority = [
'super_admin',
'club_admin',
'manager',
'coordinator',
'receptionist',
'trainer',
];
for (const p of priority) {
if (roles.includes(p)) return p;
}
return roles[0] || '';
}
export abstract class EntityPermissions {
abstract canCreate(userId: string, role: string, clubId: string | null): boolean;
abstract canCreate(userId: string, role: string | string[], clubId: string | null): boolean;
abstract canRead(
userId: string,
role: string,
role: string | string[],
clubId: string | null,
entityClubId: string | null,
): boolean;
abstract canUpdate(userId: string, role: string, clubId: string | null, entity: any): boolean;
abstract canUpdate(
userId: string,
role: string | string[],
clubId: string | null,
entity: any,
): boolean;
abstract canDelete(userId: string, role: string, clubId: string | null): boolean;
abstract canDelete(userId: string, role: string | string[], clubId: string | null): boolean;
/** Return additional Prisma where-clause for row-level filtering */
abstract filterByRole(userId: string, role: string, clubId: string | null): Record<string, any>;
abstract filterByRole(
userId: string,
role: string | string[],
clubId: string | null,
): Record<string, any>;
}

View File

@@ -10,6 +10,7 @@ import { LostReasonsService } from './services/lost-reasons.service';
import { TimelineService } from './services/timeline.service';
import { ActivitiesService } from './services/activities.service';
import { UserFieldsService } from './services/user-fields.service';
import { CardLayoutService } from './services/card-layout.service';
import { FraudDetectionService } from './services/fraud-detection.service';
import { FieldDetectorService } from './services/field-detector.service';
import { WebhookProcessorService } from './services/webhook-processor.service';
@@ -28,6 +29,7 @@ import { DealsController } from './controllers/deals.controller';
import { TimelineController } from './controllers/timeline.controller';
import { ActivitiesController } from './controllers/activities.controller';
import { FieldsController } from './controllers/fields.controller';
import { CardLayoutsController } from './controllers/card-layouts.controller';
import {
WebhooksPublicController,
WebhooksAdminController,
@@ -46,6 +48,7 @@ import { CrmSchedulerProcessor } from './processors/crm-scheduler.processor';
TimelineController,
ActivitiesController,
FieldsController,
CardLayoutsController,
WebhooksPublicController,
WebhooksAdminController,
TrainingsController,
@@ -61,6 +64,7 @@ import { CrmSchedulerProcessor } from './processors/crm-scheduler.processor';
TimelineService,
ActivitiesService,
UserFieldsService,
CardLayoutService,
FraudDetectionService,
FieldDetectorService,
WebhookProcessorService,
@@ -82,6 +86,7 @@ import { CrmSchedulerProcessor } from './processors/crm-scheduler.processor';
TimelineService,
ActivitiesService,
UserFieldsService,
CardLayoutService,
WebhookProcessorService,
EntityEventsService,
EntityRegistry,

View File

@@ -59,6 +59,13 @@ export class CreateFieldDto {
@IsOptional()
showToTrainer?: boolean;
@ApiPropertyOptional({
description: 'Per-role access: { "trainer": "hidden"|"readonly"|"editable", ... }',
example: { trainer: 'readonly', manager: 'editable', club_admin: 'editable' },
})
@IsOptional()
roleAccess?: Record<string, string>;
@ApiPropertyOptional()
@IsString()
@IsOptional()

View File

@@ -7,6 +7,11 @@ export class CreatePipelineDto {
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ description: 'Entity type', default: 'deal', example: 'deal' })
@IsString()
@IsOptional()
entityType?: string;
@ApiPropertyOptional({ description: 'Is default pipeline', default: false })
@IsBoolean()
@IsOptional()

View File

@@ -1,5 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsInt, IsEnum, IsBoolean, Min } from 'class-validator';
import {
IsString,
IsNotEmpty,
IsOptional,
IsInt,
IsEnum,
IsBoolean,
Min,
Matches,
} from 'class-validator';
import { CrmStageType, CrmActivityType } from '@prisma/client';
export class CreateStageDto {
@@ -16,6 +25,7 @@ export class CreateStageDto {
@ApiPropertyOptional({ description: 'Hex color', example: '#3B82F6' })
@IsString()
@IsOptional()
@Matches(/^#[0-9a-fA-F]{6}$/, { message: 'color must be a valid hex color (e.g. #3B82F6)' })
color?: string;
@ApiPropertyOptional({ enum: CrmStageType, default: 'OPEN' })

View File

@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsArray, ValidateNested, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
class CardLayoutFieldDto {
@ApiProperty({ example: 'amount' })
@IsString()
@IsNotEmpty()
fieldKey: string;
@ApiProperty({ enum: ['system', 'custom'] })
@IsIn(['system', 'custom'])
source: 'system' | 'custom';
@ApiProperty({ enum: ['full', 'half'] })
@IsIn(['full', 'half'])
width: 'full' | 'half';
}
export class SetCardLayoutDto {
@ApiProperty({ example: 'deal' })
@IsString()
@IsNotEmpty()
entityType: string;
@ApiProperty({ type: [CardLayoutFieldDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CardLayoutFieldDto)
fields: CardLayoutFieldDto[];
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { EntityPermissions } from '../../core/entity-permissions.base';
import { EntityPermissions, normalizeRoles, hasAnyRole } from '../../core/entity-permissions.base';
@Injectable()
export class DealPermissions extends EntityPermissions {
@@ -7,33 +7,41 @@ export class DealPermissions extends EntityPermissions {
private readonly ADMIN_ROLES = ['super_admin', 'club_admin'];
canCreate(userId: string, role: string): boolean {
return this.WRITE_ROLES.includes(role);
canCreate(_userId: string, role: string | string[]): boolean {
return hasAnyRole(normalizeRoles(role), this.WRITE_ROLES);
}
canRead(
_userId: string,
role: string,
role: string | string[],
_clubId: string | null,
_entityClubId: string | null,
): boolean {
if (this.ADMIN_ROLES.includes(role)) return true;
if (role === 'manager') return true; // filtered by assigneeId
const roles = normalizeRoles(role);
if (hasAnyRole(roles, this.ADMIN_ROLES)) return true;
if (roles.includes('manager')) return true; // filtered by assigneeId
return false;
}
canUpdate(userId: string, role: string, clubId: string | null, entity: any): boolean {
if (this.ADMIN_ROLES.includes(role)) return true;
if (role === 'manager') return entity.assigneeId === userId;
canUpdate(userId: string, role: string | string[], _clubId: string | null, entity: any): boolean {
const roles = normalizeRoles(role);
if (hasAnyRole(roles, this.ADMIN_ROLES)) return true;
if (roles.includes('manager')) return entity.assigneeId === userId;
return false;
}
canDelete(userId: string, role: string): boolean {
return this.ADMIN_ROLES.includes(role);
canDelete(_userId: string, role: string | string[]): boolean {
return hasAnyRole(normalizeRoles(role), this.ADMIN_ROLES);
}
filterByRole(userId: string, role: string, _clubId: string | null): Record<string, any> {
if (role === 'manager') {
filterByRole(
userId: string,
role: string | string[],
_clubId: string | null,
): Record<string, any> {
const roles = normalizeRoles(role);
if (hasAnyRole(roles, this.ADMIN_ROLES)) return {};
if (roles.includes('manager')) {
return { assigneeId: userId };
}
return {};

View File

@@ -1,31 +1,37 @@
import { Injectable } from '@nestjs/common';
import { EntityPermissions } from '../../core/entity-permissions.base';
import { EntityPermissions, normalizeRoles, hasAnyRole } from '../../core/entity-permissions.base';
const WRITE_ROLES = ['super_admin', 'club_admin', 'manager'];
const ADMIN_ROLES = ['super_admin', 'club_admin'];
@Injectable()
export class TrainingPermissions extends EntityPermissions {
canCreate(userId: string, role: string): boolean {
return WRITE_ROLES.includes(role);
canCreate(_userId: string, role: string | string[]): boolean {
return hasAnyRole(normalizeRoles(role), WRITE_ROLES);
}
canRead(): boolean {
return true;
}
canUpdate(userId: string, role: string): boolean {
return WRITE_ROLES.includes(role);
canUpdate(_userId: string, role: string | string[]): boolean {
return hasAnyRole(normalizeRoles(role), WRITE_ROLES);
}
canDelete(userId: string, role: string): boolean {
return role === 'super_admin' || role === 'club_admin';
canDelete(_userId: string, role: string | string[]): boolean {
return hasAnyRole(normalizeRoles(role), ADMIN_ROLES);
}
filterByRole(userId: string, role: string, clubId?: string | null): Record<string, any> {
if (role === 'super_admin') return {};
if (role === 'club_admin') return { clubId };
filterByRole(
userId: string,
role: string | string[],
clubId?: string | null,
): Record<string, any> {
const roles = normalizeRoles(role);
if (roles.includes('super_admin')) return {};
if (roles.includes('club_admin')) return { clubId };
// Trainers see their own
if (role === 'trainer') return { trainerId: userId };
if (roles.includes('trainer')) return { trainerId: userId };
// Managers see club trainings
return { clubId };
}

View File

@@ -0,0 +1,130 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
interface CardLayoutField {
fieldKey: string;
source: 'system' | 'custom';
width: 'full' | 'half';
}
interface SetLayoutDto {
entityType: string;
fields: CardLayoutField[];
}
@Injectable()
export class CardLayoutService {
constructor(private readonly prisma: PrismaService) {}
/** Get effective layout: user override or admin default */
async getEffectiveLayout(
clubId: string | null,
entityType: string,
userId: string,
): Promise<{ fields: CardLayoutField[]; isDefault: boolean }> {
// Check for user override
const userLayout = await this.prisma.crmCardLayoutUser.findUnique({
where: {
userId_clubId_entityType: {
userId,
clubId: clubId ?? '',
entityType,
},
},
});
if (userLayout && !userLayout.useDefault && userLayout.fields) {
return {
fields: userLayout.fields as unknown as CardLayoutField[],
isDefault: false,
};
}
// Fall back to admin default
const adminLayout = await this.prisma.crmCardLayout.findUnique({
where: {
clubId_entityType: {
clubId: clubId ?? '',
entityType,
},
},
});
if (adminLayout) {
return {
fields: adminLayout.fields as unknown as CardLayoutField[],
isDefault: true,
};
}
// No layout configured — return empty (frontend uses its own default)
return { fields: [], isDefault: true };
}
/** Set admin-default layout for entity type */
async setAdminLayout(clubId: string | null, dto: SetLayoutDto) {
return this.prisma.crmCardLayout.upsert({
where: {
clubId_entityType: {
clubId: clubId ?? '',
entityType: dto.entityType,
},
},
create: {
clubId: clubId || null,
entityType: dto.entityType,
fields: dto.fields as any,
},
update: {
fields: dto.fields as any,
},
});
}
/** Set personal user layout override */
async setUserLayout(userId: string, clubId: string | null, dto: SetLayoutDto) {
return this.prisma.crmCardLayoutUser.upsert({
where: {
userId_clubId_entityType: {
userId,
clubId: clubId ?? '',
entityType: dto.entityType,
},
},
create: {
userId,
clubId: clubId || null,
entityType: dto.entityType,
useDefault: false,
fields: dto.fields as any,
},
update: {
useDefault: false,
fields: dto.fields as any,
},
});
}
/** Reset user layout to admin default */
async resetUserLayout(userId: string, clubId: string | null, entityType: string) {
const existing = await this.prisma.crmCardLayoutUser.findUnique({
where: {
userId_clubId_entityType: {
userId,
clubId: clubId ?? '',
entityType,
},
},
});
if (!existing) {
throw new NotFoundException('User layout not found');
}
return this.prisma.crmCardLayoutUser.update({
where: { id: existing.id },
data: { useDefault: true, fields: Prisma.JsonNull },
});
}
}

View File

@@ -108,9 +108,12 @@ const B2C_STAGES: DefaultStage[] = [
export class PipelinesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(clubId: string | null): Promise<any[]> {
async findAll(clubId: string | null, entityType?: string): Promise<any[]> {
return this.prisma.crmPipeline.findMany({
where: { clubId },
where: {
clubId,
...(entityType ? { entityType } : {}),
},
include: PIPELINE_INCLUDE,
orderBy: { createdAt: 'asc' },
});
@@ -316,10 +319,10 @@ export class PipelinesService {
return stage;
}
/** Get the default pipeline for a club (or platform) */
async getDefaultPipeline(clubId: string | null): Promise<any> {
/** Get the default pipeline for a club (or platform), optionally scoped by entityType */
async getDefaultPipeline(clubId: string | null, entityType = 'deal'): Promise<any> {
const pipeline = await this.prisma.crmPipeline.findFirst({
where: { clubId, isDefault: true, isActive: true },
where: { clubId, entityType, isDefault: true, isActive: true },
include: PIPELINE_INCLUDE,
});
return pipeline;

View File

@@ -9,11 +9,30 @@ export class UserFieldsService {
constructor(private readonly prisma: PrismaService) {}
/** Get all fields for entity type, scoped to club */
async findByEntity(clubId: string | null, entityType: string): Promise<any[]> {
return this.prisma.crmUserField.findMany({
async findByEntity(
clubId: string | null,
entityType: string,
userRoles?: string[],
): Promise<any[]> {
const fields = await this.prisma.crmUserField.findMany({
where: { clubId, entityType, isActive: true },
orderBy: { position: 'asc' },
});
// Filter out hidden fields for the user's roles
if (userRoles && userRoles.length > 0) {
return fields.filter((field) => {
if (!field.roleAccess) return true;
const access = field.roleAccess as Record<string, string>;
// Field visible if any of user's roles has non-hidden access
return userRoles.some((role) => {
const level = access[role];
return !level || level !== 'hidden';
});
});
}
return fields;
}
async getById(id: string): Promise<any> {
@@ -56,6 +75,7 @@ export class UserFieldsService {
isRequired: dto.isRequired ?? false,
isMultiple: dto.isMultiple ?? false,
showToTrainer: dto.showToTrainer ?? false,
roleAccess: dto.roleAccess ?? undefined,
defaultValue: dto.defaultValue,
description: dto.description,
position: (maxPos._max.position ?? 0) + 1,
@@ -74,6 +94,7 @@ export class UserFieldsService {
...(dto.isRequired !== undefined ? { isRequired: dto.isRequired } : {}),
...(dto.isMultiple !== undefined ? { isMultiple: dto.isMultiple } : {}),
...(dto.showToTrainer !== undefined ? { showToTrainer: dto.showToTrainer } : {}),
...(dto.roleAccess !== undefined ? { roleAccess: dto.roleAccess } : {}),
...(dto.defaultValue !== undefined ? { defaultValue: dto.defaultValue } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
},

View File

@@ -0,0 +1,78 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Put,
Param,
Body,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import { CurrentUser, type JwtPayload } from '../../common/decorators/current-user.decorator';
import { RolesService } from './roles.service';
@ApiTags('Roles')
@ApiBearerAuth()
@Controller('roles')
@UseGuards(JwtAuthGuard)
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
@ApiOperation({ summary: 'List all roles (built-in + custom)' })
async findAll(@CurrentUser() user: JwtPayload) {
const clubId = user.role === (UserRole.SUPER_ADMIN as string) ? null : user.clubId;
return this.rolesService.findAll(clubId);
}
@Post()
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Create a custom role' })
async create(@CurrentUser() user: JwtPayload, @Body() body: { name: string; slug: string }) {
const clubId = user.clubId;
return this.rolesService.create(clubId, body);
}
@Patch(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Rename a role' })
async rename(@Param('id', ParseUUIDPipe) id: string, @Body() body: { name: string }) {
return this.rolesService.rename(id, body.name);
}
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN, UserRole.CLUB_ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a custom role' })
async delete(@Param('id', ParseUUIDPipe) id: string) {
await this.rolesService.delete(id);
}
@Put('users/:userId/roles')
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Assign roles to a user' })
async assignRoles(
@Param('userId', ParseUUIDPipe) userId: string,
@Body() body: { roleIds: string[] },
) {
return this.rolesService.assignRoles(userId, body.roleIds);
}
@Get('users/:userId/roles')
@ApiOperation({ summary: 'Get roles for a user' })
async getUserRoles(@Param('userId', ParseUUIDPipe) userId: string) {
return this.rolesService.getUserRoles(userId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
@Module({
controllers: [RolesController],
providers: [RolesService],
exports: [RolesService],
})
export class RolesModule {}

View File

@@ -0,0 +1,165 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class RolesService {
constructor(private readonly prisma: PrismaService) {}
/** List all roles: built-in (clubId=null) + custom for a specific club */
async findAll(clubId: string | null) {
const roles = await this.prisma.role.findMany({
where: {
OR: [{ clubId: null }, ...(clubId ? [{ clubId }] : [])],
},
include: { _count: { select: { userRoles: true } } },
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
});
return roles.map((r) => ({
id: r.id,
clubId: r.clubId,
name: r.name,
slug: r.slug,
isBuiltIn: r.isBuiltIn,
isActive: r.isActive,
userCount: r._count.userRoles,
createdAt: r.createdAt,
}));
}
/** Create a custom role for a club */
async create(clubId: string, data: { name: string; slug: string }) {
if (!data.name?.trim() || !data.slug?.trim()) {
throw new BadRequestException('name and slug are required');
}
const slug = data.slug.toLowerCase().replace(/[^a-z0-9_]/g, '_');
// Check uniqueness
const existing = await this.prisma.role.findFirst({
where: { clubId, slug },
});
if (existing) {
throw new ConflictException(`Role with slug "${slug}" already exists`);
}
return this.prisma.role.create({
data: {
clubId,
name: data.name.trim(),
slug,
isBuiltIn: false,
isActive: true,
},
});
}
/** Rename a role (built-in: only if allowed, custom: always) */
async rename(id: string, name: string) {
const role = await this.prisma.role.findUnique({ where: { id } });
if (!role) throw new NotFoundException('Role not found');
// Built-in roles that can't be renamed
const nonRenameable = ['super_admin', 'club_admin', 'trainer'];
if (role.isBuiltIn && nonRenameable.includes(role.slug)) {
throw new BadRequestException(`Built-in role "${role.slug}" cannot be renamed`);
}
return this.prisma.role.update({
where: { id },
data: { name: name.trim() },
});
}
/** Delete a custom role (not built-in, no users assigned) */
async delete(id: string) {
const role = await this.prisma.role.findUnique({
where: { id },
include: { _count: { select: { userRoles: true } } },
});
if (!role) throw new NotFoundException('Role not found');
if (role.isBuiltIn) {
throw new BadRequestException('Built-in roles cannot be deleted');
}
if (role._count.userRoles > 0) {
throw new BadRequestException(
`Role "${role.name}" has ${role._count.userRoles} users assigned. Remove assignments first.`,
);
}
return this.prisma.role.delete({ where: { id } });
}
/** Assign roles to a user (replaces existing assignments) */
async assignRoles(userId: string, roleIds: string[]) {
if (!roleIds.length) {
throw new BadRequestException('At least one role is required');
}
// Validate all roles exist
const roles = await this.prisma.role.findMany({
where: { id: { in: roleIds } },
});
if (roles.length !== roleIds.length) {
throw new BadRequestException('One or more role IDs are invalid');
}
// Delete existing and create new in transaction
await this.prisma.$transaction([
this.prisma.userRoleMap.deleteMany({ where: { userId } }),
...roleIds.map((roleId) =>
this.prisma.userRoleMap.create({
data: { userId, roleId },
}),
),
]);
// Also update legacy User.role field for backward compat
const roleSlugs = roles.map((r) => r.slug);
const primaryRole = this.getPrimaryRole(roleSlugs);
const enumValue = primaryRole.toUpperCase();
await this.prisma.$executeRawUnsafe(
`UPDATE users SET role = $1::"UserRole", "updatedAt" = NOW() WHERE id = $2`,
enumValue,
userId,
);
return { userId, roleIds };
}
/** Get roles for a user */
async getUserRoles(userId: string) {
const mappings = await this.prisma.userRoleMap.findMany({
where: { userId },
include: { role: true },
});
return mappings.map((m) => ({
id: m.role.id,
name: m.role.name,
slug: m.role.slug,
isBuiltIn: m.role.isBuiltIn,
}));
}
private getPrimaryRole(slugs: string[]): string {
const priority = [
'super_admin',
'club_admin',
'manager',
'coordinator',
'receptionist',
'trainer',
];
for (const p of priority) {
if (slugs.includes(p)) return p;
}
return slugs[0] || 'trainer';
}
}

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@fitcrm/crm-ui'],
};
export default nextConfig;

View File

@@ -10,21 +10,25 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fitcrm/crm-ui": "workspace:^",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.460.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0",
"lucide-react": "^0.460.0"
"tailwindcss": "^4.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.5.0",
"postcss": "^8.4.0",
"@tailwindcss/postcss": "^4.0.0"
"typescript": "^5.5.0"
}
}

View File

@@ -7,8 +7,9 @@ import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { PipelineManager } from '@/components/crm/pipeline-manager';
import { FieldManager } from '@/components/crm/field-manager';
import { CardLayoutEditor } from '@/components/crm/card-layout-editor';
type Tab = 'pipelines' | 'fields' | 'webhooks' | 'lost-reasons';
type Tab = 'pipelines' | 'fields' | 'card-layout' | 'webhooks' | 'lost-reasons';
interface WebhookEndpoint {
id: string;
@@ -51,6 +52,7 @@ export default function CrmSettingsPage() {
{[
{ key: 'pipelines' as const, label: 'Воронки' },
{ key: 'fields' as const, label: 'Кастомные поля' },
{ key: 'card-layout' as const, label: 'Раскладка карточки' },
{ key: 'webhooks' as const, label: 'Вебхуки' },
{ key: 'lost-reasons' as const, label: 'Причины проигрыша' },
].map((t) => (
@@ -72,6 +74,7 @@ export default function CrmSettingsPage() {
{/* Tab content */}
{tab === 'pipelines' && <PipelinesTab />}
{tab === 'fields' && <FieldsTab />}
{tab === 'card-layout' && <CardLayoutTab />}
{tab === 'webhooks' && <WebhooksTab />}
{tab === 'lost-reasons' && <LostReasonsTab />}
</div>
@@ -85,7 +88,61 @@ function PipelinesTab() {
// --- Custom Fields Tab ---
function FieldsTab() {
return <FieldManager />;
const [entityType, setEntityType] = useState('deal');
const entityTypes = [
{ key: 'deal', label: 'Сделки' },
{ key: 'training', label: 'Тренировки' },
];
return (
<div>
<div className="flex items-center gap-1 mb-4">
{entityTypes.map((et) => (
<button
key={et.key}
onClick={() => setEntityType(et.key)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
entityType === et.key
? 'bg-primary/10 text-primary'
: 'text-muted hover:text-text hover:bg-background',
)}
>
{et.label}
</button>
))}
</div>
<FieldManager key={entityType} entityType={entityType} />
</div>
);
}
// --- Card Layout Tab ---
function CardLayoutTab() {
const [entityType, setEntityType] = useState('deal');
const entityTypes = [{ key: 'deal', label: 'Сделки' }];
return (
<div>
<div className="flex items-center gap-1 mb-4">
{entityTypes.map((et) => (
<button
key={et.key}
onClick={() => setEntityType(et.key)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
entityType === et.key
? 'bg-primary/10 text-primary'
: 'text-muted hover:text-text hover:bg-background',
)}
>
{et.label}
</button>
))}
</div>
<CardLayoutEditor key={entityType} entityType={entityType} />
</div>
);
}
// --- Webhooks Tab ---

View File

@@ -0,0 +1,287 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Plus, Pencil, Trash2, Loader2, Shield, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
interface RoleData {
id: string;
clubId: string | null;
name: string;
slug: string;
isBuiltIn: boolean;
isActive: boolean;
userCount: number;
}
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
export default function RolesSettingsPage() {
const router = useRouter();
const [roles, setRoles] = useState<RoleData[]>([]);
const [loading, setLoading] = useState(true);
// Create/rename form
const [showForm, setShowForm] = useState(false);
const [editingRole, setEditingRole] = useState<RoleData | null>(null);
const [formName, setFormName] = useState('');
const [formSlug, setFormSlug] = useState('');
const [saving, setSaving] = useState(false);
const fetchRoles = useCallback(async () => {
try {
const data = await api.get<RoleData[]>('/roles');
setRoles(data);
} catch {
//
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchRoles();
}, [fetchRoles]);
const openCreate = () => {
setEditingRole(null);
setFormName('');
setFormSlug('');
setShowForm(true);
};
const openRename = (role: RoleData) => {
setEditingRole(role);
setFormName(role.name);
setFormSlug(role.slug);
setShowForm(true);
};
const handleNameChange = (val: string) => {
setFormName(val);
if (!editingRole) {
setFormSlug(
val
.toLowerCase()
.replace(/[а-яё]/g, (c) => {
const map: Record<string, string> = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ё: 'e',
ж: 'zh',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
х: 'kh',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'sch',
ъ: '',
ы: 'y',
ь: '',
э: 'e',
ю: 'yu',
я: 'ya',
};
return map[c] || c;
})
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, ''),
);
}
};
const handleSave = async () => {
if (!formName.trim()) return;
setSaving(true);
try {
if (editingRole) {
await api.patch(`/roles/${editingRole.id}`, { name: formName.trim() });
} else {
await api.post('/roles', { name: formName.trim(), slug: formSlug.trim() });
}
setShowForm(false);
await fetchRoles();
} catch (err: any) {
alert(err?.message || 'Ошибка');
} finally {
setSaving(false);
}
};
const handleDelete = async (role: RoleData) => {
if (!confirm(`Удалить роль "${role.name}"? Это возможно только если нет назначений.`)) return;
try {
await api.delete(`/roles/${role.id}`);
await fetchRoles();
} catch (err: any) {
alert(err?.message || 'Ошибка удаления');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.back()}
className="p-2 rounded-lg hover:bg-background transition-colors"
>
<ArrowLeft className="h-5 w-5 text-muted" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text">Управление ролями</h1>
<p className="text-sm text-muted">Системные и кастомные роли</p>
</div>
<button
onClick={openCreate}
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Новая роль
</button>
</div>
<div className="bg-card rounded-xl border border-border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-background/50">
<th className="text-left px-4 py-3 text-muted font-medium">Роль</th>
<th className="text-left px-4 py-3 text-muted font-medium">Slug</th>
<th className="text-center px-4 py-3 text-muted font-medium">Тип</th>
<th className="text-center px-4 py-3 text-muted font-medium">Пользователей</th>
<th className="text-right px-4 py-3 text-muted font-medium w-24">Действия</th>
</tr>
</thead>
<tbody>
{roles.map((role) => (
<tr key={role.id} className="border-b border-border/50 group">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{role.isBuiltIn ? (
<ShieldCheck className="h-4 w-4 text-primary" />
) : (
<Shield className="h-4 w-4 text-muted" />
)}
<span className="font-medium text-text">{role.name}</span>
</div>
</td>
<td className="px-4 py-3 text-muted font-mono text-xs">{role.slug}</td>
<td className="px-4 py-3 text-center">
<span
className={cn(
'text-[10px] px-2 py-0.5 rounded-full',
role.isBuiltIn ? 'bg-primary/10 text-primary' : 'bg-background text-muted',
)}
>
{role.isBuiltIn ? 'Системная' : 'Кастомная'}
</span>
</td>
<td className="px-4 py-3 text-center text-muted">{role.userCount}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{(!role.isBuiltIn ||
!['super_admin', 'club_admin', 'trainer'].includes(role.slug)) && (
<button
onClick={() => openRename(role)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{!role.isBuiltIn && (
<button
onClick={() => handleDelete(role)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Create/Rename dialog */}
<Dialog open={showForm} onClose={() => setShowForm(false)}>
<DialogHeader
title={editingRole ? 'Переименовать роль' : 'Новая роль'}
onClose={() => setShowForm(false)}
/>
<DialogBody className="space-y-4">
<div>
<label className="block text-sm font-medium text-text mb-1">Название</label>
<input
type="text"
value={formName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Инструктор групповых"
className={inputClass}
/>
</div>
{!editingRole && (
<div>
<label className="block text-sm font-medium text-text mb-1">
Slug (латиница, snake_case)
</label>
<input
type="text"
value={formSlug}
onChange={(e) => setFormSlug(e.target.value)}
placeholder="group_instructor"
className={inputClass}
/>
</div>
)}
</DialogBody>
<DialogFooter>
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm text-muted hover:text-text transition-colors"
>
Отмена
</button>
<button
onClick={handleSave}
disabled={saving || !formName.trim() || (!editingRole && !formSlug.trim())}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</DialogFooter>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,302 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { GripVertical, Plus, Trash2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
interface LayoutField {
fieldKey: string;
source: 'system' | 'custom';
width: 'full' | 'half';
}
interface CustomFieldInfo {
id: string;
name: string;
fieldName: string;
type: string;
}
interface CardLayoutEditorProps {
entityType?: string;
}
const DEAL_SYSTEM_FIELDS = [
{ key: 'amount', label: 'Сумма' },
{ key: 'source', label: 'Источник' },
{ key: 'phone', label: 'Телефон' },
{ key: 'email', label: 'Email' },
{ key: 'contactName', label: 'Контакт' },
{ key: 'assignee', label: 'Ответственный' },
{ key: 'pipeline', label: 'Воронка' },
{ key: 'stage', label: 'Стадия' },
{ key: 'createdAt', label: 'Дата создания' },
{ key: 'updatedAt', label: 'Дата обновления' },
{ key: 'lostReason', label: 'Причина проигрыша' },
{ key: 'comment', label: 'Комментарий' },
];
const SYSTEM_FIELDS_MAP: Record<string, { key: string; label: string }[]> = {
deal: DEAL_SYSTEM_FIELDS,
};
export function CardLayoutEditor({ entityType = 'deal' }: CardLayoutEditorProps) {
const [fields, setFields] = useState<(LayoutField & { label: string })[]>([]);
const [customFields, setCustomFields] = useState<CustomFieldInfo[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isDefault, setIsDefault] = useState(true);
const systemFields = SYSTEM_FIELDS_MAP[entityType] || DEAL_SYSTEM_FIELDS;
const fetchLayout = useCallback(async () => {
try {
const [layoutData, cfData] = await Promise.all([
api.get<{ fields: LayoutField[]; isDefault: boolean }>(
`/crm/card-layouts?entityType=${entityType}`,
),
api.get<CustomFieldInfo[]>(`/crm/fields?entityType=${entityType}`),
]);
setCustomFields(cfData);
setIsDefault(layoutData.isDefault);
if (layoutData.fields.length > 0) {
// Map layout fields to labeled fields
setFields(
layoutData.fields.map((f) => ({
...f,
label: getFieldLabel(f, systemFields, cfData),
})),
);
} else {
// Build default layout from system fields
setFields(
systemFields.map((sf) => ({
fieldKey: sf.key,
source: 'system' as const,
width: 'half' as const,
label: sf.label,
})),
);
}
} catch {
//
} finally {
setLoading(false);
}
}, [entityType, systemFields]);
useEffect(() => {
setLoading(true);
void fetchLayout();
}, [fetchLayout]);
const getFieldLabel = (
f: LayoutField,
sysFields: { key: string; label: string }[],
cfList: CustomFieldInfo[],
): string => {
if (f.source === 'system') {
return sysFields.find((sf) => sf.key === f.fieldKey)?.label || f.fieldKey;
}
return cfList.find((cf) => cf.fieldName === f.fieldKey)?.name || f.fieldKey;
};
const addField = (fieldKey: string, source: 'system' | 'custom', label: string) => {
// Don't add duplicates
if (fields.some((f) => f.fieldKey === fieldKey && f.source === source)) return;
setFields((prev) => [...prev, { fieldKey, source, width: 'half', label }]);
};
const removeField = (index: number) => {
setFields((prev) => prev.filter((_, i) => i !== index));
};
const toggleWidth = (index: number) => {
setFields((prev) =>
prev.map((f, i) => (i === index ? { ...f, width: f.width === 'full' ? 'half' : 'full' } : f)),
);
};
const moveField = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= fields.length) return;
setFields((prev) => {
const next = [...prev];
const removed = next.splice(fromIndex, 1);
if (removed[0]) next.splice(toIndex, 0, removed[0]);
return next;
});
};
const handleSave = async () => {
setSaving(true);
try {
await api.put('/crm/card-layouts', {
entityType,
fields: fields.map(({ fieldKey, source, width }) => ({ fieldKey, source, width })),
});
setIsDefault(false);
} catch (err: any) {
alert(err?.message || 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
// Available fields not yet in the layout
const unusedSystemFields = systemFields.filter(
(sf) => !fields.some((f) => f.fieldKey === sf.key && f.source === 'system'),
);
const unusedCustomFields = customFields.filter(
(cf) => !fields.some((f) => f.fieldKey === cf.fieldName && f.source === 'custom'),
);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Current layout */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-text">Поля карточки</h3>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
Настроено
</span>
)}
</div>
</div>
<div className="bg-card rounded-xl border border-border">
{fields.length === 0 ? (
<div className="text-center py-8 text-muted text-sm">
Нет полей в карточке. Добавьте поля ниже.
</div>
) : (
<div className="divide-y divide-border/50">
{fields.map((field, index) => (
<div
key={`${field.source}-${field.fieldKey}`}
className="flex items-center gap-3 px-4 py-2.5 group"
>
<div className="flex flex-col gap-0.5">
<button
onClick={() => moveField(index, index - 1)}
disabled={index === 0}
className="text-muted hover:text-text disabled:opacity-20 transition-colors"
>
<GripVertical className="h-3 w-3 rotate-180" />
</button>
<button
onClick={() => moveField(index, index + 1)}
disabled={index === fields.length - 1}
className="text-muted hover:text-text disabled:opacity-20 transition-colors"
>
<GripVertical className="h-3 w-3" />
</button>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm text-text">{field.label}</span>
<span className="text-[10px] ml-2 text-muted">
{field.source === 'system' ? 'системное' : 'кастомное'}
</span>
</div>
<button
onClick={() => toggleWidth(index)}
className={cn(
'text-[10px] px-2 py-0.5 rounded border transition-colors',
field.width === 'full'
? 'border-primary/30 bg-primary/5 text-primary'
: 'border-border bg-background text-muted',
)}
>
{field.width === 'full' ? 'Полная ширина' : 'Половина'}
</button>
<button
onClick={() => removeField(index)}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Add fields */}
{(unusedSystemFields.length > 0 || unusedCustomFields.length > 0) && (
<div>
<h3 className="text-sm font-medium text-text mb-3">Добавить поля</h3>
<div className="flex flex-wrap gap-2">
{unusedSystemFields.map((sf) => (
<button
key={sf.key}
onClick={() => addField(sf.key, 'system', sf.label)}
className="flex items-center gap-1 px-3 py-1.5 text-xs border border-border rounded-lg text-muted hover:text-text hover:border-primary/30 transition-colors"
>
<Plus className="h-3 w-3" />
{sf.label}
</button>
))}
{unusedCustomFields.map((cf) => (
<button
key={cf.fieldName}
onClick={() => addField(cf.fieldName, 'custom', cf.name)}
className="flex items-center gap-1 px-3 py-1.5 text-xs border border-dashed border-border rounded-lg text-muted hover:text-text hover:border-primary/30 transition-colors"
>
<Plus className="h-3 w-3" />
{cf.name}
</button>
))}
</div>
</div>
)}
{/* Preview */}
<div>
<h3 className="text-sm font-medium text-text mb-3">Предпросмотр</h3>
<div className="bg-card rounded-xl border border-border p-4">
<div className="grid grid-cols-2 gap-3">
{fields.map((field) => (
<div
key={`${field.source}-${field.fieldKey}`}
className={cn(
'p-2 rounded-lg bg-background border border-border/50',
field.width === 'full' && 'col-span-2',
)}
>
<div className="text-[10px] text-muted mb-0.5">{field.label}</div>
<div className="text-xs text-text/50"></div>
</div>
))}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить раскладку'}
</button>
</div>
</div>
);
}

View File

@@ -14,11 +14,23 @@ interface CustomField {
isRequired: boolean;
isMultiple: boolean;
showToTrainer: boolean;
roleAccess?: Record<string, string> | null;
position: number;
defaultValue?: string | null;
description?: string | null;
}
interface RoleData {
id: string;
name: string;
slug: string;
isBuiltIn: boolean;
}
interface FieldManagerProps {
entityType?: string;
}
const FIELD_TYPES = [
{ value: 'STRING', label: 'Строка' },
{ value: 'INTEGER', label: 'Целое число' },
@@ -29,6 +41,12 @@ const FIELD_TYPES = [
{ value: 'DATETIME', label: 'Дата и время' },
];
const ACCESS_LEVELS = [
{ value: 'editable', label: 'Редактирование' },
{ value: 'readonly', label: 'Только чтение' },
{ value: 'hidden', label: 'Скрыто' },
];
const fieldTypeLabels: Record<string, string> = Object.fromEntries(
FIELD_TYPES.map((t) => [t.value, t.label]),
);
@@ -36,8 +54,12 @@ const fieldTypeLabels: Record<string, string> = Object.fromEntries(
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
export function FieldManager() {
const selectClass =
'px-2 py-1 border border-border rounded text-xs bg-background text-text focus:outline-none focus:ring-1 focus:ring-primary/30';
export function FieldManager({ entityType = 'deal' }: FieldManagerProps) {
const [fields, setFields] = useState<CustomField[]>([]);
const [roles, setRoles] = useState<RoleData[]>([]);
const [loading, setLoading] = useState(true);
// Form state
@@ -48,24 +70,44 @@ export function FieldManager() {
const [type, setType] = useState('STRING');
const [isRequired, setIsRequired] = useState(false);
const [showToTrainer, setShowToTrainer] = useState(false);
const [roleAccess, setRoleAccess] = useState<Record<string, string>>({});
const [description, setDescription] = useState('');
const [listOptions, setListOptions] = useState('');
const [saving, setSaving] = useState(false);
const fetchFields = useCallback(async () => {
try {
const data = await api.get<CustomField[]>('/crm/fields?entityType=deal');
const data = await api.get<CustomField[]>(`/crm/fields?entityType=${entityType}`);
setFields(data);
} catch {
//
} finally {
setLoading(false);
}
}, [entityType]);
const fetchRoles = useCallback(async () => {
try {
const data = await api.get<RoleData[]>('/roles');
setRoles(data);
} catch {
//
}
}, []);
useEffect(() => {
setLoading(true);
void fetchFields();
}, [fetchFields]);
void fetchRoles();
}, [fetchFields, fetchRoles]);
const buildDefaultRoleAccess = (rolesData: RoleData[]): Record<string, string> => {
const access: Record<string, string> = {};
for (const role of rolesData) {
access[role.slug] = 'editable';
}
return access;
};
const openCreate = () => {
setEditing(null);
@@ -74,6 +116,7 @@ export function FieldManager() {
setType('STRING');
setIsRequired(false);
setShowToTrainer(false);
setRoleAccess(buildDefaultRoleAccess(roles));
setDescription('');
setListOptions('');
setShowForm(true);
@@ -86,6 +129,7 @@ export function FieldManager() {
setType(field.type);
setIsRequired(field.isRequired);
setShowToTrainer(field.showToTrainer);
setRoleAccess(field.roleAccess || buildDefaultRoleAccess(roles));
setDescription(field.description || '');
setListOptions(Array.isArray(field.listOptions) ? field.listOptions.join('\n') : '');
setShowForm(true);
@@ -100,6 +144,7 @@ export function FieldManager() {
type,
isRequired,
showToTrainer,
roleAccess,
description: description.trim() || undefined,
};
if (type === 'LIST' && listOptions.trim()) {
@@ -111,7 +156,7 @@ export function FieldManager() {
if (editing) {
await api.patch(`/crm/fields/${editing.id}`, data);
} else {
data.entityType = 'deal';
data.entityType = entityType;
data.fieldName = fieldName.trim();
await api.post('/crm/fields', data);
}
@@ -184,6 +229,18 @@ export function FieldManager() {
}
};
const getRoleAccessSummary = (field: CustomField): string => {
if (!field.roleAccess) return 'Все роли';
const access = field.roleAccess;
const hidden = Object.entries(access).filter(([, v]) => v === 'hidden').length;
const readonly = Object.entries(access).filter(([, v]) => v === 'readonly').length;
if (hidden === 0 && readonly === 0) return 'Все роли';
const parts: string[] = [];
if (hidden > 0) parts.push(`${hidden} скрыто`);
if (readonly > 0) parts.push(`${readonly} только чтение`);
return parts.join(', ');
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -213,7 +270,7 @@ export function FieldManager() {
<th className="text-left px-4 py-3 text-muted font-medium">Код</th>
<th className="text-left px-4 py-3 text-muted font-medium">Тип</th>
<th className="text-center px-4 py-3 text-muted font-medium">Обязательное</th>
<th className="text-center px-4 py-3 text-muted font-medium">Тренер</th>
<th className="text-left px-4 py-3 text-muted font-medium">Доступ ролей</th>
<th className="text-right px-4 py-3 text-muted font-medium w-24">Действия</th>
</tr>
</thead>
@@ -231,7 +288,7 @@ export function FieldManager() {
{fieldTypeLabels[field.type] || field.type}
</td>
<td className="px-4 py-3 text-center">{field.isRequired ? 'Да' : '—'}</td>
<td className="px-4 py-3 text-center">{field.showToTrainer ? 'Да' : '—'}</td>
<td className="px-4 py-3 text-xs text-muted">{getRoleAccessSummary(field)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
@@ -344,6 +401,53 @@ export function FieldManager() {
Показывать тренеру
</label>
</div>
{/* Role access section */}
{roles.length > 0 && (
<div>
<label className="block text-sm font-medium text-text mb-2">Доступ по ролям</label>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-background/50">
<th className="text-left px-3 py-2 text-muted font-medium">Роль</th>
<th className="text-right px-3 py-2 text-muted font-medium">Доступ</th>
</tr>
</thead>
<tbody>
{roles
.filter((r) => !['super_admin', 'club_admin'].includes(r.slug))
.map((role) => (
<tr key={role.slug} className="border-t border-border/50">
<td className="px-3 py-2 text-text">{role.name}</td>
<td className="px-3 py-2 text-right">
<select
value={roleAccess[role.slug] || 'editable'}
onChange={(e) =>
setRoleAccess((prev) => ({
...prev,
[role.slug]: e.target.value,
}))
}
className={selectClass}
>
{ACCESS_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[10px] text-muted mt-1">
Суперадмин и Админ клуба всегда имеют полный доступ
</p>
</div>
)}
</DialogBody>
<DialogFooter>
<button

View File

@@ -11,6 +11,22 @@ import {
Loader2,
Star,
} from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
@@ -33,6 +49,10 @@ interface Pipeline {
stages: Stage[];
}
interface PipelineManagerProps {
entityType?: string;
}
const STAGE_COLORS = [
'#3B82F6',
'#8B5CF6',
@@ -49,7 +69,95 @@ const STAGE_COLORS = [
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
export function PipelineManager() {
// --- Sortable Stage Row ---
function SortableStageRow({
stage,
index,
totalCount,
onMoveUp,
onMoveDown,
onEdit,
onDelete,
}: {
stage: Stage;
index: number;
totalCount: number;
onMoveUp: () => void;
onMoveDown: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: stage.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-background/50 transition-colors group',
isDragging && 'bg-background shadow-lg',
)}
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none p-0.5 rounded hover:bg-background"
aria-label="Перетащить для сортировки"
>
<GripVertical className="h-4 w-4 text-muted/50 shrink-0" />
</button>
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: stage.color }} />
<span className="text-sm text-text flex-1">{stage.name}</span>
{stage.isDefault && (
<span className="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">
default
</span>
)}
<span className="text-xs text-muted bg-background px-2 py-0.5 rounded">{stage.type}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onMoveUp}
disabled={index === 0}
className="p-1 rounded hover:bg-background disabled:opacity-30"
aria-label="Вверх"
>
<ChevronUp className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={onMoveDown}
disabled={index === totalCount - 1}
className="p-1 rounded hover:bg-background disabled:opacity-30"
aria-label="Вниз"
>
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={onEdit}
className="p-1 rounded hover:bg-background text-muted hover:text-text"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={onDelete}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export function PipelineManager({ entityType }: PipelineManagerProps = {}) {
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [loading, setLoading] = useState(true);
@@ -68,6 +176,7 @@ export function PipelineManager() {
const [stageType, setStageType] = useState<'OPEN' | 'WON' | 'LOST'>('OPEN');
const [stageStaleDays, setStageStaleDays] = useState('');
const [stageIsDefault, setStageIsDefault] = useState(false);
const [customColorHex, setCustomColorHex] = useState('');
// Delete stage dialog
const [deleteStage, setDeleteStage] = useState<{ stage: Stage; pipelineId: string } | null>(null);
@@ -76,9 +185,17 @@ export function PipelineManager() {
const [saving, setSaving] = useState(false);
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const queryParam = entityType ? `?entityType=${entityType}` : '';
const fetchPipelines = useCallback(async () => {
try {
const data = await api.get<Pipeline[]>('/crm/pipelines');
const data = await api.get<Pipeline[]>(`/crm/pipelines${queryParam}`);
const enriched = await Promise.all(
data.map(async (p) => {
const stages = await api.get<Stage[]>(`/crm/pipelines/${p.id}/stages`);
@@ -91,7 +208,7 @@ export function PipelineManager() {
} finally {
setLoading(false);
}
}, []);
}, [queryParam]);
useEffect(() => {
void fetchPipelines();
@@ -122,10 +239,12 @@ export function PipelineManager() {
isDefault: pipelineDefault,
});
} else {
await api.post('/crm/pipelines', {
const body: Record<string, any> = {
name: pipelineName.trim(),
isDefault: pipelineDefault,
});
};
if (entityType) body.entityType = entityType;
await api.post('/crm/pipelines', body);
}
setShowPipelineForm(false);
await fetchPipelines();
@@ -155,6 +274,7 @@ export function PipelineManager() {
setStageType('OPEN');
setStageStaleDays('');
setStageIsDefault(false);
setCustomColorHex('');
setShowStageForm(true);
};
@@ -166,9 +286,26 @@ export function PipelineManager() {
setStageType(stage.type);
setStageStaleDays(stage.staleDays?.toString() || '');
setStageIsDefault(stage.isDefault);
setCustomColorHex(STAGE_COLORS.includes(stage.color) ? '' : stage.color);
setShowStageForm(true);
};
const handleColorChange = (color: string) => {
setStageColor(color);
if (!STAGE_COLORS.includes(color)) {
setCustomColorHex(color);
} else {
setCustomColorHex('');
}
};
const handleHexInput = (hex: string) => {
setCustomColorHex(hex);
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
setStageColor(hex);
}
};
const handleSaveStage = async () => {
if (!stageName.trim()) return;
setSaving(true);
@@ -233,6 +370,40 @@ export function PipelineManager() {
}
};
const handleDragEnd = async (pipelineId: string, event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const pipeline = pipelines.find((p) => p.id === pipelineId);
if (!pipeline) return;
const sorted = [...pipeline.stages].sort((a, b) => a.position - b.position);
const oldIndex = sorted.findIndex((s) => s.id === active.id);
const newIndex = sorted.findIndex((s) => s.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Reorder in place
const [moved] = sorted.splice(oldIndex, 1);
sorted.splice(newIndex, 0, moved!);
// Optimistic update
setPipelines((prev) =>
prev.map((p) =>
p.id === pipelineId
? { ...p, stages: sorted.map((s, i) => ({ ...s, position: i + 1 })) }
: p,
),
);
try {
await api.put('/crm/pipelines/stages/reorder', {
stageIds: sorted.map((s) => s.id),
});
} catch {
await fetchPipelines();
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -255,99 +426,74 @@ export function PipelineManager() {
</div>
{/* Pipeline cards */}
{pipelines.map((pipeline) => (
<div key={pipeline.id} className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text">{pipeline.name}</h3>
{pipeline.isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary flex items-center gap-0.5">
<Star className="h-3 w-3" />
по умолчанию
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEditPipeline(pipeline)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
title="Редактировать"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePipeline(pipeline.id)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-1">
{pipeline.stages
.sort((a, b) => a.position - b.position)
.map((stage, idx) => (
<div
key={stage.id}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-background/50 transition-colors group"
>
<GripVertical className="h-4 w-4 text-muted/50 shrink-0" />
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: stage.color }}
/>
<span className="text-sm text-text flex-1">{stage.name}</span>
{stage.isDefault && (
<span className="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">
default
</span>
)}
<span className="text-xs text-muted bg-background px-2 py-0.5 rounded">
{stage.type}
{pipelines.map((pipeline) => {
const sortedStages = [...pipeline.stages].sort((a, b) => a.position - b.position);
return (
<div key={pipeline.id} className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text">{pipeline.name}</h3>
{pipeline.isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary flex items-center gap-0.5">
<Star className="h-3 w-3" />
по умолчанию
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleMoveStage(pipeline.id, stage.id, 'up')}
disabled={idx === 0}
className="p-1 rounded hover:bg-background disabled:opacity-30"
>
<ChevronUp className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={() => handleMoveStage(pipeline.id, stage.id, 'down')}
disabled={idx === pipeline.stages.length - 1}
className="p-1 rounded hover:bg-background disabled:opacity-30"
>
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={() => openEditStage(stage, pipeline.id)}
className="p-1 rounded hover:bg-background text-muted hover:text-text"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setDeleteStage({ stage, pipelineId: pipeline.id })}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEditPipeline(pipeline)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
title="Редактировать"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePipeline(pipeline.id)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<button
onClick={() => openCreateStage(pipeline.id)}
className="flex items-center gap-1.5 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Добавить стадию
</button>
</div>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => handleDragEnd(pipeline.id, event)}
>
<SortableContext
items={sortedStages.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{sortedStages.map((stage, idx) => (
<SortableStageRow
key={stage.id}
stage={stage}
index={idx}
totalCount={sortedStages.length}
onMoveUp={() => handleMoveStage(pipeline.id, stage.id, 'up')}
onMoveDown={() => handleMoveStage(pipeline.id, stage.id, 'down')}
onEdit={() => openEditStage(stage, pipeline.id)}
onDelete={() => setDeleteStage({ stage, pipelineId: pipeline.id })}
/>
))}
</div>
</SortableContext>
</DndContext>
<button
onClick={() => openCreateStage(pipeline.id)}
className="flex items-center gap-1.5 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Добавить стадию
</button>
</div>
);
})}
{pipelines.length === 0 && (
<div className="text-center py-12 text-muted text-sm">
@@ -422,7 +568,7 @@ export function PipelineManager() {
{STAGE_COLORS.map((c) => (
<button
key={c}
onClick={() => setStageColor(c)}
onClick={() => handleColorChange(c)}
className={cn(
'w-7 h-7 rounded-full border-2 transition-all',
stageColor === c
@@ -433,6 +579,28 @@ export function PipelineManager() {
/>
))}
</div>
{/* Custom color picker */}
<div className="flex items-center gap-2 mt-2">
<input
type="color"
value={stageColor}
onChange={(e) => handleColorChange(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-border bg-transparent p-0"
title="Произвольный цвет"
/>
<input
type="text"
value={customColorHex || stageColor}
onChange={(e) => handleHexInput(e.target.value)}
placeholder="#FF5733"
maxLength={7}
className="w-28 px-2 py-1.5 border border-border rounded-lg text-sm font-mono bg-background text-text focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<span
className="w-6 h-6 rounded-full border border-border shrink-0"
style={{ backgroundColor: stageColor }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>

View File

@@ -9,13 +9,11 @@
"noEmit": true,
"allowJs": true,
"incremental": true,
"plugins": [
{ "name": "next" }
],
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "next-env.d.ts", ".next/types/**/*.ts"],
"include": ["src/**/*", "next-env.d.ts", ".next/types/**/*.ts", "next.config.ts"],
"exclude": ["node_modules", ".next"]
}

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@fitcrm/crm-ui'],
};
export default nextConfig;

View File

@@ -10,6 +10,10 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fitcrm/crm-ui": "workspace:^",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"html2canvas-pro": "^1.6.7",

View File

@@ -7,8 +7,9 @@ import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { PipelineManager } from '@/components/crm/pipeline-manager';
import { FieldManager } from '@/components/crm/field-manager';
import { CardLayoutEditor } from '@/components/crm/card-layout-editor';
type Tab = 'pipelines' | 'fields' | 'webhooks' | 'lost-reasons';
type Tab = 'pipelines' | 'fields' | 'card-layout' | 'webhooks' | 'lost-reasons';
interface WebhookEndpoint {
id: string;
@@ -51,6 +52,7 @@ export default function CrmSettingsPage() {
{[
{ key: 'pipelines' as const, label: 'Воронки' },
{ key: 'fields' as const, label: 'Кастомные поля' },
{ key: 'card-layout' as const, label: 'Раскладка карточки' },
{ key: 'webhooks' as const, label: 'Вебхуки' },
{ key: 'lost-reasons' as const, label: 'Причины проигрыша' },
].map((t) => (
@@ -72,6 +74,7 @@ export default function CrmSettingsPage() {
{/* Tab content */}
{tab === 'pipelines' && <PipelinesTab />}
{tab === 'fields' && <FieldsTab />}
{tab === 'card-layout' && <CardLayoutTab />}
{tab === 'webhooks' && <WebhooksTab />}
{tab === 'lost-reasons' && <LostReasonsTab />}
</div>
@@ -85,7 +88,61 @@ function PipelinesTab() {
// --- Custom Fields Tab ---
function FieldsTab() {
return <FieldManager />;
const [entityType, setEntityType] = useState('deal');
const entityTypes = [
{ key: 'deal', label: 'Сделки' },
{ key: 'training', label: 'Тренировки' },
];
return (
<div>
<div className="flex items-center gap-1 mb-4">
{entityTypes.map((et) => (
<button
key={et.key}
onClick={() => setEntityType(et.key)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
entityType === et.key
? 'bg-primary/10 text-primary'
: 'text-muted hover:text-text hover:bg-background',
)}
>
{et.label}
</button>
))}
</div>
<FieldManager key={entityType} entityType={entityType} />
</div>
);
}
// --- Card Layout Tab ---
function CardLayoutTab() {
const [entityType, setEntityType] = useState('deal');
const entityTypes = [{ key: 'deal', label: 'Сделки' }];
return (
<div>
<div className="flex items-center gap-1 mb-4">
{entityTypes.map((et) => (
<button
key={et.key}
onClick={() => setEntityType(et.key)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
entityType === et.key
? 'bg-primary/10 text-primary'
: 'text-muted hover:text-text hover:bg-background',
)}
>
{et.label}
</button>
))}
</div>
<CardLayoutEditor key={entityType} entityType={entityType} />
</div>
);
}
// --- Webhooks Tab ---

View File

@@ -0,0 +1,287 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Plus, Pencil, Trash2, Loader2, Shield, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
interface RoleData {
id: string;
clubId: string | null;
name: string;
slug: string;
isBuiltIn: boolean;
isActive: boolean;
userCount: number;
}
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
export default function RolesSettingsPage() {
const router = useRouter();
const [roles, setRoles] = useState<RoleData[]>([]);
const [loading, setLoading] = useState(true);
// Create/rename form
const [showForm, setShowForm] = useState(false);
const [editingRole, setEditingRole] = useState<RoleData | null>(null);
const [formName, setFormName] = useState('');
const [formSlug, setFormSlug] = useState('');
const [saving, setSaving] = useState(false);
const fetchRoles = useCallback(async () => {
try {
const data = await api.get<RoleData[]>('/roles');
setRoles(data);
} catch {
//
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchRoles();
}, [fetchRoles]);
const openCreate = () => {
setEditingRole(null);
setFormName('');
setFormSlug('');
setShowForm(true);
};
const openRename = (role: RoleData) => {
setEditingRole(role);
setFormName(role.name);
setFormSlug(role.slug);
setShowForm(true);
};
const handleNameChange = (val: string) => {
setFormName(val);
if (!editingRole) {
setFormSlug(
val
.toLowerCase()
.replace(/[а-яё]/g, (c) => {
const map: Record<string, string> = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ё: 'e',
ж: 'zh',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
х: 'kh',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'sch',
ъ: '',
ы: 'y',
ь: '',
э: 'e',
ю: 'yu',
я: 'ya',
};
return map[c] || c;
})
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, ''),
);
}
};
const handleSave = async () => {
if (!formName.trim()) return;
setSaving(true);
try {
if (editingRole) {
await api.patch(`/roles/${editingRole.id}`, { name: formName.trim() });
} else {
await api.post('/roles', { name: formName.trim(), slug: formSlug.trim() });
}
setShowForm(false);
await fetchRoles();
} catch (err: any) {
alert(err?.message || 'Ошибка');
} finally {
setSaving(false);
}
};
const handleDelete = async (role: RoleData) => {
if (!confirm(`Удалить роль "${role.name}"? Это возможно только если нет назначений.`)) return;
try {
await api.delete(`/roles/${role.id}`);
await fetchRoles();
} catch (err: any) {
alert(err?.message || 'Ошибка удаления');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.back()}
className="p-2 rounded-lg hover:bg-background transition-colors"
>
<ArrowLeft className="h-5 w-5 text-muted" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text">Управление ролями</h1>
<p className="text-sm text-muted">Системные и кастомные роли</p>
</div>
<button
onClick={openCreate}
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Новая роль
</button>
</div>
<div className="bg-card rounded-xl border border-border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-background/50">
<th className="text-left px-4 py-3 text-muted font-medium">Роль</th>
<th className="text-left px-4 py-3 text-muted font-medium">Slug</th>
<th className="text-center px-4 py-3 text-muted font-medium">Тип</th>
<th className="text-center px-4 py-3 text-muted font-medium">Пользователей</th>
<th className="text-right px-4 py-3 text-muted font-medium w-24">Действия</th>
</tr>
</thead>
<tbody>
{roles.map((role) => (
<tr key={role.id} className="border-b border-border/50 group">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{role.isBuiltIn ? (
<ShieldCheck className="h-4 w-4 text-primary" />
) : (
<Shield className="h-4 w-4 text-muted" />
)}
<span className="font-medium text-text">{role.name}</span>
</div>
</td>
<td className="px-4 py-3 text-muted font-mono text-xs">{role.slug}</td>
<td className="px-4 py-3 text-center">
<span
className={cn(
'text-[10px] px-2 py-0.5 rounded-full',
role.isBuiltIn ? 'bg-primary/10 text-primary' : 'bg-background text-muted',
)}
>
{role.isBuiltIn ? 'Системная' : 'Кастомная'}
</span>
</td>
<td className="px-4 py-3 text-center text-muted">{role.userCount}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{(!role.isBuiltIn ||
!['super_admin', 'club_admin', 'trainer'].includes(role.slug)) && (
<button
onClick={() => openRename(role)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{!role.isBuiltIn && (
<button
onClick={() => handleDelete(role)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Create/Rename dialog */}
<Dialog open={showForm} onClose={() => setShowForm(false)}>
<DialogHeader
title={editingRole ? 'Переименовать роль' : 'Новая роль'}
onClose={() => setShowForm(false)}
/>
<DialogBody className="space-y-4">
<div>
<label className="block text-sm font-medium text-text mb-1">Название</label>
<input
type="text"
value={formName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Инструктор групповых"
className={inputClass}
/>
</div>
{!editingRole && (
<div>
<label className="block text-sm font-medium text-text mb-1">
Slug (латиница, snake_case)
</label>
<input
type="text"
value={formSlug}
onChange={(e) => setFormSlug(e.target.value)}
placeholder="group_instructor"
className={inputClass}
/>
</div>
)}
</DialogBody>
<DialogFooter>
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm text-muted hover:text-text transition-colors"
>
Отмена
</button>
<button
onClick={handleSave}
disabled={saving || !formName.trim() || (!editingRole && !formSlug.trim())}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</DialogFooter>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,302 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { GripVertical, Plus, Trash2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
interface LayoutField {
fieldKey: string;
source: 'system' | 'custom';
width: 'full' | 'half';
}
interface CustomFieldInfo {
id: string;
name: string;
fieldName: string;
type: string;
}
interface CardLayoutEditorProps {
entityType?: string;
}
const DEAL_SYSTEM_FIELDS = [
{ key: 'amount', label: 'Сумма' },
{ key: 'source', label: 'Источник' },
{ key: 'phone', label: 'Телефон' },
{ key: 'email', label: 'Email' },
{ key: 'contactName', label: 'Контакт' },
{ key: 'assignee', label: 'Ответственный' },
{ key: 'pipeline', label: 'Воронка' },
{ key: 'stage', label: 'Стадия' },
{ key: 'createdAt', label: 'Дата создания' },
{ key: 'updatedAt', label: 'Дата обновления' },
{ key: 'lostReason', label: 'Причина проигрыша' },
{ key: 'comment', label: 'Комментарий' },
];
const SYSTEM_FIELDS_MAP: Record<string, { key: string; label: string }[]> = {
deal: DEAL_SYSTEM_FIELDS,
};
export function CardLayoutEditor({ entityType = 'deal' }: CardLayoutEditorProps) {
const [fields, setFields] = useState<(LayoutField & { label: string })[]>([]);
const [customFields, setCustomFields] = useState<CustomFieldInfo[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isDefault, setIsDefault] = useState(true);
const systemFields = SYSTEM_FIELDS_MAP[entityType] || DEAL_SYSTEM_FIELDS;
const fetchLayout = useCallback(async () => {
try {
const [layoutData, cfData] = await Promise.all([
api.get<{ fields: LayoutField[]; isDefault: boolean }>(
`/crm/card-layouts?entityType=${entityType}`,
),
api.get<CustomFieldInfo[]>(`/crm/fields?entityType=${entityType}`),
]);
setCustomFields(cfData);
setIsDefault(layoutData.isDefault);
if (layoutData.fields.length > 0) {
// Map layout fields to labeled fields
setFields(
layoutData.fields.map((f) => ({
...f,
label: getFieldLabel(f, systemFields, cfData),
})),
);
} else {
// Build default layout from system fields
setFields(
systemFields.map((sf) => ({
fieldKey: sf.key,
source: 'system' as const,
width: 'half' as const,
label: sf.label,
})),
);
}
} catch {
//
} finally {
setLoading(false);
}
}, [entityType, systemFields]);
useEffect(() => {
setLoading(true);
void fetchLayout();
}, [fetchLayout]);
const getFieldLabel = (
f: LayoutField,
sysFields: { key: string; label: string }[],
cfList: CustomFieldInfo[],
): string => {
if (f.source === 'system') {
return sysFields.find((sf) => sf.key === f.fieldKey)?.label || f.fieldKey;
}
return cfList.find((cf) => cf.fieldName === f.fieldKey)?.name || f.fieldKey;
};
const addField = (fieldKey: string, source: 'system' | 'custom', label: string) => {
// Don't add duplicates
if (fields.some((f) => f.fieldKey === fieldKey && f.source === source)) return;
setFields((prev) => [...prev, { fieldKey, source, width: 'half', label }]);
};
const removeField = (index: number) => {
setFields((prev) => prev.filter((_, i) => i !== index));
};
const toggleWidth = (index: number) => {
setFields((prev) =>
prev.map((f, i) => (i === index ? { ...f, width: f.width === 'full' ? 'half' : 'full' } : f)),
);
};
const moveField = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= fields.length) return;
setFields((prev) => {
const next = [...prev];
const removed = next.splice(fromIndex, 1);
if (removed[0]) next.splice(toIndex, 0, removed[0]);
return next;
});
};
const handleSave = async () => {
setSaving(true);
try {
await api.put('/crm/card-layouts', {
entityType,
fields: fields.map(({ fieldKey, source, width }) => ({ fieldKey, source, width })),
});
setIsDefault(false);
} catch (err: any) {
alert(err?.message || 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
// Available fields not yet in the layout
const unusedSystemFields = systemFields.filter(
(sf) => !fields.some((f) => f.fieldKey === sf.key && f.source === 'system'),
);
const unusedCustomFields = customFields.filter(
(cf) => !fields.some((f) => f.fieldKey === cf.fieldName && f.source === 'custom'),
);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Current layout */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-text">Поля карточки</h3>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
Настроено
</span>
)}
</div>
</div>
<div className="bg-card rounded-xl border border-border">
{fields.length === 0 ? (
<div className="text-center py-8 text-muted text-sm">
Нет полей в карточке. Добавьте поля ниже.
</div>
) : (
<div className="divide-y divide-border/50">
{fields.map((field, index) => (
<div
key={`${field.source}-${field.fieldKey}`}
className="flex items-center gap-3 px-4 py-2.5 group"
>
<div className="flex flex-col gap-0.5">
<button
onClick={() => moveField(index, index - 1)}
disabled={index === 0}
className="text-muted hover:text-text disabled:opacity-20 transition-colors"
>
<GripVertical className="h-3 w-3 rotate-180" />
</button>
<button
onClick={() => moveField(index, index + 1)}
disabled={index === fields.length - 1}
className="text-muted hover:text-text disabled:opacity-20 transition-colors"
>
<GripVertical className="h-3 w-3" />
</button>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm text-text">{field.label}</span>
<span className="text-[10px] ml-2 text-muted">
{field.source === 'system' ? 'системное' : 'кастомное'}
</span>
</div>
<button
onClick={() => toggleWidth(index)}
className={cn(
'text-[10px] px-2 py-0.5 rounded border transition-colors',
field.width === 'full'
? 'border-primary/30 bg-primary/5 text-primary'
: 'border-border bg-background text-muted',
)}
>
{field.width === 'full' ? 'Полная ширина' : 'Половина'}
</button>
<button
onClick={() => removeField(index)}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Add fields */}
{(unusedSystemFields.length > 0 || unusedCustomFields.length > 0) && (
<div>
<h3 className="text-sm font-medium text-text mb-3">Добавить поля</h3>
<div className="flex flex-wrap gap-2">
{unusedSystemFields.map((sf) => (
<button
key={sf.key}
onClick={() => addField(sf.key, 'system', sf.label)}
className="flex items-center gap-1 px-3 py-1.5 text-xs border border-border rounded-lg text-muted hover:text-text hover:border-primary/30 transition-colors"
>
<Plus className="h-3 w-3" />
{sf.label}
</button>
))}
{unusedCustomFields.map((cf) => (
<button
key={cf.fieldName}
onClick={() => addField(cf.fieldName, 'custom', cf.name)}
className="flex items-center gap-1 px-3 py-1.5 text-xs border border-dashed border-border rounded-lg text-muted hover:text-text hover:border-primary/30 transition-colors"
>
<Plus className="h-3 w-3" />
{cf.name}
</button>
))}
</div>
</div>
)}
{/* Preview */}
<div>
<h3 className="text-sm font-medium text-text mb-3">Предпросмотр</h3>
<div className="bg-card rounded-xl border border-border p-4">
<div className="grid grid-cols-2 gap-3">
{fields.map((field) => (
<div
key={`${field.source}-${field.fieldKey}`}
className={cn(
'p-2 rounded-lg bg-background border border-border/50',
field.width === 'full' && 'col-span-2',
)}
>
<div className="text-[10px] text-muted mb-0.5">{field.label}</div>
<div className="text-xs text-text/50"></div>
</div>
))}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить раскладку'}
</button>
</div>
</div>
);
}

View File

@@ -14,11 +14,23 @@ interface CustomField {
isRequired: boolean;
isMultiple: boolean;
showToTrainer: boolean;
roleAccess?: Record<string, string> | null;
position: number;
defaultValue?: string | null;
description?: string | null;
}
interface RoleData {
id: string;
name: string;
slug: string;
isBuiltIn: boolean;
}
interface FieldManagerProps {
entityType?: string;
}
const FIELD_TYPES = [
{ value: 'STRING', label: 'Строка' },
{ value: 'INTEGER', label: 'Целое число' },
@@ -29,6 +41,12 @@ const FIELD_TYPES = [
{ value: 'DATETIME', label: 'Дата и время' },
];
const ACCESS_LEVELS = [
{ value: 'editable', label: 'Редактирование' },
{ value: 'readonly', label: 'Только чтение' },
{ value: 'hidden', label: 'Скрыто' },
];
const fieldTypeLabels: Record<string, string> = Object.fromEntries(
FIELD_TYPES.map((t) => [t.value, t.label]),
);
@@ -36,8 +54,12 @@ const fieldTypeLabels: Record<string, string> = Object.fromEntries(
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
export function FieldManager() {
const selectClass =
'px-2 py-1 border border-border rounded text-xs bg-background text-text focus:outline-none focus:ring-1 focus:ring-primary/30';
export function FieldManager({ entityType = 'deal' }: FieldManagerProps) {
const [fields, setFields] = useState<CustomField[]>([]);
const [roles, setRoles] = useState<RoleData[]>([]);
const [loading, setLoading] = useState(true);
// Form state
@@ -48,24 +70,44 @@ export function FieldManager() {
const [type, setType] = useState('STRING');
const [isRequired, setIsRequired] = useState(false);
const [showToTrainer, setShowToTrainer] = useState(false);
const [roleAccess, setRoleAccess] = useState<Record<string, string>>({});
const [description, setDescription] = useState('');
const [listOptions, setListOptions] = useState('');
const [saving, setSaving] = useState(false);
const fetchFields = useCallback(async () => {
try {
const data = await api.get<CustomField[]>('/crm/fields?entityType=deal');
const data = await api.get<CustomField[]>(`/crm/fields?entityType=${entityType}`);
setFields(data);
} catch {
//
} finally {
setLoading(false);
}
}, [entityType]);
const fetchRoles = useCallback(async () => {
try {
const data = await api.get<RoleData[]>('/roles');
setRoles(data);
} catch {
//
}
}, []);
useEffect(() => {
setLoading(true);
void fetchFields();
}, [fetchFields]);
void fetchRoles();
}, [fetchFields, fetchRoles]);
const buildDefaultRoleAccess = (rolesData: RoleData[]): Record<string, string> => {
const access: Record<string, string> = {};
for (const role of rolesData) {
access[role.slug] = 'editable';
}
return access;
};
const openCreate = () => {
setEditing(null);
@@ -74,6 +116,7 @@ export function FieldManager() {
setType('STRING');
setIsRequired(false);
setShowToTrainer(false);
setRoleAccess(buildDefaultRoleAccess(roles));
setDescription('');
setListOptions('');
setShowForm(true);
@@ -86,6 +129,7 @@ export function FieldManager() {
setType(field.type);
setIsRequired(field.isRequired);
setShowToTrainer(field.showToTrainer);
setRoleAccess(field.roleAccess || buildDefaultRoleAccess(roles));
setDescription(field.description || '');
setListOptions(Array.isArray(field.listOptions) ? field.listOptions.join('\n') : '');
setShowForm(true);
@@ -100,6 +144,7 @@ export function FieldManager() {
type,
isRequired,
showToTrainer,
roleAccess,
description: description.trim() || undefined,
};
if (type === 'LIST' && listOptions.trim()) {
@@ -111,7 +156,7 @@ export function FieldManager() {
if (editing) {
await api.patch(`/crm/fields/${editing.id}`, data);
} else {
data.entityType = 'deal';
data.entityType = entityType;
data.fieldName = fieldName.trim();
await api.post('/crm/fields', data);
}
@@ -184,6 +229,18 @@ export function FieldManager() {
}
};
const getRoleAccessSummary = (field: CustomField): string => {
if (!field.roleAccess) return 'Все роли';
const access = field.roleAccess;
const hidden = Object.entries(access).filter(([, v]) => v === 'hidden').length;
const readonly = Object.entries(access).filter(([, v]) => v === 'readonly').length;
if (hidden === 0 && readonly === 0) return 'Все роли';
const parts: string[] = [];
if (hidden > 0) parts.push(`${hidden} скрыто`);
if (readonly > 0) parts.push(`${readonly} только чтение`);
return parts.join(', ');
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -213,7 +270,7 @@ export function FieldManager() {
<th className="text-left px-4 py-3 text-muted font-medium">Код</th>
<th className="text-left px-4 py-3 text-muted font-medium">Тип</th>
<th className="text-center px-4 py-3 text-muted font-medium">Обязательное</th>
<th className="text-center px-4 py-3 text-muted font-medium">Тренер</th>
<th className="text-left px-4 py-3 text-muted font-medium">Доступ ролей</th>
<th className="text-right px-4 py-3 text-muted font-medium w-24">Действия</th>
</tr>
</thead>
@@ -231,7 +288,7 @@ export function FieldManager() {
{fieldTypeLabels[field.type] || field.type}
</td>
<td className="px-4 py-3 text-center">{field.isRequired ? 'Да' : '—'}</td>
<td className="px-4 py-3 text-center">{field.showToTrainer ? 'Да' : '—'}</td>
<td className="px-4 py-3 text-xs text-muted">{getRoleAccessSummary(field)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
@@ -344,6 +401,53 @@ export function FieldManager() {
Показывать тренеру
</label>
</div>
{/* Role access section */}
{roles.length > 0 && (
<div>
<label className="block text-sm font-medium text-text mb-2">Доступ по ролям</label>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-background/50">
<th className="text-left px-3 py-2 text-muted font-medium">Роль</th>
<th className="text-right px-3 py-2 text-muted font-medium">Доступ</th>
</tr>
</thead>
<tbody>
{roles
.filter((r) => !['super_admin', 'club_admin'].includes(r.slug))
.map((role) => (
<tr key={role.slug} className="border-t border-border/50">
<td className="px-3 py-2 text-text">{role.name}</td>
<td className="px-3 py-2 text-right">
<select
value={roleAccess[role.slug] || 'editable'}
onChange={(e) =>
setRoleAccess((prev) => ({
...prev,
[role.slug]: e.target.value,
}))
}
className={selectClass}
>
{ACCESS_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[10px] text-muted mt-1">
Суперадмин и Админ клуба всегда имеют полный доступ
</p>
</div>
)}
</DialogBody>
<DialogFooter>
<button

View File

@@ -11,6 +11,22 @@ import {
Loader2,
Star,
} from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
@@ -33,6 +49,10 @@ interface Pipeline {
stages: Stage[];
}
interface PipelineManagerProps {
entityType?: string;
}
const STAGE_COLORS = [
'#3B82F6',
'#8B5CF6',
@@ -49,7 +69,95 @@ const STAGE_COLORS = [
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
export function PipelineManager() {
// --- Sortable Stage Row ---
function SortableStageRow({
stage,
index,
totalCount,
onMoveUp,
onMoveDown,
onEdit,
onDelete,
}: {
stage: Stage;
index: number;
totalCount: number;
onMoveUp: () => void;
onMoveDown: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: stage.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-background/50 transition-colors group',
isDragging && 'bg-background shadow-lg',
)}
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none p-0.5 rounded hover:bg-background"
aria-label="Перетащить для сортировки"
>
<GripVertical className="h-4 w-4 text-muted/50 shrink-0" />
</button>
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: stage.color }} />
<span className="text-sm text-text flex-1">{stage.name}</span>
{stage.isDefault && (
<span className="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">
default
</span>
)}
<span className="text-xs text-muted bg-background px-2 py-0.5 rounded">{stage.type}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onMoveUp}
disabled={index === 0}
className="p-1 rounded hover:bg-background disabled:opacity-30"
aria-label="Вверх"
>
<ChevronUp className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={onMoveDown}
disabled={index === totalCount - 1}
className="p-1 rounded hover:bg-background disabled:opacity-30"
aria-label="Вниз"
>
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={onEdit}
className="p-1 rounded hover:bg-background text-muted hover:text-text"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={onDelete}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export function PipelineManager({ entityType }: PipelineManagerProps = {}) {
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [loading, setLoading] = useState(true);
@@ -68,6 +176,7 @@ export function PipelineManager() {
const [stageType, setStageType] = useState<'OPEN' | 'WON' | 'LOST'>('OPEN');
const [stageStaleDays, setStageStaleDays] = useState('');
const [stageIsDefault, setStageIsDefault] = useState(false);
const [customColorHex, setCustomColorHex] = useState('');
// Delete stage dialog
const [deleteStage, setDeleteStage] = useState<{ stage: Stage; pipelineId: string } | null>(null);
@@ -76,9 +185,17 @@ export function PipelineManager() {
const [saving, setSaving] = useState(false);
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const queryParam = entityType ? `?entityType=${entityType}` : '';
const fetchPipelines = useCallback(async () => {
try {
const data = await api.get<Pipeline[]>('/crm/pipelines');
const data = await api.get<Pipeline[]>(`/crm/pipelines${queryParam}`);
const enriched = await Promise.all(
data.map(async (p) => {
const stages = await api.get<Stage[]>(`/crm/pipelines/${p.id}/stages`);
@@ -91,7 +208,7 @@ export function PipelineManager() {
} finally {
setLoading(false);
}
}, []);
}, [queryParam]);
useEffect(() => {
void fetchPipelines();
@@ -122,10 +239,12 @@ export function PipelineManager() {
isDefault: pipelineDefault,
});
} else {
await api.post('/crm/pipelines', {
const body: Record<string, any> = {
name: pipelineName.trim(),
isDefault: pipelineDefault,
});
};
if (entityType) body.entityType = entityType;
await api.post('/crm/pipelines', body);
}
setShowPipelineForm(false);
await fetchPipelines();
@@ -155,6 +274,7 @@ export function PipelineManager() {
setStageType('OPEN');
setStageStaleDays('');
setStageIsDefault(false);
setCustomColorHex('');
setShowStageForm(true);
};
@@ -166,9 +286,26 @@ export function PipelineManager() {
setStageType(stage.type);
setStageStaleDays(stage.staleDays?.toString() || '');
setStageIsDefault(stage.isDefault);
setCustomColorHex(STAGE_COLORS.includes(stage.color) ? '' : stage.color);
setShowStageForm(true);
};
const handleColorChange = (color: string) => {
setStageColor(color);
if (!STAGE_COLORS.includes(color)) {
setCustomColorHex(color);
} else {
setCustomColorHex('');
}
};
const handleHexInput = (hex: string) => {
setCustomColorHex(hex);
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
setStageColor(hex);
}
};
const handleSaveStage = async () => {
if (!stageName.trim()) return;
setSaving(true);
@@ -233,6 +370,40 @@ export function PipelineManager() {
}
};
const handleDragEnd = async (pipelineId: string, event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const pipeline = pipelines.find((p) => p.id === pipelineId);
if (!pipeline) return;
const sorted = [...pipeline.stages].sort((a, b) => a.position - b.position);
const oldIndex = sorted.findIndex((s) => s.id === active.id);
const newIndex = sorted.findIndex((s) => s.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Reorder in place
const [moved] = sorted.splice(oldIndex, 1);
sorted.splice(newIndex, 0, moved!);
// Optimistic update
setPipelines((prev) =>
prev.map((p) =>
p.id === pipelineId
? { ...p, stages: sorted.map((s, i) => ({ ...s, position: i + 1 })) }
: p,
),
);
try {
await api.put('/crm/pipelines/stages/reorder', {
stageIds: sorted.map((s) => s.id),
});
} catch {
await fetchPipelines();
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -255,99 +426,74 @@ export function PipelineManager() {
</div>
{/* Pipeline cards */}
{pipelines.map((pipeline) => (
<div key={pipeline.id} className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text">{pipeline.name}</h3>
{pipeline.isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary flex items-center gap-0.5">
<Star className="h-3 w-3" />
по умолчанию
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEditPipeline(pipeline)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
title="Редактировать"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePipeline(pipeline.id)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-1">
{pipeline.stages
.sort((a, b) => a.position - b.position)
.map((stage, idx) => (
<div
key={stage.id}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-background/50 transition-colors group"
>
<GripVertical className="h-4 w-4 text-muted/50 shrink-0" />
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: stage.color }}
/>
<span className="text-sm text-text flex-1">{stage.name}</span>
{stage.isDefault && (
<span className="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">
default
</span>
)}
<span className="text-xs text-muted bg-background px-2 py-0.5 rounded">
{stage.type}
{pipelines.map((pipeline) => {
const sortedStages = [...pipeline.stages].sort((a, b) => a.position - b.position);
return (
<div key={pipeline.id} className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text">{pipeline.name}</h3>
{pipeline.isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary flex items-center gap-0.5">
<Star className="h-3 w-3" />
по умолчанию
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleMoveStage(pipeline.id, stage.id, 'up')}
disabled={idx === 0}
className="p-1 rounded hover:bg-background disabled:opacity-30"
>
<ChevronUp className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={() => handleMoveStage(pipeline.id, stage.id, 'down')}
disabled={idx === pipeline.stages.length - 1}
className="p-1 rounded hover:bg-background disabled:opacity-30"
>
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={() => openEditStage(stage, pipeline.id)}
className="p-1 rounded hover:bg-background text-muted hover:text-text"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setDeleteStage({ stage, pipelineId: pipeline.id })}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEditPipeline(pipeline)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
title="Редактировать"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDeletePipeline(pipeline.id)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<button
onClick={() => openCreateStage(pipeline.id)}
className="flex items-center gap-1.5 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Добавить стадию
</button>
</div>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => handleDragEnd(pipeline.id, event)}
>
<SortableContext
items={sortedStages.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{sortedStages.map((stage, idx) => (
<SortableStageRow
key={stage.id}
stage={stage}
index={idx}
totalCount={sortedStages.length}
onMoveUp={() => handleMoveStage(pipeline.id, stage.id, 'up')}
onMoveDown={() => handleMoveStage(pipeline.id, stage.id, 'down')}
onEdit={() => openEditStage(stage, pipeline.id)}
onDelete={() => setDeleteStage({ stage, pipelineId: pipeline.id })}
/>
))}
</div>
</SortableContext>
</DndContext>
<button
onClick={() => openCreateStage(pipeline.id)}
className="flex items-center gap-1.5 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Добавить стадию
</button>
</div>
);
})}
{pipelines.length === 0 && (
<div className="text-center py-12 text-muted text-sm">
@@ -422,7 +568,7 @@ export function PipelineManager() {
{STAGE_COLORS.map((c) => (
<button
key={c}
onClick={() => setStageColor(c)}
onClick={() => handleColorChange(c)}
className={cn(
'w-7 h-7 rounded-full border-2 transition-all',
stageColor === c
@@ -433,6 +579,28 @@ export function PipelineManager() {
/>
))}
</div>
{/* Custom color picker */}
<div className="flex items-center gap-2 mt-2">
<input
type="color"
value={stageColor}
onChange={(e) => handleColorChange(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-border bg-transparent p-0"
title="Произвольный цвет"
/>
<input
type="text"
value={customColorHex || stageColor}
onChange={(e) => handleHexInput(e.target.value)}
placeholder="#FF5733"
maxLength={7}
className="w-28 px-2 py-1.5 border border-border rounded-lg text-sm font-mono bg-background text-text focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<span
className="w-6 h-6 rounded-full border border-border shrink-0"
style={{ backgroundColor: stageColor }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>

View File

@@ -9,13 +9,11 @@
"noEmit": true,
"allowJs": true,
"incremental": true,
"plugins": [
{ "name": "next" }
],
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "next-env.d.ts", ".next/types/**/*.ts"],
"include": ["src/**/*", "next-env.d.ts", ".next/types/**/*.ts", "next.config.ts"],
"exclude": ["node_modules", ".next"]
}

View File

@@ -0,0 +1,32 @@
{
"name": "@fitcrm/crm-ui",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./entities/*": "./src/entities/*.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/ --ext .ts,.tsx"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": ">=0.400.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,133 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Phone, Users, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
import { cn } from '../utils';
import { useCrmApi } from '../context';
import type { ActivityData } from '../types';
interface ActivityListProps {
entityType: string;
entityId: string;
}
const typeIcons: Record<string, typeof Phone> = {
CALL: Phone,
MEETING: Users,
TASK: CheckCircle2,
EMAIL: Clock,
};
export function ActivityList({ entityType, entityId }: ActivityListProps) {
const api = useCrmApi();
const [activities, setActivities] = useState<ActivityData[]>([]);
const [loading, setLoading] = useState(true);
const fetchActivities = useCallback(async () => {
try {
const res = await api.get<{ data: ActivityData[] }>(
`/crm/activities/${entityType}/${entityId}`,
);
setActivities(res.data);
} catch {
//
} finally {
setLoading(false);
}
}, [api, entityType, entityId]);
useEffect(() => {
void fetchActivities();
}, [fetchActivities]);
const handleComplete = async (id: string) => {
try {
await api.post(`/crm/activities/${id}/complete`, {});
await fetchActivities();
} catch {
//
}
};
if (loading) {
return <div className="text-center py-4 text-muted text-sm">Загрузка...</div>;
}
return (
<div className="space-y-2">
{activities.map((activity) => {
const Icon = typeIcons[activity.type] || Clock;
const isCompleted = !!activity.completedAt;
const isOverdue = !isCompleted && new Date(activity.scheduledAt) < new Date();
return (
<div
key={activity.id}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border transition-colors',
isCompleted
? 'border-border/30 bg-background/30 opacity-60'
: isOverdue
? 'border-error/30 bg-error/5'
: 'border-border hover:bg-background/50',
)}
>
<div
className={cn(
'h-8 w-8 rounded-full flex items-center justify-center shrink-0',
isCompleted
? 'bg-green-100 text-green-600'
: isOverdue
? 'bg-red-100 text-red-600'
: 'bg-primary/10 text-primary',
)}
>
{isCompleted ? (
<CheckCircle2 className="h-4 w-4" />
) : isOverdue ? (
<AlertTriangle className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">{activity.subject}</div>
<div className="flex items-center gap-2 text-xs text-muted">
<span>
{new Date(activity.scheduledAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{activity.assignee && (
<span>
{activity.assignee.firstName} {activity.assignee.lastName?.[0]}.
</span>
)}
</div>
{activity.result && (
<div className="text-xs text-muted mt-1">Результат: {activity.result}</div>
)}
</div>
{!isCompleted && (
<button
onClick={() => void handleComplete(activity.id)}
className="px-3 py-1.5 text-xs bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors shrink-0"
>
Выполнить
</button>
)}
</div>
);
})}
{activities.length === 0 && (
<div className="text-center py-8 text-muted text-sm">Нет запланированных дел</div>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { User, Phone, Calendar } from 'lucide-react';
import { cn } from '../utils';
import type { EntityConfig, EntityCardData } from '../types';
interface EntityCardProps {
config: EntityConfig;
data: EntityCardData;
onClick?: () => void;
compact?: boolean;
}
export function EntityCard({ config, data, onClick, compact }: EntityCardProps) {
// Build enum label map from config
const enumLabels: Record<string, Record<string, string>> = {};
for (const field of config.systemFields) {
if (field.type === 'enum' && field.enumOptions) {
enumLabels[field.key] = Object.fromEntries(field.enumOptions.map((o) => [o.value, o.label]));
}
}
const sourceValue = data.source as string | undefined;
const sourceLabel = sourceValue ? enumLabels['source']?.[sourceValue] || sourceValue : null;
return (
<div
onClick={onClick}
className={cn(
'bg-card rounded-lg border border-border p-3 cursor-pointer',
'hover:shadow-md hover:border-primary/30 transition-all',
compact && 'p-2',
)}
>
<div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-sm font-medium text-text truncate flex-1">
{data.number != null && <span className="text-muted font-normal">#{data.number} </span>}
{data.title}
</h4>
{data.amount != null && data.amount > 0 && (
<span className="text-xs font-semibold text-primary whitespace-nowrap">
{data.amount.toLocaleString('ru-RU')}
</span>
)}
</div>
{data.contactName && (
<div className="flex items-center gap-1.5 text-xs text-muted mb-1">
<User className="h-3 w-3" />
<span className="truncate">{data.contactName}</span>
</div>
)}
{data.contactPhone && (
<div className="flex items-center gap-1.5 text-xs text-muted mb-1">
<Phone className="h-3 w-3" />
<span>{data.contactPhone}</span>
</div>
)}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-border/50">
<div className="flex items-center gap-2">
{sourceLabel && (
<span className="text-[10px] text-muted bg-background px-1.5 py-0.5 rounded">
{sourceLabel}
</span>
)}
{data.assignee && (
<span className="text-[10px] text-muted">
{data.assignee.firstName} {data.assignee.lastName?.[0]}.
</span>
)}
</div>
<div className="flex items-center gap-1 text-[10px] text-muted">
<Calendar className="h-3 w-3" />
{new Date(data.createdAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,375 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
'use client';
import { useEffect, useState, useCallback } from 'react';
import { X, Loader2 } from 'lucide-react';
import { useCrmApi } from '../context';
import type { EntityConfig, CustomFieldDef, PipelineData } from '../types';
interface EntityFormDialogProps {
config: EntityConfig;
open: boolean;
onClose: () => void;
onSaved: () => void;
mode: 'create' | 'edit';
initialData?: Record<string, any>;
entityId?: string;
}
const inputClass =
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/30 bg-white text-gray-900';
export function EntityFormDialog({
config,
open,
onClose,
onSaved,
mode,
initialData,
entityId,
}: EntityFormDialogProps) {
const api = useCrmApi();
const [saving, setSaving] = useState(false);
const [values, setValues] = useState<Record<string, any>>({});
const [customFields, setCustomFields] = useState<CustomFieldDef[]>([]);
const [customValues, setCustomValues] = useState<Record<string, any>>({});
const [pipelines, setPipelines] = useState<PipelineData[]>([]);
const [staffList, setStaffList] = useState<{ id: string; name: string }[]>([]);
const fetchMeta = useCallback(async () => {
try {
const promises: Promise<any>[] = [];
// Fetch custom fields
if (config.features.customFields) {
promises.push(api.get<CustomFieldDef[]>(`/crm/fields?entityType=${config.entityType}`));
} else {
promises.push(Promise.resolve([]));
}
// Fetch pipelines
if (config.features.pipelines) {
promises.push(api.get<PipelineData[]>(`/crm/pipelines?entityType=${config.entityType}`));
} else {
promises.push(Promise.resolve([]));
}
// Fetch staff for assignee
promises.push(
api
.get<{ id: string; firstName: string; lastName: string }[]>('/users?role=manager')
.catch(() => []),
);
const [cf, pl, staff] = await Promise.all(promises);
setCustomFields((cf as CustomFieldDef[]) || []);
setPipelines((pl as PipelineData[]) || []);
const staffData = (staff || []) as { id: string; firstName: string; lastName: string }[];
setStaffList(
staffData.map((s) => ({
id: s.id,
name: `${s.firstName} ${s.lastName}`,
})),
);
} catch {
//
}
}, [api, config]);
useEffect(() => {
if (open) {
void fetchMeta();
if (mode === 'edit' && initialData) {
const vals: Record<string, any> = {};
for (const field of config.systemFields) {
if (initialData[field.key] !== undefined) {
vals[field.key] = initialData[field.key];
}
}
// Also copy other known keys
for (const key of ['pipelineId', 'stageId', 'assigneeId']) {
if (initialData[key] !== undefined) vals[key] = initialData[key];
}
setValues(vals);
setCustomValues(initialData.customFieldValues || {});
} else {
setValues({});
setCustomValues({});
}
}
}, [open, mode, initialData, config, fetchMeta]);
const setVal = (key: string, value: any) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
const setCfVal = (fieldName: string, value: any) => {
setCustomValues((prev) => ({ ...prev, [fieldName]: value }));
};
const handleSave = async () => {
setSaving(true);
try {
const data: Record<string, any> = { ...values };
// Add custom fields
if (Object.keys(customValues).length > 0) {
data.customFieldValues = customValues;
}
if (mode === 'edit' && entityId) {
await api.patch(`${config.apiPrefix}/${entityId}`, data);
} else {
await api.post(config.apiPrefix, data);
}
onSaved();
onClose();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">
{mode === 'edit'
? `Редактировать ${config.label.singular.toLowerCase()}`
: `Новая ${config.label.singular.toLowerCase()}`}
</h2>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
<X className="h-5 w-5 text-gray-400" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* System fields */}
{config.systemFields
.filter((f) => f.defaultVisible !== false)
.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
{renderFieldInput(field, values[field.key], (v) => setVal(field.key, v))}
</div>
))}
{/* Pipeline selector */}
{config.features.pipelines && pipelines.length > 0 && mode === 'create' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Воронка</label>
<select
value={values.pipelineId || pipelines[0]?.id || ''}
onChange={(e) => setVal('pipelineId', e.target.value)}
className={inputClass}
>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
{/* Assignee */}
{staffList.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ответственный</label>
<select
value={values.assigneeId || ''}
onChange={(e) => setVal('assigneeId', e.target.value || undefined)}
className={inputClass}
>
<option value=""> Не назначен </option>
{staffList.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
{/* Custom fields */}
{customFields.length > 0 && (
<>
<div className="border-t border-gray-100 pt-4 mt-4">
<h3 className="text-xs font-medium text-gray-500 mb-3 uppercase">
Дополнительные поля
</h3>
</div>
{customFields.map((cf) => (
<div key={cf.id}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{cf.name}
{cf.isRequired && <span className="text-red-500 ml-0.5">*</span>}
</label>
{renderCustomFieldInput(cf, customValues[cf.fieldName], (v) =>
setCfVal(cf.fieldName, v),
)}
{cf.description && (
<p className="text-xs text-gray-400 mt-0.5">{cf.description}</p>
)}
</div>
))}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
);
}
function renderFieldInput(
field: { key: string; type: string; enumOptions?: { value: string; label: string }[] },
value: any,
onChange: (v: any) => void,
) {
switch (field.type) {
case 'currency':
case 'number':
return (
<input
type="number"
value={value ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
className={inputClass}
/>
);
case 'date':
return (
<input
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
/>
);
case 'enum':
return (
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
>
<option value=""></option>
{(field.enumOptions || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case 'phone':
case 'email':
case 'string':
default:
return (
<input
type={field.type === 'email' ? 'email' : field.type === 'phone' ? 'tel' : 'text'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
/>
);
}
}
function renderCustomFieldInput(cf: CustomFieldDef, value: any, onChange: (v: any) => void) {
switch (cf.type) {
case 'INTEGER':
case 'FLOAT':
return (
<input
type="number"
step={cf.type === 'FLOAT' ? '0.01' : '1'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
className={inputClass}
/>
);
case 'BOOLEAN':
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700">{cf.name}</span>
</label>
);
case 'DATE':
return (
<input
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
/>
);
case 'DATETIME':
return (
<input
type="datetime-local"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
/>
);
case 'LIST':
return (
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
>
<option value=""></option>
{(cf.listOptions || []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
case 'STRING':
default:
return (
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
/>
);
}
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState, useCallback } from 'react';
import { cn } from '../utils';
import { useCrmApi } from '../context';
import { EntityCard } from './entity-card';
import type { EntityConfig, KanbanColumn } from '../types';
interface EntityKanbanProps {
config: EntityConfig;
columns: Record<string, KanbanColumn>;
onCardClick: (entityId: string) => void;
onRefresh: () => void;
}
export function EntityKanban({ config, columns, onCardClick, onRefresh }: EntityKanbanProps) {
const api = useCrmApi();
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverStageId, setDragOverStageId] = useState<string | null>(null);
const stageEntries = Object.entries(columns).sort(
([, a], [, b]) => a.stage.position - b.stage.position,
);
const handleDragStart = useCallback((e: React.DragEvent, entityId: string) => {
setDragId(entityId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', entityId);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, stageId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverStageId(stageId);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverStageId(null);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent, targetStageId: string) => {
e.preventDefault();
const entityId = e.dataTransfer.getData('text/plain');
setDragId(null);
setDragOverStageId(null);
if (!entityId) return;
let currentStageId: string | null = null;
for (const [stageId, col] of Object.entries(columns)) {
if (col.items.some((d) => d.id === entityId)) {
currentStageId = stageId;
break;
}
}
if (currentStageId === targetStageId) return;
try {
await api.post(`${config.apiPrefix}/${entityId}/move`, { stageId: targetStageId });
onRefresh();
} catch (err) {
console.error('Failed to move entity:', err);
}
},
[columns, onRefresh, api, config.apiPrefix],
);
return (
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[calc(100vh-220px)]">
{stageEntries.map(([stageId, col]) => (
<div
key={stageId}
className={cn(
'flex-shrink-0 w-72 bg-background/50 rounded-xl border border-border/50',
'flex flex-col',
dragOverStageId === stageId && 'ring-2 ring-primary/40 bg-primary/5',
)}
onDragOver={(e) => handleDragOver(e, stageId)}
onDragLeave={handleDragLeave}
onDrop={(e) => void handleDrop(e, stageId)}
>
<div className="px-3 py-3 border-b border-border/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: col.stage.color }}
/>
<h3 className="text-sm font-medium text-text truncate">{col.stage.name}</h3>
</div>
<span className="text-xs text-muted bg-background px-2 py-0.5 rounded-full">
{col.items.length}
</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{col.items.map((item) => (
<div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, item.id)}
className={cn('transition-opacity', dragId === item.id && 'opacity-40')}
>
<EntityCard
config={config}
data={item}
onClick={() => onCardClick(item.id)}
compact
/>
</div>
))}
{col.items.length === 0 && (
<div className="text-center py-8 text-xs text-muted">Нет {config.label.genitive}</div>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { User } from 'lucide-react';
import { StageBadge } from './stage-badge';
import type { EntityConfig, EntityCardData } from '../types';
interface EntityTableProps {
config: EntityConfig;
data: EntityCardData[];
onRowClick: (entityId: string) => void;
}
export function EntityTable({ config, data, onRowClick }: EntityTableProps) {
// Build enum label map from config
const enumLabels: Record<string, Record<string, string>> = {};
for (const field of config.systemFields) {
if (field.type === 'enum' && field.enumOptions) {
enumLabels[field.key] = Object.fromEntries(field.enumOptions.map((o) => [o.value, o.label]));
}
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-background/50">
<th className="text-left px-4 py-3 text-muted font-medium w-12">#</th>
<th className="text-left px-4 py-3 text-muted font-medium">Название</th>
<th className="text-left px-4 py-3 text-muted font-medium">Контакт</th>
{config.features.pipelines && (
<th className="text-left px-4 py-3 text-muted font-medium">Стадия</th>
)}
<th className="text-left px-4 py-3 text-muted font-medium">Источник</th>
<th className="text-left px-4 py-3 text-muted font-medium">Ответственный</th>
<th className="text-right px-4 py-3 text-muted font-medium">Сумма</th>
<th className="text-left px-4 py-3 text-muted font-medium">Создана</th>
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr
key={item.id}
onClick={() => onRowClick(item.id)}
className="border-b border-border/50 hover:bg-background/30 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-muted text-xs font-mono">
{item.number != null ? `#${item.number}` : '—'}
</td>
<td className="px-4 py-3">
<span className="font-medium text-text">{item.title}</span>
</td>
<td className="px-4 py-3">
<div>
{item.contactName && <div className="text-text">{item.contactName}</div>}
{item.contactPhone && (
<div className="text-xs text-muted">{item.contactPhone}</div>
)}
</div>
</td>
{config.features.pipelines && (
<td className="px-4 py-3">
{item.stage && (
<StageBadge
name={item.stage.name}
color={item.stage.color}
type={item.stage.type}
/>
)}
</td>
)}
<td className="px-4 py-3 text-muted">
{item.source ? enumLabels['source']?.[item.source as string] || item.source : '—'}
</td>
<td className="px-4 py-3">
{item.assignee ? (
<div className="flex items-center gap-1.5">
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-3 w-3 text-primary" />
</div>
<span className="text-text">
{item.assignee.firstName} {item.assignee.lastName}
</span>
</div>
) : (
<span className="text-muted"></span>
)}
</td>
<td className="px-4 py-3 text-right">
{item.amount != null && item.amount > 0 ? (
<span className="font-medium">{item.amount.toLocaleString('ru-RU')} </span>
) : (
<span className="text-muted"></span>
)}
</td>
<td className="px-4 py-3 text-muted">
{new Date(item.createdAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</td>
</tr>
))}
{data.length === 0 && (
<tr>
<td
colSpan={config.features.pipelines ? 8 : 7}
className="text-center py-12 text-muted"
>
Нет {config.label.genitive}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,404 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Trash2, Pencil, Loader2, X } from 'lucide-react';
import { useCrmApi } from '../context';
import type { CustomFieldDef } from '../types';
interface FieldManagerProps {
entityType?: string;
}
const FIELD_TYPES = [
{ value: 'STRING', label: 'Строка' },
{ value: 'INTEGER', label: 'Целое число' },
{ value: 'FLOAT', label: 'Дробное число' },
{ value: 'LIST', label: 'Список' },
{ value: 'BOOLEAN', label: 'Да/Нет' },
{ value: 'DATE', label: 'Дата' },
{ value: 'DATETIME', label: 'Дата и время' },
];
const fieldTypeLabels: Record<string, string> = Object.fromEntries(
FIELD_TYPES.map((t) => [t.value, t.label]),
);
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
// Inline dialog (self-contained, no external Dialog dependency)
function InlineDialog({
open,
onClose,
title,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer: React.ReactNode;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', h);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', h);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="fixed inset-0 bg-black/50" />
<div className="relative z-10 bg-card rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between p-6 pb-0">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted/20 transition-colors -mt-1 -mr-1"
>
<X className="h-4 w-4 text-muted" />
</button>
</div>
<div className="p-6 space-y-4">{children}</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">{footer}</div>
</div>
</div>
);
}
export function FieldManager({ entityType = 'deal' }: FieldManagerProps) {
const api = useCrmApi();
const [fields, setFields] = useState<CustomFieldDef[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<CustomFieldDef | null>(null);
const [name, setName] = useState('');
const [fieldName, setFieldName] = useState('');
const [type, setType] = useState('STRING');
const [isRequired, setIsRequired] = useState(false);
const [showToTrainer, setShowToTrainer] = useState(false);
const [description, setDescription] = useState('');
const [listOptions, setListOptions] = useState('');
const [saving, setSaving] = useState(false);
const fetchFields = useCallback(async () => {
try {
const data = await api.get<CustomFieldDef[]>(`/crm/fields?entityType=${entityType}`);
setFields(data);
} catch {
/**/
} finally {
setLoading(false);
}
}, [api, entityType]);
useEffect(() => {
setLoading(true);
void fetchFields();
}, [fetchFields]);
const openCreate = () => {
setEditing(null);
setName('');
setFieldName('');
setType('STRING');
setIsRequired(false);
setShowToTrainer(false);
setDescription('');
setListOptions('');
setShowForm(true);
};
const openEdit = (field: CustomFieldDef) => {
setEditing(field);
setName(field.name);
setFieldName(field.fieldName);
setType(field.type);
setIsRequired(field.isRequired);
setShowToTrainer(field.showToTrainer);
setDescription(field.description || '');
setListOptions(Array.isArray(field.listOptions) ? field.listOptions.join('\n') : '');
setShowForm(true);
};
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
const data: Record<string, unknown> = {
name: name.trim(),
type,
isRequired,
showToTrainer,
description: description.trim() || undefined,
};
if (type === 'LIST' && listOptions.trim())
data.listOptions = listOptions
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (editing) {
await api.patch(`/crm/fields/${editing.id}`, data);
} else {
data.entityType = entityType;
data.fieldName = fieldName.trim();
await api.post('/crm/fields', data);
}
setShowForm(false);
await fetchFields();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Деактивировать поле? Данные сохранятся, но поле будет скрыто.')) return;
try {
await api.delete(`/crm/fields/${id}`);
await fetchFields();
} catch {
/**/
}
};
const handleNameChange = (val: string) => {
setName(val);
if (!editing) {
const transliterated = val
.toLowerCase()
.replace(/[а-яё]/g, (c) => {
const map: Record<string, string> = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ё: 'e',
ж: 'zh',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
х: 'kh',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'sch',
ъ: '',
ы: 'y',
ь: '',
э: 'e',
ю: 'yu',
я: 'ya',
};
return map[c] || c;
})
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '');
setFieldName(transliterated);
}
};
if (loading)
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
return (
<div>
<div className="flex justify-end mb-4">
<button
onClick={openCreate}
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Добавить поле
</button>
</div>
<div className="bg-card rounded-xl border border-border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-background/50">
<th className="text-left px-4 py-3 text-muted font-medium">Название</th>
<th className="text-left px-4 py-3 text-muted font-medium">Код</th>
<th className="text-left px-4 py-3 text-muted font-medium">Тип</th>
<th className="text-center px-4 py-3 text-muted font-medium">Обязательное</th>
<th className="text-center px-4 py-3 text-muted font-medium">Тренер</th>
<th className="text-right px-4 py-3 text-muted font-medium w-24">Действия</th>
</tr>
</thead>
<tbody>
{fields.map((field) => (
<tr key={field.id} className="border-b border-border/50 group">
<td className="px-4 py-3">
<div className="font-medium text-text">{field.name}</div>
{field.description && (
<div className="text-xs text-muted">{field.description}</div>
)}
</td>
<td className="px-4 py-3 text-muted font-mono text-xs">{field.fieldName}</td>
<td className="px-4 py-3 text-muted">
{fieldTypeLabels[field.type] || field.type}
</td>
<td className="px-4 py-3 text-center">{field.isRequired ? 'Да' : '—'}</td>
<td className="px-4 py-3 text-center">{field.showToTrainer ? 'Да' : '—'}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openEdit(field)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => void handleDelete(field.id)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
{fields.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 text-muted">
Нет кастомных полей
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<InlineDialog
open={showForm}
onClose={() => setShowForm(false)}
title={editing ? 'Редактировать поле' : 'Новое поле'}
footer={
<>
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm text-muted hover:text-text transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSave()}
disabled={saving || !name.trim() || (!editing && !fieldName.trim())}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</>
}
>
<div>
<label className="block text-sm font-medium text-text mb-1">Название</label>
<input
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Размер ноги"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Код (snake_case)</label>
<input
type="text"
value={fieldName}
onChange={(e) => setFieldName(e.target.value)}
placeholder="shoe_size"
disabled={!!editing}
className={`${inputClass} ${editing ? 'opacity-50' : ''}`}
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Тип</label>
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
{FIELD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{type === 'LIST' && (
<div>
<label className="block text-sm font-medium text-text mb-1">
Опции (по одной на строке)
</label>
<textarea
value={listOptions}
onChange={(e) => setListOptions(e.target.value)}
rows={4}
placeholder="Опция 1&#10;Опция 2&#10;Опция 3"
className={inputClass}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-text mb-1">Описание</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Подсказка для менеджера"
className={inputClass}
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={isRequired}
onChange={(e) => setIsRequired(e.target.checked)}
className="rounded border-border"
/>
Обязательное
</label>
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={showToTrainer}
onChange={(e) => setShowToTrainer(e.target.checked)}
className="rounded border-border"
/>
Показывать тренеру
</label>
</div>
</InlineDialog>
</div>
);
}

View File

@@ -0,0 +1,712 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
Trash2,
Pencil,
GripVertical,
ChevronUp,
ChevronDown,
Loader2,
Star,
X,
} from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '../utils';
import { useCrmApi } from '../context';
import type { StageData, PipelineData } from '../types';
interface PipelineManagerProps {
entityType?: string;
}
const STAGE_COLORS = [
'#3B82F6',
'#8B5CF6',
'#F59E0B',
'#EF4444',
'#EC4899',
'#14B8A6',
'#22C55E',
'#6B7280',
'#F97316',
'#06B6D4',
];
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-background text-text';
function SortableStageRow({
stage,
index,
totalCount,
onMoveUp,
onMoveDown,
onEdit,
onDelete,
}: {
stage: StageData;
index: number;
totalCount: number;
onMoveUp: () => void;
onMoveDown: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: stage.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-background/50 transition-colors group',
isDragging && 'bg-background shadow-lg',
)}
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none p-0.5 rounded hover:bg-background"
aria-label="Перетащить"
>
<GripVertical className="h-4 w-4 text-muted/50 shrink-0" />
</button>
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: stage.color }} />
<span className="text-sm text-text flex-1">{stage.name}</span>
{stage.isDefault && (
<span className="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">
default
</span>
)}
<span className="text-xs text-muted bg-background px-2 py-0.5 rounded">{stage.type}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onMoveUp}
disabled={index === 0}
className="p-1 rounded hover:bg-background disabled:opacity-30"
>
<ChevronUp className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={onMoveDown}
disabled={index === totalCount - 1}
className="p-1 rounded hover:bg-background disabled:opacity-30"
>
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</button>
<button
onClick={onEdit}
className="p-1 rounded hover:bg-background text-muted hover:text-text"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={onDelete}
className="p-1 rounded hover:bg-error/10 text-muted hover:text-error"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
// --- Inline Dialog (no external Dialog dependency) ---
function InlineDialog({
open,
onClose,
title,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer: React.ReactNode;
}) {
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', h);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', h);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="fixed inset-0 bg-black/50" />
<div className="relative z-10 bg-card rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between p-6 pb-0">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted/20 transition-colors -mt-1 -mr-1"
>
<X className="h-4 w-4 text-muted" />
</button>
</div>
<div className="p-6 space-y-4">{children}</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">{footer}</div>
</div>
</div>
);
}
export function PipelineManager({ entityType }: PipelineManagerProps = {}) {
const api = useCrmApi();
const [pipelines, setPipelines] = useState<PipelineData[]>([]);
const [loading, setLoading] = useState(true);
const [showPipelineForm, setShowPipelineForm] = useState(false);
const [editingPipeline, setEditingPipeline] = useState<PipelineData | null>(null);
const [pipelineName, setPipelineName] = useState('');
const [pipelineDefault, setPipelineDefault] = useState(false);
const [showStageForm, setShowStageForm] = useState(false);
const [editingStage, setEditingStage] = useState<StageData | null>(null);
const [stagePipelineId, setStagePipelineId] = useState('');
const [stageName, setStageName] = useState('');
const [stageColor, setStageColor] = useState(STAGE_COLORS[0]);
const [stageType, setStageType] = useState<'OPEN' | 'WON' | 'LOST'>('OPEN');
const [stageStaleDays, setStageStaleDays] = useState('');
const [stageIsDefault, setStageIsDefault] = useState(false);
const [customColorHex, setCustomColorHex] = useState('');
const [deleteStageData, setDeleteStageData] = useState<{
stage: StageData;
pipelineId: string;
} | null>(null);
const [moveToStageId, setMoveToStageId] = useState('');
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const queryParam = entityType ? `?entityType=${entityType}` : '';
const fetchPipelines = useCallback(async () => {
try {
const data = await api.get<PipelineData[]>(`/crm/pipelines${queryParam}`);
const enriched = await Promise.all(
data.map(async (p) => {
const stages = await api.get<StageData[]>(`/crm/pipelines/${p.id}/stages`);
return { ...p, stages };
}),
);
setPipelines(enriched);
} catch {
/**/
} finally {
setLoading(false);
}
}, [api, queryParam]);
useEffect(() => {
void fetchPipelines();
}, [fetchPipelines]);
const openCreatePipeline = () => {
setEditingPipeline(null);
setPipelineName('');
setPipelineDefault(false);
setShowPipelineForm(true);
};
const openEditPipeline = (p: PipelineData) => {
setEditingPipeline(p);
setPipelineName(p.name);
setPipelineDefault(p.isDefault);
setShowPipelineForm(true);
};
const handleSavePipeline = async () => {
if (!pipelineName.trim()) return;
setSaving(true);
try {
if (editingPipeline) {
await api.patch(`/crm/pipelines/${editingPipeline.id}`, {
name: pipelineName.trim(),
isDefault: pipelineDefault,
});
} else {
const body: Record<string, unknown> = {
name: pipelineName.trim(),
isDefault: pipelineDefault,
};
if (entityType) body.entityType = entityType;
await api.post('/crm/pipelines', body);
}
setShowPipelineForm(false);
await fetchPipelines();
} catch {
/**/
} finally {
setSaving(false);
}
};
const handleDeletePipeline = async (id: string) => {
if (!confirm('Удалить воронку?')) return;
try {
await api.delete(`/crm/pipelines/${id}`);
await fetchPipelines();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'Ошибка удаления');
}
};
const openCreateStage = (pipelineId: string) => {
setEditingStage(null);
setStagePipelineId(pipelineId);
setStageName('');
setStageColor(STAGE_COLORS[0]);
setStageType('OPEN');
setStageStaleDays('');
setStageIsDefault(false);
setCustomColorHex('');
setShowStageForm(true);
};
const openEditStage = (stage: StageData, pipelineId: string) => {
setEditingStage(stage);
setStagePipelineId(pipelineId);
setStageName(stage.name);
setStageColor(stage.color);
setStageType(stage.type as 'OPEN' | 'WON' | 'LOST');
setStageStaleDays(stage.staleDays?.toString() || '');
setStageIsDefault(stage.isDefault);
setCustomColorHex(STAGE_COLORS.includes(stage.color) ? '' : stage.color);
setShowStageForm(true);
};
const handleColorChange = (color: string) => {
setStageColor(color);
setCustomColorHex(STAGE_COLORS.includes(color) ? '' : color);
};
const handleHexInput = (hex: string) => {
setCustomColorHex(hex);
if (/^#[0-9a-fA-F]{6}$/.test(hex)) setStageColor(hex);
};
const handleSaveStage = async () => {
if (!stageName.trim()) return;
setSaving(true);
try {
const data: Record<string, unknown> = {
name: stageName.trim(),
color: stageColor,
type: stageType,
staleDays: stageStaleDays ? parseInt(stageStaleDays) : null,
isDefault: stageIsDefault,
};
if (editingStage) {
await api.patch(`/crm/pipelines/stages/${editingStage.id}`, data);
} else {
const p = pipelines.find((p) => p.id === stagePipelineId);
data.position = (p?.stages.length ?? 0) + 1;
await api.post(`/crm/pipelines/${stagePipelineId}/stages`, data);
}
setShowStageForm(false);
await fetchPipelines();
} catch {
/**/
} finally {
setSaving(false);
}
};
const handleDeleteStage = async () => {
if (!deleteStageData) return;
setDeleting(true);
try {
const params = moveToStageId ? `?moveToStageId=${moveToStageId}` : '';
await api.delete(`/crm/pipelines/stages/${deleteStageData.stage.id}${params}`);
setDeleteStageData(null);
setMoveToStageId('');
await fetchPipelines();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : 'Ошибка удаления');
} finally {
setDeleting(false);
}
};
const handleMoveStage = async (pipelineId: string, stageId: string, direction: 'up' | 'down') => {
const pipeline = pipelines.find((p) => p.id === pipelineId);
if (!pipeline) return;
const sorted = [...pipeline.stages].sort((a, b) => a.position - b.position);
const idx = sorted.findIndex((s) => s.id === stageId);
if (direction === 'up' && idx <= 0) return;
if (direction === 'down' && idx >= sorted.length - 1) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
const tmp = sorted[idx]!;
sorted[idx] = sorted[swapIdx]!;
sorted[swapIdx] = tmp;
try {
await api.put('/crm/pipelines/stages/reorder', { stageIds: sorted.map((s) => s.id) });
await fetchPipelines();
} catch {
/**/
}
};
const handleDragEnd = async (pipelineId: string, event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const pipeline = pipelines.find((p) => p.id === pipelineId);
if (!pipeline) return;
const sorted = [...pipeline.stages].sort((a, b) => a.position - b.position);
const oldIdx = sorted.findIndex((s) => s.id === active.id);
const newIdx = sorted.findIndex((s) => s.id === over.id);
if (oldIdx === -1 || newIdx === -1) return;
const [moved] = sorted.splice(oldIdx, 1);
sorted.splice(newIdx, 0, moved!);
setPipelines((prev) =>
prev.map((p) =>
p.id === pipelineId
? { ...p, stages: sorted.map((s, i) => ({ ...s, position: i + 1 })) }
: p,
),
);
try {
await api.put('/crm/pipelines/stages/reorder', { stageIds: sorted.map((s) => s.id) });
} catch {
await fetchPipelines();
}
};
if (loading)
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
return (
<div className="space-y-6">
<div className="flex justify-end">
<button
onClick={openCreatePipeline}
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Добавить воронку
</button>
</div>
{pipelines.map((pipeline) => {
const sorted = [...pipeline.stages].sort((a, b) => a.position - b.position);
return (
<div key={pipeline.id} className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text">{pipeline.name}</h3>
{pipeline.isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary flex items-center gap-0.5">
<Star className="h-3 w-3" />
по умолчанию
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => openEditPipeline(pipeline)}
className="p-1.5 rounded-lg hover:bg-background text-muted hover:text-text transition-colors"
title="Редактировать"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => void handleDeletePipeline(pipeline.id)}
className="p-1.5 rounded-lg hover:bg-error/10 text-muted hover:text-error transition-colors"
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => void handleDragEnd(pipeline.id, e)}
>
<SortableContext
items={sorted.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{sorted.map((stage, idx) => (
<SortableStageRow
key={stage.id}
stage={stage}
index={idx}
totalCount={sorted.length}
onMoveUp={() => void handleMoveStage(pipeline.id, stage.id, 'up')}
onMoveDown={() => void handleMoveStage(pipeline.id, stage.id, 'down')}
onEdit={() => openEditStage(stage, pipeline.id)}
onDelete={() => setDeleteStageData({ stage, pipelineId: pipeline.id })}
/>
))}
</div>
</SortableContext>
</DndContext>
<button
onClick={() => openCreateStage(pipeline.id)}
className="flex items-center gap-1.5 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Добавить стадию
</button>
</div>
);
})}
{pipelines.length === 0 && (
<div className="text-center py-12 text-muted text-sm">
Нет воронок. Нажмите кнопку выше, чтобы создать первую.
</div>
)}
{/* Pipeline dialog */}
<InlineDialog
open={showPipelineForm}
onClose={() => setShowPipelineForm(false)}
title={editingPipeline ? 'Редактировать воронку' : 'Новая воронка'}
footer={
<>
<button
onClick={() => setShowPipelineForm(false)}
className="px-4 py-2 text-sm text-muted hover:text-text transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSavePipeline()}
disabled={saving || !pipelineName.trim()}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</>
}
>
<div>
<label className="block text-sm font-medium text-text mb-1">Название</label>
<input
type="text"
value={pipelineName}
onChange={(e) => setPipelineName(e.target.value)}
placeholder="Продажи B2C"
className={inputClass}
/>
</div>
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={pipelineDefault}
onChange={(e) => setPipelineDefault(e.target.checked)}
className="rounded border-border"
/>
По умолчанию
</label>
</InlineDialog>
{/* Stage dialog */}
<InlineDialog
open={showStageForm}
onClose={() => setShowStageForm(false)}
title={editingStage ? 'Редактировать стадию' : 'Новая стадия'}
footer={
<>
<button
onClick={() => setShowStageForm(false)}
className="px-4 py-2 text-sm text-muted hover:text-text transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSaveStage()}
disabled={saving || !stageName.trim()}
className="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</>
}
>
<div>
<label className="block text-sm font-medium text-text mb-1">Название</label>
<input
type="text"
value={stageName}
onChange={(e) => setStageName(e.target.value)}
placeholder="Квалификация"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Цвет</label>
<div className="flex items-center gap-2 flex-wrap">
{STAGE_COLORS.map((c) => (
<button
key={c}
onClick={() => handleColorChange(c)}
className={cn(
'w-7 h-7 rounded-full border-2 transition-all',
stageColor === c ? 'border-text scale-110' : 'border-transparent hover:scale-105',
)}
style={{ backgroundColor: c }}
/>
))}
</div>
<div className="flex items-center gap-2 mt-2">
<input
type="color"
value={stageColor}
onChange={(e) => handleColorChange(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-border bg-transparent p-0"
/>
<input
type="text"
value={customColorHex || stageColor}
onChange={(e) => handleHexInput(e.target.value)}
placeholder="#FF5733"
maxLength={7}
className="w-28 px-2 py-1.5 border border-border rounded-lg text-sm font-mono bg-background text-text focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<span
className="w-6 h-6 rounded-full border border-border shrink-0"
style={{ backgroundColor: stageColor }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text mb-1">Тип</label>
<select
value={stageType}
onChange={(e) => setStageType(e.target.value as 'OPEN' | 'WON' | 'LOST')}
className={inputClass}
>
<option value="OPEN">OPEN (рабочая)</option>
<option value="WON">WON (выиграна)</option>
<option value="LOST">LOST (проиграна)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text mb-1">Устаревание (дни)</label>
<input
type="number"
value={stageStaleDays}
onChange={(e) => setStageStaleDays(e.target.value)}
placeholder="—"
min={0}
className={inputClass}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={stageIsDefault}
onChange={(e) => setStageIsDefault(e.target.checked)}
className="rounded border-border"
/>
Стадия по умолчанию
</label>
</InlineDialog>
{/* Delete stage dialog */}
<InlineDialog
open={!!deleteStageData}
onClose={() => setDeleteStageData(null)}
title="Удалить стадию"
footer={
<>
<button
onClick={() => setDeleteStageData(null)}
className="px-4 py-2 text-sm text-muted hover:text-text transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleDeleteStage()}
disabled={deleting}
className="px-4 py-2 text-sm bg-error text-white rounded-lg hover:bg-error/90 disabled:opacity-50 transition-colors"
>
{deleting ? 'Удаление...' : 'Удалить'}
</button>
</>
}
>
{deleteStageData && (
<>
<p className="text-sm text-text">
Вы удаляете стадию <strong>{deleteStageData.stage.name}</strong>.
</p>
{(() => {
const p = pipelines.find((p) => p.id === deleteStageData.pipelineId);
const other = p?.stages.filter((s) => s.id !== deleteStageData.stage.id) || [];
return other.length > 0 ? (
<div>
<label className="block text-sm font-medium text-text mb-1">
Перенести сделки в стадию:
</label>
<select
value={moveToStageId}
onChange={(e) => setMoveToStageId(e.target.value)}
className={inputClass}
>
<option value=""> Не переносить </option>
{other
.sort((a, b) => a.position - b.position)
.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.type})
</option>
))}
</select>
</div>
) : null;
})()}
</>
)}
</InlineDialog>
</div>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { cn } from '../utils';
interface StageBadgeProps {
name: string;
color?: string;
type?: string;
className?: string;
}
const typeColors: Record<string, string> = {
WON: 'bg-green-100 text-green-700',
LOST: 'bg-red-100 text-red-700',
INCOMING: 'bg-blue-100 text-blue-700',
};
export function StageBadge({ name, color, type, className }: StageBadgeProps) {
const bgStyle =
type && typeColors[type] ? typeColors[type] : color ? '' : 'bg-primary/10 text-primary';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium',
bgStyle,
className,
)}
style={
color && !typeColors[type ?? ''] ? { backgroundColor: `${color}20`, color } : undefined
}
>
{color && (
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
)}
{name}
</span>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { useCrmApi } from '../context';
import { cn } from '../utils';
import type { StageData, PipelineData } from '../types';
interface StageSwitcherProps {
entityType: string;
entityId: string;
currentStageId: string;
pipeline: PipelineData;
onStageChanged?: (newStageId: string) => void;
compact?: boolean;
}
export function StageSwitcher({
entityType,
entityId,
currentStageId,
pipeline,
onStageChanged,
compact = false,
}: StageSwitcherProps) {
const api = useCrmApi();
const [loading, setLoading] = useState<string | null>(null);
const stages = [...pipeline.stages].sort((a, b) => a.position - b.position);
const currentIndex = stages.findIndex((s) => s.id === currentStageId);
const handleStageClick = async (stage: StageData) => {
if (stage.id === currentStageId || loading) return;
setLoading(stage.id);
try {
const apiPrefix = entityType === 'deal' ? '/crm/deals' : `/crm/${entityType}s`;
await api.post(`${apiPrefix}/${entityId}/move`, { stageId: stage.id });
onStageChanged?.(stage.id);
} catch {
//
} finally {
setLoading(null);
}
};
if (compact) {
return (
<div className="flex items-center gap-1">
{stages.map((stage, index) => {
const isCurrent = stage.id === currentStageId;
const isPast = index < currentIndex;
const isLoading = loading === stage.id;
return (
<button
key={stage.id}
onClick={() => void handleStageClick(stage)}
disabled={isLoading || isCurrent}
title={stage.name}
className={cn(
'h-2 rounded-full transition-all',
isCurrent ? 'w-6' : 'w-2',
isLoading && 'animate-pulse',
)}
style={{
backgroundColor: isCurrent || isPast ? stage.color : '#e5e7eb',
opacity: isCurrent ? 1 : isPast ? 0.6 : 0.3,
}}
/>
);
})}
</div>
);
}
return (
<div className="flex flex-col gap-1">
{stages.map((stage, index) => {
const isCurrent = stage.id === currentStageId;
const isPast = index < currentIndex;
const isLoading = loading === stage.id;
return (
<button
key={stage.id}
onClick={() => void handleStageClick(stage)}
disabled={isLoading || isCurrent}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs transition-all text-left',
isCurrent ? 'font-medium' : 'hover:bg-gray-50 cursor-pointer',
isLoading && 'opacity-50',
)}
>
<span
className={cn('w-2.5 h-2.5 rounded-full shrink-0', isLoading && 'animate-pulse')}
style={{
backgroundColor: stage.color,
opacity: isCurrent || isPast ? 1 : 0.4,
}}
/>
<span
className={cn(
isCurrent ? 'text-gray-900' : isPast ? 'text-gray-500' : 'text-gray-400',
)}
>
{stage.name}
</span>
{isCurrent && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-500 ml-auto">
текущая
</span>
)}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
MessageSquare,
Phone,
ArrowRightLeft,
FileText,
CheckCircle2,
Dumbbell,
Pin,
Send,
Settings2,
} from 'lucide-react';
import { cn } from '../utils';
import { useCrmApi } from '../context';
import type { EntityConfig, TimelineEntryData } from '../types';
interface TimelineFeedProps {
config: EntityConfig;
entityId: string;
}
const typeConfig: Record<string, { icon: typeof MessageSquare; color: string; label: string }> = {
COMMENT: { icon: MessageSquare, color: 'text-blue-500 bg-blue-100', label: 'Комментарий' },
CALL: { icon: Phone, color: 'text-green-500 bg-green-100', label: 'Звонок' },
STAGE_CHANGE: {
icon: ArrowRightLeft,
color: 'text-purple-500 bg-purple-100',
label: 'Смена стадии',
},
FORM_SUBMISSION: { icon: FileText, color: 'text-orange-500 bg-orange-100', label: 'Заявка' },
ACTIVITY_CREATED: { icon: Settings2, color: 'text-cyan-500 bg-cyan-100', label: 'Дело создано' },
ACTIVITY_DONE: {
icon: CheckCircle2,
color: 'text-green-500 bg-green-100',
label: 'Дело выполнено',
},
TRAINING_CREATED: { icon: Dumbbell, color: 'text-indigo-500 bg-indigo-100', label: 'Тренировка' },
MESSAGE: { icon: Send, color: 'text-sky-500 bg-sky-100', label: 'Сообщение' },
SYSTEM: { icon: Settings2, color: 'text-gray-500 bg-gray-100', label: 'Система' },
};
export function TimelineFeed({ config, entityId }: TimelineFeedProps) {
const api = useCrmApi();
const [entries, setEntries] = useState<TimelineEntryData[]>([]);
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState('');
const [sending, setSending] = useState(false);
const fetchTimeline = useCallback(async () => {
try {
const res = await api.get<{ data: TimelineEntryData[] }>(
`${config.apiPrefix}/${entityId}/timeline`,
);
setEntries(res.data);
} catch {
//
} finally {
setLoading(false);
}
}, [api, config.apiPrefix, entityId]);
useEffect(() => {
void fetchTimeline();
}, [fetchTimeline]);
const handleAddComment = async () => {
if (!comment.trim()) return;
setSending(true);
try {
await api.post(`${config.apiPrefix}/${entityId}/timeline/comment`, {
content: comment.trim(),
});
setComment('');
await fetchTimeline();
} catch {
//
} finally {
setSending(false);
}
};
const handlePin = async (id: string, pinned: boolean) => {
try {
await api.patch(`${config.apiPrefix}/${entityId}/timeline/${id}/${pinned ? 'unpin' : 'pin'}`);
await fetchTimeline();
} catch {
//
}
};
if (loading) {
return <div className="text-center py-8 text-muted text-sm">Загрузка таймлайна...</div>;
}
return (
<div>
<div className="flex gap-2 mb-4">
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAddComment();
}}
placeholder="Написать комментарий..."
className="flex-1 px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary bg-background text-text"
/>
<button
onClick={() => void handleAddComment()}
disabled={sending || !comment.trim()}
className="px-3 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Send className="h-4 w-4" />
</button>
</div>
{entries.filter((e) => e.pinnedAt).length > 0 && (
<div className="mb-4 space-y-2">
{entries
.filter((e) => e.pinnedAt)
.map((entry) => (
<TimelineItem
key={`pinned-${entry.id}`}
entry={entry}
pinned
onPin={() => void handlePin(entry.id, true)}
/>
))}
</div>
)}
<div className="space-y-3">
{entries
.filter((e) => !e.pinnedAt)
.map((entry) => (
<TimelineItem
key={entry.id}
entry={entry}
onPin={() => void handlePin(entry.id, false)}
/>
))}
{entries.length === 0 && (
<div className="text-center py-8 text-muted text-sm">Пока нет записей в таймлайне</div>
)}
</div>
</div>
);
}
function TimelineItem({
entry,
pinned,
onPin,
}: {
entry: TimelineEntryData;
pinned?: boolean;
onPin: () => void;
}) {
const cfg = (typeConfig[entry.type] ?? typeConfig.SYSTEM)!;
const Icon = cfg.icon;
return (
<div
className={cn(
'flex gap-3 p-3 rounded-lg border transition-colors',
pinned ? 'border-primary/30 bg-primary/5' : 'border-border/50 hover:bg-background/50',
)}
>
<div
className={cn('h-8 w-8 rounded-full flex items-center justify-center shrink-0', cfg.color)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{entry.subject && (
<span className="text-sm font-medium text-text truncate">{entry.subject}</span>
)}
<span className="text-[10px] text-muted shrink-0">{cfg.label}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onPin}
className="p-1 rounded hover:bg-background/50 text-muted hover:text-text transition-colors"
title={pinned ? 'Открепить' : 'Закрепить'}
>
<Pin className={cn('h-3 w-3', pinned && 'fill-current text-primary')} />
</button>
<span className="text-[10px] text-muted">
{new Date(entry.createdAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
{entry.content && <p className="text-sm text-muted mt-1">{entry.content}</p>}
{entry.user && (
<span className="text-[10px] text-muted mt-1 inline-block">
{entry.user.firstName} {entry.user.lastName}
</span>
)}
{entry.type === 'STAGE_CHANGE' && entry.metadata && (
<div className="text-xs text-muted mt-1">
{entry.metadata.fromStageName} {entry.metadata.toStageName}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { createContext, useContext } from 'react';
import type { CrmApi } from './types';
const CrmApiContext = createContext<CrmApi | null>(null);
export const CrmApiProvider = CrmApiContext.Provider;
export function useCrmApi(): CrmApi {
const api = useContext(CrmApiContext);
if (!api) throw new Error('useCrmApi must be used within <CrmApiProvider>');
return api;
}

View File

@@ -0,0 +1,70 @@
import type { EntityConfig } from '../types';
export const DEAL_CONFIG: EntityConfig = {
entityType: 'deal',
apiPrefix: '/crm/deals',
label: { singular: 'Сделка', plural: 'Сделки', genitive: 'сделки' },
systemFields: [
{ key: 'title', label: 'Название', type: 'string', defaultVisible: true, defaultInCard: true },
{ key: 'amount', label: 'Сумма', type: 'currency', defaultVisible: true, defaultInCard: true },
{
key: 'contactName',
label: 'Контакт',
type: 'string',
section: 'contact',
defaultVisible: true,
defaultInCard: true,
},
{
key: 'contactPhone',
label: 'Телефон',
type: 'phone',
section: 'contact',
defaultVisible: true,
defaultInCard: true,
},
{
key: 'contactEmail',
label: 'Email',
type: 'email',
section: 'contact',
defaultVisible: true,
},
{ key: 'contactTelegram', label: 'Telegram', type: 'string', section: 'contact' },
{ key: 'contactWhatsapp', label: 'WhatsApp', type: 'string', section: 'contact' },
{ key: 'contactVk', label: 'VK', type: 'string', section: 'contact' },
{
key: 'source',
label: 'Источник',
type: 'enum',
defaultVisible: true,
defaultInCard: true,
enumOptions: [
{ value: 'MANUAL', label: 'Вручную' },
{ value: 'LANDING', label: 'Лендинг' },
{ value: 'WEBHOOK', label: 'Вебхук' },
{ value: 'IMPORT', label: 'Импорт' },
{ value: 'REFERRAL', label: 'Реферал' },
{ value: 'PHONE', label: 'Телефон' },
{ value: 'SOCIAL', label: 'Соцсети' },
],
},
{ key: 'probability', label: 'Вероятность %', type: 'number' },
{ key: 'expectedCloseDate', label: 'Ожидаемое закрытие', type: 'date' },
{ key: 'assigneeId', label: 'Ответственный', type: 'relation', relationEndpoint: '/staff' },
{ key: 'companyName', label: 'Компания', type: 'string', section: 'company' },
{ key: 'companyInn', label: 'ИНН', type: 'string', section: 'company' },
{ key: 'companyKpp', label: 'КПП', type: 'string', section: 'company' },
{ key: 'companyOgrn', label: 'ОГРН', type: 'string', section: 'company' },
{ key: 'companyLegalAddress', label: 'Юр. адрес', type: 'string', section: 'company' },
{ key: 'companyBankAccount', label: 'Р/С', type: 'string', section: 'company' },
{ key: 'companyBik', label: 'БИК', type: 'string', section: 'company' },
{ key: 'companyBankName', label: 'Банк', type: 'string', section: 'company' },
],
features: {
pipelines: true,
activities: true,
timeline: true,
customFields: true,
},
};

View File

@@ -0,0 +1,31 @@
// Types
export type {
EntityConfig,
FieldDef,
StageData,
PipelineData,
EntityCardData,
KanbanColumn,
ActivityData,
TimelineEntryData,
CustomFieldDef,
CrmApi,
} from './types';
// Context
export { CrmApiProvider, useCrmApi } from './context';
// Components
export { StageBadge } from './components/stage-badge';
export { EntityCard } from './components/entity-card';
export { EntityKanban } from './components/entity-kanban';
export { EntityTable } from './components/entity-table';
export { EntityFormDialog } from './components/entity-form-dialog';
export { ActivityList } from './components/activity-list';
export { TimelineFeed } from './components/timeline-feed';
export { StageSwitcher } from './components/stage-switcher';
export { PipelineManager } from './components/pipeline-manager';
export { FieldManager } from './components/field-manager';
// Utils
export { cn } from './utils';

View File

@@ -0,0 +1,112 @@
/** Core entity configuration — drives all generic CRM components */
export interface EntityConfig {
entityType: string;
apiPrefix: string;
label: { singular: string; plural: string; genitive: string };
systemFields: FieldDef[];
features: {
pipelines: boolean;
activities: boolean;
timeline: boolean;
customFields: boolean;
};
}
export interface FieldDef {
key: string;
label: string;
type: 'string' | 'number' | 'currency' | 'date' | 'enum' | 'relation' | 'phone' | 'email';
section?: string;
enumOptions?: { value: string; label: string }[];
relationEndpoint?: string;
defaultVisible?: boolean;
defaultInCard?: boolean;
}
/** Stage data shared across components */
export interface StageData {
id: string;
name: string;
color: string;
type: string;
position: number;
staleDays?: number | null;
isDefault: boolean;
}
/** Pipeline data */
export interface PipelineData {
id: string;
name: string;
isDefault: boolean;
isActive: boolean;
entityType?: string;
stages: StageData[];
}
/** Minimal entity card data for kanban/table */
export interface EntityCardData {
id: string;
number?: number | null;
title: string;
amount?: number | null;
stage?: { name: string; color: string; type: string } | null;
assignee?: { firstName: string; lastName: string } | null;
createdAt: string;
[key: string]: any;
}
/** Kanban column */
export interface KanbanColumn {
stage: StageData;
items: EntityCardData[];
}
/** Activity */
export interface ActivityData {
id: string;
type: string;
subject: string;
description?: string;
scheduledAt: string;
completedAt?: string | null;
result?: string | null;
assignee?: { firstName: string; lastName: string } | null;
completedBy?: { firstName: string; lastName: string } | null;
}
/** Timeline entry */
export interface TimelineEntryData {
id: string;
type: string;
subject?: string;
content?: string;
metadata?: Record<string, string>;
pinnedAt?: string | null;
user?: { id: string; firstName: string; lastName: string } | null;
createdAt: string;
}
/** Custom field definition */
export interface CustomFieldDef {
id: string;
fieldName: string;
name: string;
type: string;
isRequired: boolean;
isMultiple: boolean;
showToTrainer: boolean;
listOptions?: string[] | null;
description?: string | null;
position: number;
defaultValue?: string | null;
}
/** API adapter — injected via CrmProvider */
export interface CrmApi {
get<T = any>(url: string): Promise<T>;
post<T = any>(url: string, data?: any): Promise<T>;
patch<T = any>(url: string, data?: any): Promise<T>;
put<T = any>(url: string, data?: any): Promise<T>;
delete<T = any>(url: string): Promise<T>;
}

View File

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

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"noEmit": true,
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

133
pnpm-lock.yaml generated
View File

@@ -390,6 +390,18 @@ importers:
apps/web-club-admin:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@fitcrm/crm-ui':
specifier: workspace:^
version: link:../../packages/crm-ui
class-variance-authority:
specifier: ^0.7.0
version: 0.7.1
@@ -436,6 +448,18 @@ importers:
apps/web-platform-admin:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@fitcrm/crm-ui':
specifier: workspace:^
version: link:../../packages/crm-ui
class-variance-authority:
specifier: ^0.7.0
version: 0.7.1
@@ -499,6 +523,43 @@ importers:
specifier: ^5.5.0
version: 5.9.3
packages/crm-ui:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.3)
clsx:
specifier: ^2.1.0
version: 2.1.1
lucide-react:
specifier: '>=0.400.0'
version: 0.460.0(react@19.2.3)
react:
specifier: ^19.0.0
version: 19.2.3
react-dom:
specifier: ^19.0.0
version: 19.2.3(react@19.2.3)
tailwind-merge:
specifier: ^2.5.0
version: 2.6.1
devDependencies:
'@types/react':
specifier: ^19.0.0
version: 19.1.17
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.1.17)
typescript:
specifier: ^5.5.0
version: 5.9.3
packages/shared-types:
devDependencies:
typescript:
@@ -1107,6 +1168,28 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@egjs/hammerjs@2.0.17':
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
engines: {node: '>=0.8.0'}
@@ -8532,6 +8615,56 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
dependencies:
react: 19.2.3
tslib: 2.8.1
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.3)
'@dnd-kit/utilities': 3.2.2(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@dnd-kit/utilities': 3.2.2(react@19.2.3)
react: 19.2.3
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.3)':
dependencies:
react: 19.2.3
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@egjs/hammerjs@2.0.17':
dependencies:
'@types/hammerjs': 2.0.46