Initial commit

This commit is contained in:
dahoud
2025-10-01 01:39:07 +00:00
commit b430bf3b96
826 changed files with 255287 additions and 0 deletions

119
services/ApiService.ts Normal file
View File

@@ -0,0 +1,119 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import { keycloak, KEYCLOAK_TIMEOUTS } from '../config/keycloak';
class ApiService {
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token Keycloak
this.api.interceptors.request.use(
async (config) => {
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
if (keycloak && keycloak.authenticated) {
try {
// Rafraîchir le token si nécessaire
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
// Ajouter le token Bearer à l'en-tête Authorization
if (keycloak.token) {
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
}
} catch (error) {
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
// En cas d'erreur, rediriger vers la page de connexion
keycloak.login();
throw error;
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Essayer de rafraîchir le token Keycloak
if (keycloak && keycloak.authenticated) {
try {
await keycloak.updateToken(-1); // Force refresh
// Retry the original request
return this.api.request(error.config);
} catch (refreshError) {
console.error('Impossible de rafraîchir le token:', refreshError);
keycloak.login();
}
} else {
// Ne pas rediriger si on est en train de traiter un code d'autorisation
if (typeof window !== 'undefined') {
const currentUrl = window.location.href;
const hasAuthCode = currentUrl.includes('code=') && currentUrl.includes('/dashboard');
if (!hasAuthCode) {
// Fallback vers l'ancien système
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('auth_token');
sessionStorage.removeItem('auth_token');
window.location.href = '/api/auth/login';
} else {
console.log('🔄 ApiService: Erreur 401 ignorée car authentification en cours...');
}
}
}
}
return Promise.reject(error);
}
);
}
async get<T = any>(url: string, config?: any): Promise<T> {
const response = await this.api.get(url, config);
return response.data;
}
async post<T = any>(url: string, data?: any, config?: any): Promise<T> {
const response = await this.api.post(url, data, config);
return response.data;
}
async put<T = any>(url: string, data?: any, config?: any): Promise<T> {
const response = await this.api.put(url, data, config);
return response.data;
}
async delete<T = any>(url: string, config?: any): Promise<T> {
const response = await this.api.delete(url, config);
return response.data;
}
async patch<T = any>(url: string, data?: any, config?: any): Promise<T> {
const response = await this.api.patch(url, data, config);
return response.data;
}
}
export default new ApiService();

View File

@@ -0,0 +1,199 @@
/**
* Tests pour le service ErrorHandler
*/
import { ErrorHandler } from '../errorHandler';
import { AxiosError } from 'axios';
// Mock du Toast
const mockToast = {
current: {
show: jest.fn()
}
};
describe('ErrorHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
ErrorHandler.setToast(mockToast as any);
});
describe('handleApiError', () => {
it('should handle 400 Bad Request', () => {
const error = new AxiosError('Bad Request', '400', undefined, undefined, {
status: 400,
data: { error: 'Données invalides' }
} as any);
ErrorHandler.handleApiError(error, 'test');
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'error',
summary: 'Données invalides',
detail: 'Données invalides',
life: 5000
});
});
it('should handle 401 Unauthorized', () => {
const error = new AxiosError('Unauthorized', '401', undefined, undefined, {
status: 401,
data: { error: 'Non autorisé' }
} as any);
// Mock window.location
delete (window as any).location;
window.location = { href: '' } as any;
ErrorHandler.handleApiError(error);
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'error',
summary: 'Non autorisé',
detail: 'Veuillez vous reconnecter',
life: 5000
});
expect(window.location.href).toBe('/auth/login');
});
it('should handle 404 Not Found', () => {
const error = new AxiosError('Not Found', '404', undefined, undefined, {
status: 404,
data: { error: 'Ressource non trouvée' }
} as any);
ErrorHandler.handleApiError(error);
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'error',
summary: 'Ressource non trouvée',
detail: 'Ressource non trouvée',
life: 5000
});
});
it('should handle 422 Validation Errors', () => {
const error = new AxiosError('Validation Error', '422', undefined, undefined, {
status: 422,
data: {
error: 'Erreurs de validation',
details: [
{ field: 'nom', message: 'Le nom est obligatoire' },
{ field: 'email', message: 'Email invalide' }
]
}
} as any);
ErrorHandler.handleApiError(error);
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'error',
summary: 'Erreurs de validation',
detail: 'nom: Le nom est obligatoire\nemail: Email invalide',
life: 5000
});
});
it('should handle 500 Internal Server Error', () => {
const error = new AxiosError('Internal Server Error', '500', undefined, undefined, {
status: 500,
data: { error: 'Erreur serveur' }
} as any);
ErrorHandler.handleApiError(error);
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'error',
summary: 'Erreur serveur',
detail: 'Une erreur interne s\'est produite. Veuillez réessayer plus tard.',
life: 5000
});
});
it('should handle network errors', () => {
const error = new AxiosError('Network Error');
error.request = {};
ErrorHandler.handleApiError(error);
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'error',
summary: 'Erreur de connexion',
detail: 'Impossible de contacter le serveur. Vérifiez votre connexion internet.',
life: 5000
});
});
});
describe('validation methods', () => {
it('should validate required fields', () => {
const fields = {
nom: 'John',
email: '',
age: null,
actif: true
};
const errors = ErrorHandler.validateRequired(fields);
expect(errors).toEqual([
'Le champ "email" est obligatoire',
'Le champ "age" est obligatoire'
]);
});
it('should validate email addresses', () => {
expect(ErrorHandler.validateEmail('test@example.com')).toBe(true);
expect(ErrorHandler.validateEmail('invalid-email')).toBe(false);
expect(ErrorHandler.validateEmail('')).toBe(false);
});
it('should validate phone numbers', () => {
expect(ErrorHandler.validatePhoneNumber('01 23 45 67 89')).toBe(true);
expect(ErrorHandler.validatePhoneNumber('0123456789')).toBe(true);
expect(ErrorHandler.validatePhoneNumber('+33 1 23 45 67 89')).toBe(true);
expect(ErrorHandler.validatePhoneNumber('invalid')).toBe(false);
});
it('should validate SIRET numbers', () => {
expect(ErrorHandler.validateSiret('12345678901234')).toBe(false); // Invalid checksum
expect(ErrorHandler.validateSiret('123456789')).toBe(false); // Too short
expect(ErrorHandler.validateSiret('abcd')).toBe(false); // Not numeric
});
});
describe('message methods', () => {
it('should show success message', () => {
ErrorHandler.showSuccess('Succès', 'Opération réussie');
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'success',
summary: 'Succès',
detail: 'Opération réussie',
life: 3000
});
});
it('should show warning message', () => {
ErrorHandler.showWarning('Attention', 'Avertissement');
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'warn',
summary: 'Attention',
detail: 'Avertissement',
life: 4000
});
});
it('should show info message', () => {
ErrorHandler.showInfo('Information', 'Message informatif');
expect(mockToast.current.show).toHaveBeenCalledWith({
severity: 'info',
summary: 'Information',
detail: 'Message informatif',
life: 3000
});
});
});
});

View File

@@ -0,0 +1,287 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import {
AnalysePrixPhase,
ApiResponse
} from '../types/btp-extended';
import materielPhaseService from './materielPhaseService';
import fournisseurPhaseService from './fournisseurPhaseService';
class AnalysePrixService {
private readonly basePath = '/analyses-prix';
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token JWT
this.api.interceptors.request.use(
(config) => {
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/api/auth/login';
}
return Promise.reject(error);
}
);
}
/**
* Récupérer l'analyse de prix d'une phase
*/
async getByPhase(phaseId: string): Promise<AnalysePrixPhase | null> {
if (!phaseId || phaseId === 'undefined' || phaseId === 'null' || phaseId === 'NaN') {
console.warn(`ID de phase invalide: ${phaseId}`);
return null;
}
try {
const response = await this.api.get(`${this.basePath}/phase/${phaseId}`);
return response.data;
} catch (error) {
console.warn(`Endpoint ${this.basePath}/phase/${phaseId} non disponible:`, error);
return null;
}
}
/**
* Créer ou mettre à jour une analyse de prix
*/
async createOrUpdate(analyse: Omit<AnalysePrixPhase, 'id'>): Promise<AnalysePrixPhase> {
try {
const response = await this.api.post(`${this.basePath}`, analyse);
return response.data;
} catch (error) {
console.warn('Endpoint createOrUpdate non disponible, calcul local:', error);
return {
...analyse,
id: Date.now(), // ID temporaire
dateAnalyse: new Date()
};
}
}
/**
* Calculer automatiquement l'analyse de prix d'une phase
*/
async calculerAnalyseComplete(phaseId: string): Promise<AnalysePrixPhase> {
try {
// Récupération des données de base
const [materiels, fournisseurs] = await Promise.all([
materielPhaseService.getByPhase(phaseId),
fournisseurPhaseService.getByPhase(phaseId)
]);
// Calcul du coût des matériaux
const coutMateriauxTotal = materiels.reduce((total, materiel) => {
const prix = materiel.prixUnitaireNegocie || materiel.prixUnitaireCatalogue || 0;
const quantite = materiel.quantiteUtilisee || materiel.quantitePrevue || 0;
return total + (prix * quantite);
}, 0);
// Calcul du coût de main d'œuvre (estimation basée sur les matériaux)
const coutMainOeuvreTotal = this.estimerCoutMainOeuvre(coutMateriauxTotal);
// Calcul du coût de sous-traitance
const coutSousTraitanceTotal = fournisseurs
.filter(f => f.typeContribution === 'SOUS_TRAITANCE')
.reduce((total, f) => total + (f.prixNegocie || f.prixCatalogue || 0), 0);
// Calcul du coût des services et autres
const coutAutresTotal = fournisseurs
.filter(f => ['SERVICE', 'LOCATION', 'TRANSPORT'].includes(f.typeContribution))
.reduce((total, f) => total + (f.prixNegocie || f.prixCatalogue || 0), 0);
// Coût total direct
const coutTotalDirect = coutMateriauxTotal + coutMainOeuvreTotal + coutSousTraitanceTotal + coutAutresTotal;
// Frais généraux (15% par défaut)
const tauxFraisGeneraux = 0.15;
const fraisGeneraux = coutTotalDirect * tauxFraisGeneraux;
const coutTotalAvecFrais = coutTotalDirect + fraisGeneraux;
// Marge objectif (20% par défaut)
const tauxMargeObjectif = 0.20;
const margeObjectif = coutTotalAvecFrais * tauxMargeObjectif;
const prixVenteCalcule = coutTotalAvecFrais + margeObjectif;
// Calcul de la rentabilité prévisionnelle
const rentabilitePrevisionnelle = (margeObjectif / prixVenteCalcule) * 100;
const analyse: AnalysePrixPhase = {
phase: { id: parseInt(phaseId) } as any,
coutMateriauxTotal,
coutMainOeuvreTotal,
coutSousTraitanceTotal,
coutAutresTotal,
coutTotalDirect,
fraisGeneraux,
tauxFraisGeneraux: tauxFraisGeneraux * 100,
coutTotalAvecFrais,
margeObjectif,
tauxMargeObjectif: tauxMargeObjectif * 100,
prixVenteCalcule,
rentabilitePrevisionnelle,
dateAnalyse: new Date()
};
return await this.createOrUpdate(analyse);
} catch (error) {
console.error('Erreur lors du calcul de l\'analyse de prix:', error);
throw error;
}
}
/**
* Comparer plusieurs scénarios de prix
*/
async comparerScenarios(
phaseId: string,
scenarios: Array<{
nom: string;
tauxMarge: number;
tauxFraisGeneraux?: number;
}>
): Promise<Array<AnalysePrixPhase & { nomScenario: string }>> {
const analyseBase = await this.calculerAnalyseComplete(phaseId);
return scenarios.map(scenario => {
const tauxFraisGeneraux = scenario.tauxFraisGeneraux || 0.15;
const fraisGeneraux = analyseBase.coutTotalDirect * tauxFraisGeneraux;
const coutTotalAvecFrais = analyseBase.coutTotalDirect + fraisGeneraux;
const margeObjectif = coutTotalAvecFrais * (scenario.tauxMarge / 100);
const prixVenteCalcule = coutTotalAvecFrais + margeObjectif;
const rentabilitePrevisionnelle = (margeObjectif / prixVenteCalcule) * 100;
return {
...analyseBase,
nomScenario: scenario.nom,
tauxFraisGeneraux: tauxFraisGeneraux * 100,
fraisGeneraux,
coutTotalAvecFrais,
tauxMargeObjectif: scenario.tauxMarge,
margeObjectif,
prixVenteCalcule,
rentabilitePrevisionnelle
};
});
}
/**
* Valider une analyse de prix
*/
async validerAnalyse(id: number, validePar: string): Promise<AnalysePrixPhase> {
try {
const response = await this.api.post(`${this.basePath}/${id}/valider`, {
validePar,
dateValidation: new Date(),
validee: true
});
return response.data;
} catch (error) {
console.warn('Validation non disponible côté serveur');
throw error;
}
}
/**
* Calculer la rentabilité réelle après réalisation
*/
async calculerRentabiliteReelle(phaseId: string, coutReel: number, prixVenteReel: number): Promise<number> {
const margeReelle = prixVenteReel - coutReel;
return (margeReelle / prixVenteReel) * 100;
}
/**
* Obtenir l'historique des analyses d'une phase
*/
async getHistorique(phaseId: string): Promise<AnalysePrixPhase[]> {
try {
const response = await this.api.get(`${this.basePath}/phase/${phaseId}/historique`);
return response.data;
} catch (error) {
console.warn('Historique non disponible');
return [];
}
}
/**
* Estimation du coût de main d'œuvre basée sur les matériaux
* (règle métier: environ 40-60% du coût des matériaux selon la complexité)
*/
private estimerCoutMainOeuvre(coutMateriaux: number): number {
// Facteur de complexité moyen: 0.5 (50% des matériaux)
return coutMateriaux * 0.5;
}
/**
* Analyser la compétitivité d'un prix
*/
analyserCompetitivite(analyse: AnalysePrixPhase, prixMarche?: number): {
niveau: 'TRÈS_COMPÉTITIF' | 'COMPÉTITIF' | 'MOYEN' | 'ÉLEVÉ' | 'TRÈS_ÉLEVÉ';
commentaire: string;
recommandation: string;
} {
const tauxMarge = analyse.tauxMargeObjectif || 0;
if (tauxMarge < 10) {
return {
niveau: 'TRÈS_COMPÉTITIF',
commentaire: 'Prix très agressif avec marge faible',
recommandation: 'Attention aux risques financiers, vérifier la viabilité'
};
} else if (tauxMarge < 20) {
return {
niveau: 'COMPÉTITIF',
commentaire: 'Prix compétitif avec marge raisonnable',
recommandation: 'Bon équilibre entre compétitivité et rentabilité'
};
} else if (tauxMarge < 30) {
return {
niveau: 'MOYEN',
commentaire: 'Prix dans la moyenne du marché',
recommandation: 'Position standard, opportunités d\'optimisation possibles'
};
} else if (tauxMarge < 40) {
return {
niveau: 'ÉLEVÉ',
commentaire: 'Prix élevé avec forte marge',
recommandation: 'Risque de perte de compétitivité, justifier la valeur ajoutée'
};
} else {
return {
niveau: 'TRÈS_ÉLEVÉ',
commentaire: 'Prix très élevé',
recommandation: 'Revoir la stratégie tarifaire, optimiser les coûts'
};
}
}
}
export default new AnalysePrixService();

234
services/api-client.ts Normal file
View File

@@ -0,0 +1,234 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { API_CONFIG } from '../config/api';
import { keycloak } from '../config/keycloak';
import { KEYCLOAK_TIMEOUTS } from '../config/keycloak';
// Créer une instance axios avec configuration par défaut depuis API_CONFIG
const axiosInstance: AxiosInstance = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
// Intercepteur pour ajouter le token d'authentification
axiosInstance.interceptors.request.use(
async (config) => {
// Vérifier si nous avons un token d'accès stocké
if (typeof window !== 'undefined') {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
// Ajouter le token Bearer à l'en-tête Authorization
config.headers.Authorization = `Bearer ${accessToken}`;
console.log('🔐 API Request avec token:', config.method?.toUpperCase(), config.url);
} else {
console.log('⚠️ API Request sans token:', config.method?.toUpperCase(), config.url);
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken') || localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Intercepteur pour gérer les réponses et erreurs
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error) => {
// Gérer les erreurs d'authentification et d'autorisation
if (error.response?.status === 401 || error.response?.status === 403) {
// Ne pas rediriger si on est en train de traiter un code d'autorisation
if (typeof window !== 'undefined') {
const currentUrl = window.location.href;
const hasAuthCode = currentUrl.includes('code=') && currentUrl.includes('/dashboard');
console.log(`🔍 API Client ${error.response?.status}:`, { currentUrl, hasAuthCode });
if (!hasAuthCode) {
// Token expiré, invalide ou permissions insuffisantes - sauvegarder la page actuelle et rediriger
console.log(`🔄 API Client: Erreur ${error.response?.status}, redirection vers Keycloak...`);
// Sauvegarder la page actuelle pour y revenir après reconnexion
const currentPath = window.location.pathname + window.location.search;
localStorage.setItem('returnUrl', currentPath);
// Nettoyer les anciens tokens
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('idToken');
// Rediriger vers Keycloak pour reconnexion
window.location.href = '/api/auth/login';
} else {
console.log(`🔄 API Client: Erreur ${error.response?.status} ignorée car authentification en cours...`);
}
}
}
// Gérer les erreurs serveur
if (error.response?.status >= 500) {
console.error('Erreur serveur:', error.response?.data || error.message);
}
return Promise.reject(error);
}
);
// Interface pour les réponses API standardisées
export interface ApiResponse<T = any> {
data: T;
message?: string;
success?: boolean;
}
// Interface pour les erreurs API
export interface ApiError {
message: string;
code?: string;
details?: any;
}
// Client API avec méthodes utilitaires
export class ApiClient {
private instance: AxiosInstance;
constructor(baseURL?: string) {
this.instance = baseURL ? axios.create({ baseURL }) : axiosInstance;
}
// GET request
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.get<T>(url, config);
}
// POST request
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.post<T>(url, data, config);
}
// PUT request
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.put<T>(url, data, config);
}
// DELETE request
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.delete<T>(url, config);
}
// PATCH request
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.patch<T>(url, data, config);
}
// Upload file
async upload<T = any>(url: string, file: File, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
const formData = new FormData();
formData.append('file', file);
return this.instance.post<T>(url, formData, {
...config,
headers: {
...config?.headers,
'Content-Type': 'multipart/form-data',
},
});
}
// Upload multiple files
async uploadMultiple<T = any>(url: string, files: File[], config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
const formData = new FormData();
files.forEach((file, index) => {
formData.append(`files[${index}]`, file);
});
return this.instance.post<T>(url, formData, {
...config,
headers: {
...config?.headers,
'Content-Type': 'multipart/form-data',
},
});
}
// Download file
async download(url: string, filename?: string, config?: AxiosRequestConfig): Promise<void> {
const response = await this.instance.get(url, {
...config,
responseType: 'blob',
});
// Créer un lien de téléchargement
const blob = new Blob([response.data]);
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
}
// Set auth token
setAuthToken(token: string): void {
this.instance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
// Remove auth token
removeAuthToken(): void {
delete this.instance.defaults.headers.common['Authorization'];
}
}
// Instance par défaut exportée
export const apiClient = new ApiClient();
// Export de l'instance axios brute pour les cas particuliers
export { axiosInstance };
// Utilitaires pour gérer les erreurs
export const handleApiError = (error: any): ApiError => {
if (error.response) {
// Erreur de réponse du serveur
return {
message: error.response.data?.message || error.response.statusText || 'Erreur serveur',
code: error.response.status.toString(),
details: error.response.data,
};
} else if (error.request) {
// Erreur de réseau
return {
message: 'Impossible de se connecter au serveur',
code: 'NETWORK_ERROR',
details: error.request,
};
} else {
// Autre erreur
return {
message: error.message || 'Une erreur inattendue est survenue',
code: 'UNKNOWN_ERROR',
details: error,
};
}
};
// Type guards pour vérifier les réponses API
export const isApiResponse = <T>(response: any): response is ApiResponse<T> => {
return response && typeof response === 'object' && 'data' in response;
};
export const isApiError = (error: any): error is ApiError => {
return error && typeof error === 'object' && 'message' in error;
};

974
services/api.ts Normal file
View File

