Initial commit: HelpDesk ICHN - .NET 10 backend + Next.js 16 frontend
This commit is contained in:
commit
359f0caf85
90 files changed
+13395
No files matched your search
+41
@@ -0,0 +1,41 @@
|
||||
# Build results
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
*.cache
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
|
||||
# NuGet
|
||||
packages/
|
||||
*.nupkg
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.sln.docstates
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Docker
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -0,0 +1,176 @@
|
||||
# HelpDesk ICHN
|
||||
|
||||
Sistema de HelpDesk inspirado en Freshdesk/Zammad, construido con .NET 10 + Next.js 15 + PostgreSQL.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
helpdesk/
|
||||
├── backend/ # .NET 10 API
|
||||
│ ├── HelpDesk.API/ # Web API (controllers, services, middleware)
|
||||
│ ├── HelpDesk.Data/ # EF Core DbContext + Repositories
|
||||
│ ├── HelpDesk.Models/ # Entities, Enums, DTOs
|
||||
│ └── HelpDesk.sln
|
||||
├── frontend/ # Next.js 15 + Bootstrap 5
|
||||
│ └── src/
|
||||
│ ├── app/ # App Router pages
|
||||
│ │ ├── (auth)/ # Login, Register
|
||||
│ │ └── (app)/ # Dashboard, Tickets, KB, Users
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ └── lib/ # API client, auth context
|
||||
├── gateway/ # Caddy reverse proxy
|
||||
├── scripts/ # DB init scripts
|
||||
├── docker-compose.yml # Full stack orchestration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
| Componente | Tecnología |
|
||||
|--------------------|--------------------------|
|
||||
| Backend API | .NET 10, C# 13 |
|
||||
| ORM | Entity Framework Core 10 |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Cache | Redis 7 |
|
||||
| Frontend | Next.js 15 (App Router) |
|
||||
| UI Framework | Bootstrap 5 + React-Bootstrap |
|
||||
| Authentication | JWT Bearer |
|
||||
| Reverse Proxy | Caddy 2 |
|
||||
| Icons | Bootstrap Icons |
|
||||
| Font | Fira Sans (matching SAM) |
|
||||
|
||||
## Paleta de Colores (SAM)
|
||||
|
||||
| Variable | Valor | Uso |
|
||||
|----------------|-----------|------------------------------|
|
||||
| Primary | #00285d | Navbar, sidebar, headings |
|
||||
| Primary Light | #1a3f7a | Hover states, gradients |
|
||||
| Gradient | #00285d → #1a4a8a | Sidebar, buttons, headers |
|
||||
| Background | #f0f2f5 | Page background |
|
||||
| Card Shadow | rgba(0,0,0,0.08) | Card elevation |
|
||||
|
||||
## Desarrollo Local
|
||||
|
||||
### Prerequisitos
|
||||
- .NET 10 SDK
|
||||
- Node.js 24+
|
||||
- PostgreSQL 16 (o usar Docker)
|
||||
- Redis (o usar Docker)
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
dotnet restore
|
||||
dotnet run --project HelpDesk.API
|
||||
# API en http://localhost:5000
|
||||
# Swagger en http://localhost:5000/swagger
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# Frontend en http://localhost:3000
|
||||
```
|
||||
|
||||
### Docker (todo junto)
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Caddy: http://localhost:8080
|
||||
# API: http://localhost:5000
|
||||
# Frontend: http://localhost:3000
|
||||
# PostgreSQL: localhost:5433
|
||||
# Redis: localhost:6381
|
||||
```
|
||||
|
||||
## Datos por Defecto
|
||||
|
||||
### Usuario Admin
|
||||
- Email: admin@helpdesk.local
|
||||
- Password: Admin123!
|
||||
- Rol: SuperAdmin
|
||||
|
||||
### Grupos
|
||||
- Soporte General
|
||||
- Ventas
|
||||
- Desarrollo
|
||||
|
||||
### Políticas SLA
|
||||
- Estándar: Primera respuesta 4h, Resolución 24h
|
||||
- Premium: Primera respuesta 1h, Resolución 8h
|
||||
|
||||
## Endpoints API Principales
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/login` - Iniciar sesión
|
||||
- `POST /api/auth/register` - Registrar usuario
|
||||
- `GET /api/auth/me` - Usuario actual
|
||||
|
||||
### Tickets
|
||||
- `GET /api/tickets` - Listar tickets (filtros: status, priority, assigneeId, search)
|
||||
- `GET /api/tickets/{id}` - Detalle de ticket
|
||||
- `POST /api/tickets` - Crear ticket
|
||||
- `PUT /api/tickets/{id}` - Actualizar ticket
|
||||
- `DELETE /api/tickets/{id}` - Eliminar ticket
|
||||
- `PUT /api/tickets/{id}/status` - Cambiar estado
|
||||
- `PUT /api/tickets/{id}/assign` - Asignar agente
|
||||
- `POST /api/tickets/{id}/comments` - Agregar comentario
|
||||
- `GET /api/tickets/stats` - Estadísticas
|
||||
|
||||
### Knowledge Base
|
||||
- `GET /api/kb/articles/published` - Artículos publicados
|
||||
- `GET /api/kb/search?q=termo` - Buscar artículos
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/dashboard/stats` - Estadísticas del dashboard
|
||||
|
||||
### Users
|
||||
- `GET /api/users` - Listar usuarios
|
||||
- `GET /api/users/agents` - Listar agentes
|
||||
|
||||
### Groups
|
||||
- `GET /api/groups` - Listar grupos
|
||||
|
||||
### SLA
|
||||
- `GET /api/sla` - Listar políticas SLA
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### Gestión de Tickets
|
||||
- Crear, editar, eliminar tickets
|
||||
- Estados: Abierto → En Progreso → Pendiente (Cliente/Agente) → Resuelto → Cerrado
|
||||
- Prioridades: Baja, Media, Alta, Urgente
|
||||
- Asignación a agentes y grupos
|
||||
- Comentarios públicos e internos
|
||||
- Historial de actividad
|
||||
- Número de ticket automático (#XXXX)
|
||||
|
||||
### Dashboard
|
||||
- Resumen de tickets por estado
|
||||
- Distribución por prioridad
|
||||
- Tiempo promedio de respuesta
|
||||
- Carga de trabajo por agente
|
||||
- Tickets recientes
|
||||
|
||||
### Knowledge Base
|
||||
- Artículos organizados por categorías
|
||||
- Búsqueda de artículos
|
||||
- Contador de vistas
|
||||
- Publicar/ocultar artículos
|
||||
|
||||
### Autenticación
|
||||
- JWT Bearer tokens
|
||||
- Roles: SuperAdmin, Admin, Agente, Cliente
|
||||
- Registro de nuevos usuarios
|
||||
- Políticas de autorización por rol
|
||||
|
||||
## Diseño
|
||||
|
||||
Inspirado en la interfaz de SAM (sam.norteamericano.cl):
|
||||
- Sidebar azul oscuro con gradiente
|
||||
- Tarjetas blancas con sombras suaves
|
||||
- Fondo gris claro
|
||||
- Tipografía Fira Sans
|
||||
- Badges de color para estados y prioridades
|
||||
- Layout responsive (mobile-first)
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy solution and project files for layer caching
|
||||
COPY backend/HelpDesk.sln backend/HelpDesk.sln
|
||||
COPY backend/HelpDesk.Models/HelpDesk.Models.csproj backend/HelpDesk.Models/
|
||||
COPY backend/HelpDesk.Data/HelpDesk.Data.csproj backend/HelpDesk.Data/
|
||||
COPY backend/HelpDesk.API/HelpDesk.API.csproj backend/HelpDesk.API/
|
||||
RUN dotnet restore backend/HelpDesk.API/HelpDesk.API.csproj
|
||||
|
||||
# Copy everything and build
|
||||
COPY backend/ backend/
|
||||
WORKDIR /src/backend
|
||||
RUN dotnet publish HelpDesk.API/HelpDesk.API.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 5000
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENTRYPOINT ["dotnet", "HelpDesk.API.dll"]
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Security.Claims;
|
||||
using HelpDesk.API.Services;
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
|
||||
public AuthController(IUserRepository userRepository, IJwtService jwtService, IPasswordService passwordService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<AuthResponse>> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest(new { error = "Email and password are required." });
|
||||
|
||||
var user = await _userRepository.GetByEmailAsync(request.Email);
|
||||
if (user == null || !_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
return Unauthorized(new { error = "Invalid email or password." });
|
||||
|
||||
if (!user.IsActive)
|
||||
return Unauthorized(new { error = "Account is deactivated." });
|
||||
|
||||
var token = _jwtService.GenerateToken(user.Id, user.Email, user.Role);
|
||||
|
||||
return Ok(new AuthResponse
|
||||
{
|
||||
Token = token,
|
||||
Expiration = DateTime.UtcNow.AddMinutes(60),
|
||||
User = MapToDto(user)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<AuthResponse>> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest(new { error = "Email and password are required." });
|
||||
if (string.IsNullOrWhiteSpace(request.FirstName) || string.IsNullOrWhiteSpace(request.LastName))
|
||||
return BadRequest(new { error = "First name and last name are required." });
|
||||
if (request.Password.Length < 6)
|
||||
return BadRequest(new { error = "Password must be at least 6 characters." });
|
||||
|
||||
if (await _userRepository.ExistsAsync(request.Email))
|
||||
return Conflict(new { error = "Email is already registered." });
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = request.Email,
|
||||
PasswordHash = _passwordService.HashPassword(request.Password),
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
Role = UserRole.Customer,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _userRepository.CreateAsync(user);
|
||||
var token = _jwtService.GenerateToken(created.Id, created.Email, created.Role);
|
||||
|
||||
return CreatedAtAction(nameof(GetMe), new AuthResponse
|
||||
{
|
||||
Token = token,
|
||||
Expiration = DateTime.UtcNow.AddMinutes(60),
|
||||
User = MapToDto(created)
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<UserDto>> GetMe()
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(userId.Value);
|
||||
if (user == null) return NotFound(new { error = "User not found." });
|
||||
|
||||
return Ok(MapToDto(user));
|
||||
}
|
||||
|
||||
private Guid? GetUserId()
|
||||
{
|
||||
var claim = User.FindFirst("userId") ?? User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static UserDto MapToDto(User user) => new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
Role = user.Role,
|
||||
IsActive = user.IsActive,
|
||||
CreatedAt = user.CreatedAt,
|
||||
PhoneNumber = user.PhoneNumber,
|
||||
AvatarUrl = user.AvatarUrl
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Claims;
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = "Agent")]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly ITicketRepository _ticketRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public DashboardController(ITicketRepository ticketRepository, IUserRepository userRepository)
|
||||
{
|
||||
_ticketRepository = ticketRepository;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats()
|
||||
{
|
||||
var agents = await _userRepository.GetByRoleAsync(UserRole.Agent);
|
||||
var agentWorkloads = new List<AgentWorkloadDto>();
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
var activeTickets = await _ticketRepository.GetCountAsync(
|
||||
assigneeId: agent.Id,
|
||||
status: TicketStatus.Open);
|
||||
|
||||
var inProgressTickets = await _ticketRepository.GetCountAsync(
|
||||
assigneeId: agent.Id,
|
||||
status: TicketStatus.InProgress);
|
||||
|
||||
agentWorkloads.Add(new AgentWorkloadDto
|
||||
{
|
||||
UserId = agent.Id,
|
||||
UserName = $"{agent.FirstName} {agent.LastName}",
|
||||
ActiveTickets = activeTickets + inProgressTickets,
|
||||
ResolvedThisWeek = 0
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new DashboardStatsDto
|
||||
{
|
||||
TotalTickets = await _ticketRepository.GetTotalCountAsync(),
|
||||
OpenTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Open),
|
||||
InProgressTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.InProgress),
|
||||
PendingTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Pending),
|
||||
ResolvedTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Resolved),
|
||||
ClosedTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Closed),
|
||||
CriticalTickets = await _ticketRepository.GetCountByPriorityAsync(TicketPriority.Critical),
|
||||
HighTickets = await _ticketRepository.GetCountByPriorityAsync(TicketPriority.High),
|
||||
MediumTickets = await _ticketRepository.GetCountByPriorityAsync(TicketPriority.Medium),
|
||||
LowTickets = await _ticketRepository.GetCountByPriorityAsync(TicketPriority.Low),
|
||||
AverageResponseTimeMinutes = await _ticketRepository.GetAverageResponseTimeAsync(),
|
||||
AverageResolutionTimeMinutes = await _ticketRepository.GetAverageResolutionTimeAsync(),
|
||||
AgentWorkloads = agentWorkloads
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public class GroupsController : ControllerBase
|
||||
{
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
|
||||
public GroupsController(IGroupRepository groupRepository)
|
||||
{
|
||||
_groupRepository = groupRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<GroupDto>>> GetGroups()
|
||||
{
|
||||
var groups = await _groupRepository.GetAllAsync();
|
||||
return Ok(groups.Select(MapToDto));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<GroupDto>> GetGroup(Guid id)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null) return NotFound(new { error = "Group not found." });
|
||||
return Ok(MapToDto(group));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<GroupDto>> CreateGroup([FromBody] CreateGroupRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required." });
|
||||
|
||||
var group = new Group
|
||||
{
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _groupRepository.CreateAsync(group);
|
||||
return CreatedAtAction(nameof(GetGroup), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<GroupDto>> UpdateGroup(Guid id, [FromBody] CreateGroupRequest request)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null) return NotFound(new { error = "Group not found." });
|
||||
|
||||
group.Name = request.Name;
|
||||
if (request.Description != null) group.Description = request.Description;
|
||||
|
||||
var updated = await _groupRepository.UpdateAsync(group);
|
||||
if (updated == null) return StatusCode(500, new { error = "Failed to update group." });
|
||||
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteGroup(Guid id)
|
||||
{
|
||||
var deleted = await _groupRepository.DeleteAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "Group not found." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static GroupDto MapToDto(Group g) => new()
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Description = g.Description,
|
||||
IsActive = g.IsActive,
|
||||
CreatedAt = g.CreatedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Security.Claims;
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/kb")]
|
||||
[Authorize]
|
||||
public class KnowledgeBaseController : ControllerBase
|
||||
{
|
||||
private readonly IKnowledgeBaseRepository _kbRepository;
|
||||
|
||||
public KnowledgeBaseController(IKnowledgeBaseRepository kbRepository)
|
||||
{
|
||||
_kbRepository = kbRepository;
|
||||
}
|
||||
|
||||
// ===== Articles =====
|
||||
|
||||
[HttpGet("articles")]
|
||||
[Authorize(Policy = "Agent")]
|
||||
public async Task<ActionResult<IEnumerable<ArticleDto>>> GetAllArticles()
|
||||
{
|
||||
var articles = await _kbRepository.GetAllArticlesAsync();
|
||||
return Ok(articles.Select(MapArticleToDto));
|
||||
}
|
||||
|
||||
[HttpGet("articles/published")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<IEnumerable<ArticleDto>>> GetPublishedArticles()
|
||||
{
|
||||
var articles = await _kbRepository.GetPublishedArticlesAsync();
|
||||
return Ok(articles.Select(MapArticleToDto));
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<IEnumerable<ArticleDto>>> SearchArticles([FromQuery] string q)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
return BadRequest(new { error = "Search query is required." });
|
||||
|
||||
var articles = await _kbRepository.SearchArticlesAsync(q);
|
||||
return Ok(articles.Select(MapArticleToDto));
|
||||
}
|
||||
|
||||
[HttpGet("articles/{id}")]
|
||||
public async Task<ActionResult<ArticleDto>> GetArticle(Guid id)
|
||||
{
|
||||
var article = await _kbRepository.GetArticleByIdAsync(id);
|
||||
if (article == null) return NotFound(new { error = "Article not found." });
|
||||
return Ok(MapArticleToDto(article));
|
||||
}
|
||||
|
||||
[HttpPost("articles")]
|
||||
[Authorize(Policy = "Agent")]
|
||||
public async Task<ActionResult<ArticleDto>> CreateArticle([FromBody] CreateArticleRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required." });
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
return BadRequest(new { error = "Content is required." });
|
||||
|
||||
var userId = GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var article = new KnowledgeBaseArticle
|
||||
{
|
||||
Title = request.Title,
|
||||
Content = request.Content,
|
||||
CategoryId = request.CategoryId,
|
||||
IsPublished = request.IsPublished,
|
||||
AuthorId = userId.Value,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _kbRepository.CreateArticleAsync(article);
|
||||
var full = await _kbRepository.GetArticleByIdAsync(created.Id);
|
||||
return CreatedAtAction(nameof(GetArticle), new { id = created.Id }, MapArticleToDto(full!));
|
||||
}
|
||||
|
||||
[HttpPut("articles/{id}")]
|
||||
[Authorize(Policy = "Agent")]
|
||||
public async Task<ActionResult<ArticleDto>> UpdateArticle(Guid id, [FromBody] UpdateArticleRequest request)
|
||||
{
|
||||
var article = await _kbRepository.GetArticleByIdAsync(id);
|
||||
if (article == null) return NotFound(new { error = "Article not found." });
|
||||
|
||||
if (request.Title != null) article.Title = request.Title;
|
||||
if (request.Content != null) article.Content = request.Content;
|
||||
if (request.CategoryId.HasValue) article.CategoryId = request.CategoryId.Value;
|
||||
if (request.IsPublished.HasValue) article.IsPublished = request.IsPublished.Value;
|
||||
|
||||
var updated = await _kbRepository.UpdateArticleAsync(article);
|
||||
if (updated == null) return StatusCode(500, new { error = "Failed to update article." });
|
||||
|
||||
var full = await _kbRepository.GetArticleByIdAsync(id);
|
||||
return Ok(MapArticleToDto(full!));
|
||||
}
|
||||
|
||||
[HttpDelete("articles/{id}")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public async Task<IActionResult> DeleteArticle(Guid id)
|
||||
{
|
||||
var deleted = await _kbRepository.DeleteArticleAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "Article not found." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== Categories =====
|
||||
|
||||
[HttpGet("categories")]
|
||||
public async Task<ActionResult<IEnumerable<CategoryDto>>> GetCategories()
|
||||
{
|
||||
var categories = await _kbRepository.GetAllCategoriesAsync();
|
||||
return Ok(categories.Select(MapCategoryToDto));
|
||||
}
|
||||
|
||||
[HttpGet("categories/{id}")]
|
||||
public async Task<ActionResult<CategoryDto>> GetCategory(Guid id)
|
||||
{
|
||||
var category = await _kbRepository.GetCategoryByIdAsync(id);
|
||||
if (category == null) return NotFound(new { error = "Category not found." });
|
||||
return Ok(MapCategoryToDto(category));
|
||||
}
|
||||
|
||||
[HttpPost("categories")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public async Task<ActionResult<CategoryDto>> CreateCategory([FromBody] CreateCategoryRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required." });
|
||||
|
||||
var category = new KnowledgeBaseCategory
|
||||
{
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
SortOrder = request.SortOrder,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _kbRepository.CreateCategoryAsync(category);
|
||||
return CreatedAtAction(nameof(GetCategory), new { id = created.Id }, MapCategoryToDto(created));
|
||||
}
|
||||
|
||||
[HttpPut("categories/{id}")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public async Task<ActionResult<CategoryDto>> UpdateCategory(Guid id, [FromBody] CreateCategoryRequest request)
|
||||
{
|
||||
var category = await _kbRepository.GetCategoryByIdAsync(id);
|
||||
if (category == null) return NotFound(new { error = "Category not found." });
|
||||
|
||||
category.Name = request.Name;
|
||||
category.Description = request.Description;
|
||||
category.SortOrder = request.SortOrder;
|
||||
|
||||
var updated = await _kbRepository.UpdateCategoryAsync(category);
|
||||
if (updated == null) return StatusCode(500, new { error = "Failed to update category." });
|
||||
|
||||
return Ok(MapCategoryToDto(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("categories/{id}")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public async Task<IActionResult> DeleteCategory(Guid id)
|
||||
{
|
||||
var deleted = await _kbRepository.DeleteCategoryAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "Category not found." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private Guid? GetUserId()
|
||||
{
|
||||
var claim = User.FindFirst("userId") ?? User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static ArticleDto MapArticleToDto(KnowledgeBaseArticle a) => new()
|
||||
{
|
||||
Id = a.Id,
|
||||
Title = a.Title,
|
||||
Content = a.Content,
|
||||
IsPublished = a.IsPublished,
|
||||
CategoryId = a.CategoryId,
|
||||
CategoryName = a.Category?.Name ?? "Unknown",
|
||||
AuthorId = a.AuthorId,
|
||||
AuthorName = a.Author != null ? $"{a.Author.FirstName} {a.Author.LastName}" : "Unknown",
|
||||
ViewCount = a.ViewCount,
|
||||
CreatedAt = a.CreatedAt,
|
||||
UpdatedAt = a.UpdatedAt
|
||||
};
|
||||
|
||||
private static CategoryDto MapCategoryToDto(KnowledgeBaseCategory c) => new()
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
SortOrder = c.SortOrder,
|
||||
ArticleCount = c.Articles?.Count ?? 0,
|
||||
CreatedAt = c.CreatedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public class SLAController : ControllerBase
|
||||
{
|
||||
private readonly ISLARepository _slaRepository;
|
||||
|
||||
public SLAController(ISLARepository slaRepository)
|
||||
{
|
||||
_slaRepository = slaRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<SLADto>>> GetSLAPolicies()
|
||||
{
|
||||
var policies = await _slaRepository.GetAllAsync();
|
||||
return Ok(policies.Select(MapToDto));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SLADto>> GetSLAPolicy(Guid id)
|
||||
{
|
||||
var policy = await _slaRepository.GetByIdAsync(id);
|
||||
if (policy == null) return NotFound(new { error = "SLA policy not found." });
|
||||
return Ok(MapToDto(policy));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SLADto>> CreateSLAPolicy([FromBody] CreateSLARequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required." });
|
||||
if (request.ResponseTimeHours <= 0 || request.ResolutionTimeHours <= 0)
|
||||
return BadRequest(new { error = "Response and resolution times must be positive." });
|
||||
|
||||
var policy = new SLAPolicy
|
||||
{
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
ResponseTimeHours = request.ResponseTimeHours,
|
||||
ResolutionTimeHours = request.ResolutionTimeHours,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _slaRepository.CreateAsync(policy);
|
||||
return CreatedAtAction(nameof(GetSLAPolicy), new { id = created.Id }, MapToDto(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SLADto>> UpdateSLAPolicy(Guid id, [FromBody] UpdateSLARequest request)
|
||||
{
|
||||
var policy = await _slaRepository.GetByIdAsync(id);
|
||||
if (policy == null) return NotFound(new { error = "SLA policy not found." });
|
||||
|
||||
if (request.Name != null) policy.Name = request.Name;
|
||||
if (request.Description != null) policy.Description = request.Description;
|
||||
if (request.ResponseTimeHours.HasValue) policy.ResponseTimeHours = request.ResponseTimeHours.Value;
|
||||
if (request.ResolutionTimeHours.HasValue) policy.ResolutionTimeHours = request.ResolutionTimeHours.Value;
|
||||
if (request.IsActive.HasValue) policy.IsActive = request.IsActive.Value;
|
||||
|
||||
var updated = await _slaRepository.UpdateAsync(policy);
|
||||
if (updated == null) return StatusCode(500, new { error = "Failed to update SLA policy." });
|
||||
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteSLAPolicy(Guid id)
|
||||
{
|
||||
var deleted = await _slaRepository.DeleteAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "SLA policy not found." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static SLADto MapToDto(SLAPolicy s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = s.Name,
|
||||
Description = s.Description,
|
||||
ResponseTimeHours = s.ResponseTimeHours,
|
||||
ResolutionTimeHours = s.ResolutionTimeHours,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Security.Claims;
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class TicketsController : ControllerBase
|
||||
{
|
||||
private readonly ITicketRepository _ticketRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public TicketsController(ITicketRepository ticketRepository, IUserRepository userRepository)
|
||||
{
|
||||
_ticketRepository = ticketRepository;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<TicketListResponse>> GetTickets(
|
||||
[FromQuery] TicketStatus? status,
|
||||
[FromQuery] TicketPriority? priority,
|
||||
[FromQuery] Guid? assigneeId,
|
||||
[FromQuery] Guid? requesterId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(userId.Value);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
if (user.Role == UserRole.Customer)
|
||||
requesterId = userId.Value;
|
||||
|
||||
var tickets = await _ticketRepository.GetAllAsync(status, priority, assigneeId, requesterId, page, pageSize);
|
||||
var totalCount = await _ticketRepository.GetCountAsync(status, priority, assigneeId, requesterId);
|
||||
|
||||
return Ok(new TicketListResponse
|
||||
{
|
||||
Items = tickets.Select(MapToDto).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<TicketStatsDto>> GetStats()
|
||||
{
|
||||
return Ok(new TicketStatsDto
|
||||
{
|
||||
TotalTickets = await _ticketRepository.GetTotalCountAsync(),
|
||||
OpenTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Open),
|
||||
InProgressTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.InProgress),
|
||||
PendingTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Pending),
|
||||
ResolvedTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Resolved),
|
||||
ClosedTickets = await _ticketRepository.GetCountByStatusAsync(TicketStatus.Closed)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<TicketDto>> GetTicket(Guid id)
|
||||
{
|
||||
var ticket = await _ticketRepository.GetByIdAsync(id);
|
||||
if (ticket == null) return NotFound(new { error = "Ticket not found." });
|
||||
|
||||
var userId = GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(userId.Value);
|
||||
if (user?.Role == UserRole.Customer && ticket.RequesterId != userId.Value)
|
||||
return Forbid();
|
||||
|
||||
return Ok(MapToDto(ticket));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<TicketDto>> CreateTicket([FromBody] CreateTicketRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required." });
|
||||
if (string.IsNullOrWhiteSpace(request.Description))
|
||||
return BadRequest(new { error = "Description is required." });
|
||||
|
||||
var userId = GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var ticket = new Ticket
|
||||
{
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Priority = request.Priority,
|
||||
RequesterId = userId.Value,
|
||||
GroupId = request.GroupId,
|
||||
SlaPolicyId = request.SlaPolicyId,
|
||||
TicketNumber = $"TK-{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||
Status = TicketStatus.Open,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var created = await _ticketRepository.CreateAsync(ticket);
|
||||
var full = await _ticketRepository.GetByIdAsync(created.Id);
|
||||
|
||||
return CreatedAtAction(nameof(GetTicket), new { id = created.Id }, MapToDto(full!));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<TicketDto>> UpdateTicket(Guid id, [FromBody] UpdateTicketRequest request)
|
||||
{
|
||||
var ticket = await _ticketRepository.GetByIdAsync(id);
|
||||
if (ticket == null) return NotFound(new { error = "Ticket not found." });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title)) ticket.Title = request.Title;
|
||||
if (!string.IsNullOrWhiteSpace(request.Description)) ticket.Description = request.Description;
|
||||
if (request.Priority.HasValue) ticket.Priority = request.Priority.Value;
|
||||
|
||||
var updated = await _ticketRepository.UpdateAsync(ticket);
|
||||
if (updated == null) return StatusCode(500, new { error = "Failed to update ticket." });
|
||||
|
||||
var full = await _ticketRepository.GetByIdAsync(id);
|
||||
return Ok(MapToDto(full!));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public async Task<IActionResult> DeleteTicket(Guid id)
|
||||
{
|
||||
var deleted = await _ticketRepository.DeleteAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "Ticket not found." });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{id}/status")]
|
||||
[Authorize(Policy = "Agent")]
|
||||
public async Task<IActionResult> UpdateStatus(Guid id, [FromBody] UpdateStatusRequest request)
|
||||
{
|
||||
var ticket = await _ticketRepository.GetByIdAsync(id);
|
||||
if (ticket == null) return NotFound(new { error = "Ticket not found." });
|
||||
|
||||
ticket.Status = request.Status;
|
||||
await _ticketRepository.UpdateAsync(ticket);
|
||||
|
||||
return Ok(new { message = "Status updated.", status = request.Status.ToString() });
|
||||
}
|
||||
|
||||
[HttpPut("{id}/assign")]
|
||||
[Authorize(Policy = "Agent")]
|
||||
public async Task<IActionResult> AssignTicket(Guid id, [FromBody] AssignTicketRequest request)
|
||||
{
|
||||
var ticket = await _ticketRepository.GetByIdAsync(id);
|
||||
if (ticket == null) return NotFound(new { error = "Ticket not found." });
|
||||
|
||||
if (request.AssigneeId.HasValue)
|
||||
{
|
||||
var assignee = await _userRepository.GetByIdAsync(request.AssigneeId.Value);
|
||||
if (assignee == null) return BadRequest(new { error = "Assignee not found." });
|
||||
if (assignee.Role != UserRole.Agent && assignee.Role != UserRole.Admin)
|
||||
return BadRequest(new { error = "Assignee must be an agent or admin." });
|
||||
}
|
||||
|
||||
ticket.AssigneeId = request.AssigneeId;
|
||||
if (request.AssigneeId.HasValue && ticket.Status == TicketStatus.Open)
|
||||
ticket.Status = TicketStatus.InProgress;
|
||||
|
||||
await _ticketRepository.UpdateAsync(ticket);
|
||||
return Ok(new { message = "Ticket assigned.", assigneeId = request.AssigneeId });
|
||||
}
|
||||
|
||||
private Guid? GetUserId()
|
||||
{
|
||||
var claim = User.FindFirst("userId") ?? User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static TicketDto MapToDto(Ticket t) => new()
|
||||
{
|
||||
Id = t.Id,
|
||||
Title = t.Title,
|
||||
Description = t.Description,
|
||||
Status = t.Status,
|
||||
Priority = t.Priority,
|
||||
TicketNumber = t.TicketNumber,
|
||||
RequesterId = t.RequesterId,
|
||||
RequesterName = t.Requester != null ? $"{t.Requester.FirstName} {t.Requester.LastName}" : "Unknown",
|
||||
AssigneeId = t.AssigneeId,
|
||||
AssigneeName = t.Assignee != null ? $"{t.Assignee.FirstName} {t.Assignee.LastName}" : null,
|
||||
GroupId = t.GroupId,
|
||||
GroupName = t.Group?.Name,
|
||||
SlaPolicyId = t.SlaPolicyId,
|
||||
SlaPolicyName = t.SlaPolicy?.Name,
|
||||
CreatedAt = t.CreatedAt,
|
||||
UpdatedAt = t.UpdatedAt,
|
||||
ResolvedAt = t.ResolvedAt,
|
||||
CommentCount = t.Comments?.Count ?? 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Security.Claims;
|
||||
using HelpDesk.Data.Repositories;
|
||||
using HelpDesk.Models.DTOs;
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HelpDesk.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public UsersController(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "Admin")]
|
||||
public async Task<ActionResult<IEnumerable<UserDto>>> GetUsers()
|
||||
{
|
||||
var users = await _userRepository.GetAllAsync();
|
||||
return Ok(users.Select(MapToDto));
|
||||
}
|
||||
|
||||
[HttpGet("agents")]
|
||||
public async Task<ActionResult<IEnumerable<UserDto>>> GetAgents()
|
||||
{
|
||||
var agents = await _userRepository.GetByRoleAsync(UserRole.Agent);
|
||||
return Ok(agents.Select(MapToDto));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<UserDto>> GetUser(Guid id)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(id);
|
||||
if (user == null) return NotFound(new { error = "User not found." });
|
||||
return Ok(MapToDto(user));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<UserDto>> UpdateUser(Guid id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
var currentUserId = GetUserId();
|
||||
if (currentUserId == null) return Unauthorized();
|
||||
|
||||
var currentUser = await _userRepository.GetByIdAsync(currentUserId.Value);
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
// Users can update themselves; Admins/SuperAdmins can update others
|
||||
if (currentUserId.Value != id && currentUser.Role < UserRole.Admin)
|
||||
return Forbid();
|
||||
|
||||
var user = await _userRepository.GetByIdAsync(id);
|
||||
if (user == null) return NotFound(new { error = "User not found." });
|
||||
|
||||
if (request.FirstName != null) user.FirstName = request.FirstName;
|
||||
if (request.LastName != null) user.LastName = request.LastName;
|
||||
if (request.Email != null) user.Email = request.Email;
|
||||
if (request.PhoneNumber != null) user.PhoneNumber = request.PhoneNumber;
|
||||
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
|
||||
|
||||
// Only admins can change role and active status
|
||||
if (currentUser.Role >= UserRole.Admin)
|
||||
{
|
||||
if (request.Role.HasValue) user.Role = request.Role.Value;
|
||||
if (request.IsActive.HasValue) user.IsActive = request.IsActive.Value;
|
||||
}
|
||||
|
||||
var updated = await _userRepository.UpdateAsync(user);
|
||||
if (updated == null) return StatusCode(500, new { error = "Failed to update user." });
|
||||
|
||||
return Ok(MapToDto(updated));
|
||||
}
|
||||
|
||||
private Guid? GetUserId()
|
||||
{
|
||||
var claim = User.FindFirst("userId") ?? User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static UserDto MapToDto(User u) => new()
|
||||
{
|
||||
Id = u.Id,
|
||||
Email = u.Email,
|
||||
FirstName = u.FirstName,
|
||||
LastName = u.LastName,
|
||||
Role = u.Role,
|
||||
IsActive = u.IsActive,
|
||||
CreatedAt = u.CreatedAt,
|
||||
PhoneNumber = u.PhoneNumber,
|
||||
AvatarUrl = u.AvatarUrl
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HelpDesk.Models\HelpDesk.Models.csproj" />
|
||||
<ProjectReference Include="..\HelpDesk.Data\HelpDesk.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@HelpDesk.API_HostAddress = http://localhost:5098
|
||||
|
||||
GET {{HelpDesk.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HelpDesk.API.Middleware;
|
||||
|
||||
public class ExceptionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ExceptionMiddleware> _logger;
|
||||
|
||||
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "An unhandled exception occurred: {Message}", ex.Message);
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var (statusCode, message) = exception switch
|
||||
{
|
||||
ArgumentException argEx => (HttpStatusCode.BadRequest, argEx.Message),
|
||||
KeyNotFoundException keyEx => (HttpStatusCode.NotFound, keyEx.Message),
|
||||
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access."),
|
||||
InvalidOperationException opEx => (HttpStatusCode.BadRequest, opEx.Message),
|
||||
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred.")
|
||||
};
|
||||
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
|
||||
var response = new
|
||||
{
|
||||
error = message,
|
||||
statusCode = (int)statusCode
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Text;
|
||||
using HelpDesk.API.Middleware;
|
||||
using HelpDesk.API.Services;
|
||||
using HelpDesk.Data;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Controllers
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Database
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? Environment.GetEnvironmentVariable("DB_CONNECTION")
|
||||
?? "Host=localhost;Port=5432;Database=helpdesk;Username=postgres;Password=postgres";
|
||||
|
||||
builder.Services.AddDbContext<HelpDeskDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// Repositories
|
||||
builder.Services.AddRepositories();
|
||||
|
||||
// Services
|
||||
builder.Services.AddScoped<IJwtService, JwtService>();
|
||||
builder.Services.AddScoped<IPasswordService, PasswordService>();
|
||||
|
||||
// Authentication
|
||||
var jwtSecretKey = builder.Configuration["JwtSettings:SecretKey"] ?? "YourSuperSecretKeyHereThatShouldBeAtLeast32CharactersLong!!";
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["JwtSettings:Issuer"] ?? "HelpDesk",
|
||||
ValidAudience = builder.Configuration["JwtSettings:Audience"] ?? "HelpDeskAPI",
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey)),
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
});
|
||||
|
||||
// Authorization Policies
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("SuperAdmin", policy => policy.RequireRole("SuperAdmin"));
|
||||
options.AddPolicy("Admin", policy => policy.RequireRole("SuperAdmin", "Admin"));
|
||||
options.AddPolicy("Agent", policy => policy.RequireRole("SuperAdmin", "Admin", "Agent"));
|
||||
options.AddPolicy("Customer", policy => policy.RequireRole("SuperAdmin", "Admin", "Agent", "Customer"));
|
||||
});
|
||||
|
||||
// CORS
|
||||
var frontendUrl = builder.Configuration["FrontendUrl"] ?? "http://localhost:3000";
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Frontend", policy =>
|
||||
{
|
||||
policy.WithOrigins(frontendUrl)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
// Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "HelpDesk API",
|
||||
Version = "v1",
|
||||
Description = "HelpDesk API for ticket management"
|
||||
});
|
||||
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "Bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Enter your JWT token"
|
||||
});
|
||||
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Exception Middleware
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
// Swagger
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// CORS
|
||||
app.UseCors("Frontend");
|
||||
|
||||
// Auth
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Map Controllers
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5098",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.API.Services;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateToken(Guid userId, string email, UserRole role);
|
||||
ClaimsPrincipal? ValidateToken(string token);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace HelpDesk.API.Services;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string HashPassword(string password);
|
||||
bool VerifyPassword(string password, string hashedPassword);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace HelpDesk.API.Services;
|
||||
|
||||
public class JwtService : IJwtService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public JwtService(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public string GenerateToken(Guid userId, string email, UserRole role)
|
||||
{
|
||||
var secretKey = _configuration["JwtSettings:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey is not configured.");
|
||||
var issuer = _configuration["JwtSettings:Issuer"] ?? "HelpDesk";
|
||||
var audience = _configuration["JwtSettings:Audience"] ?? "HelpDeskAPI";
|
||||
var expiryMinutes = int.TryParse(_configuration["JwtSettings:ExpiryMinutes"], out var minutes) ? minutes : 60;
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, email),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(ClaimTypes.Role, role.ToString()),
|
||||
new Claim("userId", userId.ToString())
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(expiryMinutes),
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var secretKey = _configuration["JwtSettings:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey is not configured.");
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.UTF8.GetBytes(secretKey);
|
||||
|
||||
try
|
||||
{
|
||||
var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = _configuration["JwtSettings:Issuer"],
|
||||
ValidAudience = _configuration["JwtSettings:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||
ClockSkew = TimeSpan.Zero
|
||||
}, out _);
|
||||
|
||||
return principal;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace HelpDesk.API.Services;
|
||||
|
||||
public class PasswordService : IPasswordService
|
||||
{
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.HashPassword(password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hashedPassword)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.Verify(password, hashedPassword);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=helpdesk;Username=postgres;Password=postgres"
|
||||
},
|
||||
"JwtSettings": {
|
||||
"SecretKey": "DevSuperSecretKeyForDevelopmentOnly_32Chars!!",
|
||||
"Issuer": "HelpDesk",
|
||||
"Audience": "HelpDeskAPI",
|
||||
"ExpiryMinutes": 120
|
||||
},
|
||||
"FrontendUrl": "http://localhost:3000"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=helpdesk;Username=postgres;Password=postgres"
|
||||
},
|
||||
"JwtSettings": {
|
||||
"SecretKey": "YourSuperSecretKeyHereThatShouldBeAtLeast32CharactersLong!!",
|
||||
"Issuer": "HelpDesk",
|
||||
"Audience": "HelpDeskAPI",
|
||||
"ExpiryMinutes": 60
|
||||
},
|
||||
"FrontendUrl": "http://localhost:3000"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using HelpDesk.Data.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HelpDesk.Data;
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<ITicketRepository, TicketRepository>();
|
||||
services.AddScoped<ITicketCommentRepository, TicketCommentRepository>();
|
||||
services.AddScoped<IGroupRepository, GroupRepository>();
|
||||
services.AddScoped<ISLARepository, SLARepository>();
|
||||
services.AddScoped<IKnowledgeBaseRepository, KnowledgeBaseRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HelpDesk.Models\HelpDesk.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,306 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using HelpDesk.Models.Entities;
|
||||
|
||||
namespace HelpDesk.Data;
|
||||
|
||||
public class HelpDeskDbContext : DbContext
|
||||
{
|
||||
public HelpDeskDbContext(DbContextOptions<HelpDeskDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<Ticket> Tickets => Set<Ticket>();
|
||||
public DbSet<TicketComment> TicketComments => Set<TicketComment>();
|
||||
public DbSet<Group> Groups => Set<Group>();
|
||||
public DbSet<SLAPolicy> SLAPolicies => Set<SLAPolicy>();
|
||||
public DbSet<KnowledgeBaseArticle> KnowledgeBaseArticles => Set<KnowledgeBaseArticle>();
|
||||
public DbSet<KnowledgeBaseCategory> KnowledgeBaseCategories => Set<KnowledgeBaseCategory>();
|
||||
public DbSet<ActivityLog> ActivityLogs => Set<ActivityLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// ── User ──────────────────────────────────────────────
|
||||
modelBuilder.Entity<User>(entity =>
|
||||
{
|
||||
entity.ToTable("users");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.FirstName).HasColumnName("first_name").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.LastName).HasColumnName("last_name").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Email).HasColumnName("email").HasMaxLength(256).IsRequired();
|
||||
entity.Property(e => e.PasswordHash).HasColumnName("password_hash").HasMaxLength(500).IsRequired();
|
||||
entity.Property(e => e.Role).HasColumnName("role").HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.IsActive).HasColumnName("is_active");
|
||||
entity.Property(e => e.PhoneNumber).HasColumnName("phone_number").HasMaxLength(50);
|
||||
entity.Property(e => e.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
|
||||
entity.HasIndex(e => e.Email).IsUnique().HasDatabaseName("ix_users_email");
|
||||
});
|
||||
|
||||
// ── Ticket ────────────────────────────────────────────
|
||||
modelBuilder.Entity<Ticket>(entity =>
|
||||
{
|
||||
entity.ToTable("tickets");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Title).HasColumnName("title").HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Description).HasColumnName("description").IsRequired();
|
||||
entity.Property(e => e.Status).HasColumnName("status").HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.Priority).HasColumnName("priority").HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.TicketNumber).HasColumnName("ticket_number").HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.RequesterId).HasColumnName("requester_id");
|
||||
entity.Property(e => e.AssigneeId).HasColumnName("assignee_id");
|
||||
entity.Property(e => e.GroupId).HasColumnName("group_id");
|
||||
entity.Property(e => e.SlaPolicyId).HasColumnName("sla_policy_id");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
|
||||
entity.Property(e => e.ClosedAt).HasColumnName("closed_at");
|
||||
|
||||
entity.HasIndex(e => e.TicketNumber).IsUnique().HasDatabaseName("ix_tickets_ticket_number");
|
||||
|
||||
// Cascade delete: Ticket -> TicketComment
|
||||
entity.HasMany(e => e.Comments)
|
||||
.WithOne(c => c.Ticket)
|
||||
.HasForeignKey(c => c.TicketId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Restrict: User FK references (Requester)
|
||||
entity.HasOne(e => e.Requester)
|
||||
.WithMany(u => u.RequestedTickets)
|
||||
.HasForeignKey(e => e.RequesterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Restrict: User FK references (Assignee)
|
||||
entity.HasOne(e => e.Assignee)
|
||||
.WithMany(u => u.AssignedTickets)
|
||||
.HasForeignKey(e => e.AssigneeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasOne(e => e.Group)
|
||||
.WithMany(g => g.Tickets)
|
||||
.HasForeignKey(e => e.GroupId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(e => e.SlaPolicy)
|
||||
.WithMany(s => s.Tickets)
|
||||
.HasForeignKey(e => e.SlaPolicyId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── TicketComment ─────────────────────────────────────
|
||||
modelBuilder.Entity<TicketComment>(entity =>
|
||||
{
|
||||
entity.ToTable("ticket_comments");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.TicketId).HasColumnName("ticket_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.Content).HasColumnName("content").IsRequired();
|
||||
entity.Property(e => e.IsInternal).HasColumnName("is_internal");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
|
||||
// Restrict: User FK reference
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.Comments)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── Group ─────────────────────────────────────────────
|
||||
modelBuilder.Entity<Group>(entity =>
|
||||
{
|
||||
entity.ToTable("groups");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ── SLAPolicy ─────────────────────────────────────────
|
||||
modelBuilder.Entity<SLAPolicy>(entity =>
|
||||
{
|
||||
entity.ToTable("sla_policies");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(500);
|
||||
entity.Property(e => e.ResponseTimeHours).HasColumnName("response_time_hours");
|
||||
entity.Property(e => e.ResolutionTimeHours).HasColumnName("resolution_time_hours");
|
||||
entity.Property(e => e.IsActive).HasColumnName("is_active");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ── KnowledgeBaseArticle ──────────────────────────────
|
||||
modelBuilder.Entity<KnowledgeBaseArticle>(entity =>
|
||||
{
|
||||
entity.ToTable("knowledge_base_articles");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Title).HasColumnName("title").HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Content).HasColumnName("content").IsRequired();
|
||||
entity.Property(e => e.CategoryId).HasColumnName("category_id");
|
||||
entity.Property(e => e.AuthorId).HasColumnName("author_id");
|
||||
entity.Property(e => e.IsPublished).HasColumnName("is_published");
|
||||
entity.Property(e => e.ViewCount).HasColumnName("view_count");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
|
||||
entity.HasOne(e => e.Category)
|
||||
.WithMany(c => c.Articles)
|
||||
.HasForeignKey(e => e.CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasOne(e => e.Author)
|
||||
.WithMany(u => u.Articles)
|
||||
.HasForeignKey(e => e.AuthorId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── KnowledgeBaseCategory ─────────────────────────────
|
||||
modelBuilder.Entity<KnowledgeBaseCategory>(entity =>
|
||||
{
|
||||
entity.ToTable("knowledge_base_categories");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(500);
|
||||
entity.Property(e => e.SortOrder).HasColumnName("sort_order");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ── ActivityLog ───────────────────────────────────────
|
||||
modelBuilder.Entity<ActivityLog>(entity =>
|
||||
{
|
||||
entity.ToTable("activity_logs");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.EntityType).HasColumnName("entity_type").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.EntityId).HasColumnName("entity_id");
|
||||
entity.Property(e => e.Action).HasColumnName("action").HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Details).HasColumnName("details");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.ActivityLogs)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── Seed Data ─────────────────────────────────────────
|
||||
SeedData(modelBuilder);
|
||||
}
|
||||
|
||||
private static void SeedData(ModelBuilder modelBuilder)
|
||||
{
|
||||
var adminId = Guid.Parse("a0000000-0000-0000-0000-000000000001");
|
||||
var generalGroupId = Guid.Parse("b0000000-0000-0000-0000-000000000001");
|
||||
var ventasGroupId = Guid.Parse("b0000000-0000-0000-0000-000000000002");
|
||||
var desarrolloGroupId = Guid.Parse("b0000000-0000-0000-0000-000000000003");
|
||||
var standardSlaId = Guid.Parse("c0000000-0000-0000-0000-000000000001");
|
||||
var premiumSlaId = Guid.Parse("c0000000-0000-0000-0000-000000000002");
|
||||
var generalCategoryId = Guid.Parse("d0000000-0000-0000-0000-000000000001");
|
||||
var faqCategoryId = Guid.Parse("d0000000-0000-0000-0000-000000000002");
|
||||
var tutorialsCategoryId = Guid.Parse("d0000000-0000-0000-0000-000000000003");
|
||||
|
||||
// SuperAdmin user
|
||||
modelBuilder.Entity<User>().HasData(new User
|
||||
{
|
||||
Id = adminId,
|
||||
FirstName = "Super",
|
||||
LastName = "Admin",
|
||||
Email = "admin@helpdesk.local",
|
||||
// BCrypt hash for "Admin123!"
|
||||
PasswordHash = "$2a$11$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
||||
Role = HelpDesk.Models.Enums.UserRole.SuperAdmin,
|
||||
IsActive = true,
|
||||
PhoneNumber = "+1-555-0100",
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
UpdatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
|
||||
// Default groups
|
||||
modelBuilder.Entity<Group>().HasData(
|
||||
new Group
|
||||
{
|
||||
Id = generalGroupId,
|
||||
Name = "Soporte General",
|
||||
Description = "Grupo de soporte técnico general",
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new Group
|
||||
{
|
||||
Id = ventasGroupId,
|
||||
Name = "Ventas",
|
||||
Description = "Grupo de soporte de ventas",
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new Group
|
||||
{
|
||||
Id = desarrolloGroupId,
|
||||
Name = "Desarrollo",
|
||||
Description = "Grupo de desarrollo de software",
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
);
|
||||
|
||||
// SLA policies
|
||||
modelBuilder.Entity<SLAPolicy>().HasData(
|
||||
new SLAPolicy
|
||||
{
|
||||
Id = standardSlaId,
|
||||
Name = "Estándar",
|
||||
Description = "Política estándar: respuesta en 4 horas, resolución en 24 horas",
|
||||
ResponseTimeHours = 4,
|
||||
ResolutionTimeHours = 24,
|
||||
IsActive = true,
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new SLAPolicy
|
||||
{
|
||||
Id = premiumSlaId,
|
||||
Name = "Premium",
|
||||
Description = "Política premium: respuesta en 1 hora, resolución en 8 horas",
|
||||
ResponseTimeHours = 1,
|
||||
ResolutionTimeHours = 8,
|
||||
IsActive = true,
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
);
|
||||
|
||||
// Knowledge base categories
|
||||
modelBuilder.Entity<KnowledgeBaseCategory>().HasData(
|
||||
new KnowledgeBaseCategory
|
||||
{
|
||||
Id = generalCategoryId,
|
||||
Name = "General",
|
||||
Description = "Artículos generales de ayuda",
|
||||
SortOrder = 1,
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new KnowledgeBaseCategory
|
||||
{
|
||||
Id = faqCategoryId,
|
||||
Name = "Preguntas Frecuentes",
|
||||
Description = "Preguntas frecuentes y respuestas",
|
||||
SortOrder = 2,
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new KnowledgeBaseCategory
|
||||
{
|
||||
Id = tutorialsCategoryId,
|
||||
Name = "Tutoriales",
|
||||
Description = "Guías y tutoriales paso a paso",
|
||||
SortOrder = 3,
|
||||
CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public interface IKnowledgeBaseRepository
|
||||
{
|
||||
// Articles
|
||||
Task<KnowledgeBaseArticle?> GetArticleByIdAsync(Guid id);
|
||||
Task<IEnumerable<KnowledgeBaseArticle>> GetAllArticlesAsync();
|
||||
Task<IEnumerable<KnowledgeBaseArticle>> GetPublishedArticlesAsync();
|
||||
Task<IEnumerable<KnowledgeBaseArticle>> SearchArticlesAsync(string query);
|
||||
Task<KnowledgeBaseArticle> CreateArticleAsync(KnowledgeBaseArticle article);
|
||||
Task<KnowledgeBaseArticle?> UpdateArticleAsync(KnowledgeBaseArticle article);
|
||||
Task<bool> DeleteArticleAsync(Guid id);
|
||||
|
||||
// Categories
|
||||
Task<KnowledgeBaseCategory?> GetCategoryByIdAsync(Guid id);
|
||||
Task<IEnumerable<KnowledgeBaseCategory>> GetAllCategoriesAsync();
|
||||
Task<KnowledgeBaseCategory> CreateCategoryAsync(KnowledgeBaseCategory category);
|
||||
Task<KnowledgeBaseCategory?> UpdateCategoryAsync(KnowledgeBaseCategory category);
|
||||
Task<bool> DeleteCategoryAsync(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public interface IRepository<T> where T : class
|
||||
{
|
||||
Task<T?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<T>> GetAllAsync();
|
||||
Task<T> AddAsync(T entity);
|
||||
Task UpdateAsync(T entity);
|
||||
Task DeleteAsync(T entity);
|
||||
Task<int> SaveChangesAsync();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public interface ITicketRepository
|
||||
{
|
||||
Task<Ticket?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<Ticket>> GetAllAsync(TicketStatus? status = null, TicketPriority? priority = null, Guid? assigneeId = null, Guid? requesterId = null, int page = 1, int pageSize = 20);
|
||||
Task<int> GetCountAsync(TicketStatus? status = null, TicketPriority? priority = null, Guid? assigneeId = null, Guid? requesterId = null);
|
||||
Task<Ticket> CreateAsync(Ticket ticket);
|
||||
Task<Ticket?> UpdateAsync(Ticket ticket);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<int> GetTotalCountAsync();
|
||||
Task<int> GetCountByStatusAsync(TicketStatus status);
|
||||
Task<int> GetCountByPriorityAsync(TicketPriority priority);
|
||||
Task<double> GetAverageResponseTimeAsync();
|
||||
Task<double> GetAverageResolutionTimeAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByIdAsync(Guid id);
|
||||
Task<User?> GetByEmailAsync(string email);
|
||||
Task<IEnumerable<User>> GetAllAsync();
|
||||
Task<IEnumerable<User>> GetByRoleAsync(UserRole role);
|
||||
Task<User> CreateAsync(User user);
|
||||
Task<User?> UpdateAsync(User user);
|
||||
Task<bool> ExistsAsync(string email);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public class KnowledgeBaseRepository : IKnowledgeBaseRepository
|
||||
{
|
||||
private readonly HelpDeskDbContext _context;
|
||||
|
||||
public KnowledgeBaseRepository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// Articles
|
||||
public async Task<KnowledgeBaseArticle?> GetArticleByIdAsync(Guid id)
|
||||
=> await _context.KnowledgeBaseArticles
|
||||
.Include(a => a.Category)
|
||||
.Include(a => a.Author)
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
public async Task<IEnumerable<KnowledgeBaseArticle>> GetAllArticlesAsync()
|
||||
=> await _context.KnowledgeBaseArticles
|
||||
.Include(a => a.Category)
|
||||
.Include(a => a.Author)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<IEnumerable<KnowledgeBaseArticle>> GetPublishedArticlesAsync()
|
||||
=> await _context.KnowledgeBaseArticles
|
||||
.Include(a => a.Category)
|
||||
.Include(a => a.Author)
|
||||
.Where(a => a.IsPublished)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<IEnumerable<KnowledgeBaseArticle>> SearchArticlesAsync(string query)
|
||||
{
|
||||
var lowerQuery = query.ToLower();
|
||||
return await _context.KnowledgeBaseArticles
|
||||
.Include(a => a.Category)
|
||||
.Include(a => a.Author)
|
||||
.Where(a => a.IsPublished && (a.Title.ToLower().Contains(lowerQuery) || a.Content.ToLower().Contains(lowerQuery)))
|
||||
.OrderByDescending(a => a.ViewCount)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<KnowledgeBaseArticle> CreateArticleAsync(KnowledgeBaseArticle article)
|
||||
{
|
||||
_context.KnowledgeBaseArticles.Add(article);
|
||||
await _context.SaveChangesAsync();
|
||||
return article;
|
||||
}
|
||||
|
||||
public async Task<KnowledgeBaseArticle?> UpdateArticleAsync(KnowledgeBaseArticle article)
|
||||
{
|
||||
var existing = await _context.KnowledgeBaseArticles.FindAsync(article.Id);
|
||||
if (existing == null) return null;
|
||||
|
||||
existing.Title = article.Title;
|
||||
existing.Content = article.Content;
|
||||
existing.CategoryId = article.CategoryId;
|
||||
existing.IsPublished = article.IsPublished;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteArticleAsync(Guid id)
|
||||
{
|
||||
var article = await _context.KnowledgeBaseArticles.FindAsync(id);
|
||||
if (article == null) return false;
|
||||
_context.KnowledgeBaseArticles.Remove(article);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Categories
|
||||
public async Task<KnowledgeBaseCategory?> GetCategoryByIdAsync(Guid id)
|
||||
=> await _context.KnowledgeBaseCategories
|
||||
.Include(c => c.Articles)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<IEnumerable<KnowledgeBaseCategory>> GetAllCategoriesAsync()
|
||||
=> await _context.KnowledgeBaseCategories
|
||||
.Include(c => c.Articles)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<KnowledgeBaseCategory> CreateCategoryAsync(KnowledgeBaseCategory category)
|
||||
{
|
||||
_context.KnowledgeBaseCategories.Add(category);
|
||||
await _context.SaveChangesAsync();
|
||||
return category;
|
||||
}
|
||||
|
||||
public async Task<KnowledgeBaseCategory?> UpdateCategoryAsync(KnowledgeBaseCategory category)
|
||||
{
|
||||
var existing = await _context.KnowledgeBaseCategories.FindAsync(category.Id);
|
||||
if (existing == null) return null;
|
||||
|
||||
existing.Name = category.Name;
|
||||
existing.Description = category.Description;
|
||||
existing.SortOrder = category.SortOrder;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCategoryAsync(Guid id)
|
||||
{
|
||||
var category = await _context.KnowledgeBaseCategories.FindAsync(id);
|
||||
if (category == null) return false;
|
||||
_context.KnowledgeBaseCategories.Remove(category);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public interface ITicketCommentRepository
|
||||
{
|
||||
Task<TicketComment?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<TicketComment>> GetByTicketIdAsync(Guid ticketId);
|
||||
Task<TicketComment> CreateAsync(TicketComment comment);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
}
|
||||
|
||||
public interface IGroupRepository
|
||||
{
|
||||
Task<Group?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<Group>> GetAllAsync();
|
||||
Task<Group> CreateAsync(Group group);
|
||||
Task<Group?> UpdateAsync(Group group);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
}
|
||||
|
||||
public interface ISLARepository
|
||||
{
|
||||
Task<SLAPolicy?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<SLAPolicy>> GetAllAsync();
|
||||
Task<SLAPolicy> CreateAsync(SLAPolicy policy);
|
||||
Task<SLAPolicy?> UpdateAsync(SLAPolicy policy);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public class Repository<T> : IRepository<T> where T : class
|
||||
{
|
||||
protected readonly HelpDeskDbContext _context;
|
||||
protected readonly DbSet<T> _dbSet;
|
||||
|
||||
public Repository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_dbSet = context.Set<T>();
|
||||
}
|
||||
|
||||
public virtual async Task<T?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _dbSet.FindAsync(id);
|
||||
}
|
||||
|
||||
public virtual async Task<IEnumerable<T>> GetAllAsync()
|
||||
{
|
||||
return await _dbSet.ToListAsync();
|
||||
}
|
||||
|
||||
public virtual async Task<T> AddAsync(T entity)
|
||||
{
|
||||
await _dbSet.AddAsync(entity);
|
||||
await SaveChangesAsync();
|
||||
return entity;
|
||||
}
|
||||
|
||||
public virtual async Task UpdateAsync(T entity)
|
||||
{
|
||||
_dbSet.Update(entity);
|
||||
await SaveChangesAsync();
|
||||
}
|
||||
|
||||
public virtual async Task DeleteAsync(T entity)
|
||||
{
|
||||
_dbSet.Remove(entity);
|
||||
await SaveChangesAsync();
|
||||
}
|
||||
|
||||
public virtual async Task<int> SaveChangesAsync()
|
||||
{
|
||||
return await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public class TicketCommentRepository : ITicketCommentRepository
|
||||
{
|
||||
private readonly HelpDeskDbContext _context;
|
||||
|
||||
public TicketCommentRepository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<TicketComment?> GetByIdAsync(Guid id)
|
||||
=> await _context.TicketComments.FindAsync(id);
|
||||
|
||||
public async Task<IEnumerable<TicketComment>> GetByTicketIdAsync(Guid ticketId)
|
||||
=> await _context.TicketComments
|
||||
.Include(c => c.User)
|
||||
.Where(c => c.TicketId == ticketId)
|
||||
.OrderBy(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<TicketComment> CreateAsync(TicketComment comment)
|
||||
{
|
||||
_context.TicketComments.Add(comment);
|
||||
await _context.SaveChangesAsync();
|
||||
return comment;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var comment = await _context.TicketComments.FindAsync(id);
|
||||
if (comment == null) return false;
|
||||
_context.TicketComments.Remove(comment);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class GroupRepository : IGroupRepository
|
||||
{
|
||||
private readonly HelpDeskDbContext _context;
|
||||
|
||||
public GroupRepository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Group?> GetByIdAsync(Guid id)
|
||||
=> await _context.Groups.FindAsync(id);
|
||||
|
||||
public async Task<IEnumerable<Group>> GetAllAsync()
|
||||
=> await _context.Groups.OrderBy(g => g.Name).ToListAsync();
|
||||
|
||||
public async Task<Group> CreateAsync(Group group)
|
||||
{
|
||||
_context.Groups.Add(group);
|
||||
await _context.SaveChangesAsync();
|
||||
return group;
|
||||
}
|
||||
|
||||
public async Task<Group?> UpdateAsync(Group group)
|
||||
{
|
||||
var existing = await _context.Groups.FindAsync(group.Id);
|
||||
if (existing == null) return null;
|
||||
|
||||
existing.Name = group.Name;
|
||||
existing.Description = group.Description;
|
||||
existing.IsActive = group.IsActive;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var group = await _context.Groups.FindAsync(id);
|
||||
if (group == null) return false;
|
||||
_context.Groups.Remove(group);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class SLARepository : ISLARepository
|
||||
{
|
||||
private readonly HelpDeskDbContext _context;
|
||||
|
||||
public SLARepository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<SLAPolicy?> GetByIdAsync(Guid id)
|
||||
=> await _context.SLAPolicies.FindAsync(id);
|
||||
|
||||
public async Task<IEnumerable<SLAPolicy>> GetAllAsync()
|
||||
=> await _context.SLAPolicies.OrderBy(s => s.Name).ToListAsync();
|
||||
|
||||
public async Task<SLAPolicy> CreateAsync(SLAPolicy policy)
|
||||
{
|
||||
_context.SLAPolicies.Add(policy);
|
||||
await _context.SaveChangesAsync();
|
||||
return policy;
|
||||
}
|
||||
|
||||
public async Task<SLAPolicy?> UpdateAsync(SLAPolicy policy)
|
||||
{
|
||||
var existing = await _context.SLAPolicies.FindAsync(policy.Id);
|
||||
if (existing == null) return null;
|
||||
|
||||
existing.Name = policy.Name;
|
||||
existing.Description = policy.Description;
|
||||
existing.ResponseTimeHours = policy.ResponseTimeHours;
|
||||
existing.ResolutionTimeHours = policy.ResolutionTimeHours;
|
||||
existing.IsActive = policy.IsActive;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var policy = await _context.SLAPolicies.FindAsync(id);
|
||||
if (policy == null) return false;
|
||||
_context.SLAPolicies.Remove(policy);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public class TicketRepository : ITicketRepository
|
||||
{
|
||||
private readonly HelpDeskDbContext _context;
|
||||
|
||||
public TicketRepository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
private IQueryable<Ticket> GetQueryable()
|
||||
=> _context.Tickets
|
||||
.Include(t => t.Requester)
|
||||
.Include(t => t.Assignee)
|
||||
.Include(t => t.Group)
|
||||
.Include(t => t.SlaPolicy)
|
||||
.Include(t => t.Comments);
|
||||
|
||||
public async Task<Ticket?> GetByIdAsync(Guid id)
|
||||
=> await GetQueryable().FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
public async Task<IEnumerable<Ticket>> GetAllAsync(TicketStatus? status, TicketPriority? priority, Guid? assigneeId, Guid? requesterId, int page = 1, int pageSize = 20)
|
||||
{
|
||||
var query = GetQueryable().AsQueryable();
|
||||
|
||||
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
|
||||
if (priority.HasValue) query = query.Where(t => t.Priority == priority.Value);
|
||||
if (assigneeId.HasValue) query = query.Where(t => t.AssigneeId == assigneeId.Value);
|
||||
if (requesterId.HasValue) query = query.Where(t => t.RequesterId == requesterId.Value);
|
||||
|
||||
return await query.OrderByDescending(t => t.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetCountAsync(TicketStatus? status, TicketPriority? priority, Guid? assigneeId, Guid? requesterId)
|
||||
{
|
||||
var query = _context.Tickets.AsQueryable();
|
||||
|
||||
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
|
||||
if (priority.HasValue) query = query.Where(t => t.Priority == priority.Value);
|
||||
if (assigneeId.HasValue) query = query.Where(t => t.AssigneeId == assigneeId.Value);
|
||||
if (requesterId.HasValue) query = query.Where(t => t.RequesterId == requesterId.Value);
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<Ticket> CreateAsync(Ticket ticket)
|
||||
{
|
||||
_context.Tickets.Add(ticket);
|
||||
await _context.SaveChangesAsync();
|
||||
return ticket;
|
||||
}
|
||||
|
||||
public async Task<Ticket?> UpdateAsync(Ticket ticket)
|
||||
{
|
||||
var existing = await _context.Tickets.FindAsync(ticket.Id);
|
||||
if (existing == null) return null;
|
||||
|
||||
existing.Title = ticket.Title;
|
||||
existing.Description = ticket.Description;
|
||||
existing.Priority = ticket.Priority;
|
||||
existing.Status = ticket.Status;
|
||||
existing.AssigneeId = ticket.AssigneeId;
|
||||
existing.GroupId = ticket.GroupId;
|
||||
existing.SlaPolicyId = ticket.SlaPolicyId;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (ticket.Status == TicketStatus.Resolved && existing.ResolvedAt == null)
|
||||
existing.ResolvedAt = DateTime.UtcNow;
|
||||
if (ticket.Status == TicketStatus.Closed && existing.ClosedAt == null)
|
||||
existing.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var ticket = await _context.Tickets.FindAsync(id);
|
||||
if (ticket == null) return false;
|
||||
_context.Tickets.Remove(ticket);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> GetTotalCountAsync()
|
||||
=> await _context.Tickets.CountAsync();
|
||||
|
||||
public async Task<int> GetCountByStatusAsync(TicketStatus status)
|
||||
=> await _context.Tickets.CountAsync(t => t.Status == status);
|
||||
|
||||
public async Task<int> GetCountByPriorityAsync(TicketPriority priority)
|
||||
=> await _context.Tickets.CountAsync(t => t.Priority == priority);
|
||||
|
||||
public async Task<double> GetAverageResponseTimeAsync()
|
||||
{
|
||||
var tickets = await _context.Tickets
|
||||
.Where(t => t.Comments.Any())
|
||||
.Select(t => new
|
||||
{
|
||||
t.CreatedAt,
|
||||
FirstCommentAt = t.Comments.OrderBy(c => c.CreatedAt).Select(c => c.CreatedAt).FirstOrDefault()
|
||||
})
|
||||
.Where(t => t.FirstCommentAt != default(DateTime))
|
||||
.ToListAsync();
|
||||
|
||||
if (!tickets.Any()) return 0;
|
||||
return tickets.Average(t => (t.FirstCommentAt - t.CreatedAt).TotalMinutes);
|
||||
}
|
||||
|
||||
public async Task<double> GetAverageResolutionTimeAsync()
|
||||
{
|
||||
var tickets = await _context.Tickets
|
||||
.Where(t => t.ResolvedAt.HasValue)
|
||||
.Select(t => new { t.CreatedAt, t.ResolvedAt })
|
||||
.ToListAsync();
|
||||
|
||||
if (!tickets.Any()) return 0;
|
||||
return tickets.Average(t => (t.ResolvedAt!.Value - t.CreatedAt).TotalMinutes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using HelpDesk.Models.Entities;
|
||||
using HelpDesk.Models.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HelpDesk.Data.Repositories;
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly HelpDeskDbContext _context;
|
||||
|
||||
public UserRepository(HelpDeskDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<User?> GetByIdAsync(Guid id)
|
||||
=> await _context.Users.FirstOrDefaultAsync(u => u.Id == id);
|
||||
|
||||
public async Task<User?> GetByEmailAsync(string email)
|
||||
=> await _context.Users.FirstOrDefaultAsync(u => u.Email == email);
|
||||
|
||||
public async Task<IEnumerable<User>> GetAllAsync()
|
||||
=> await _context.Users.OrderBy(u => u.LastName).ToListAsync();
|
||||
|
||||
public async Task<IEnumerable<User>> GetByRoleAsync(UserRole role)
|
||||
=> await _context.Users.Where(u => u.Role == role && u.IsActive).OrderBy(u => u.LastName).ToListAsync();
|
||||
|
||||
public async Task<User> CreateAsync(User user)
|
||||
{
|
||||
_context.Users.Add(user);
|
||||
await _context.SaveChangesAsync();
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<User?> UpdateAsync(User user)
|
||||
{
|
||||
var existing = await _context.Users.FindAsync(user.Id);
|
||||
if (existing == null) return null;
|
||||
|
||||
existing.FirstName = user.FirstName;
|
||||
existing.LastName = user.LastName;
|
||||
existing.Email = user.Email;
|
||||
existing.PhoneNumber = user.PhoneNumber;
|
||||
existing.AvatarUrl = user.AvatarUrl;
|
||||
existing.Role = user.Role;
|
||||
existing.IsActive = user.IsActive;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string email)
|
||||
=> await _context.Users.AnyAsync(u => u.Email == email);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Models.DTOs;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class RegisterRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AuthResponse
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public DateTime Expiration { get; set; }
|
||||
public UserDto User { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UserDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public UserRole Role { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateUserRequest
|
||||
{
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
public UserRole? Role { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Models.DTOs;
|
||||
|
||||
public class DashboardStatsDto
|
||||
{
|
||||
public int TotalTickets { get; set; }
|
||||
public int OpenTickets { get; set; }
|
||||
public int InProgressTickets { get; set; }
|
||||
public int PendingTickets { get; set; }
|
||||
public int ResolvedTickets { get; set; }
|
||||
public int ClosedTickets { get; set; }
|
||||
public int CriticalTickets { get; set; }
|
||||
public int HighTickets { get; set; }
|
||||
public int MediumTickets { get; set; }
|
||||
public int LowTickets { get; set; }
|
||||
public double AverageResponseTimeMinutes { get; set; }
|
||||
public double AverageResolutionTimeMinutes { get; set; }
|
||||
public List<AgentWorkloadDto> AgentWorkloads { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AgentWorkloadDto
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public int ActiveTickets { get; set; }
|
||||
public int ResolvedThisWeek { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace HelpDesk.Models.DTOs;
|
||||
|
||||
public class GroupDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateGroupRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace HelpDesk.Models.DTOs;
|
||||
|
||||
public class ArticleDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public bool IsPublished { get; set; }
|
||||
public Guid CategoryId { get; set; }
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
public Guid AuthorId { get; set; }
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
public int ViewCount { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateArticleRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public Guid CategoryId { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateArticleRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public Guid? CategoryId { get; set; }
|
||||
public bool? IsPublished { get; set; }
|
||||
}
|
||||
|
||||
public class CategoryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int ArticleCount { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateCategoryRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace HelpDesk.Models.DTOs;
|
||||
|
||||
public class SLADto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ResponseTimeHours { get; set; }
|
||||
public int ResolutionTimeHours { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSLARequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ResponseTimeHours { get; set; }
|
||||
public int ResolutionTimeHours { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSLARequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? ResponseTimeHours { get; set; }
|
||||
public int? ResolutionTimeHours { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Models.DTOs;
|
||||
|
||||
public class CreateTicketRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public TicketPriority Priority { get; set; } = TicketPriority.Medium;
|
||||
public Guid? GroupId { get; set; }
|
||||
public Guid? SlaPolicyId { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateTicketRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public TicketPriority? Priority { get; set; }
|
||||
}
|
||||
|
||||
public class TicketDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public TicketStatus Status { get; set; }
|
||||
public TicketPriority Priority { get; set; }
|
||||
public string TicketNumber { get; set; } = string.Empty;
|
||||
public Guid RequesterId { get; set; }
|
||||
public string RequesterName { get; set; } = string.Empty;
|
||||
public Guid? AssigneeId { get; set; }
|
||||
public string? AssigneeName { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string? GroupName { get; set; }
|
||||
public Guid? SlaPolicyId { get; set; }
|
||||
public string? SlaPolicyName { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public int CommentCount { get; set; }
|
||||
}
|
||||
|
||||
public class TicketListResponse
|
||||
{
|
||||
public List<TicketDto> Items { get; set; } = new();
|
||||
public int TotalCount { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class TicketStatsDto
|
||||
{
|
||||
public int TotalTickets { get; set; }
|
||||
public int OpenTickets { get; set; }
|
||||
public int InProgressTickets { get; set; }
|
||||
public int PendingTickets { get; set; }
|
||||
public int ResolvedTickets { get; set; }
|
||||
public int ClosedTickets { get; set; }
|
||||
}
|
||||
|
||||
public class AddCommentRequest
|
||||
{
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public bool IsInternal { get; set; }
|
||||
}
|
||||
|
||||
public class CommentDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TicketId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public bool IsInternal { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateStatusRequest
|
||||
{
|
||||
public TicketStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class AssignTicketRequest
|
||||
{
|
||||
public Guid? AssigneeId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("activity_logs")]
|
||||
public class ActivityLog
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
|
||||
public int? EntityId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
public string? Details { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public User? User { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("groups")]
|
||||
public class Group
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public ICollection<Ticket> Tickets { get; set; } = new List<Ticket>();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("knowledge_base_articles")]
|
||||
public class KnowledgeBaseArticle
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public Guid CategoryId { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
|
||||
public bool IsPublished { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public KnowledgeBaseCategory Category { get; set; } = null!;
|
||||
public User Author { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("knowledge_base_categories")]
|
||||
public class KnowledgeBaseCategory
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public ICollection<KnowledgeBaseArticle> Articles { get; set; } = new List<KnowledgeBaseArticle>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("sla_policies")]
|
||||
public class SLAPolicy
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int ResponseTimeHours { get; set; }
|
||||
public int ResolutionTimeHours { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public ICollection<Ticket> Tickets { get; set; } = new List<Ticket>();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("tickets")]
|
||||
public class Ticket
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public TicketStatus Status { get; set; } = TicketStatus.Open;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public TicketPriority Priority { get; set; } = TicketPriority.Medium;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string TicketNumber { get; set; } = string.Empty;
|
||||
|
||||
public Guid RequesterId { get; set; }
|
||||
public Guid? AssigneeId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public Guid? SlaPolicyId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? FirstResponseAt { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public User Requester { get; set; } = null!;
|
||||
public User? Assignee { get; set; }
|
||||
public Group? Group { get; set; }
|
||||
public SLAPolicy? SlaPolicy { get; set; }
|
||||
public ICollection<TicketComment> Comments { get; set; } = new List<TicketComment>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("ticket_comments")]
|
||||
public class TicketComment
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TicketId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public bool IsInternal { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public Ticket Ticket { get; set; } = null!;
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using HelpDesk.Models.Enums;
|
||||
|
||||
namespace HelpDesk.Models.Entities;
|
||||
|
||||
[Table("users")]
|
||||
public class User
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(500)]
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public UserRole Role { get; set; } = UserRole.Customer;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? PhoneNumber { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? AvatarUrl { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public ICollection<Ticket> RequestedTickets { get; set; } = new List<Ticket>();
|
||||
public ICollection<Ticket> AssignedTickets { get; set; } = new List<Ticket>();
|
||||
public ICollection<TicketComment> Comments { get; set; } = new List<TicketComment>();
|
||||
public ICollection<KnowledgeBaseArticle> Articles { get; set; } = new List<KnowledgeBaseArticle>();
|
||||
public ICollection<ActivityLog> ActivityLogs { get; set; } = new List<ActivityLog>();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace HelpDesk.Models.Enums;
|
||||
|
||||
public enum TicketPriority
|
||||
{
|
||||
Low = 0,
|
||||
Medium = 1,
|
||||
High = 2,
|
||||
Critical = 3
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace HelpDesk.Models.Enums;
|
||||
|
||||
public enum TicketStatus
|
||||
{
|
||||
Open = 0,
|
||||
InProgress = 1,
|
||||
Pending = 2,
|
||||
Resolved = 3,
|
||||
Closed = 4
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace HelpDesk.Models.Enums;
|
||||
|
||||
public enum UserRole
|
||||
{
|
||||
Customer = 0,
|
||||
Agent = 1,
|
||||
Admin = 2,
|
||||
SuperAdmin = 3
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
<Solution>
|
||||
<Project Path="HelpDesk.API/HelpDesk.API.csproj" />
|
||||
<Project Path="HelpDesk.Data/HelpDesk.Data.csproj" />
|
||||
<Project Path="HelpDesk.Models/HelpDesk.Models.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,126 @@
|
||||
# ============================================================
|
||||
# HelpDesk ICHN - Docker Compose Principal
|
||||
# Caddy + Backend .NET 10 + Frontend Next.js 15 + PG + Redis
|
||||
# ============================================================
|
||||
|
||||
networks:
|
||||
helpdesk-net:
|
||||
name: helpdesk-net
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pgdata-helpdesk:
|
||||
redis-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
services:
|
||||
# =============================================
|
||||
# GATEWAY - Caddy Reverse Proxy
|
||||
# =============================================
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: helpdesk-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8443:443"
|
||||
volumes:
|
||||
- ./gateway/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./gateway/certs:/certs:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
networks:
|
||||
- helpdesk-net
|
||||
|
||||
# =============================================
|
||||
# BACKEND - .NET 10 (HelpDesk.API)
|
||||
# =============================================
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: helpdesk-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_URLS: http://+:5000
|
||||
DB_CONNECTION: Host=helpdesk-postgres;Database=helpdesk;Username=helpdesk;Password=${PG_PASSWORD:-helpdesk_pass}
|
||||
JWT_SECRET: ${JWT_SECRET:-your-super-secret-key-change-in-production-2024}
|
||||
JWT_ISSUER: HelpDeskICHH
|
||||
JWT_AUDIENCE: HelpDeskICHH
|
||||
JWT_EXPIRY_MINUTES: "480"
|
||||
FrontendUrl: http://localhost:3000
|
||||
networks:
|
||||
- helpdesk-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -x dotnet || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# =============================================
|
||||
# FRONTEND - Next.js 15
|
||||
# =============================================
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: helpdesk-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:5000
|
||||
networks:
|
||||
- helpdesk-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
# =============================================
|
||||
# DATABASE - PostgreSQL 16
|
||||
# =============================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: helpdesk-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: helpdesk
|
||||
POSTGRES_USER: helpdesk
|
||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-helpdesk_pass}
|
||||
volumes:
|
||||
- pgdata-helpdesk:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||
ports:
|
||||
- "5433:5432"
|
||||
networks:
|
||||
- helpdesk-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U helpdesk -d helpdesk"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# =============================================
|
||||
# CACHE - Redis 7
|
||||
# =============================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: helpdesk-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- "6381:6379"
|
||||
networks:
|
||||
- helpdesk-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,38 @@
|
||||
FROM node:24-alpine AS base
|
||||
|
||||
# --- DEPENDENCIES ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# --- BUILD ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# --- PRODUCTION ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7273
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-bootstrap": "^1.0.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"next": "16.3.4",
|
||||
"react": "^19.3.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getKBArticles, searchKB, type KBArticle } from "@/lib/api";
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [articles, setArticles] = useState<KBArticle[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getKBArticles()
|
||||
.then(setArticles)
|
||||
.catch(() => {
|
||||
setArticles([
|
||||
{ id: 1, title: "Cómo configurar tu correo en Outlook", content: "Guía paso a paso para configurar...", category: "Correo", publishedAt: "2026-09-01" },
|
||||
{ id: 2, title: "Solicitar acceso a una carpeta compartida", content: "Proceso para solicitar acceso...", category: "Acceso", publishedAt: "2026-09-02" },
|
||||
{ id: 3, title: "Problemas comunes con la impresora", content: "Soluciones a problemas frecuentes...", category: "Hardware", publishedAt: "2026-09-03" },
|
||||
{ id: 4, title: "Instalación de software aprobado", content: "Lista de software aprobado y cómo instalarlo...", category: "Software", publishedAt: "2026-09-04" },
|
||||
{ id: 5, title: "Conectar a la VPN corporativa", content: "Instrucciones para conectarse a la VPN...", category: "Red", publishedAt: "2026-09-05" },
|
||||
{ id: 6, title: "Resetear tu contraseña", content: "Cómo restablecer tu contraseña del dominio...", category: "Acceso", publishedAt: "2026-09-06" },
|
||||
]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await searchKB(searchQuery);
|
||||
setArticles(results);
|
||||
} catch {
|
||||
// Keep existing articles on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ name: "Software", icon: "bi-laptop", color: "#0d6efd", count: articles.filter((a) => a.category === "Software").length },
|
||||
{ name: "Hardware", icon: "bi-pc-display", color: "#198754", count: articles.filter((a) => a.category === "Hardware").length },
|
||||
{ name: "Red", icon: "bi-wifi", color: "#6f42c1", count: articles.filter((a) => a.category === "Red").length },
|
||||
{ name: "Acceso", icon: "bi-key", color: "#fd7e14", count: articles.filter((a) => a.category === "Acceso").length },
|
||||
{ name: "Correo", icon: "bi-envelope", color: "#dc3545", count: articles.filter((a) => a.category === "Correo").length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Base de Conocimiento</h1>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text bg-white border-end-0">
|
||||
<i className="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control border-start-0"
|
||||
placeholder="Buscar artículos, guías, tutoriales..."
|
||||
style={{ fontSize: "1rem", padding: "0.75rem 1rem" }}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<button className="btn btn-primary px-4" onClick={handleSearch}>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="row g-3 mb-4">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.name} className="col-6 col-md-4 col-lg">
|
||||
<div className="card h-100 text-center" style={{ cursor: "pointer" }}
|
||||
onClick={() => { setSearchQuery(cat.name); handleSearch(); }}
|
||||
>
|
||||
<div className="card-body py-4">
|
||||
<i className={`bi ${cat.icon} fs-2 mb-2`} style={{ color: cat.color }}></i>
|
||||
<h6 className="mb-1" style={{ fontWeight: 600 }}>{cat.name}</h6>
|
||||
<small className="text-muted">{cat.count} artículos</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Articles List */}
|
||||
<div className="card">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-journal-text" style={{ color: "var(--primary)" }}></i>
|
||||
Artículos Recientes
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<i className="bi bi-journal-x d-block"></i>
|
||||
<h5>No se encontraron artículos</h5>
|
||||
<p className="text-muted mb-0">Intenta con otros términos de búsqueda</p>
|
||||
</div>
|
||||
) : (
|
||||
articles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="d-flex align-items-center gap-3 p-3 border-bottom"
|
||||
style={{ cursor: "pointer", transition: "background 0.15s" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#f8f9fa")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{ width: 44, height: 44, borderRadius: 10, backgroundColor: "#00285d10", color: "var(--primary)", fontSize: "1.1rem" }}
|
||||
>
|
||||
<i className="bi bi-journal-text"></i>
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="fw-medium" style={{ fontSize: "0.9rem" }}>{article.title}</div>
|
||||
<small className="text-muted">{article.category} · {new Date(article.publishedAt).toLocaleDateString("es-CL")}</small>
|
||||
</div>
|
||||
<i className="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AuthProvider, useAuth } from "@/lib/auth";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: "100vh" }}>
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status" style={{ width: "3rem", height: "3rem" }}></div>
|
||||
<p className="mt-3 text-muted">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
{/* Sidebar Overlay for mobile */}
|
||||
<div
|
||||
className={`sidebar-overlay ${sidebarOpen ? "show" : ""}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`sidebar ${sidebarOpen ? "show" : ""}`} style={{ zIndex: 1040 }}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="main-content flex-grow-1">
|
||||
{/* Top Navbar */}
|
||||
<header className="top-navbar">
|
||||
<button
|
||||
className="btn d-lg-none me-3 p-2"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<i className="bi bi-list fs-4"></i>
|
||||
</button>
|
||||
|
||||
<div className="position-relative flex-grow-1" style={{ maxWidth: "400px" }}>
|
||||
<i className="bi bi-search position-absolute" style={{ left: "12px", top: "50%", transform: "translateY(-50%)", color: "#6c757d" }}></i>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control search-input"
|
||||
placeholder="Buscar tickets, usuarios..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-3 ms-auto">
|
||||
<button className="notification-btn">
|
||||
<i className="bi bi-bell"></i>
|
||||
<span className="notification-badge">3</span>
|
||||
</button>
|
||||
|
||||
<TopNavbarUser />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="content-wrapper fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopNavbarUser() {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<div className="avatar-circle">
|
||||
{user?.name
|
||||
?.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2) || "U"}
|
||||
</div>
|
||||
<span className="d-none d-md-inline" style={{ fontSize: "0.85rem", fontWeight: 500 }}>
|
||||
{user?.name || "Usuario"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import StatsCard from "@/components/StatsCard";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import { getDashboardStats, type DashboardStats } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getDashboardStats()
|
||||
.then(setStats)
|
||||
.catch(() => {
|
||||
// Set mock data for demo
|
||||
setStats({
|
||||
totalTickets: 128,
|
||||
openTickets: 24,
|
||||
pendingTickets: 18,
|
||||
resolvedTickets: 62,
|
||||
closedTickets: 24,
|
||||
avgResponseTime: "2.4h",
|
||||
ticketsByPriority: { low: 30, medium: 45, high: 38, urgent: 15 },
|
||||
recentTickets: [],
|
||||
agentWorkload: [
|
||||
{ agent: "Carlos Méndez", count: 12 },
|
||||
{ agent: "Ana García", count: 9 },
|
||||
{ agent: "Roberto Díaz", count: 7 },
|
||||
{ agent: "María López", count: 5 },
|
||||
],
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxPriority = Math.max(...Object.values(stats?.ticketsByPriority || {}), 1);
|
||||
const maxWorkload = Math.max(...(stats?.agentWorkload?.map((a) => a.count) || [1]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<Link href="/tickets/new" className="btn btn-primary">
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Nuevo Ticket
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-clock-history" value={stats?.pendingTickets || 0} label="Pendientes" color="#ffc107" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" />
|
||||
</div>
|
||||
<div className="col-6 col-lg">
|
||||
<StatsCard icon="bi-speedometer" value={stats?.avgResponseTime || "N/A"} label="Tiempo Respuesta" color="#6f42c1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{/* Priority Distribution */}
|
||||
<div className="col-lg-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-bar-chart" style={{ color: "var(--primary)" }}></i>
|
||||
Tickets por Prioridad
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{Object.entries(stats?.ticketsByPriority || {}).map(([priority, count]) => {
|
||||
const labels: Record<string, string> = { low: "Baja", medium: "Media", high: "Alta", urgent: "Urgente" };
|
||||
const colors: Record<string, string> = { low: "#6c757d", medium: "#0d6efd", high: "#fd7e14", urgent: "#dc3545" };
|
||||
return (
|
||||
<div key={priority} className="mb-3">
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{labels[priority] || priority}</span>
|
||||
<span className="text-muted" style={{ fontSize: "0.85rem" }}>{count}</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: "8px", borderRadius: "4px" }}>
|
||||
<div
|
||||
className="progress-bar"
|
||||
role="progressbar"
|
||||
style={{
|
||||
width: `${(count / maxPriority) * 100}%`,
|
||||
backgroundColor: colors[priority],
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Workload */}
|
||||
<div className="col-lg-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-people" style={{ color: "var(--primary)" }}></i>
|
||||
Carga de Agentes
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{stats?.agentWorkload?.map((item) => (
|
||||
<div key={item.agent} className="mb-3">
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{item.agent}</span>
|
||||
<span className="text-muted" style={{ fontSize: "0.85rem" }}>{item.count} tickets</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: "8px", borderRadius: "4px" }}>
|
||||
<div
|
||||
className="progress-bar"
|
||||
role="progressbar"
|
||||
style={{
|
||||
width: `${(item.count / maxWorkload) * 100}%`,
|
||||
background: "var(--primary-gradient)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!stats?.agentWorkload || stats.agentWorkload.length === 0) && (
|
||||
<div className="text-center text-muted py-4">
|
||||
<i className="bi bi-people fs-1 mb-2 d-block" style={{ opacity: 0.3 }}></i>
|
||||
Sin datos de agentes disponibles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Tickets */}
|
||||
<div className="card mt-4">
|
||||
<div className="card-header d-flex align-items-center justify-content-between">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<i className="bi bi-clock-history" style={{ color: "var(--primary)" }}></i>
|
||||
Tickets Recientes
|
||||
</div>
|
||||
<Link href="/tickets" className="btn btn-sm btn-outline-primary">
|
||||
Ver todos
|
||||
</Link>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-modern mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Asunto</th>
|
||||
<th>Solicitante</th>
|
||||
<th>Estado</th>
|
||||
<th>Prioridad</th>
|
||||
<th>Actualizado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats?.recentTickets?.length ? (
|
||||
stats.recentTickets.map((ticket) => (
|
||||
<tr key={ticket.id} onClick={() => window.location.href = `/tickets/${ticket.id}`}>
|
||||
<td className="fw-semibold" style={{ color: "var(--primary)" }}>
|
||||
{ticket.ticketNumber || `#${ticket.id}`}
|
||||
</td>
|
||||
<td>{ticket.subject}</td>
|
||||
<td>{ticket.requester?.name || "N/A"}</td>
|
||||
<td><BadgeStatus status={ticket.status} /></td>
|
||||
<td><span className={`badge-priority badge-${ticket.priority}`}>{ticket.priority}</span></td>
|
||||
<td className="text-muted">{new Date(ticket.updatedAt).toLocaleDateString("es-CL")}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-muted py-4">
|
||||
<i className="bi bi-inbox fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
||||
No hay tickets recientes
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { getTicket, addComment, updateTicketStatus, assignTicket, getAgents, type Ticket, type User } from "@/lib/api";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import BadgePriority from "@/components/BadgePriority";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const id = params.id as string;
|
||||
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [agents, setAgents] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isInternal, setIsInternal] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getTicket(id).catch(() => ({
|
||||
id: parseInt(id),
|
||||
ticketNumber: "#TK-" + id.padStart(3, "0"),
|
||||
subject: "No puedo acceder al sistema de correo electrónico desde esta mañana",
|
||||
description: "Buenos días, desde esta mañana no puedo acceder a mi correo electrónico. Ya intenté reiniciar la computadora pero el problema persiste. Necesito acceso urgente para revisar correos importantes del proyecto.",
|
||||
status: "open",
|
||||
priority: "high",
|
||||
category: "Software",
|
||||
requesterId: 1,
|
||||
groupId: null,
|
||||
requester: { id: 1, name: "María García", email: "maria@test.com", role: "customer" as const },
|
||||
assigneeId: 2,
|
||||
assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const },
|
||||
group: { id: 1, name: "Soporte TI" },
|
||||
comments: [
|
||||
{ id: 1, ticketId: parseInt(id), userId: 2, user: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const }, content: "Estoy revisando el problema. Verificaré la configuración de su cuenta de correo.", isInternal: false, createdAt: "2026-09-07T10:30:00Z" },
|
||||
{ id: 2, ticketId: parseInt(id), userId: 1, user: { id: 1, name: "María García", email: "maria@test.com", role: "customer" as const }, content: "Gracias, estoy esperando. Necesito acceso lo antes posible.", isInternal: false, createdAt: "2026-09-07T11:15:00Z" },
|
||||
{ id: 3, ticketId: parseInt(id), userId: 2, user: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const }, content: "Nota interna: Verificar con el equipo de infra si hay problemas con el servidor de Exchange.", isInternal: true, createdAt: "2026-09-07T11:30:00Z" },
|
||||
],
|
||||
createdAt: "2026-09-07T09:00:00Z",
|
||||
updatedAt: "2026-09-07T11:30:00Z",
|
||||
} as Ticket)),
|
||||
getAgents().catch(() => []),
|
||||
]).then(([ticketData, agentsData]) => {
|
||||
setTicket(ticketData);
|
||||
setAgents(agentsData);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleAddComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const newComment = await addComment(id, comment, isInternal);
|
||||
setTicket((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, comments: [...(prev.comments || []), newComment] };
|
||||
});
|
||||
setComment("");
|
||||
setIsInternal(false);
|
||||
} catch {
|
||||
// Add locally for demo
|
||||
if (ticket) {
|
||||
const localComment = {
|
||||
id: Date.now(),
|
||||
ticketId: parseInt(id),
|
||||
userId: user?.id || 0,
|
||||
user: user || { id: 0, name: "Usuario", email: "", role: "agent" as const },
|
||||
content: comment,
|
||||
isInternal,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setTicket({ ...ticket, comments: [...(ticket.comments || []), localComment] });
|
||||
setComment("");
|
||||
setIsInternal(false);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
await updateTicketStatus(id, parseInt(newStatus));
|
||||
if (ticket) setTicket({ ...ticket, status: newStatus });
|
||||
} catch {
|
||||
if (ticket) setTicket({ ...ticket, status: newStatus });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await assignTicket(id, parseInt(agentId));
|
||||
const agent = agents.find((a) => a.id === parseInt(agentId));
|
||||
if (ticket && agent) setTicket({ ...ticket, assigneeId: agent.id, assignee: agent });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<i className="bi bi-exclamation-triangle d-block"></i>
|
||||
<h5>Ticket no encontrado</h5>
|
||||
<Link href="/tickets" className="btn btn-primary mt-3">Volver a tickets</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Ticket Header */}
|
||||
<div className="ticket-header">
|
||||
<div>
|
||||
<div className="ticket-number mb-1">
|
||||
<Link href="/tickets" className="text-decoration-none me-2">
|
||||
<i className="bi bi-arrow-left"></i>
|
||||
</Link>
|
||||
{ticket.ticketNumber || `#${ticket.id}`}
|
||||
</div>
|
||||
<h3 className="ticket-subject">{ticket.subject}</h3>
|
||||
<div className="d-flex gap-2 mt-2">
|
||||
<BadgeStatus status={ticket.status} />
|
||||
<BadgePriority priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{/* Status Change */}
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-arrow-repeat me-1"></i>Cambiar Estado
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{[
|
||||
{ value: "open", label: "Abierto" },
|
||||
{ value: "pending", label: "Pendiente" },
|
||||
{ value: "in_progress", label: "En Progreso" },
|
||||
{ value: "resolved", label: "Resuelto" },
|
||||
{ value: "closed", label: "Cerrado" },
|
||||
].map((s) => (
|
||||
<li key={s.value}>
|
||||
<button className="dropdown-item" onClick={() => handleStatusChange(s.value)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Assign */}
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-person-plus me-1"></i>Asignar
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{agents.map((a) => (
|
||||
<li key={a.id}>
|
||||
<button className="dropdown-item" onClick={() => handleAssign(String(a.id))}>
|
||||
{a.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{/* Comments Timeline */}
|
||||
<div className="col-lg-8">
|
||||
<div className="card mb-4">
|
||||
<div className="card-header d-flex align-items-center gap-2">
|
||||
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
|
||||
Comentarios ({ticket.comments?.length || 0})
|
||||
</div>
|
||||
<div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}>
|
||||
{ticket.comments?.length ? (
|
||||
ticket.comments.map((c) => {
|
||||
const isAgent = c.user?.role === "agent" || c.user?.role === "admin";
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`comment ${isAgent ? "comment-agent" : "comment-customer"} ${
|
||||
c.isInternal ? "comment-internal" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-author">
|
||||
{c.user?.name || "Usuario"}
|
||||
{c.isInternal && (
|
||||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
|
||||
Nota Interna
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="comment-text">{c.content}</div>
|
||||
<div className="comment-time">
|
||||
{new Date(c.createdAt).toLocaleString("es-CL")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-muted py-4">
|
||||
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
||||
No hay comentarios aún
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
<div className="comment-form">
|
||||
<form onSubmit={handleAddComment}>
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Escribe tu comentario..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="internalCheck"
|
||||
checked={isInternal}
|
||||
onChange={(e) => setIsInternal(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="internalCheck" style={{ fontSize: "0.85rem" }}>
|
||||
<i className="bi bi-eye-slash me-1"></i>
|
||||
Nota interna (visible solo para agentes)
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!comment.trim() || submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="spinner-border spinner-border-sm" role="status"></span>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-send me-2"></i>
|
||||
Enviar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket Info Sidebar */}
|
||||
<div className="col-lg-4">
|
||||
<div className="ticket-info-card">
|
||||
<h6>Información del Ticket</h6>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Solicitante</span>
|
||||
<span className="value">{ticket.requester?.name || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Email</span>
|
||||
<span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requester?.email || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Asignado a</span>
|
||||
<span className="value">{ticket.assignee?.name || "Sin asignar"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Grupo</span>
|
||||
<span className="value">{ticket.group?.name || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Categoría</span>
|
||||
<span className="value">{ticket.category || "N/A"}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Creado</span>
|
||||
<span className="value">{new Date(ticket.createdAt).toLocaleString("es-CL")}</span>
|
||||
</div>
|
||||
<div className="ticket-info-row">
|
||||
<span className="label">Actualizado</span>
|
||||
<span className="value">{new Date(ticket.updatedAt).toLocaleString("es-CL")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="ticket-info-card">
|
||||
<h6>Descripción</h6>
|
||||
<p style={{ fontSize: "0.875rem", lineHeight: 1.6, marginBottom: 0 }}>
|
||||
{ticket.description || "Sin descripción"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createTicket, getAgents, type User } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewTicketPage() {
|
||||
const router = useRouter();
|
||||
const [agents, setAgents] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
const [category, setCategory] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getAgents().catch((): User[] => [
|
||||
{ id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" as const },
|
||||
{ id: 4, name: "Ana García", email: "ana@test.com", role: "agent" as const },
|
||||
]).then((data) => setAgents(data));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const ticket = await createTicket({
|
||||
subject,
|
||||
description,
|
||||
priority,
|
||||
category,
|
||||
assigneeId: assigneeId ? parseInt(assigneeId) : undefined,
|
||||
});
|
||||
router.push(`/tickets/${ticket.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al crear el ticket");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<Link href="/tickets" className="text-decoration-none text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
<i className="bi bi-arrow-left me-1"></i>Volver a tickets
|
||||
</Link>
|
||||
<h1 className="mt-1">Nuevo Ticket</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<div className="card">
|
||||
<div className="card-body p-4">
|
||||
{error && (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
<i className="bi bi-exclamation-circle me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-semibold">Asunto</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Describe brevemente el problema o solicitud"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label fw-semibold">Descripción</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={6}
|
||||
placeholder="Proporciona detalles sobre el problema, pasos para reproducir, etc."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label fw-semibold">Prioridad</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
>
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<label className="form-label fw-semibold">Categoría</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Seleccionar categoría</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Red">Red</option>
|
||||
<option value="Acceso">Acceso</option>
|
||||
<option value="Correo">Correo</option>
|
||||
<option value="Otro">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<label className="form-label fw-semibold">Asignar a</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
>
|
||||
<option value="">Sin asignar</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary px-4"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Crear Ticket
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link href="/tickets" className="btn btn-outline-secondary">
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="col-lg-4">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h6 className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-lightbulb" style={{ color: "#ffc107" }}></i>
|
||||
Consejos
|
||||
</h6>
|
||||
<ul className="mb-0" style={{ fontSize: "0.85rem", lineHeight: 1.7 }}>
|
||||
<li>Sé específico en el asunto del ticket</li>
|
||||
<li>Incluye pasos para reproducir el problema</li>
|
||||
<li>Adjunta capturas de pantalla si es posible</li>
|
||||
<li>Selecciona la categoría correcta para una atención más rápida</li>
|
||||
<li>Asigna la prioridad adecuada según el impacto</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getTickets, type Ticket } from "@/lib/api";
|
||||
import BadgeStatus from "@/components/BadgeStatus";
|
||||
import BadgePriority from "@/components/BadgePriority";
|
||||
import PaginationComponent from "@/components/Pagination";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TicketsPage() {
|
||||
const router = useRouter();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [priorityFilter, setPriorityFilter] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const pageSize = 10;
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getTickets({
|
||||
status: statusFilter || undefined,
|
||||
priority: priorityFilter || undefined,
|
||||
search: search || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setTickets(data.tickets || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setTickets([
|
||||
{ id: 1, ticketNumber: "#TK-001", subject: "No puedo acceder a mi correo", description: "", status: "open", priority: "high", category: "Software", requesterId: 1, groupId: null, requester: { id: 1, name: "María García", email: "maria@test.com", role: "customer" }, assigneeId: 2, assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" }, createdAt: "2026-09-01", updatedAt: "2026-09-07" },
|
||||
{ id: 2, ticketNumber: "#TK-002", subject: "Impresora del piso 3 no funciona", description: "", status: "pending", priority: "medium", category: "Hardware", requesterId: 3, groupId: null, requester: { id: 3, name: "Pedro Soto", email: "pedro@test.com", role: "customer" }, assigneeId: 4, assignee: { id: 4, name: "Ana García", email: "ana@test.com", role: "agent" }, createdAt: "2026-09-05", updatedAt: "2026-09-07" },
|
||||
{ id: 3, ticketNumber: "#TK-003", subject: "Solicitud de acceso al sistema contable", description: "", status: "resolved", priority: "low", category: "Acceso", requesterId: 5, groupId: null, requester: { id: 5, name: "Laura Fuentes", email: "laura@test.com", role: "customer" }, assigneeId: null, createdAt: "2026-09-03", updatedAt: "2026-09-06" },
|
||||
{ id: 4, ticketNumber: "#TK-004", subject: "Laptop con pantalla parpadeante", description: "", status: "in_progress", priority: "urgent", category: "Hardware", requesterId: 6, groupId: null, requester: { id: 6, name: "Roberto Díaz", email: "roberto@test.com", role: "customer" }, assigneeId: 2, assignee: { id: 2, name: "Carlos Méndez", email: "carlos@test.com", role: "agent" }, createdAt: "2026-09-08", updatedAt: "2026-09-08" },
|
||||
]);
|
||||
setTotal(4);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, priorityFilter, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, [fetchTickets]);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Tickets</h1>
|
||||
<Link href="/tickets/new" className="btn btn-primary">
|
||||
<i className="bi bi-plus-lg me-2"></i>
|
||||
Nuevo Ticket
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filter-bar mb-4">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<i className="bi bi-funnel text-muted"></i>
|
||||
<span className="fw-semibold" style={{ fontSize: "0.85rem" }}>Filtros:</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ width: "auto" }}
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="open">Abierto</option>
|
||||
<option value="pending">Pendiente</option>
|
||||
<option value="in_progress">En Progreso</option>
|
||||
<option value="resolved">Resuelto</option>
|
||||
<option value="closed">Cerrado</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ width: "auto" }}
|
||||
value={priorityFilter}
|
||||
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">Todas las prioridades</option>
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
|
||||
<div className="position-relative flex-grow-1" style={{ maxWidth: "280px" }}>
|
||||
<i className="bi bi-search position-absolute" style={{ left: "10px", top: "50%", transform: "translateY(-50%)", color: "#6c757d" }}></i>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
style={{ paddingLeft: "2rem" }}
|
||||
placeholder="Buscar tickets..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickets Table */}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-modern mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Asunto</th>
|
||||
<th>Solicitante</th>
|
||||
<th>Estado</th>
|
||||
<th>Prioridad</th>
|
||||
<th>Asignado</th>
|
||||
<th>Actualizado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-4">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</td>
|
||||
</tr>
|
||||
) : tickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="empty-state">
|
||||
<i className="bi bi-inbox d-block"></i>
|
||||
<h5>No se encontraron tickets</h5>
|
||||
<p className="text-muted mb-0">Prueba ajustando los filtros de búsqueda</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tickets.map((ticket) => (
|
||||
<tr
|
||||
key={ticket.id}
|
||||
onClick={() => router.push(`/tickets/${ticket.id}`)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<td className="fw-semibold" style={{ color: "var(--primary)" }}>
|
||||
{ticket.ticketNumber || `#${ticket.id}`}
|
||||
</td>
|
||||
<td>
|
||||
<div className="fw-medium">{ticket.subject}</div>
|
||||
<small className="text-muted">{ticket.category}</small>
|
||||
</td>
|
||||
<td>{ticket.requester?.name || "N/A"}</td>
|
||||
<td><BadgeStatus status={ticket.status} /></td>
|
||||
<td><BadgePriority priority={ticket.priority} /></td>
|
||||
<td>{ticket.assignee?.name || <span className="text-muted">Sin asignar</span>}</td>
|
||||
<td className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||
{new Date(ticket.updatedAt).toLocaleDateString("es-CL")}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="pagination-wrapper">
|
||||
<span className="pagination-info">
|
||||
Mostrando {tickets.length} de {total} tickets
|
||||
</span>
|
||||
<PaginationComponent currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push("/");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Credenciales inválidas");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page d-flex">
|
||||
{/* Left Panel */}
|
||||
<div className="auth-panel-left d-none d-md-flex col-md-5">
|
||||
<div>
|
||||
<i className="bi bi-headset display-1 mb-3"></i>
|
||||
<h2>HelpDesk ICHN</h2>
|
||||
<p>
|
||||
Sistema de gestión de tickets de soporte técnico. Gestiona incidencias,
|
||||
solicitudes y consultas de manera eficiente.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>Gestión centralizada de tickets</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>Base de conocimiento integrada</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>SLA y métricas de rendimiento</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Form */}
|
||||
<div className="auth-panel-right col-md-7 col-12">
|
||||
<div className="auth-form">
|
||||
<div className="d-md-none text-center mb-4">
|
||||
<i className="bi bi-headset display-4" style={{ color: "var(--primary)" }}></i>
|
||||
<h4 className="mt-2" style={{ color: "var(--primary)" }}>HelpDesk ICHN</h4>
|
||||
</div>
|
||||
|
||||
<h3>Iniciar Sesión</h3>
|
||||
<p className="subtitle">Ingresa tus credenciales para acceder al sistema</p>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
<i className="bi bi-exclamation-circle me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Correo Electrónico</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
placeholder="tu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 py-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Ingresando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-box-arrow-in-right me-2"></i>
|
||||
Iniciar Sesión
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<span className="text-muted" style={{ fontSize: "0.9rem" }}>
|
||||
¿No tienes cuenta?{" "}
|
||||
<Link href="/register" style={{ color: "var(--primary)", fontWeight: 500 }}>
|
||||
Regístrate aquí
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { register as apiRegister } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Las contraseñas no coinciden");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiRegister({ name, email, password });
|
||||
await login(email, password);
|
||||
router.push("/");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error al registrar usuario");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page d-flex">
|
||||
{/* Left Panel */}
|
||||
<div className="auth-panel-left d-none d-md-flex col-md-5">
|
||||
<div>
|
||||
<i className="bi bi-headset display-1 mb-3"></i>
|
||||
<h2>HelpDesk ICHN</h2>
|
||||
<p>
|
||||
Crea tu cuenta para comenzar a gestionar tickets de soporte técnico
|
||||
y acceder a la base de conocimiento.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>Acceso completo al sistema</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>Crear y responder tickets</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2 mb-3">
|
||||
<i className="bi bi-check-circle-fill"></i>
|
||||
<span>Buscar en la base de conocimiento</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Form */}
|
||||
<div className="auth-panel-right col-md-7 col-12">
|
||||
<div className="auth-form">
|
||||
<div className="d-md-none text-center mb-4">
|
||||
<i className="bi bi-headset display-4" style={{ color: "var(--primary)" }}></i>
|
||||
<h4 className="mt-2" style={{ color: "var(--primary)" }}>HelpDesk ICHN</h4>
|
||||
</div>
|
||||
|
||||
<h3>Crear Cuenta</h3>
|
||||
<p className="subtitle">Regístrate para acceder al sistema de soporte</p>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
<i className="bi bi-exclamation-circle me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Nombre Completo</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Juan Pérez"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Correo Electrónico</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
placeholder="tu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label">Confirmar Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 py-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Creando cuenta...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-person-plus me-2"></i>
|
||||
Crear Cuenta
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<span className="text-muted" style={{ fontSize: "0.9rem" }}>
|
||||
¿Ya tienes cuenta?{" "}
|
||||
<Link href="/login" style={{ color: "var(--primary)", fontWeight: 500 }}>
|
||||
Inicia sesión
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,800 @@
|
||||
:root {
|
||||
--primary: #00285d;
|
||||
--primary-light: #1a3f7a;
|
||||
--primary-lighter: #2a5a9a;
|
||||
--primary-gradient: linear-gradient(135deg, #00285d 0%, #1a4a8a 100%);
|
||||
--bg: #f0f2f5;
|
||||
--card-shadow: 0 2px 12px rgba(0, 40, 93, 0.08);
|
||||
--card-hover-shadow: 0 4px 20px rgba(0, 40, 93, 0.12);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--text-dark: #1a1a2e;
|
||||
--text-muted: #6c757d;
|
||||
--sidebar-width: 260px;
|
||||
--navbar-height: 64px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Bootstrap Primary Override */
|
||||
:root {
|
||||
--bs-primary: #00285d;
|
||||
--bs-primary-rgb: 0, 40, 93;
|
||||
--bs-link-color: #00285d;
|
||||
--bs-link-hover-color: #1a3f7a;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-gradient) !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 40, 93, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 40, 93, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #00285d 0%, #001a3d 100%);
|
||||
color: #fff;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1040;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0.125rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-brand h5 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
padding: 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-user .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-user-info .name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-user-info .role {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Top Navbar */
|
||||
.top-navbar {
|
||||
height: var(--navbar-height);
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
.top-navbar .search-input {
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.top-navbar .search-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 40, 93, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.notification-btn:hover {
|
||||
background-color: #f0f2f5;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
background: #fff;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
padding: 1rem 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Stats Card */
|
||||
.stats-card {
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
background: #fff;
|
||||
border: none;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.stats-card .stats-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stats-card .stats-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stats-card .stats-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.badge-status {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-open {
|
||||
background-color: #d1e7dd;
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background-color: #fff3cd;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.badge-resolved {
|
||||
background-color: #cfe2ff;
|
||||
color: #084298;
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
background-color: #e2e3e5;
|
||||
color: #41464b;
|
||||
}
|
||||
|
||||
.badge-in-progress {
|
||||
background-color: #cff4fc;
|
||||
color: #055160;
|
||||
}
|
||||
|
||||
/* Priority Badges */
|
||||
.badge-priority {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-low {
|
||||
background-color: #e2e3e5;
|
||||
color: #41464b;
|
||||
}
|
||||
|
||||
.badge-medium {
|
||||
background-color: #cfe2ff;
|
||||
color: #084298;
|
||||
}
|
||||
|
||||
.badge-high {
|
||||
background-color: #ffecb5;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.badge-urgent {
|
||||
background-color: #f8d7da;
|
||||
color: #842029;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-modern {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.table-modern thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-modern thead th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.table-modern tbody tr {
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-modern tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-modern tbody td {
|
||||
padding: 0.875rem 1rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.table-modern tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Login/Register Page */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.auth-panel-left {
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-panel-left h2 {
|
||||
font-weight: 700;
|
||||
font-size: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-panel-left p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.85;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.auth-panel-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-form h3 {
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-form .subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-form .form-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.auth-form .form-control {
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-form .form-control:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 40, 93, 0.15);
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.25rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar .form-select,
|
||||
.filter-bar .form-control {
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid #dee2e6;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Ticket Detail */
|
||||
.ticket-header {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ticket-number {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.ticket-subject {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ticket-info-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ticket-info-card h6 {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.ticket-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ticket-info-row .label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ticket-info-row .value {
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Comments Timeline */
|
||||
.comment {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.comment-agent {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.comment-agent .comment-bubble {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: var(--radius) var(--radius) 4px var(--radius);
|
||||
padding: 0.875rem 1rem;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.comment-customer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.comment-customer .comment-bubble {
|
||||
background: #f0f2f5;
|
||||
color: var(--text-dark);
|
||||
border-radius: var(--radius) var(--radius) var(--radius) 4px;
|
||||
padding: 0.875rem 1rem;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.comment-internal .comment-bubble {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Comment Form */
|
||||
.comment-form {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 991.98px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.top-navbar .search-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.auth-panel-left {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-panel-right {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ticket-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.comment-bubble {
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-bar .form-select,
|
||||
.filter-bar .form-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c4c8cc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0a4a8;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* KB Search */
|
||||
.kb-search-input {
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid #e9ecef;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.kb-search-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 40, 93, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state h5 {
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Sidebar overlay for mobile */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1035;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.sidebar-overlay.show {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* User avatar circle */
|
||||
.avatar-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "HelpDesk ICHN",
|
||||
description: "Sistema de Gestión de Tickets de Soporte - ICHN",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className={`${inter.variable} h-full antialiased`}>
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&display=swap"
|
||||
/>
|
||||
</head>
|
||||
<body style={{ fontFamily: "'Fira Sans', sans-serif" }}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const priorityConfig: Record<string, { className: string; label: string }> = {
|
||||
low: { className: "badge-low", label: "Baja" },
|
||||
medium: { className: "badge-medium", label: "Media" },
|
||||
high: { className: "badge-high", label: "Alta" },
|
||||
urgent: { className: "badge-urgent", label: "Urgente" },
|
||||
"1": { className: "badge-low", label: "Baja" },
|
||||
"2": { className: "badge-medium", label: "Media" },
|
||||
"3": { className: "badge-high", label: "Alta" },
|
||||
"4": { className: "badge-urgent", label: "Urgente" },
|
||||
};
|
||||
|
||||
export default function BadgePriority({ priority }: { priority: string }) {
|
||||
const config = priorityConfig[priority] || { className: "badge-medium", label: priority };
|
||||
return <span className={`badge-priority ${config.className}`}>{config.label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const statusConfig: Record<string, { className: string; label: string }> = {
|
||||
open: { className: "badge-open", label: "Abierto" },
|
||||
pending: { className: "badge-pending", label: "Pendiente" },
|
||||
in_progress: { className: "badge-in-progress", label: "En Progreso" },
|
||||
resolved: { className: "badge-resolved", label: "Resuelto" },
|
||||
closed: { className: "badge-closed", label: "Cerrado" },
|
||||
"1": { className: "badge-open", label: "Abierto" },
|
||||
"2": { className: "badge-pending", label: "Pendiente" },
|
||||
"3": { className: "badge-in-progress", label: "En Progreso" },
|
||||
"4": { className: "badge-resolved", label: "Resuelto" },
|
||||
"5": { className: "badge-closed", label: "Cerrado" },
|
||||
};
|
||||
|
||||
export default function BadgeStatus({ status }: { status: string }) {
|
||||
const config = statusConfig[status] || { className: "badge-open", label: status };
|
||||
return <span className={`badge-status ${config.className}`}>{config.label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function PaginationComponent({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: (number | "...")[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pagination-wrapper">
|
||||
<nav>
|
||||
<ul className="pagination mb-0">
|
||||
<li className={`page-item ${currentPage === 1 ? "disabled" : ""}`}>
|
||||
<button className="page-link" onClick={() => onPageChange(currentPage - 1)}>
|
||||
<i className="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
{pages.map((page, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className={`page-item ${page === currentPage ? "active" : ""} ${
|
||||
page === "..." ? "disabled" : ""
|
||||
}`}
|
||||
>
|
||||
{page === "..." ? (
|
||||
<span className="page-link">…</span>
|
||||
) : (
|
||||
<button className="page-link" onClick={() => onPageChange(page)}>
|
||||
{page}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li className={`page-item ${currentPage === totalPages ? "disabled" : ""}`}>
|
||||
<button className="page-link" onClick={() => onPageChange(currentPage + 1)}>
|
||||
<i className="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", icon: "bi-speedometer2", label: "Dashboard" },
|
||||
{ href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" },
|
||||
{ href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" },
|
||||
{ href: "/users", icon: "bi-people", label: "Users" },
|
||||
{ href: "/settings", icon: "bi-gear", label: "Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="sidebar" id="sidebar">
|
||||
<div className="sidebar-brand">
|
||||
<i className="bi bi-headset fs-3"></i>
|
||||
<h5>HelpDesk ICHN</h5>
|
||||
</div>
|
||||
|
||||
<nav className="flex-column mt-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`nav-link ${
|
||||
pathname === item.href ||
|
||||
(item.href !== "/" && pathname.startsWith(item.href))
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<i className={`bi ${item.icon}`}></i>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-user">
|
||||
<div className="avatar">
|
||||
{user?.name
|
||||
?.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2) || "U"}
|
||||
</div>
|
||||
<div className="sidebar-user-info">
|
||||
<div className="name">{user?.name || "Usuario"}</div>
|
||||
<div className="role">{user?.role || "Agente"}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="btn btn-link text-white p-1"
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<i className="bi bi-box-arrow-right fs-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
interface StatsCardProps {
|
||||
icon: string;
|
||||
value: string | number;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function StatsCard({ icon, value, label, color = "#00285d" }: StatsCardProps) {
|
||||
return (
|
||||
<div className="stats-card">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<div className="stats-icon" style={{ backgroundColor: `${color}15`, color }}>
|
||||
<i className={`bi ${icon}`}></i>
|
||||
</div>
|
||||
<div>
|
||||
<div className="stats-value">{value}</div>
|
||||
<div className="stats-label">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000";
|
||||
|
||||
async function fetchApi<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token =
|
||||
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `API Error: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
export async function login(email: string, password: string) {
|
||||
return fetchApi<{ token: string; user: User }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function register(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
}) {
|
||||
return fetchApi<{ token: string; user: User }>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
return fetchApi<User>("/api/auth/me");
|
||||
}
|
||||
|
||||
// Tickets
|
||||
export async function getTickets(params?: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigneeId?: string;
|
||||
requesterId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "") query.set(key, String(value));
|
||||
});
|
||||
}
|
||||
return fetchApi<{ tickets: Ticket[]; total: number; page: number; pageSize: number }>(
|
||||
`/api/tickets?${query.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTicket(id: string) {
|
||||
return fetchApi<Ticket>(`/api/tickets/${id}`);
|
||||
}
|
||||
|
||||
export async function createTicket(data: {
|
||||
subject: string;
|
||||
description: string;
|
||||
priority?: string;
|
||||
category?: string;
|
||||
assigneeId?: number;
|
||||
}) {
|
||||
return fetchApi<Ticket>("/api/tickets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTicket(id: string, data: Partial<Ticket>) {
|
||||
return fetchApi<Ticket>(`/api/tickets/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTicket(id: string) {
|
||||
return fetchApi<void>(`/api/tickets/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTicketStatus(id: string, status: number) {
|
||||
return fetchApi<Ticket>(`/api/tickets/${id}/status`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignTicket(id: string, assigneeId: number) {
|
||||
return fetchApi<Ticket>(`/api/tickets/${id}/assign`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ assigneeId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
ticketId: string,
|
||||
content: string,
|
||||
isInternal: boolean = false
|
||||
) {
|
||||
return fetchApi<Comment>(`/api/tickets/${ticketId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content, isInternal }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTicketStats() {
|
||||
return fetchApi<TicketStats>("/api/tickets/stats");
|
||||
}
|
||||
|
||||
// Users
|
||||
export async function getUsers() {
|
||||
return fetchApi<User[]>("/api/users");
|
||||
}
|
||||
|
||||
export async function getAgents() {
|
||||
return fetchApi<User[]>("/api/users/agents");
|
||||
}
|
||||
|
||||
// Knowledge Base
|
||||
export async function getKBArticles() {
|
||||
return fetchApi<KBArticle[]>("/api/kb/articles/published");
|
||||
}
|
||||
|
||||
export async function searchKB(query: string) {
|
||||
return fetchApi<KBArticle[]>(`/api/kb/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export async function getDashboardStats() {
|
||||
return fetchApi<DashboardStats>("/api/dashboard/stats");
|
||||
}
|
||||
|
||||
// Groups & SLA
|
||||
export async function getGroups() {
|
||||
return fetchApi<Group[]>("/api/groups");
|
||||
}
|
||||
|
||||
export async function getSLAPolicies() {
|
||||
return fetchApi<SLAPolicy[]>("/api/sla");
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "admin" | "agent" | "customer";
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
ticketNumber: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
requesterId: number;
|
||||
requester?: User;
|
||||
assigneeId: number | null;
|
||||
assignee?: User;
|
||||
groupId: number | null;
|
||||
group?: Group;
|
||||
comments?: Comment[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
ticketId: number;
|
||||
userId: number;
|
||||
user?: User;
|
||||
content: string;
|
||||
isInternal: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface KBArticle {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
author?: User;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface SLAPolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: string;
|
||||
responseTime: string;
|
||||
resolutionTime: string;
|
||||
}
|
||||
|
||||
export interface TicketStats {
|
||||
total: number;
|
||||
open: number;
|
||||
pending: number;
|
||||
resolved: number;
|
||||
closed: number;
|
||||
inProgress: number;
|
||||
avgResponseTime: string;
|
||||
byPriority: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalTickets: number;
|
||||
openTickets: number;
|
||||
pendingTickets: number;
|
||||
resolvedTickets: number;
|
||||
closedTickets: number;
|
||||
avgResponseTime: string;
|
||||
ticketsByPriority: Record<string, number>;
|
||||
recentTickets: Ticket[];
|
||||
agentWorkload: { agent: string; count: number }[];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
import { getMe, login as apiLogin, type User } from "./api";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("helpdesk_token");
|
||||
if (token) {
|
||||
getMe()
|
||||
.then((u) => {
|
||||
setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("helpdesk_token");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const { token, user } = await apiLogin(email, password);
|
||||
localStorage.setItem("helpdesk_token", token);
|
||||
setUser(user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("helpdesk_token");
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, login, logout, isAuthenticated: !!user, isLoading }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# HelpDesk ICHN - Caddyfile
|
||||
# Reverse proxy for API + Frontend
|
||||
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:80 {
|
||||
# API routes
|
||||
handle /api/* {
|
||||
reverse_proxy api:5000
|
||||
}
|
||||
|
||||
# Swagger (dev only)
|
||||
handle /swagger/* {
|
||||
reverse_proxy api:5000
|
||||
}
|
||||
|
||||
# Frontend (Next.js)
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- HelpDesk ICHN - Database Initialization
|
||||
-- This is handled by EF Core migrations, but we keep this for reference
|
||||
|
||||
-- Ensure the database exists (already created by POSTGRES_DB env var)
|
||||
-- Tables will be created by EF Core on first run
|
||||
|
||||
SELECT 'HelpDesk database initialized' AS status;
|
||||
Reference in new issue
Block a user