Initial commit
This commit is contained in:
119
services/ApiService.ts
Normal file
119
services/ApiService.ts
Normal 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();
|
||||
199
services/__tests__/errorHandler.test.ts
Normal file
199
services/__tests__/errorHandler.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
287
services/analysePrixService.ts
Normal file
287
services/analysePrixService.ts
Normal 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
234
services/api-client.ts
Normal 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
974
services/api.ts
Normal 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;
|
||||
283
services/budgetCoherenceService.ts
Normal file
283
services/budgetCoherenceService.ts
Normal 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
157
services/cacheService.ts
Normal 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;
|
||||
484
services/calculsTechniquesService.ts
Normal file
484
services/calculsTechniquesService.ts
Normal 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}m² × ${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
|
||||
};
|
||||
}
|
||||
}
|
||||
303
services/chantierActionsService.ts
Normal file
303
services/chantierActionsService.ts
Normal 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
348
services/chantierService.ts
Normal 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();
|
||||
169
services/chantierTemplateService.ts
Normal file
169
services/chantierTemplateService.ts
Normal 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
143
services/clientService.ts
Normal 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
328
services/dashboard.ts
Normal 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();
|
||||
95
services/devisActionsService.ts
Normal file
95
services/devisActionsService.ts
Normal 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
228
services/errorHandler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
301
services/executionGranulaireService.ts
Normal file
301
services/executionGranulaireService.ts
Normal 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();
|
||||
464
services/exportBTPService.ts
Normal file
464
services/exportBTPService.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
135
services/factureActionsService.ts
Normal file
135
services/factureActionsService.ts
Normal 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();
|
||||
239
services/fournisseurPhaseService.ts
Normal file
239
services/fournisseurPhaseService.ts
Normal 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();
|
||||
353
services/fournisseurService.ts
Normal file
353
services/fournisseurService.ts
Normal 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();
|
||||
323
services/materielBTPService.ts
Normal file
323
services/materielBTPService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
203
services/materielPhaseService.ts
Normal file
203
services/materielPhaseService.ts
Normal 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();
|
||||
277
services/monitoringService.ts
Normal file
277
services/monitoringService.ts
Normal 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
|
||||
};
|
||||
}
|
||||
236
services/notificationService.ts
Normal file
236
services/notificationService.ts
Normal 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();
|
||||
526
services/permissionService.ts
Normal file
526
services/permissionService.ts
Normal 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();
|
||||
418
services/phaseChantierService.ts
Normal file
418
services/phaseChantierService.ts
Normal 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
428
services/phaseService.ts
Normal 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();
|
||||
328
services/phaseTemplateService.ts
Normal file
328
services/phaseTemplateService.ts
Normal 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();
|
||||
570
services/phaseValidationService.ts
Normal file
570
services/phaseValidationService.ts
Normal 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();
|
||||
296
services/serverStatusService.ts
Normal file
296
services/serverStatusService.ts
Normal 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;
|
||||
666
services/typeChantierService.ts
Normal file
666
services/typeChantierService.ts
Normal 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
269
services/userService.ts
Normal 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();
|
||||
352
services/zoneClimatiqueService.ts
Normal file
352
services/zoneClimatiqueService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user