@@ -0,0 +1,974 @@
/**
* Services API pour BTP Xpress
*/
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { API_CONFIG } from '../config/api';
import { keycloak, KEYCLOAK_TIMEOUTS } from '../config/keycloak';
import { CacheService, CacheKeys } from './cacheService';
import {
Client,
Chantier,
Devis,
Facture,
DashboardStats,
ChantierRecent,
FactureEnRetard,
DevisEnAttente,
FilterOptions,
SearchResult,
Employe,
Equipe,
Materiel,
MaintenanceMateriel,
PlanningEvent,
PlanningCalendrierView,
PlanningConflict,
PlanningStats
} from '../types/btp';
import { UserInfo } from '../types/auth';
class ApiService {
private api: AxiosInstance;
private serverStatusListeners: ((isOnline: boolean) => void)[] = [];
constructor() {
this.api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
// Interceptor pour les requêtes - ajouter le token d'authentification
this.api.interceptors.request.use(
async (config) => {
// Utiliser le token stocké dans localStorage (nouveau système)
if (typeof window !== 'undefined') {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
console.log('🔐 API Request avec token:', config.url);
} else {
console.log('⚠️ API Request sans token:', config.url);
}
}
// Ajouter des en-têtes par défaut
config.headers['X-Requested-With'] = 'XMLHttpRequest';
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error) => {
// Ne pas logger les 404 sur les endpoints de chantiers par client (pas encore implémentés)
const is404OnChantiersByClient = error.response?.status === 404 &&
error.config?.url?.includes('/chantiers/client/');
if (!is404OnChantiersByClient) {
// Utiliser console.warn au lieu de console.error pour éviter les erreurs React DevTools
console.warn('API Error:', error.response?.status, error.response?.data || error.message);
}
// Gérer les erreurs de connexion réseau
if (!error.response) {
// Erreur réseau (serveur indisponible, pas de connexion, etc.)
if (error.code === 'ECONNABORTED') {
error.userMessage = 'Délai d\'attente dépassé. Le serveur backend met trop de temps à répondre.';
error.statusCode = 'TIMEOUT';
} else if (error.code === 'ERR_NETWORK') {
error.userMessage = 'Impossible de joindre le serveur backend. Vérifiez votre connexion internet et que le serveur backend est démarré (mvn quarkus:dev).';
error.statusCode = 'NETWORK_ERROR';
} else {
error.userMessage = 'Serveur backend indisponible. Vérifiez que le serveur backend est démarré (mvn quarkus:dev) et accessible sur le port 8080.';
error.statusCode = 'SERVER_UNAVAILABLE';
}
// Émettre un événement global pour notifier l'application
this.notifyServerStatus(false);
return Promise.reject(error);
}
// Serveur répond, donc il est disponible
this.notifyServerStatus(true);
if (error.response?.status === 401) {
// Gestion des erreurs d'authentification
if (typeof window !== 'undefined') {
const currentUrl = window.location.href;
const hasAuthCode = currentUrl.includes('code=') && currentUrl.includes('/dashboard');
if (!hasAuthCode) {
console.log('🔄 Token expiré, redirection vers la connexion...');
// Sauvegarder la page actuelle pour y revenir après reconnexion
const currentPath = window.location.pathname + window.location.search;
localStorage.setItem('returnUrl', currentPath);
// Nettoyer les tokens expirés
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('idToken');
// Rediriger vers la page de connexion
window.location.href = '/api/auth/login';
} else {
console.log('🔄 API Service: Erreur 401 ignorée car authentification en cours...');
}
}
}
// Créer une erreur plus propre pour l'affichage
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
'Une erreur est survenue';
const enhancedError = {
...error,
userMessage: errorMessage,
statusCode: error.response?.status
};
return Promise.reject(enhancedError);
}
);
}
// === GESTION STATUT SERVEUR ===
private notifyServerStatus(isOnline: boolean) {
this.serverStatusListeners.forEach(listener => {
try {
listener(isOnline);
} catch (error) {
console.error('Erreur dans listener de statut serveur:', error);
}
});
}
public onServerStatusChange(callback: (isOnline: boolean) => void) {
this.serverStatusListeners.push(callback);
return () => {
const index = this.serverStatusListeners.indexOf(callback);
if (index > -1) {
this.serverStatusListeners.splice(index, 1);
}
};
}
public async checkServerHealth(urgentCheck: boolean = false): Promise<boolean> {
const timeout = urgentCheck ? 3000 : 8000; // Timeout plus long pour checks de routine
try {
// Endpoint dédié health check ultra-léger
await this.api.get('/api/v1/health', {
timeout,
headers: { 'Cache-Control': 'no-cache' },
params: { _t: Date.now() } // Cache busting
});
this.notifyServerStatus(true);
return true;
} catch (error) {
// Fallback sur endpoint Quarkus standard puis clients
try {
await this.api.get('/q/health', { timeout: timeout * 0.8 });
this.notifyServerStatus(true);
return true;
} catch (secondError) {
try {
await this.api.get('/api/v1/clients', {
timeout: timeout * 0.6,
params: { size: 1 }
});
this.notifyServerStatus(true);
return true;
} catch (thirdError) {
this.notifyServerStatus(false);
return false;
}
}
}
}
// === CLIENTS ===
async getClients(): Promise<Client[]> {
return CacheService.getOrSet(
CacheKeys.CLIENTS,
async () => {
const response = await this.api.get<Client[]>('/api/v1/clients');
return Array.isArray(response.data) ? response.data : [];
},
5 * 60 * 1000 // 5 minutes
);
}
async getClient(id: string): Promise<Client> {
const response = await this.api.get<Client>(`/api/v1/clients/${id}`);
return response.data;
}
async createClient(client: Partial<Client>): Promise<Client> {
const response = await this.api.post<Client>('/api/v1/clients', client);
return response.data;
}
async updateClient(id: string, client: Partial<Client>): Promise<Client> {
const response = await this.api.put<Client>(`/api/v1/clients/${id}`, client);
return response.data;
}
async deleteClient(id: string): Promise<void> {
await this.api.delete(`/api/v1/clients/${id}`);
}
async searchClients(params: {
nom?: string;
entreprise?: string;
ville?: string;
email?: string;
}): Promise<Client[]> {
const response = await this.api.get<Client[]>('/api/v1/clients/search', { params });
return response.data;
}
async searchClientsByNom(nom: string): Promise<Client[]> {
const response = await this.api.get<Client[]>('/api/v1/clients/search', { params: { nom } });
return response.data;
}
async searchClientsByEntreprise(entreprise: string): Promise<Client[]> {
const response = await this.api.get<Client[]>('/api/v1/clients/search', { params: { entreprise } });
return response.data;
}
async searchClientsByVille(ville: string): Promise<Client[]> {
const response = await this.api.get<Client[]>('/api/v1/clients/search', { params: { ville } });
return response.data;
}
async searchClientsByEmail(email: string): Promise<Client[]> {
const response = await this.api.get<Client[]>('/api/v1/clients/search', { params: { email } });
return response.data;
}
async getClientsCount(): Promise<number> {
const response = await this.api.get<number>('/api/v1/clients/count');
return response.data;
}
// === CHANTIERS ===
async getChantiers(): Promise<Chantier[]> {
const response = await this.api.get<Chantier[]>('/api/v1/chantiers');
return Array.isArray(response.data) ? response.data : [];
}
async getChantiersActifs(): Promise<Chantier[]> {
const response = await this.api.get<Chantier[]>('/api/v1/chantiers/actifs');
return Array.isArray(response.data) ? response.data : [];
}
async getChantier(id: string): Promise<Chantier> {
const response = await this.api.get<Chantier>(`/api/v1/chantiers/${id}`);
return response.data;
}
async createChantier(chantier: Partial<Chantier>): Promise<Chantier> {
const response = await this.api.post<Chantier>('/api/v1/chantiers', chantier);
return response.data;
}
async updateChantier(id: string, chantier: Partial<Chantier>): Promise<Chantier> {
const response = await this.api.put<Chantier>(`/api/v1/chantiers/${id}`, chantier);
return response.data;
}
async deleteChantier(id: string, permanent: boolean = false): Promise<void> {
await this.api.delete(`/api/v1/chantiers/${id}`, {
params: { permanent }
});
}
async getChantiersByClient(clientId: string): Promise<Chantier[]> {
try {
const response = await this.api.get<Chantier[]>(`/api/v1/chantiers/client/${clientId}`);
return response.data;
} catch (error: any) {
// Si l'endpoint n'existe pas encore côté backend (404)
if (error.response?.status === 404) {
console.debug(`Endpoint /api/v1/chantiers/client/${clientId} non implémenté, retour d'une liste vide`);
// Retourner une liste vide en attendant l'implémentation backend
return [];
}
// Relancer l'erreur pour les autres cas
throw error;
}
}
async getChantiersRecents(): Promise<ChantierRecent[]> {
const response = await this.api.get<ChantierRecent[]>('/api/v1/chantiers/recent');
return response.data;
}
// === DEVIS ===
async getDevis(): Promise<Devis[]> {
return CacheService.getOrSet(
CacheKeys.DEVIS,
async () => {
const response = await this.api.get<Devis[]>('/api/v1/devis');
return Array.isArray(response.data) ? response.data : [];
},
3 * 60 * 1000 // 3 minutes (plus court car données plus volatiles)
);
}
async getDevisById(id: string): Promise<Devis> {
const response = await this.api.get<Devis>(`/api/v1/devis/${id}`);
return response.data;
}
async getDevisEnAttente(): Promise<DevisEnAttente[]> {
const response = await this.api.get<DevisEnAttente[]>('/api/v1/devis/en-attente');
return response.data;
}
async createDevis(devis: Partial<Devis>): Promise<Devis> {
const response = await this.api.post<Devis>('/api/v1/devis', devis);
// Invalider le cache des devis
CacheService.delete(CacheKeys.DEVIS);
CacheService.invalidatePattern('devis_.*');
return response.data;
}
async updateDevis(id: string, devis: Partial<Devis>): Promise<Devis> {
const response = await this.api.put<Devis>(`/api/v1/devis/${id}`, devis);
// Invalider le cache des devis
CacheService.delete(CacheKeys.DEVIS);
CacheService.delete(CacheKeys.devisById(id));
CacheService.invalidatePattern('devis_.*');
return response.data;
}
async deleteDevis(id: string): Promise<void> {
await this.api.delete(`/api/v1/devis/${id}`);
// Invalider le cache des devis
CacheService.delete(CacheKeys.DEVIS);
CacheService.delete(CacheKeys.devisById(id));
CacheService.invalidatePattern('devis_.*');
}
// === FACTURES ===
async getFactures(): Promise<Facture[]> {
return CacheService.getOrSet(
CacheKeys.FACTURES,
async () => {
const response = await this.api.get<Facture[]>('/api/v1/factures');
return Array.isArray(response.data) ? response.data : [];
},
3 * 60 * 1000 // 3 minutes
);
}
async getFacture(id: string): Promise<Facture> {
const response = await this.api.get<Facture>(`/api/v1/factures/${id}`);
return response.data;
}
async getFacturesEnRetard(): Promise<FactureEnRetard[]> {
const response = await this.api.get<FactureEnRetard[]>('/api/v1/factures/en-retard');
return response.data;
}
async createFacture(facture: Partial<Facture>): Promise<Facture> {
const response = await this.api.post<Facture>('/api/v1/factures', facture);
// Invalider le cache des factures
CacheService.delete(CacheKeys.FACTURES);
CacheService.invalidatePattern('factures_.*');
return response.data;
}
async updateFacture(id: string, facture: Partial<Facture>): Promise<Facture> {
const response = await this.api.put<Facture>(`/api/v1/factures/${id}`, facture);
// Invalider le cache des factures
CacheService.delete(CacheKeys.FACTURES);
CacheService.delete(CacheKeys.factureById(id));
CacheService.invalidatePattern('factures_.*');
return response.data;
}
async deleteFacture(id: string): Promise<void> {
await this.api.delete(`/api/v1/factures/${id}`);
// Invalider le cache des factures
CacheService.delete(CacheKeys.FACTURES);
CacheService.delete(CacheKeys.factureById(id));
CacheService.invalidatePattern('factures_.*');
}
// === DASHBOARD ===
async getDashboardStats(): Promise<DashboardStats> {
const response = await this.api.get<DashboardStats>('/api/v1/dashboard/stats');
return response.data;
}
// === HEALTH ===
async getHealth(): Promise<{ status: string; timestamp: string }> {
const response = await this.api.get('/api/v1/health');
return response.data;
}
async getVersion(): Promise<{ version: string; environment: string }> {
const response = await this.api.get('/api/v1/version');
return response.data;
}
// === AUTH ===
async getCurrentUser(): Promise<UserInfo> {
const response = await this.api.get('/api/v1/auth/user');
return response.data;
}
async getAuthStatus(): Promise<{ authenticated: boolean; principal: string | null; hasJWT: boolean; timestamp: number }> {
const response = await this.api.get('/api/v1/auth/status');
return response.data;
}
// === BUDGETS ===
async getBudgets(params?: { search?: string; statut?: string; tendance?: string }) {
const response = await this.api.get('/api/v1/budgets', { params });
return response.data;
}
async getBudget(id: string) {
const response = await this.api.get(`/api/v1/budgets/${id}`);
return response.data;
}
async getBudgetByChantier(chantierId: string) {
const response = await this.api.get(`/api/v1/budgets/chantier/${chantierId}`);
return response.data;
}
async getBudgetsEnDepassement() {
const response = await this.api.get('/api/v1/budgets/depassement');
return response.data;
}
async getBudgetsNecessitantAttention() {
const response = await this.api.get('/api/v1/budgets/attention');
return response.data;
}
async createBudget(budget: any) {
const response = await this.api.post('/api/v1/budgets', budget);
return response.data;
}
async updateBudget(id: string, budget: any) {
const response = await this.api.put(`/api/v1/budgets/${id}`, budget);
return response.data;
}
async deleteBudget(id: string) {
const response = await this.api.delete(`/api/v1/budgets/${id}`);
return response.data;
}
async updateBudgetDepenses(id: string, depense: number) {
const response = await this.api.put(`/api/v1/budgets/${id}/depenses`, { depense });
return response.data;
}
async updateBudgetAvancement(id: string, avancement: number) {
const response = await this.api.put(`/api/v1/budgets/${id}/avancement`, { avancement });
return response.data;
}
async ajouterAlerteBudget(id: string, description: string) {
const response = await this.api.post(`/api/v1/budgets/${id}/alertes`, { description });
return response.data;
}
async supprimerAlertesBudget(id: string) {
const response = await this.api.delete(`/api/v1/budgets/${id}/alertes`);
return response.data;
}
// === EMPLOYÉS ===
async getEmployes(): Promise<Employe[]> {
const response = await this.api.get<Employe[]>('/api/v1/employes');
return Array.isArray(response.data) ? response.data : [];
}
async getEmploye(id: string): Promise<Employe> {
const response = await this.api.get<Employe>(`/api/v1/employes/${id}`);
return response.data;
}
async createEmploye(employe: Partial<Employe>): Promise<Employe> {
const response = await this.api.post<Employe>('/api/v1/employes', employe);
return response.data;
}
async updateEmploye(id: string, employe: Partial<Employe>): Promise<Employe> {
const response = await this.api.put<Employe>(`/api/v1/employes/${id}`, employe);
return response.data;
}
async deleteEmploye(id: string): Promise<void> {
await this.api.delete(`/api/v1/employes/${id}`);
}
async searchEmployes(params: {
nom?: string;
poste?: string;
specialite?: string;
statut?: string;
}): Promise<Employe[]> {
const response = await this.api.get<Employe[]>('/api/v1/employes/search', { params });
return response.data;
}
async getEmployesDisponibles(dateDebut?: string, dateFin?: string): Promise<Employe[]> {
const response = await this.api.get<Employe[]>('/api/v1/employes/disponibles', {
params: { dateDebut, dateFin }
});
return response.data;
}
async getEmployesByEquipe(equipeId: string): Promise<Employe[]> {
const response = await this.api.get<Employe[]>(`/api/v1/employes/by-equipe/${equipeId}`);
return response.data;
}
async getEmployesCount(): Promise<number> {
const response = await this.api.get<number>('/api/v1/employes/count');
return response.data;
}
async getEmployesStats(): Promise<any> {
const response = await this.api.get('/api/v1/employes/stats');
return response.data;
}
// === ÉQUIPES ===
async getEquipes(): Promise<Equipe[]> {
const response = await this.api.get<Equipe[]>('/api/v1/equipes');
return Array.isArray(response.data) ? response.data : [];
}
async getEquipe(id: string): Promise<Equipe> {
const response = await this.api.get<Equipe>(`/api/v1/equipes/${id}`);
return response.data;
}
async createEquipe(equipe: Partial<Equipe>): Promise<Equipe> {
const response = await this.api.post<Equipe>('/api/v1/equipes', equipe);
return response.data;
}
async updateEquipe(id: string, equipe: Partial<Equipe>): Promise<Equipe> {
const response = await this.api.put<Equipe>(`/api/v1/equipes/${id}`, equipe);
return response.data;
}
async deleteEquipe(id: string): Promise<void> {
await this.api.delete(`/api/v1/equipes/${id}`);
}
async searchEquipes(params: {
nom?: string;
specialite?: string;
statut?: string;
}): Promise<Equipe[]> {
const response = await this.api.get<Equipe[]>('/api/v1/equipes/search', { params });
return response.data;
}
async getEquipesDisponibles(dateDebut?: string, dateFin?: string): Promise<Equipe[]> {
const response = await this.api.get<Equipe[]>('/api/v1/equipes/disponibles', {
params: { dateDebut, dateFin }
});
return response.data;
}
async getMembresEquipe(equipeId: string): Promise<Employe[]> {
const response = await this.api.get<Employe[]>(`/api/v1/equipes/${equipeId}/membres`);
return response.data;
}
async ajouterMembreEquipe(equipeId: string, employeId: string): Promise<void> {
await this.api.post(`/api/v1/equipes/${equipeId}/membres/${employeId}`);
}
async retirerMembreEquipe(equipeId: string, employeId: string): Promise<void> {
await this.api.delete(`/api/v1/equipes/${equipeId}/membres/${employeId}`);
}
async getEquipesCount(): Promise<number> {
const response = await this.api.get<number>('/api/v1/equipes/count');
return response.data;
}
async getEquipesStats(): Promise<any> {
const response = await this.api.get('/api/v1/equipes/stats');
return response.data;
}
// === MATÉRIELS ===
async getMateriels(): Promise<Materiel[]> {
const response = await this.api.get<Materiel[]>('/api/v1/materiels');
return Array.isArray(response.data) ? response.data : [];
}
async getMateriel(id: string): Promise<Materiel> {
const response = await this.api.get<Materiel>(`/api/v1/materiels/${id}`);
return response.data;
}
async createMateriel(materiel: Partial<Materiel>): Promise<Materiel> {
const response = await this.api.post<Materiel>('/api/v1/materiels', materiel);
return response.data;
}
async updateMateriel(id: string, materiel: Partial<Materiel>): Promise<Materiel> {
const response = await this.api.put<Materiel>(`/api/v1/materiels/${id}`, materiel);
return response.data;
}
async deleteMateriel(id: string): Promise<void> {
await this.api.delete(`/api/v1/materiels/${id}`);
}
async searchMateriels(params: {
nom?: string;
type?: string;
marque?: string;
statut?: string;
localisation?: string;
}): Promise<Materiel[]> {
const response = await this.api.get<Materiel[]>('/api/v1/materiels/search', { params });
return response.data;
}
async getMaterielsDisponibles(dateDebut?: string, dateFin?: string, type?: string): Promise<Materiel[]> {
const response = await this.api.get<Materiel[]>('/api/v1/materiels/disponibles', {
params: { dateDebut, dateFin, type }
});
return response.data;
}
async getMaterielsMaintenancePrevue(jours: number = 30): Promise<Materiel[]> {
const response = await this.api.get<Materiel[]>('/api/v1/materiels/maintenance-prevue', {
params: { jours }
});
return response.data;
}
async getMaterielsByType(type: string): Promise<Materiel[]> {
const response = await this.api.get<Materiel[]>(`/api/v1/materiels/by-type/${type}`);
return response.data;
}
async reserverMateriel(id: string, dateDebut: string, dateFin: string): Promise<void> {
await this.api.post(`/api/v1/materiels/${id}/reserve`, null, {
params: { dateDebut, dateFin }
});
}
async libererMateriel(id: string): Promise<void> {
await this.api.post(`/api/v1/materiels/${id}/liberer`);
}
async getMaterielsCount(): Promise<number> {
const response = await this.api.get<number>('/api/v1/materiels/count');
return response.data;
}
async getMaterielsStats(): Promise<any> {
const response = await this.api.get('/api/v1/materiels/stats');
return response.data;
}
async getValeurTotaleMateriels(): Promise<number> {
const response = await this.api.get<number>('/api/v1/materiels/valeur-totale');
return response.data;
}
// === MAINTENANCES ===
async getMaintenances(): Promise<MaintenanceMateriel[]> {
const response = await this.api.get<MaintenanceMateriel[]>('/api/v1/maintenances');
return Array.isArray(response.data) ? response.data : [];
}
async getMaintenance(id: string): Promise<MaintenanceMateriel> {
const response = await this.api.get<MaintenanceMateriel>(`/api/v1/maintenances/${id}`);
return response.data;
}
async createMaintenance(maintenance: Partial<MaintenanceMateriel>): Promise<MaintenanceMateriel> {
const response = await this.api.post<MaintenanceMateriel>('/api/v1/maintenances', maintenance);
return response.data;
}
async updateMaintenance(id: string, maintenance: Partial<MaintenanceMateriel>): Promise<MaintenanceMateriel> {
const response = await this.api.put<MaintenanceMateriel>(`/api/v1/maintenances/${id}`, maintenance);
return response.data;
}
async deleteMaintenance(id: string): Promise<void> {
await this.api.delete(`/api/v1/maintenances/${id}`);
}
async getMaintenancesByMateriel(materielId: string): Promise<MaintenanceMateriel[]> {
const response = await this.api.get<MaintenanceMateriel[]>(`/api/v1/maintenances/by-materiel/${materielId}`);
return response.data;
}
// === PLANNING ===
async getPlanningEvents(params?: {
dateDebut?: string;
dateFin?: string;
type?: string;
statut?: string;
}): Promise<PlanningEvent[]> {
const response = await this.api.get<PlanningEvent[]>('/api/v1/planning/events', { params });
return Array.isArray(response.data) ? response.data : [];
}
async getPlanningEvent(id: string): Promise<PlanningEvent> {
const response = await this.api.get<PlanningEvent>(`/api/v1/planning/events/${id}`);
return response.data;
}
async createPlanningEvent(event: Partial<PlanningEvent>): Promise<PlanningEvent> {
const response = await this.api.post<PlanningEvent>('/api/v1/planning/events', event);
return response.data;
}
async updatePlanningEvent(id: string, event: Partial<PlanningEvent>): Promise<PlanningEvent> {
const response = await this.api.put<PlanningEvent>(`/api/v1/planning/events/${id}`, event);
return response.data;
}
async deletePlanningEvent(id: string): Promise<void> {
await this.api.delete(`/api/v1/planning/events/${id}`);
}
async getCalendrierView(annee: number, mois: number): Promise<PlanningCalendrierView> {
const response = await this.api.get<PlanningCalendrierView>('/api/v1/planning/calendrier', {
params: { annee, mois }
});
return response.data;
}
async detecterConflitsPlanification(dateDebut?: string, dateFin?: string): Promise<PlanningConflict[]> {
const response = await this.api.get<PlanningConflict[]>('/api/v1/planning/conflits', {
params: { dateDebut, dateFin }
});
return response.data;
}
async getPlanningEmploye(employeId: string, dateDebut?: string, dateFin?: string): Promise<PlanningEvent[]> {
const response = await this.api.get<PlanningEvent[]>(`/api/v1/planning/employe/${employeId}`, {
params: { dateDebut, dateFin }
});
return response.data;
}
async getPlanningEquipe(equipeId: string, dateDebut?: string, dateFin?: string): Promise<PlanningEvent[]> {
const response = await this.api.get<PlanningEvent[]>(`/api/v1/planning/equipe/${equipeId}`, {
params: { dateDebut, dateFin }
});
return response.data;
}
async getPlanningMateriel(materielId: string, dateDebut?: string, dateFin?: string): Promise<PlanningEvent[]> {
const response = await this.api.get<PlanningEvent[]>(`/api/v1/planning/materiel/${materielId}`, {
params: { dateDebut, dateFin }
});
return response.data;
}
async assignEmployesToEvent(eventId: string, employeIds: string[]): Promise<void> {
await this.api.post(`/api/v1/planning/events/${eventId}/assign-employes`, employeIds);
}
async assignMaterielsToEvent(eventId: string, materielIds: string[]): Promise<void> {
await this.api.post(`/api/v1/planning/events/${eventId}/assign-materiels`, materielIds);
}
async getPlanningStats(): Promise<PlanningStats> {
const response = await this.api.get<PlanningStats>('/api/v1/planning/stats');
return response.data;
}
async getTauxOccupation(dateDebut?: string, dateFin?: string): Promise<any> {
const response = await this.api.get('/api/v1/planning/occupation', {
params: { dateDebut, dateFin }
});
return response.data;
}
// === TEST ===
async testCreateChantier(chantier: any): Promise<string> {
const response = await this.api.post('/api/v1/test/chantier', chantier);
return response.data;
}
}
// Instance singleton
export const apiService = new ApiService();
// Services spécialisés
export const clientService = {
getAll: () => apiService.getClients(),
getById: (id: string) => apiService.getClient(id),
create: (client: Partial<Client>) => apiService.createClient(client),
update: (id: string, client: Partial<Client>) => apiService.updateClient(id, client),
delete: (id: string) => apiService.deleteClient(id),
search: (params: any) => apiService.searchClients(params),
searchByNom: (nom: string) => apiService.searchClientsByNom(nom),
searchByEntreprise: (entreprise: string) => apiService.searchClientsByEntreprise(entreprise),
searchByVille: (ville: string) => apiService.searchClientsByVille(ville),
searchByEmail: (email: string) => apiService.searchClientsByEmail(email),
count: () => apiService.getClientsCount(),
};
export const chantierService = {
getAll: () => apiService.getChantiers(),
getAllActive: () => apiService.getChantiersActifs(),
getById: (id: string) => apiService.getChantier(id),
create: (chantier: Partial<Chantier>) => apiService.createChantier(chantier),
update: (id: string, chantier: Partial<Chantier>) => apiService.updateChantier(id, chantier),
delete: (id: string, permanent: boolean = false) => apiService.deleteChantier(id, permanent),
getByClient: (clientId: string) => apiService.getChantiersByClient(clientId),
getRecents: () => apiService.getChantiersRecents(),
};
export const devisService = {
getAll: () => apiService.getDevis(),
getById: (id: string) => apiService.getDevisById(id),
getEnAttente: () => apiService.getDevisEnAttente(),
create: (devis: Partial<Devis>) => apiService.createDevis(devis),
update: (id: string, devis: Partial<Devis>) => apiService.updateDevis(id, devis),
delete: (id: string) => apiService.deleteDevis(id),
};
export const factureService = {
getAll: () => apiService.getFactures(),
getById: (id: string) => apiService.getFacture(id),
getEnRetard: () => apiService.getFacturesEnRetard(),
create: (facture: Partial<Facture>) => apiService.createFacture(facture),
update: (id: string, facture: Partial<Facture>) => apiService.updateFacture(id, facture),
delete: (id: string) => apiService.deleteFacture(id),
};
export const dashboardService = {
getStats: () => apiService.getDashboardStats(),
};
export const employeService = {
getAll: () => apiService.getEmployes(),
getById: (id: string) => apiService.getEmploye(id),
create: (employe: Partial<Employe>) => apiService.createEmploye(employe),
update: (id: string, employe: Partial<Employe>) => apiService.updateEmploye(id, employe),
delete: (id: string) => apiService.deleteEmploye(id),
search: (params: any) => apiService.searchEmployes(params),
getDisponibles: (dateDebut?: string, dateFin?: string) => apiService.getEmployesDisponibles(dateDebut, dateFin),
getByEquipe: (equipeId: string) => apiService.getEmployesByEquipe(equipeId),
count: () => apiService.getEmployesCount(),
getStats: () => apiService.getEmployesStats(),
};
export const equipeService = {
getAll: () => apiService.getEquipes(),
getById: (id: string) => apiService.getEquipe(id),
create: (equipe: Partial<Equipe>) => apiService.createEquipe(equipe),
update: (id: string, equipe: Partial<Equipe>) => apiService.updateEquipe(id, equipe),
delete: (id: string) => apiService.deleteEquipe(id),
search: (params: any) => apiService.searchEquipes(params),
getDisponibles: (dateDebut?: string, dateFin?: string) => apiService.getEquipesDisponibles(dateDebut, dateFin),
getMembres: (equipeId: string) => apiService.getMembresEquipe(equipeId),
ajouterMembre: (equipeId: string, employeId: string) => apiService.ajouterMembreEquipe(equipeId, employeId),
retirerMembre: (equipeId: string, employeId: string) => apiService.retirerMembreEquipe(equipeId, employeId),
count: () => apiService.getEquipesCount(),
getStats: () => apiService.getEquipesStats(),
};
export const materielService = {
getAll: () => apiService.getMateriels(),
getById: (id: string) => apiService.getMateriel(id),
create: (materiel: Partial<Materiel>) => apiService.createMateriel(materiel),
update: (id: string, materiel: Partial<Materiel>) => apiService.updateMateriel(id, materiel),
delete: (id: string) => apiService.deleteMateriel(id),
search: (params: any) => apiService.searchMateriels(params),
getDisponibles: (dateDebut?: string, dateFin?: string, type?: string) => apiService.getMaterielsDisponibles(dateDebut, dateFin, type),
getMaintenancePrevue: (jours?: number) => apiService.getMaterielsMaintenancePrevue(jours),
getByType: (type: string) => apiService.getMaterielsByType(type),
reserver: (id: string, dateDebut: string, dateFin: string) => apiService.reserverMateriel(id, dateDebut, dateFin),
liberer: (id: string) => apiService.libererMateriel(id),
count: () => apiService.getMaterielsCount(),
getStats: () => apiService.getMaterielsStats(),
getValeurTotale: () => apiService.getValeurTotaleMateriels(),
};
export const budgetService = {
getAll: (params?: { search?: string; statut?: string; tendance?: string }) => apiService.getBudgets(params),
getById: (id: string) => apiService.getBudget(id),
getByChantier: (chantierId: string) => apiService.getBudgetByChantier(chantierId),
getEnDepassement: () => apiService.getBudgetsEnDepassement(),
getNecessitantAttention: () => apiService.getBudgetsNecessitantAttention(),
create: (budget: any) => apiService.createBudget(budget),
update: (id: string, budget: any) => apiService.updateBudget(id, budget),
delete: (id: string) => apiService.deleteBudget(id),
updateDepenses: (id: string, depense: number) => apiService.updateBudgetDepenses(id, depense),
updateAvancement: (id: string, avancement: number) => apiService.updateBudgetAvancement(id, avancement),
ajouterAlerte: (id: string, description: string) => apiService.ajouterAlerteBudget(id, description),
supprimerAlertes: (id: string) => apiService.supprimerAlertesBudget(id),
};
export const maintenanceService = {
getAll: () => apiService.getMaintenances(),
getById: (id: string) => apiService.getMaintenance(id),
create: (maintenance: Partial<MaintenanceMateriel>) => apiService.createMaintenance(maintenance),
update: (id: string, maintenance: Partial<MaintenanceMateriel>) => apiService.updateMaintenance(id, maintenance),
delete: (id: string) => apiService.deleteMaintenance(id),
getByMateriel: (materielId: string) => apiService.getMaintenancesByMateriel(materielId),
};
export const planningService = {
getEvents: (params?: any) => apiService.getPlanningEvents(params),
getEvent: (id: string) => apiService.getPlanningEvent(id),
createEvent: (event: Partial<PlanningEvent>) => apiService.createPlanningEvent(event),
updateEvent: (id: string, event: Partial<PlanningEvent>) => apiService.updatePlanningEvent(id, event),
deleteEvent: (id: string) => apiService.deletePlanningEvent(id),
getCalendrierView: (annee: number, mois: number) => apiService.getCalendrierView(annee, mois),
detecterConflits: (dateDebut?: string, dateFin?: string) => apiService.detecterConflitsPlanification(dateDebut, dateFin),
getPlanningEmploye: (employeId: string, dateDebut?: string, dateFin?: string) => apiService.getPlanningEmploye(employeId, dateDebut, dateFin),
getPlanningEquipe: (equipeId: string, dateDebut?: string, dateFin?: string) => apiService.getPlanningEquipe(equipeId, dateDebut, dateFin),
getPlanningMateriel: (materielId: string, dateDebut?: string, dateFin?: string) => apiService.getPlanningMateriel(materielId, dateDebut, dateFin),
assignEmployes: (eventId: string, employeIds: string[]) => apiService.assignEmployesToEvent(eventId, employeIds),
assignMateriels: (eventId: string, materielIds: string[]) => apiService.assignMaterielsToEvent(eventId, materielIds),
getStats: () => apiService.getPlanningStats(),
getTauxOccupation: (dateDebut?: string, dateFin?: string) => apiService.getTauxOccupation(dateDebut, dateFin),
};
// Service pour les types de chantier
export const typeChantierService = {
getAll: () => apiService.get('/api/v1/types-chantier'),
getByCategorie: () => apiService.get('/api/v1/types-chantier/par-categorie'),
getById: (id: string) => apiService.get(`/api/v1/types-chantier/${id}`),
getByCode: (code: string) => apiService.get(`/api/v1/types-chantier/code/${code}`),
create: (typeChantier: any) => apiService.post('/api/v1/types-chantier', typeChantier),
update: (id: string, typeChantier: any) => apiService.put(`/api/v1/types-chantier/${id}`, typeChantier),
delete: (id: string) => apiService.delete(`/api/v1/types-chantier/${id}`),
reactivate: (id: string) => apiService.post(`/api/v1/types-chantier/${id}/reactivate`),
getStatistiques: () => apiService.get('/api/v1/types-chantier/statistiques'),
};
export default apiService;

View File

@@ -0,0 +1,283 @@
/**
* Service pour gérer la cohérence des budgets entre chantiers et phases
* Assure que le montant_prevu du chantier correspond à la somme des budget_prevu des phases
*/
import { API_CONFIG } from '../config/api';
import axios from 'axios';
interface BudgetCoherence {
chantierId: string;
chantierNom: string;
budgetChantier: number;
budgetPhasesTotal: number;
ecartAbsolu: number;
ecartPourcentage: number;
coherent: boolean;
nombrePhases: number;
}
interface ValidationBudget {
valide: boolean;
message: string;
recommandation?: 'METTRE_A_JOUR_CHANTIER' | 'AJUSTER_PHASES' | 'AUCUNE_ACTION';
nouveauBudgetSuggere?: number;
}
class BudgetCoherenceService {
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token Keycloak
this.api.interceptors.request.use(
async (config) => {
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
if (typeof window !== 'undefined') {
const { keycloak, KEYCLOAK_TIMEOUTS } = await import('../config/keycloak');
if (keycloak.authenticated) {
try {
// Rafraîchir le token si nécessaire
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
// Ajouter le token Bearer à l'en-tête Authorization
if (keycloak.token) {
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
}
} catch (error) {
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
keycloak.login();
throw error;
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
return config;
},
(error) => Promise.reject(error)
);
}
/**
* Vérifier la cohérence budgétaire pour un chantier donné
*/
async verifierCoherence(chantierId: string): Promise<BudgetCoherence> {
try {
// Pour l'instant, on fait toujours le calcul côté client
// L'endpoint backend sera implémenté plus tard
const [chantierResponse, phasesResponse] = await Promise.all([
this.api.get(`/api/v1/chantiers/${chantierId}`),
this.api.get(`/api/v1/phases-chantier/chantier/${chantierId}`)
]);
const chantier = chantierResponse.data;
const phases = phasesResponse.data || [];
const budgetChantier = parseFloat(chantier.montantPrevu || 0);
const budgetPhasesTotal = phases.reduce((sum: number, phase: any) =>
sum + parseFloat(phase.budgetPrevu || 0), 0);
const ecartAbsolu = Math.abs(budgetChantier - budgetPhasesTotal);
const ecartPourcentage = budgetChantier > 0 ? (ecartAbsolu / budgetChantier) * 100 : 0;
return {
chantierId,
chantierNom: chantier.nom,
budgetChantier,
budgetPhasesTotal,
ecartAbsolu,
ecartPourcentage,
coherent: ecartPourcentage <= 5, // Tolérance de 5%
nombrePhases: phases.length
};
} catch (error) {
console.warn('Erreur lors de la vérification de cohérence:', error);
// Retourner des valeurs par défaut en cas d'erreur
return {
chantierId,
chantierNom: 'Chantier inconnu',
budgetChantier: 0,
budgetPhasesTotal: 0,
ecartAbsolu: 0,
ecartPourcentage: 0,
coherent: true, // Considérer comme cohérent en cas d'erreur
nombrePhases: 0
};
}
}
/**
* Valider un budget de phases avant génération
*/
async validerBudgetPhases(chantierId: string, budgetPhasesPrevu: number): Promise<ValidationBudget> {
try {
const coherence = await this.verifierCoherence(chantierId);
const nouveauTotal = budgetPhasesPrevu;
const ecart = Math.abs(coherence.budgetChantier - nouveauTotal);
const ecartPourcentage = coherence.budgetChantier > 0 ? (ecart / coherence.budgetChantier) * 100 : 0;
if (ecartPourcentage <= 5) {
return {
valide: true,
message: 'Le budget des phases est cohérent avec le budget du chantier',
recommandation: 'AUCUNE_ACTION'
};
} else if (nouveauTotal > coherence.budgetChantier) {
return {
valide: false,
message: `Le budget des phases (${this.formatCurrency(nouveauTotal)}) dépasse le budget du chantier (${this.formatCurrency(coherence.budgetChantier)}) de ${ecartPourcentage.toFixed(1)}%`,
recommandation: 'METTRE_A_JOUR_CHANTIER',
nouveauBudgetSuggere: Math.ceil(nouveauTotal * 1.1) // +10% de marge
};
} else {
return {
valide: false,
message: `Le budget des phases (${this.formatCurrency(nouveauTotal)}) est inférieur au budget du chantier (${this.formatCurrency(coherence.budgetChantier)}) de ${ecartPourcentage.toFixed(1)}%`,
recommandation: 'AJUSTER_PHASES',
nouveauBudgetSuggere: coherence.budgetChantier
};
}
} catch (error) {
console.error('Erreur lors de la validation budgétaire:', error);
return {
valide: true, // En cas d'erreur, on laisse passer
message: 'Impossible de valider la cohérence budgétaire'
};
}
}
/**
* Mettre à jour le budget du chantier pour le faire correspondre aux phases
*/
async synchroniserBudgetChantier(chantierId: string, nouveauBudget: number): Promise<boolean> {
try {
await this.api.put(`/api/v1/chantiers/${chantierId}`, {
montantPrevu: nouveauBudget
});
console.log(`Budget du chantier mis à jour: ${this.formatCurrency(nouveauBudget)}`);
return true;
} catch (error) {
console.error('Erreur lors de la mise à jour du budget:', error);
return false;
}
}
/**
* Suggérer une répartition équilibrée du budget sur les phases
*/
suggererRepartitionBudget(budgetTotal: number, phases: any[]): any[] {
if (!phases.length) return [];
// Répartition basée sur la complexité/durée des phases
const phasesAvecPoids = phases.map(phase => ({
...phase,
poids: this.calculerPoidsPhase(phase)
}));
const poidsTotal = phasesAvecPoids.reduce((sum, p) => sum + p.poids, 0);
return phasesAvecPoids.map(phase => ({
...phase,
budgetSuggere: Math.round((phase.poids / poidsTotal) * budgetTotal)
}));
}
/**
* Calculer le poids d'une phase pour la répartition budgétaire
*/
private calculerPoidsPhase(phase: any): number {
let poids = 1;
// Basé sur la durée
if (phase.dureeEstimee) {
poids *= Math.max(1, phase.dureeEstimee / 5); // Phases plus longues = plus de poids
}
// Basé sur le type de phase
const typesPoids: Record<string, number> = {
'GROS_OEUVRE': 2.0,
'FONDATIONS': 1.8,
'CHARPENTE': 1.5,
'SECOND_OEUVRE': 1.2,
'FINITIONS': 1.0,
'EQUIPEMENTS': 1.3
};
if (phase.categorieMetier && typesPoids[phase.categorieMetier]) {
poids *= typesPoids[phase.categorieMetier];
}
// Phase critique = plus de poids
if (phase.obligatoire || phase.critique) {
poids *= 1.2;
}
return poids;
}
/**
* Formater un montant en devise
*/
private formatCurrency(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(amount);
}
/**
* Obtenir des recommandations budgétaires pour un chantier
*/
async obtenirRecommandations(chantierId: string): Promise<string[]> {
const coherence = await this.verifierCoherence(chantierId);
const recommandations: string[] = [];
if (!coherence.coherent) {
if (coherence.budgetPhasesTotal > coherence.budgetChantier) {
recommandations.push(
`💰 Le budget des phases dépasse celui du chantier de ${this.formatCurrency(coherence.ecartAbsolu)}`
);
recommandations.push(
`🔧 Recommandation : Augmenter le budget du chantier à ${this.formatCurrency(coherence.budgetPhasesTotal * 1.1)}`
);
} else {
recommandations.push(
`📉 Le budget des phases est inférieur à celui du chantier de ${this.formatCurrency(coherence.ecartAbsolu)}`
);
recommandations.push(
`🔧 Recommandation : Réajuster les budgets des phases ou prévoir des phases supplémentaires`
);
}
}
if (coherence.nombrePhases === 0) {
recommandations.push(
`⚠️ Aucune phase définie pour ce chantier. Utilisez l'assistant de génération de phases.`
);
}
return recommandations;
}
}
export default new BudgetCoherenceService();

157
services/cacheService.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Service de cache pour optimiser les performances
*/
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // Time to live en millisecondes
}
export class CacheService {
private static cache = new Map<string, CacheEntry<any>>();
private static readonly DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes par défaut
/**
* Stocke une valeur dans le cache
*/
static set<T>(key: string, data: T, ttl: number = this.DEFAULT_TTL): void {
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
ttl
};
this.cache.set(key, entry);
}
/**
* Récupère une valeur du cache
*/
static get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Vérifier si l'entrée a expiré
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
/**
* Supprime une entrée du cache
*/
static delete(key: string): boolean {
return this.cache.delete(key);
}
/**
* Vide tout le cache
*/
static clear(): void {
this.cache.clear();
}
/**
* Nettoie les entrées expirées
*/
static cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
}
}
}
/**
* Récupère ou exécute une fonction avec mise en cache
*/
static async getOrSet<T>(
key: string,
fetchFunction: () => Promise<T>,
ttl: number = this.DEFAULT_TTL
): Promise<T> {
// Essayer de récupérer depuis le cache
const cached = this.get<T>(key);
if (cached !== null) {
return cached;
}
// Exécuter la fonction et mettre en cache
try {
const data = await fetchFunction();
this.set(key, data, ttl);
return data;
} catch (error) {
// Ne pas mettre en cache les erreurs
throw error;
}
}
/**
* Invalide le cache pour un pattern de clés
*/
static invalidatePattern(pattern: string): void {
const regex = new RegExp(pattern);
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
}
}
}
/**
* Obtient les statistiques du cache
*/
static getStats(): {
size: number;
keys: string[];
expired: number;
} {
const now = Date.now();
let expired = 0;
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
expired++;
}
}
return {
size: this.cache.size,
keys: Array.from(this.cache.keys()),
expired
};
}
}
// Nettoyage automatique toutes les 10 minutes
if (typeof window !== 'undefined') {
setInterval(() => {
CacheService.cleanup();
}, 10 * 60 * 1000);
}
// Clés de cache prédéfinies
export const CacheKeys = {
CLIENTS: 'clients',
CHANTIERS: 'chantiers',
DEVIS: 'devis',
FACTURES: 'factures',
DASHBOARD_STATS: 'dashboard_stats',
USER_PROFILE: 'user_profile',
// Fonctions utilitaires pour générer des clés
clientById: (id: string) => `client_${id}`,
chantierId: (id: string) => `chantier_${id}`,
devisById: (id: string) => `devis_${id}`,
factureById: (id: string) => `facture_${id}`,
devisByClient: (clientId: string) => `devis_client_${clientId}`,
facturesByClient: (clientId: string) => `factures_client_${clientId}`,
} as const;

View File

