feat: refactor Daily components and enhance UI integration
- Replaced `DailyCalendar` with a new `Calendar` component for improved functionality and consistency. - Introduced `AlertBanner` to replace `DeadlineReminder`, providing a more flexible way to display urgent tasks. - Updated `DailyAddForm` to use new options for task types, enhancing user experience when adding tasks. - Removed unused state and components, streamlining the DailyPageClient for better performance and maintainability. - Enhanced `DailySection` to utilize new `CheckboxItem` format for better integration with the UI. - Cleaned up imports and improved overall structure for better readability.
This commit is contained in:
87
src/components/ui/AchievementCard.tsx
Normal file
87
src/components/ui/AchievementCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { PriorityBadge } from '@/components/ui/PriorityBadge';
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
export interface AchievementData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
impact: 'low' | 'medium' | 'high';
|
||||
completedAt: Date;
|
||||
tags?: string[];
|
||||
todosCount?: number;
|
||||
}
|
||||
|
||||
interface AchievementCardProps {
|
||||
achievement: AchievementData;
|
||||
availableTags: (Tag & { usage: number })[];
|
||||
index: number;
|
||||
showDescription?: boolean;
|
||||
maxTags?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AchievementCard({
|
||||
achievement,
|
||||
availableTags,
|
||||
index,
|
||||
showDescription = true,
|
||||
maxTags = 2,
|
||||
className = ''
|
||||
}: AchievementCardProps) {
|
||||
return (
|
||||
<div className={`relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group ${className}`}>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center text-[var(--success)] bg-[var(--success)]/15 border border-[var(--success)]/25">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<PriorityBadge priority={achievement.impact} />
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(achievement.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{achievement.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{achievement.tags && achievement.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={achievement.tags}
|
||||
availableTags={availableTags as Tag[]}
|
||||
size="sm"
|
||||
maxTags={maxTags}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{showDescription && achievement.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{achievement.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{achievement.todosCount && achievement.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{achievement.todosCount} todo{achievement.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,49 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
'use client';
|
||||
|
||||
interface AlertProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'destructive' | 'warning' | 'info';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface AlertProps {
|
||||
variant?: 'default' | 'destructive' | 'success' | 'warning' | 'info';
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]',
|
||||
success: 'bg-[color-mix(in_srgb,var(--success)_10%,transparent)] text-[var(--success)] border border-[color-mix(in_srgb,var(--success)_20%,var(--border))]',
|
||||
destructive: 'bg-[color-mix(in_srgb,var(--destructive)_10%,transparent)] text-[var(--destructive)] border border-[color-mix(in_srgb,var(--destructive)_20%,var(--border))]',
|
||||
warning: 'bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] text-[var(--accent)] border border-[color-mix(in_srgb,var(--accent)_20%,var(--border))]',
|
||||
info: 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)] text-[var(--primary)] border border-[color-mix(in_srgb,var(--primary)_20%,var(--border))]'
|
||||
};
|
||||
export function Alert({ variant = 'default', className = '', children }: AlertProps) {
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'destructive':
|
||||
return 'outline-card-red';
|
||||
case 'success':
|
||||
return 'outline-card-green';
|
||||
case 'info':
|
||||
return 'outline-card-blue';
|
||||
case 'warning':
|
||||
return 'outline-card-yellow';
|
||||
case 'default':
|
||||
default:
|
||||
return 'outline-card-gray';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative w-full rounded-lg border p-4',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<Card className={`${getVariantClasses()} ${className}`}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.displayName = 'Alert';
|
||||
export function AlertTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<h3 className={`text-sm font-semibold mb-2 ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
const AlertTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
export function AlertDescription({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`text-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/components/ui/AlertBanner.tsx
Normal file
137
src/components/ui/AlertBanner.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
export interface AlertItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
urgency?: 'low' | 'medium' | 'high' | 'critical';
|
||||
source?: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
interface AlertProps {
|
||||
title: string;
|
||||
items: AlertItem[];
|
||||
icon?: string;
|
||||
variant?: 'info' | 'warning' | 'error' | 'success';
|
||||
className?: string;
|
||||
onItemClick?: (item: AlertItem) => void;
|
||||
}
|
||||
|
||||
export function AlertBanner({
|
||||
title,
|
||||
items,
|
||||
icon = '⚠️',
|
||||
variant = 'warning',
|
||||
className = '',
|
||||
onItemClick
|
||||
}: AlertProps) {
|
||||
// Ne rien afficher si pas d'éléments
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return 'outline-card-red';
|
||||
case 'success':
|
||||
return 'outline-card-green';
|
||||
case 'info':
|
||||
return 'outline-card-blue';
|
||||
case 'warning':
|
||||
default:
|
||||
return 'outline-card-yellow';
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyColor = (urgency?: string) => {
|
||||
switch (urgency) {
|
||||
case 'critical':
|
||||
return 'text-red-600';
|
||||
case 'high':
|
||||
return 'text-orange-600';
|
||||
case 'medium':
|
||||
return 'text-yellow-600';
|
||||
case 'low':
|
||||
return 'text-green-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceIcon = (source?: string) => {
|
||||
switch (source) {
|
||||
case 'jira':
|
||||
return '🔗';
|
||||
case 'reminder':
|
||||
return '📱';
|
||||
case 'tfs':
|
||||
return '🔧';
|
||||
default:
|
||||
return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`mb-2 ${className}`}>
|
||||
<div className={`${getVariantClasses()} p-4`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">{icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold mb-2">
|
||||
{title} ({items.length})
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
onItemClick ? 'hover:bg-[var(--card)]/50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--primary) 15%, var(--card))',
|
||||
borderColor: 'color-mix(in srgb, var(--primary) 35%, var(--border))',
|
||||
border: '1px solid',
|
||||
color: 'color-mix(in srgb, var(--primary) 85%, var(--foreground))'
|
||||
}}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
title={item.title}
|
||||
>
|
||||
<span>{item.icon || getSourceIcon(item.source)}</span>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.metadata && (
|
||||
<span className="text-[10px] opacity-75">
|
||||
({item.metadata})
|
||||
</span>
|
||||
)}
|
||||
{item.urgency && (
|
||||
<span className={`text-[10px] ${getUrgencyColor(item.urgency)}`}>
|
||||
{item.urgency === 'critical' ? '🔴' :
|
||||
item.urgency === 'high' ? '🟠' :
|
||||
item.urgency === 'medium' ? '🟡' : '🟢'}
|
||||
</span>
|
||||
)}
|
||||
{index < items.length - 1 && (
|
||||
<span className="opacity-50">•</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="mt-2 text-xs opacity-75">
|
||||
Cliquez sur un élément pour plus de détails
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
220
src/components/ui/Calendar.tsx
Normal file
220
src/components/ui/Calendar.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatDateForAPI, createDate, getToday } from '@/lib/date-utils';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface CalendarProps {
|
||||
currentDate: Date;
|
||||
onDateSelect: (date: Date) => void;
|
||||
markedDates?: string[]; // Liste des dates marquées (format YYYY-MM-DD)
|
||||
showTodayButton?: boolean;
|
||||
showLegend?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Calendar({
|
||||
currentDate,
|
||||
onDateSelect,
|
||||
markedDates = [],
|
||||
showTodayButton = true,
|
||||
showLegend = true,
|
||||
className = ''
|
||||
}: CalendarProps) {
|
||||
const [viewDate, setViewDate] = useState(createDate(currentDate));
|
||||
|
||||
// Formatage des dates pour comparaison (éviter le décalage timezone)
|
||||
const formatDateKey = (date: Date) => {
|
||||
return formatDateForAPI(date);
|
||||
};
|
||||
|
||||
const currentDateKey = formatDateKey(currentDate);
|
||||
|
||||
// Navigation mois
|
||||
const goToPreviousMonth = () => {
|
||||
const newDate = createDate(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
const newDate = createDate(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
const today = getToday();
|
||||
setViewDate(today);
|
||||
onDateSelect(today);
|
||||
};
|
||||
|
||||
// Obtenir les jours du mois
|
||||
const getDaysInMonth = () => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
// Premier jour du mois
|
||||
const firstDay = new Date(year, month, 1);
|
||||
// Dernier jour du mois
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Premier lundi de la semaine contenant le premier jour
|
||||
const startDate = createDate(firstDay);
|
||||
const dayOfWeek = firstDay.getDay();
|
||||
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Lundi = 0
|
||||
startDate.setDate(firstDay.getDate() - daysToSubtract);
|
||||
|
||||
// Générer toutes les dates du calendrier (6 semaines)
|
||||
const days = [];
|
||||
const currentDay = createDate(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
// 6 semaines × 7 jours
|
||||
days.push(createDate(currentDay));
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
return { days, firstDay, lastDay };
|
||||
};
|
||||
|
||||
const { days } = getDaysInMonth();
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
onDateSelect(date);
|
||||
};
|
||||
|
||||
const isTodayDate = (date: Date) => {
|
||||
const today = getToday();
|
||||
return formatDateKey(date) === formatDateKey(today);
|
||||
};
|
||||
|
||||
const isCurrentMonth = (date: Date) => {
|
||||
return date.getMonth() === viewDate.getMonth();
|
||||
};
|
||||
|
||||
const hasMarkedDate = (date: Date) => {
|
||||
return markedDates.includes(formatDateKey(date));
|
||||
};
|
||||
|
||||
const isSelected = (date: Date) => {
|
||||
return formatDateKey(date) === currentDateKey;
|
||||
};
|
||||
|
||||
const formatMonthYear = () => {
|
||||
return format(viewDate, 'MMMM yyyy', { locale: fr });
|
||||
};
|
||||
|
||||
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
|
||||
return (
|
||||
<Card className={`p-4 ${className}`}>
|
||||
{/* Header avec navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
onClick={goToPreviousMonth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--foreground)]"
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)] capitalize">
|
||||
{formatMonthYear()}
|
||||
</h3>
|
||||
|
||||
<Button
|
||||
onClick={goToNextMonth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--foreground)]"
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bouton Aujourd'hui */}
|
||||
{showTodayButton && (
|
||||
<div className="mb-4 text-center">
|
||||
<Button onClick={goToToday} variant="primary" size="sm">
|
||||
Aujourd'hui
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jours de la semaine */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-[var(--muted-foreground)] p-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille du calendrier */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((date, index) => {
|
||||
const isCurrentMonthDay = isCurrentMonth(date);
|
||||
const isTodayDay = isTodayDate(date);
|
||||
const hasMarked = hasMarkedDate(date);
|
||||
const isSelectedDay = isSelected(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDateClick(date)}
|
||||
className={`
|
||||
relative p-2 text-sm rounded transition-all hover:bg-[var(--muted)]/50
|
||||
${
|
||||
isCurrentMonthDay
|
||||
? 'text-[var(--foreground)]'
|
||||
: 'text-[var(--muted-foreground)]'
|
||||
}
|
||||
${
|
||||
isTodayDay
|
||||
? 'bg-[var(--primary)]/20 border border-[var(--primary)]'
|
||||
: ''
|
||||
}
|
||||
${isSelectedDay ? 'bg-[var(--primary)] text-white' : ''}
|
||||
${hasMarked ? 'font-bold' : ''}
|
||||
`}
|
||||
>
|
||||
{date.getDate()}
|
||||
|
||||
{/* Indicateur de date marquée */}
|
||||
{hasMarked && (
|
||||
<div
|
||||
className={`
|
||||
absolute bottom-1 right-1 w-2 h-2 rounded-full
|
||||
${isSelectedDay ? 'bg-white' : 'bg-[var(--primary)]'}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
{showLegend && (
|
||||
<div className="mt-4 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--primary)]"></div>
|
||||
<span>Jour avec des éléments</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border border-[var(--primary)] bg-[var(--primary)]/20"></div>
|
||||
<span>Aujourd'hui</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
90
src/components/ui/ChallengeCard.tsx
Normal file
90
src/components/ui/ChallengeCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { PriorityBadge } from '@/components/ui/PriorityBadge';
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
export interface ChallengeData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
deadline?: Date;
|
||||
tags?: string[];
|
||||
todosCount?: number;
|
||||
blockers?: string[];
|
||||
}
|
||||
|
||||
interface ChallengeCardProps {
|
||||
challenge: ChallengeData;
|
||||
availableTags: (Tag & { usage: number })[];
|
||||
index: number;
|
||||
showDescription?: boolean;
|
||||
maxTags?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChallengeCard({
|
||||
challenge,
|
||||
availableTags,
|
||||
index,
|
||||
showDescription = true,
|
||||
maxTags = 2,
|
||||
className = ''
|
||||
}: ChallengeCardProps) {
|
||||
return (
|
||||
<div className={`relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group ${className}`}>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center text-[var(--accent)] bg-[var(--accent)]/15 border border-[var(--accent)]/25">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<PriorityBadge priority={challenge.priority} />
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags as Tag[]}
|
||||
size="sm"
|
||||
maxTags={maxTags}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{showDescription && challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount && challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/components/ui/CheckboxItem.tsx
Normal file
193
src/components/ui/CheckboxItem.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
export interface CheckboxItemData {
|
||||
id: string;
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
type?: 'task' | 'meeting' | string;
|
||||
taskId?: string;
|
||||
task?: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckboxItemProps {
|
||||
item: CheckboxItemData;
|
||||
onToggle: (itemId: string) => Promise<void>;
|
||||
onUpdate: (itemId: string, text: string, type?: string, taskId?: string) => Promise<void>;
|
||||
onDelete: (itemId: string) => Promise<void>;
|
||||
saving?: boolean;
|
||||
showTypeIndicator?: boolean;
|
||||
showTaskLink?: boolean;
|
||||
showEditButton?: boolean;
|
||||
showDeleteButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckboxItem({
|
||||
item,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
saving = false,
|
||||
showTaskLink = true,
|
||||
showEditButton = true,
|
||||
showDeleteButton = true,
|
||||
className = ''
|
||||
}: CheckboxItemProps) {
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditingText, setInlineEditingText] = useState('');
|
||||
const [optimisticChecked, setOptimisticChecked] = useState<boolean | null>(null);
|
||||
|
||||
// État optimiste local pour une réponse immédiate
|
||||
const isChecked = optimisticChecked !== null ? optimisticChecked : item.isChecked;
|
||||
|
||||
// Synchroniser l'état optimiste avec les changements externes
|
||||
useEffect(() => {
|
||||
if (optimisticChecked !== null && optimisticChecked === item.isChecked) {
|
||||
// L'état serveur a été mis à jour, on peut reset l'optimiste
|
||||
setOptimisticChecked(null);
|
||||
}
|
||||
}, [item.isChecked, optimisticChecked]);
|
||||
|
||||
// Handler optimiste pour le toggle
|
||||
const handleOptimisticToggle = async () => {
|
||||
const newCheckedState = !isChecked;
|
||||
|
||||
// Mise à jour optimiste immédiate
|
||||
setOptimisticChecked(newCheckedState);
|
||||
|
||||
try {
|
||||
await onToggle(item.id);
|
||||
// Reset l'état optimiste après succès
|
||||
setOptimisticChecked(null);
|
||||
} catch (error) {
|
||||
// Rollback en cas d'erreur
|
||||
setOptimisticChecked(null);
|
||||
console.error('Erreur lors du toggle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Édition inline simple
|
||||
const handleStartInlineEdit = () => {
|
||||
setInlineEditingId(item.id);
|
||||
setInlineEditingText(item.text);
|
||||
};
|
||||
|
||||
const handleSaveInlineEdit = async () => {
|
||||
if (!inlineEditingText.trim()) return;
|
||||
|
||||
try {
|
||||
await onUpdate(item.id, inlineEditingText.trim(), item.type, item.taskId);
|
||||
setInlineEditingId(null);
|
||||
setInlineEditingText('');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la modification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelInlineEdit = () => {
|
||||
setInlineEditingId(null);
|
||||
setInlineEditingText('');
|
||||
};
|
||||
|
||||
const handleInlineEditKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveInlineEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelInlineEdit();
|
||||
}
|
||||
};
|
||||
|
||||
// Obtenir la couleur de bordure selon le type
|
||||
const getTypeBorderColor = () => {
|
||||
if (item.type === 'meeting') return 'border-l-blue-500';
|
||||
return 'border-l-green-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group border-l-4 ${getTypeBorderColor()} border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)] ${className}`}>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={handleOptimisticToggle}
|
||||
disabled={saving}
|
||||
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
{inlineEditingId === item.id ? (
|
||||
<Input
|
||||
value={inlineEditingText}
|
||||
onChange={(e) => setInlineEditingText(e.target.value)}
|
||||
onKeyDown={handleInlineEditKeyPress}
|
||||
onBlur={handleSaveInlineEdit}
|
||||
autoFocus
|
||||
className="flex-1 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{/* Texte cliquable pour édition inline */}
|
||||
<span
|
||||
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||
item.isChecked
|
||||
? 'line-through text-[var(--muted-foreground)]'
|
||||
: 'text-[var(--foreground)]'
|
||||
}`}
|
||||
onClick={handleStartInlineEdit}
|
||||
title="Cliquer pour éditer le texte"
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
|
||||
{/* Icône d'édition avancée */}
|
||||
{showEditButton && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Pour l'instant, on utilise l'édition inline
|
||||
// Plus tard, on pourra ajouter une modal d'édition avancée
|
||||
handleStartInlineEdit();
|
||||
}}
|
||||
disabled={saving}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--muted)]/50 hover:bg-[var(--muted)] border border-[var(--border)]/30 hover:border-[var(--border)] flex items-center justify-center transition-all duration-200 text-[var(--foreground)] text-xs"
|
||||
title="Éditer le texte"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lien vers la tâche si liée */}
|
||||
{showTaskLink && item.task && (
|
||||
<Link
|
||||
href={`/kanban?taskId=${item.task.id}`}
|
||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono truncate max-w-[100px]"
|
||||
title={`Tâche: ${item.task.title}`}
|
||||
>
|
||||
{item.task.title}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Bouton de suppression */}
|
||||
{showDeleteButton && (
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
disabled={saving}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] text-xs"
|
||||
title="Supprimer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
src/components/ui/CollapsibleSection.tsx
Normal file
224
src/components/ui/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface CollapsibleItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metadata?: string;
|
||||
isChecked?: boolean;
|
||||
isArchived?: boolean;
|
||||
icon?: string;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'destructive';
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
items: CollapsibleItem[];
|
||||
icon?: string;
|
||||
defaultCollapsed?: boolean;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
filters?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
onChange: (value: string) => void;
|
||||
}>;
|
||||
onRefresh?: () => void;
|
||||
onItemToggle?: (itemId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
items,
|
||||
icon = '📋',
|
||||
defaultCollapsed = false,
|
||||
loading = false,
|
||||
emptyMessage = 'Aucun élément',
|
||||
filters = [],
|
||||
onRefresh,
|
||||
onItemToggle,
|
||||
className = ''
|
||||
}: CollapsibleSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
|
||||
const handleItemToggle = (itemId: string) => {
|
||||
onItemToggle?.(itemId);
|
||||
};
|
||||
|
||||
const getItemClasses = (item: CollapsibleItem) => {
|
||||
let classes = 'flex items-center gap-3 p-3 rounded-lg border border-[var(--border)]';
|
||||
|
||||
if (item.isArchived) {
|
||||
classes += ' opacity-60 bg-[var(--muted)]/20';
|
||||
} else {
|
||||
classes += ' bg-[var(--card)]';
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
const getCheckboxClasses = (item: CollapsibleItem) => {
|
||||
let classes = 'w-5 h-5 rounded border-2 flex items-center justify-center transition-colors';
|
||||
|
||||
if (item.isArchived) {
|
||||
classes += ' border-[var(--muted)] cursor-not-allowed';
|
||||
} else {
|
||||
classes += ' border-[var(--border)] hover:border-[var(--primary)]';
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
const getActionClasses = (action: NonNullable<CollapsibleItem['actions']>[0]) => {
|
||||
let classes = 'text-xs px-2 py-1';
|
||||
|
||||
switch (action.variant) {
|
||||
case 'destructive':
|
||||
classes += ' text-[var(--destructive)] hover:text-[var(--destructive)]';
|
||||
break;
|
||||
case 'primary':
|
||||
classes += ' text-[var(--primary)] hover:text-[var(--primary)]';
|
||||
break;
|
||||
default:
|
||||
classes += ' text-[var(--foreground)]';
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`mt-6 ${className}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}>
|
||||
▶️
|
||||
</span>
|
||||
{icon} {title}
|
||||
{items.length > 0 && (
|
||||
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filtres */}
|
||||
{filters.map((filter, index) => (
|
||||
<select
|
||||
key={index}
|
||||
value={filter.value}
|
||||
onChange={(e) => filter.onChange(e.target.value)}
|
||||
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||
>
|
||||
{filter.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
|
||||
{/* Bouton refresh */}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '🔄' : '↻'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||
Chargement...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||
🎉 {emptyMessage} ! Excellent travail.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={getItemClasses(item)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
{item.isChecked !== undefined && (
|
||||
<button
|
||||
onClick={() => handleItemToggle(item.id)}
|
||||
disabled={item.isArchived}
|
||||
className={getCheckboxClasses(item)}
|
||||
>
|
||||
{item.isChecked && <span className="text-[var(--primary)]">✓</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
<span className={`text-sm font-medium ${item.isArchived ? 'line-through' : ''}`}>
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
{(item.subtitle || item.metadata) && (
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
{item.subtitle && <span>{item.subtitle}</span>}
|
||||
{item.metadata && <span>{item.metadata}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{item.actions && (
|
||||
<div className="flex items-center gap-1">
|
||||
{item.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
title={action.label}
|
||||
className={getActionClasses(action)}
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
157
src/components/ui/DailyAddForm.tsx
Normal file
157
src/components/ui/DailyAddForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
export interface AddFormOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface AddFormProps {
|
||||
onAdd: (text: string, option?: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
options?: AddFormOption[];
|
||||
defaultOption?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DailyAddForm({
|
||||
onAdd,
|
||||
disabled = false,
|
||||
placeholder = "Ajouter un élément...",
|
||||
options = [],
|
||||
defaultOption,
|
||||
className = ''
|
||||
}: AddFormProps) {
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [selectedOption, setSelectedOption] = useState<string>(defaultOption || (options.length > 0 ? options[0].value : ''));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!newItemText.trim()) return;
|
||||
|
||||
const text = newItemText.trim();
|
||||
|
||||
// Vider et refocus IMMÉDIATEMENT pour l'UX optimiste
|
||||
setNewItemText('');
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lancer l'ajout en arrière-plan (fire and forget)
|
||||
onAdd(text, selectedOption).catch(error => {
|
||||
console.error('Erreur lors de l\'ajout:', error);
|
||||
// En cas d'erreur, on pourrait restaurer le texte
|
||||
// setNewItemText(text);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddItem();
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (placeholder !== "Ajouter un élément...") return placeholder;
|
||||
|
||||
if (options.length > 0) {
|
||||
const selectedOptionData = options.find(opt => opt.value === selectedOption);
|
||||
if (selectedOptionData) {
|
||||
return `Ajouter ${selectedOptionData.label.toLowerCase()}...`;
|
||||
}
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
const getOptionColor = (option: AddFormOption) => {
|
||||
if (option.color) return option.color;
|
||||
|
||||
// Couleurs par défaut selon le type
|
||||
switch (option.value) {
|
||||
case 'task':
|
||||
return 'green';
|
||||
case 'meeting':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getOptionClasses = (option: AddFormOption) => {
|
||||
const color = getOptionColor(option);
|
||||
const isSelected = selectedOption === option.value;
|
||||
|
||||
if (isSelected) {
|
||||
switch (color) {
|
||||
case 'green':
|
||||
return 'border-l-green-500 bg-green-500/30 text-white font-medium';
|
||||
case 'blue':
|
||||
return 'border-l-blue-500 bg-blue-500/30 text-white font-medium';
|
||||
default:
|
||||
return 'border-l-gray-500 bg-gray-500/30 text-white font-medium';
|
||||
}
|
||||
} else {
|
||||
switch (color) {
|
||||
case 'green':
|
||||
return 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90';
|
||||
case 'blue':
|
||||
return 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90';
|
||||
default:
|
||||
return 'border-l-gray-300 hover:border-l-gray-400 opacity-70 hover:opacity-90';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{/* Sélecteur d'options */}
|
||||
{options.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedOption(option.value)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-1 text-xs border-l-4 ${getOptionClasses(option)}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Champ de saisie et bouton d'ajout */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={getPlaceholder()}
|
||||
value={newItemText}
|
||||
onChange={(e) => setNewItemText(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={disabled}
|
||||
className="flex-1 min-w-[300px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddItem}
|
||||
disabled={!newItemText.trim() || disabled}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="min-w-[40px]"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/PriorityBadge.tsx
Normal file
37
src/components/ui/PriorityBadge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { getPriorityConfig } from '@/lib/status-config';
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PriorityBadge({ priority, className = '' }: PriorityBadgeProps) {
|
||||
const config = getPriorityConfig(priority);
|
||||
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium border';
|
||||
|
||||
let colorClasses = '';
|
||||
switch (config.color) {
|
||||
case 'blue':
|
||||
colorClasses = 'text-[var(--primary)] bg-[var(--primary)]/10 border-[var(--primary)]/20';
|
||||
break;
|
||||
case 'yellow':
|
||||
colorClasses = 'text-[var(--accent)] bg-[var(--accent)]/10 border-[var(--accent)]/20';
|
||||
break;
|
||||
case 'purple':
|
||||
colorClasses = 'text-[#8b5cf6] bg-[#8b5cf6]/10 border-[#8b5cf6]/20';
|
||||
break;
|
||||
case 'red':
|
||||
colorClasses = 'text-[var(--destructive)] bg-[var(--destructive)]/10 border-[var(--destructive)]/20';
|
||||
break;
|
||||
default:
|
||||
colorClasses = 'text-[var(--muted-foreground)] bg-[var(--muted)]/10 border-[var(--muted)]/20';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${baseClasses} ${colorClasses} ${className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/Tabs.tsx
Normal file
45
src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
items: TabItem[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tabs({ items, activeTab, onTabChange, className = '' }: TabsProps) {
|
||||
return (
|
||||
<div className={`border-b border-[var(--border)] ${className}`}>
|
||||
<nav className="flex space-x-8">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => !item.disabled && onTabChange(item.id)}
|
||||
disabled={item.disabled}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === item.id
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
} ${item.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
{item.count !== undefined && (
|
||||
<span className="text-xs opacity-75">({item.count})</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,19 @@ export { ColumnHeader } from './ColumnHeader';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { DropZone } from './DropZone';
|
||||
|
||||
// Composants Weekly Manager
|
||||
export { Tabs } from './Tabs';
|
||||
export { PriorityBadge } from './PriorityBadge';
|
||||
export { AchievementCard } from './AchievementCard';
|
||||
export { ChallengeCard } from './ChallengeCard';
|
||||
|
||||
// Composants Daily
|
||||
export { CheckboxItem } from './CheckboxItem';
|
||||
export { Calendar } from './Calendar';
|
||||
export { DailyAddForm } from './DailyAddForm';
|
||||
export { AlertBanner } from './AlertBanner';
|
||||
export { CollapsibleSection } from './CollapsibleSection';
|
||||
|
||||
// Composants existants
|
||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
|
||||
export { FontSizeToggle } from './FontSizeToggle';
|
||||
|
||||
Reference in New Issue
Block a user