feat: Implement training record notification system with UI and backend email integration, and ensure 'TRAIN' category seeding.
This commit is contained in:
@@ -49,6 +49,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
|||||||
- Corretta chiave di traduzione errata per la tab "Gestione Applicazioni" e migliorata la gestione dell'aggiornamento etichette tab.
|
- Corretta chiave di traduzione errata per la tab "Gestione Applicazioni" e migliorata la gestione dell'aggiornamento etichette tab.
|
||||||
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
|
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
|
||||||
- [2025-12-12 Training Course Module](./devlog/2025-12-12-105500_training_course_module.md) - **Completato**
|
- [2025-12-12 Training Course Module](./devlog/2025-12-12-105500_training_course_module.md) - **Completato**
|
||||||
|
- Implementazione gestione Corsi (sottocategorie Formazione), Registro Training, Scadenze, Notifiche e Dashboard.
|
||||||
- [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato**
|
- [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato**
|
||||||
- [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato**
|
- [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato**
|
||||||
- [2025-12-12 Magazzino: Categorie Gerarchiche](./devlog/2025-12-12-133000_remove_product_groups_add_categories.md) - **Completato**
|
- [2025-12-12 Magazzino: Categorie Gerarchiche](./devlog/2025-12-12-133000_remove_product_groups_add_categories.md) - **Completato**
|
||||||
|
|||||||
@@ -60,14 +60,14 @@ Mapping delle funzionalità sui moduli esistenti:
|
|||||||
- `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso).
|
- `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso).
|
||||||
|
|
||||||
#### Integrazione Moduli Esistenti
|
#### Integrazione Moduli Esistenti
|
||||||
- [ ] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia).
|
- [x] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia). (Implementato selezione sottocategorie in RegistryPage)
|
||||||
- [x] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo.
|
- [x] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo.
|
||||||
- [x] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
|
- [x] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
|
||||||
- [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`.
|
- [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`.
|
||||||
|
|
||||||
### 4. Workflow e Notifiche
|
### 4. Workflow e Notifiche
|
||||||
- [ ] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard.
|
- [x] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. (Aggiunto pulsante invio notifica)
|
||||||
- [ ] Integrazione con il Modulo Email per invio solleciti scadenze.
|
- [x] Integrazione con il Modulo Email per invio solleciti scadenze.
|
||||||
|
|
||||||
### 5. Verifica e Test
|
### 5. Verifica e Test
|
||||||
- [ ] Test flusso completo:
|
- [ ] Test flusso completo:
|
||||||
@@ -79,5 +79,7 @@ Mapping delle funzionalità sui moduli esistenti:
|
|||||||
|
|
||||||
## Stato Attuale
|
## Stato Attuale
|
||||||
- Implementazione Core (Backend/Frontend) completata.
|
- Implementazione Core (Backend/Frontend) completata.
|
||||||
- Da completare integrazione fine UI e workflow notifiche.
|
- Integrazione Modulo Comunicazioni completata (Controllo attivazione app + invio email).
|
||||||
- 2025-12-12-174800_rimosse_tab_interne_modulo_formazione: Rimosse le tab interne (Dashboard, Registry, Matrix) dal layout del modulo Formazione in quanto ridondanti rispetto alla navigazione principale.
|
- 2025-12-12-174800_rimosse_tab_interne_modulo_formazione: Rimosse le tab interne (Dashboard, Registry, Matrix) dal layout del modulo Formazione in quanto ridondanti rispetto alla navigazione principale.
|
||||||
|
- 2025-12-12-185000_integrazione_comunicazioni_formazione: Implementata integrazione formale con modulo Comunicazioni (Check AppService + logging).
|
||||||
|
- 2025-12-12-190500_fix_seed_db: Risolto bug mancata creazione categoria "Formazione" (TRAIN) nel seed del database per database esistenti.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Zentral.Domain.Entities.Training;
|
using Zentral.Domain.Entities.Training;
|
||||||
|
using Zentral.Domain.Interfaces;
|
||||||
using Zentral.Infrastructure.Data;
|
using Zentral.Infrastructure.Data;
|
||||||
|
using Zentral.API.Services;
|
||||||
|
|
||||||
namespace Zentral.API.Modules.Training.Controllers;
|
namespace Zentral.API.Modules.Training.Controllers;
|
||||||
|
|
||||||
@@ -10,10 +12,14 @@ namespace Zentral.API.Modules.Training.Controllers;
|
|||||||
public class TrainingController : ControllerBase
|
public class TrainingController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ZentralDbContext _context;
|
private readonly ZentralDbContext _context;
|
||||||
|
private readonly IEmailSender _emailSender;
|
||||||
|
private readonly AppService _appService;
|
||||||
|
|
||||||
public TrainingController(ZentralDbContext context)
|
public TrainingController(ZentralDbContext context, IEmailSender emailSender, AppService appService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_emailSender = emailSender;
|
||||||
|
_appService = appService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -160,4 +166,44 @@ public class TrainingController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new { url = training.AttestatoUrl });
|
return Ok(new { url = training.AttestatoUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/notify")]
|
||||||
|
public async Task<IActionResult> SendNotification(int id)
|
||||||
|
{
|
||||||
|
if (!await _appService.IsAppEnabledAsync("communications"))
|
||||||
|
return BadRequest(new { message = "Il modulo Comunicazioni non è attivo. Impossibile inviare email." });
|
||||||
|
|
||||||
|
var training = await _context.TrainingRecords
|
||||||
|
.Include(t => t.ClienteContatto)
|
||||||
|
.Include(t => t.Articolo)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
|
|
||||||
|
if (training == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var emailSubject = $"Scadenza Formazione: {training.Articolo?.Descrizione}";
|
||||||
|
var emailBody = $@"
|
||||||
|
<h3>Avviso Scadenza Formazione</h3>
|
||||||
|
<p>Gentile {training.ClienteContatto?.Nome} {training.ClienteContatto?.Cognome},</p>
|
||||||
|
<p>Si ricorda che la formazione <strong>{training.Articolo?.Descrizione}</strong> effettuata il {training.DataEsecuzione:dd/MM/yyyy} è in scadenza il <strong>{training.DataScadenza:dd/MM/yyyy}</strong>.</p>
|
||||||
|
<p>Si prega di provvedere al rinnovo.</p>
|
||||||
|
<br>
|
||||||
|
<p>Cordiali saluti,<br>Team Formazione</p>
|
||||||
|
";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(training.ClienteContatto?.Email))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _emailSender.SendEmailAsync(training.ClienteContatto.Email, emailSubject, emailBody);
|
||||||
|
return Ok(new { message = $"Notifica inviata a {training.ClienteContatto.Email}" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = $"Errore invio email: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new { message = "Email contatto non presente" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ public static class DbSeeder
|
|||||||
{
|
{
|
||||||
public static void Seed(ZentralDbContext context)
|
public static void Seed(ZentralDbContext context)
|
||||||
{
|
{
|
||||||
if (context.TipiPasto.Any()) return;
|
if (!context.TipiPasto.Any())
|
||||||
|
{
|
||||||
|
|
||||||
// Tipi Pasto
|
// Tipi Pasto
|
||||||
var tipiPasto = new List<TipoPasto>
|
var tipiPasto = new List<TipoPasto>
|
||||||
@@ -232,6 +233,21 @@ public static class DbSeeder
|
|||||||
};
|
};
|
||||||
context.Utenti.AddRange(utenti);
|
context.Utenti.AddRange(utenti);
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure TRAIN category exists
|
||||||
|
if (!context.CodiciCategoria.Any(c => c.Codice == "TRAIN"))
|
||||||
|
{
|
||||||
|
context.CodiciCategoria.Add(new CodiceCategoria
|
||||||
|
{
|
||||||
|
Codice = "TRAIN",
|
||||||
|
Descrizione = "Formazione",
|
||||||
|
CoeffA = 1.0m,
|
||||||
|
CoeffB = 1.0m,
|
||||||
|
CoeffS = 1.0m
|
||||||
|
});
|
||||||
|
context.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
if (!context.Apps.Any())
|
if (!context.Apps.Any())
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
"preview": "Anteprima",
|
"preview": "Anteprima",
|
||||||
"none": "Nessuno",
|
"none": "Nessuno",
|
||||||
"view": "Dettaglio",
|
"view": "Dettaglio",
|
||||||
"copy": "Copia"
|
"copy": "Copia",
|
||||||
|
"category": "Categoria"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -1747,6 +1748,10 @@
|
|||||||
"course": "Corso",
|
"course": "Corso",
|
||||||
"deleteConfirm": "Eliminare questa formazione?",
|
"deleteConfirm": "Eliminare questa formazione?",
|
||||||
"daysRemaining": "Giorni rimanenti",
|
"daysRemaining": "Giorni rimanenti",
|
||||||
"expiringIn": "Scade tra {{days}} giorni"
|
"expiringIn": "Scade tra {{days}} giorni",
|
||||||
|
"sendNotification": "Invia Notifica",
|
||||||
|
"notificationSent": "Notifica inviata con successo",
|
||||||
|
"editCourse": "Modifica Corso",
|
||||||
|
"editTraining": "Modifica Formazione"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Paper,
|
Paper,
|
||||||
Grid,
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Tooltip
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { Send as SendIcon } from "@mui/icons-material";
|
||||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { trainingService } from "../services/trainingService";
|
import { trainingService } from "../services/trainingService";
|
||||||
@@ -20,6 +23,13 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => trainingService.getExpiring(),
|
queryFn: () => trainingService.getExpiring(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const notifyMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => trainingService.sendNotification(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
alert(t("training.notificationSent"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const expiredCount = expiringRecords.filter((r: any) => r.stato === 2).length;
|
const expiredCount = expiringRecords.filter((r: any) => r.stato === 2).length;
|
||||||
const expiringCount = expiringRecords.filter((r: any) => r.stato === 1).length;
|
const expiringCount = expiringRecords.filter((r: any) => r.stato === 1).length;
|
||||||
|
|
||||||
@@ -36,6 +46,22 @@ export default function DashboardPage() {
|
|||||||
? <Chip label={t("training.expired")} color="error" size="small" />
|
? <Chip label={t("training.expired")} color="error" size="small" />
|
||||||
: <Chip label={t("training.expiring")} color="warning" size="small" />
|
: <Chip label={t("training.expiring")} color="warning" size="small" />
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: t("common.actions"),
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params: any) => (
|
||||||
|
<Tooltip title={t("training.sendNotification")}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => notifyMutation.mutate(params.row.id)}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { trainingService } from "../services/trainingService";
|
import { trainingService } from "../services/trainingService";
|
||||||
import { TrainingRecord, ClienteContatto, Articolo } from "../../../types";
|
import { TrainingRecord, ClienteContatto, Articolo, TipoArticolo } from "../../../types";
|
||||||
import { lookupService, articoliService, clientiService } from "../../../services/lookupService";
|
import { lookupService, articoliService, clientiService } from "../../../services/lookupService";
|
||||||
|
|
||||||
export default function MatrixPage() {
|
export default function MatrixPage() {
|
||||||
@@ -47,19 +47,10 @@ export default function MatrixPage() {
|
|||||||
queryFn: () => lookupService.getClienti(),
|
queryFn: () => lookupService.getClienti(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: categories = [] } = useQuery({
|
/* Removed unused trainingCategoryId logic */
|
||||||
queryKey: ["categorie"],
|
|
||||||
queryFn: () => lookupService.getCategorie(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const trainingCategoryId = useMemo(() => {
|
|
||||||
return categories.find(c => c.codice === "TRAIN")?.id;
|
|
||||||
}, [categories]);
|
|
||||||
|
|
||||||
const { data: courses = [] } = useQuery({
|
const { data: courses = [] } = useQuery({
|
||||||
queryKey: ["articles", "training", trainingCategoryId],
|
queryKey: ["articles", "training"],
|
||||||
queryFn: () => trainingCategoryId ? articoliService.getAll({ categoriaId: trainingCategoryId }) : [],
|
queryFn: () => articoliService.getAll({ tipo: TipoArticolo.Corso }),
|
||||||
enabled: !!trainingCategoryId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: contacts = [] } = useQuery({
|
const { data: contacts = [] } = useQuery({
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import {
|
|||||||
Delete as DeleteIcon,
|
Delete as DeleteIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { articoliService, lookupService } from "../../../services/lookupService";
|
import { articoliService } from "../../../services/lookupService";
|
||||||
import { Articolo, LookupItem, TipoArticolo } from "../../../types";
|
import { categoryService } from "../../warehouse/services/warehouseService";
|
||||||
|
import { Articolo, TipoArticolo } from "../../../types";
|
||||||
|
import { MenuItem } from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
export default function RegistryPage() {
|
export default function RegistryPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,31 +35,47 @@ export default function RegistryPage() {
|
|||||||
tipo: TipoArticolo.Corso
|
tipo: TipoArticolo.Corso
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Fetch Categories to find "TRAIN" (Formazione)
|
// 1. Fetch Request ALL Categories
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
queryKey: ["categorie"],
|
queryKey: ["warehouse-categories"],
|
||||||
queryFn: () => lookupService.getCategorie(),
|
queryFn: () => categoryService.getAll(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const trainingCategoryId = useMemo(() => {
|
const trainingCategoryId = useMemo(() => {
|
||||||
return categories.find((c: LookupItem) => c.codice === "TRAIN")?.id;
|
return categories.find((c: any) => c.code === "TRAIN")?.id;
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
// 2. Fetch Articles filtered by Training Category AND TipoArticolo.Corso
|
// Find all descendants of TRAIN
|
||||||
|
const allowedCategories = useMemo(() => {
|
||||||
|
if (!trainingCategoryId) return [];
|
||||||
|
|
||||||
|
const descendants: any[] = [];
|
||||||
|
const queue = [trainingCategoryId];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const parentId = queue.shift();
|
||||||
|
const children = categories.filter((c: any) => c.parentCategoryId === parentId);
|
||||||
|
children.forEach((c: any) => {
|
||||||
|
descendants.push(c);
|
||||||
|
queue.push(c.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include TRAIN itself? Maybe better to force using subcategories if they exist,
|
||||||
|
// but allowing TRAIN is flexible.
|
||||||
|
const root = categories.find((c: any) => c.id === trainingCategoryId);
|
||||||
|
return root ? [root, ...descendants] : descendants;
|
||||||
|
}, [categories, trainingCategoryId]);
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Fetch Articles filtered by TipoArticolo.Corso (ignore category filter for list to show all)
|
||||||
const { data: articles = [], isLoading } = useQuery({
|
const { data: articles = [], isLoading } = useQuery({
|
||||||
queryKey: ["articles", "training", trainingCategoryId],
|
queryKey: ["articles", "training"],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
// Even if categories not waiting, we can filter by Tipo
|
// We explicitly want ALL courses, regardless of subcategory
|
||||||
// But we prefer having both if possible.
|
|
||||||
// If trainingCategoryId is missing but we want to show list based on Type, we can do it.
|
|
||||||
// Let's pass trainingCategoryId if exists.
|
|
||||||
|
|
||||||
const params: any = { tipo: TipoArticolo.Corso };
|
const params: any = { tipo: TipoArticolo.Corso };
|
||||||
if (trainingCategoryId) params.categoriaId = trainingCategoryId;
|
|
||||||
|
|
||||||
return articoliService.getAll(params);
|
return articoliService.getAll(params);
|
||||||
},
|
},
|
||||||
enabled: true, // Always enabled so we can see list by Type even if category missing (or maybe not?)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
@@ -110,7 +129,7 @@ export default function RegistryPage() {
|
|||||||
|
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
...formData,
|
...formData,
|
||||||
categoriaId: trainingCategoryId,
|
categoriaId: formData.categoriaId || trainingCategoryId, // Use selected or default to TRAIN
|
||||||
tipoMaterialeId: 1, // Default Material Type ID
|
tipoMaterialeId: 1, // Default Material Type ID
|
||||||
unitaMisura: "H", // Hours
|
unitaMisura: "H", // Hours
|
||||||
tipo: TipoArticolo.Corso, // Force Type
|
tipo: TipoArticolo.Corso, // Force Type
|
||||||
@@ -206,6 +225,21 @@ export default function RegistryPage() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t("common.category")}
|
||||||
|
value={formData.categoriaId || trainingCategoryId || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, categoriaId: Number(e.target.value) })}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{allowedCategories.map((c: any) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.code})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label={t("training.validityDays")}
|
label={t("training.validityDays")}
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -26,5 +26,8 @@ export const trainingService = {
|
|||||||
getExpiring: async () => {
|
getExpiring: async () => {
|
||||||
const { data } = await api.get<TrainingRecord[]>("/training/expiring");
|
const { data } = await api.get<TrainingRecord[]>("/training/expiring");
|
||||||
return data;
|
return data;
|
||||||
|
},
|
||||||
|
sendNotification: async (id: number) => {
|
||||||
|
await api.post(`/training/${id}/notify`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user