@@ -0,0 +1,484 @@
import ApiService from './ApiService';
/**
* Service pour les calculs techniques ultra-détaillés BTP
* Le plus ambitieux système de calculs BTP d'Afrique
*/
// =================== INTERFACES PARAMÈTRES ===================
export interface ParametresCalculBriques {
surface: number;
epaisseurMur: number;
codeBrique: string;
zoneClimatique: string;
typeAppareillage: 'DROIT' | 'QUINCONCE' | 'FLAMAND' | 'ANGLAIS';
jointHorizontal: number;
jointVertical: number;
ouvertures: Ouverture[];
}
export interface Ouverture {
largeur: number;
hauteur: number;
}
export interface ParametresCalculMortier {
volumeMaconnerie: number;
typeMortier: 'POSE_BRIQUES' | 'JOINTOIEMENT' | 'ENDUIT_BASE' | 'ENDUIT_FINITION' | 'STANDARD';
zoneClimatique: string;
}
export interface ParametresCalculBetonArme {
volume: number;
classeBeton: 'C20/25' | 'C25/30' | 'C30/37' | 'C35/45';
classeExposition: 'XC1' | 'XC3' | 'XC4' | 'XS1' | 'XS3';
typeOuvrage: 'DALLE' | 'POUTRE' | 'POTEAU' | 'VOILE';
epaisseur: number;
zoneClimatique: string;
}
// =================== INTERFACES RÉSULTATS ===================
export interface ResultatCalculBriques {
nombreBriques: number;
nombrePalettes: number;
briquesParM2: number;
surfaceNette: number;
mortier: ResultatCalculMortier;
facteurPerte: number;
facteurClimatique: number;
nombreCouches: number;
recommendationsZone: string[];
}
export interface ResultatCalculMortier {
volumeTotal: number;
cimentKg: number;
sableLitres: number;
eauLitres: number;
sacs50kg: number;
}
export interface ResultatCalculBetonArme {
volume: number;
cimentKg: number;
cimentSacs50kg: number;
sableKg: number;
sableM3: number;
graviersKg: number;
graviersM3: number;
eauLitres: number;
acierKgTotal: number;
repartitionAcier: Record<number, number>; // diamètre -> poids en kg
enrobage: number;
dosageAdapte: DosageBeton;
adaptationsClimatiques: string[];
}
export interface DosageBeton {
ciment: number; // kg/m³
eau: number; // L/m³
graviers: number; // kg/m³
sable: number; // kg/m³
}
export interface DosageBetonInfo {
usage: string;
ciment: string;
resistance: string;
exposition: string;
}
// =================== CLASSES DE SERVICE ===================
export class CalculsTechniquesService {
private static readonly BASE_PATH = '/api/v1/calculs-techniques';
// =================== CALCULS MAÇONNERIE ===================
/**
* Calcul ultra-précis quantité briques pour mur
* Prend en compte dimensions exactes, joints, appareillage, pertes, zone climatique
*/
static async calculerBriquesMur(params: ParametresCalculBriques): Promise<ResultatCalculBriques> {
const response = await ApiService.post<ResultatCalculBriques>(
`${this.BASE_PATH}/briques-mur`,
params
);
return response;
}
/**
* Calcul mortier pour maçonnerie traditionnelle
*/
static async calculerMortierMaconnerie(params: ParametresCalculMortier): Promise<ResultatCalculMortier> {
const response = await ApiService.post<ResultatCalculMortier>(
`${this.BASE_PATH}/mortier-maconnerie`,
params
);
return response;
}
/**
* Estimation rapide briques pour surface donnée
*/
static async estimationRapideBriques(surface: number, typeBrique: string = 'brique-rouge-15x10x5'): Promise<{
estimationBasse: number;
estimationHaute: number;
estimationMoyenne: number;
baseCalcul: string;
}> {
// Calcul côté client pour estimation rapide
const briquesParM2Moyen = 67; // Moyenne pour brique 15x10x5cm
const facteurPerteMoyen = 1.08; // 8% de perte moyenne
const estimationMoyenne = Math.ceil(surface * briquesParM2Moyen * facteurPerteMoyen);
const estimationBasse = Math.ceil(estimationMoyenne * 0.85);
const estimationHaute = Math.ceil(estimationMoyenne * 1.25);
return {
estimationBasse,
estimationHaute,
estimationMoyenne,
baseCalcul: `Surface: ${surface}× ${briquesParM2Moyen} briques/m² × ${facteurPerteMoyen} (pertes)`
};
}
// =================== CALCULS BÉTON ARMÉ ===================
/**
* Calcul béton armé avec adaptation climatique africaine
*/
static async calculerBetonArme(params: ParametresCalculBetonArme): Promise<ResultatCalculBetonArme> {
const response = await ApiService.post<ResultatCalculBetonArme>(
`${this.BASE_PATH}/beton-arme`,
params
);
return response;
}
/**
* Récupère les dosages béton standard avec adaptations climatiques
*/
static async getDosagesBeton(): Promise<{
dosages: Record<string, DosageBetonInfo>;
notes: string[];
}> {
const response = await ApiService.get<{
dosages: Record<string, DosageBetonInfo>;
notes: string[];
}>(`${this.BASE_PATH}/dosages-beton`);
return response;
}
/**
* Estimation rapide béton pour volume donné
*/
static async estimationRapideBeton(volume: number, classeBeton: string = 'C25/30'): Promise<{
cimentSacs: number;
sableM3: number;
graviersM3: number;
eauLitres: number;
coutEstime: number;
}> {
// Dosages moyens selon classe
const dosages = {
'C20/25': { ciment: 300, sable: 1100, graviers: 650, eau: 165 },
'C25/30': { ciment: 350, sable: 1050, graviers: 600, eau: 175 },
'C30/37': { ciment: 385, sable: 1000, graviers: 580, eau: 180 },
'C35/45': { ciment: 420, sable: 950, graviers: 550, eau: 185 }
};
const dosage = dosages[classeBeton] || dosages['C25/30'];
const cimentKg = volume * dosage.ciment;
const cimentSacs = Math.ceil(cimentKg / 50);
const sableKg = volume * dosage.sable;
const sableM3 = sableKg / 1600; // densité sable
const graviersKg = volume * dosage.graviers;
const graviersM3 = graviersKg / 1500; // densité graviers
const eauLitres = volume * dosage.eau;
// Estimation coût (prix moyens Afrique de l'Ouest)
const coutEstime = (cimentSacs * 8000) + // 8000 FCFA/sac
(sableM3 * 25000) + // 25000 FCFA/m³
(graviersM3 * 30000) + // 30000 FCFA/m³
(eauLitres * 2); // 2 FCFA/L
return {
cimentSacs,
sableM3: Math.ceil(sableM3 * 100) / 100, // 2 décimales
graviersM3: Math.ceil(graviersM3 * 100) / 100,
eauLitres,
coutEstime
};
}
// =================== CALCULS COMPLEXES ===================
/**
* Calcul complet d'un mur (briques + mortier + enduit)
*/
static async calculerMurComplet(params: {
surface: number;
epaisseurMur: number;
codeBrique: string;
zoneClimatique: string;
typeAppareillage: string;
avecEnduit: boolean;
typeEnduit?: 'CIMENT' | 'CHAUX' | 'PLATRE';
}): Promise<{
briques: ResultatCalculBriques;
enduit?: {
mortierM3: number;
cimentKg: number;
sableKg: number;
eauLitres: number;
};
coutTotal: number;
tempsTotal: number; // en heures
}> {
// Calcul briques
const paramsB: ParametresCalculBriques = {
surface: params.surface,
epaisseurMur: params.epaisseurMur,
codeBrique: params.codeBrique,
zoneClimatique: params.zoneClimatique,
typeAppareillage: params.typeAppareillage as any,
jointHorizontal: 10, // défaut 10mm
jointVertical: 10, // défaut 10mm
ouvertures: []
};
const briques = await this.calculerBriquesMur(paramsB);
let enduit;
if (params.avecEnduit) {
// Calcul enduit (15mm d'épaisseur moyenne)
const volumeEnduit = params.surface * 0.015; // 1.5cm
const dosageEnduit = params.typeEnduit === 'CHAUX' ? 250 : 350; // kg/m³
enduit = {
mortierM3: volumeEnduit,
cimentKg: volumeEnduit * dosageEnduit,
sableKg: volumeEnduit * 800, // 800kg sable/m³ mortier
eauLitres: volumeEnduit * 200 // 200L eau/m³ mortier
};
}
// Estimation coûts et temps
const coutBriques = briques.nombreBriques * 250; // 250 FCFA/brique
const coutMortier = briques.mortier.cimentKg * 160; // 160 FCFA/kg ciment
const coutEnduit = enduit ? enduit.cimentKg * 160 : 0;
const coutTotal = coutBriques + coutMortier + coutEnduit;
const tempsBriques = briques.nombreBriques * 3 / 60; // 3min/brique
const tempsEnduit = params.avecEnduit ? params.surface * 45 / 60 : 0; // 45min/m²
const tempsTotal = tempsBriques + tempsEnduit;
return {
briques,
enduit,
coutTotal,
tempsTotal
};
}
/**
* Calcul dalle béton complète (béton + armatures + coffrages)
*/
static async calculerDalleComplete(params: {
surface: number;
epaisseur: number;
classeBeton: string;
zoneClimatique: string;
typeArmature: 'LEGERE' | 'NORMALE' | 'RENFORCEE';
avecCoffrage: boolean;
}): Promise<{
beton: ResultatCalculBetonArme;
coffrage?: {
planchesM2: number;
etaisNombre: number;
};
coutTotal: number;
tempsTotal: number;
}> {
const volume = params.surface * (params.epaisseur / 100); // épaisseur en cm -> m
const paramsBA: ParametresCalculBetonArme = {
volume,
classeBeton: params.classeBeton as any,
classeExposition: 'XC3', // défaut intérieur humide
typeOuvrage: 'DALLE',
epaisseur: params.epaisseur,
zoneClimatique: params.zoneClimatique
};
const beton = await this.calculerBetonArme(paramsBA);
let coffrage;
if (params.avecCoffrage) {
// Surface coffrante = surface dalle + rives
const perimetre = 2 * Math.sqrt(params.surface * 4); // approximation carré
const surfaceCoffrante = params.surface + (perimetre * params.epaisseur / 100);
coffrage = {
planchesM2: surfaceCoffrante * 1.15, // 15% majoration
etaisNombre: Math.ceil(params.surface / 2) // 1 étai par 2m²
};
}
// Estimation coûts
const coutBeton = (beton.cimentSacs50kg * 8000) +
(beton.sableM3.valueOf() * 25000) +
(beton.graviersM3.valueOf() * 30000);
const coutAcier = beton.acierKgTotal * 1200; // 1200 FCFA/kg acier
const coutCoffrage = coffrage ? (coffrage.planchesM2 * 5000) + (coffrage.etaisNombre * 15000) : 0;
const coutTotal = coutBeton + coutAcier + coutCoffrage;
// Estimation temps
const tempsBeton = volume * 2; // 2h/m³
const tempsArmature = beton.acierKgTotal * 0.5; // 30min/kg
const tempsCoffrage = coffrage ? coffrage.planchesM2 * 0.25 : 0; // 15min/m²
const tempsTotal = tempsBeton + tempsArmature + tempsCoffrage;
return {
beton,
coffrage,
coutTotal,
tempsTotal
};
}
// =================== OUTILS UTILITAIRES ===================
/**
* Conversion d'unités de mesure BTP
*/
static convertirUnites(valeur: number, uniteSource: string, uniteDestination: string): number {
const conversions: Record<string, Record<string, number>> = {
'm': { 'cm': 100, 'mm': 1000, 'km': 0.001 },
'm²': { 'cm²': 10000, 'mm²': 1000000, 'ha': 0.0001 },
'm³': { 'l': 1000, 'cm³': 1000000, 'mm³': 1000000000 },
'kg': { 'g': 1000, 't': 0.001, 'quintal': 0.01 },
'MPa': { 'kPa': 1000, 'Pa': 1000000, 'bar': 10 }
};
if (conversions[uniteSource]?.[uniteDestination]) {
return valeur * conversions[uniteSource][uniteDestination];
}
throw new Error(`Conversion non supportée: ${uniteSource} vers ${uniteDestination}`);
}
/**
* Validation des paramètres de calcul
*/
static validerParametres(type: 'BRIQUES' | 'MORTIER' | 'BETON', params: any): {
valide: boolean;
erreurs: string[];
avertissements: string[];
} {
const erreurs: string[] = [];
const avertissements: string[] = [];
switch (type) {
case 'BRIQUES':
if (!params.surface || params.surface <= 0) erreurs.push('Surface requise et > 0');
if (!params.epaisseurMur || params.epaisseurMur <= 0) erreurs.push('Épaisseur mur requise et > 0');
if (!params.codeBrique) erreurs.push('Code brique requis');
if (!params.zoneClimatique) erreurs.push('Zone climatique requise');
if (params.surface > 1000) avertissements.push('Surface très importante (>1000m²)');
if (params.epaisseurMur > 30) avertissements.push('Mur très épais (>30cm)');
break;
case 'BETON':
if (!params.volume || params.volume <= 0) erreurs.push('Volume requis et > 0');
if (!params.classeBeton) erreurs.push('Classe béton requise');
if (!params.typeOuvrage) erreurs.push('Type ouvrage requis');
if (params.volume > 500) avertissements.push('Volume très important (>500m³)');
if (params.epaisseur && params.epaisseur < 10) avertissements.push('Épaisseur faible (<10cm)');
break;
}
return {
valide: erreurs.length === 0,
erreurs,
avertissements
};
}
/**
* Génération de devis détaillé
*/
static async genererDevis(calculs: {
briques?: ResultatCalculBriques;
mortier?: ResultatCalculMortier;
beton?: ResultatCalculBetonArme;
}, options?: {
margeEntreprise?: number; // %
tva?: number; // %
delaiExecution?: number; // jours
}): Promise<{
lignesDevis: Array<{
designation: string;
quantite: number;
unite: string;
prixUnitaire: number;
montantHT: number;
}>;
totalHT: number;
totalTTC: number;
delaiExecution: number;
}> {
const lignesDevis: Array<{
designation: string;
quantite: number;
unite: string;
prixUnitaire: number;
montantHT: number;
}> = [];
// Ajout lignes selon calculs
if (calculs.briques) {
lignesDevis.push({
designation: 'Briques terre cuite',
quantite: calculs.briques.nombreBriques,
unite: 'unité',
prixUnitaire: 250,
montantHT: calculs.briques.nombreBriques * 250
});
}
if (calculs.beton) {
lignesDevis.push({
designation: 'Ciment Portland CEM I',
quantite: calculs.beton.cimentSacs50kg,
unite: 'sac 50kg',
prixUnitaire: 8000,
montantHT: calculs.beton.cimentSacs50kg * 8000
});
}
const totalHT = lignesDevis.reduce((sum, ligne) => sum + ligne.montantHT, 0);
const marge = (options?.margeEntreprise || 20) / 100;
const tva = (options?.tva || 18) / 100;
const totalAvecMarge = totalHT * (1 + marge);
const totalTTC = totalAvecMarge * (1 + tva);
return {
lignesDevis,
totalHT: totalAvecMarge,
totalTTC,
delaiExecution: options?.delaiExecution || 15
};
}
}

View File

