FEAT: adiciona endpoints administrativos de overview e áreas
All checks were successful
Deploy Dev / deploy (push) Successful in 3s
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:
parent
da17cbda2d
commit
babe525154
@ -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';
|
import { AdminAccessService } from './admin-access.service';
|
||||||
|
|
||||||
@Controller('admin/access')
|
@Controller('admin/access')
|
||||||
@ -10,6 +10,29 @@ export class AdminAccessController {
|
|||||||
return this.adminAccessService.getOptions();
|
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')
|
@Get('users')
|
||||||
listUsers() {
|
listUsers() {
|
||||||
return this.adminAccessService.listUsers();
|
return this.adminAccessService.listUsers();
|
||||||
|
|||||||
@ -6,6 +6,13 @@ interface AccessUpdateInput {
|
|||||||
areaId?: number | null;
|
areaId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AreaInput {
|
||||||
|
nome?: string;
|
||||||
|
descricao?: string | null;
|
||||||
|
responsavelUsuarioId?: number | null;
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminAccessService {
|
export class AdminAccessService {
|
||||||
constructor(private readonly database: DatabaseService) {}
|
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() {
|
async listUsers() {
|
||||||
const result = await this.database.query(
|
const result = await this.database.query(
|
||||||
`
|
`
|
||||||
@ -100,4 +216,86 @@ export class AdminAccessService {
|
|||||||
|
|
||||||
return this.listUsers().then((users) => users.find((user) => user.id === usuarioId));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user