feat(crm): мультисущностная архитектура, роли, раскладка карточек
Some checks failed
Some checks failed
- 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:
@@ -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");
|
||||
51
apps/api/prisma/migrations/00004_roles_system/migration.sql
Normal file
51
apps/api/prisma/migrations/00004_roles_system/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
31
apps/api/src/modules/crm/dto/set-card-layout.dto.ts
Normal file
31
apps/api/src/modules/crm/dto/set-card-layout.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
130
apps/api/src/modules/crm/services/card-layout.service.ts
Normal file
130
apps/api/src/modules/crm/services/card-layout.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
78
apps/api/src/modules/roles/roles.controller.ts
Normal file
78
apps/api/src/modules/roles/roles.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/roles/roles.module.ts
Normal file
10
apps/api/src/modules/roles/roles.module.ts
Normal 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 {}
|
||||
165
apps/api/src/modules/roles/roles.service.ts
Normal file
165
apps/api/src/modules/roles/roles.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@fitcrm/crm-ui'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
287
apps/web-club-admin/src/app/(dashboard)/settings/roles/page.tsx
Normal file
287
apps/web-club-admin/src/app/(dashboard)/settings/roles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
apps/web-club-admin/src/components/crm/card-layout-editor.tsx
Normal file
302
apps/web-club-admin/src/components/crm/card-layout-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@fitcrm/crm-ui'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
32
packages/crm-ui/package.json
Normal file
32
packages/crm-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
133
packages/crm-ui/src/components/activity-list.tsx
Normal file
133
packages/crm-ui/src/components/activity-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
packages/crm-ui/src/components/entity-card.tsx
Normal file
84
packages/crm-ui/src/components/entity-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
375
packages/crm-ui/src/components/entity-form-dialog.tsx
Normal file
375
packages/crm-ui/src/components/entity-form-dialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
121
packages/crm-ui/src/components/entity-kanban.tsx
Normal file
121
packages/crm-ui/src/components/entity-kanban.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
packages/crm-ui/src/components/entity-table.tsx
Normal file
118
packages/crm-ui/src/components/entity-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
404
packages/crm-ui/src/components/field-manager.tsx
Normal file
404
packages/crm-ui/src/components/field-manager.tsx
Normal 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 Опция 2 Опция 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>
|
||||
);
|
||||
}
|
||||
712
packages/crm-ui/src/components/pipeline-manager.tsx
Normal file
712
packages/crm-ui/src/components/pipeline-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
packages/crm-ui/src/components/stage-badge.tsx
Normal file
39
packages/crm-ui/src/components/stage-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
packages/crm-ui/src/components/stage-switcher.tsx
Normal file
118
packages/crm-ui/src/components/stage-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
packages/crm-ui/src/components/timeline-feed.tsx
Normal file
216
packages/crm-ui/src/components/timeline-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
packages/crm-ui/src/context.ts
Normal file
14
packages/crm-ui/src/context.ts
Normal 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;
|
||||
}
|
||||
70
packages/crm-ui/src/entities/deal.ts
Normal file
70
packages/crm-ui/src/entities/deal.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
31
packages/crm-ui/src/index.ts
Normal file
31
packages/crm-ui/src/index.ts
Normal 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';
|
||||
112
packages/crm-ui/src/types.ts
Normal file
112
packages/crm-ui/src/types.ts
Normal 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>;
|
||||
}
|
||||
6
packages/crm-ui/src/utils.ts
Normal file
6
packages/crm-ui/src/utils.ts
Normal 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));
|
||||
}
|
||||
17
packages/crm-ui/tsconfig.json
Normal file
17
packages/crm-ui/tsconfig.json
Normal 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
133
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user