@@ -0,0 +1,303 @@
/**
* Service pour les actions sur les chantiers
*/
import { apiClient } from './api-client';
export interface ChantierActionResult {
success: boolean;
message: string;
data?: any;
pdfUrl?: string;
amendmentId?: string;
}
class ChantierActionsService {
private readonly basePath = '/chantiers';
/**
* Suspendre ou reprendre un chantier
*/
async toggleSuspend(chantierId: string, suspend: boolean): Promise<ChantierActionResult> {
try {
const action = suspend ? 'suspend' : 'resume';
const response = await apiClient.put(`${this.basePath}/${chantierId}/${action}`);
return {
success: true,
message: suspend ? 'Chantier suspendu avec succès' : 'Chantier repris avec succès',
data: response.data
};
} catch (error) {
return {
success: false,
message: 'Erreur lors de la modification du statut',
data: error
};
}
}
/**
* Clôturer un chantier
*/
async closeChantier(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.put(`${this.basePath}/${chantierId}/close`, {
dateFinReelle: new Date().toISOString(),
statut: 'TERMINE'
});
return {
success: true,
message: 'Chantier clôturé avec succès',
data: response.data
};
} catch (error) {
return {
success: false,
message: 'Erreur lors de la clôture du chantier',
data: error
};
}
}
/**
* Archiver un chantier
*/
async archiveChantier(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.put(`${this.basePath}/${chantierId}/archive`, {
actif: false,
dateArchivage: new Date().toISOString()
});
return {
success: true,
message: 'Chantier archivé avec succès',
data: response.data
};
} catch (error) {
return {
success: false,
message: 'Erreur lors de l\'archivage du chantier',
data: error
};
}
}
/**
* Générer un rapport pour un chantier
*/
async generateReport(chantierId: string, format: 'pdf' | 'excel' = 'pdf'): Promise<ChantierActionResult> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierId}/report`, {
params: { format },
responseType: 'blob'
});
// Créer un lien de téléchargement
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `rapport-chantier-${chantierId}.${format}`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return {
success: true,
message: 'Rapport généré avec succès',
data: response.data
};
} catch (error) {
return {
success: false,
message: 'Erreur lors de la génération du rapport',
data: error
};
}
}
/**
* Exporter les données d'un chantier
*/
async exportChantier(chantierId: string, format: 'pdf' | 'excel'): Promise<ChantierActionResult> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierId}/export`, {
params: { format },
responseType: 'blob'
});
// Créer un lien de téléchargement
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `export-chantier-${chantierId}.${format === 'excel' ? 'xlsx' : format}`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return {
success: true,
message: `Export ${format.toUpperCase()} réussi`,
data: response.data
};
} catch (error) {
return {
success: false,
message: `Erreur lors de l'export ${format.toUpperCase()}`,
data: error
};
}
}
/**
* Obtenir un résumé rapide d'un chantier
*/
async getQuickSummary(chantierId: string): Promise<any> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierId}/summary`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération du résumé:', error);
throw error;
}
}
/**
* Obtenir les statistiques d'un chantier
*/
async getChantierStats(chantierId: string): Promise<any> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierId}/stats`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des statistiques:', error);
throw error;
}
}
/**
* Obtenir les alertes d'un chantier
*/
async getChantierAlerts(chantierId: string): Promise<any> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierId}/alerts`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des alertes:', error);
throw error;
}
}
// === NOUVELLES ACTIONS PRIORITAIRES BTP ===
/**
* Suspendre temporairement un chantier
*/
async suspendChantier(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.put(`${this.basePath}/${chantierId}/suspend`, {
datesSuspension: new Date().toISOString(),
motif: 'Suspension temporaire',
statut: 'SUSPENDU'
});
return {
success: true,
message: 'Chantier suspendu temporairement',
data: response.data
};
} catch (error) {
console.error('Erreur lors de la suspension:', error);
throw error;
}
}
/**
* Clôturer définitivement un chantier avec rapport final
*/
async closeChantierDefinitively(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.put(`${this.basePath}/${chantierId}/close-definitively`, {
dateFinReelle: new Date().toISOString(),
statut: 'TERMINE',
generateFinalReport: true
});
return {
success: true,
message: 'Chantier clôturé définitivement',
data: response.data
};
} catch (error) {
console.error('Erreur lors de la clôture:', error);
throw error;
}
}
/**
* Envoyer notification au client
*/
async notifyClient(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.post(`${this.basePath}/${chantierId}/notify-client`, {
type: 'progress_update',
includePhotos: true,
includeProgress: true,
timestamp: new Date().toISOString()
});
return {
success: true,
message: 'Notification envoyée au client',
data: response.data
};
} catch (error) {
console.error('Erreur lors de la notification client:', error);
throw error;
}
}
/**
* Générer facture intermédiaire
*/
async generateIntermediateInvoice(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.post(`${this.basePath}/${chantierId}/invoice/intermediate`, {
dateGeneration: new Date().toISOString(),
type: 'INTERMEDIAIRE',
basedOnProgress: true
});
return {
success: true,
message: 'Facture intermédiaire générée',
data: response.data,
pdfUrl: response.data.pdfUrl
};
} catch (error) {
console.error('Erreur lors de la génération de facture:', error);
throw error;
}
}
/**
* Créer un avenant budgétaire
*/
async createAmendment(chantierId: string): Promise<ChantierActionResult> {
try {
const response = await apiClient.post(`${this.basePath}/${chantierId}/amendment`, {
dateCreation: new Date().toISOString(),
type: 'BUDGETAIRE',
status: 'DRAFT'
});
return {
success: true,
message: 'Avenant budgétaire créé',
data: response.data,
amendmentId: response.data.id
};
} catch (error) {
console.error('Erreur lors de la création d\'avenant:', error);
throw error;
}
}
}
export const chantierActionsService = new ChantierActionsService();

348
services/chantierService.ts Normal file
View File

@@ -0,0 +1,348 @@
import { chantierService as apiChantierService, apiService } from './api';
import { Chantier, ChantierFormData } from '../types/btp';
class ChantierService {
/**
* Récupérer tous les chantiers
*/
async getAll(): Promise<Chantier[]> {
return await apiChantierService.getAll();
}
/**
* Récupérer un chantier par ID
*/
async getById(id: string): Promise<Chantier> {
return await apiChantierService.getById(id);
}
/**
* Créer un nouveau chantier
*/
async create(chantier: ChantierFormData): Promise<Chantier> {
return await apiChantierService.create(chantier);
}
/**
* Modifier un chantier existant
*/
async update(id: string, chantier: ChantierFormData): Promise<Chantier> {
return await apiChantierService.update(id, chantier);
}
/**
* Supprimer un chantier
* @param id - ID du chantier
* @param permanent - Si true, suppression physique définitive. Si false, suppression logique (défaut)
*/
async delete(id: string, permanent: boolean = false): Promise<void> {
await apiChantierService.delete(id, permanent);
}
/**
* Récupérer les chantiers d'un client
*/
async getByClient(clientId: string): Promise<Chantier[]> {
try {
return await apiChantierService.getByClient(clientId);
} catch (error: any) {
// Si l'endpoint spécifique n'existe pas (404), essayer de filtrer tous les chantiers
if (error.status === 404 || error.response?.status === 404) {
try {
const tousLesChantiers = await this.getAll();
return tousLesChantiers.filter(chantier => chantier.clientId === clientId);
} catch (fallbackError) {
console.debug('Fallback sur filtrage côté client également impossible, retour liste vide');
// Retourner une liste vide plutôt qu'une erreur si pas de chantiers
return [];
}
}
// Pour toute autre erreur, la remonter
throw error;
}
}
/**
* Récupérer les chantiers récents
*/
async getRecents(): Promise<Chantier[]> {
return await apiChantierService.getRecents();
}
/**
* Récupérer les chantiers par statut
*/
async getByStatut(statut: string): Promise<Chantier[]> {
const allChantiers = await this.getAll();
return allChantiers.filter(c => c.statut === statut);
}
/**
* Récupérer les chantiers en retard
*/
async getEnRetard(): Promise<Chantier[]> {
const allChantiers = await this.getAll();
return allChantiers.filter(c => this.isEnRetard(c));
}
/**
* Récupérer les chantiers actifs
*/
async getActifs(): Promise<Chantier[]> {
return await this.getByStatut('EN_COURS');
}
/**
* Actions sur les chantiers (nécessitent des endpoints spécifiques)
*/
async demarrer(id: string): Promise<void> {
// Ces méthodes nécessiteraient des endpoints spécifiques dans l'API
throw new Error('Méthode non implémentée côté API');
}
async terminer(id: string): Promise<void> {
throw new Error('Méthode non implémentée côté API');
}
async suspendre(id: string): Promise<void> {
throw new Error('Méthode non implémentée côté API');
}
async reprendre(id: string): Promise<void> {
throw new Error('Méthode non implémentée côté API');
}
async annuler(id: string): Promise<void> {
throw new Error('Méthode non implémentée côté API');
}
/**
* Valider les données d'un chantier
*/
validateChantier(chantier: ChantierFormData): string[] {
const errors: string[] = [];
if (!chantier.nom || chantier.nom.trim().length === 0) {
errors.push('Le nom du chantier est obligatoire');
}
if (!chantier.adresse || chantier.adresse.trim().length === 0) {
errors.push('L\'adresse est obligatoire');
}
if (!chantier.clientId) {
errors.push('Le client est obligatoire');
}
if (!chantier.dateDebut) {
errors.push('La date de début est obligatoire');
}
if (!chantier.dateFinPrevue) {
errors.push('La date de fin prévue est obligatoire');
}
if (chantier.dateDebut && chantier.dateFinPrevue) {
const debut = new Date(chantier.dateDebut);
const fin = new Date(chantier.dateFinPrevue);
if (debut >= fin) {
errors.push('La date de fin doit être postérieure à la date de début');
}
}
if (chantier.montantPrevu !== undefined && chantier.montantPrevu < 0) {
errors.push('Le montant prévu doit être positif');
}
return errors;
}
/**
* Calculer la durée prévue du chantier en jours
*/
calculateDureePrevue(chantier: Chantier): number {
if (!chantier.dateDebut || !chantier.dateFinPrevue) return 0;
const debut = new Date(chantier.dateDebut);
const fin = new Date(chantier.dateFinPrevue);
const diffTime = Math.abs(fin.getTime() - debut.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Calculer la durée réelle du chantier en jours
*/
calculateDureeReelle(chantier: Chantier): number {
if (!chantier.dateDebut) return 0;
const debut = new Date(chantier.dateDebut);
const fin = chantier.dateFinReelle ? new Date(chantier.dateFinReelle) : new Date();
const diffTime = Math.abs(fin.getTime() - debut.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Vérifier si un chantier est en retard
*/
isEnRetard(chantier: Chantier): boolean {
if (chantier.statut === 'TERMINE') return false;
if (!chantier.dateFinPrevue) return false;
return new Date() > new Date(chantier.dateFinPrevue);
}
/**
* Calculer le retard en jours
*/
calculateRetard(chantier: Chantier): number {
if (!this.isEnRetard(chantier)) return 0;
const dateFinPrevue = new Date(chantier.dateFinPrevue!);
const maintenant = new Date();
const diffTime = maintenant.getTime() - dateFinPrevue.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Calculer l'avancement du chantier en pourcentage
*/
calculateAvancement(chantier: Chantier): number {
if (chantier.statut === 'TERMINE') return 100;
if (chantier.statut === 'ANNULE') return 0;
if (!chantier.dateDebut || !chantier.dateFinPrevue) return 0;
const now = new Date();
const start = new Date(chantier.dateDebut);
const end = new Date(chantier.dateFinPrevue);
if (now < start) return 0;
if (now > end) return 100;
const totalDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
const elapsedDays = (now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
return Math.min(Math.max((elapsedDays / totalDays) * 100, 0), 100);
}
/**
* Obtenir le libellé d'un statut
*/
getStatutLabel(statut: string): string {
const labels: Record<string, string> = {
PLANIFIE: 'Planifié',
EN_COURS: 'En cours',
TERMINE: 'Terminé',
ANNULE: 'Annulé',
SUSPENDU: 'Suspendu'
};
return labels[statut] || statut;
}
/**
* Obtenir la couleur d'un statut
*/
getStatutColor(statut: string): string {
const colors: Record<string, string> = {
PLANIFIE: '#6c757d',
EN_COURS: '#0d6efd',
TERMINE: '#198754',
ANNULE: '#dc3545',
SUSPENDU: '#fd7e14'
};
return colors[statut] || '#6c757d';
}
/**
* Calculer les statistiques des chantiers
*/
calculateStatistiques(chantiers: Chantier[]): {
total: number;
planifies: number;
enCours: number;
termines: number;
annules: number;
suspendus: number;
enRetard: number;
montantTotal: number;
coutTotal: number;
} {
const stats = {
total: chantiers.length,
planifies: 0,
enCours: 0,
termines: 0,
annules: 0,
suspendus: 0,
enRetard: 0,
montantTotal: 0,
coutTotal: 0
};
chantiers.forEach(chantier => {
// Compter par statut
switch (chantier.statut) {
case 'PLANIFIE':
stats.planifies++;
break;
case 'EN_COURS':
stats.enCours++;
break;
case 'TERMINE':
stats.termines++;
break;
case 'ANNULE':
stats.annules++;
break;
case 'SUSPENDU':
stats.suspendus++;
break;
}
// Vérifier les retards
if (this.isEnRetard(chantier)) {
stats.enRetard++;
}
// Calculer montants
stats.montantTotal += chantier.montantPrevu || 0;
stats.coutTotal += chantier.montantReel || 0;
});
return stats;
}
/**
* Exporter les chantiers au format CSV
*/
async exportToCsv(): Promise<Blob> {
const chantiers = await this.getAll();
const headers = [
'ID', 'Nom', 'Description', 'Adresse', 'Client', 'Statut',
'Date Début', 'Date Fin Prévue', 'Date Fin Réelle',
'Montant Prévu', 'Montant Réel', 'Actif'
];
const csvContent = [
headers.join(';'),
...chantiers.map(c => [
c.id || '',
c.nom || '',
c.description || '',
c.adresse || '',
c.client ? `${c.client.prenom} ${c.client.nom}` : '',
this.getStatutLabel(c.statut),
c.dateDebut ? new Date(c.dateDebut).toLocaleDateString('fr-FR') : '',
c.dateFinPrevue ? new Date(c.dateFinPrevue).toLocaleDateString('fr-FR') : '',
c.dateFinReelle ? new Date(c.dateFinReelle).toLocaleDateString('fr-FR') : '',
c.montantPrevu || 0,
c.montantReel || 0,
c.actif ? 'Oui' : 'Non'
].join(';'))
].join('\n');
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
}
}
export default new ChantierService();

View File

@@ -0,0 +1,169 @@
/**
* Service pour la gestion des templates de chantiers et l'auto-génération des phases
*/
import { TypeChantier, PhaseTemplate, ChantierTemplate } from '../types/chantier-templates';
import { PhaseChantier } from '../types/btp-extended';
import phaseTemplateService from './phaseTemplateService';
class ChantierTemplateService {
/**
* Récupérer la liste des types de chantiers disponibles
*/
async getAvailableChantierTypes(): Promise<{ value: TypeChantier; label: string; categorie: string }[]> {
return phaseTemplateService.getAvailableChantierTypes();
}
/**
* Récupérer le template d'un type de chantier
*/
async getTemplate(typeChantier: TypeChantier): Promise<ChantierTemplate> {
const templates = await phaseTemplateService.getTemplatesByType(typeChantier);
const dureeTotal = await phaseTemplateService.calculateDureeTotale(typeChantier);
// Construire un ChantierTemplate à partir des données API
return {
typeChantier,
nom: typeChantier,
description: `Template pour ${typeChantier}`,
dureeMoyenneJours: dureeTotal,
phases: templates
};
}
/**
* Estimer la durée d'un projet selon son type
*/
async estimateProjectDuration(typeChantier: TypeChantier): Promise<number> {
return phaseTemplateService.calculateDureeTotale(typeChantier);
}
/**
* Générer automatiquement les phases d'un chantier
*/
async generatePhases(
chantierId: string,
typeChantier: TypeChantier,
dateDebutProjet: Date,
options?: {
ajusterDelais?: boolean;
inclureSousPhases?: boolean;
personnaliser?: boolean;
}
): Promise<PhaseChantier[]> {
try {
// Utiliser l'API backend pour générer les phases
const phasesGenerees = await phaseTemplateService.generatePhases(
chantierId,
dateDebutProjet,
options?.inclureSousPhases !== false // Par défaut true
);
return phasesGenerees;
} catch (error) {
console.error('Erreur lors de la génération des phases:', error);
throw new Error('Impossible de générer les phases automatiquement');
}
}
/**
* Prévisualiser les phases qui seraient générées (sans les créer)
*/
async previewPhases(typeChantier: TypeChantier, dateDebutProjet: Date): Promise<PhaseTemplate[]> {
return phaseTemplateService.previewPhases(typeChantier);
}
/**
* Obtenir les spécificités d'un type de chantier
*/
async getSpecificites(typeChantier: TypeChantier): Promise<string[]> {
const template = await this.getTemplate(typeChantier);
return template.specificites || [];
}
/**
* Obtenir les réglementations applicables
*/
async getReglementations(typeChantier: TypeChantier): Promise<string[]> {
const template = await this.getTemplate(typeChantier);
return template.reglementations || [];
}
/**
* Calculer le planning prévisionnel complet
*/
async calculatePlanning(typeChantier: TypeChantier, dateDebutProjet: Date): Promise<{
dateDebut: Date;
dateFin: Date;
dureeJours: number;
phases: Array<{
nom: string;
dateDebut: Date;
dateFin: Date;
duree: number;
critique: boolean;
}>;
}> {
const template = await this.getTemplate(typeChantier);
let currentDate = new Date(dateDebutProjet);
const phases = template.phases.map(phase => {
const phaseDebut = new Date(currentDate);
const phaseFin = new Date(currentDate);
phaseFin.setDate(phaseFin.getDate() + phase.dureePrevueJours);
const phaseInfo = {
nom: phase.nom,
dateDebut: phaseDebut,
dateFin: phaseFin,
duree: phase.dureePrevueJours,
critique: phase.critique
};
currentDate = new Date(phaseFin);
return phaseInfo;
});
return {
dateDebut: dateDebutProjet,
dateFin: currentDate,
dureeJours: template.dureeMoyenneJours,
phases
};
}
/**
* Analyser la complexité d'un projet
*/
async analyzeComplexity(typeChantier: TypeChantier): Promise<{
niveau: 'SIMPLE' | 'MOYEN' | 'COMPLEXE' | 'TRES_COMPLEXE';
score: number;
facteurs: string[];
}> {
const complexiteAPI = await phaseTemplateService.analyzeComplexity(typeChantier);
// Calculer des facteurs supplémentaires basés sur les données
const facteurs: string[] = [];
if (complexiteAPI.nombrePhases > 8) facteurs.push('Nombreuses phases');
if (complexiteAPI.dureeTotal > 365) facteurs.push('Durée longue');
if (complexiteAPI.nombrePhasesCritiques > 3) facteurs.push('Phases critiques multiples');
// Calculer un score basé sur les données API
let score = 0;
score += complexiteAPI.nombrePhases * 2;
score += Math.floor(complexiteAPI.dureeTotal / 30);
score += complexiteAPI.nombrePhasesCritiques * 3;
return {
niveau: complexiteAPI.niveauComplexite as 'SIMPLE' | 'MOYEN' | 'COMPLEXE' | 'TRES_COMPLEXE',
score,
facteurs
};
}
}
export default new ChantierTemplateService();

143
services/clientService.ts Normal file
View File

@@ -0,0 +1,143 @@
import { clientService } from './api';
import { Client, ClientFormData } from '../types/btp';
class ClientService {
private readonly basePath = '/api/clients';
/**
* Récupérer tous les clients
*/
async getAll(): Promise<Client[]> {
return await clientService.getAll();
}
/**
* Récupérer un client par ID
*/
async getById(id: string): Promise<Client> {
return await clientService.getById(id);
}
/**
* Créer un nouveau client
*/
async create(client: ClientFormData): Promise<Client> {
return await clientService.create(client as Partial<Client>);
}
/**
* Modifier un client existant
*/
async update(id: string, client: ClientFormData): Promise<Client> {
return await clientService.update(id, client as Partial<Client>);
}
/**
* Supprimer un client
*/
async delete(id: string): Promise<void> {
await clientService.delete(id);
}
/**
* Rechercher des clients
*/
async search(query: string): Promise<Client[]> {
return await clientService.searchByNom(query);
}
/**
* Récupérer les chantiers d'un client
*/
async getChantiers(clientId: string): Promise<any[]> {
// Utiliser le service chantier pour récupérer les chantiers par client
const { chantierService } = await import('./api');
return await chantierService.getByClient(clientId);
}
/**
* Valider les données d'un client
*/
validateClient(client: ClientFormData): string[] {
const errors: string[] = [];
if (!client.nom || client.nom.trim().length === 0) {
errors.push('Le nom est obligatoire');
}
if (!client.prenom || client.prenom.trim().length === 0) {
errors.push('Le prénom est obligatoire');
}
if (client.email && !this.isValidEmail(client.email)) {
errors.push('L\'email n\'est pas valide');
}
if (client.telephone && !this.isValidPhoneNumber(client.telephone)) {
errors.push('Le numéro de téléphone n\'est pas valide');
}
return errors;
}
/**
* Valider une adresse email
*/
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Valider un numéro de téléphone
*/
private isValidPhoneNumber(phone: string): boolean {
const phoneRegex = /^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/;
return phoneRegex.test(phone);
}
/**
* Formater le nom complet d'un client
*/
formatFullName(client: Client): string {
let fullName = `${client.prenom} ${client.nom}`;
if (client.entreprise) {
fullName += ` - ${client.entreprise}`;
}
return fullName;
}
/**
* Exporter les clients au format CSV
*/
async exportToCsv(): Promise<Blob> {
const clients = await this.getAll();
const headers = [
'ID', 'Prénom', 'Nom', 'Entreprise', 'Email', 'Téléphone',
'Adresse', 'Ville', 'Code Postal', 'Pays', 'Type', 'Actif'
];
const csvContent = [
headers.join(';'),
...clients.map(c => [
c.id || '',
c.prenom || '',
c.nom || '',
c.entreprise || '',
c.email || '',
c.telephone || '',
c.adresse || '',
c.ville || '',
c.codePostal || '',
c.pays || '',
c.typeClient || '',
c.actif ? 'Oui' : 'Non'
].join(';'))
].join('\n');
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
}
}
export default new ClientService();

328
services/dashboard.ts Normal file
View File

@@ -0,0 +1,328 @@
import { apiClient } from './api-client';
export interface DashboardMetrics {
totalChantiers: number;
chantiersActifs: number;
chantiersEnRetard: number;
chantiersTermines: number;
totalEquipes: number;
equipesDisponibles: number;
totalMateriel: number;
materielDisponible: number;
materielEnMaintenance: number;
totalDocuments: number;
totalPhotos: number;
budgetTotal: number;
coutReel: number;
chiffreAffaires: number;
objectifCA: number;
tauxReussite: number;
satisfactionClient: number;
}
export interface ChantierActif {
id: string;
nom: string;
client: string;
avancement: number;
dateDebut: string;
dateFin: string;
statut: 'EN_COURS' | 'EN_RETARD' | 'PLANIFIE' | 'TERMINE';
budget: number;
coutReel: number;
equipe?: {
id: string;
nom: string;
nombreMembres: number;
};
}
export interface ActiviteRecente {
id: string;
type: 'CHANTIER' | 'MAINTENANCE' | 'DOCUMENT' | 'EQUIPE';
titre: string;
description: string;
date: string;
utilisateur: string;
statut: 'SUCCESS' | 'WARNING' | 'ERROR' | 'INFO';
}
export interface TacheUrgente {
id: string;
titre: string;
description: string;
priorite: 'HAUTE' | 'MOYENNE' | 'BASSE';
echeance: string;
assignee: string;
statut: 'A_FAIRE' | 'EN_COURS' | 'TERMINEE';
chantier?: {
id: string;
nom: string;
};
}
export interface StatistiquesMaintenance {
totalEquipements: number;
maintenancesPreventives: number;
maintenancesCorrectives: number;
equipementsEnPanne: number;
tauxDisponibilite: number;
}
export interface DashboardData {
metrics: DashboardMetrics;
chantiersActifs: ChantierActif[];
activitesRecentes: ActiviteRecente[];
tachesUrgentes: TacheUrgente[];
statistiquesMaintenance: StatistiquesMaintenance;
graphiques: {
chiffreAffaires: {
labels: string[];
objectifs: number[];
realisations: number[];
};
avancementPhases: {
labels: string[];
pourcentages: number[];
};
};
}
class DashboardService {
private readonly baseUrl = '/api';
async getDashboardData(periode: 'semaine' | 'mois' | 'trimestre' | 'annee' = 'mois'): Promise<DashboardData> {
try {
console.log('🏗️ DashboardService: Récupération des données depuis les endpoints réels...');
// Récupérer les données depuis les différents endpoints réels
const [chantiers, clients, materiels, employes] = await Promise.all([
apiClient.get('/api/v1/chantiers').catch(() => ({ data: [] })),
apiClient.get('/api/v1/clients').catch(() => ({ data: [] })),
apiClient.get('/api/v1/materiels').catch(() => ({ data: [] })),
apiClient.get('/api/v1/employes').catch(() => ({ data: [] }))
]);
console.log('🏗️ DashboardService: Données récupérées:', {
chantiers: chantiers.data.length,
clients: clients.data.length,
materiels: materiels.data.length,
employes: employes.data.length
});
// Calculer les métriques à partir des données réelles
const metrics = this.calculateMetrics(chantiers.data, clients.data, materiels.data, employes.data);
const chantiersActifs = this.filterChantiersActifs(chantiers.data);
return {
metrics,
chantiersActifs,
activitesRecentes: [], // TODO: Implémenter avec les vraies données
tachesUrgentes: [], // TODO: Implémenter avec les vraies données
statistiquesMaintenance: this.calculateMaintenanceStats(materiels.data)
};
} catch (error) {
console.error('Erreur lors de la récupération des données du dashboard:', error);
throw error;
}
}
async getMetrics(periode: 'semaine' | 'mois' | 'trimestre' | 'annee' = 'mois'): Promise<DashboardMetrics> {
try {
// Utiliser les endpoints réels pour calculer les métriques
const [chantiers, employes, materiels] = await Promise.all([
apiClient.get('/api/chantiers').catch(() => ({ data: [] })),
apiClient.get('/api/employes').catch(() => ({ data: [] })),
apiClient.get('/api/materiels').catch(() => ({ data: [] }))
]);
return this.calculateMetrics(chantiers.data, [], materiels.data, employes.data);
} catch (error) {
console.error('Erreur lors de la récupération des métriques:', error);
throw error;
}
}
async getChantiersActifs(limit: number = 10): Promise<ChantierActif[]> {
try {
const response = await apiClient.get('/api/v1/chantiers');
const chantiers = response.data || [];
return this.filterChantiersActifs(chantiers).slice(0, limit);
} catch (error) {
console.error('Erreur lors de la récupération des chantiers actifs:', error);
return []; // Retourner un tableau vide en cas d'erreur
}
}
async getActivitesRecentes(limit: number = 20): Promise<ActiviteRecente[]> {
try {
const response = await apiClient.get(`${this.baseUrl}/activites-recentes?limit=${limit}`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des activités récentes:', error);
throw error;
}
}
async getTachesUrgentes(limit: number = 10): Promise<TacheUrgente[]> {
try {
const response = await apiClient.get(`${this.baseUrl}/taches-urgentes?limit=${limit}`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des tâches urgentes:', error);
throw error;
}
}
async getStatistiquesMaintenance(): Promise<StatistiquesMaintenance> {
try {
const response = await apiClient.get(`${this.baseUrl}/statistiques-maintenance`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des statistiques de maintenance:', error);
throw error;
}
}
async exportDashboard(format: 'pdf' | 'excel' = 'pdf', periode: string = 'mois'): Promise<Blob> {
try {
const response = await apiClient.get(`${this.baseUrl}/export`, {
params: { format, periode },
responseType: 'blob'
});
return response.data;
} catch (error) {
console.error('Erreur lors de l\'export du dashboard:', error);
throw error;
}
}
// Méthodes utilitaires pour calculer les métriques à partir des données réelles
private calculateMetrics(chantiers: any[], clients: any[], materiels: any[], employes: any[]): DashboardMetrics {
const chantiersActifs = chantiers.filter(c => c.statut === 'EN_COURS' || c.statut === 'ACTIF');
const chantiersEnRetard = chantiers.filter(c => c.statut === 'EN_RETARD');
const chantiersTermines = chantiers.filter(c => c.statut === 'TERMINE');
const equipesDisponibles = employes.filter(e => e.statut === 'DISPONIBLE' || e.statut === 'ACTIF');
const materielDisponible = materiels.filter(m => m.statut === 'DISPONIBLE');
const materielEnMaintenance = materiels.filter(m => m.statut === 'MAINTENANCE');
return {
totalChantiers: chantiers.length,
chantiersActifs: chantiersActifs.length,
chantiersEnRetard: chantiersEnRetard.length,
chantiersTermines: chantiersTermines.length,
totalEquipes: employes.length,
equipesDisponibles: equipesDisponibles.length,
totalMateriel: materiels.length,
materielDisponible: materielDisponible.length,
materielEnMaintenance: materielEnMaintenance.length,
totalDocuments: 0, // TODO: Implémenter quand l'endpoint sera disponible
totalPhotos: 0, // TODO: Implémenter quand l'endpoint sera disponible
budgetTotal: chantiers.reduce((sum, c) => sum + (c.budget || 0), 0),
coutReel: chantiers.reduce((sum, c) => sum + (c.coutReel || 0), 0),
chiffreAffaires: chantiersTermines.reduce((sum, c) => sum + (c.budget || 0), 0),
objectifCA: 1000000, // TODO: Récupérer depuis la configuration
tauxReussite: chantiers.length > 0 ? (chantiersTermines.length / chantiers.length) * 100 : 0,
satisfactionClient: 85 // TODO: Calculer depuis les évaluations clients
};
}
private filterChantiersActifs(chantiers: any[]): ChantierActif[] {
return chantiers
.filter(c => c.statut === 'EN_COURS' || c.statut === 'ACTIF')
.map(c => ({
id: c.id,
nom: c.nom || c.titre,
client: c.client?.nom || c.clientNom || 'Client non défini',
avancement: c.avancement || 0,
dateDebut: c.dateDebut,
dateFin: c.dateFin,
statut: c.statut,
budget: c.budget || 0,
coutReel: c.coutReel || 0,
equipe: c.equipe ? {
id: c.equipe.id,
nom: c.equipe.nom,
nombreMembres: c.equipe.nombreMembres || 0
} : undefined
}));
}
private calculateMaintenanceStats(materiels: any[]): StatistiquesMaintenance {
const materielEnMaintenance = materiels.filter(m => m.statut === 'MAINTENANCE');
const materielDisponible = materiels.filter(m => m.statut === 'DISPONIBLE');
return {
materielEnMaintenance: materielEnMaintenance.length,
materielDisponible: materielDisponible.length,
maintenancesPrevues: 0, // TODO: Implémenter avec les vraies données
maintenancesEnRetard: 0, // TODO: Implémenter avec les vraies données
coutMaintenance: 0, // TODO: Calculer depuis les coûts de maintenance
tauxDisponibilite: materiels.length > 0 ? (materielDisponible.length / materiels.length) * 100 : 0
};
}
// Méthodes utilitaires pour formatter les données
static formatCurrency(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
static formatPercentage(value: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format(value / 100);
}
static formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
static getStatutColor(statut: string): string {
const colors: Record<string, string> = {
'EN_COURS': 'success',
'EN_RETARD': 'danger',
'PLANIFIE': 'info',
'TERMINE': 'secondary',
'SUCCESS': 'success',
'WARNING': 'warning',
'ERROR': 'danger',
'INFO': 'info',
'HAUTE': 'danger',
'MOYENNE': 'warning',
'BASSE': 'info',
'A_FAIRE': 'secondary',
'TERMINEE': 'success'
};
return colors[statut] || 'secondary';
}
static getStatutIcon(statut: string): string {
const icons: Record<string, string> = {
'EN_COURS': 'pi-play',
'EN_RETARD': 'pi-exclamation-triangle',
'PLANIFIE': 'pi-calendar',
'TERMINE': 'pi-check',
'SUCCESS': 'pi-check-circle',
'WARNING': 'pi-exclamation-triangle',
'ERROR': 'pi-times-circle',
'INFO': 'pi-info-circle',
'CHANTIER': 'pi-building',
'MAINTENANCE': 'pi-cog',
'DOCUMENT': 'pi-file',
'EQUIPE': 'pi-users'
};
return icons[statut] || 'pi-circle';
}
}
export const dashboardService = new DashboardService();

View File

@@ -0,0 +1,95 @@
import { apiService } from './api';
interface DevisRenewalRequest {
devisId: string;
nouveaueDateValidite: string;
modifications?: string;
}
interface DevisArchiveRequest {
devisId: string;
motif: string;
}
interface ClientFollowUpRequest {
devisId: string;
clientId: string;
type: 'email' | 'telephone' | 'courrier';
message: string;
}
interface ChantierCreationRequest {
devisId: string;
dateDebutSouhaitee: string;
notes?: string;
}
interface FactureCreationRequest {
devisId: string;
type: 'FACTURE' | 'ACOMPTE';
pourcentage?: number; // Pour les acomptes
}
class DevisActionsService {
/**
* Renouveler un devis expiré
*/
async renewDevis(request: DevisRenewalRequest): Promise<void> {
try {
await apiService.api.post(`/devis/${request.devisId}/renew`, request);
} catch (error) {
console.error('Erreur lors du renouvellement du devis:', error);
throw new Error('Impossible de renouveler le devis. Veuillez réessayer.');
}
}
/**
* Archiver un devis
*/
async archiveDevis(request: DevisArchiveRequest): Promise<void> {
try {
await apiService.api.post(`/devis/${request.devisId}/archive`, request);
} catch (error) {
console.error('Erreur lors de l\'archivage du devis:', error);
throw new Error('Impossible d\'archiver le devis. Veuillez réessayer.');
}
}
/**
* Effectuer un suivi client
*/
async followUpClient(request: ClientFollowUpRequest): Promise<void> {
try {
await apiService.api.post(`/devis/${request.devisId}/follow-up`, request);
} catch (error) {
console.error('Erreur lors du suivi client:', error);
throw new Error('Impossible d\'effectuer le suivi client. Veuillez réessayer.');
}
}
/**
* Créer un chantier à partir d'un devis accepté
*/
async createChantierFromDevis(request: ChantierCreationRequest): Promise<void> {
try {
await apiService.api.post(`/devis/${request.devisId}/create-chantier`, request);
} catch (error) {
console.error('Erreur lors de la création du chantier:', error);
throw new Error('Impossible de créer le chantier. Veuillez réessayer.');
}
}
/**
* Créer une facture à partir d'un devis accepté
*/
async createFactureFromDevis(request: FactureCreationRequest): Promise<void> {
try {
await apiService.api.post(`/devis/${request.devisId}/create-facture`, request);
} catch (error) {
console.error('Erreur lors de la création de la facture:', error);
throw new Error('Impossible de créer la facture. Veuillez réessayer.');
}
}
}
export default new DevisActionsService();

228
services/errorHandler.ts Normal file
View File

@@ -0,0 +1,228 @@
/**
* Service centralisé de gestion d'erreurs pour BTP Xpress
*/
import { Toast } from 'primereact/toast';
import { AxiosError } from 'axios';
export interface ErrorDetails {
code?: string;
message: string;
field?: string;
severity: 'error' | 'warn' | 'info';
}
export interface ApiErrorResponse {
error: string;
message?: string;
details?: ErrorDetails[];
timestamp?: string;
path?: string;
}
export class ErrorHandler {
private static toast: React.RefObject<Toast> | null = null;
static setToast(toastRef: React.RefObject<Toast>) {
this.toast = toastRef;
}
/**
* Gère les erreurs API avec messages appropriés
*/
static handleApiError(error: unknown, context?: string): void {
console.error(`Erreur API ${context ? `(${context})` : ''}:`, error);
if (error instanceof AxiosError) {
const response = error.response;
if (response) {
const status = response.status;
const data = response.data as ApiErrorResponse;
switch (status) {
case 400:
this.showError('Données invalides', data.message || data.error || 'Vérifiez les informations saisies');
break;
case 401:
this.showError('Non autorisé', 'Veuillez vous reconnecter');
// Ne pas rediriger si on est en train de traiter un code d'autorisation
if (typeof window !== 'undefined') {
const currentUrl = window.location.href;
const hasAuthCode = currentUrl.includes('code=') && currentUrl.includes('/dashboard');
if (!hasAuthCode) {
// Rediriger vers la page de connexion
window.location.href = '/api/auth/login';
} else {
console.log('🔄 ErrorHandler: Erreur 401 ignorée car authentification en cours...');
}
}
break;
case 403:
this.showError('Accès refusé', 'Vous n\'avez pas les permissions nécessaires');
break;
case 404:
this.showError('Ressource non trouvée', data.message || 'L\'élément demandé n\'existe pas');
break;
case 409:
this.showError('Conflit', data.message || 'Cette opération entre en conflit avec l\'état actuel');
break;
case 422:
this.handleValidationErrors(data);
break;
case 500:
this.showError('Erreur serveur', 'Une erreur interne s\'est produite. Veuillez réessayer plus tard.');
break;
case 503:
this.showError('Service indisponible', 'Le service est temporairement indisponible');
break;
default:
this.showError('Erreur réseau', `Erreur ${status}: ${data.message || data.error || 'Erreur inconnue'}`);
}
} else if (error.request) {
this.showError('Erreur de connexion', 'Impossible de contacter le serveur. Vérifiez votre connexion internet.');
} else {
this.showError('Erreur', error.message || 'Une erreur inattendue s\'est produite');
}
} else if (error instanceof Error) {
this.showError('Erreur', error.message);
} else {
this.showError('Erreur', 'Une erreur inconnue s\'est produite');
}
}
/**
* Gère les erreurs de validation (422)
*/
private static handleValidationErrors(data: ApiErrorResponse): void {
if (data.details && data.details.length > 0) {
const messages = data.details.map(detail =>
detail.field ? `${detail.field}: ${detail.message}` : detail.message
);
this.showError('Erreurs de validation', messages.join('\n'));
} else {
this.showError('Erreur de validation', data.message || data.error || 'Données invalides');
}
}
/**
* Affiche un message d'erreur
*/
static showError(summary: string, detail: string): void {
if (this.toast?.current) {
this.toast.current.show({
severity: 'error',
summary,
detail,
life: 5000
});
} else {
console.error(`${summary}: ${detail}`);
}
}
/**
* Affiche un message d'avertissement
*/
static showWarning(summary: string, detail: string): void {
if (this.toast?.current) {
this.toast.current.show({
severity: 'warn',
summary,
detail,
life: 4000
});
} else {
console.warn(`${summary}: ${detail}`);
}
}
/**
* Affiche un message de succès
*/
static showSuccess(summary: string, detail: string): void {
if (this.toast?.current) {
this.toast.current.show({
severity: 'success',
summary,
detail,
life: 3000
});
} else {
console.log(`${summary}: ${detail}`);
}
}
/**
* Affiche un message d'information
*/
static showInfo(summary: string, detail: string): void {
if (this.toast?.current) {
this.toast.current.show({
severity: 'info',
summary,
detail,
life: 3000
});
} else {
console.info(`${summary}: ${detail}`);
}
}
/**
* Valide les champs obligatoires
*/
static validateRequired(fields: Record<string, any>): string[] {
const errors: string[] = [];
Object.entries(fields).forEach(([fieldName, value]) => {
if (value === null || value === undefined ||
(typeof value === 'string' && value.trim() === '') ||
(Array.isArray(value) && value.length === 0)) {
errors.push(`Le champ "${fieldName}" est obligatoire`);
}
});
return errors;
}
/**
* Valide un email
*/
static validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Valide un numéro de téléphone français
*/
static validatePhoneNumber(phone: string): boolean {
const phoneRegex = /^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/;
return phoneRegex.test(phone);
}
/**
* Valide un SIRET
*/
static validateSiret(siret: string): boolean {
if (!siret || siret.length !== 14) return false;
const digits = siret.replace(/\s/g, '').split('').map(Number);
if (digits.some(isNaN)) return false;
// Algorithme de validation SIRET
let sum = 0;
for (let i = 0; i < 14; i++) {
let digit = digits[i];
if (i % 2 === 1) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
}
return sum % 10 === 0;
}
}

View File

@@ -0,0 +1,301 @@
/**
* Service pour la gestion de l'exécution granulaire des chantiers
* Gère les tâches, leur completion et le calcul d'avancement basé sur les tâches
*/
import { apiClient } from './api-client';
export interface TacheExecution {
id: string;
tacheTemplateId: string;
chantierID: string;
nom: string;
description?: string;
ordreExecution: number;
dureeEstimeeMinutes?: number;
critique: boolean;
bloquante: boolean;
priorite: 'BASSE' | 'NORMALE' | 'HAUTE';
niveauQualification?: string;
nombreOperateursRequis: number;
conditionsMeteo: string;
outilsRequis?: string[];
materiauxRequis?: string[];
// État d'exécution
terminee: boolean;
dateCompletion?: Date;
completeepar?: string;
commentaires?: string;
tempsRealise?: number;
difficulteRencontree?: 'AUCUNE' | 'FAIBLE' | 'MOYENNE' | 'ELEVEE';
}
export interface AvancementGranulaire {
chantierID: string;
pourcentage: number;
totalTaches: number;
tachesTerminees: number;
phasesAvancement: {
phaseId: string;
nom: string;
pourcentage: number;
sousPhases: {
sousPhaseId: string;
nom: string;
pourcentage: number;
tachesTerminees: number;
totalTaches: number;
}[];
}[];
derniereMAJ: Date;
}
export interface StatistiquesExecution {
totalTachesTerminees: number;
totalTaches: number;
pourcentageGlobal: number;
moyenneTempsByTache: number;
tachesEnRetard: number;
tachesCritiquesRestantes: number;
estimationFinChantier?: Date;
efficaciteEquipe: number; // % temps réalisé vs estimé
}
class ExecutionGranulaireService {
private readonly basePath = '/chantiers';
/**
* Récupère l'avancement granulaire d'un chantier
*/
async getAvancementGranulaire(chantierID: string): Promise<AvancementGranulaire> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierID}/avancement-granulaire`);
return {
...response.data,
derniereMAJ: new Date(response.data.derniereMAJ)
};
} catch (error) {
console.error('Erreur lors de la récupération de l\'avancement granulaire:', error);
throw error;
}
}
/**
* Récupère toutes les tâches d'exécution pour un chantier
*/
async getTachesExecution(chantierID: string): Promise<TacheExecution[]> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierID}/taches-execution`);
return response.data.map((tache: any) => ({
...tache,
dateCompletion: tache.dateCompletion ? new Date(tache.dateCompletion) : undefined
}));
} catch (error) {
console.error('Erreur lors de la récupération des tâches:', error);
throw error;
}
}
/**
* Récupère les tâches d'exécution pour une sous-phase spécifique
*/
async getTachesExecutionBySousPhase(chantierID: string, sousPhaseId: string): Promise<TacheExecution[]> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierID}/taches-execution/${sousPhaseId}`);
return response.data.map((tache: any) => ({
...tache,
dateCompletion: tache.dateCompletion ? new Date(tache.dateCompletion) : undefined
}));
} catch (error) {
console.error('Erreur lors de la récupération des tâches de la sous-phase:', error);
throw error;
}
}
/**
* Marque une tâche comme terminée
*/
async marquerTacheTerminee(
chantierID: string,
tacheTemplateId: string,
details: {
commentaires?: string;
tempsRealise?: number;
difficulteRencontree?: 'AUCUNE' | 'FAIBLE' | 'MOYENNE' | 'ELEVEE';
completeepar?: string;
}
): Promise<void> {
try {
await apiClient.post(`${this.basePath}/${chantierID}/taches-execution`, {
tacheTemplateId,
terminee: true,
dateCompletion: new Date().toISOString(),
...details
});
} catch (error) {
console.error('Erreur lors du marquage de la tâche comme terminée:', error);
throw error;
}
}
/**
* Marque une tâche comme non terminée
*/
async marquerTacheNonTerminee(chantierID: string, tacheTemplateId: string): Promise<void> {
try {
await apiClient.post(`${this.basePath}/${chantierID}/taches-execution`, {
tacheTemplateId,
terminee: false,
dateCompletion: null,
commentaires: '',
tempsRealise: null,
difficulteRencontree: 'AUCUNE'
});
} catch (error) {
console.error('Erreur lors du marquage de la tâche comme non terminée:', error);
throw error;
}
}
/**
* Met à jour l'état d'exécution d'une tâche
*/
async updateTacheExecution(
chantierID: string,
tacheTemplateId: string,
updates: Partial<TacheExecution>
): Promise<void> {
try {
await apiClient.put(`${this.basePath}/${chantierID}/taches-execution/${tacheTemplateId}`, updates);
} catch (error) {
console.error('Erreur lors de la mise à jour de la tâche:', error);
throw error;
}
}
/**
* Récupère les statistiques d'exécution d'un chantier
*/
async getStatistiquesExecution(chantierID: string): Promise<StatistiquesExecution> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierID}/statistiques-execution`);
return {
...response.data,
estimationFinChantier: response.data.estimationFinChantier
? new Date(response.data.estimationFinChantier)
: undefined
};
} catch (error) {
console.error('Erreur lors de la récupération des statistiques:', error);
throw error;
}
}
/**
* Récupère les tâches critiques en retard
*/
async getTachesCritiquesEnRetard(chantierID: string): Promise<TacheExecution[]> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierID}/taches-critiques-retard`);
return response.data.map((tache: any) => ({
...tache,
dateCompletion: tache.dateCompletion ? new Date(tache.dateCompletion) : undefined
}));
} catch (error) {
console.error('Erreur lors de la récupération des tâches critiques:', error);
throw error;
}
}
/**
* Génère un rapport d'avancement granulaire
*/
async genererRapportAvancement(chantierID: string, format: 'PDF' | 'EXCEL' = 'PDF'): Promise<Blob> {
try {
const response = await apiClient.get(
`${this.basePath}/${chantierID}/rapport-avancement-granulaire`,
{
params: { format },
responseType: 'blob'
}
);
return response.data;
} catch (error) {
console.error('Erreur lors de la génération du rapport:', error);
throw error;
}
}
/**
* Initialise l'exécution granulaire pour un chantier
* Crée les entrées de tâches basées sur les templates
*/
async initialiserExecutionGranulaire(chantierID: string): Promise<void> {
try {
await apiClient.post(`${this.basePath}/${chantierID}/initialiser-execution-granulaire`);
} catch (error) {
console.error('Erreur lors de l\'initialisation de l\'exécution granulaire:', error);
throw error;
}
}
/**
* Calcule l'avancement projeté basé sur la vitesse actuelle
*/
async calculerAvancementProjetee(chantierID: string, dateTarget: Date): Promise<number> {
try {
const response = await apiClient.post(
`${this.basePath}/${chantierID}/avancement-projete`,
{ dateTarget: dateTarget.toISOString() }
);
return response.data.pourcentageProjecte;
} catch (error) {
console.error('Erreur lors du calcul de l\'avancement projeté:', error);
throw error;
}
}
/**
* Récupère l'historique d'avancement du chantier
*/
async getHistoriqueAvancement(chantierID: string, periode: 'SEMAINE' | 'MOIS' = 'SEMAINE'): Promise<{
date: Date;
pourcentage: number;
tachesTermineesJour: number;
efficaciteJour: number;
}[]> {
try {
const response = await apiClient.get(`${this.basePath}/${chantierID}/historique-avancement`, {
params: { periode }
});
return response.data.map((entry: any) => ({
...entry,
date: new Date(entry.date)
}));
} catch (error) {
console.error('Erreur lors de la récupération de l\'historique:', error);
throw error;
}
}
/**
* Valide la completion d'une sous-phase
* Toutes les tâches critiques doivent être terminées
*/
async validerCompletionSousPhase(chantierID: string, sousPhaseId: string): Promise<{
valide: boolean;
tachesCritiquesRestantes: string[];
pourcentageCompletion: number;
}> {
try {
const response = await apiClient.post(`${this.basePath}/${chantierID}/valider-sous-phase/${sousPhaseId}`);
return response.data;
} catch (error) {
console.error('Erreur lors de la validation de la sous-phase:', error);
throw error;
}
}
}
export const executionGranulaireService = new ExecutionGranulaireService();

View File

@@ -0,0 +1,464 @@
import ApiService from './ApiService';
import { MaterielBTP, RechercheMaterielParams } from './materielBTPService';
import { ZoneClimatique } from './zoneClimatiqueService';
import { ResultatCalculBriques, ResultatCalculBetonArme } from './calculsTechniquesService';
/**
* Service d'export pour les données BTP ultra-détaillées
* Permet l'export en CSV, Excel et PDF des matériaux, calculs et zones climatiques
*/
export type FormatExport = 'CSV' | 'EXCEL' | 'PDF';
export interface OptionsExport {
format: FormatExport;
includeImages?: boolean;
includeCharts?: boolean;
filtres?: any;
colonnesPersonnalisees?: string[];
template?: 'STANDARD' | 'DETAILLE' | 'RESUME';
}
export interface ResultatExport {
filename: string;
blob: Blob;
size: number;
format: FormatExport;
nbLignes: number;
dateGeneration: string;
}
export class ExportBTPService {
/**
* Export des matériaux BTP avec filtres
*/
static async exporterMateriaux(options: OptionsExport & {
filtres?: RechercheMaterielParams;
}): Promise<ResultatExport> {
try {
const blob = await ApiService.post<Blob>(
'/calculs-techniques/materiaux/export',
{
format: options.format,
filtres: options.filtres,
template: options.template || 'STANDARD',
colonnesPersonnalisees: options.colonnesPersonnalisees
},
{ responseType: 'blob' }
);
const filename = this.genererNomFichier('materiaux', options.format);
return {
filename,
blob,
size: blob.size,
format: options.format,
nbLignes: 0, // Sera calculé côté serveur
dateGeneration: new Date().toISOString()
};
} catch (error) {
console.error('Erreur export matériaux:', error);
throw new Error('Impossible d\'exporter les matériaux');
}
}
/**
* Export des zones climatiques
*/
static async exporterZonesClimatiques(options: OptionsExport): Promise<ResultatExport> {
try {
const blob = await ApiService.post<Blob>(
'/calculs-techniques/zones-climatiques/export',
{
format: options.format,
template: options.template || 'STANDARD'
},
{ responseType: 'blob' }
);
const filename = this.genererNomFichier('zones-climatiques', options.format);
return {
filename,
blob,
size: blob.size,
format: options.format,
nbLignes: 0,
dateGeneration: new Date().toISOString()
};
} catch (error) {
console.error('Erreur export zones climatiques:', error);
throw new Error('Impossible d\'exporter les zones climatiques');
}
}
/**
* Export des résultats de calculs techniques
*/
static async exporterCalculs(
calculs: Array<{
type: 'BRIQUES' | 'BETON' | 'MORTIER';
resultat: ResultatCalculBriques | ResultatCalculBetonArme | any;
parametres: any;
}>,
options: OptionsExport
): Promise<ResultatExport> {
try {
const data = {
calculs: calculs,
format: options.format,
template: options.template || 'DETAILLE',
includeCharts: options.includeCharts || false
};
const blob = await ApiService.post<Blob>(
'/calculs-techniques/export-calculs',
data,
{ responseType: 'blob' }
);
const filename = this.genererNomFichier('calculs-techniques', options.format);
return {
filename,
blob,
size: blob.size,
format: options.format,
nbLignes: calculs.length,
dateGeneration: new Date().toISOString()
};
} catch (error) {
// Fallback : export côté client si serveur indisponible
console.warn('Export serveur indisponible, génération côté client');
return this.exporterCalculsCoteClient(calculs, options);
}
}
/**
* Export complet du projet BTP (matériaux + zones + calculs)
*/
static async exporterProjetComplet(
chantierId: string,
options: OptionsExport & {
includeMateriaux?: boolean;
includeZones?: boolean;
includeCalculs?: boolean;
includePhases?: boolean;
}
): Promise<ResultatExport> {
try {
const data = {
chantierId,
format: options.format,
template: options.template || 'DETAILLE',
sections: {
materiaux: options.includeMateriaux !== false,
zones: options.includeZones !== false,
calculs: options.includeCalculs !== false,
phases: options.includePhases !== false
},
includeCharts: options.includeCharts || false,
includeImages: options.includeImages || false
};
const blob = await ApiService.post<Blob>(
'/calculs-techniques/export-projet-complet',
data,
{ responseType: 'blob' }
);
const filename = this.genererNomFichier(`projet-${chantierId}`, options.format);
return {
filename,
blob,
size: blob.size,
format: options.format,
nbLignes: 0,
dateGeneration: new Date().toISOString()
};
} catch (error) {
console.error('Erreur export projet complet:', error);
throw new Error('Impossible d\'exporter le projet complet');
}
}
/**
* Génération de devis BTP détaillé avec matériaux et calculs
*/
static async genererDevisBTP(
devisData: {
chantierId: string;
phases: Array<{
nom: string;
materiaux: Array<{
code: string;
quantite: number;
prixUnitaire?: number;
}>;
calculs?: any;
}>;
client: {
nom: string;
adresse: string;
telephone?: string;
email?: string;
};
options: {
margeCommerciale: number;
tva: number;
delaiExecution: number;
validiteDevis: number; // jours
conditionsPaiement: string;
};
},
format: FormatExport = 'PDF'
): Promise<ResultatExport> {
try {
const blob = await ApiService.post<Blob>(
'/calculs-techniques/generer-devis',
{
...devisData,
format,
template: 'DEVIS_PROFESSIONNEL'
},
{ responseType: 'blob' }
);
const filename = this.genererNomFichier(`devis-${devisData.chantierId}`, format);
return {
filename,
blob,
size: blob.size,
format,
nbLignes: devisData.phases.length,
dateGeneration: new Date().toISOString()
};
} catch (error) {
console.error('Erreur génération devis:', error);
throw new Error('Impossible de générer le devis');
}
}
/**
* Export des matériaux avec QR codes pour traçabilité
*/
static async exporterMateriauxAvecQRCodes(
materiaux: MaterielBTP[],
options: OptionsExport
): Promise<ResultatExport> {
try {
const data = {
materiaux: materiaux.map(m => ({
id: m.id,
code: m.code,
nom: m.nom,
categorie: m.categorie,
specifications: {
resistance: m.resistanceCompression,
densite: m.densite,
norme: m.normePrincipale
}
})),
format: options.format,
includeQRCodes: true,
template: 'TRACABILITE'
};
const blob = await ApiService.post<Blob>(
'/calculs-techniques/export-tracabilite',
data,
{ responseType: 'blob' }
);
const filename = this.genererNomFichier('tracabilite-materiaux', options.format);
return {
filename,
blob,
size: blob.size,
format: options.format,
nbLignes: materiaux.length,
dateGeneration: new Date().toISOString()
};
} catch (error) {
console.error('Erreur export traçabilité:', error);
throw new Error('Impossible d\'exporter la traçabilité');
}
}
// =================== MÉTHODES UTILITAIRES ===================
/**
* Télécharge automatiquement le fichier exporté
*/
static telechargerFichier(resultat: ResultatExport): void {
const url = window.URL.createObjectURL(resultat.blob);
const a = document.createElement('a');
a.href = url;
a.download = resultat.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
/**
* Prévisualise le contenu avant export (pour PDF)
*/
static async previsualiserExport(
type: 'MATERIAUX' | 'ZONES' | 'CALCULS' | 'DEVIS',
donnees: any,
options: OptionsExport
): Promise<string> {
try {
const response = await ApiService.post<{ previewUrl: string }>(
'/calculs-techniques/previsualiser-export',
{
type,
donnees,
options
}
);
return response.previewUrl;
} catch (error) {
console.error('Erreur prévisualisation:', error);
throw new Error('Impossible de générer la prévisualisation');
}
}
/**
* Obtient les templates d'export disponibles
*/
static async getTemplatesDisponibles(): Promise<Array<{
id: string;
nom: string;
description: string;
formatsSupportes: FormatExport[];
sections: string[];
}>> {
try {
const response = await ApiService.get<Array<{
id: string;
nom: string;
description: string;
formatsSupportes: FormatExport[];
sections: string[];
}>>('/calculs-techniques/templates-export');
return response;
} catch (error) {
// Templates par défaut
return [
{
id: 'STANDARD',
nom: 'Standard',
description: 'Export standard avec informations essentielles',
formatsSupportes: ['CSV', 'EXCEL', 'PDF'],
sections: ['donnees_base']
},
{
id: 'DETAILLE',
nom: 'Détaillé',
description: 'Export complet avec toutes les spécifications techniques',
formatsSupportes: ['EXCEL', 'PDF'],
sections: ['donnees_base', 'specifications', 'calculs', 'normes']
},
{
id: 'RESUME',
nom: 'Résumé',
description: 'Export synthétique pour présentation',
formatsSupportes: ['PDF'],
sections: ['resume', 'graphiques']
}
];
}
}
/**
* Validation des options d'export
*/
static validerOptionsExport(options: OptionsExport): {
valide: boolean;
erreurs: string[];
} {
const erreurs: string[] = [];
if (!options.format) {
erreurs.push('Format d\'export requis');
}
if (options.format === 'PDF' && options.colonnesPersonnalisees?.length > 20) {
erreurs.push('Maximum 20 colonnes pour export PDF');
}
if (options.includeImages && options.format === 'CSV') {
erreurs.push('Images non supportées en format CSV');
}
return {
valide: erreurs.length === 0,
erreurs
};
}
// =================== MÉTHODES PRIVÉES ===================
/**
* Génère un nom de fichier unique
*/
private static genererNomFichier(prefix: string, format: FormatExport): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const extension = format.toLowerCase();
return `${prefix}_${timestamp}.${extension}`;
}
/**
* Export côté client en cas de problème serveur
*/
private static async exporterCalculsCoteClient(
calculs: any[],
options: OptionsExport
): Promise<ResultatExport> {
let contenu = '';
if (options.format === 'CSV') {
// Génération CSV simple
contenu = 'Type,Résultat,Date\n';
calculs.forEach(calcul => {
contenu += `${calcul.type},"${JSON.stringify(calcul.resultat)}",${new Date().toISOString()}\n`;
});
} else {
// Format texte pour autres formats
contenu = 'Export des calculs techniques BTP\n\n';
calculs.forEach(calcul => {
contenu += `Type: ${calcul.type}\n`;
contenu += `Résultat: ${JSON.stringify(calcul.resultat, null, 2)}\n\n`;
});
}
const blob = new Blob([contenu], { type: 'text/plain;charset=utf-8' });
const filename = this.genererNomFichier('calculs-fallback', 'CSV');
return {
filename,
blob,
size: blob.size,
format: 'CSV',
nbLignes: calculs.length,
dateGeneration: new Date().toISOString()
};
}
}

