chore: prettier everywhere
This commit is contained in:
@@ -1,80 +1,83 @@
|
||||
import { NextAuthOptions } from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import { usersService } from "@/services/users"
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import { usersService } from '@/services/users';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Chercher l'utilisateur dans la base de données
|
||||
const user = await usersService.getUserByEmail(credentials.email)
|
||||
|
||||
const user = await usersService.getUserByEmail(credentials.email);
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier le mot de passe
|
||||
const isValidPassword = await usersService.verifyPassword(
|
||||
credentials.password,
|
||||
user.password
|
||||
)
|
||||
);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email,
|
||||
name:
|
||||
user.name ||
|
||||
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
user.email,
|
||||
firstName: user.firstName || undefined,
|
||||
lastName: user.lastName || undefined,
|
||||
avatar: user.avatar || undefined,
|
||||
role: user.role,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error)
|
||||
return null
|
||||
console.error('Auth error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
signIn: '/login',
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
strategy: 'jwt',
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
token.firstName = user.firstName
|
||||
token.lastName = user.lastName
|
||||
token.avatar = user.avatar
|
||||
token.role = user.role
|
||||
token.id = user.id;
|
||||
token.firstName = user.firstName;
|
||||
token.lastName = user.lastName;
|
||||
token.avatar = user.avatar;
|
||||
token.role = user.role;
|
||||
}
|
||||
return token
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id as string
|
||||
session.user.firstName = token.firstName as string | undefined
|
||||
session.user.lastName = token.lastName as string | undefined
|
||||
session.user.avatar = token.avatar as string | undefined
|
||||
session.user.role = token.role as string
|
||||
session.user.id = token.id as string;
|
||||
session.user.firstName = token.firstName as string | undefined;
|
||||
session.user.lastName = token.lastName as string | undefined;
|
||||
session.user.avatar = token.avatar as string | undefined;
|
||||
session.user.role = token.role as string;
|
||||
}
|
||||
return session
|
||||
return session;
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,8 +43,8 @@ export class BackupUtils {
|
||||
if (process.env.BACKUP_STORAGE_PATH) {
|
||||
return path.resolve(process.cwd(), process.env.BACKUP_STORAGE_PATH);
|
||||
}
|
||||
|
||||
return process.env.NODE_ENV === 'production'
|
||||
|
||||
return process.env.NODE_ENV === 'production'
|
||||
? path.join(process.cwd(), 'data', 'backups')
|
||||
: path.join(process.cwd(), 'backups');
|
||||
}
|
||||
@@ -52,14 +52,17 @@ export class BackupUtils {
|
||||
/**
|
||||
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||
*/
|
||||
static async createSQLiteBackup(sourcePath: string, backupPath: string): Promise<void> {
|
||||
static async createSQLiteBackup(
|
||||
sourcePath: string,
|
||||
backupPath: string
|
||||
): Promise<void> {
|
||||
// Vérifier que le fichier source existe
|
||||
try {
|
||||
await fs.stat(sourcePath);
|
||||
} catch {
|
||||
throw new Error(`Source database not found: ${sourcePath}`);
|
||||
}
|
||||
|
||||
|
||||
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
|
||||
try {
|
||||
const command = `sqlite3 "${sourcePath}" ".backup '${backupPath}'"`;
|
||||
@@ -67,7 +70,10 @@ export class BackupUtils {
|
||||
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
|
||||
return;
|
||||
} catch (cliError) {
|
||||
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
|
||||
console.warn(
|
||||
`⚠️ SQLite CLI backup failed, trying copy method:`,
|
||||
cliError
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode 2: Copie simple du fichier (fallback)
|
||||
@@ -84,7 +90,7 @@ export class BackupUtils {
|
||||
*/
|
||||
static async compressFile(filePath: string): Promise<string> {
|
||||
const compressedPath = `${filePath}.gz`;
|
||||
|
||||
|
||||
try {
|
||||
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
|
||||
await execAsync(command);
|
||||
@@ -99,7 +105,10 @@ export class BackupUtils {
|
||||
/**
|
||||
* Décompresse un fichier gzip temporairement
|
||||
*/
|
||||
static async decompressFileTemp(compressedPath: string, tempPath: string): Promise<void> {
|
||||
static async decompressFileTemp(
|
||||
compressedPath: string,
|
||||
tempPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execAsync(`gunzip -c "${compressedPath}" > "${tempPath}"`);
|
||||
} catch (error) {
|
||||
@@ -114,12 +123,12 @@ export class BackupUtils {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
@@ -138,31 +147,40 @@ export class BackupUtils {
|
||||
/**
|
||||
* Parse le nom de fichier de backup pour extraire les métadonnées
|
||||
*/
|
||||
static parseBackupFilename(filename: string): { type: 'manual' | 'automatic'; date: Date | null } {
|
||||
static parseBackupFilename(filename: string): {
|
||||
type: 'manual' | 'automatic';
|
||||
date: Date | null;
|
||||
} {
|
||||
// Nouveau format: towercontrol_manual_2025-09-18T14-12-05-737Z.db
|
||||
// Ancien format: towercontrol_2025-09-18T14-12-05-737Z.db (considéré comme automatic)
|
||||
let type: 'manual' | 'automatic' = 'automatic';
|
||||
let dateMatch = filename.match(/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
|
||||
let dateMatch = filename.match(
|
||||
/towercontrol_(manual|automatic)_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/
|
||||
);
|
||||
|
||||
if (!dateMatch) {
|
||||
// Format ancien sans type - considérer comme automatic
|
||||
dateMatch = filename.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
dateMatch = filename.match(
|
||||
/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/
|
||||
);
|
||||
if (dateMatch) {
|
||||
dateMatch = [dateMatch[0], 'automatic', dateMatch[1]]; // Restructurer pour compatibilité
|
||||
}
|
||||
} else {
|
||||
type = dateMatch[1] as 'manual' | 'automatic';
|
||||
}
|
||||
|
||||
|
||||
let date: Date | null = null;
|
||||
if (dateMatch && dateMatch[2]) {
|
||||
// Convertir le format de fichier vers ISO string valide
|
||||
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
|
||||
const isoString = dateMatch[2]
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||
const isoString = dateMatch[2].replace(
|
||||
/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/,
|
||||
'T$1:$2:$3.$4Z'
|
||||
);
|
||||
date = parseDate(isoString);
|
||||
}
|
||||
|
||||
|
||||
return { type, date };
|
||||
}
|
||||
|
||||
@@ -178,17 +196,17 @@ export class BackupUtils {
|
||||
* Écrit une entrée dans le fichier de log
|
||||
*/
|
||||
static async writeLogEntry(
|
||||
logPath: string,
|
||||
type: 'manual' | 'automatic',
|
||||
action: 'created' | 'skipped' | 'failed',
|
||||
details: string,
|
||||
logPath: string,
|
||||
type: 'manual' | 'automatic',
|
||||
action: 'created' | 'skipped' | 'failed',
|
||||
details: string,
|
||||
extra?: { hash?: string; size?: number; previousHash?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const date = formatDateForDisplay(getToday(), 'DISPLAY_LONG');
|
||||
|
||||
|
||||
let logEntry = `[${date}] ${type.toUpperCase()} BACKUP ${action.toUpperCase()}: ${details}`;
|
||||
|
||||
|
||||
if (extra) {
|
||||
if (extra.hash) {
|
||||
logEntry += ` | Hash: ${extra.hash.substring(0, 12)}...`;
|
||||
@@ -200,9 +218,9 @@ export class BackupUtils {
|
||||
logEntry += ` | Previous: ${extra.previousHash.substring(0, 12)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logEntry += '\n';
|
||||
|
||||
|
||||
await fs.appendFile(logPath, logEntry);
|
||||
} catch (error) {
|
||||
console.error('Error writing to backup log:', error);
|
||||
|
||||
@@ -30,25 +30,32 @@ export interface AppConfig {
|
||||
const defaultConfig: AppConfig = {
|
||||
app: {
|
||||
name: 'TowerControl',
|
||||
version: '2.0.0'
|
||||
version: '2.0.0',
|
||||
},
|
||||
ui: {
|
||||
theme: (process.env.NEXT_PUBLIC_THEME as 'light' | 'dark' | 'system') || 'system',
|
||||
itemsPerPage: parseInt(process.env.NEXT_PUBLIC_ITEMS_PER_PAGE || '50')
|
||||
theme:
|
||||
(process.env.NEXT_PUBLIC_THEME as 'light' | 'dark' | 'system') ||
|
||||
'system',
|
||||
itemsPerPage: parseInt(process.env.NEXT_PUBLIC_ITEMS_PER_PAGE || '50'),
|
||||
},
|
||||
features: {
|
||||
enableDragAndDrop: process.env.NEXT_PUBLIC_ENABLE_DRAG_DROP !== 'false',
|
||||
enableNotifications: process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true',
|
||||
autoSave: process.env.NEXT_PUBLIC_AUTO_SAVE !== 'false'
|
||||
enableNotifications:
|
||||
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true',
|
||||
autoSave: process.env.NEXT_PUBLIC_AUTO_SAVE !== 'false',
|
||||
},
|
||||
integrations: {
|
||||
jira: {
|
||||
enabled: Boolean(process.env.JIRA_BASE_URL && process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN),
|
||||
enabled: Boolean(
|
||||
process.env.JIRA_BASE_URL &&
|
||||
process.env.JIRA_EMAIL &&
|
||||
process.env.JIRA_API_TOKEN
|
||||
),
|
||||
baseUrl: process.env.JIRA_BASE_URL,
|
||||
email: process.env.JIRA_EMAIL,
|
||||
apiToken: process.env.JIRA_API_TOKEN
|
||||
}
|
||||
}
|
||||
apiToken: process.env.JIRA_API_TOKEN,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -64,5 +71,5 @@ export function getConfig(): AppConfig {
|
||||
export const DEBUG_CONFIG = {
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
verboseLogging: process.env.VERBOSE_LOGGING === 'true',
|
||||
enableDevTools: process.env.NODE_ENV === 'development'
|
||||
enableDevTools: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
@@ -3,21 +3,32 @@
|
||||
* Regroupe toutes les fonctions de formatage, manipulation et validation de dates
|
||||
*/
|
||||
|
||||
import { format, startOfDay, endOfDay, isValid, formatDistanceToNow as formatDistanceToNowFns } from 'date-fns';
|
||||
import {
|
||||
format,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isValid,
|
||||
formatDistanceToNow as formatDistanceToNowFns,
|
||||
} from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
// Re-export des utilitaires workday existants
|
||||
export { getPreviousWorkday, getNextWorkday, isWorkday, getDayName } from './workday-utils';
|
||||
export {
|
||||
getPreviousWorkday,
|
||||
getNextWorkday,
|
||||
isWorkday,
|
||||
getDayName,
|
||||
} from './workday-utils';
|
||||
|
||||
/**
|
||||
* Formats de dates standardisés
|
||||
*/
|
||||
export const DATE_FORMATS = {
|
||||
API: 'yyyy-MM-dd', // Format API (YYYY-MM-DD)
|
||||
DISPLAY_SHORT: 'dd/MM/yy', // Format court (01/12/25)
|
||||
API: 'yyyy-MM-dd', // Format API (YYYY-MM-DD)
|
||||
DISPLAY_SHORT: 'dd/MM/yy', // Format court (01/12/25)
|
||||
DISPLAY_LONG: 'EEEE d MMMM yyyy', // Format long (lundi 1 décembre 2025)
|
||||
DISPLAY_MEDIUM: 'dd/MM/yyyy', // Format moyen (01/12/2025)
|
||||
ISO: "yyyy-MM-dd'T'HH:mm:ss.SSSxxx" // Format ISO complet
|
||||
ISO: "yyyy-MM-dd'T'HH:mm:ss.SSSxxx", // Format ISO complet
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -39,7 +50,7 @@ export function formatDateForAPI(date: Date | string): string {
|
||||
if (!ensuredDate || !isValid(ensuredDate)) {
|
||||
throw new Error('Date invalide fournie à formatDateForAPI');
|
||||
}
|
||||
|
||||
|
||||
const year = ensuredDate.getFullYear();
|
||||
const month = String(ensuredDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(ensuredDate.getDate()).padStart(2, '0');
|
||||
@@ -49,12 +60,15 @@ export function formatDateForAPI(date: Date | string): string {
|
||||
/**
|
||||
* Formate une date pour l'affichage en français
|
||||
*/
|
||||
export function formatDateForDisplay(date: Date | string, formatType: keyof typeof DATE_FORMATS = 'DISPLAY_MEDIUM'): string {
|
||||
export function formatDateForDisplay(
|
||||
date: Date | string,
|
||||
formatType: keyof typeof DATE_FORMATS = 'DISPLAY_MEDIUM'
|
||||
): string {
|
||||
const ensuredDate = ensureDate(date);
|
||||
if (!ensuredDate || !isValid(ensuredDate)) {
|
||||
throw new Error('Date invalide fournie à formatDateForDisplay');
|
||||
}
|
||||
|
||||
|
||||
return format(ensuredDate, DATE_FORMATS[formatType], { locale: fr });
|
||||
}
|
||||
|
||||
@@ -73,7 +87,7 @@ export function getDaysAgo(date: Date | string): number {
|
||||
if (!ensuredDate) {
|
||||
throw new Error('Date invalide fournie à getDaysAgo');
|
||||
}
|
||||
|
||||
|
||||
const today = getToday();
|
||||
const diffTime = today.getTime() - normalizeDate(ensuredDate).getTime();
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
@@ -92,9 +106,11 @@ export function formatDateLong(date: Date): string {
|
||||
export function isToday(date: Date | string): boolean {
|
||||
const ensuredDate = ensureDate(date);
|
||||
if (!ensuredDate) return false;
|
||||
|
||||
|
||||
const today = new Date();
|
||||
return normalizeDate(ensuredDate).getTime() === normalizeDate(today).getTime();
|
||||
return (
|
||||
normalizeDate(ensuredDate).getTime() === normalizeDate(today).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,10 +119,12 @@ export function isToday(date: Date | string): boolean {
|
||||
export function isYesterday(date: Date | string): boolean {
|
||||
const ensuredDate = ensureDate(date);
|
||||
if (!ensuredDate) return false;
|
||||
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return normalizeDate(ensuredDate).getTime() === normalizeDate(yesterday).getTime();
|
||||
return (
|
||||
normalizeDate(ensuredDate).getTime() === normalizeDate(yesterday).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +133,13 @@ export function isYesterday(date: Date | string): boolean {
|
||||
export function isSameDay(date1: Date | string, date2: Date | string): boolean {
|
||||
const ensuredDate1 = ensureDate(date1);
|
||||
const ensuredDate2 = ensureDate(date2);
|
||||
|
||||
|
||||
if (!ensuredDate1 || !ensuredDate2) return false;
|
||||
|
||||
return normalizeDate(ensuredDate1).getTime() === normalizeDate(ensuredDate2).getTime();
|
||||
|
||||
return (
|
||||
normalizeDate(ensuredDate1).getTime() ===
|
||||
normalizeDate(ensuredDate2).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +168,11 @@ export function createDate(date: Date): Date {
|
||||
/**
|
||||
* Crée une date à partir de composants année/mois/jour
|
||||
*/
|
||||
export function createDateFromParts(year: number, month: number, day: number): Date {
|
||||
export function createDateFromParts(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number
|
||||
): Date {
|
||||
return new Date(year, month - 1, day); // month est 0-indexé en JavaScript
|
||||
}
|
||||
|
||||
@@ -155,7 +180,9 @@ export function createDateFromParts(year: number, month: number, day: number): D
|
||||
* Convertit une date pour un input datetime-local (gestion timezone)
|
||||
*/
|
||||
export function formatDateForDateTimeInput(date: Date): string {
|
||||
const adjustedDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
const adjustedDate = new Date(
|
||||
date.getTime() - date.getTimezoneOffset() * 60000
|
||||
);
|
||||
return adjustedDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
@@ -210,7 +237,7 @@ export function isValidAPIDate(dateString: string): boolean {
|
||||
if (!regex.test(dateString)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const date = parseDate(dateString);
|
||||
return formatDateForAPI(date) === dateString;
|
||||
@@ -240,40 +267,46 @@ export function formatDateSmart(date: Date): string {
|
||||
if (isToday(date)) {
|
||||
return "Aujourd'hui";
|
||||
}
|
||||
|
||||
|
||||
if (isYesterday(date)) {
|
||||
return "Hier";
|
||||
return 'Hier';
|
||||
}
|
||||
|
||||
|
||||
return formatDateForDisplay(date, 'DISPLAY_MEDIUM');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un titre intelligent pour une date (avec emojis)
|
||||
*/
|
||||
export function generateDateTitle(date: Date, emoji: string = '📅'): { emoji: string; text: string } {
|
||||
export function generateDateTitle(
|
||||
date: Date,
|
||||
emoji: string = '📅'
|
||||
): { emoji: string; text: string } {
|
||||
if (isToday(date)) {
|
||||
return { emoji, text: 'Aujourd\'hui' };
|
||||
return { emoji, text: "Aujourd'hui" };
|
||||
}
|
||||
|
||||
|
||||
if (isYesterday(date)) {
|
||||
return { emoji, text: 'Hier' };
|
||||
}
|
||||
|
||||
|
||||
return { emoji, text: formatDateShort(date) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la distance depuis maintenant en français
|
||||
*/
|
||||
export function formatDistanceToNow(date: Date, options?: { addSuffix?: boolean }): string {
|
||||
export function formatDistanceToNow(
|
||||
date: Date,
|
||||
options?: { addSuffix?: boolean }
|
||||
): string {
|
||||
if (!isValid(date)) {
|
||||
throw new Error('Date invalide fournie à formatDistanceToNow');
|
||||
}
|
||||
|
||||
|
||||
return formatDistanceToNowFns(date, {
|
||||
locale: fr,
|
||||
addSuffix: options?.addSuffix ?? true
|
||||
addSuffix: options?.addSuffix ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,16 +314,18 @@ export function formatDistanceToNow(date: Date, options?: { addSuffix?: boolean
|
||||
* S'assure qu'une valeur est un objet Date valide
|
||||
* Convertit les chaînes de caractères en Date si nécessaire
|
||||
*/
|
||||
export function ensureDate(value: Date | string | null | undefined): Date | null {
|
||||
export function ensureDate(
|
||||
value: Date | string | null | undefined
|
||||
): Date | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Si c'est déjà un objet Date valide
|
||||
if (value instanceof Date && isValid(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
// Si c'est une chaîne de caractères, essayer de la parser
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
@@ -301,7 +336,7 @@ export function ensureDate(value: Date | string | null | undefined): Date | null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Si c'est un objet Date mais invalide, essayer de le recréer
|
||||
if (value instanceof Date) {
|
||||
try {
|
||||
@@ -313,7 +348,7 @@ export function ensureDate(value: Date | string | null | undefined): Date | null
|
||||
console.warn('Objet Date invalide détecté');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.warn('Valeur de date non supportée:', value);
|
||||
return null;
|
||||
}
|
||||
@@ -321,7 +356,10 @@ export function ensureDate(value: Date | string | null | undefined): Date | null
|
||||
/**
|
||||
* S'assure qu'une valeur est un objet Date valide, avec une valeur par défaut
|
||||
*/
|
||||
export function ensureDateWithDefault(value: Date | string | null | undefined, defaultValue: Date = new Date()): Date {
|
||||
export function ensureDateWithDefault(
|
||||
value: Date | string | null | undefined,
|
||||
defaultValue: Date = new Date()
|
||||
): Date {
|
||||
const result = ensureDate(value);
|
||||
return result || defaultValue;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export type PeriodFilter = '7d' | '30d' | '3m' | 'current';
|
||||
* Filtre les analytics Jira selon la période sélectionnée
|
||||
*/
|
||||
export function filterAnalyticsByPeriod(
|
||||
analytics: JiraAnalytics,
|
||||
analytics: JiraAnalytics,
|
||||
period: PeriodFilter
|
||||
): JiraAnalytics {
|
||||
const now = getToday();
|
||||
@@ -39,37 +39,50 @@ export function filterAnalyticsByPeriod(
|
||||
function filterCurrentSprintAnalytics(analytics: JiraAnalytics): JiraAnalytics {
|
||||
// Garder seulement le dernier sprint (le plus récent)
|
||||
const currentSprint = analytics.velocityMetrics.sprintHistory.slice(-1);
|
||||
|
||||
|
||||
return {
|
||||
...analytics,
|
||||
velocityMetrics: {
|
||||
...analytics.velocityMetrics,
|
||||
sprintHistory: currentSprint,
|
||||
// Recalculer la vélocité moyenne avec seulement le sprint actuel
|
||||
averageVelocity: currentSprint.length > 0 ? currentSprint[0].completedPoints : 0
|
||||
}
|
||||
averageVelocity:
|
||||
currentSprint.length > 0 ? currentSprint[0].completedPoints : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les analytics par date de cutoff
|
||||
*/
|
||||
function filterAnalyticsByDate(analytics: JiraAnalytics, cutoffDate: Date): JiraAnalytics {
|
||||
function filterAnalyticsByDate(
|
||||
analytics: JiraAnalytics,
|
||||
cutoffDate: Date
|
||||
): JiraAnalytics {
|
||||
// Filtrer l'historique des sprints
|
||||
const filteredSprintHistory = analytics.velocityMetrics.sprintHistory.filter(sprint => {
|
||||
const sprintEndDate = parseDate(sprint.endDate);
|
||||
return sprintEndDate >= cutoffDate;
|
||||
});
|
||||
const filteredSprintHistory = analytics.velocityMetrics.sprintHistory.filter(
|
||||
(sprint) => {
|
||||
const sprintEndDate = parseDate(sprint.endDate);
|
||||
return sprintEndDate >= cutoffDate;
|
||||
}
|
||||
);
|
||||
|
||||
// Si aucun sprint dans la période, garder au moins le plus récent
|
||||
const sprintHistory = filteredSprintHistory.length > 0
|
||||
? filteredSprintHistory
|
||||
: analytics.velocityMetrics.sprintHistory.slice(-1);
|
||||
const sprintHistory =
|
||||
filteredSprintHistory.length > 0
|
||||
? filteredSprintHistory
|
||||
: analytics.velocityMetrics.sprintHistory.slice(-1);
|
||||
|
||||
// Recalculer la vélocité moyenne
|
||||
const averageVelocity = sprintHistory.length > 0
|
||||
? Math.round(sprintHistory.reduce((sum, sprint) => sum + sprint.completedPoints, 0) / sprintHistory.length)
|
||||
: 0;
|
||||
const averageVelocity =
|
||||
sprintHistory.length > 0
|
||||
? Math.round(
|
||||
sprintHistory.reduce(
|
||||
(sum, sprint) => sum + sprint.completedPoints,
|
||||
0
|
||||
) / sprintHistory.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Pour simplifier, on garde les autres métriques inchangées
|
||||
// Dans une vraie implémentation, on devrait re-filtrer toutes les données
|
||||
@@ -78,8 +91,8 @@ function filterAnalyticsByDate(analytics: JiraAnalytics, cutoffDate: Date): Jira
|
||||
velocityMetrics: {
|
||||
...analytics.velocityMetrics,
|
||||
sprintHistory,
|
||||
averageVelocity
|
||||
}
|
||||
averageVelocity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,31 +127,31 @@ export function getPeriodInfo(period: PeriodFilter): {
|
||||
return {
|
||||
label: 'Derniers 7 jours',
|
||||
description: 'Vue hebdomadaire des métriques',
|
||||
icon: '📅'
|
||||
icon: '📅',
|
||||
};
|
||||
case '30d':
|
||||
return {
|
||||
label: 'Derniers 30 jours',
|
||||
description: 'Vue mensuelle des métriques',
|
||||
icon: '📊'
|
||||
icon: '📊',
|
||||
};
|
||||
case '3m':
|
||||
return {
|
||||
label: 'Derniers 3 mois',
|
||||
description: 'Vue trimestrielle des métriques',
|
||||
icon: '📈'
|
||||
icon: '📈',
|
||||
};
|
||||
case 'current':
|
||||
return {
|
||||
label: 'Sprint actuel',
|
||||
description: 'Focus sur le sprint en cours',
|
||||
icon: '🎯'
|
||||
icon: '🎯',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Période inconnue',
|
||||
description: '',
|
||||
icon: '❓'
|
||||
icon: '❓',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Task, TaskPriority } from './types';
|
||||
import { getPriorityConfig } from './status-config';
|
||||
|
||||
export type SortField = 'priority' | 'tags' | 'createdAt' | 'updatedAt' | 'dueDate' | 'title';
|
||||
export type SortField =
|
||||
| 'priority'
|
||||
| 'tags'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'dueDate'
|
||||
| 'title';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortConfig {
|
||||
@@ -24,70 +30,70 @@ export const SORT_OPTIONS: SortOption[] = [
|
||||
label: 'Priorité (Urgente → Faible)',
|
||||
field: 'priority',
|
||||
direction: 'desc',
|
||||
iconName: 'flame'
|
||||
iconName: 'flame',
|
||||
},
|
||||
{
|
||||
key: 'priority-asc',
|
||||
label: 'Priorité (Faible → Urgente)',
|
||||
field: 'priority',
|
||||
direction: 'asc',
|
||||
iconName: 'circle'
|
||||
iconName: 'circle',
|
||||
},
|
||||
{
|
||||
key: 'tags-asc',
|
||||
label: 'Tags (A → Z)',
|
||||
field: 'tags',
|
||||
direction: 'asc',
|
||||
iconName: 'tag'
|
||||
iconName: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'title-asc',
|
||||
label: 'Titre (A → Z)',
|
||||
field: 'title',
|
||||
direction: 'asc',
|
||||
iconName: 'file-text'
|
||||
iconName: 'file-text',
|
||||
},
|
||||
{
|
||||
key: 'title-desc',
|
||||
label: 'Titre (Z → A)',
|
||||
field: 'title',
|
||||
direction: 'desc',
|
||||
iconName: 'file-text'
|
||||
iconName: 'file-text',
|
||||
},
|
||||
{
|
||||
key: 'createdAt-desc',
|
||||
label: 'Date création (Récent → Ancien)',
|
||||
field: 'createdAt',
|
||||
direction: 'desc',
|
||||
iconName: 'calendar'
|
||||
iconName: 'calendar',
|
||||
},
|
||||
{
|
||||
key: 'createdAt-asc',
|
||||
label: 'Date création (Ancien → Récent)',
|
||||
field: 'createdAt',
|
||||
direction: 'asc',
|
||||
iconName: 'calendar'
|
||||
iconName: 'calendar',
|
||||
},
|
||||
{
|
||||
key: 'dueDate-asc',
|
||||
label: 'Échéance (Proche → Lointaine)',
|
||||
field: 'dueDate',
|
||||
direction: 'asc',
|
||||
iconName: 'clock'
|
||||
iconName: 'clock',
|
||||
},
|
||||
{
|
||||
key: 'dueDate-desc',
|
||||
label: 'Échéance (Lointaine → Proche)',
|
||||
field: 'dueDate',
|
||||
direction: 'desc',
|
||||
iconName: 'clock'
|
||||
}
|
||||
iconName: 'clock',
|
||||
},
|
||||
];
|
||||
|
||||
// Tri par défaut : Priorité (desc) puis Tags (asc)
|
||||
export const DEFAULT_SORT: SortConfig[] = [
|
||||
{ field: 'priority', direction: 'desc' },
|
||||
{ field: 'tags', direction: 'asc' }
|
||||
{ field: 'tags', direction: 'asc' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -97,7 +103,7 @@ function compareValues<T>(a: T, b: T, direction: SortDirection): number {
|
||||
if (a === b) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
|
||||
|
||||
const result = a < b ? -1 : 1;
|
||||
return direction === 'asc' ? result : -result;
|
||||
}
|
||||
@@ -108,7 +114,9 @@ function compareValues<T>(a: T, b: T, direction: SortDirection): number {
|
||||
function getPriorityValue(priority: TaskPriority): number {
|
||||
const config = getPriorityConfig(priority);
|
||||
if (!config) {
|
||||
console.warn(`⚠️ Priorité inconnue: ${priority}, utilisation de 'medium' par défaut`);
|
||||
console.warn(
|
||||
`⚠️ Priorité inconnue: ${priority}, utilisation de 'medium' par défaut`
|
||||
);
|
||||
return getPriorityConfig('medium').order;
|
||||
}
|
||||
return config.order;
|
||||
@@ -126,7 +134,7 @@ function getFirstTag(task: Task): string {
|
||||
*/
|
||||
function compareTasksByField(a: Task, b: Task, sortConfig: SortConfig): number {
|
||||
const { field, direction } = sortConfig;
|
||||
|
||||
|
||||
switch (field) {
|
||||
case 'priority':
|
||||
return compareValues(
|
||||
@@ -134,42 +142,38 @@ function compareTasksByField(a: Task, b: Task, sortConfig: SortConfig): number {
|
||||
getPriorityValue(b.priority),
|
||||
direction
|
||||
);
|
||||
|
||||
|
||||
case 'tags':
|
||||
return compareValues(
|
||||
getFirstTag(a),
|
||||
getFirstTag(b),
|
||||
direction
|
||||
);
|
||||
|
||||
return compareValues(getFirstTag(a), getFirstTag(b), direction);
|
||||
|
||||
case 'title':
|
||||
return compareValues(
|
||||
a.title.toLowerCase(),
|
||||
b.title.toLowerCase(),
|
||||
direction
|
||||
);
|
||||
|
||||
|
||||
case 'createdAt':
|
||||
return compareValues(
|
||||
a.createdAt.getTime(),
|
||||
b.createdAt.getTime(),
|
||||
direction
|
||||
);
|
||||
|
||||
|
||||
case 'updatedAt':
|
||||
return compareValues(
|
||||
a.updatedAt.getTime(),
|
||||
b.updatedAt.getTime(),
|
||||
direction
|
||||
);
|
||||
|
||||
|
||||
case 'dueDate':
|
||||
return compareValues(
|
||||
a.dueDate ? a.dueDate.getTime() : null,
|
||||
b.dueDate ? b.dueDate.getTime() : null,
|
||||
direction
|
||||
);
|
||||
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -178,7 +182,10 @@ function compareTasksByField(a: Task, b: Task, sortConfig: SortConfig): number {
|
||||
/**
|
||||
* Trie un tableau de tâches selon une configuration de tri multiple
|
||||
*/
|
||||
export function sortTasks(tasks: Task[], sortConfigs: SortConfig[] = DEFAULT_SORT): Task[] {
|
||||
export function sortTasks(
|
||||
tasks: Task[],
|
||||
sortConfigs: SortConfig[] = DEFAULT_SORT
|
||||
): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
for (const sortConfig of sortConfigs) {
|
||||
const result = compareTasksByField(a, b, sortConfig);
|
||||
@@ -194,12 +201,15 @@ export function sortTasks(tasks: Task[], sortConfigs: SortConfig[] = DEFAULT_SOR
|
||||
* Utilitaire pour obtenir une option de tri par sa clé
|
||||
*/
|
||||
export function getSortOption(key: string): SortOption | undefined {
|
||||
return SORT_OPTIONS.find(option => option.key === key);
|
||||
return SORT_OPTIONS.find((option) => option.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaire pour créer une clé de tri
|
||||
*/
|
||||
export function createSortKey(field: SortField, direction: SortDirection): string {
|
||||
export function createSortKey(
|
||||
field: SortField,
|
||||
direction: SortDirection
|
||||
): string {
|
||||
return `${field}-${direction}`;
|
||||
}
|
||||
|
||||
@@ -14,50 +14,50 @@ export const STATUS_CONFIG: Record<TaskStatus, StatusConfig> = {
|
||||
label: 'Backlog',
|
||||
icon: '📋',
|
||||
color: 'gray',
|
||||
order: 0
|
||||
order: 0,
|
||||
},
|
||||
todo: {
|
||||
key: 'todo',
|
||||
label: 'À faire',
|
||||
icon: '⚡',
|
||||
color: 'gray',
|
||||
order: 1
|
||||
order: 1,
|
||||
},
|
||||
in_progress: {
|
||||
key: 'in_progress',
|
||||
label: 'En cours',
|
||||
icon: '⚙️',
|
||||
color: 'blue',
|
||||
order: 2
|
||||
order: 2,
|
||||
},
|
||||
freeze: {
|
||||
key: 'freeze',
|
||||
label: 'Gelé',
|
||||
icon: '🧊',
|
||||
color: 'purple',
|
||||
order: 3
|
||||
order: 3,
|
||||
},
|
||||
done: {
|
||||
key: 'done',
|
||||
label: 'Terminé',
|
||||
icon: '✓',
|
||||
color: 'green',
|
||||
order: 4
|
||||
order: 4,
|
||||
},
|
||||
cancelled: {
|
||||
key: 'cancelled',
|
||||
label: 'Annulé',
|
||||
icon: '✕',
|
||||
color: 'red',
|
||||
order: 5
|
||||
order: 5,
|
||||
},
|
||||
archived: {
|
||||
key: 'archived',
|
||||
label: 'Archivé',
|
||||
icon: '📦',
|
||||
color: 'gray',
|
||||
order: 6
|
||||
}
|
||||
order: 6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Utilitaires pour récupérer facilement les infos
|
||||
@@ -87,45 +87,51 @@ export const TECH_STYLES = {
|
||||
border: 'border-slate-700',
|
||||
glow: 'shadow-slate-500/20',
|
||||
accent: 'text-slate-400',
|
||||
badge: 'bg-slate-800 text-slate-300 border border-slate-600'
|
||||
badge: 'bg-slate-800 text-slate-300 border border-slate-600',
|
||||
},
|
||||
blue: {
|
||||
border: 'border-cyan-500/30',
|
||||
glow: 'shadow-cyan-500/20',
|
||||
accent: 'text-cyan-400',
|
||||
badge: 'bg-cyan-950 text-cyan-300 border border-cyan-500/30'
|
||||
badge: 'bg-cyan-950 text-cyan-300 border border-cyan-500/30',
|
||||
},
|
||||
green: {
|
||||
border: 'border-emerald-500/30',
|
||||
glow: 'shadow-emerald-500/20',
|
||||
accent: 'text-emerald-400',
|
||||
badge: 'bg-emerald-950 text-emerald-300 border border-emerald-500/30'
|
||||
badge: 'bg-emerald-950 text-emerald-300 border border-emerald-500/30',
|
||||
},
|
||||
red: {
|
||||
border: 'border-red-500/30',
|
||||
glow: 'shadow-red-500/20',
|
||||
accent: 'text-red-400',
|
||||
badge: 'bg-red-950 text-red-300 border border-red-500/30'
|
||||
badge: 'bg-red-950 text-red-300 border border-red-500/30',
|
||||
},
|
||||
purple: {
|
||||
border: 'border-purple-500/30',
|
||||
glow: 'shadow-purple-500/20',
|
||||
accent: 'text-purple-400',
|
||||
badge: 'bg-purple-950 text-purple-300 border border-purple-500/30'
|
||||
}
|
||||
badge: 'bg-purple-950 text-purple-300 border border-purple-500/30',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getTechStyle = (color: StatusConfig['color']) => {
|
||||
return TECH_STYLES[color];
|
||||
};
|
||||
|
||||
export const getBadgeVariant = (color: StatusConfig['color']): 'success' | 'primary' | 'danger' | 'default' => {
|
||||
export const getBadgeVariant = (
|
||||
color: StatusConfig['color']
|
||||
): 'success' | 'primary' | 'danger' | 'default' => {
|
||||
switch (color) {
|
||||
case 'green': return 'success';
|
||||
case 'blue':
|
||||
case 'purple': return 'primary';
|
||||
case 'red': return 'danger';
|
||||
default: return 'default';
|
||||
case 'green':
|
||||
return 'success';
|
||||
case 'blue':
|
||||
case 'purple':
|
||||
return 'primary';
|
||||
case 'red':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,29 +150,29 @@ export const PRIORITY_CONFIG: Record<TaskPriority, PriorityConfig> = {
|
||||
label: 'Faible',
|
||||
icon: '🔵',
|
||||
color: 'blue',
|
||||
order: 1
|
||||
order: 1,
|
||||
},
|
||||
medium: {
|
||||
key: 'medium',
|
||||
label: 'Moyenne',
|
||||
icon: '🟡',
|
||||
color: 'yellow',
|
||||
order: 2
|
||||
order: 2,
|
||||
},
|
||||
high: {
|
||||
key: 'high',
|
||||
label: 'Élevée',
|
||||
icon: '🟣',
|
||||
color: 'purple',
|
||||
order: 3
|
||||
order: 3,
|
||||
},
|
||||
urgent: {
|
||||
key: 'urgent',
|
||||
label: 'Urgente',
|
||||
icon: '🔴',
|
||||
color: 'red',
|
||||
order: 4
|
||||
}
|
||||
order: 4,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Utilitaires pour les priorités
|
||||
@@ -186,25 +192,27 @@ export const getPriorityIcon = (priority: TaskPriority): string => {
|
||||
return PRIORITY_CONFIG[priority].icon;
|
||||
};
|
||||
|
||||
export const getPriorityColor = (priority: TaskPriority): PriorityConfig['color'] => {
|
||||
export const getPriorityColor = (
|
||||
priority: TaskPriority
|
||||
): PriorityConfig['color'] => {
|
||||
return PRIORITY_CONFIG[priority].color;
|
||||
};
|
||||
|
||||
// Configuration des couleurs HEX pour les priorités (cohérente avec le design)
|
||||
export const PRIORITY_COLOR_MAP = {
|
||||
blue: '#60a5fa', // blue-400 (low priority)
|
||||
yellow: '#fbbf24', // amber-400 (medium priority)
|
||||
purple: '#a78bfa', // violet-400 (high priority)
|
||||
red: '#f87171' // red-400 (urgent priority)
|
||||
blue: '#60a5fa', // blue-400 (low priority)
|
||||
yellow: '#fbbf24', // amber-400 (medium priority)
|
||||
purple: '#a78bfa', // violet-400 (high priority)
|
||||
red: '#f87171', // red-400 (urgent priority)
|
||||
} as const;
|
||||
|
||||
// Couleurs alternatives pour les graphiques et charts
|
||||
export const PRIORITY_CHART_COLORS = {
|
||||
'Faible': '#10b981', // green-500 (plus lisible dans les charts)
|
||||
'Moyenne': '#f59e0b', // amber-500
|
||||
'Élevée': '#8b5cf6', // violet-500
|
||||
'Urgente': '#ef4444', // red-500
|
||||
'Non définie': '#6b7280' // gray-500
|
||||
Faible: '#10b981', // green-500 (plus lisible dans les charts)
|
||||
Moyenne: '#f59e0b', // amber-500
|
||||
Élevée: '#8b5cf6', // violet-500
|
||||
Urgente: '#ef4444', // red-500
|
||||
'Non définie': '#6b7280', // gray-500
|
||||
} as const;
|
||||
|
||||
export const getPriorityColorHex = (color: PriorityConfig['color']): string => {
|
||||
@@ -213,18 +221,22 @@ export const getPriorityColorHex = (color: PriorityConfig['color']): string => {
|
||||
|
||||
// Fonction pour récupérer la couleur d'un chart basée sur le label
|
||||
export const getPriorityChartColor = (priorityLabel: string): string => {
|
||||
return PRIORITY_CHART_COLORS[priorityLabel as keyof typeof PRIORITY_CHART_COLORS] || PRIORITY_CHART_COLORS['Non définie'];
|
||||
return (
|
||||
PRIORITY_CHART_COLORS[
|
||||
priorityLabel as keyof typeof PRIORITY_CHART_COLORS
|
||||
] || PRIORITY_CHART_COLORS['Non définie']
|
||||
);
|
||||
};
|
||||
|
||||
// Configuration des couleurs pour les badges de statut
|
||||
export const STATUS_BADGE_COLORS = {
|
||||
backlog: 'bg-gray-100 text-gray-800',
|
||||
todo: 'bg-gray-100 text-gray-800',
|
||||
todo: 'bg-gray-100 text-gray-800',
|
||||
in_progress: 'bg-orange-100 text-orange-800',
|
||||
freeze: 'bg-purple-100 text-purple-800',
|
||||
done: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
archived: 'bg-gray-100 text-gray-600'
|
||||
archived: 'bg-gray-100 text-gray-600',
|
||||
} as const;
|
||||
|
||||
// Fonction pour récupérer les classes CSS d'un badge de statut
|
||||
@@ -238,29 +250,31 @@ export const DASHBOARD_STAT_COLORS = {
|
||||
color: 'bg-blue-500',
|
||||
textColor: 'text-blue-600',
|
||||
progressColor: 'bg-blue-500',
|
||||
dotColor: 'bg-blue-500'
|
||||
dotColor: 'bg-blue-500',
|
||||
},
|
||||
todo: {
|
||||
color: 'bg-gray-500',
|
||||
color: 'bg-gray-500',
|
||||
textColor: 'text-gray-600',
|
||||
progressColor: 'bg-gray-500',
|
||||
dotColor: 'bg-gray-500'
|
||||
dotColor: 'bg-gray-500',
|
||||
},
|
||||
inProgress: {
|
||||
color: 'bg-orange-500',
|
||||
textColor: 'text-orange-600',
|
||||
textColor: 'text-orange-600',
|
||||
progressColor: 'bg-orange-500',
|
||||
dotColor: 'bg-orange-500'
|
||||
dotColor: 'bg-orange-500',
|
||||
},
|
||||
completed: {
|
||||
color: 'bg-green-500',
|
||||
textColor: 'text-green-600',
|
||||
progressColor: 'bg-green-500',
|
||||
dotColor: 'bg-green-500'
|
||||
}
|
||||
progressColor: 'bg-green-500',
|
||||
dotColor: 'bg-green-500',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Fonction pour récupérer les couleurs d'une stat du dashboard
|
||||
export const getDashboardStatColors = (statType: keyof typeof DASHBOARD_STAT_COLORS) => {
|
||||
export const getDashboardStatColors = (
|
||||
statType: keyof typeof DASHBOARD_STAT_COLORS
|
||||
) => {
|
||||
return DASHBOARD_STAT_COLORS[statType];
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export function generateTagColor(tagName: string): string {
|
||||
for (let i = 0; i < tagName.length; i++) {
|
||||
hash = tagName.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
|
||||
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export function getTagColor(tagName: string, existingColor?: string): string {
|
||||
/**
|
||||
* Génère un tableau de couleurs pour les graphiques en respectant les couleurs des tags
|
||||
*/
|
||||
export function generateChartColors(tags: Array<{ name: string; color?: string }>): string[] {
|
||||
return tags.map(tag => getTagColor(tag.name, tag.color));
|
||||
export function generateChartColors(
|
||||
tags: Array<{ name: string; color?: string }>
|
||||
): string[] {
|
||||
return tags.map((tag) => getTagColor(tag.name, tag.color));
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ export interface JiraConfig {
|
||||
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
|
||||
}
|
||||
|
||||
|
||||
export interface UserPreferences {
|
||||
kanbanFilters: KanbanFilters;
|
||||
viewPreferences: ViewPreferences;
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
// ===== CONFIGURATION DES THÈMES =====
|
||||
|
||||
// Types de thèmes
|
||||
export type Theme = 'light' | 'dark' | 'dracula' | 'monokai' | 'nord' | 'gruvbox' | 'tokyo_night' | 'catppuccin' | 'rose_pine' | 'one_dark' | 'material' | 'solarized';
|
||||
export type Theme =
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| 'dracula'
|
||||
| 'monokai'
|
||||
| 'nord'
|
||||
| 'gruvbox'
|
||||
| 'tokyo_night'
|
||||
| 'catppuccin'
|
||||
| 'rose_pine'
|
||||
| 'one_dark'
|
||||
| 'material'
|
||||
| 'solarized';
|
||||
|
||||
// Configuration des thèmes
|
||||
export const THEME_CONFIG = {
|
||||
// Thème par défaut
|
||||
default: 'dark' as Theme,
|
||||
|
||||
|
||||
// Thème light
|
||||
light: 'light' as Theme,
|
||||
|
||||
|
||||
// Liste de tous les thèmes dark disponibles
|
||||
darkThemes: [
|
||||
'dark',
|
||||
'dracula',
|
||||
'dracula',
|
||||
'monokai',
|
||||
'nord',
|
||||
'gruvbox',
|
||||
@@ -23,14 +35,14 @@ export const THEME_CONFIG = {
|
||||
'rose_pine',
|
||||
'one_dark',
|
||||
'material',
|
||||
'solarized'
|
||||
'solarized',
|
||||
] as Theme[],
|
||||
|
||||
|
||||
// Tous les thèmes disponibles
|
||||
allThemes: [
|
||||
'light',
|
||||
'dark',
|
||||
'dracula',
|
||||
'dracula',
|
||||
'monokai',
|
||||
'nord',
|
||||
'gruvbox',
|
||||
@@ -39,24 +51,63 @@ export const THEME_CONFIG = {
|
||||
'rose_pine',
|
||||
'one_dark',
|
||||
'material',
|
||||
'solarized'
|
||||
] as Theme[]
|
||||
'solarized',
|
||||
] as Theme[],
|
||||
} as const;
|
||||
|
||||
// Métadonnées des thèmes pour l'affichage
|
||||
export const THEME_METADATA: Record<Theme, { name: string; description: string; icon: string }> = {
|
||||
export const THEME_METADATA: Record<
|
||||
Theme,
|
||||
{ name: string; description: string; icon: string }
|
||||
> = {
|
||||
light: { name: 'Light', description: 'Thème clair par défaut', icon: '☀️' },
|
||||
dark: { name: 'Dark', description: 'Thème sombre classique', icon: '🌙' },
|
||||
dracula: { name: 'Dracula', description: 'Inspiré du thème Dracula', icon: '🧛' },
|
||||
monokai: { name: 'Monokai', description: 'Inspiré du thème Monokai', icon: '🎨' },
|
||||
dracula: {
|
||||
name: 'Dracula',
|
||||
description: 'Inspiré du thème Dracula',
|
||||
icon: '🧛',
|
||||
},
|
||||
monokai: {
|
||||
name: 'Monokai',
|
||||
description: 'Inspiré du thème Monokai',
|
||||
icon: '🎨',
|
||||
},
|
||||
nord: { name: 'Nord', description: 'Palette Nord arctique', icon: '❄️' },
|
||||
gruvbox: { name: 'Gruvbox', description: 'Palette Gruvbox retro', icon: '🎭' },
|
||||
tokyo_night: { name: 'Tokyo Night', description: 'Nuit tokyoïte', icon: '🌃' },
|
||||
catppuccin: { name: 'Catppuccin', description: 'Palette pastel douce', icon: '🐱' },
|
||||
rose_pine: { name: 'Rose Pine', description: 'Palette rose et pin', icon: '🌹' },
|
||||
one_dark: { name: 'One Dark', description: 'Inspiré d\'Atom One Dark', icon: '🌑' },
|
||||
material: { name: 'Material', description: 'Inspiré de Material Design', icon: '📱' },
|
||||
solarized: { name: 'Solarized', description: 'Palette Solarized', icon: '💊' }
|
||||
gruvbox: {
|
||||
name: 'Gruvbox',
|
||||
description: 'Palette Gruvbox retro',
|
||||
icon: '🎭',
|
||||
},
|
||||
tokyo_night: {
|
||||
name: 'Tokyo Night',
|
||||
description: 'Nuit tokyoïte',
|
||||
icon: '🌃',
|
||||
},
|
||||
catppuccin: {
|
||||
name: 'Catppuccin',
|
||||
description: 'Palette pastel douce',
|
||||
icon: '🐱',
|
||||
},
|
||||
rose_pine: {
|
||||
name: 'Rose Pine',
|
||||
description: 'Palette rose et pin',
|
||||
icon: '🌹',
|
||||
},
|
||||
one_dark: {
|
||||
name: 'One Dark',
|
||||
description: "Inspiré d'Atom One Dark",
|
||||
icon: '🌑',
|
||||
},
|
||||
material: {
|
||||
name: 'Material',
|
||||
description: 'Inspiré de Material Design',
|
||||
icon: '📱',
|
||||
},
|
||||
solarized: {
|
||||
name: 'Solarized',
|
||||
description: 'Palette Solarized',
|
||||
icon: '💊',
|
||||
},
|
||||
};
|
||||
|
||||
// Fonctions utilitaires pour les thèmes
|
||||
@@ -74,7 +125,13 @@ export const isDarkTheme = (theme: Theme): boolean => {
|
||||
};
|
||||
|
||||
export const getThemeMetadata = (theme: Theme) => {
|
||||
return THEME_METADATA[theme] || { name: theme, description: 'Thème personnalisé', icon: '🎨' };
|
||||
return (
|
||||
THEME_METADATA[theme] || {
|
||||
name: theme,
|
||||
description: 'Thème personnalisé',
|
||||
icon: '🎨',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ===== CONFIGURATION DES BACKGROUNDS =====
|
||||
@@ -85,118 +142,134 @@ export const PRESET_BACKGROUNDS = [
|
||||
id: 'none',
|
||||
name: 'Aucun fond',
|
||||
description: 'Fond par défaut du système',
|
||||
preview: 'var(--background)'
|
||||
preview: 'var(--background)',
|
||||
},
|
||||
{
|
||||
id: 'theme-subtle',
|
||||
name: 'Dégradé subtil',
|
||||
description: 'Dégradé très léger et discret',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--primary) 8%, var(--background)) 0%, color-mix(in srgb, var(--accent) 5%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--primary) 8%, var(--background)) 0%, color-mix(in srgb, var(--accent) 5%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-primary',
|
||||
name: 'Dégradé primaire',
|
||||
description: 'Dégradé avec la couleur primaire',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--primary) 25%, var(--background)) 0%, color-mix(in srgb, var(--primary) 15%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--primary) 25%, var(--background)) 0%, color-mix(in srgb, var(--primary) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-accent',
|
||||
name: 'Dégradé accent',
|
||||
description: 'Dégradé avec la couleur accent',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--accent) 25%, var(--background)) 0%, color-mix(in srgb, var(--accent) 15%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--accent) 25%, var(--background)) 0%, color-mix(in srgb, var(--accent) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-success',
|
||||
name: 'Dégradé vert',
|
||||
description: 'Dégradé avec la couleur de succès',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--success) 25%, var(--background)) 0%, color-mix(in srgb, var(--success) 15%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--success) 25%, var(--background)) 0%, color-mix(in srgb, var(--success) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-purple',
|
||||
name: 'Dégradé violet',
|
||||
description: 'Dégradé avec la couleur violette',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--purple) 25%, var(--background)) 0%, color-mix(in srgb, var(--purple) 15%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--purple) 25%, var(--background)) 0%, color-mix(in srgb, var(--purple) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-diagonal',
|
||||
name: 'Dégradé diagonal',
|
||||
description: 'Dégradé diagonal dynamique',
|
||||
preview: 'linear-gradient(45deg, color-mix(in srgb, var(--primary) 20%, var(--background)) 0%, color-mix(in srgb, var(--accent) 20%, var(--background)) 50%, color-mix(in srgb, var(--success) 20%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(45deg, color-mix(in srgb, var(--primary) 20%, var(--background)) 0%, color-mix(in srgb, var(--accent) 20%, var(--background)) 50%, color-mix(in srgb, var(--success) 20%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-radial',
|
||||
name: 'Dégradé radial',
|
||||
description: 'Dégradé radial depuis le centre',
|
||||
preview: 'radial-gradient(circle, color-mix(in srgb, var(--primary) 20%, var(--background)) 0%, color-mix(in srgb, var(--accent) 10%, var(--background)) 70%, var(--background) 100%)'
|
||||
preview:
|
||||
'radial-gradient(circle, color-mix(in srgb, var(--primary) 20%, var(--background)) 0%, color-mix(in srgb, var(--accent) 10%, var(--background)) 70%, var(--background) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-sunset',
|
||||
name: 'Dégradé coucher',
|
||||
description: 'Dégradé inspiré du coucher de soleil',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--accent) 25%, var(--background)) 0%, color-mix(in srgb, var(--purple) 20%, var(--background)) 50%, color-mix(in srgb, var(--primary) 15%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--accent) 25%, var(--background)) 0%, color-mix(in srgb, var(--purple) 20%, var(--background)) 50%, color-mix(in srgb, var(--primary) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-ocean',
|
||||
name: 'Dégradé océan',
|
||||
description: 'Dégradé inspiré de l\'océan',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--primary) 25%, var(--background)) 0%, color-mix(in srgb, var(--blue) 20%, var(--background)) 50%, color-mix(in srgb, var(--success) 15%, var(--background)) 100%)'
|
||||
description: "Dégradé inspiré de l'océan",
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--primary) 25%, var(--background)) 0%, color-mix(in srgb, var(--blue) 20%, var(--background)) 50%, color-mix(in srgb, var(--success) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-forest',
|
||||
name: 'Dégradé forêt',
|
||||
description: 'Dégradé inspiré de la forêt',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--success) 25%, var(--background)) 0%, color-mix(in srgb, var(--green) 20%, var(--background)) 50%, color-mix(in srgb, var(--accent) 15%, var(--background)) 100%)'
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--success) 25%, var(--background)) 0%, color-mix(in srgb, var(--green) 20%, var(--background)) 50%, color-mix(in srgb, var(--accent) 15%, var(--background)) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'theme-galaxy',
|
||||
name: 'Dégradé galaxie',
|
||||
description: 'Dégradé inspiré de la galaxie',
|
||||
preview: 'linear-gradient(135deg, color-mix(in srgb, var(--purple) 25%, var(--background)) 0%, color-mix(in srgb, var(--blue) 20%, var(--background)) 100%)'
|
||||
}
|
||||
preview:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--purple) 25%, var(--background)) 0%, color-mix(in srgb, var(--blue) 20%, var(--background)) 100%)',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Liste des backgrounds pour le cycle (IDs seulement)
|
||||
export const BACKGROUND_CYCLE = PRESET_BACKGROUNDS.map(bg => bg.id);
|
||||
export const BACKGROUND_CYCLE = PRESET_BACKGROUNDS.map((bg) => bg.id);
|
||||
|
||||
// Types pour les backgrounds
|
||||
export type BackgroundId = typeof PRESET_BACKGROUNDS[number]['id'];
|
||||
export type BackgroundId = (typeof PRESET_BACKGROUNDS)[number]['id'];
|
||||
|
||||
// Fonctions utilitaires pour les backgrounds
|
||||
export const getBackgroundById = (id: BackgroundId) => {
|
||||
return PRESET_BACKGROUNDS.find(bg => bg.id === id);
|
||||
return PRESET_BACKGROUNDS.find((bg) => bg.id === id);
|
||||
};
|
||||
|
||||
export const isPresetBackground = (id: string): id is BackgroundId => {
|
||||
return PRESET_BACKGROUNDS.some(bg => bg.id === id);
|
||||
return PRESET_BACKGROUNDS.some((bg) => bg.id === id);
|
||||
};
|
||||
|
||||
export const getNextBackground = (currentBackground: string, customImages: string[] = []): string => {
|
||||
export const getNextBackground = (
|
||||
currentBackground: string,
|
||||
customImages: string[] = []
|
||||
): string => {
|
||||
const allBackgrounds = [...BACKGROUND_CYCLE];
|
||||
|
||||
|
||||
// Ajouter toutes les images personnalisées
|
||||
customImages.forEach(url => {
|
||||
if (!allBackgrounds.includes(url as typeof BACKGROUND_CYCLE[number])) {
|
||||
allBackgrounds.push(url as typeof BACKGROUND_CYCLE[number]);
|
||||
customImages.forEach((url) => {
|
||||
if (!allBackgrounds.includes(url as (typeof BACKGROUND_CYCLE)[number])) {
|
||||
allBackgrounds.push(url as (typeof BACKGROUND_CYCLE)[number]);
|
||||
}
|
||||
});
|
||||
|
||||
const currentIndex = allBackgrounds.findIndex(bg => bg === currentBackground);
|
||||
|
||||
const currentIndex = allBackgrounds.findIndex(
|
||||
(bg) => bg === currentBackground
|
||||
);
|
||||
|
||||
// Si on ne trouve pas l'index, c'est qu'on est sur "none" (undefined)
|
||||
let actualCurrentIndex = currentIndex;
|
||||
if (currentIndex === -1) {
|
||||
actualCurrentIndex = -1; // On est sur "none", on va commencer à l'index 0
|
||||
}
|
||||
|
||||
|
||||
const nextIndex = (actualCurrentIndex + 1) % allBackgrounds.length;
|
||||
const nextBackground = allBackgrounds[nextIndex];
|
||||
|
||||
|
||||
// Si on est sur "none" (undefined) et qu'on va vers "none", on va vers le suivant
|
||||
if (currentBackground === undefined && nextBackground === 'none') {
|
||||
const nextNextIndex = (nextIndex + 1) % allBackgrounds.length;
|
||||
return allBackgrounds[nextNextIndex];
|
||||
}
|
||||
|
||||
|
||||
return nextBackground;
|
||||
};
|
||||
|
||||
@@ -204,7 +277,7 @@ export const getNextBackground = (currentBackground: string, customImages: strin
|
||||
|
||||
// Mapping des noms de backgrounds pour l'affichage
|
||||
export const BACKGROUND_NAMES: Record<string, string> = {
|
||||
'none': 'Aucun fond',
|
||||
none: 'Aucun fond',
|
||||
'theme-subtle': 'Dégradé subtil',
|
||||
'theme-primary': 'Dégradé primaire',
|
||||
'theme-accent': 'Dégradé accent',
|
||||
@@ -215,23 +288,23 @@ export const BACKGROUND_NAMES: Record<string, string> = {
|
||||
'theme-sunset': 'Dégradé coucher',
|
||||
'theme-ocean': 'Dégradé océan',
|
||||
'theme-forest': 'Dégradé forêt',
|
||||
'theme-galaxy': 'Dégradé galaxie'
|
||||
'theme-galaxy': 'Dégradé galaxie',
|
||||
};
|
||||
|
||||
// Mapping des noms de thèmes pour l'affichage
|
||||
export const THEME_NAMES: Record<Theme, string> = {
|
||||
'light': 'Thème clair',
|
||||
'dark': 'Thème sombre',
|
||||
'dracula': 'Thème Dracula',
|
||||
'monokai': 'Thème Monokai',
|
||||
'nord': 'Thème Nord',
|
||||
'gruvbox': 'Thème Gruvbox',
|
||||
'tokyo_night': 'Thème Tokyo Night',
|
||||
'catppuccin': 'Thème Catppuccin',
|
||||
'rose_pine': 'Thème Rose Pine',
|
||||
'one_dark': 'Thème One Dark',
|
||||
'material': 'Thème Material',
|
||||
'solarized': 'Thème Solarized'
|
||||
light: 'Thème clair',
|
||||
dark: 'Thème sombre',
|
||||
dracula: 'Thème Dracula',
|
||||
monokai: 'Thème Monokai',
|
||||
nord: 'Thème Nord',
|
||||
gruvbox: 'Thème Gruvbox',
|
||||
tokyo_night: 'Thème Tokyo Night',
|
||||
catppuccin: 'Thème Catppuccin',
|
||||
rose_pine: 'Thème Rose Pine',
|
||||
one_dark: 'Thème One Dark',
|
||||
material: 'Thème Material',
|
||||
solarized: 'Thème Solarized',
|
||||
};
|
||||
|
||||
// Fonctions utilitaires pour les métadonnées
|
||||
@@ -250,5 +323,5 @@ export const getThemeDescription = (theme: Theme): string => {
|
||||
// Icônes pour les toasts
|
||||
export const TOAST_ICONS = {
|
||||
background: '🎨',
|
||||
theme: '🎭'
|
||||
theme: '🎭',
|
||||
} as const;
|
||||
|
||||
@@ -7,20 +7,20 @@
|
||||
* Calcule le jour de travail précédent selon la logique métier :
|
||||
* - Lundi → Vendredi (au lieu de Dimanche)
|
||||
* - Mardi-Vendredi → jour précédent
|
||||
* - Samedi → Vendredi
|
||||
* - Samedi → Vendredi
|
||||
* - Dimanche → Vendredi
|
||||
*/
|
||||
export function getPreviousWorkday(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const dayOfWeek = result.getDay(); // 0 = Dimanche, 1 = Lundi, ..., 6 = Samedi
|
||||
|
||||
|
||||
switch (dayOfWeek) {
|
||||
case 1: // Lundi → Vendredi précédent
|
||||
result.setDate(result.getDate() - 3);
|
||||
break;
|
||||
case 0: // Dimanche → Vendredi précédent
|
||||
case 0: // Dimanche → Vendredi précédent
|
||||
result.setDate(result.getDate() - 2);
|
||||
break;
|
||||
case 6: // Samedi → Vendredi précédent
|
||||
@@ -30,7 +30,7 @@ export function getPreviousWorkday(date: Date): Date {
|
||||
result.setDate(result.getDate() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -38,15 +38,15 @@ export function getPreviousWorkday(date: Date): Date {
|
||||
* Calcule le jour de travail suivant selon la logique métier :
|
||||
* - Vendredi → Lundi suivant
|
||||
* - Samedi → Lundi suivant
|
||||
* - Dimanche → Lundi suivant
|
||||
* - Dimanche → Lundi suivant
|
||||
* - Lundi-Jeudi → jour suivant
|
||||
*/
|
||||
export function getNextWorkday(date: Date): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const dayOfWeek = result.getDay(); // 0 = Dimanche, 1 = Lundi, ..., 6 = Samedi
|
||||
|
||||
|
||||
switch (dayOfWeek) {
|
||||
case 5: // Vendredi → Lundi suivant
|
||||
result.setDate(result.getDate() + 3);
|
||||
@@ -61,7 +61,7 @@ export function getNextWorkday(date: Date): Date {
|
||||
result.setDate(result.getDate() + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,14 @@ export function isWorkday(date: Date): boolean {
|
||||
* Retourne le nom du jour en français
|
||||
*/
|
||||
export function getDayName(date: Date): string {
|
||||
const days = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||
const days = [
|
||||
'Dimanche',
|
||||
'Lundi',
|
||||
'Mardi',
|
||||
'Mercredi',
|
||||
'Jeudi',
|
||||
'Vendredi',
|
||||
'Samedi',
|
||||
];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user