ar-saas 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/templates/backend/.env.example +13 -3
- package/templates/backend/README.md +22 -3
- package/templates/backend/package-lock.json +165 -2
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/app.module.ts +14 -0
- package/templates/backend/src/common/guards/github-auth.guard.ts +5 -0
- package/templates/backend/src/main.ts +2 -2
- package/templates/backend/src/modules/auth/auth.controller.ts +51 -3
- package/templates/backend/src/modules/auth/auth.module.ts +2 -1
- package/templates/backend/src/modules/auth/auth.service.ts +96 -11
- package/templates/backend/src/modules/auth/strategies/github.strategy.ts +46 -0
- package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +1 -1
- package/templates/backend/src/modules/clients/clients.controller.ts +91 -0
- package/templates/backend/src/modules/clients/clients.module.ts +16 -0
- package/templates/backend/src/modules/clients/clients.repository.ts +14 -0
- package/templates/backend/src/modules/clients/clients.service.ts +52 -0
- package/templates/backend/src/modules/clients/dto/create-client.dto.ts +40 -0
- package/templates/backend/src/modules/clients/dto/query-client.dto.ts +30 -0
- package/templates/backend/src/modules/clients/dto/update-client.dto.ts +4 -0
- package/templates/backend/src/modules/clients/schemas/client.schema.ts +32 -0
- package/templates/backend/src/modules/invoices/dto/create-invoice.dto.ts +79 -0
- package/templates/backend/src/modules/invoices/dto/invoice-item.dto.ts +23 -0
- package/templates/backend/src/modules/invoices/dto/query-invoice.dto.ts +40 -0
- package/templates/backend/src/modules/invoices/dto/update-invoice.dto.ts +4 -0
- package/templates/backend/src/modules/invoices/invoices.controller.ts +91 -0
- package/templates/backend/src/modules/invoices/invoices.module.ts +18 -0
- package/templates/backend/src/modules/invoices/invoices.repository.ts +14 -0
- package/templates/backend/src/modules/invoices/invoices.service.ts +104 -0
- package/templates/backend/src/modules/invoices/schemas/invoice.schema.ts +75 -0
- package/templates/backend/src/modules/notifications/dto/create-notification.dto.ts +45 -0
- package/templates/backend/src/modules/notifications/dto/query-notification.dto.ts +30 -0
- package/templates/backend/src/modules/notifications/dto/update-notification.dto.ts +4 -0
- package/templates/backend/src/modules/notifications/notifications.controller.ts +119 -0
- package/templates/backend/src/modules/notifications/notifications.module.ts +16 -0
- package/templates/backend/src/modules/notifications/notifications.repository.ts +31 -0
- package/templates/backend/src/modules/notifications/notifications.service.ts +64 -0
- package/templates/backend/src/modules/notifications/schemas/notification.schema.ts +38 -0
- package/templates/backend/src/modules/pipeline/dto/create-deal.dto.ts +40 -0
- package/templates/backend/src/modules/pipeline/dto/query-deal.dto.ts +35 -0
- package/templates/backend/src/modules/pipeline/dto/update-deal.dto.ts +4 -0
- package/templates/backend/src/modules/pipeline/pipeline.controller.ts +91 -0
- package/templates/backend/src/modules/pipeline/pipeline.module.ts +18 -0
- package/templates/backend/src/modules/pipeline/pipeline.repository.ts +14 -0
- package/templates/backend/src/modules/pipeline/pipeline.service.ts +64 -0
- package/templates/backend/src/modules/pipeline/schemas/deal.schema.ts +39 -0
- package/templates/backend/src/modules/planner/dto/create-planner-block.dto.ts +66 -0
- package/templates/backend/src/modules/planner/dto/query-planner-block.dto.ts +48 -0
- package/templates/backend/src/modules/planner/dto/update-block-status.dto.ts +10 -0
- package/templates/backend/src/modules/planner/dto/update-planner-block.dto.ts +4 -0
- package/templates/backend/src/modules/planner/planner.controller.ts +124 -0
- package/templates/backend/src/modules/planner/planner.module.ts +16 -0
- package/templates/backend/src/modules/planner/planner.repository.ts +45 -0
- package/templates/backend/src/modules/planner/planner.service.ts +104 -0
- package/templates/backend/src/modules/planner/schemas/planner-block.schema.ts +56 -0
- package/templates/backend/src/modules/task-columns/dto/create-task-column.dto.ts +20 -0
- package/templates/backend/src/modules/task-columns/dto/reorder-columns.dto.ts +9 -0
- package/templates/backend/src/modules/task-columns/dto/update-task-column.dto.ts +4 -0
- package/templates/backend/src/modules/task-columns/schemas/task-column.schema.ts +21 -0
- package/templates/backend/src/modules/task-columns/task-columns.controller.ts +86 -0
- package/templates/backend/src/modules/task-columns/task-columns.module.ts +16 -0
- package/templates/backend/src/modules/task-columns/task-columns.repository.ts +15 -0
- package/templates/backend/src/modules/task-columns/task-columns.service.ts +49 -0
- package/templates/backend/src/modules/tasks/dto/checklist-item.dto.ts +13 -0
- package/templates/backend/src/modules/tasks/dto/create-task.dto.ts +67 -0
- package/templates/backend/src/modules/tasks/dto/label.dto.ts +12 -0
- package/templates/backend/src/modules/tasks/dto/move-task.dto.ts +15 -0
- package/templates/backend/src/modules/tasks/dto/query-task.dto.ts +40 -0
- package/templates/backend/src/modules/tasks/dto/update-task.dto.ts +4 -0
- package/templates/backend/src/modules/tasks/schemas/task.schema.ts +66 -0
- package/templates/backend/src/modules/tasks/tasks.controller.ts +104 -0
- package/templates/backend/src/modules/tasks/tasks.module.ts +18 -0
- package/templates/backend/src/modules/tasks/tasks.repository.ts +14 -0
- package/templates/backend/src/modules/tasks/tasks.service.ts +76 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +3 -0
- package/templates/backend/src/modules/users/users.repository.ts +8 -0
- package/templates/backend/src/modules/users/users.service.ts +34 -0
- package/templates/frontend/.env.local.example +1 -1
- package/templates/frontend/README.md +43 -1
- package/templates/frontend/package.json +48 -45
- package/templates/frontend/pnpm-lock.yaml +5096 -5012
- package/templates/frontend/src/app/(auth)/layout.tsx +7 -1
- package/templates/frontend/src/app/(auth)/login/page.tsx +13 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +13 -0
- package/templates/frontend/src/app/(dashboard)/clients/page.tsx +295 -0
- package/templates/frontend/src/app/(dashboard)/invoices/page.tsx +305 -0
- package/templates/frontend/src/app/(dashboard)/notifications/page.tsx +173 -0
- package/templates/frontend/src/app/(dashboard)/pipeline/page.tsx +244 -0
- package/templates/frontend/src/app/(dashboard)/planner/page.tsx +287 -0
- package/templates/frontend/src/app/(dashboard)/settings/page.tsx +165 -128
- package/templates/frontend/src/app/(dashboard)/tasks/page.tsx +366 -0
- package/templates/frontend/src/app/auth/github/callback/page.tsx +82 -0
- package/templates/frontend/src/app/landing/page.tsx +21 -0
- package/templates/frontend/src/app/page.tsx +5 -5
- package/templates/frontend/src/app/setup/page.tsx +15 -14
- package/templates/frontend/src/components/auth/github-button.tsx +25 -0
- package/templates/frontend/src/components/dashboard/sidebar.tsx +90 -71
- package/templates/frontend/src/components/ui/alert-dialog.tsx +141 -0
- package/templates/frontend/src/components/ui/button.tsx +56 -52
- package/templates/frontend/src/components/ui/popover.tsx +31 -0
- package/templates/frontend/src/components/ui/select.tsx +160 -0
- package/templates/frontend/src/components/ui/sheet.tsx +140 -0
- package/templates/frontend/src/lib/api/auth.ts +7 -0
- package/templates/frontend/src/lib/api/clients.ts +17 -0
- package/templates/frontend/src/lib/api/invoices.ts +18 -0
- package/templates/frontend/src/lib/api/notifications.ts +27 -0
- package/templates/frontend/src/lib/api/pipeline.ts +18 -0
- package/templates/frontend/src/lib/api/planner.ts +26 -0
- package/templates/frontend/src/lib/api/task-columns.ts +17 -0
- package/templates/frontend/src/lib/api/tasks.ts +21 -0
- package/templates/frontend/src/lib/hooks/use-unread-notifications.ts +23 -0
- package/templates/frontend/src/providers/auth-provider.tsx +7 -1
- package/templates/frontend/src/types/clients.ts +38 -0
- package/templates/frontend/src/types/invoices.ts +51 -0
- package/templates/frontend/src/types/notifications.ts +30 -0
- package/templates/frontend/src/types/pipeline.ts +35 -0
- package/templates/frontend/src/types/planner.ts +49 -0
- package/templates/frontend/src/types/tasks.ts +65 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsDateString, IsEnum, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class CreateDealDto {
|
|
5
|
+
@ApiProperty({ example: 'Proyecto de integración', maxLength: 200 })
|
|
6
|
+
@IsString({ message: 'El título debe ser un texto' })
|
|
7
|
+
@MaxLength(200, { message: 'El título no puede superar los 200 caracteres' })
|
|
8
|
+
title!: string;
|
|
9
|
+
|
|
10
|
+
@ApiPropertyOptional({ description: 'ID del cliente' })
|
|
11
|
+
@IsOptional()
|
|
12
|
+
@IsString({ message: 'El clientId debe ser un texto' })
|
|
13
|
+
clientId?: string;
|
|
14
|
+
|
|
15
|
+
@ApiPropertyOptional({ example: 5000, minimum: 0 })
|
|
16
|
+
@IsOptional()
|
|
17
|
+
@IsNumber({}, { message: 'El valor debe ser un número' })
|
|
18
|
+
@Min(0, { message: 'El valor no puede ser negativo' })
|
|
19
|
+
value?: number;
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional({ example: 'USD' })
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@IsString({ message: 'La moneda debe ser un texto' })
|
|
24
|
+
currency?: string;
|
|
25
|
+
|
|
26
|
+
@ApiPropertyOptional({ enum: ['lead', 'contacted', 'proposal', 'won', 'lost'], default: 'lead' })
|
|
27
|
+
@IsOptional()
|
|
28
|
+
@IsEnum(['lead', 'contacted', 'proposal', 'won', 'lost'], { message: 'El stage no es válido' })
|
|
29
|
+
stage?: 'lead' | 'contacted' | 'proposal' | 'won' | 'lost';
|
|
30
|
+
|
|
31
|
+
@ApiPropertyOptional({ example: '2026-03-31' })
|
|
32
|
+
@IsOptional()
|
|
33
|
+
@IsDateString({}, { message: 'La fecha de cierre esperada debe ser una fecha válida' })
|
|
34
|
+
expectedCloseDate?: string;
|
|
35
|
+
|
|
36
|
+
@ApiPropertyOptional()
|
|
37
|
+
@IsOptional()
|
|
38
|
+
@IsString({ message: 'Las notas deben ser un texto' })
|
|
39
|
+
notes?: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
export class QueryDealDto {
|
|
6
|
+
@ApiPropertyOptional()
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@IsString({ message: 'La búsqueda debe ser un texto' })
|
|
9
|
+
search?: string;
|
|
10
|
+
|
|
11
|
+
@ApiPropertyOptional({ enum: ['lead', 'contacted', 'proposal', 'won', 'lost'] })
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@IsEnum(['lead', 'contacted', 'proposal', 'won', 'lost'], { message: 'El stage no es válido' })
|
|
14
|
+
stage?: 'lead' | 'contacted' | 'proposal' | 'won' | 'lost';
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional()
|
|
17
|
+
@IsOptional()
|
|
18
|
+
@IsString({ message: 'El clientId debe ser un texto' })
|
|
19
|
+
clientId?: string;
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@Type(() => Number)
|
|
24
|
+
@IsInt({ message: 'La página debe ser un número entero' })
|
|
25
|
+
@Min(1, { message: 'La página mínima es 1' })
|
|
26
|
+
page?: number = 1;
|
|
27
|
+
|
|
28
|
+
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
|
29
|
+
@IsOptional()
|
|
30
|
+
@Type(() => Number)
|
|
31
|
+
@IsInt({ message: 'El límite debe ser un número entero' })
|
|
32
|
+
@Min(1, { message: 'El límite mínimo es 1' })
|
|
33
|
+
@Max(100, { message: 'El límite máximo es 100' })
|
|
34
|
+
limit?: number = 20;
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Delete,
|
|
5
|
+
Get,
|
|
6
|
+
HttpCode,
|
|
7
|
+
HttpStatus,
|
|
8
|
+
Param,
|
|
9
|
+
Patch,
|
|
10
|
+
Post,
|
|
11
|
+
Query,
|
|
12
|
+
UseGuards,
|
|
13
|
+
} from '@nestjs/common';
|
|
14
|
+
import {
|
|
15
|
+
ApiBearerAuth,
|
|
16
|
+
ApiOperation,
|
|
17
|
+
ApiResponse,
|
|
18
|
+
ApiTags,
|
|
19
|
+
} from '@nestjs/swagger';
|
|
20
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
21
|
+
import type { TokenPayload } from '../../common/decorators/current-user.decorator';
|
|
22
|
+
import { WorkspaceId } from '../../common/decorators/workspace-id.decorator';
|
|
23
|
+
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
24
|
+
import { CreateDealDto } from './dto/create-deal.dto';
|
|
25
|
+
import { QueryDealDto } from './dto/query-deal.dto';
|
|
26
|
+
import { UpdateDealDto } from './dto/update-deal.dto';
|
|
27
|
+
import { PipelineService } from './pipeline.service';
|
|
28
|
+
|
|
29
|
+
@ApiTags('Pipeline')
|
|
30
|
+
@ApiBearerAuth()
|
|
31
|
+
@UseGuards(JwtAuthGuard)
|
|
32
|
+
@Controller('pipeline')
|
|
33
|
+
export class PipelineController {
|
|
34
|
+
constructor(private readonly pipelineService: PipelineService) {}
|
|
35
|
+
|
|
36
|
+
@Get()
|
|
37
|
+
@ApiOperation({ summary: 'Listar deals del workspace' })
|
|
38
|
+
@ApiResponse({ status: 200, description: 'Lista paginada de deals' })
|
|
39
|
+
findAll(
|
|
40
|
+
@WorkspaceId() workspaceId: string,
|
|
41
|
+
@Query() query: QueryDealDto,
|
|
42
|
+
) {
|
|
43
|
+
return this.pipelineService.findAll(workspaceId, query);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Get(':id')
|
|
47
|
+
@ApiOperation({ summary: 'Obtener un deal por ID' })
|
|
48
|
+
@ApiResponse({ status: 200, description: 'Deal encontrado' })
|
|
49
|
+
@ApiResponse({ status: 404, description: 'Deal no encontrado' })
|
|
50
|
+
findOne(
|
|
51
|
+
@WorkspaceId() workspaceId: string,
|
|
52
|
+
@Param('id') id: string,
|
|
53
|
+
) {
|
|
54
|
+
return this.pipelineService.findOne(workspaceId, id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Post()
|
|
58
|
+
@ApiOperation({ summary: 'Crear un nuevo deal' })
|
|
59
|
+
@ApiResponse({ status: 201, description: 'Deal creado' })
|
|
60
|
+
create(
|
|
61
|
+
@WorkspaceId() workspaceId: string,
|
|
62
|
+
@CurrentUser() user: TokenPayload,
|
|
63
|
+
@Body() dto: CreateDealDto,
|
|
64
|
+
) {
|
|
65
|
+
return this.pipelineService.create(workspaceId, user.userId, dto);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Patch(':id')
|
|
69
|
+
@ApiOperation({ summary: 'Actualizar un deal' })
|
|
70
|
+
@ApiResponse({ status: 200, description: 'Deal actualizado' })
|
|
71
|
+
@ApiResponse({ status: 404, description: 'Deal no encontrado' })
|
|
72
|
+
update(
|
|
73
|
+
@WorkspaceId() workspaceId: string,
|
|
74
|
+
@Param('id') id: string,
|
|
75
|
+
@Body() dto: UpdateDealDto,
|
|
76
|
+
) {
|
|
77
|
+
return this.pipelineService.update(workspaceId, id, dto);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Delete(':id')
|
|
81
|
+
@HttpCode(HttpStatus.NO_CONTENT)
|
|
82
|
+
@ApiOperation({ summary: 'Eliminar un deal (soft delete)' })
|
|
83
|
+
@ApiResponse({ status: 204, description: 'Deal eliminado' })
|
|
84
|
+
@ApiResponse({ status: 404, description: 'Deal no encontrado' })
|
|
85
|
+
remove(
|
|
86
|
+
@WorkspaceId() workspaceId: string,
|
|
87
|
+
@Param('id') id: string,
|
|
88
|
+
) {
|
|
89
|
+
return this.pipelineService.remove(workspaceId, id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { ClientsModule } from '../clients/clients.module';
|
|
4
|
+
import { Deal, DealSchema } from './schemas/deal.schema';
|
|
5
|
+
import { PipelineController } from './pipeline.controller';
|
|
6
|
+
import { PipelineRepository } from './pipeline.repository';
|
|
7
|
+
import { PipelineService } from './pipeline.service';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [
|
|
11
|
+
ClientsModule,
|
|
12
|
+
MongooseModule.forFeature([{ name: Deal.name, schema: DealSchema }]),
|
|
13
|
+
],
|
|
14
|
+
controllers: [PipelineController],
|
|
15
|
+
providers: [PipelineService, PipelineRepository],
|
|
16
|
+
exports: [PipelineService],
|
|
17
|
+
})
|
|
18
|
+
export class PipelineModule {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Model } from 'mongoose';
|
|
4
|
+
import { BaseRepository } from '../../common/base/base.repository';
|
|
5
|
+
import { Deal, DealDocument } from './schemas/deal.schema';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class PipelineRepository extends BaseRepository<DealDocument> {
|
|
9
|
+
constructor(
|
|
10
|
+
@InjectModel(Deal.name) private readonly dealModel: Model<DealDocument>,
|
|
11
|
+
) {
|
|
12
|
+
super(dealModel);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { ClientsService } from '../clients/clients.service';
|
|
3
|
+
import { CreateDealDto } from './dto/create-deal.dto';
|
|
4
|
+
import { QueryDealDto } from './dto/query-deal.dto';
|
|
5
|
+
import { UpdateDealDto } from './dto/update-deal.dto';
|
|
6
|
+
import { PipelineRepository } from './pipeline.repository';
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class PipelineService {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly pipelineRepository: PipelineRepository,
|
|
12
|
+
private readonly clientsService: ClientsService,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
findAll(workspaceId: string, query: QueryDealDto) {
|
|
16
|
+
const { page, limit, search, stage, clientId } = query;
|
|
17
|
+
const filter: Record<string, unknown> = {};
|
|
18
|
+
|
|
19
|
+
if (stage) filter.stage = stage;
|
|
20
|
+
if (clientId) filter.clientId = clientId;
|
|
21
|
+
if (search) filter.title = { $regex: search, $options: 'i' };
|
|
22
|
+
|
|
23
|
+
return this.pipelineRepository.paginate(workspaceId, {
|
|
24
|
+
page,
|
|
25
|
+
limit,
|
|
26
|
+
filter,
|
|
27
|
+
sort: { createdAt: -1 },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async findOne(workspaceId: string, id: string) {
|
|
32
|
+
const deal = await this.pipelineRepository.findById(workspaceId, id);
|
|
33
|
+
if (!deal) throw new NotFoundException('Deal no encontrado');
|
|
34
|
+
return deal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async create(workspaceId: string, userId: string, dto: CreateDealDto) {
|
|
38
|
+
if (dto.clientId) {
|
|
39
|
+
await this.clientsService.findOne(workspaceId, dto.clientId);
|
|
40
|
+
}
|
|
41
|
+
return this.pipelineRepository.create({
|
|
42
|
+
...dto,
|
|
43
|
+
expectedCloseDate: dto.expectedCloseDate ? new Date(dto.expectedCloseDate) : undefined,
|
|
44
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
45
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
46
|
+
deletedAt: null,
|
|
47
|
+
} as never);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async update(workspaceId: string, id: string, dto: UpdateDealDto) {
|
|
51
|
+
await this.findOne(workspaceId, id);
|
|
52
|
+
if (dto.clientId) {
|
|
53
|
+
await this.clientsService.findOne(workspaceId, dto.clientId);
|
|
54
|
+
}
|
|
55
|
+
const deal = await this.pipelineRepository.update(workspaceId, id, dto as never);
|
|
56
|
+
if (!deal) throw new NotFoundException('Deal no encontrado');
|
|
57
|
+
return deal;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async remove(workspaceId: string, id: string) {
|
|
61
|
+
await this.findOne(workspaceId, id);
|
|
62
|
+
await this.pipelineRepository.softDelete(workspaceId, id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
|
2
|
+
import { HydratedDocument } from 'mongoose';
|
|
3
|
+
import { BaseSchema } from '../../../common/base/base.schema';
|
|
4
|
+
|
|
5
|
+
export type DealDocument = HydratedDocument<Deal>;
|
|
6
|
+
|
|
7
|
+
@Schema({ collection: 'deals', timestamps: true })
|
|
8
|
+
export class Deal extends BaseSchema {
|
|
9
|
+
@Prop({ required: true, trim: true, maxlength: 200 })
|
|
10
|
+
title!: string;
|
|
11
|
+
|
|
12
|
+
@Prop()
|
|
13
|
+
clientId!: string;
|
|
14
|
+
|
|
15
|
+
@Prop({ default: 0 })
|
|
16
|
+
value!: number;
|
|
17
|
+
|
|
18
|
+
@Prop({ default: 'USD', trim: true, uppercase: true })
|
|
19
|
+
currency!: string;
|
|
20
|
+
|
|
21
|
+
@Prop({
|
|
22
|
+
type: String,
|
|
23
|
+
enum: ['lead', 'contacted', 'proposal', 'won', 'lost'],
|
|
24
|
+
default: 'lead',
|
|
25
|
+
})
|
|
26
|
+
stage!: 'lead' | 'contacted' | 'proposal' | 'won' | 'lost';
|
|
27
|
+
|
|
28
|
+
@Prop({ type: Date })
|
|
29
|
+
expectedCloseDate!: Date;
|
|
30
|
+
|
|
31
|
+
@Prop({ trim: true })
|
|
32
|
+
notes!: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DealSchema = SchemaFactory.createForClass(Deal);
|
|
36
|
+
|
|
37
|
+
DealSchema.index({ workspaceId: 1, stage: 1 });
|
|
38
|
+
DealSchema.index({ workspaceId: 1, clientId: 1 });
|
|
39
|
+
DealSchema.index({ workspaceId: 1, createdAt: -1 });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class CreatePlannerBlockDto {
|
|
5
|
+
@ApiProperty({ example: '2026-06-15', description: 'Fecha en formato YYYY-MM-DD' })
|
|
6
|
+
@IsString({ message: 'La fecha debe ser un texto' })
|
|
7
|
+
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'La fecha debe tener el formato YYYY-MM-DD' })
|
|
8
|
+
date!: string;
|
|
9
|
+
|
|
10
|
+
@ApiProperty({ example: '09:00', description: 'Hora de inicio en formato HH:mm' })
|
|
11
|
+
@IsString({ message: 'La hora de inicio debe ser un texto' })
|
|
12
|
+
@Matches(/^\d{2}:\d{2}$/, { message: 'La hora de inicio debe tener el formato HH:mm' })
|
|
13
|
+
startTime!: string;
|
|
14
|
+
|
|
15
|
+
@ApiProperty({ example: '10:30', description: 'Hora de fin en formato HH:mm' })
|
|
16
|
+
@IsString({ message: 'La hora de fin debe ser un texto' })
|
|
17
|
+
@Matches(/^\d{2}:\d{2}$/, { message: 'La hora de fin debe tener el formato HH:mm' })
|
|
18
|
+
endTime!: string;
|
|
19
|
+
|
|
20
|
+
@ApiProperty({ example: 'Reunión con cliente', maxLength: 120 })
|
|
21
|
+
@IsString({ message: 'El título debe ser un texto' })
|
|
22
|
+
@MaxLength(120, { message: 'El título no puede superar los 120 caracteres' })
|
|
23
|
+
title!: string;
|
|
24
|
+
|
|
25
|
+
@ApiPropertyOptional()
|
|
26
|
+
@IsOptional()
|
|
27
|
+
@IsString({ message: 'La descripción debe ser un texto' })
|
|
28
|
+
description?: string;
|
|
29
|
+
|
|
30
|
+
@ApiPropertyOptional({ example: 'work', default: 'work' })
|
|
31
|
+
@IsOptional()
|
|
32
|
+
@IsString({ message: 'La categoría debe ser un texto' })
|
|
33
|
+
category?: string;
|
|
34
|
+
|
|
35
|
+
@ApiPropertyOptional({ enum: ['low', 'medium', 'high'], default: 'medium' })
|
|
36
|
+
@IsOptional()
|
|
37
|
+
@IsEnum(['low', 'medium', 'high'], { message: 'La prioridad no es válida' })
|
|
38
|
+
priority?: 'low' | 'medium' | 'high';
|
|
39
|
+
|
|
40
|
+
@ApiPropertyOptional({ enum: ['pending', 'in-progress', 'completed', 'skipped'], default: 'pending' })
|
|
41
|
+
@IsOptional()
|
|
42
|
+
@IsEnum(['pending', 'in-progress', 'completed', 'skipped'], { message: 'El estado no es válido' })
|
|
43
|
+
status?: 'pending' | 'in-progress' | 'completed' | 'skipped';
|
|
44
|
+
|
|
45
|
+
@ApiPropertyOptional({ example: '#2563EB' })
|
|
46
|
+
@IsOptional()
|
|
47
|
+
@IsString({ message: 'El color debe ser un texto' })
|
|
48
|
+
color?: string;
|
|
49
|
+
|
|
50
|
+
@ApiPropertyOptional({ default: false })
|
|
51
|
+
@IsOptional()
|
|
52
|
+
@IsBoolean({ message: 'isFocusBlock debe ser un booleano' })
|
|
53
|
+
isFocusBlock?: boolean;
|
|
54
|
+
|
|
55
|
+
@ApiPropertyOptional({ example: 0, minimum: 0 })
|
|
56
|
+
@IsOptional()
|
|
57
|
+
@IsInt({ message: 'El orden debe ser un número entero' })
|
|
58
|
+
@Min(0, { message: 'El orden no puede ser negativo' })
|
|
59
|
+
order?: number;
|
|
60
|
+
|
|
61
|
+
@ApiPropertyOptional({ type: [String] })
|
|
62
|
+
@IsOptional()
|
|
63
|
+
@IsArray({ message: 'Los tags deben ser un array' })
|
|
64
|
+
@IsString({ each: true, message: 'Cada tag debe ser un texto' })
|
|
65
|
+
tags?: string[];
|
|
66
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { IsEnum, IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
export class QueryPlannerBlockDto {
|
|
6
|
+
@ApiPropertyOptional({ example: '2026-06-15', description: 'Fecha exacta YYYY-MM-DD' })
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@IsString({ message: 'La fecha debe ser un texto' })
|
|
9
|
+
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'La fecha debe tener el formato YYYY-MM-DD' })
|
|
10
|
+
date?: string;
|
|
11
|
+
|
|
12
|
+
@ApiPropertyOptional({ example: '2026-06-01' })
|
|
13
|
+
@IsOptional()
|
|
14
|
+
@IsString({ message: 'La fecha desde debe ser un texto' })
|
|
15
|
+
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'La fecha desde debe tener el formato YYYY-MM-DD' })
|
|
16
|
+
dateFrom?: string;
|
|
17
|
+
|
|
18
|
+
@ApiPropertyOptional({ example: '2026-06-30' })
|
|
19
|
+
@IsOptional()
|
|
20
|
+
@IsString({ message: 'La fecha hasta debe ser un texto' })
|
|
21
|
+
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'La fecha hasta debe tener el formato YYYY-MM-DD' })
|
|
22
|
+
dateTo?: string;
|
|
23
|
+
|
|
24
|
+
@ApiPropertyOptional({ enum: ['pending', 'in-progress', 'completed', 'skipped'] })
|
|
25
|
+
@IsOptional()
|
|
26
|
+
@IsEnum(['pending', 'in-progress', 'completed', 'skipped'], { message: 'El estado no es válido' })
|
|
27
|
+
status?: 'pending' | 'in-progress' | 'completed' | 'skipped';
|
|
28
|
+
|
|
29
|
+
@ApiPropertyOptional()
|
|
30
|
+
@IsOptional()
|
|
31
|
+
@IsString({ message: 'La categoría debe ser un texto' })
|
|
32
|
+
category?: string;
|
|
33
|
+
|
|
34
|
+
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
|
35
|
+
@IsOptional()
|
|
36
|
+
@Type(() => Number)
|
|
37
|
+
@IsInt({ message: 'La página debe ser un número entero' })
|
|
38
|
+
@Min(1, { message: 'La página mínima es 1' })
|
|
39
|
+
page?: number = 1;
|
|
40
|
+
|
|
41
|
+
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
|
42
|
+
@IsOptional()
|
|
43
|
+
@Type(() => Number)
|
|
44
|
+
@IsInt({ message: 'El límite debe ser un número entero' })
|
|
45
|
+
@Min(1, { message: 'El límite mínimo es 1' })
|
|
46
|
+
@Max(200, { message: 'El límite máximo es 200' })
|
|
47
|
+
limit?: number = 50;
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsEnum } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class UpdateBlockStatusDto {
|
|
5
|
+
@ApiProperty({ enum: ['pending', 'in-progress', 'completed', 'skipped'] })
|
|
6
|
+
@IsEnum(['pending', 'in-progress', 'completed', 'skipped'], {
|
|
7
|
+
message: 'El estado debe ser "pending", "in-progress", "completed" o "skipped"',
|
|
8
|
+
})
|
|
9
|
+
status!: 'pending' | 'in-progress' | 'completed' | 'skipped';
|
|
10
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Delete,
|
|
5
|
+
Get,
|
|
6
|
+
HttpCode,
|
|
7
|
+
HttpStatus,
|
|
8
|
+
Param,
|
|
9
|
+
Patch,
|
|
10
|
+
Post,
|
|
11
|
+
Query,
|
|
12
|
+
UseGuards,
|
|
13
|
+
} from '@nestjs/common';
|
|
14
|
+
import {
|
|
15
|
+
ApiBearerAuth,
|
|
16
|
+
ApiOperation,
|
|
17
|
+
ApiQuery,
|
|
18
|
+
ApiResponse,
|
|
19
|
+
ApiTags,
|
|
20
|
+
} from '@nestjs/swagger';
|
|
21
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
22
|
+
import type { TokenPayload } from '../../common/decorators/current-user.decorator';
|
|
23
|
+
import { WorkspaceId } from '../../common/decorators/workspace-id.decorator';
|
|
24
|
+
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
25
|
+
import { CreatePlannerBlockDto } from './dto/create-planner-block.dto';
|
|
26
|
+
import { QueryPlannerBlockDto } from './dto/query-planner-block.dto';
|
|
27
|
+
import { UpdateBlockStatusDto } from './dto/update-block-status.dto';
|
|
28
|
+
import { UpdatePlannerBlockDto } from './dto/update-planner-block.dto';
|
|
29
|
+
import { PlannerService } from './planner.service';
|
|
30
|
+
|
|
31
|
+
@ApiTags('Planner Blocks')
|
|
32
|
+
@ApiBearerAuth()
|
|
33
|
+
@UseGuards(JwtAuthGuard)
|
|
34
|
+
@Controller('planner-blocks')
|
|
35
|
+
export class PlannerController {
|
|
36
|
+
constructor(private readonly plannerService: PlannerService) {}
|
|
37
|
+
|
|
38
|
+
@Get()
|
|
39
|
+
@ApiOperation({ summary: 'Listar bloques del planner (por fecha, rango, estado)' })
|
|
40
|
+
@ApiResponse({ status: 200, description: 'Lista de bloques' })
|
|
41
|
+
findAll(
|
|
42
|
+
@WorkspaceId() workspaceId: string,
|
|
43
|
+
@CurrentUser() user: TokenPayload,
|
|
44
|
+
@Query() query: QueryPlannerBlockDto,
|
|
45
|
+
) {
|
|
46
|
+
return this.plannerService.findAll(workspaceId, user.userId, query);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Get(':id')
|
|
50
|
+
@ApiOperation({ summary: 'Obtener un bloque por ID' })
|
|
51
|
+
@ApiResponse({ status: 200, description: 'Bloque encontrado' })
|
|
52
|
+
@ApiResponse({ status: 404, description: 'Bloque no encontrado' })
|
|
53
|
+
findOne(
|
|
54
|
+
@WorkspaceId() workspaceId: string,
|
|
55
|
+
@CurrentUser() user: TokenPayload,
|
|
56
|
+
@Param('id') id: string,
|
|
57
|
+
) {
|
|
58
|
+
return this.plannerService.findOne(workspaceId, user.userId, id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Post()
|
|
62
|
+
@ApiOperation({ summary: 'Crear un nuevo bloque' })
|
|
63
|
+
@ApiResponse({ status: 201, description: 'Bloque creado' })
|
|
64
|
+
create(
|
|
65
|
+
@WorkspaceId() workspaceId: string,
|
|
66
|
+
@CurrentUser() user: TokenPayload,
|
|
67
|
+
@Body() dto: CreatePlannerBlockDto,
|
|
68
|
+
) {
|
|
69
|
+
return this.plannerService.create(workspaceId, user.userId, dto);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Patch(':id')
|
|
73
|
+
@ApiOperation({ summary: 'Actualizar un bloque' })
|
|
74
|
+
@ApiResponse({ status: 200, description: 'Bloque actualizado' })
|
|
75
|
+
@ApiResponse({ status: 404, description: 'Bloque no encontrado' })
|
|
76
|
+
update(
|
|
77
|
+
@WorkspaceId() workspaceId: string,
|
|
78
|
+
@CurrentUser() user: TokenPayload,
|
|
79
|
+
@Param('id') id: string,
|
|
80
|
+
@Body() dto: UpdatePlannerBlockDto,
|
|
81
|
+
) {
|
|
82
|
+
return this.plannerService.update(workspaceId, user.userId, id, dto);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Patch(':id/status')
|
|
86
|
+
@ApiOperation({ summary: 'Actualizar solo el estado de un bloque' })
|
|
87
|
+
@ApiResponse({ status: 200, description: 'Estado actualizado' })
|
|
88
|
+
@ApiResponse({ status: 404, description: 'Bloque no encontrado' })
|
|
89
|
+
updateStatus(
|
|
90
|
+
@WorkspaceId() workspaceId: string,
|
|
91
|
+
@CurrentUser() user: TokenPayload,
|
|
92
|
+
@Param('id') id: string,
|
|
93
|
+
@Body() dto: UpdateBlockStatusDto,
|
|
94
|
+
) {
|
|
95
|
+
return this.plannerService.updateStatus(workspaceId, user.userId, id, dto);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Post(':id/duplicate')
|
|
99
|
+
@ApiOperation({ summary: 'Duplicar un bloque (opcionalmente en otra fecha)' })
|
|
100
|
+
@ApiQuery({ name: 'targetDate', required: false, example: '2026-06-16' })
|
|
101
|
+
@ApiResponse({ status: 201, description: 'Bloque duplicado' })
|
|
102
|
+
@ApiResponse({ status: 404, description: 'Bloque no encontrado' })
|
|
103
|
+
duplicate(
|
|
104
|
+
@WorkspaceId() workspaceId: string,
|
|
105
|
+
@CurrentUser() user: TokenPayload,
|
|
106
|
+
@Param('id') id: string,
|
|
107
|
+
@Query('targetDate') targetDate?: string,
|
|
108
|
+
) {
|
|
109
|
+
return this.plannerService.duplicate(workspaceId, user.userId, id, targetDate);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Delete(':id')
|
|
113
|
+
@HttpCode(HttpStatus.NO_CONTENT)
|
|
114
|
+
@ApiOperation({ summary: 'Eliminar un bloque (soft delete)' })
|
|
115
|
+
@ApiResponse({ status: 204, description: 'Bloque eliminado' })
|
|
116
|
+
@ApiResponse({ status: 404, description: 'Bloque no encontrado' })
|
|
117
|
+
remove(
|
|
118
|
+
@WorkspaceId() workspaceId: string,
|
|
119
|
+
@CurrentUser() user: TokenPayload,
|
|
120
|
+
@Param('id') id: string,
|
|
121
|
+
) {
|
|
122
|
+
return this.plannerService.remove(workspaceId, user.userId, id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { PlannerBlock, PlannerBlockSchema } from './schemas/planner-block.schema';
|
|
4
|
+
import { PlannerController } from './planner.controller';
|
|
5
|
+
import { PlannerRepository } from './planner.repository';
|
|
6
|
+
import { PlannerService } from './planner.service';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [
|
|
10
|
+
MongooseModule.forFeature([{ name: PlannerBlock.name, schema: PlannerBlockSchema }]),
|
|
11
|
+
],
|
|
12
|
+
controllers: [PlannerController],
|
|
13
|
+
providers: [PlannerService, PlannerRepository],
|
|
14
|
+
exports: [PlannerService],
|
|
15
|
+
})
|
|
16
|
+
export class PlannerModule {}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Model } from 'mongoose';
|
|
4
|
+
import { BaseRepository } from '../../common/base/base.repository';
|
|
5
|
+
import { PlannerBlock, PlannerBlockDocument } from './schemas/planner-block.schema';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class PlannerRepository extends BaseRepository<PlannerBlockDocument> {
|
|
9
|
+
constructor(
|
|
10
|
+
@InjectModel(PlannerBlock.name)
|
|
11
|
+
private readonly plannerBlockModel: Model<PlannerBlockDocument>,
|
|
12
|
+
) {
|
|
13
|
+
super(plannerBlockModel);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async findByDate(
|
|
17
|
+
workspaceId: string,
|
|
18
|
+
userId: string,
|
|
19
|
+
date: string,
|
|
20
|
+
): Promise<PlannerBlockDocument[]> {
|
|
21
|
+
return this.plannerBlockModel
|
|
22
|
+
.find({ workspaceId, userId, date, deletedAt: null })
|
|
23
|
+
.sort({ order: 1 })
|
|
24
|
+
.lean()
|
|
25
|
+
.exec() as unknown as PlannerBlockDocument[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async findByDateRange(
|
|
29
|
+
workspaceId: string,
|
|
30
|
+
userId: string,
|
|
31
|
+
dateFrom: string,
|
|
32
|
+
dateTo: string,
|
|
33
|
+
): Promise<PlannerBlockDocument[]> {
|
|
34
|
+
return this.plannerBlockModel
|
|
35
|
+
.find({
|
|
36
|
+
workspaceId,
|
|
37
|
+
userId,
|
|
38
|
+
date: { $gte: dateFrom, $lte: dateTo },
|
|
39
|
+
deletedAt: null,
|
|
40
|
+
})
|
|
41
|
+
.sort({ date: 1, order: 1 })
|
|
42
|
+
.lean()
|
|
43
|
+
.exec() as unknown as PlannerBlockDocument[];
|
|
44
|
+
}
|
|
45
|
+
}
|