View File

@@ -0,0 +1,135 @@
import { apiService } from './api';
interface PaymentRecordRequest {
factureId: string;
montant: number;
datePaiement: string;
modePaiement: 'VIREMENT' | 'CHEQUE' | 'ESPECES' | 'CARTE';
reference?: string;
notes?: string;
}
interface RelanceRequest {
factureId: string;
type: 'GENTILLE' | 'FERME' | 'URGENTE';
message?: string;
delaiSupplementaire?: number; // en jours
}
interface PaymentPlanRequest {
factureId: string;
nbEcheances: number;
datePremiereEcheance: string;
montantEcheance: number;
conditions?: string;
}
interface MiseEnDemeureRequest {
factureId: string;
delaiPaiement: number; // en jours
mentionsLegales: string;
fraisDossier?: number;
}
interface ClientSuspensionRequest {
clientId: string;
motif: string;
duree?: number; // en jours, si temporaire
temporaire: boolean;
}
interface AvoirCreationRequest {
factureOriginaleId: string;
motif: string;
montant: number;
lignesRetournees: string[]; // IDs des lignes concernées
notes?: string;
}
class FactureActionsService {
/**
* Enregistrer un paiement
*/
async recordPayment(request: PaymentRecordRequest): Promise<void> {
try {
await apiService.api.post(`/factures/${request.factureId}/payment`, request);
} catch (error) {
console.error('Erreur lors de l\'enregistrement du paiement:', error);
throw new Error('Impossible d\'enregistrer le paiement. Veuillez réessayer.');
}
}
/**
* Envoyer une relance
*/
async sendRelance(request: RelanceRequest): Promise<void> {
try {
await apiService.api.post(`/factures/${request.factureId}/relance`, request);
} catch (error) {
console.error('Erreur lors de l\'envoi de la relance:', error);
throw new Error('Impossible d\'envoyer la relance. Veuillez réessayer.');
}
}
/**
* Planifier un échéancier de paiement
*/
async createPaymentPlan(request: PaymentPlanRequest): Promise<void> {
try {
await apiService.api.post(`/factures/${request.factureId}/payment-plan`, request);
} catch (error) {
console.error('Erreur lors de la création de l\'échéancier:', error);
throw new Error('Impossible de créer l\'échéancier. Veuillez réessayer.');
}
}
/**
* Envoyer une mise en demeure
*/
async sendMiseEnDemeure(request: MiseEnDemeureRequest): Promise<void> {
try {
await apiService.api.post(`/factures/${request.factureId}/mise-en-demeure`, request);
} catch (error) {
console.error('Erreur lors de l\'envoi de la mise en demeure:', error);
throw new Error('Impossible d\'envoyer la mise en demeure. Veuillez réessayer.');
}
}
/**
* Suspendre un client
*/
async suspendClient(request: ClientSuspensionRequest): Promise<void> {
try {
await apiService.api.post(`/clients/${request.clientId}/suspend`, request);
} catch (error) {
console.error('Erreur lors de la suspension du client:', error);
throw new Error('Impossible de suspendre le client. Veuillez réessayer.');
}
}
/**
* Créer un avoir
*/
async createAvoir(request: AvoirCreationRequest): Promise<void> {
try {
await apiService.api.post('/factures/avoir', request);
} catch (error) {
console.error('Erreur lors de la création de l\'avoir:', error);
throw new Error('Impossible de créer l\'avoir. Veuillez réessayer.');
}
}
/**
* Envoyer une relance urgente
*/
async sendUrgentRelance(factureId: string, message: string): Promise<void> {
await this.sendRelance({
factureId,
type: 'URGENTE',
message,
delaiSupplementaire: 7
});
}
}
export default new FactureActionsService();

View File

@@ -0,0 +1,239 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import {
FournisseurPhase,
ApiResponse
} from '../types/btp-extended';
class FournisseurPhaseService {
private readonly basePath = '/fournisseurs-phases';
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token Keycloak
this.api.interceptors.request.use(
async (config) => {
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
if (typeof window !== 'undefined') {
const { keycloak, KEYCLOAK_TIMEOUTS } = await import('../config/keycloak');
if (keycloak.authenticated) {
try {
// Rafraîchir le token si nécessaire
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
// Ajouter le token Bearer à l'en-tête Authorization
if (keycloak.token) {
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
}
} catch (error) {
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
keycloak.login();
throw error;
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/api/auth/login';
}
return Promise.reject(error);
}
);
}
/**
* Récupérer les fournisseurs d'une phase
*/
async getByPhase(phaseId: string): Promise<FournisseurPhase[]> {
if (!phaseId || phaseId === 'undefined' || phaseId === 'null' || phaseId === 'NaN') {
console.warn(`ID de phase invalide: ${phaseId}`);
return [];
}
try {
const response = await this.api.get(`${this.basePath}/phase/${phaseId}`);
return response.data;
} catch (error) {
console.warn(`Endpoint ${this.basePath}/phase/${phaseId} non disponible:`, error);
return [];
}
}
/**
* Récupérer un fournisseur de phase par ID
*/
async getById(id: number): Promise<FournisseurPhase> {
const response = await this.api.get(`${this.basePath}/${id}`);
return response.data;
}
/**
* Créer un nouveau fournisseur de phase
*/
async create(fournisseurPhase: Omit<FournisseurPhase, 'id'>): Promise<FournisseurPhase> {
console.log('Creating fournisseur phase with data:', fournisseurPhase);
const response = await this.api.post(this.basePath, fournisseurPhase);
return response.data;
}
/**
* Modifier un fournisseur de phase existant
*/
async update(id: number, fournisseurPhase: Partial<FournisseurPhase>): Promise<FournisseurPhase> {
const response = await this.api.put(`${this.basePath}/${id}`, fournisseurPhase);
return response.data;
}
/**
* Supprimer un fournisseur de phase
*/
async delete(id: number): Promise<void> {
await this.api.delete(`${this.basePath}/${id}`);
}
/**
* Obtenir les fournisseurs par type de contribution
*/
async getByTypeContribution(phaseId: string, type: string): Promise<FournisseurPhase[]> {
try {
const fournisseurs = await this.getByPhase(phaseId);
return fournisseurs.filter(f => f.typeContribution === type);
} catch (error) {
console.error('Erreur lors de la récupération par type:', error);
return [];
}
}
/**
* Calculer les économies réalisées avec les négociations
*/
async calculerEconomies(phaseId: string): Promise<number> {
try {
const fournisseurs = await this.getByPhase(phaseId);
return fournisseurs.reduce((total, fournisseur) => {
const prixCatalogue = fournisseur.prixCatalogue || 0;
const prixNegocie = fournisseur.prixNegocie || prixCatalogue;
return total + (prixCatalogue - prixNegocie);
}, 0);
} catch (error) {
console.error('Erreur lors du calcul des économies:', error);
return 0;
}
}
/**
* Obtenir le fournisseur principal d'une phase
*/
async getFournisseurPrincipal(phaseId: string): Promise<FournisseurPhase | null> {
try {
const fournisseurs = await this.getByPhase(phaseId);
return fournisseurs.find(f => f.priorite === 1) || null;
} catch (error) {
console.error('Erreur lors de la récupération du fournisseur principal:', error);
return null;
}
}
/**
* Lancer un appel d'offres pour une phase
*/
async lancerAppelOffres(phaseId: string, fournisseurIds: number[]): Promise<FournisseurPhase[]> {
try {
const response = await this.api.post(`${this.basePath}/appel-offres`, {
phaseId,
fournisseurIds
});
return response.data;
} catch (error) {
console.warn('Appel d\'offres non disponible:', error);
return [];
}
}
/**
* Comparer les offres de fournisseurs
*/
async comparerOffres(phaseId: string): Promise<FournisseurPhase[]> {
try {
const fournisseurs = await this.getByPhase(phaseId);
// Tri par prix négocié croissant
return fournisseurs.sort((a, b) => {
const prixA = a.prixNegocie || a.prixCatalogue || Infinity;
const prixB = b.prixNegocie || b.prixCatalogue || Infinity;
return prixA - prixB;
});
} catch (error) {
console.error('Erreur lors de la comparaison des offres:', error);
return [];
}
}
/**
* Valider une négociation
*/
async validerNegociation(id: number, validePar: string): Promise<FournisseurPhase> {
const response = await this.api.post(`${this.basePath}/${id}/valider`, {
validePar,
dateValidation: new Date()
});
return response.data;
}
/**
* Calculer le score d'un fournisseur (prix, délai, qualité)
*/
calculerScoreFournisseur(fournisseur: FournisseurPhase): number {
let score = 0;
// Score prix (40% du total)
const prixFinal = fournisseur.prixNegocie || fournisseur.prixCatalogue || 0;
const remise = fournisseur.remise || 0;
const scoreRemise = Math.min(remise / 100, 0.3); // Max 30% de remise
score += scoreRemise * 40;
// Score délai (30% du total)
const delai = fournisseur.delaiLivraison || 30;
const scoreDelai = Math.max(0, (30 - delai) / 30); // Meilleur si délai < 30 jours
score += scoreDelai * 30;
// Score priorité/historique (30% du total)
const priorite = fournisseur.priorite || 5;
const scorePriorite = Math.max(0, (6 - priorite) / 5); // Meilleur si priorité = 1
score += scorePriorite * 30;
return Math.round(score);
}
}
export default new FournisseurPhaseService();

View File

@@ -0,0 +1,353 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import {
Fournisseur,
FournisseurFormData,
FournisseurFilters,
CommandeFournisseur,
CatalogueItem,
TypeFournisseur,
ApiResponse,
PaginatedResponse
} from '../types/btp-extended';
class FournisseurService {
private readonly basePath = '/api/v1/fournisseurs';
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token Keycloak
this.api.interceptors.request.use(
async (config) => {
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
if (typeof window !== 'undefined') {
const { keycloak, KEYCLOAK_TIMEOUTS } = await import('../config/keycloak');
if (keycloak.authenticated) {
try {
// Rafraîchir le token si nécessaire
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
// Ajouter le token Bearer à l'en-tête Authorization
if (keycloak.token) {
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
}
} catch (error) {
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
keycloak.login();
throw error;
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/api/auth/login';
}
return Promise.reject(error);
}
);
}
/**
* Récupérer tous les fournisseurs
*/
async getAll(filters?: FournisseurFilters): Promise<Fournisseur[]> {
const params = new URLSearchParams();
if (filters?.actif !== undefined) {
params.append('actifs', filters.actif.toString());
}
if (filters?.type) {
params.append('type', filters.type);
}
const response = await this.api.get(`${this.basePath}?${params}`);
return response.data;
}
/**
* Récupérer un fournisseur par ID
*/
async getById(id: number): Promise<Fournisseur> {
const response = await this.api.get(`${this.basePath}/${id}`);
return response.data;
}
/**
* Créer un nouveau fournisseur
*/
async create(fournisseur: FournisseurFormData): Promise<Fournisseur> {
const response = await this.api.post(this.basePath, fournisseur);
return response.data;
}
/**
* Modifier un fournisseur existant
*/
async update(id: number, fournisseur: FournisseurFormData): Promise<Fournisseur> {
const response = await this.api.put(`${this.basePath}/${id}`, fournisseur);
return response.data;
}
/**
* Supprimer un fournisseur
*/
async delete(id: number): Promise<void> {
await this.api.delete(`${this.basePath}/${id}`);
}
/**
* Désactiver un fournisseur
*/
async deactivate(id: number): Promise<void> {
await this.api.post(`${this.basePath}/${id}/desactiver`);
}
/**
* Activer un fournisseur
*/
async activate(id: number): Promise<void> {
await this.api.post(`${this.basePath}/${id}/activer`);
}
/**
* Rechercher des fournisseurs
*/
async search(terme: string): Promise<Fournisseur[]> {
const response = await this.api.get(`${this.basePath}/recherche?q=${encodeURIComponent(terme)}`);
return response.data;
}
/**
* Récupérer les types de fournisseurs
*/
async getTypes(): Promise<TypeFournisseur[]> {
const response = await this.api.get(`${this.basePath}/types`);
return response.data;
}
/**
* Récupérer les commandes d'un fournisseur
*/
async getCommandes(id: number): Promise<CommandeFournisseur[]> {
const response = await this.api.get(`${this.basePath}/${id}/commandes`);
return response.data;
}
/**
* Récupérer le catalogue d'un fournisseur
*/
async getCatalogue(id: number): Promise<CatalogueItem[]> {
const response = await this.api.get(`${this.basePath}/${id}/catalogue`);
return response.data;
}
/**
* Récupérer les fournisseurs actifs uniquement
*/
async getActifs(): Promise<Fournisseur[]> {
return this.getAll({ actif: true });
}
/**
* Récupérer les fournisseurs par type
*/
async getByType(type: TypeFournisseur): Promise<Fournisseur[]> {
return this.getAll({ type });
}
/**
* Valider les données d'un fournisseur
*/
validateFournisseur(fournisseur: FournisseurFormData): string[] {
const errors: string[] = [];
if (!fournisseur.nom || fournisseur.nom.trim().length === 0) {
errors.push('Le nom du fournisseur est obligatoire');
}
if (fournisseur.nom && fournisseur.nom.length > 100) {
errors.push('Le nom ne peut pas dépasser 100 caractères');
}
if (fournisseur.email && !this.isValidEmail(fournisseur.email)) {
errors.push('L\'adresse email n\'est pas valide');
}
if (fournisseur.siret && fournisseur.siret.length > 20) {
errors.push('Le numéro SIRET ne peut pas dépasser 20 caractères');
}
if (fournisseur.telephone && fournisseur.telephone.length > 20) {
errors.push('Le numéro de téléphone ne peut pas dépasser 20 caractères');
}
return errors;
}
/**
* Valider une adresse email
*/
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Formater l'adresse complète d'un fournisseur
*/
formatAdresseComplete(fournisseur: Fournisseur): string {
const parties: string[] = [];
if (fournisseur.adresse) {
parties.push(fournisseur.adresse);
}
if (fournisseur.codePostal || fournisseur.ville) {
const ligneVille = [fournisseur.codePostal, fournisseur.ville]
.filter(Boolean)
.join(' ');
if (ligneVille) {
parties.push(ligneVille);
}
}
if (fournisseur.pays && fournisseur.pays !== 'France') {
parties.push(fournisseur.pays);
}
return parties.join(', ');
}
/**
* Obtenir le libellé d'un type de fournisseur
*/
getTypeLabel(type: TypeFournisseur): string {
const labels: Record<TypeFournisseur, string> = {
MATERIEL: 'Matériel',
SERVICE: 'Service',
SOUS_TRAITANT: 'Sous-traitant',
LOCATION: 'Location',
TRANSPORT: 'Transport',
CONSOMMABLE: 'Consommable'
};
return labels[type] || type;
}
/**
* Exporter la liste des fournisseurs au format CSV
*/
async exportToCsv(filters?: FournisseurFilters): Promise<Blob> {
const fournisseurs = await this.getAll(filters);
const headers = [
'ID', 'Nom', 'Type', 'SIRET', 'Email', 'Téléphone',
'Adresse', 'Code Postal', 'Ville', 'Pays', 'Actif'
];
const csvContent = [
headers.join(';'),
...fournisseurs.map(f => [
f.id || '',
f.nom || '',
this.getTypeLabel(f.type),
f.siret || '',
f.email || '',
f.telephone || '',
f.adresse || '',
f.codePostal || '',
f.ville || '',
f.pays || '',
f.actif ? 'Oui' : 'Non'
].join(';'))
].join('\n');
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
}
/**
* Importer des fournisseurs depuis un fichier CSV
*/
async importFromCsv(file: File): Promise<{ success: number; errors: string[] }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const csv = e.target?.result as string;
const lines = csv.split('\n');
const headers = lines[0].split(';');
let successCount = 0;
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim()) {
try {
const values = lines[i].split(';');
const fournisseur: FournisseurFormData = {
nom: values[1] || '',
type: (values[2] as TypeFournisseur) || 'MATERIEL',
siret: values[3] || undefined,
email: values[4] || undefined,
telephone: values[5] || undefined,
adresse: values[6] || undefined,
codePostal: values[7] || undefined,
ville: values[8] || undefined,
pays: values[9] || 'France',
actif: values[10] === 'Oui'
};
const validationErrors = this.validateFournisseur(fournisseur);
if (validationErrors.length === 0) {
await this.create(fournisseur);
successCount++;
} else {
errors.push(`Ligne ${i + 1}: ${validationErrors.join(', ')}`);
}
} catch (error) {
errors.push(`Ligne ${i + 1}: Erreur lors de la création`);
}
}
}
resolve({ success: successCount, errors });
} catch (error) {
reject(error);
}
};
reader.readAsText(file);
});
}
}
export default new FournisseurService();

View File

@@ -0,0 +1,323 @@
import ApiService from './ApiService';
/**
* Service pour la gestion des matériaux BTP ultra-détaillés
* Connecté aux APIs backend du système le plus ambitieux d'Afrique
*/
export interface MaterielBTP {
id: number;
code: string;
nom: string;
description: string;
categorie: CategorieMateriel;
sousCategorie: string;
// Dimensions techniques
dimensions: {
longueur: number;
largeur: number;
hauteur: number;
diametre?: number;
tolerance: number;
surface?: number;
volume?: number;
perimetre?: number;
};
// Propriétés physiques
densite: number;
resistanceCompression: number;
resistanceTraction: number;
resistanceFlexion: number;
moduleElasticite: number;
coefficientDilatation: number;
absorptionEau: number;
porosite: number;
conductiviteThermique: number;
resistanceGel: boolean;
resistanceIntemperies: NiveauResistance;
// Spécifications climatiques
temperatureMin: number;
temperatureMax: number;
humiditeMax: number;
resistanceUV: NiveauResistance;
resistancePluie: NiveauResistance;
resistanceVentFort: boolean;
// Normes et certifications
normePrincipale: string;
classification: string;
certificationRequise: boolean;
marquageCE: boolean;
conformiteECOWAS: boolean;
conformiteSADC?: boolean;
// Quantification
uniteBase: string;
facteurPerte: number;
facteurSurapprovisionnement: number;
modeFourniture: ModeFourniture;
quantiteParUnite: number;
poidsUnitaire: number;
// Calcul automatique
formuleCalcul?: string;
parametresCalcul?: string;
// Mise en œuvre
tempsUnitaire: number;
temperatureOptimaleMin: number;
temperatureOptimaleMax: number;
// Contrôle qualité
frequenceControle: string;
dureeVieEstimee: number;
maintenanceRequise: boolean;
// Métadonnées
actif: boolean;
creePar: string;
dateCreation: string;
modifiePar?: string;
dateModification?: string;
}
export enum CategorieMateriel {
GROS_OEUVRE = 'GROS_OEUVRE',
SECOND_OEUVRE = 'SECOND_OEUVRE',
FINITION = 'FINITION',
PLOMBERIE = 'PLOMBERIE',
ELECTRICITE = 'ELECTRICITE',
MENUISERIE = 'MENUISERIE',
COUVERTURE = 'COUVERTURE',
ISOLATION = 'ISOLATION',
OUTILLAGE = 'OUTILLAGE',
EQUIPEMENT = 'EQUIPEMENT'
}
export enum NiveauResistance {
EXCELLENT = 'EXCELLENT',
BON = 'BON',
MOYEN = 'MOYEN',
FAIBLE = 'FAIBLE'
}
export enum ModeFourniture {
VRAC = 'VRAC',
SACS = 'SACS',
PALETTE = 'PALETTE',
UNITE = 'UNITE',
KIT = 'KIT'
}
export interface RechercheMaterielParams {
categorie?: CategorieMateriel;
sousCategorie?: string;
texte?: string;
temperatureMin?: number;
temperatureMax?: number;
certifie?: boolean;
zoneClimatique?: string;
actif?: boolean;
}
export interface StatistiquesMateriel {
total: number;
parCategorie: Array<{ categorie: string; nombre: number; densiteMoyenne: number }>;
certifies: number;
marquageCE: number;
conformesECOWAS: number;
}
export class MaterielBTPService {
private static readonly BASE_PATH = '/calculs-techniques';
/**
* Récupère tous les matériaux ou par critères
*/
static async getMateriaux(params?: RechercheMaterielParams): Promise<{
materiaux: MaterielBTP[];
total: number;
filtres: any;
}> {
const queryParams = new URLSearchParams();
if (params?.categorie) queryParams.append('categorie', params.categorie);
if (params?.zoneClimatique) queryParams.append('zone', params.zoneClimatique);
const url = `${this.BASE_PATH}/materiaux${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const response = await ApiService.get<{
materiaux: MaterielBTP[];
total: number;
filtres: any;
}>(url);
return response;
}
/**
* Recherche avancée de matériaux
*/
static async rechercherMateriaux(params: RechercheMaterielParams): Promise<MaterielBTP[]> {
const response = await ApiService.post<MaterielBTP[]>(`${this.BASE_PATH}/materiaux/recherche`, params);
return response;
}
/**
* Récupère un matériau par son code
*/
static async getMaterielByCode(code: string): Promise<MaterielBTP> {
const response = await ApiService.get<MaterielBTP>(`${this.BASE_PATH}/materiaux/${code}`);
return response;
}
/**
* Récupère les matériaux par catégorie
*/
static async getMateriauxByCategorie(categorie: CategorieMateriel): Promise<MaterielBTP[]> {
const response = await this.getMateriaux({ categorie });
return response.materiaux;
}
/**
* Récupère les matériaux adaptés à une zone climatique
*/
static async getMateriauxAdaptesZone(zoneClimatique: string): Promise<MaterielBTP[]> {
const response = await this.getMateriaux({ zoneClimatique });
return response.materiaux;
}
/**
* Récupère les matériaux par sous-catégorie
*/
static async getMateriauxBySousCategorie(sousCategorie: string): Promise<MaterielBTP[]> {
const response = await this.getMateriaux({ sousCategorie });
return response.materiaux;
}
/**
* Recherche textuelle de matériaux
*/
static async rechercherTexte(texte: string): Promise<MaterielBTP[]> {
const response = await this.getMateriaux({ texte });
return response.materiaux;
}
/**
* Récupère les matériaux certifiés
*/
static async getMateriauxCertifies(): Promise<MaterielBTP[]> {
const response = await this.getMateriaux({ certifie: true });
return response.materiaux;
}
/**
* Récupère les matériaux pour une plage de température
*/
static async getMateriauxParTemperature(tempMin: number, tempMax: number): Promise<MaterielBTP[]> {
const response = await this.getMateriaux({ temperatureMin: tempMin, temperatureMax: tempMax });
return response.materiaux;
}
/**
* Récupère les statistiques des matériaux
*/
static async getStatistiquesMateriaux(): Promise<StatistiquesMateriel> {
const response = await ApiService.get<StatistiquesMateriel>(`${this.BASE_PATH}/materiaux/statistiques`);
return response;
}
/**
* Récupère les sous-catégories d'une catégorie
*/
static async getSousCategories(categorie: CategorieMateriel): Promise<string[]> {
const materiaux = await this.getMateriauxByCategorie(categorie);
const sousCategories = [...new Set(materiaux.map(m => m.sousCategorie))];
return sousCategories.sort();
}
/**
* Récupère les types d'unités utilisées
*/
static async getUnitesBase(): Promise<string[]> {
const response = await this.getMateriaux();
const unites = [...new Set(response.materiaux.map(m => m.uniteBase))];
return unites.sort();
}
/**
* Récupère les normes utilisées
*/
static async getNormesPrincipales(): Promise<string[]> {
const response = await this.getMateriaux();
const normes = [...new Set(response.materiaux.map(m => m.normePrincipale))];
return normes.sort();
}
/**
* Validation d'un matériau pour une zone climatique
*/
static async validerMaterielPourZone(codeMateriel: string, zoneClimatique: string): Promise<{
adapte: boolean;
warnings: string[];
recommendations: string[];
}> {
const response = await ApiService.post<{
adapte: boolean;
warnings: string[];
recommendations: string[];
}>(`${this.BASE_PATH}/materiaux/${codeMateriel}/validation-zone`, { zoneClimatique });
return response;
}
/**
* Récupère les alternatives à un matériau
*/
static async getAlternativesMateriel(codeMateriel: string, zoneClimatique?: string): Promise<MaterielBTP[]> {
const queryParams = new URLSearchParams();
if (zoneClimatique) queryParams.append('zone', zoneClimatique);
const url = `${this.BASE_PATH}/materiaux/${codeMateriel}/alternatives${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const response = await ApiService.get<{ alternatives: MaterielBTP[] }>(url);
return response.alternatives;
}
/**
* Calcule les quantités nécessaires selon formule matériau
*/
static async calculerQuantite(codeMateriel: string, parametres: Record<string, number>): Promise<{
quantiteBase: number;
quantiteAvecPerte: number;
quantiteAvecSurapprovisionnement: number;
uniteQuantite: string;
details: any;
}> {
const response = await ApiService.post<{
quantiteBase: number;
quantiteAvecPerte: number;
quantiteAvecSurapprovisionnement: number;
uniteQuantite: string;
details: any;
}>(`${this.BASE_PATH}/materiaux/${codeMateriel}/calcul-quantite`, parametres);
return response;
}
/**
* Export des matériaux en différents formats
*/
static async exporterMateriaux(format: 'CSV' | 'EXCEL' | 'PDF', filtres?: RechercheMaterielParams): Promise<Blob> {
const response = await ApiService.post<Blob>(
`${this.BASE_PATH}/materiaux/export`,
{ format, filtres },
{ responseType: 'blob' }
);
return response;
}
}

View File

@@ -0,0 +1,203 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import {
MaterielPhase,
FournisseurPhase,
AnalysePrixPhase,
ApiResponse
} from '../types/btp-extended';
class MaterielPhaseService {
private readonly basePath = '/materiels-phases';
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token JWT
this.api.interceptors.request.use(
(config) => {
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/api/auth/login';
}
return Promise.reject(error);
}
);
}
/**
* Récupérer les matériels d'une phase
*/
async getByPhase(phaseId: string): Promise<MaterielPhase[]> {
if (!phaseId || phaseId === 'undefined' || phaseId === 'null' || phaseId === 'NaN') {
console.warn(`ID de phase invalide: ${phaseId}`);
return [];
}
try {
const response = await this.api.get(`${this.basePath}/phase/${phaseId}`);
return response.data;
} catch (error) {
console.warn(`Endpoint ${this.basePath}/phase/${phaseId} non disponible:`, error);
return [];
}
}
/**
* Récupérer un matériel de phase par ID
*/
async getById(id: number): Promise<MaterielPhase> {
const response = await this.api.get(`${this.basePath}/${id}`);
return response.data;
}
/**
* Créer un nouveau matériel de phase
*/
async create(materielPhase: Omit<MaterielPhase, 'id'>): Promise<MaterielPhase> {
console.log('Creating materiel phase with data:', materielPhase);
const response = await this.api.post(this.basePath, materielPhase);
return response.data;
}
/**
* Modifier un matériel de phase existant
*/
async update(id: number, materielPhase: Partial<MaterielPhase>): Promise<MaterielPhase> {
const response = await this.api.put(`${this.basePath}/${id}`, materielPhase);
return response.data;
}
/**
* Supprimer un matériel de phase
*/
async delete(id: number): Promise<void> {
await this.api.delete(`${this.basePath}/${id}`);
}
/**
* Calculer le coût total des matériels d'une phase
*/
async calculerCoutTotal(phaseId: string): Promise<number> {
try {
const materiels = await this.getByPhase(phaseId);
return materiels.reduce((total, materiel) => {
const prix = materiel.prixUnitaireNegocie || materiel.prixUnitaireCatalogue || 0;
const quantite = materiel.quantiteUtilisee || materiel.quantitePrevue || 0;
return total + (prix * quantite);
}, 0);
} catch (error) {
console.error('Erreur lors du calcul du coût total:', error);
return 0;
}
}
/**
* Obtenir les matériels en rupture de stock pour une phase
*/
async getMaterielsEnRupture(phaseId: string): Promise<MaterielPhase[]> {
try {
const materiels = await this.getByPhase(phaseId);
return materiels.filter(materiel =>
materiel.enStock === false ||
(materiel.quantiteStock || 0) < (materiel.quantitePrevue || 0)
);
} catch (error) {
console.error('Erreur lors de la vérification du stock:', error);
return [];
}
}
/**
* Obtenir les alternatives de fournisseurs pour un matériel
*/
async getFournisseursAlternatifs(materielPhaseId: number): Promise<FournisseurPhase[]> {
try {
const response = await this.api.get(`${this.basePath}/${materielPhaseId}/fournisseurs-alternatifs`);
return response.data;
} catch (error) {
console.warn('Fournisseurs alternatifs non disponibles:', error);
return [];
}
}
/**
* Négocier un prix avec un fournisseur
*/
async negocierPrix(materielPhaseId: number, fournisseurId: number, prixNegocie: number): Promise<MaterielPhase> {
const response = await this.api.post(`${this.basePath}/${materielPhaseId}/negocier-prix`, {
fournisseurId,
prixNegocie
});
return response.data;
}
/**
* Valider la sélection d'un fournisseur
*/
async validerFournisseur(materielPhaseId: number, fournisseurPhaseId: number): Promise<MaterielPhase> {
const response = await this.api.post(`${this.basePath}/${materielPhaseId}/valider-fournisseur`, {
fournisseurPhaseId
});
return response.data;
}
/**
* Calculer l'analyse de prix pour une phase
*/
async calculerAnalysePrix(phaseId: string): Promise<AnalysePrixPhase> {
try {
const response = await this.api.post(`/analyses-prix/calculer/${phaseId}`);
return response.data;
} catch (error) {
// Calcul côté client si l'endpoint n'existe pas
const materiels = await this.getByPhase(phaseId);
const coutMateriauxTotal = materiels.reduce((total, materiel) => {
const prix = materiel.prixUnitaireNegocie || materiel.prixUnitaireCatalogue || 0;
const quantite = materiel.quantiteUtilisee || materiel.quantitePrevue || 0;
return total + (prix * quantite);
}, 0);
return {
phase: { id: parseInt(phaseId) } as any,
coutMateriauxTotal,
coutMainOeuvreTotal: 0,
coutSousTraitanceTotal: 0,
coutAutresTotal: 0,
coutTotalDirect: coutMateriauxTotal,
coutTotalAvecFrais: coutMateriauxTotal,
prixVenteCalcule: coutMateriauxTotal * 1.2, // Marge de 20% par défaut
dateAnalyse: new Date()
};
}
}
}
export default new MaterielPhaseService();

View File

