FEAT: adiciona endpoints administrativos de overview e áreas
All checks were successful
Deploy Dev / deploy (push) Successful in 3s

- cria endpoint de overview com métricas reais do mês
- adiciona listagem de áreas com responsável e total de usuários
- permite criar novas áreas
- permite alterar responsável de uma área
- promove responsável a supervisor da área automaticamente
This commit is contained in:
Rafael Alves Lopes 2026-05-21 12:07:00 -03:00
parent da17cbda2d
commit babe525154
2 changed files with 222 additions and 1 deletions

View File

@ -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();

View File

@ -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;
}
}