diff --git a/src/modules/admin/admin-access.controller.ts b/src/modules/admin/admin-access.controller.ts index badb8cf..2fa64e3 100644 --- a/src/modules/admin/admin-access.controller.ts +++ b/src/modules/admin/admin-access.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { AdminAccessService } from './admin-access.service'; @Controller('admin/access') @@ -10,6 +10,29 @@ export class AdminAccessController { return this.adminAccessService.getOptions(); } + @Get('overview') + getOverview() { + return this.adminAccessService.getOverview(); + } + + @Get('areas') + listAreas() { + return this.adminAccessService.listAreas(); + } + + @Post('areas') + createArea(@Body() body: { nome: string; descricao?: string | null; responsavelUsuarioId?: number | null }) { + return this.adminAccessService.createArea(body); + } + + @Put('areas/:id') + updateArea( + @Param('id') id: string, + @Body() body: { nome?: string; descricao?: string | null; responsavelUsuarioId?: number | null; ativo?: boolean }, + ) { + return this.adminAccessService.updateArea(Number(id), body); + } + @Get('users') listUsers() { return this.adminAccessService.listUsers(); diff --git a/src/modules/admin/admin-access.service.ts b/src/modules/admin/admin-access.service.ts index d90a3da..3b9b7f2 100644 --- a/src/modules/admin/admin-access.service.ts +++ b/src/modules/admin/admin-access.service.ts @@ -6,6 +6,13 @@ interface AccessUpdateInput { areaId?: number | null; } +interface AreaInput { + nome?: string; + descricao?: string | null; + responsavelUsuarioId?: number | null; + ativo?: boolean; +} + @Injectable() export class AdminAccessService { constructor(private readonly database: DatabaseService) {} @@ -26,6 +33,115 @@ export class AdminAccessService { }; } + async getOverview() { + const [ + currentMonth, + previousMonth, + activeUsers, + totalUsers, + channels, + avgHandling, + avgFirstResponse, + ] = await Promise.all([ + this.database.query<{ total: string }>( + ` + SELECT COUNT(*)::TEXT AS total + FROM whatsapp_chat_atribuicoes + WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ), + this.database.query<{ total: string }>( + ` + SELECT COUNT(*)::TEXT AS total + FROM whatsapp_chat_atribuicoes + WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) - INTERVAL '1 month' + AND conversation_started_at < date_trunc('month', CURRENT_DATE) + `, + ), + this.database.query<{ total: string }>( + ` + SELECT COUNT(DISTINCT user_id)::TEXT AS total + FROM whatsapp_chat_atribuicoes + WHERE user_id IS NOT NULL + AND conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ), + this.database.query<{ total: string }>('SELECT COUNT(*)::TEXT AS total FROM usuarios WHERE ativo = TRUE'), + this.database.query<{ whatsapp: string; email: string; sms: string }>( + ` + SELECT + COUNT(*)::TEXT AS whatsapp, + '0'::TEXT AS email, + '0'::TEXT AS sms + FROM whatsapp_chat_atribuicoes + WHERE conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ), + this.database.query<{ minutes: string | null }>( + ` + SELECT ROUND(AVG(EXTRACT(EPOCH FROM (updated_at - assigned_at))) / 60)::TEXT AS minutes + FROM whatsapp_chat_atribuicoes + WHERE assigned_at IS NOT NULL + AND updated_at IS NOT NULL + AND status IN ('assigned', 'queued', 'expired') + AND conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ), + this.database.query<{ minutes: string | null }>( + ` + SELECT ROUND(AVG(EXTRACT(EPOCH FROM (assigned_at - conversation_started_at))) / 60)::TEXT AS minutes + FROM whatsapp_chat_atribuicoes + WHERE assigned_at IS NOT NULL + AND conversation_started_at IS NOT NULL + AND conversation_started_at >= date_trunc('month', CURRENT_DATE) + `, + ), + ]); + + const currentTotal = Number(currentMonth.rows[0]?.total || 0); + const previousTotal = Number(previousMonth.rows[0]?.total || 0); + const variation = previousTotal + ? Math.round(((currentTotal - previousTotal) / previousTotal) * 100) + : null; + + return { + totalAttendances: currentTotal, + previousMonthVariation: variation, + activeAttendants: Number(activeUsers.rows[0]?.total || 0), + totalActiveUsers: Number(totalUsers.rows[0]?.total || 0), + channels: { + whatsapp: Number(channels.rows[0]?.whatsapp || 0), + email: Number(channels.rows[0]?.email || 0), + sms: Number(channels.rows[0]?.sms || 0), + }, + avgHandlingMinutes: avgHandling.rows[0]?.minutes ? Number(avgHandling.rows[0].minutes) : null, + avgFirstResponseMinutes: avgFirstResponse.rows[0]?.minutes ? Number(avgFirstResponse.rows[0].minutes) : null, + satisfactionRate: null, + }; + } + + async listAreas() { + const result = await this.database.query( + ` + SELECT + a.id, + a.nome, + a.descricao, + a.ativo, + a.responsavel_usuario_id, + r.nome AS responsavel_nome, + COUNT(DISTINCT ua.usuario_id)::INTEGER AS members + FROM areas a + LEFT JOIN usuarios r ON r.id = a.responsavel_usuario_id + LEFT JOIN usuarios_areas ua ON ua.area_id = a.id AND ua.ativo = TRUE + GROUP BY a.id, r.nome + ORDER BY a.nome + `, + ); + + return result.rows; + } + async listUsers() { const result = await this.database.query( ` @@ -100,4 +216,86 @@ export class AdminAccessService { return this.listUsers().then((users) => users.find((user) => user.id === usuarioId)); } + + async createArea(input: AreaInput) { + const nome = String(input.nome || '').trim(); + if (!nome) { + throw new Error('Nome da area e obrigatorio'); + } + + const result = await this.database.query( + ` + INSERT INTO areas (nome, descricao, responsavel_usuario_id, ativo, created_at, updated_at) + VALUES ($1, $2, $3, TRUE, NOW(), NOW()) + RETURNING * + `, + [nome, this.normalizeText(input.descricao), input.responsavelUsuarioId || null], + ); + + if (input.responsavelUsuarioId) { + await this.ensureAreaSupervisor(input.responsavelUsuarioId, result.rows[0].id); + } + + return this.listAreas(); + } + + async updateArea(areaId: number, input: AreaInput) { + const result = await this.database.query( + ` + UPDATE areas + SET + nome = COALESCE($2, nome), + descricao = $3, + responsavel_usuario_id = $4, + ativo = COALESCE($5, ativo), + updated_at = NOW() + WHERE id = $1 + RETURNING * + `, + [ + areaId, + input.nome ? String(input.nome).trim() : null, + this.normalizeText(input.descricao), + input.responsavelUsuarioId || null, + typeof input.ativo === 'boolean' ? input.ativo : null, + ], + ); + + if (input.responsavelUsuarioId && result.rows[0]) { + await this.ensureAreaSupervisor(input.responsavelUsuarioId, areaId); + } + + return this.listAreas(); + } + + private async ensureAreaSupervisor(usuarioId: number, areaId: number) { + const supervisorProfile = await this.database.query<{ id: number }>( + `SELECT id FROM perfis_acesso WHERE nome = 'Supervisor' LIMIT 1`, + ); + const perfilId = supervisorProfile.rows[0]?.id; + + await this.database.transaction(async (client) => { + if (perfilId) { + await client.query( + `INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [usuarioId, perfilId], + ); + } + + await client.query( + ` + INSERT INTO usuarios_areas (usuario_id, area_id, funcao, principal, ativo) + VALUES ($1, $2, 'Supervisor', TRUE, TRUE) + ON CONFLICT (usuario_id, area_id) + DO UPDATE SET funcao = 'Supervisor', principal = TRUE, ativo = TRUE, updated_at = NOW() + `, + [usuarioId, areaId], + ); + }); + } + + private normalizeText(value?: string | null) { + const text = String(value || '').trim(); + return text || null; + } }