@@ -0,0 +1,277 @@
/**
* Service de monitoring avancé côté client
*/
export interface PerformanceMetric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
export interface ErrorMetric {
error: string;
stack?: string;
url: string;
userAgent: string;
timestamp: number;
userId?: string;
}
export interface UserAction {
action: string;
component: string;
timestamp: number;
duration?: number;
metadata?: Record<string, any>;
}
export class MonitoringService {
private static metrics: PerformanceMetric[] = [];
private static errors: ErrorMetric[] = [];
private static userActions: UserAction[] = [];
private static isEnabled = true;
/**
* Initialise le service de monitoring
*/
static init(): void {
if (typeof window === 'undefined') return;
// Monitoring des erreurs JavaScript
window.addEventListener('error', (event) => {
this.recordError({
error: event.message,
stack: event.error?.stack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
});
});
// Monitoring des promesses rejetées
window.addEventListener('unhandledrejection', (event) => {
this.recordError({
error: `Unhandled Promise Rejection: ${event.reason}`,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
});
});
// Monitoring des performances de navigation
if ('performance' in window) {
window.addEventListener('load', () => {
setTimeout(() => {
this.recordNavigationMetrics();
}, 0);
});
}
// Envoi périodique des métriques
setInterval(() => {
this.sendMetrics();
}, 60000); // Toutes les minutes
}
/**
* Enregistre une métrique de performance
*/
static recordMetric(name: string, value: number, tags?: Record<string, string>): void {
if (!this.isEnabled) return;
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags
});
// Limiter le nombre de métriques en mémoire
if (this.metrics.length > 1000) {
this.metrics = this.metrics.slice(-500);
}
}
/**
* Enregistre une erreur
*/
static recordError(error: ErrorMetric): void {
if (!this.isEnabled) return;
this.errors.push(error);
// Limiter le nombre d'erreurs en mémoire
if (this.errors.length > 100) {
this.errors = this.errors.slice(-50);
}
}
/**
* Enregistre une action utilisateur
*/
static recordUserAction(action: string, component: string, metadata?: Record<string, any>): void {
if (!this.isEnabled) return;
this.userActions.push({
action,
component,
timestamp: Date.now(),
metadata
});
// Limiter le nombre d'actions en mémoire
if (this.userActions.length > 500) {
this.userActions = this.userActions.slice(-250);
}
}
/**
* Mesure le temps d'exécution d'une fonction
*/
static async measureTime<T>(
name: string,
fn: () => Promise<T>,
tags?: Record<string, string>
): Promise<T> {
const start = performance.now();
try {
const result = await fn();
const duration = performance.now() - start;
this.recordMetric(`${name}.duration`, duration, tags);
this.recordMetric(`${name}.success`, 1, tags);
return result;
} catch (error) {
const duration = performance.now() - start;
this.recordMetric(`${name}.duration`, duration, tags);
this.recordMetric(`${name}.error`, 1, tags);
throw error;
}
}
/**
* Enregistre les métriques de navigation
*/
private static recordNavigationMetrics(): void {
if (!('performance' in window) || !window.performance.timing) return;
const timing = window.performance.timing;
const navigation = window.performance.navigation;
// Temps de chargement de la page
const pageLoadTime = timing.loadEventEnd - timing.navigationStart;
this.recordMetric('page.load_time', pageLoadTime, {
url: window.location.pathname
});
// Temps de réponse du serveur
const serverResponseTime = timing.responseEnd - timing.requestStart;
this.recordMetric('page.server_response_time', serverResponseTime);
// Temps de rendu DOM
const domRenderTime = timing.domContentLoadedEventEnd - timing.domLoading;
this.recordMetric('page.dom_render_time', domRenderTime);
// Type de navigation
const navigationType = navigation.type === 0 ? 'navigate' :
navigation.type === 1 ? 'reload' :
navigation.type === 2 ? 'back_forward' : 'unknown';
this.recordMetric('page.navigation_type', 1, {
type: navigationType
});
}
/**
* Envoie les métriques au serveur
*/
private static async sendMetrics(): void {
if (this.metrics.length === 0 && this.errors.length === 0 && this.userActions.length === 0) {
return;
}
try {
const payload = {
metrics: [...this.metrics],
errors: [...this.errors],
userActions: [...this.userActions],
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
};
// Envoyer au serveur (endpoint à implémenter)
await fetch('/api/monitoring', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
// Vider les buffers après envoi réussi
this.metrics = [];
this.errors = [];
this.userActions = [];
} catch (error) {
console.warn('Échec de l\'envoi des métriques:', error);
}
}
/**
* Obtient les statistiques actuelles
*/
static getStats(): {
metricsCount: number;
errorsCount: number;
userActionsCount: number;
isEnabled: boolean;
} {
return {
metricsCount: this.metrics.length,
errorsCount: this.errors.length,
userActionsCount: this.userActions.length,
isEnabled: this.isEnabled
};
}
/**
* Active ou désactive le monitoring
*/
static setEnabled(enabled: boolean): void {
this.isEnabled = enabled;
}
/**
* Vide tous les buffers
*/
static clear(): void {
this.metrics = [];
this.errors = [];
this.userActions = [];
}
}
// Initialisation automatique
if (typeof window !== 'undefined') {
MonitoringService.init();
}
// Hook React pour le monitoring des composants
export function useMonitoring(componentName: string) {
const recordAction = (action: string, metadata?: Record<string, any>) => {
MonitoringService.recordUserAction(action, componentName, metadata);
};
const measureAsync = async <T>(
name: string,
fn: () => Promise<T>
): Promise<T> => {
return MonitoringService.measureTime(`${componentName}.${name}`, fn);
};
return {
recordAction,
measureAsync
};
}

View File

@@ -0,0 +1,236 @@
import { apiService } from './api';
export interface Notification {
id: string;
type: 'info' | 'warning' | 'success' | 'error';
titre: string;
message: string;
date: Date;
lu: boolean;
userId?: string;
metadata?: {
chantierId?: string;
chantierNom?: string;
clientId?: string;
clientNom?: string;
action?: string;
};
}
export interface NotificationStats {
total: number;
nonLues: number;
parType: Record<string, number>;
tendance: {
periode: string;
nombre: number;
}[];
}
class NotificationService {
/**
* Récupérer toutes les notifications
*/
async getNotifications(): Promise<Notification[]> {
try {
const response = await apiService.api.get('/notifications');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des notifications:', error);
return this.getMockNotifications();
}
}
/**
* Récupérer les notifications non lues
*/
async getUnreadNotifications(): Promise<Notification[]> {
try {
const response = await apiService.api.get('/notifications/unread');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des notifications non lues:', error);
return this.getMockNotifications().filter(n => !n.lu);
}
}
/**
* Marquer une notification comme lue
*/
async markAsRead(notificationId: string): Promise<void> {
try {
await apiService.api.put(`/notifications/${notificationId}/read`);
} catch (error) {
console.error('Erreur lors du marquage comme lu:', error);
}
}
/**
* Marquer toutes les notifications comme lues
*/
async markAllAsRead(): Promise<void> {
try {
await apiService.api.put('/notifications/read-all');
} catch (error) {
console.error('Erreur lors du marquage global:', error);
}
}
/**
* Créer une nouvelle notification
*/
async createNotification(notification: Omit<Notification, 'id' | 'date'>): Promise<Notification> {
try {
const response = await apiService.api.post('/notifications', {
...notification,
date: new Date().toISOString()
});
return response.data;
} catch (error) {
console.error('Erreur lors de la création de notification:', error);
throw error;
}
}
/**
* Supprimer une notification
*/
async deleteNotification(notificationId: string): Promise<void> {
try {
await apiService.api.delete(`/notifications/${notificationId}`);
} catch (error) {
console.error('Erreur lors de la suppression:', error);
throw error;
}
}
/**
* Récupérer les statistiques des notifications
*/
async getNotificationStats(): Promise<NotificationStats> {
try {
const response = await apiService.api.get('/notifications/stats');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des statistiques:', error);
return this.getMockNotificationStats();
}
}
/**
* Diffuser une notification à plusieurs utilisateurs
*/
async broadcastNotification(notification: {
type: 'info' | 'warning' | 'success' | 'error';
titre: string;
message: string;
userIds?: string[];
roles?: string[];
}): Promise<void> {
try {
await apiService.api.post('/notifications/broadcast', notification);
} catch (error) {
console.error('Erreur lors de la diffusion:', error);
throw error;
}
}
/**
* Notifications mockées
*/
private getMockNotifications(): Notification[] {
return [
{
id: '1',
type: 'info',
titre: 'Nouveau devis disponible',
message: 'Le devis pour votre extension cuisine est maintenant disponible',
date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
lu: false,
metadata: {
chantierId: 'chantier-1',
chantierNom: 'Extension cuisine',
action: 'devis_cree'
}
},
{
id: '2',
type: 'warning',
titre: 'Rendez-vous prévu',
message: 'Rendez-vous avec votre gestionnaire demain à 14h00',
date: new Date(Date.now() - 24 * 60 * 60 * 1000),
lu: false,
metadata: {
action: 'rendez_vous_planifie'
}
},
{
id: '3',
type: 'success',
titre: 'Phase terminée',
message: 'La phase "Gros œuvre" de votre chantier a été terminée',
date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
lu: true,
metadata: {
chantierId: 'chantier-2',
chantierNom: 'Rénovation appartement',
action: 'phase_terminee'
}
},
{
id: '4',
type: 'error',
titre: 'Retard détecté',
message: 'Le chantier "Villa moderne" accuse un retard de 3 jours',
date: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000),
lu: true,
metadata: {
chantierId: 'chantier-3',
chantierNom: 'Villa moderne',
action: 'retard_detecte'
}
},
{
id: '5',
type: 'info',
titre: 'Nouveau client attribué',
message: 'Vous avez été désigné gestionnaire pour M. Durand',
date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
lu: true,
metadata: {
clientId: 'client-4',
clientNom: 'M. Durand',
action: 'client_attribue'
}
}
];
}
/**
* Statistiques mockées
*/
private getMockNotificationStats(): NotificationStats {
const notifications = this.getMockNotifications();
return {
total: notifications.length,
nonLues: notifications.filter(n => !n.lu).length,
parType: {
info: notifications.filter(n => n.type === 'info').length,
warning: notifications.filter(n => n.type === 'warning').length,
success: notifications.filter(n => n.type === 'success').length,
error: notifications.filter(n => n.type === 'error').length
},
tendance: [
{ periode: 'Lundi', nombre: 3 },
{ periode: 'Mardi', nombre: 7 },
{ periode: 'Mercredi', nombre: 2 },
{ periode: 'Jeudi', nombre: 5 },
{ periode: 'Vendredi', nombre: 4 },
{ periode: 'Samedi', nombre: 1 },
{ periode: 'Dimanche', nombre: 0 }
]
};
}
}
export default new NotificationService();

View File

@@ -0,0 +1,526 @@
import { UserRole, ROLE_PERMISSIONS } from '../types/auth';
import { useAuth } from '../hooks/useAuth';
interface Permission {
id: string;
name: string;
description: string;
resource: string;
action: string;
category: 'ADMIN' | 'GESTION' | 'COMMERCIAL' | 'TECHNIQUE' | 'CONSULTATION';
}
interface Role {
id: string;
name: string;
displayName: string;
description: string;
permissions: string[];
level: number; // Niveau hiérarchique (plus élevé = plus de permissions)
}
class PermissionService {
/**
* Obtenir toutes les permissions disponibles
*/
getAllPermissions(): Permission[] {
return [
// Permissions administratives
{
id: 'users:read',
name: 'Consulter utilisateurs',
description: 'Voir la liste des utilisateurs',
resource: 'users',
action: 'read',
category: 'ADMIN'
},
{
id: 'users:write',
name: 'Gérer utilisateurs',
description: 'Créer, modifier les utilisateurs',
resource: 'users',
action: 'write',
category: 'ADMIN'
},
{
id: 'users:delete',
name: 'Supprimer utilisateurs',
description: 'Supprimer des utilisateurs',
resource: 'users',
action: 'delete',
category: 'ADMIN'
},
{
id: 'attribution:read',
name: 'Consulter attributions',
description: 'Voir les attributions client-gestionnaire',
resource: 'attribution',
action: 'read',
category: 'ADMIN'
},
{
id: 'attribution:write',
name: 'Gérer attributions',
description: 'Modifier les attributions client-gestionnaire',
resource: 'attribution',
action: 'write',
category: 'ADMIN'
},
// Permissions de gestion
{
id: 'clients:read',
name: 'Consulter clients',
description: 'Voir la liste des clients',
resource: 'clients',
action: 'read',
category: 'GESTION'
},
{
id: 'clients:write',
name: 'Gérer clients',
description: 'Créer, modifier les clients',
resource: 'clients',
action: 'write',
category: 'GESTION'
},
{
id: 'clients:delete',
name: 'Supprimer clients',
description: 'Supprimer des clients',
resource: 'clients',
action: 'delete',
category: 'GESTION'
},
{
id: 'assigned_clients:read',
name: 'Consulter clients attribués',
description: 'Voir ses clients attribués',
resource: 'assigned_clients',
action: 'read',
category: 'GESTION'
},
{
id: 'assigned_clients:write',
name: 'Gérer clients attribués',
description: 'Modifier ses clients attribués',
resource: 'assigned_clients',
action: 'write',
category: 'GESTION'
},
// Permissions chantiers
{
id: 'chantiers:read',
name: 'Consulter chantiers',
description: 'Voir tous les chantiers',
resource: 'chantiers',
action: 'read',
category: 'TECHNIQUE'
},
{
id: 'chantiers:write',
name: 'Gérer chantiers',
description: 'Créer, modifier les chantiers',
resource: 'chantiers',
action: 'write',
category: 'TECHNIQUE'
},
{
id: 'chantiers:delete',
name: 'Supprimer chantiers',
description: 'Supprimer des chantiers',
resource: 'chantiers',
action: 'delete',
category: 'TECHNIQUE'
},
{
id: 'assigned_chantiers:read',
name: 'Consulter chantiers attribués',
description: 'Voir ses chantiers attribués',
resource: 'assigned_chantiers',
action: 'read',
category: 'TECHNIQUE'
},
{
id: 'assigned_chantiers:write',
name: 'Gérer chantiers attribués',
description: 'Modifier ses chantiers attribués',
resource: 'assigned_chantiers',
action: 'write',
category: 'TECHNIQUE'
},
// Permissions phases
{
id: 'phases:read',
name: 'Consulter phases',
description: 'Voir les phases des chantiers',
resource: 'phases',
action: 'read',
category: 'TECHNIQUE'
},
{
id: 'phases:write',
name: 'Gérer phases',
description: 'Modifier les phases des chantiers',
resource: 'phases',
action: 'write',
category: 'TECHNIQUE'
},
// Permissions commerciales
{
id: 'devis:read',
name: 'Consulter devis',
description: 'Voir tous les devis',
resource: 'devis',
action: 'read',
category: 'COMMERCIAL'
},
{
id: 'devis:write',
name: 'Gérer devis',
description: 'Créer, modifier les devis',
resource: 'devis',
action: 'write',
category: 'COMMERCIAL'
},
{
id: 'devis:delete',
name: 'Supprimer devis',
description: 'Supprimer des devis',
resource: 'devis',
action: 'delete',
category: 'COMMERCIAL'
},
{
id: 'assigned_devis:read',
name: 'Consulter devis attribués',
description: 'Voir ses devis attribués',
resource: 'assigned_devis',
action: 'read',
category: 'COMMERCIAL'
},
{
id: 'assigned_devis:write',
name: 'Gérer devis attribués',
description: 'Modifier ses devis attribués',
resource: 'assigned_devis',
action: 'write',
category: 'COMMERCIAL'
},
// Permissions factures
{
id: 'factures:read',
name: 'Consulter factures',
description: 'Voir toutes les factures',
resource: 'factures',
action: 'read',
category: 'COMMERCIAL'
},
{
id: 'factures:write',
name: 'Gérer factures',
description: 'Créer, modifier les factures',
resource: 'factures',
action: 'write',
category: 'COMMERCIAL'
},
{
id: 'factures:delete',
name: 'Supprimer factures',
description: 'Supprimer des factures',
resource: 'factures',
action: 'delete',
category: 'COMMERCIAL'
},
{
id: 'assigned_factures:read',
name: 'Consulter factures attribuées',
description: 'Voir ses factures attribuées',
resource: 'assigned_factures',
action: 'read',
category: 'COMMERCIAL'
},
{
id: 'assigned_factures:write',
name: 'Gérer factures attribuées',
description: 'Modifier ses factures attribuées',
resource: 'assigned_factures',
action: 'write',
category: 'COMMERCIAL'
},
// Permissions budget
{
id: 'budget:read',
name: 'Consulter budgets',
description: 'Voir les informations budgétaires',
resource: 'budget',
action: 'read',
category: 'GESTION'
},
{
id: 'budget:write',
name: 'Gérer budgets',
description: 'Modifier les budgets',
resource: 'budget',
action: 'write',
category: 'GESTION'
},
// Permissions planning
{
id: 'planning:read',
name: 'Consulter planning',
description: 'Voir le planning des chantiers',
resource: 'planning',
action: 'read',
category: 'TECHNIQUE'
},
{
id: 'planning:write',
name: 'Gérer planning',
description: 'Modifier le planning',
resource: 'planning',
action: 'write',
category: 'TECHNIQUE'
},
// Permissions dashboard
{
id: 'dashboard:read',
name: 'Accès dashboard',
description: 'Accéder au tableau de bord',
resource: 'dashboard',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'client_dashboard:read',
name: 'Accès espace client',
description: 'Accéder à l\'espace client',
resource: 'client_dashboard',
action: 'read',
category: 'CONSULTATION'
},
// Permissions spécifiques clients
{
id: 'own_chantiers:read',
name: 'Consulter ses chantiers',
description: 'Voir ses propres chantiers',
resource: 'own_chantiers',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'own_phases:read',
name: 'Consulter ses phases',
description: 'Voir les phases de ses chantiers',
resource: 'own_phases',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'own_devis:read',
name: 'Consulter ses devis',
description: 'Voir ses propres devis',
resource: 'own_devis',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'own_factures:read',
name: 'Consulter ses factures',
description: 'Voir ses propres factures',
resource: 'own_factures',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'own_documents:read',
name: 'Consulter ses documents',
description: 'Voir ses documents',
resource: 'own_documents',
action: 'read',
category: 'CONSULTATION'
},
// Permissions messages
{
id: 'messages:read',
name: 'Consulter messages',
description: 'Voir les messages',
resource: 'messages',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'messages:write',
name: 'Envoyer messages',
description: 'Envoyer des messages',
resource: 'messages',
action: 'write',
category: 'CONSULTATION'
},
// Permissions documents
{
id: 'documents:read',
name: 'Consulter documents',
description: 'Voir tous les documents',
resource: 'documents',
action: 'read',
category: 'CONSULTATION'
},
{
id: 'documents:write',
name: 'Gérer documents',
description: 'Uploader, modifier les documents',
resource: 'documents',
action: 'write',
category: 'CONSULTATION'
},
// Permissions paramètres
{
id: 'settings:write',
name: 'Gérer paramètres',
description: 'Modifier les paramètres système',
resource: 'settings',
action: 'write',
category: 'ADMIN'
}
];
}
/**
* Obtenir tous les rôles avec leurs descriptions
*/
getAllRoles(): Role[] {
return [
{
id: UserRole.ADMIN,
name: UserRole.ADMIN,
displayName: 'Administrateur',
description: 'Accès complet au système, gestion des utilisateurs et paramètres',
permissions: ROLE_PERMISSIONS[UserRole.ADMIN],
level: 100
},
{
id: UserRole.MANAGER,
name: UserRole.MANAGER,
displayName: 'Responsable',
description: 'Gestion opérationnelle, supervision des gestionnaires et chantiers',
permissions: ROLE_PERMISSIONS[UserRole.MANAGER],
level: 80
},
{
id: UserRole.GESTIONNAIRE_PROJET,
name: UserRole.GESTIONNAIRE_PROJET,
displayName: 'Gestionnaire de Projet',
description: 'Gestion des clients attribués et de leurs projets',
permissions: ROLE_PERMISSIONS[UserRole.GESTIONNAIRE_PROJET],
level: 60
},
{
id: UserRole.CHEF_CHANTIER,
name: UserRole.CHEF_CHANTIER,
displayName: 'Chef de Chantier',
description: 'Gestion opérationnelle des chantiers et des équipes',
permissions: ROLE_PERMISSIONS[UserRole.CHEF_CHANTIER],
level: 50
},
{
id: UserRole.COMPTABLE,
name: UserRole.COMPTABLE,
displayName: 'Comptable',
description: 'Gestion financière, devis, factures et budgets',
permissions: ROLE_PERMISSIONS[UserRole.COMPTABLE],
level: 40
},
{
id: UserRole.OUVRIER,
name: UserRole.OUVRIER,
displayName: 'Ouvrier',
description: 'Consultation des chantiers et mise à jour des phases',
permissions: ROLE_PERMISSIONS[UserRole.OUVRIER],
level: 30
},
{
id: UserRole.CLIENT,
name: UserRole.CLIENT,
displayName: 'Client',
description: 'Consultation de ses projets, devis et factures',
permissions: ROLE_PERMISSIONS[UserRole.CLIENT],
level: 10
}
];
}
/**
* Vérifier si un utilisateur a une permission spécifique
*/
hasPermission(userRole: UserRole, permission: string): boolean {
const rolePermissions = ROLE_PERMISSIONS[userRole];
return rolePermissions.includes(permission);
}
/**
* Vérifier si un utilisateur a toutes les permissions requises
*/
hasAllPermissions(userRole: UserRole, permissions: string[]): boolean {
return permissions.every(permission => this.hasPermission(userRole, permission));
}
/**
* Vérifier si un utilisateur a au moins une des permissions
*/
hasAnyPermission(userRole: UserRole, permissions: string[]): boolean {
return permissions.some(permission => this.hasPermission(userRole, permission));
}
/**
* Obtenir les permissions d'un rôle groupées par catégorie
*/
getPermissionsByCategory(role: UserRole): Record<string, Permission[]> {
const allPermissions = this.getAllPermissions();
const rolePermissions = ROLE_PERMISSIONS[role];
const userPermissions = allPermissions.filter(p =>
rolePermissions.includes(p.id)
);
return userPermissions.reduce((acc, permission) => {
const category = permission.category;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(permission);
return acc;
}, {} as Record<string, Permission[]>);
}
/**
* Comparer deux rôles (retourne true si role1 >= role2 en niveau)
*/
compareRoles(role1: UserRole, role2: UserRole): boolean {
const roles = this.getAllRoles();
const level1 = roles.find(r => r.id === role1)?.level || 0;
const level2 = roles.find(r => r.id === role2)?.level || 0;
return level1 >= level2;
}
/**
* Obtenir le niveau hiérarchique d'un rôle
*/
getRoleLevel(role: UserRole): number {
const roles = this.getAllRoles();
return roles.find(r => r.id === role)?.level || 0;
}
}
export default new PermissionService();

View File

@@ -0,0 +1,418 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import {
PhaseChantier,
PhaseChantierFormData,
PhaseFilters,
JalonPhase,
PointagePhase,
StatutPhase,
ApiResponse
} from '../types/btp-extended';
class PhaseChantierService {
private readonly basePath = '/phases-chantier';
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token Keycloak
this.api.interceptors.request.use(
async (config) => {
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
if (typeof window !== 'undefined') {
const { keycloak, KEYCLOAK_TIMEOUTS } = await import('../config/keycloak');
if (keycloak.authenticated) {
try {
// Rafraîchir le token si nécessaire
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
// Ajouter le token Bearer à l'en-tête Authorization
if (keycloak.token) {
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
}
} catch (error) {
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
keycloak.login();
throw error;
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/api/auth/login';
}
return Promise.reject(error);
}
);
}
/**
* Récupérer les phases d'un chantier
*/
async getByChantier(chantierId: number): Promise<PhaseChantier[]> {
const response = await this.api.api.get(`${this.basePath}/chantier/${chantierId}`);
return response.data;
}
/**
* Récupérer une phase par ID
*/
async getById(id: number): Promise<PhaseChantier> {
const response = await this.api.get(`${this.basePath}/${id}`);
return response.data;
}
/**
* Créer une nouvelle phase
*/
async create(phase: PhaseChantierFormData): Promise<PhaseChantier> {
const response = await this.api.post(this.basePath, phase);
return response.data;
}
/**
* Modifier une phase existante
*/
async update(id: number, phase: PhaseChantierFormData): Promise<PhaseChantier> {
const response = await this.api.put(`${this.basePath}/${id}`, phase);
return response.data;
}
/**
* Supprimer une phase
*/
async delete(id: number): Promise<void> {
await this.api.delete(`${this.basePath}/${id}`);
}
/**
* Démarrer une phase
*/
async start(id: number): Promise<void> {
await this.api.post(`${this.basePath}/${id}/demarrer`);
}
/**
* Terminer une phase
*/
async complete(id: number): Promise<void> {
await this.api.post(`${this.basePath}/${id}/terminer`);
}
/**
* Suspendre une phase
*/
async suspend(id: number): Promise<void> {
await this.api.post(`${this.basePath}/${id}/suspendre`);
}
/**
* Reprendre une phase suspendue
*/
async resume(id: number): Promise<void> {
await this.api.post(`${this.basePath}/${id}/reprendre`);
}
/**
* Mettre à jour l'avancement d'une phase
*/
async updateProgress(id: number, pourcentage: number): Promise<void> {
await this.api.put(`${this.basePath}/${id}/avancement?pourcentage=${pourcentage}`);
}
/**
* Récupérer les statuts disponibles
*/
async getStatuts(): Promise<StatutPhase[]> {
const response = await this.api.get(`${this.basePath}/statuts`);
return response.data;
}
/**
* Récupérer les phases en retard
*/
async getEnRetard(): Promise<PhaseChantier[]> {
const response = await this.api.get(`${this.basePath}/en-retard`);
return response.data;
}
/**
* Récupérer les phases d'un responsable
*/
async getByResponsable(employeId: number): Promise<PhaseChantier[]> {
const response = await this.api.get(`${this.basePath}/responsable/${employeId}`);
return response.data;
}
/**
* Récupérer les jalons d'une phase
*/
async getJalons(phaseId: number): Promise<JalonPhase[]> {
const response = await this.api.get(`${this.basePath}/${phaseId}/jalons`);
return response.data;
}
/**
* Récupérer les pointages d'une phase
*/
async getPointages(phaseId: number): Promise<PointagePhase[]> {
const response = await this.api.get(`${this.basePath}/${phaseId}/pointages`);
return response.data;
}
/**
* Valider les données d'une phase
*/
validatePhase(phase: PhaseChantierFormData): string[] {
const errors: string[] = [];
if (!phase.nom || phase.nom.trim().length === 0) {
errors.push('Le nom de la phase est obligatoire');
}
if (phase.nom && phase.nom.length > 100) {
errors.push('Le nom ne peut pas dépasser 100 caractères');
}
if (!phase.dateDebutPrevue) {
errors.push('La date de début prévue est obligatoire');
}
if (!phase.dateFinPrevue) {
errors.push('La date de fin prévue est obligatoire');
}
if (phase.dateDebutPrevue && phase.dateFinPrevue) {
const debut = new Date(phase.dateDebutPrevue);
const fin = new Date(phase.dateFinPrevue);
if (debut >= fin) {
errors.push('La date de fin doit être postérieure à la date de début');
}
}
if (phase.budgetPrevu !== undefined && phase.budgetPrevu < 0) {
errors.push('Le budget prévu doit être positif');
}
return errors;
}
/**
* Calculer la durée prévue d'une phase en jours
*/
calculateDureePrevue(phase: PhaseChantier): number {
if (!phase.dateDebutPrevue || !phase.dateFinPrevue) return 0;
const debut = new Date(phase.dateDebutPrevue);
const fin = new Date(phase.dateFinPrevue);
const diffTime = Math.abs(fin.getTime() - debut.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Calculer la durée réelle d'une phase en jours
*/
calculateDureeReelle(phase: PhaseChantier): number {
if (!phase.dateDebutReelle) return 0;
const debut = new Date(phase.dateDebutReelle);
const fin = phase.dateFinReelle ? new Date(phase.dateFinReelle) : new Date();
const diffTime = Math.abs(fin.getTime() - debut.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Vérifier si une phase est en retard
*/
isEnRetard(phase: PhaseChantier): boolean {
if (phase.statut === 'TERMINEE') return false;
if (!phase.dateFinPrevue) return false;
return new Date() > new Date(phase.dateFinPrevue);
}
/**
* Calculer le retard en jours
*/
calculateRetard(phase: PhaseChantier): number {
if (!this.isEnRetard(phase)) return 0;
const dateFinPrevue = new Date(phase.dateFinPrevue!);
const maintenant = new Date();
const diffTime = maintenant.getTime() - dateFinPrevue.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Obtenir le libellé d'un statut
*/
getStatutLabel(statut: StatutPhase): string {
const labels: Record<StatutPhase, string> = {
PLANIFIEE: 'Planifiée',
EN_ATTENTE: 'En attente',
EN_COURS: 'En cours',
EN_PAUSE: 'En pause',
TERMINEE: 'Terminée',
ANNULEE: 'Annulée',
EN_RETARD: 'En retard'
};
return labels[statut] || statut;
}
/**
* Obtenir la couleur d'un statut
*/
getStatutColor(statut: StatutPhase): string {
const colors: Record<StatutPhase, string> = {
PLANIFIEE: '#6c757d',
EN_ATTENTE: '#ffc107',
EN_COURS: '#0d6efd',
EN_PAUSE: '#fd7e14',
TERMINEE: '#198754',
ANNULEE: '#dc3545',
EN_RETARD: '#dc3545'
};
return colors[statut] || '#6c757d';
}
/**
* Calculer les statistiques des phases d'un chantier
*/
calculateStatistiques(phases: PhaseChantier[]): {
total: number;
planifiees: number;
enCours: number;
terminees: number;
enRetard: number;
avancementMoyen: number;
budgetTotal: number;
coutTotal: number;
} {
const stats = {
total: phases.length,
planifiees: 0,
enCours: 0,
terminees: 0,
enRetard: 0,
avancementMoyen: 0,
budgetTotal: 0,
coutTotal: 0
};
let avancementTotal = 0;
phases.forEach(phase => {
// Compter par statut
switch (phase.statut) {
case 'PLANIFIEE':
case 'EN_ATTENTE':
stats.planifiees++;
break;
case 'EN_COURS':
case 'EN_PAUSE':
stats.enCours++;
break;
case 'TERMINEE':
stats.terminees++;
break;
case 'EN_RETARD':
stats.enRetard++;
break;
}
// Calculer avancement moyen
avancementTotal += phase.pourcentageAvancement || 0;
// Calculer budget et coût
stats.budgetTotal += phase.budgetPrevu || 0;
stats.coutTotal += phase.coutReel || 0;
});
stats.avancementMoyen = phases.length > 0 ? avancementTotal / phases.length : 0;
return stats;
}
/**
* Générer un planning Gantt simple
*/
generateGanttData(phases: PhaseChantier[]): any[] {
return phases.map(phase => ({
id: phase.id,
name: phase.nom,
start: phase.dateDebutPrevue,
end: phase.dateFinPrevue,
progress: (phase.pourcentageAvancement || 0) / 100,
status: phase.statut,
parent: phase.phaseParent?.id,
dependencies: [], // TODO: Implémenter les dépendances
color: this.getStatutColor(phase.statut)
}));
}
/**
* Exporter les phases au format CSV
*/
async exportToCsv(chantierId: number): Promise<Blob> {
const phases = await this.getByChantier(chantierId);
const headers = [
'ID', 'Nom', 'Statut', 'Date Début Prévue', 'Date Fin Prévue',
'Date Début Réelle', 'Date Fin Réelle', 'Avancement (%)',
'Budget Prévu', 'Coût Réel', 'Responsable', 'Critique'
];
const csvContent = [
headers.join(';'),
...phases.map(p => [
p.id || '',
p.nom || '',
this.getStatutLabel(p.statut),
p.dateDebutPrevue ? new Date(p.dateDebutPrevue).toLocaleDateString('fr-FR') : '',
p.dateFinPrevue ? new Date(p.dateFinPrevue).toLocaleDateString('fr-FR') : '',
p.dateDebutReelle ? new Date(p.dateDebutReelle).toLocaleDateString('fr-FR') : '',
p.dateFinReelle ? new Date(p.dateFinReelle).toLocaleDateString('fr-FR') : '',
p.pourcentageAvancement || 0,
p.budgetPrevu || 0,
p.coutReel || 0,
p.responsable ? `${p.responsable.nom} ${p.responsable.prenom}` : '',
p.critique ? 'Oui' : 'Non'
].join(';'))
].join('\n');
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
}
}
export default new PhaseChantierService();

428
services/phaseService.ts Normal file
View File

@@ -0,0 +1,428 @@
import axios from 'axios';
import { API_CONFIG } from '../config/api';
import {
PhaseChantier,
PhaseChantierFormData,
PhaseFilters,
JalonPhase,
PointagePhase,
StatutPhase,
ApiResponse
} from '../types/btp-extended';
import { MaterielBTPService, MaterielBTP, CategorieMateriel } from './materielBTPService';
import { CalculsTechniquesService, ParametresCalculBriques, ResultatCalculBriques } from './calculsTechniquesService';
import { ZoneClimatiqueService, ZoneClimatique } from './zoneClimatiqueService';
class PhaseService {
private readonly basePath = '/api/v1/phases-chantier';
private api = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
headers: API_CONFIG.headers,
});
constructor() {
// Interceptor pour ajouter le token Keycloak
this.api.interceptors.request.use(
async (config) => {
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
if (typeof window !== 'undefined') {
const { keycloak, KEYCLOAK_TIMEOUTS } = await import('../config/keycloak');
if (keycloak.authenticated) {
try {
// Rafraîchir le token si nécessaire
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
// Ajouter le token Bearer à l'en-tête Authorization
if (keycloak.token) {
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
}
} catch (error) {
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
keycloak.login();
throw error;
}
} else {
// Fallback vers l'ancien système pour la rétrocompatibilité
let token = null;
try {
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
if (authTokenItem) {
const parsed = JSON.parse(authTokenItem);
token = parsed.value;
}
} catch (e) {
token = localStorage.getItem('token');
}
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
return config;
},
(error) => Promise.reject(error)
);
// Interceptor pour les réponses
this.api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/auth/login';
}
return Promise.reject(error);
}
);
}
/**
* Récupérer les phases d'un chantier
*/
async getByChantier(chantierId: string): Promise<PhaseChantier[]> {
// Vérifier si l'ID est valide
if (!chantierId || chantierId === 'undefined' || chantierId === 'null' || chantierId === 'NaN') {
console.warn(`ID de chantier invalide: ${chantierId}`);
return [];
}
try {
console.log(`🔍 Recherche des phases pour chantier: ${chantierId}`);
// Ajouter un timestamp pour éviter le cache
const response = await this.api.get(`${this.basePath}/chantier/${chantierId}`, {
params: {
_t: Date.now() // Forcer le rafraîchissement
}
});
console.log(`${response.data.length} phases récupérées pour chantier ${chantierId}:`, response.data);
return response.data;
} catch (error) {
// Pour l'instant, retourner des données vides si l'endpoint n'existe pas
console.warn(`❌ Endpoint ${this.basePath}/chantier/${chantierId} non disponible:`, error);
return [];
}
}
/**
* Récupérer toutes les phases
*/
async getAll(): Promise<PhaseChantier[]> {
try {
const response = await this.api.get(this.basePath);
console.log('Toutes les phases récupérées:', response.data);
return response.data;
} catch (error) {
console.warn('Erreur lors de la récupération de toutes les phases:', error);
return [];
}
}
/**
* Récupérer une phase par ID
*/
async getById(id: string): Promise<PhaseChantier> {
const response = await this.api.get(`${this.basePath}/${id}`);
return response.data;
}
/**
* Créer une nouvelle phase
*/
async create(phase: PhaseChantierFormData): Promise<PhaseChantier> {
// D'abord récupérer les phases existantes pour calculer l'ordre d'exécution
const chantierId = phase.chantier?.id || phase.chantierId;
if (!chantierId) {
throw new Error('ID du chantier requis');
}
// Récupérer les phases existantes pour calculer le prochain ordre
const existingPhases = await this.getByChantier(chantierId);
const nextOrder = existingPhases.length > 0
? Math.max(...existingPhases.map(p => p.ordreExecution || 0)) + 1
: 1;
// S'assurer que chantierId est inclus dans la requête
// et mapper phaseParent vers phaseParentId pour le backend
const requestData = {
...phase,
chantierId: chantierId,
ordreExecution: phase.ordreExecution || nextOrder,
phaseParentId: phase.phaseParent || phase.phaseParentId
};
// Supprimer phaseParent si il existe pour éviter la confusion
delete requestData.phaseParent;
console.log('🚀 Création de phase avec données:', requestData);
const response = await this.api.post(this.basePath, requestData);
console.log('✅ Phase créée avec succès:', response.data);
return response.data;
}
/**
* Modifier une phase existante
*/
async update(id: string, phase: PhaseChantierFormData): Promise<PhaseChantier> {
const response = await this.api.put(`${this.basePath}/${id}`, phase);
return response.data;
}
/**
* Supprimer une phase
*/
async delete(id: string): Promise<void> {
await this.api.delete(`${this.basePath}/${id}`);
}
/**
* Générer des phases à partir d'un template
*/
async generateFromTemplate(
chantierId: number,
templateId: string,
configuration: {
phasesSelectionnees: any[];
configurationsPersonnalisees?: any;
optionsAvancees?: any;
dateDebutSouhaitee?: string;
dureeGlobale?: number;
}
): Promise<PhaseChantier[]> {
try {
console.log('Génération de phases depuis template:', { chantierId, templateId, configuration });
// Pour l'instant, on utilise la génération locale basée sur les templates
// Cette méthode peut être remplacée par un appel API backend plus tard
const phasesGenerees: PhaseChantier[] = [];
// Récupérer le template et générer les phases
const typeChantierService = await import('./typeChantierService');
const templates = await typeChantierService.default.getAllTemplates();
const template = templates.find(t => t.id === templateId);
if (!template) {
throw new Error(`Template ${templateId} non trouvé`);
}
// Générer les phases sélectionnées depuis la configuration (avec données utilisateur)
for (let i = 0; i < configuration.phasesSelectionnees.length; i++) {
const phaseSelectionnee = configuration.phasesSelectionnees[i];
// Utiliser les données personnalisées (saisies par l'utilisateur)
const phaseData: any = {
nom: phaseSelectionnee.nom,
description: phaseSelectionnee.description,
chantierId: chantierId.toString(),
statut: 'PLANIFIEE' as StatutPhase,
ordreExecution: phaseSelectionnee.ordre,
dureeEstimeeHeures: (phaseSelectionnee.dureeEstimee || 1) * 8, // Convertir jours en heures (défaut 1 jour)
budgetPrevu: phaseSelectionnee.budgetEstime || 0,
coutReel: 0,
priorite: phaseSelectionnee.categorieMetier === 'GROS_OEUVRE' ? 'CRITIQUE' : 'MOYENNE',
critique: phaseSelectionnee.obligatoire,
dateDebutPrevue: configuration.dateDebutSouhaitee,
competencesRequises: phaseSelectionnee.competencesRequises || [],
materielsNecessaires: [],
fournisseursRecommandes: []
};
// Créer la phase via l'API
const phaseCreee = await this.create(phaseData);
phasesGenerees.push(phaseCreee);
// Créer les sous-phases si elles existent
if (phaseSelectionnee.sousPhases && phaseSelectionnee.sousPhases.length > 0) {
for (let j = 0; j < phaseSelectionnee.sousPhases.length; j++) {
const sousPhaseTemplate = phaseSelectionnee.sousPhases[j];
const sousPhaseData: any = {
nom: sousPhaseTemplate.nom,
description: sousPhaseTemplate.description,
chantierId: chantierId.toString(),
statut: 'PLANIFIEE' as StatutPhase,
phaseParentId: phaseCreee.id,
ordreExecution: sousPhaseTemplate.ordre,
dureeEstimeeHeures: (sousPhaseTemplate.dureeEstimee || 1) * 8,
budgetPrevu: sousPhaseTemplate.budgetEstime || 0,
coutReel: 0,
priorite: 'MOYENNE',
critique: sousPhaseTemplate.obligatoire,
competencesRequises: sousPhaseTemplate.competencesRequises || [],
materielsNecessaires: [],
fournisseursRecommandes: []
};
const sousPhaseCreee = await this.create(sousPhaseData);
phasesGenerees.push(sousPhaseCreee);
}
}
}
console.log('Phases générées avec succès:', phasesGenerees);
return phasesGenerees;
} catch (error) {
console.error('Erreur lors de la génération depuis template:', error);
throw error;
}
}
/**
* Démarrer une phase
*/
async start(id: string): Promise<void> {
await this.api.post(`${this.basePath}/${id}/demarrer`);
}
/**
* Terminer une phase
*/
async complete(id: string): Promise<void> {
await this.api.post(`${this.basePath}/${id}/terminer`);
}
/**
* Suspendre une phase
*/
async suspend(id: string): Promise<void> {
await this.api.post(`${this.basePath}/${id}/suspendre`);
}
/**
* Reprendre une phase suspendue
*/
async resume(id: string): Promise<void> {
await this.api.post(`${this.basePath}/${id}/reprendre`);
}
/**
* Mettre à jour l'avancement d'une phase
*/
async updateProgress(id: string, pourcentage: number): Promise<void> {
await this.api.put(`${this.basePath}/${id}/avancement?pourcentage=${pourcentage}`);
}
/**
* Alias français pour start()
*/
async demarrer(id: string): Promise<void> {
return this.start(id);
}
/**
* Alias français pour complete()
*/
async terminer(id: string): Promise<void> {
return this.complete(id);
}
/**
* Alias français pour suspend()
*/
async suspendre(id: string, motif?: string): Promise<void> {
return this.suspend(id);
}
/**
* Alias français pour resume()
*/
async reprendre(id: string): Promise<void> {
return this.resume(id);
}
/**
* Alias français pour updateProgress()
*/
async updateAvancement(id: string, pourcentage: number): Promise<void> {
return this.updateProgress(id, pourcentage);
}
/**
* Récupérer les statuts disponibles
*/
async getStatuts(): Promise<StatutPhase[]> {
const response = await this.api.get(`${this.basePath}/statuts`);
return response.data;
}
/**
* Récupérer les phases en retard
*/
async getEnRetard(): Promise<PhaseChantier[]> {
const response = await this.api.get(`${this.basePath}/en-retard`);
return response.data;
}
/**
* Récupérer les phases d'un responsable
*/
async getByResponsable(employeId: number): Promise<PhaseChantier[]> {
const response = await this.api.get(`${this.basePath}/responsable/${employeId}`);
return response.data;
}
/**
* Récupérer les jalons d'une phase
*/
async getJalons(phaseId: number): Promise<JalonPhase[]> {
const response = await this.api.get(`${this.basePath}/${phaseId}/jalons`);
return response.data;
}
/**
* Récupérer les pointages d'une phase
*/
async getPointages(phaseId: number): Promise<PointagePhase[]> {
const response = await this.api.get(`${this.basePath}/${phaseId}/pointages`);
return response.data;
}
/**
* Obtenir le libellé d'un statut
*/
getStatutLabel(statut: StatutPhase): string {
const labels: Record<StatutPhase, string> = {
PLANIFIEE: 'Planifiée',
EN_ATTENTE: 'En attente',
EN_COURS: 'En cours',
EN_PAUSE: 'En pause',
TERMINEE: 'Terminée',
ANNULEE: 'Annulée',
EN_RETARD: 'En retard'
};
return labels[statut] || statut;
}
/**
* Obtenir la couleur d'un statut
*/
getStatutColor(statut: StatutPhase): string {
const colors: Record<StatutPhase, string> = {
PLANIFIEE: '#6c757d',
EN_ATTENTE: '#ffc107',
EN_COURS: '#0d6efd',
EN_PAUSE: '#fd7e14',
TERMINEE: '#198754',
ANNULEE: '#dc3545',
EN_RETARD: '#dc3545'
};
return colors[statut] || '#6c757d';
}
/**
* Vérifier si une phase est en retard
*/
isEnRetard(phase: PhaseChantier): boolean {
if (phase.statut === 'TERMINEE') return false;
if (!phase.dateFinPrevue) return false;
return new Date() > new Date(phase.dateFinPrevue);
}
}
export default new PhaseService();

View File

@@ -0,0 +1,328 @@
/**
* Service pour la gestion des templates de phases BTP via l'API backend
* Remplace l'utilisation des données statiques par des appels API
*/
import { TypeChantier, PhaseTemplate, ChantierTemplate, TYPE_CHANTIER_LABELS, CATEGORIES_CHANTIER } from '../types/chantier-templates';
import { PhaseChantier } from '../types/btp-extended';
class PhaseTemplateService {
private apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1';
/**
* Récupérer la liste des types de chantiers disponibles depuis l'API
*/
async getAvailableChantierTypes(): Promise<{ value: TypeChantier; label: string; categorie: string }[]> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/types-chantier`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Erreur lors de la récupération des types de chantiers');
}
const typesFromAPI = await response.json();
// Mapper les types de l'API vers le format attendu par le frontend
const mappedTypes = typesFromAPI.map((type: any) => {
// Trouver la catégorie correspondante
let categorie = 'Autre';
for (const [cat, config] of Object.entries(CATEGORIES_CHANTIER)) {
if (config.types.includes(type as TypeChantier)) {
categorie = config.label;
break;
}
}
return {
value: type as TypeChantier,
label: TYPE_CHANTIER_LABELS[type as TypeChantier] || type,
categorie: categorie
};
});
return mappedTypes;
} catch (error) {
console.error('Erreur lors de la récupération des types de chantiers:', error);
// Fallback vers les données locales si l'API est indisponible
return this.getLocalChantierTypes();
}
}
/**
* Récupérer les templates de phases pour un type de chantier
*/
async getTemplatesByType(typeChantier: TypeChantier): Promise<PhaseTemplate[]> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/by-type/${typeChantier}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Erreur lors de la récupération des templates');
}
const templates = await response.json();
return templates;
} catch (error) {
console.error('Erreur lors de la récupération des templates:', error);
throw error;
}
}
/**
* Récupérer un template par son ID avec ses sous-phases
*/
async getTemplateById(id: string): Promise<PhaseTemplate | null> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/${id}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Erreur lors de la récupération du template');
}
const template = await response.json();
return template;
} catch (error) {
console.error('Erreur lors de la récupération du template:', error);
throw error;
}
}
/**
* Prévisualiser les phases qui seraient générées pour un type de chantier
*/
async previewPhases(typeChantier: TypeChantier): Promise<PhaseTemplate[]> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/previsualisation/${typeChantier}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Erreur lors de la prévisualisation des phases');
}
const phases = await response.json();
return phases;
} catch (error) {
console.error('Erreur lors de la prévisualisation des phases:', error);
throw error;
}
}
/**
* Calculer la durée totale estimée pour un type de chantier
*/
async calculateDureeTotale(typeChantier: TypeChantier): Promise<number> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/duree-estimee/${typeChantier}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Erreur lors du calcul de la durée');
}
const duree = await response.json();
return duree;
} catch (error) {
console.error('Erreur lors du calcul de la durée:', error);
throw error;
}
}
/**
* Analyser la complexité d'un type de chantier
*/
async analyzeComplexity(typeChantier: TypeChantier): Promise<{
typeChantier: TypeChantier;
nombrePhases: number;
nombrePhasesCritiques: number;
dureeTotal: number;
niveauComplexite: string;
}> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/complexite/${typeChantier}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Erreur lors de l\'analyse de complexité');
}
const complexite = await response.json();
return complexite;
} catch (error) {
console.error('Erreur lors de l\'analyse de complexité:', error);
throw error;
}
}
/**
* Générer automatiquement les phases pour un chantier
*/
async generatePhases(
chantierId: string,
dateDebutChantier: Date,
inclureSousPhases: boolean = true
): Promise<PhaseChantier[]> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/generer-phases`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
chantierId: chantierId,
dateDebutChantier: dateDebutChantier.toISOString().split('T')[0],
inclureSousPhases: inclureSousPhases
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erreur lors de la génération des phases');
}
const phasesGenerees = await response.json();
return phasesGenerees;
} catch (error) {
console.error('Erreur lors de la génération des phases:', error);
throw error;
}
}
/**
* Créer un nouveau template de phase
*/
async createTemplate(template: Partial<PhaseTemplate>): Promise<PhaseTemplate> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(template)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erreur lors de la création du template');
}
const nouveauTemplate = await response.json();
return nouveauTemplate;
} catch (error) {
console.error('Erreur lors de la création du template:', error);
throw error;
}
}
/**
* Mettre à jour un template de phase
*/
async updateTemplate(id: string, template: Partial<PhaseTemplate>): Promise<PhaseTemplate> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/${id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(template)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erreur lors de la mise à jour du template');
}
const templateMisAJour = await response.json();
return templateMisAJour;
} catch (error) {
console.error('Erreur lors de la mise à jour du template:', error);
throw error;
}
}
/**
* Supprimer (désactiver) un template de phase
*/
async deleteTemplate(id: string): Promise<void> {
try {
const response = await fetch(`${this.apiBaseUrl}/phase-templates/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Erreur lors de la suppression du template');
}
} catch (error) {
console.error('Erreur lors de la suppression du template:', error);
throw error;
}
}
/**
* Fallback: Récupérer les types de chantiers depuis les données locales
*/
private getLocalChantierTypes(): { value: TypeChantier; label: string; categorie: string }[] {
const types: { value: TypeChantier; label: string; categorie: string }[] = [];
for (const [categorie, config] of Object.entries(CATEGORIES_CHANTIER)) {
for (const type of config.types) {
types.push({
value: type,
label: TYPE_CHANTIER_LABELS[type],
categorie: config.label
});
}
}
return types;
}
}
export default new PhaseTemplateService();

View File

@@ -0,0 +1,570 @@
/**
* Service de validation des prérequis de phases
* Gère la validation des dépendances entre phases et leurs prérequis métier
*/
import type { PhaseChantier } from '../types/btp';
export interface PhaseValidationResult {
canStart: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
blockedBy: string[];
readyToStart: boolean;
}
export interface ValidationError {
code: string;
message: string;
severity: 'error' | 'warning' | 'info';
phaseId?: string;
prerequisiteId?: string;
}
export interface ValidationWarning {
code: string;
message: string;
recommendation?: string;
}
export interface PrerequisiteStatus {
prerequisiteId: string;
prerequisiteName: string;
status: 'completed' | 'in_progress' | 'not_started' | 'not_found';
completionDate?: Date;
isBlocking: boolean;
}
class PhaseValidationService {
/**
* Valide si une phase peut être démarrée en fonction de ses prérequis
*/
validatePhaseStart(
phase: PhaseChantier,
allPhases: PhaseChantier[],
options: { strictMode?: boolean } = {}
): PhaseValidationResult {
const { strictMode = false } = options;
const result: PhaseValidationResult = {
canStart: true,
errors: [],
warnings: [],
blockedBy: [],
readyToStart: false
};
// Vérifier les prérequis de base
this.validateBasicPrerequisites(phase, result);
// Vérifier les dépendances entre phases
this.validatePhaseDependencies(phase, allPhases, result, strictMode);
// Vérifier les prérequis métier
this.validateBusinessPrerequisites(phase, allPhases, result);
// Vérifier les contraintes temporelles
this.validateTemporalConstraints(phase, allPhases, result);
// Déterminer si la phase est prête à démarrer
result.readyToStart = result.canStart && result.errors.length === 0;
return result;
}
/**
* Valide les prérequis de base d'une phase
*/
private validateBasicPrerequisites(phase: PhaseChantier, result: PhaseValidationResult): void {
// Vérifier le statut de la phase
if (phase.statut === 'TERMINEE') {
result.errors.push({
code: 'PHASE_ALREADY_COMPLETED',
message: 'Cette phase est déjà terminée',
severity: 'info'
});
result.canStart = false;
}
if (phase.statut === 'EN_COURS') {
result.warnings.push({
code: 'PHASE_ALREADY_STARTED',
message: 'Cette phase est déjà en cours',
recommendation: 'Vérifiez l\'avancement actuel'
});
}
// Vérifier les dates
const today = new Date();
today.setHours(0, 0, 0, 0);
if (phase.dateDebutPrevue) {
const dateDebut = new Date(phase.dateDebutPrevue);
dateDebut.setHours(0, 0, 0, 0);
if (dateDebut > today) {
result.warnings.push({
code: 'PHASE_FUTURE_START',
message: `Cette phase est prévue pour commencer le ${dateDebut.toLocaleDateString('fr-FR')}`,
recommendation: 'Vérifiez si un démarrage anticipé est possible'
});
}
}
if (phase.dateFinPrevue) {
const dateFin = new Date(phase.dateFinPrevue);
dateFin.setHours(0, 0, 0, 0);
if (dateFin < today) {
result.warnings.push({
code: 'PHASE_OVERDUE',
message: 'Cette phase aurait dû être terminée',
recommendation: 'Réviser la planification'
});
}
}
}
/**
* Valide les dépendances entre phases
*/
private validatePhaseDependencies(
phase: PhaseChantier,
allPhases: PhaseChantier[],
result: PhaseValidationResult,
strictMode: boolean
): void {
if (!phase.prerequis || phase.prerequis.length === 0) {
return;
}
const prerequisiteStatuses = this.getPrerequisiteStatuses(phase, allPhases);
prerequisiteStatuses.forEach(prereq => {
switch (prereq.status) {
case 'not_found':
result.errors.push({
code: 'PREREQUISITE_NOT_FOUND',
message: `Prérequis non trouvé: ${prereq.prerequisiteName}`,
severity: 'error',
prerequisiteId: prereq.prerequisiteId
});
result.canStart = false;
break;
case 'not_started':
if (prereq.isBlocking || strictMode) {
result.errors.push({
code: 'PREREQUISITE_NOT_STARTED',
message: `Prérequis non démarré: ${prereq.prerequisiteName}`,
severity: 'error',
prerequisiteId: prereq.prerequisiteId
});
result.blockedBy.push(prereq.prerequisiteName);
result.canStart = false;
} else {
result.warnings.push({
code: 'PREREQUISITE_NOT_STARTED_WARNING',
message: `Prérequis non démarré: ${prereq.prerequisiteName}`,
recommendation: 'Considérez démarrer ce prérequis en parallèle'
});
}
break;
case 'in_progress':
if (prereq.isBlocking || strictMode) {
result.warnings.push({
code: 'PREREQUISITE_IN_PROGRESS',
message: `Prérequis en cours: ${prereq.prerequisiteName}`,
recommendation: 'Attendez la fin de cette phase ou vérifiez si un démarrage parallèle est possible'
});
}
break;
case 'completed':
// Tout va bien
break;
}
});
}
/**
* Valide les prérequis métier spécifiques
*/
private validateBusinessPrerequisites(
phase: PhaseChantier,
allPhases: PhaseChantier[],
result: PhaseValidationResult
): void {
// Règles métier spécifiques au BTP
this.validateBTPBusinessRules(phase, allPhases, result);
// Vérifier les compétences requises
this.validateRequiredSkills(phase, result);
// Vérifier les ressources disponibles
this.validateResourceAvailability(phase, result);
}
/**
* Valide les règles métier spécifiques au BTP
*/
private validateBTPBusinessRules(
phase: PhaseChantier,
allPhases: PhaseChantier[],
result: PhaseValidationResult
): void {
const phaseName = phase.nom.toLowerCase();
// Règles pour la maçonnerie
if (phaseName.includes('maçonnerie') || phaseName.includes('béton')) {
const fondations = allPhases.find(p =>
p.nom.toLowerCase().includes('fondation') ||
p.nom.toLowerCase().includes('excavation')
);
if (fondations && fondations.statut !== 'TERMINEE') {
result.errors.push({
code: 'FOUNDATION_NOT_COMPLETED',
message: 'Les fondations doivent être terminées avant les travaux de maçonnerie',
severity: 'error',
phaseId: fondations.id
});
result.canStart = false;
result.blockedBy.push('Fondations');
}
}
// Règles pour l'électricité
if (phaseName.includes('électric') || phaseName.includes('électro')) {
const grosOeuvre = allPhases.find(p =>
p.nom.toLowerCase().includes('gros œuvre') ||
p.nom.toLowerCase().includes('gros oeuvre') ||
p.nom.toLowerCase().includes('structure')
);
if (grosOeuvre && grosOeuvre.statut !== 'TERMINEE') {
result.warnings.push({
code: 'STRUCTURE_NOT_COMPLETED',
message: 'Il est recommandé d\'attendre la fin du gros œuvre',
recommendation: 'Certains travaux électriques peuvent commencer en parallèle selon les zones'
});
}
}
// Règles pour la plomberie
if (phaseName.includes('plomberie') || phaseName.includes('sanitaire')) {
const cloisons = allPhases.find(p =>
p.nom.toLowerCase().includes('cloison') ||
p.nom.toLowerCase().includes('doublage')
);
if (cloisons && cloisons.statut === 'TERMINEE') {
result.warnings.push({
code: 'PARTITIONS_ALREADY_DONE',
message: 'Les cloisons sont déjà terminées - travaux de plomberie plus complexes',
recommendation: 'Prévoir des saignées ou passages techniques'
});
}
}
// Règles pour les finitions
if (phaseName.includes('peinture') || phaseName.includes('revêtement') || phaseName.includes('carrelage')) {
const secondOeuvre = allPhases.filter(p => {
const nom = p.nom.toLowerCase();
return nom.includes('électric') || nom.includes('plomberie') || nom.includes('chauffage');
});
const unfinishedSecondOeuvre = secondOeuvre.filter(p => p.statut !== 'TERMINEE');
if (unfinishedSecondOeuvre.length > 0) {
result.warnings.push({
code: 'SECOND_WORK_NOT_COMPLETED',
message: 'Certains travaux de second œuvre ne sont pas terminés',
recommendation: 'Terminer électricité, plomberie et chauffage avant les finitions'
});
}
}
}
/**
* Valide les compétences requises
*/
private validateRequiredSkills(phase: PhaseChantier, result: PhaseValidationResult): void {
// Cette validation serait étoffée avec une vraie base de données des compétences
const requiredSkills = this.getRequiredSkillsForPhase(phase);
if (requiredSkills.length > 0) {
result.warnings.push({
code: 'SKILLS_REQUIRED',
message: `Compétences requises: ${requiredSkills.join(', ')}`,
recommendation: 'Vérifiez la disponibilité des artisans qualifiés'
});
}
}
/**
* Valide la disponibilité des ressources
*/
private validateResourceAvailability(phase: PhaseChantier, result: PhaseValidationResult): void {
// Vérifications météorologiques pour certaines phases
if (this.isWeatherSensitivePhase(phase)) {
const season = this.getCurrentSeason();
if (season === 'winter') {
result.warnings.push({
code: 'WEATHER_SENSITIVE_WINTER',
message: 'Phase sensible aux conditions météorologiques - période hivernale',
recommendation: 'Prévoir des protections contre le gel et les intempéries'
});
}
}
// Vérification des matériaux
result.warnings.push({
code: 'MATERIALS_CHECK',
message: 'Vérifiez la disponibilité des matériaux',
recommendation: 'Confirmez les livraisons avant le démarrage'
});
}
/**
* Valide les contraintes temporelles
*/
private validateTemporalConstraints(
phase: PhaseChantier,
allPhases: PhaseChantier[],
result: PhaseValidationResult
): void {
const today = new Date();
// Vérifier les chevauchements problématiques
const overlappingPhases = this.findOverlappingPhases(phase, allPhases);
overlappingPhases.forEach(overlapping => {
if (this.arePhasesMutuallyExclusive(phase, overlapping)) {
result.errors.push({
code: 'PHASE_CONFLICT',
message: `Conflit avec la phase: ${overlapping.nom}`,
severity: 'error',
phaseId: overlapping.id
});
result.canStart = false;
}
});
// Vérifier les délais critiques
if (phase.critique && phase.dateFinPrevue) {
const dateFin = new Date(phase.dateFinPrevue);
const diffDays = Math.ceil((dateFin.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 7) {
result.warnings.push({
code: 'CRITICAL_PHASE_URGENT',
message: 'Phase critique avec délai serré',
recommendation: 'Mobiliser des ressources supplémentaires'
});
}
}
}
/**
* Obtient le statut des prérequis d'une phase
*/
private getPrerequisiteStatuses(phase: PhaseChantier, allPhases: PhaseChantier[]): PrerequisiteStatus[] {
if (!phase.prerequis) return [];
return phase.prerequis.map(prerequisiteId => {
const prerequisitePhase = allPhases.find(p => p.id === prerequisiteId);
return {
prerequisiteId,
prerequisiteName: prerequisitePhase?.nom || prerequisiteId,
status: prerequisitePhase ?
(prerequisitePhase.statut === 'TERMINEE' ? 'completed' :
prerequisitePhase.statut === 'EN_COURS' ? 'in_progress' : 'not_started') :
'not_found',
completionDate: prerequisitePhase?.dateFinReelle ? new Date(prerequisitePhase.dateFinReelle) : undefined,
isBlocking: this.isBlockingPrerequisite(prerequisiteId, phase)
};
});
}
/**
* Détermine si un prérequis est bloquant
*/
private isBlockingPrerequisite(prerequisiteId: string, phase: PhaseChantier): boolean {
// Logique pour déterminer si un prérequis est critique/bloquant
// Basée sur le type de phase et les règles métier
const criticalPrerequisites = [
'fondations', 'excavation', 'gros-oeuvre', 'structure'
];
return criticalPrerequisites.some(critical =>
prerequisiteId.toLowerCase().includes(critical)
);
}
/**
* Obtient les compétences requises pour une phase
*/
private getRequiredSkillsForPhase(phase: PhaseChantier): string[] {
const phaseName = phase.nom.toLowerCase();
const skills: string[] = [];
if (phaseName.includes('maçonnerie')) skills.push('Maçon');
if (phaseName.includes('électric')) skills.push('Électricien');
if (phaseName.includes('plomberie')) skills.push('Plombier');
if (phaseName.includes('charpente')) skills.push('Charpentier');
if (phaseName.includes('couverture')) skills.push('Couvreur');
if (phaseName.includes('peinture')) skills.push('Peintre');
if (phaseName.includes('carrelage')) skills.push('Carreleur');
return skills;
}
/**
* Détermine si une phase est sensible aux conditions météorologiques
*/
private isWeatherSensitivePhase(phase: PhaseChantier): boolean {
const weatherSensitive = [
'couverture', 'étanchéité', 'façade', 'maçonnerie extérieure',
'terrassement', 'fondations', 'béton'
];
return weatherSensitive.some(sensitive =>
phase.nom.toLowerCase().includes(sensitive)
);
}
/**
* Obtient la saison actuelle
*/
private getCurrentSeason(): 'spring' | 'summer' | 'fall' | 'winter' {
const month = new Date().getMonth() + 1;
if (month >= 3 && month <= 5) return 'spring';
if (month >= 6 && month <= 8) return 'summer';
if (month >= 9 && month <= 11) return 'fall';
return 'winter';
}
/**
* Trouve les phases qui se chevauchent temporellement
*/
private findOverlappingPhases(phase: PhaseChantier, allPhases: PhaseChantier[]): PhaseChantier[] {
if (!phase.dateDebutPrevue || !phase.dateFinPrevue) return [];
const phaseStart = new Date(phase.dateDebutPrevue);
const phaseEnd = new Date(phase.dateFinPrevue);
return allPhases.filter(otherPhase => {
if (otherPhase.id === phase.id) return false;
if (!otherPhase.dateDebutPrevue || !otherPhase.dateFinPrevue) return false;
const otherStart = new Date(otherPhase.dateDebutPrevue);
const otherEnd = new Date(otherPhase.dateFinPrevue);
return (phaseStart <= otherEnd && phaseEnd >= otherStart);
});
}
/**
* Détermine si deux phases sont mutuellement exclusives
*/
private arePhasesMutuallyExclusive(phase1: PhaseChantier, phase2: PhaseChantier): boolean {
const exclusiveGroups = [
['maçonnerie', 'béton'],
['peinture', 'électricité'],
['carrelage', 'plomberie']
];
return exclusiveGroups.some(group => {
const phase1InGroup = group.some(term => phase1.nom.toLowerCase().includes(term));
const phase2InGroup = group.some(term => phase2.nom.toLowerCase().includes(term));
return phase1InGroup && phase2InGroup;
});
}
/**
* Valide l'ensemble d'un planning de phases
*/
validateProjectSchedule(phases: PhaseChantier[]): {
isValid: boolean;
globalErrors: ValidationError[];
phaseValidations: Map<string, PhaseValidationResult>;
} {
const phaseValidations = new Map<string, PhaseValidationResult>();
const globalErrors: ValidationError[] = [];
// Valider chaque phase individuellement
phases.forEach(phase => {
const validation = this.validatePhaseStart(phase, phases);
phaseValidations.set(phase.id!, validation);
});
// Validations globales
this.validateGlobalScheduleConstraints(phases, globalErrors);
const isValid = globalErrors.length === 0 &&
Array.from(phaseValidations.values()).every(v => v.readyToStart || v.errors.length === 0);
return {
isValid,
globalErrors,
phaseValidations
};
}
/**
* Valide les contraintes globales du planning
*/
private validateGlobalScheduleConstraints(phases: PhaseChantier[], globalErrors: ValidationError[]): void {
// Vérifier l'ordre logique des phases
const orderedPhases = phases
.filter(p => p.ordreExecution !== undefined)
.sort((a, b) => (a.ordreExecution || 0) - (b.ordreExecution || 0));
for (let i = 1; i < orderedPhases.length; i++) {
const currentPhase = orderedPhases[i];
const previousPhase = orderedPhases[i - 1];
if (currentPhase.dateDebutPrevue && previousPhase.dateFinPrevue) {
const currentStart = new Date(currentPhase.dateDebutPrevue);
const previousEnd = new Date(previousPhase.dateFinPrevue);
if (currentStart < previousEnd) {
globalErrors.push({
code: 'SCHEDULE_ORDER_VIOLATION',
message: `La phase "${currentPhase.nom}" commence avant la fin de "${previousPhase.nom}"`,
severity: 'warning',
phaseId: currentPhase.id
});
}
}
}
// Vérifier la durée totale du projet
if (phases.length > 0) {
const startDates = phases
.filter(p => p.dateDebutPrevue)
.map(p => new Date(p.dateDebutPrevue!));
const endDates = phases
.filter(p => p.dateFinPrevue)
.map(p => new Date(p.dateFinPrevue!));
if (startDates.length > 0 && endDates.length > 0) {
const projectStart = new Date(Math.min(...startDates.map(d => d.getTime())));
const projectEnd = new Date(Math.max(...endDates.map(d => d.getTime())));
const projectDuration = Math.ceil((projectEnd.getTime() - projectStart.getTime()) / (1000 * 60 * 60 * 24));
if (projectDuration > 730) { // 2 ans
globalErrors.push({
code: 'PROJECT_TOO_LONG',
message: `Durée du projet très longue: ${projectDuration} jours`,
severity: 'warning'
});
}
}
}
}
}
export default new PhaseValidationService();

View File

@@ -0,0 +1,296 @@
/**
* Service intelligent pour monitoring serveur via Server-Sent Events
* Avec fallback polling et reconnexion automatique
*/
import { API_CONFIG } from '../config/api';
export interface ServerStatusEvent {
status: 'UP' | 'DOWN' | 'MAINTENANCE';
timestamp: string;
message: string;
system?: {
version: string;
uptimeSeconds: number;
activeConnections: number;
};
}
export interface ServerStatusListener {
(isOnline: boolean, event?: ServerStatusEvent): void;
}
export class ServerStatusService {
private eventSource: EventSource | null = null;
private listeners: ServerStatusListener[] = [];
private isOnline: boolean = true;
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 5;
private reconnectDelay: number = 1000;
private fallbackPolling: boolean = false;
private fallbackInterval: NodeJS.Timeout | null = null;
private lastEventTime: number = Date.now();
private heartbeatTimeout: NodeJS.Timeout | null = null;
constructor() {
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
// Vérifier si on est côté client (évite les erreurs SSR)
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
}
}
/**
* Démarrer le monitoring serveur
*/
public start(): void {
console.log('🚀 Démarrage monitoring serveur SSE');
this.initSSE();
}
/**
* Arrêter le monitoring
*/
public stop(): void {
console.log('🛑 Arrêt monitoring serveur');
this.cleanup();
// Vérifier si on est côté client avant de supprimer l'event listener
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
}
}
/**
* S'abonner aux changements de statut
*/
public onStatusChange(listener: ServerStatusListener): () => void {
this.listeners.push(listener);
// Retourner une fonction de désabonnement
return () => {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
};
}
/**
* Obtenir le statut actuel
*/
public getCurrentStatus(): boolean {
return this.isOnline;
}
/**
* Initialiser Server-Sent Events
*/
private initSSE(): void {
try {
// Retirer /api/v1 de l'URL de base pour l'endpoint SSE
const baseUrl = API_CONFIG.baseURL.replace('/api/v1', '');
const sseUrl = `${baseUrl}/api/server-status/stream`;
console.log('📡 Connexion SSE:', sseUrl);
this.eventSource = new EventSource(sseUrl);
this.eventSource.onopen = (event) => {
console.log('✅ SSE connecté avec succès');
this.reconnectAttempts = 0;
this.fallbackPolling = false;
this.stopFallbackPolling();
this.updateStatus(true);
this.resetHeartbeatTimeout();
};
this.eventSource.onmessage = (event) => {
try {
const statusEvent: ServerStatusEvent = JSON.parse(event.data);
console.log('📊 SSE reçu:', statusEvent);
this.lastEventTime = Date.now();
this.updateStatus(statusEvent.status === 'UP', statusEvent);
this.resetHeartbeatTimeout();
} catch (error) {
console.error('❌ Erreur parsing SSE:', error);
}
};
this.eventSource.onerror = (event) => {
console.warn('⚠️ Erreur SSE, tentative de reconnexion...');
this.handleSSEError();
};
} catch (error) {
console.error('❌ Impossible d\'initialiser SSE:', error);
this.startFallbackPolling();
}
}
/**
* Gérer les erreurs SSE et reconnexion
*/
private handleSSEError(): void {
this.updateStatus(false);
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Backoff exponentiel
console.log(`🔄 Reconnexion SSE dans ${delay}ms (tentative ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
this.cleanup();
this.initSSE();
}, delay);
} else {
console.warn('❌ Échec reconnexion SSE, passage en mode polling');
this.startFallbackPolling();
}
}
/**
* Démarrer le polling de secours
*/
private startFallbackPolling(): void {
if (this.fallbackPolling) return;
console.log('🔄 Démarrage polling de secours');
this.fallbackPolling = true;
const poll = async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
// Retirer /api/v1 pour l'endpoint de fallback aussi
const baseUrl = API_CONFIG.baseURL.replace('/api/v1', '');
const response = await fetch(`${baseUrl}/api/server-status/current`, {
signal: controller.signal,
headers: { 'Accept': 'application/json' }
});
clearTimeout(timeoutId);
if (response.ok) {
const statusEvent: ServerStatusEvent = await response.json();
this.updateStatus(statusEvent.status === 'UP', statusEvent);
// Essayer de rétablir SSE si le serveur est de nouveau UP
if (statusEvent.status === 'UP' && this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('🔄 Serveur récupéré, tentative de rétablissement SSE');
this.stopFallbackPolling();
this.reconnectAttempts = 0;
setTimeout(() => this.initSSE(), 2000);
}
} else {
this.updateStatus(false);
}
} catch (error) {
console.warn('⚠️ Erreur polling de secours:', error);
this.updateStatus(false);
}
};
// Poll immédiat puis toutes les 10 secondes
poll();
this.fallbackInterval = setInterval(poll, 10000);
}
/**
* Arrêter le polling de secours
*/
private stopFallbackPolling(): void {
if (this.fallbackInterval) {
clearInterval(this.fallbackInterval);
this.fallbackInterval = null;
}
this.fallbackPolling = false;
}
/**
* Timeout de heartbeat pour détecter les connexions perdues
*/
private resetHeartbeatTimeout(): void {
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
}
// Si pas de message pendant 45 secondes (heartbeat = 30s), considérer comme déconnecté
this.heartbeatTimeout = setTimeout(() => {
console.warn('💔 Pas de heartbeat SSE, connexion probablement perdue');
this.handleSSEError();
}, 45000);
}
/**
* Gérer la visibilité de l'onglet
*/
private handleVisibilityChange(): void {
// Vérifier si on est côté client
if (typeof document === 'undefined') return;
if (!document.hidden) {
console.log('👁️ Onglet redevenu visible, vérification connexion SSE');
// Vérifier si la connexion SSE est toujours active
if (this.eventSource && this.eventSource.readyState === EventSource.CLOSED) {
console.log('🔄 SSE fermé, reconnexion...');
this.reconnectAttempts = 0;
this.initSSE();
}
}
}
/**
* Mettre à jour le statut et notifier les listeners
*/
private updateStatus(isOnline: boolean, event?: ServerStatusEvent): void {
const wasOnline = this.isOnline;
this.isOnline = isOnline;
if (wasOnline !== isOnline) {
console.log(`🔄 Statut serveur changé: ${isOnline ? 'ON' : 'OFF'}LINE`);
}
// Notifier tous les listeners
this.listeners.forEach(listener => {
try {
listener(isOnline, event);
} catch (error) {
console.error('❌ Erreur dans listener status:', error);
}
});
}
/**
* Nettoyer les ressources
*/
private cleanup(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.stopFallbackPolling();
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
}
}
// Instance singleton avec initialisation paresseuse
let _serverStatusService: ServerStatusService | null = null;
export const getServerStatusService = (): ServerStatusService => {
if (!_serverStatusService && typeof window !== 'undefined') {
_serverStatusService = new ServerStatusService();
}
return _serverStatusService!;
};
// Pour la compatibilité avec l'ancien code
export const serverStatusService = typeof window !== 'undefined' ? getServerStatusService() : null;

View File

@@ -0,0 +1,666 @@
/**
* Service pour la gestion des types de chantiers et templates de phases
* Interface avec l'API backend pour les templates métier BTP
*/
import { TypeChantierTemplate } from '../components/phases/PhaseGenerationWizard';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1';
class TypeChantierService {
/**
* Récupérer tous les templates de types de chantiers disponibles
*/
async getAllTemplates(): Promise<TypeChantierTemplate[]> {
try {
// Essayer d'abord l'API backend
const response = await fetch(`${API_BASE_URL}/type-chantiers/templates`);
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn('Endpoint backend non disponible, utilisation des données mockées:', error);
// Fallback sur les données mockées
return this.getMockTemplates();
}
}
/**
* Récupérer un template spécifique par son ID
*/
async getTemplateById(id: string): Promise<TypeChantierTemplate | null> {
try {
const templates = await this.getAllTemplates();
return templates.find(t => t.id === id) || null;
} catch (error) {
console.error('Erreur lors du chargement du template:', error);
return null;
}
}
/**
* Récupérer les templates par catégorie
*/
async getTemplatesByCategorie(categorie: string): Promise<TypeChantierTemplate[]> {
try {
const templates = await this.getAllTemplates();
return templates.filter(t => t.categorie === categorie);
} catch (error) {
console.error('Erreur lors du filtrage par catégorie:', error);
return [];
}
}
/**
* Compte le nombre total de phases (principales + sous-phases) dans un template
*/
private countTotalPhases(phases: any[]): number {
return phases.reduce((total, phase) => {
return total + 1 + (phase.sousPhases ? phase.sousPhases.length : 0);
}, 0);
}
/**
* Données mockées pour les templates de chantiers BTP
* REMARQUE : Les durées et budgets sont à null - ils doivent être saisis par l'utilisateur
*/
private getMockTemplates(): TypeChantierTemplate[] {
// Définir les phases de Villa Moderne (structure complète réaliste)
const villaModernePhases = [
{
id: 'vm-01',
nom: 'Préparation et Terrassement',
description: 'Préparation du terrain et travaux de terrassement',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Terrassement', 'VRD', 'Géomètre'],
prerequis: [],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'vm-01-01',
nom: 'Implantation et piquetage',
description: 'Implantation précise de la construction',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Géomètre'],
obligatoire: true
},
{
id: 'vm-01-02',
nom: 'Décapage et excavation',
description: 'Décapage terre végétale et excavation',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Terrassement'],
obligatoire: true
},
{
id: 'vm-01-03',
nom: 'Évacuation terres et remblaiement',
description: 'Évacuation des terres excédentaires et remblaiement',
ordre: 3,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Transport', 'Terrassement'],
obligatoire: true
}
]
},
{
id: 'vm-02',
nom: 'Fondations',
description: 'Réalisation des fondations béton armé',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Béton armé', 'Coffreur', 'Ferrailleur'],
prerequis: ['vm-01'],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'vm-02-01',
nom: 'Ferraillage semelles',
description: 'Ferraillage des semelles de fondation',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Ferrailleur'],
obligatoire: true
},
{
id: 'vm-02-02',
nom: 'Coulage semelles',
description: 'Coulage béton des semelles',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Béton'],
obligatoire: true
},
{
id: 'vm-02-03',
nom: 'Vide sanitaire',
description: 'Réalisation du vide sanitaire',
ordre: 3,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Maçonnerie'],
obligatoire: true
},
{
id: 'vm-02-04',
nom: 'Étanchéité fondations',
description: 'Application de l\'étanchéité des fondations',
ordre: 4,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Étancheur'],
obligatoire: true
}
]
},
{
id: 'vm-03',
nom: 'Élévation des murs',
description: 'Construction des murs porteurs et cloisons',
ordre: 3,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Maçonnerie', 'Béton armé'],
prerequis: ['vm-02'],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'vm-03-01',
nom: 'Murs porteurs RDC',
description: 'Élévation murs porteurs rez-de-chaussée',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Maçonnerie'],
obligatoire: true
},
{
id: 'vm-03-02',
nom: 'Plancher intermédiaire',
description: 'Réalisation plancher entre RDC et étage',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Béton armé'],
obligatoire: true
},
{
id: 'vm-03-03',
nom: 'Murs porteurs étage',
description: 'Élévation murs porteurs étage',
ordre: 3,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Maçonnerie'],
obligatoire: true
}
]
},
{
id: 'vm-04',
nom: 'Charpente et Couverture',
description: 'Pose de la charpente et réalisation de la couverture',
ordre: 4,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Charpentier', 'Couvreur'],
prerequis: ['vm-03'],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'vm-04-01',
nom: 'Pose charpente',
description: 'Montage de la charpente',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Charpentier'],
obligatoire: true
},
{
id: 'vm-04-02',
nom: 'Pose couverture',
description: 'Pose des tuiles et finitions de toiture',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Couvreur'],
obligatoire: true
}
]
},
{
id: 'vm-05',
nom: 'Menuiseries Extérieures',
description: 'Pose des fenêtres, portes et volets',
ordre: 5,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Menuisier'],
prerequis: ['vm-03'],
categorieMetier: 'SECOND_OEUVRE' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'vm-05-01',
nom: 'Pose fenêtres',
description: 'Installation des fenêtres',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Menuisier'],
obligatoire: true
},
{
id: 'vm-05-02',
nom: 'Pose porte d\'entrée',
description: 'Installation de la porte d\'entrée',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Menuisier'],
obligatoire: true
}
]
},
{
id: 'vm-06',
nom: 'Isolation et Cloisons',
description: 'Isolation thermique et montage des cloisons',
ordre: 6,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Plaquiste', 'Isolateur'],
prerequis: ['vm-05'],
categorieMetier: 'SECOND_OEUVRE' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'vm-06-01',
nom: 'Isolation thermique',
description: 'Pose de l\'isolation thermique',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Isolateur'],
obligatoire: true
},
{
id: 'vm-06-02',
nom: 'Cloisons placo',
description: 'Montage des cloisons en placo',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Plaquiste'],
obligatoire: true
}
]
},
{
id: 'vm-07',
nom: 'Électricité',
description: 'Installation électrique complète',
ordre: 7,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Électricien'],
prerequis: ['vm-06'],
categorieMetier: 'EQUIPEMENTS' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'vm-07-01',
nom: 'Saignées et gaines',
description: 'Réalisation des saignées et pose des gaines',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Électricien'],
obligatoire: true
},
{
id: 'vm-07-02',
nom: 'Câblage et tableau',
description: 'Câblage et installation du tableau électrique',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Électricien'],
obligatoire: true
}
]
},
{
id: 'vm-08',
nom: 'Plomberie et Sanitaires',
description: 'Installation plomberie et équipements sanitaires',
ordre: 8,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Plombier'],
prerequis: ['vm-06'],
categorieMetier: 'EQUIPEMENTS' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'vm-08-01',
nom: 'Réseaux eau',
description: 'Installation des réseaux d\'eau',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Plombier'],
obligatoire: true
},
{
id: 'vm-08-02',
nom: 'Sanitaires',
description: 'Pose des équipements sanitaires',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Plombier'],
obligatoire: true
}
]
},
{
id: 'vm-09',
nom: 'Chauffage',
description: 'Installation du système de chauffage',
ordre: 9,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Chauffagiste'],
prerequis: ['vm-08'],
categorieMetier: 'EQUIPEMENTS' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'vm-09-01',
nom: 'Chaudière et réseau',
description: 'Installation chaudière et réseau de chauffage',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Chauffagiste'],
obligatoire: true
},
{
id: 'vm-09-02',
nom: 'Radiateurs',
description: 'Pose des radiateurs',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Chauffagiste'],
obligatoire: true
}
]
},
{
id: 'vm-10',
nom: 'Revêtements Sols et Murs',
description: 'Pose des revêtements de sols et murs',
ordre: 10,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Carreleur', 'Parqueteur'],
prerequis: ['vm-07', 'vm-08'],
categorieMetier: 'FINITIONS' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'vm-10-01',
nom: 'Carrelage',
description: 'Pose du carrelage',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Carreleur'],
obligatoire: true
},
{
id: 'vm-10-02',
nom: 'Parquet',
description: 'Pose du parquet',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Parqueteur'],
obligatoire: true
}
]
},
{
id: 'vm-11',
nom: 'Peinture',
description: 'Peinture intérieure et extérieure',
ordre: 11,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Peintre'],
prerequis: ['vm-10'],
categorieMetier: 'FINITIONS' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'vm-11-01',
nom: 'Peinture intérieure',
description: 'Peinture de tous les murs intérieurs',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Peintre'],
obligatoire: true
},
{
id: 'vm-11-02',
nom: 'Peinture extérieure',
description: 'Peinture des façades extérieures',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Peintre'],
obligatoire: true
}
]
},
{
id: 'vm-12',
nom: 'Finitions et Nettoyage',
description: 'Finitions diverses et nettoyage final',
ordre: 12,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Finisseur'],
prerequis: ['vm-11'],
categorieMetier: 'FINITIONS' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'vm-12-01',
nom: 'Finitions diverses',
description: 'Petites finitions et retouches',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Finisseur'],
obligatoire: true
},
{
id: 'vm-12-02',
nom: 'Nettoyage final',
description: 'Nettoyage complet du chantier',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Nettoyage'],
obligatoire: true
}
]
}
];
// Créer les templates finaux
return [
{
id: 'villa-moderne',
nom: 'Villa Moderne',
description: 'Construction d\'une villa moderne avec prestations haut de gamme',
categorie: 'RESIDENTIEL',
complexiteMetier: 'MOYENNE',
dureeGlobaleEstimee: null, // À saisir par l'utilisateur
budgetGlobalEstime: null, // À saisir par l'utilisateur
nombreTotalPhases: this.countTotalPhases(villaModernePhases),
tags: ['Villa', 'Moderne', 'Haut de gamme', 'BBC'],
phases: villaModernePhases
},
{
id: 'extension-maison',
nom: 'Extension Maison',
description: 'Extension latérale d\'une maison existante',
categorie: 'RESIDENTIEL',
complexiteMetier: 'SIMPLE',
dureeGlobaleEstimee: null,
budgetGlobalEstime: null,
nombreTotalPhases: 8, // 4 phases + 4 sous-phases
tags: ['Extension', 'Raccordement', 'Rénovation'],
phases: [
{
id: 'em-01',
nom: 'Démolition et Préparation',
description: 'Démolitions partielles et préparation du raccordement',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Démolition', 'Maçonnerie'],
prerequis: [],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'em-01-01',
nom: 'Ouverture mur existant',
description: 'Ouverture dans le mur existant pour raccordement',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Démolition'],
obligatoire: true
}
]
},
{
id: 'em-02',
nom: 'Fondations Extension',
description: 'Fondations spécifiques pour l\'extension',
ordre: 2,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Béton armé', 'Maçonnerie'],
prerequis: ['em-01'],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: false,
sousPhases: [
{
id: 'em-02-01',
nom: 'Fondations raccordement',
description: 'Fondations avec raccordement à l\'existant',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Béton armé'],
obligatoire: true
}
]
},
{
id: 'em-03',
nom: 'Élévation Extension',
description: 'Construction de l\'extension',
ordre: 3,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Maçonnerie'],
prerequis: ['em-02'],
categorieMetier: 'GROS_OEUVRE' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'em-03-01',
nom: 'Murs extension',
description: 'Élévation des murs de l\'extension',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Maçonnerie'],
obligatoire: true
}
]
},
{
id: 'em-04',
nom: 'Finitions Extension',
description: 'Finitions et raccordements',
ordre: 4,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Finisseur', 'Électricien', 'Plombier'],
prerequis: ['em-03'],
categorieMetier: 'FINITIONS' as const,
obligatoire: true,
personnalisable: true,
sousPhases: [
{
id: 'em-04-01',
nom: 'Raccordements techniques',
description: 'Raccordements électriques et plomberie',
ordre: 1,
dureeEstimee: null,
budgetEstime: null,
competencesRequises: ['Électricien', 'Plombier'],
obligatoire: true
}
]
}
]
}
];
}
}
export const typeChantierService = new TypeChantierService();
export default typeChantierService;

269
services/userService.ts Normal file
View File

@@ -0,0 +1,269 @@
import { apiService } from './api';
import type { User, UserRole } from '../types/auth';
interface CreateUserRequest {
email: string;
nom: string;
prenom: string;
role: UserRole;
entreprise?: string;
siret?: string;
secteurActivite?: string;
}
interface UpdateUserRequest {
nom?: string;
prenom?: string;
entreprise?: string;
siret?: string;
secteurActivite?: string;
actif?: boolean;
}
interface UserStats {
totalUsers: number;
activeUsers: number;
pendingUsers: number;
usersByRole: Record<UserRole, number>;
recentActivity: UserActivity[];
}
interface UserActivity {
userId: string;
userName: string;
action: string;
timestamp: string;
details?: string;
}
class UserService {
/**
* Récupérer tous les utilisateurs
*/
async getAllUsers(): Promise<User[]> {
try {
const response = await apiService.api.get('/users');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des utilisateurs:', error);
// Retourner des données mockées en attendant l'API
return this.getMockUsers();
}
}
/**
* Récupérer un utilisateur par ID
*/
async getUserById(id: string): Promise<User> {
try {
const response = await apiService.api.get(`/users/${id}`);
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération de l\'utilisateur:', error);
throw error;
}
}
/**
* Créer un nouvel utilisateur
*/
async createUser(userData: CreateUserRequest): Promise<User> {
try {
const response = await apiService.api.post('/users', userData);
return response.data;
} catch (error) {
console.error('Erreur lors de la création de l\'utilisateur:', error);
throw error;
}
}
/**
* Mettre à jour un utilisateur
*/
async updateUser(id: string, userData: UpdateUserRequest): Promise<User> {
try {
const response = await apiService.api.put(`/users/${id}`, userData);
return response.data;
} catch (error) {
console.error('Erreur lors de la mise à jour de l\'utilisateur:', error);
throw error;
}
}
/**
* Supprimer un utilisateur
*/
async deleteUser(id: string): Promise<void> {
try {
await apiService.api.delete(`/users/${id}`);
} catch (error) {
console.error('Erreur lors de la suppression de l\'utilisateur:', error);
throw error;
}
}
/**
* Récupérer les gestionnaires de projet
*/
async getGestionnaires(): Promise<User[]> {
try {
const response = await apiService.api.get('/users/gestionnaires');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des gestionnaires:', error);
// Retourner des données mockées
return this.getMockGestionnaires();
}
}
/**
* Récupérer les statistiques utilisateurs
*/
async getUserStats(): Promise<UserStats> {
try {
const response = await apiService.api.get('/users/stats');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération des statistiques:', error);
return this.getMockUserStats();
}
}
/**
* Récupérer l'activité récente des utilisateurs
*/
async getUserActivity(): Promise<UserActivity[]> {
try {
const response = await apiService.api.get('/users/activity');
return response.data;
} catch (error) {
console.error('Erreur lors de la récupération de l\'activité:', error);
return this.getMockUserActivity();
}
}
/**
* Données mockées pour les utilisateurs
*/
private getMockUsers(): User[] {
return [
{
id: 'admin-1',
email: 'admin@btpxpress.com',
nom: 'Administrateur',
prenom: 'Système',
role: UserRole.ADMIN,
actif: true,
status: 'APPROVED' as any,
entreprise: 'BTP Xpress',
dateCreation: '2024-01-01T00:00:00Z'
},
{
id: 'manager-1',
email: 'manager@btpxpress.com',
nom: 'Dupont',
prenom: 'Jean',
role: UserRole.MANAGER,
actif: true,
status: 'APPROVED' as any,
entreprise: 'BTP Xpress',
dateCreation: '2024-01-15T00:00:00Z'
},
{
id: 'gest-1',
email: 'john.doe@btpxpress.com',
nom: 'Doe',
prenom: 'John',
role: UserRole.GESTIONNAIRE_PROJET,
actif: true,
status: 'APPROVED' as any,
entreprise: 'BTP Xpress',
dateCreation: '2024-02-01T00:00:00Z',
clientsAttribues: ['client-1', 'client-2']
},
{
id: 'gest-2',
email: 'marie.martin@btpxpress.com',
nom: 'Martin',
prenom: 'Marie',
role: UserRole.GESTIONNAIRE_PROJET,
actif: true,
status: 'APPROVED' as any,
entreprise: 'BTP Xpress',
dateCreation: '2024-02-15T00:00:00Z',
clientsAttribues: ['client-3']
},
{
id: 'client-1',
email: 'client1@example.com',
nom: 'Dupont',
prenom: 'Pierre',
role: UserRole.CLIENT,
actif: true,
status: 'APPROVED' as any,
dateCreation: '2024-03-01T00:00:00Z',
clientId: 'client-1'
}
];
}
/**
* Données mockées pour les gestionnaires
*/
private getMockGestionnaires(): User[] {
return this.getMockUsers().filter(user => user.role === UserRole.GESTIONNAIRE_PROJET);
}
/**
* Statistiques mockées
*/
private getMockUserStats(): UserStats {
const users = this.getMockUsers();
return {
totalUsers: users.length,
activeUsers: users.filter(u => u.actif).length,
pendingUsers: users.filter(u => u.status === 'PENDING').length,
usersByRole: {
[UserRole.ADMIN]: users.filter(u => u.role === UserRole.ADMIN).length,
[UserRole.MANAGER]: users.filter(u => u.role === UserRole.MANAGER).length,
[UserRole.GESTIONNAIRE_PROJET]: users.filter(u => u.role === UserRole.GESTIONNAIRE_PROJET).length,
[UserRole.CHEF_CHANTIER]: users.filter(u => u.role === UserRole.CHEF_CHANTIER).length,
[UserRole.OUVRIER]: users.filter(u => u.role === UserRole.OUVRIER).length,
[UserRole.COMPTABLE]: users.filter(u => u.role === UserRole.COMPTABLE).length,
[UserRole.CLIENT]: users.filter(u => u.role === UserRole.CLIENT).length
},
recentActivity: this.getMockUserActivity()
};
}
/**
* Activité mockée
*/
private getMockUserActivity(): UserActivity[] {
return [
{
userId: 'gest-1',
userName: 'John Doe',
action: 'Connexion',
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
details: 'Dashboard gestionnaire'
},
{
userId: 'client-1',
userName: 'Pierre Dupont',
action: 'Consultation projet',
timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
details: 'Extension maison individuelle'
},
{
userId: 'manager-1',
userName: 'Jean Dupont',
action: 'Attribution client',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
details: 'Client attribué à Marie Martin'
}
];
}
}
export default new UserService();

View File

@@ -0,0 +1,352 @@
import ApiService from './ApiService';
import { MaterielBTP } from './materielBTPService';
/**
* Service pour la gestion des zones climatiques africaines
* Système spécialisé pour les contraintes de construction en Afrique
*/
export interface ZoneClimatique {
id: number;
code: string;
nom: string;
description: string;
// Paramètres climatiques
temperatureMin: number;
temperatureMax: number;
pluviometrieAnnuelle: number;
humiditeMin: number;
humiditeMax: number;
ventsMaximaux: number;
// Risques naturels
risqueSeisme: boolean;
risqueCyclones: boolean;
// Contraintes construction
profondeurFondationsMin: number;
drainageObligatoire: boolean;
isolationThermiqueObligatoire: boolean;
ventilationRenforcee: boolean;
protectionUVObligatoire: boolean;
traitementAntiTermites: boolean;
resistanceCorrosionMarine: boolean;
// Coefficients techniques
coefficientVent: number;
penteToitureMin: number;
evacuationEPMin: number;
// Relations
pays?: string[];
saisons?: SaisonClimatique[];
contraintes?: ContrainteConstruction[];
// Métadonnées
actif: boolean;
creePar: string;
dateCreation: string;
modifiePar?: string;
dateModification?: string;
}
export interface SaisonClimatique {
nom: string;
moisDebut: number;
moisFin: number;
temperatureMoyenne: number;
pluviometrieMoyenne: number;
humiditeRelative: number;
caracteristiques: string[];
}
export interface ContrainteConstruction {
type: TypeContrainte;
description: string;
obligatoire: boolean;
solution: string;
cout_supplementaire?: number;
}
export enum TypeContrainte {
FONDATIONS = 'FONDATIONS',
DRAINAGE = 'DRAINAGE',
ISOLATION = 'ISOLATION',
VENTILATION = 'VENTILATION',
PROTECTION_UV = 'PROTECTION_UV',
ANTI_TERMITES = 'ANTI_TERMITES',
CORROSION_MARINE = 'CORROSION_MARINE',
RESISTANCE_VENT = 'RESISTANCE_VENT',
ETANCHEITE = 'ETANCHEITE'
}
export interface CriteresRecherche {
temperatureMin?: number;
temperatureMax?: number;
pluvioMin?: number;
pluvioMax?: number;
risqueSeisme?: boolean;
corrosionMarine?: boolean;
texte?: string;
}
export interface StatistiquesZones {
total: number;
tempMoyenne: number;
pluvioMoyenne: number;
nbSeisme: number;
nbMarine: number;
}
export class ZoneClimatiqueService {
private static readonly BASE_PATH = '/calculs-techniques';
/**
* Récupère toutes les zones climatiques actives
*/
static async getZonesClimatiques(): Promise<{
zones: ZoneClimatique[];
info: string;
}> {
const response = await ApiService.get<{
zones: ZoneClimatique[];
info: string;
}>(`${this.BASE_PATH}/zones-climatiques`);
return response;
}
/**
* Récupère une zone climatique par son code
*/
static async getZoneByCode(code: string): Promise<ZoneClimatique> {
const response = await ApiService.get<ZoneClimatique>(`${this.BASE_PATH}/zones-climatiques/${code}`);
return response;
}
/**
* Recherche avancée de zones climatiques
*/
static async rechercherZones(criteres: CriteresRecherche): Promise<ZoneClimatique[]> {
const response = await ApiService.post<ZoneClimatique[]>(`${this.BASE_PATH}/zones-climatiques/recherche`, criteres);
return response;
}
/**
* Trouve les zones par plage de température
*/
static async getZonesParTemperature(tempMin: number, tempMax: number): Promise<ZoneClimatique[]> {
const criteres: CriteresRecherche = { temperatureMin: tempMin, temperatureMax: tempMax };
return await this.rechercherZones(criteres);
}
/**
* Trouve les zones par pluviométrie
*/
static async getZonesParPluviometrie(pluvioMin: number, pluvioMax: number): Promise<ZoneClimatique[]> {
const criteres: CriteresRecherche = { pluvioMin, pluvioMax };
return await this.rechercherZones(criteres);
}
/**
* Trouve les zones avec risque sismique
*/
static async getZonesAvecRisqueSeisme(): Promise<ZoneClimatique[]> {
const criteres: CriteresRecherche = { risqueSeisme: true };
return await this.rechercherZones(criteres);
}
/**
* Trouve les zones avec corrosion marine
*/
static async getZonesAvecCorrosionMarine(): Promise<ZoneClimatique[]> {
const criteres: CriteresRecherche = { corrosionMarine: true };
return await this.rechercherZones(criteres);
}
/**
* Recherche textuelle dans nom et description
*/
static async rechercherTexte(texte: string): Promise<ZoneClimatique[]> {
const criteres: CriteresRecherche = { texte };
return await this.rechercherZones(criteres);
}
/**
* Trouve la zone la plus adaptée selon critères météo
*/
static async getMeilleureAdaptation(temperature: number, humidite: number, vents: number): Promise<ZoneClimatique | null> {
const response = await ApiService.post<{ zone: ZoneClimatique | null }>(`${this.BASE_PATH}/zones-climatiques/meilleure-adaptation`, {
temperature,
humidite,
vents
});
return response.zone;
}
/**
* Récupère les statistiques des zones climatiques
*/
static async getStatistiquesZones(): Promise<StatistiquesZones> {
const response = await ApiService.get<StatistiquesZones>(`${this.BASE_PATH}/zones-climatiques/statistiques`);
return response;
}
/**
* Zones ordonnées par sévérité climatique
*/
static async getZonesParSeverite(): Promise<ZoneClimatique[]> {
const response = await ApiService.get<ZoneClimatique[]>(`${this.BASE_PATH}/zones-climatiques/par-severite`);
return response;
}
/**
* Valide si un matériau est adapté à une zone
*/
static async validerMaterielPourZone(zoneCode: string, materiel: MaterielBTP): Promise<{
adapte: boolean;
score: number;
avertissements: string[];
recommandations: string[];
}> {
const response = await ApiService.post<{
adapte: boolean;
score: number;
avertissements: string[];
recommandations: string[];
}>(`${this.BASE_PATH}/zones-climatiques/${zoneCode}/valider-materiel`, {
materielCode: materiel.code
});
return response;
}
/**
* Récupère les recommandations de construction pour une zone
*/
static async getRecommandationsConstruction(zoneCode: string): Promise<{
fondations: string[];
structure: string[];
enveloppe: string[];
finitions: string[];
equipements: string[];
}> {
const response = await ApiService.get<{
fondations: string[];
structure: string[];
enveloppe: string[];
finitions: string[];
equipements: string[];
}>(`${this.BASE_PATH}/zones-climatiques/${zoneCode}/recommandations`);
return response;
}
/**
* Calcule les coefficients de majoration pour une zone
*/
static async getCoefficientsZone(zoneCode: string): Promise<{
coefficient_vent: number;
coefficient_seisme: number;
coefficient_humidite: number;
coefficient_temperature: number;
coefficient_global: number;
}> {
const response = await ApiService.get<{
coefficient_vent: number;
coefficient_seisme: number;
coefficient_humidite: number;
coefficient_temperature: number;
coefficient_global: number;
}>(`${this.BASE_PATH}/zones-climatiques/${zoneCode}/coefficients`);
return response;
}
/**
* Récupère les contraintes spécifiques d'une zone
*/
static async getContraintesZone(zoneCode: string): Promise<ContrainteConstruction[]> {
const response = await ApiService.get<{ contraintes: ContrainteConstruction[] }>(`${this.BASE_PATH}/zones-climatiques/${zoneCode}/contraintes`);
return response.contraintes;
}
/**
* Récupère les saisons climatiques d'une zone
*/
static async getSaisonsZone(zoneCode: string): Promise<SaisonClimatique[]> {
const response = await ApiService.get<{ saisons: SaisonClimatique[] }>(`${this.BASE_PATH}/zones-climatiques/${zoneCode}/saisons`);
return response.saisons;
}
/**
* Simulation impact climatique sur construction
*/
static async simulerImpactClimatique(zoneCode: string, parametres: {
typeConstruction: string;
materiauxPrincipaux: string[];
dureeVie: number;
}): Promise<{
risqueGlobal: 'FAIBLE' | 'MOYEN' | 'ELEVE' | 'CRITIQUE';
impactTemperature: number;
impactHumidite: number;
impactVent: number;
impactSeisme: number;
recommandationsUrgentes: string[];
coutSupplementaire: number;
}> {
const response = await ApiService.post<{
risqueGlobal: 'FAIBLE' | 'MOYEN' | 'ELEVE' | 'CRITIQUE';
impactTemperature: number;
impactHumidite: number;
impactVent: number;
impactSeisme: number;
recommandationsUrgentes: string[];
coutSupplementaire: number;
}>(`${this.BASE_PATH}/zones-climatiques/${zoneCode}/simulation-impact`, parametres);
return response;
}
/**
* Comparaison entre zones climatiques
*/
static async comparerZones(codes: string[]): Promise<{
zones: ZoneClimatique[];
comparaison: {
plus_chaude: string;
plus_humide: string;
plus_ventee: string;
plus_contraignante: string;
recommandation: string;
};
}> {
const response = await ApiService.post<{
zones: ZoneClimatique[];
comparaison: {
plus_chaude: string;
plus_humide: string;
plus_ventee: string;
plus_contraignante: string;
recommandation: string;
};
}>(`${this.BASE_PATH}/zones-climatiques/comparer`, { codes });
return response;
}
/**
* Export des zones climatiques
*/
static async exporterZones(format: 'CSV' | 'EXCEL' | 'PDF'): Promise<Blob> {
const response = await ApiService.post<Blob>(
`${this.BASE_PATH}/zones-climatiques/export`,
{ format },
{ responseType: 'blob' }
);
return response;
}
}