feat: TFS Sync

This commit is contained in:
Julien Froidefond
2025-09-22 21:51:12 +02:00
parent 472135a97f
commit 723a44df32
27 changed files with 3309 additions and 364 deletions

View File

@@ -6,17 +6,32 @@ import { Button } from '@/components/ui/Button';
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
import { TaskBasicFields } from './task/TaskBasicFields';
import { TaskJiraInfo } from './task/TaskJiraInfo';
import { TaskTfsInfo } from './task/TaskTfsInfo';
import { TaskTagsSection } from './task/TaskTagsSection';
interface EditTaskFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
onSubmit: (data: {
taskId: string;
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}) => Promise<void>;
task: Task | null;
loading?: boolean;
}
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
export function EditTaskForm({
isOpen,
onClose,
onSubmit,
task,
loading = false,
}: EditTaskFormProps) {
const [formData, setFormData] = useState<{
title: string;
description: string;
@@ -30,7 +45,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: [],
dueDate: undefined
dueDate: undefined,
});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -44,7 +59,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
status: task.status,
priority: task.priority,
tags: task.tags || [],
dueDate: task.dueDate
dueDate: task.dueDate,
});
}
}, [task]);
@@ -61,7 +76,8 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
}
if (formData.description && formData.description.length > 1000) {
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
newErrors.description =
'La description ne peut pas dépasser 1000 caractères';
}
setErrors(newErrors);
@@ -70,13 +86,13 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm() || !task) return;
try {
await onSubmit({
taskId: task.id,
...formData
...formData,
});
handleClose();
} catch (error) {
@@ -89,33 +105,50 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
onClose();
};
if (!task) return null;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Modifier la tâche"
size="lg"
>
<form
onSubmit={handleSubmit}
className="space-y-4 max-h-[80vh] overflow-y-auto pr-2"
>
<TaskBasicFields
title={formData.title}
description={formData.description}
priority={formData.priority}
status={formData.status}
dueDate={formData.dueDate}
onTitleChange={(title) => setFormData(prev => ({ ...prev, title }))}
onDescriptionChange={(description) => setFormData(prev => ({ ...prev, description }))}
onPriorityChange={(priority) => setFormData(prev => ({ ...prev, priority }))}
onStatusChange={(status) => setFormData(prev => ({ ...prev, status }))}
onDueDateChange={(dueDate) => setFormData(prev => ({ ...prev, dueDate }))}
onTitleChange={(title) => setFormData((prev) => ({ ...prev, title }))}
onDescriptionChange={(description) =>
setFormData((prev) => ({ ...prev, description }))
}
onPriorityChange={(priority) =>
setFormData((prev) => ({ ...prev, priority }))
}
onStatusChange={(status) =>
setFormData((prev) => ({ ...prev, status }))
}
onDueDateChange={(dueDate) =>
setFormData((prev) => ({ ...prev, dueDate }))
}
errors={errors}
loading={loading}
/>
<TaskJiraInfo task={task} />
<TaskTfsInfo task={task} />
<TaskTagsSection
taskId={task.id}
tags={formData.tags}
onTagsChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
/>
{/* Actions */}
@@ -128,11 +161,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
>
Annuler
</Button>
<Button
type="submit"
variant="primary"
disabled={loading}
>
<Button type="submit" variant="primary" disabled={loading}>
{loading ? 'Mise à jour...' : 'Mettre à jour'}
</Button>
</div>

View File

@@ -0,0 +1,84 @@
'use client';
import { Badge } from '@/components/ui/Badge';
import { Task } from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
interface TaskTfsInfoProps {
task: Task;
}
export function TaskTfsInfo({ task }: TaskTfsInfoProps) {
const { preferences } = useUserPreferences();
// Helper pour construire l'URL TFS
const getTfsPullRequestUrl = (pullRequestId: number, project: string, repository: string): string => {
const organizationUrl = (preferences.tfsConfig as TfsConfig)?.organizationUrl;
if (!organizationUrl || !pullRequestId || !project || !repository) return '';
return `${organizationUrl}/${project}/_git/${repository}/pullrequest/${pullRequestId}`;
};
if (task.source !== 'tfs' || !task.tfsPullRequestId) {
return null;
}
return (
<div className="space-y-3">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
TFS / Azure DevOps
</label>
<div className="flex items-center gap-3 flex-wrap">
{preferences.tfsConfig && (preferences.tfsConfig as TfsConfig).organizationUrl ? (
<a
href={getTfsPullRequestUrl(
task.tfsPullRequestId,
task.tfsProject || '',
task.tfsRepository || ''
)}
target="_blank"
rel="noopener noreferrer"
className="hover:scale-105 transition-transform inline-flex"
>
<Badge
variant="outline"
size="sm"
className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer"
>
PR-{task.tfsPullRequestId}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
PR-{task.tfsPullRequestId}
</Badge>
)}
{task.tfsRepository && (
<Badge variant="outline" size="sm" className="text-orange-400 border-orange-400/30">
{task.tfsRepository}
</Badge>
)}
{task.tfsProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
{task.tfsProject}
</Badge>
)}
{task.tfsSourceBranch && (
<Badge variant="outline" size="sm" className="text-yellow-400 border-yellow-400/30">
{task.tfsSourceBranch.replace('refs/heads/', '')}
</Badge>
)}
{task.tfsTargetBranch && task.tfsTargetBranch !== task.tfsSourceBranch && (
<Badge variant="outline" size="sm" className="text-green-400 border-green-400/30">
{task.tfsTargetBranch.replace('refs/heads/', '')}
</Badge>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useTransition } from 'react';
import { Task } from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Card } from '@/components/ui/Card';
@@ -24,7 +25,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { tags: availableTags, refreshTasks } = useTasksContext();
const { preferences } = useUserPreferences();
// Classes CSS pour les différentes tailles de police
const getFontSizeClasses = () => {
switch (preferences.viewPreferences.fontSize) {
@@ -32,23 +33,23 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
return {
title: 'text-xs',
description: 'text-xs',
meta: 'text-xs'
meta: 'text-xs',
};
case 'large':
return {
title: 'text-base',
description: 'text-sm',
meta: 'text-sm'
meta: 'text-sm',
};
default: // medium
return {
title: 'text-sm',
description: 'text-xs',
meta: 'text-xs'
meta: 'text-xs',
};
}
};
const fontClasses = getFontSizeClasses();
// Helper pour construire l'URL Jira
@@ -58,16 +59,24 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
return `${baseUrl}/browse/${jiraKey}`;
};
// Helper pour construire l'URL TFS Pull Request
const getTfsPullRequestUrl = (
tfsPullRequestId: number,
tfsProject: string,
tfsRepository: string
): string => {
const tfsConfig = preferences.tfsConfig as TfsConfig;
const baseUrl = tfsConfig?.organizationUrl;
if (!baseUrl || !tfsPullRequestId || !tfsProject || !tfsRepository)
return '';
return `${baseUrl}/${encodeURIComponent(tfsProject)}/_git/${tfsRepository}/pullrequest/${tfsPullRequestId}`;
};
// Configuration du draggable
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: task.id,
});
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: task.id,
});
// Mettre à jour le titre local quand la tâche change
useEffect(() => {
@@ -76,9 +85,10 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
// Nettoyer le timeout au démontage
useEffect(() => {
const currentTimeout = timeoutRef.current;
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
if (currentTimeout) {
clearTimeout(currentTimeout);
}
};
}, []);
@@ -86,7 +96,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
startTransition(async () => {
const result = await deleteTask(task.id);
@@ -151,35 +161,36 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
}
};
// Style de transformation pour le drag
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : undefined;
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
// Extraire les emojis du titre pour les afficher comme tags visuels
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
// Extraire les emojis du titre pour les afficher comme tags visuels
const emojiRegex =
/(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
const titleEmojis = task.title.match(emojiRegex) || [];
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
// Composant titre avec tooltip
const TitleWithTooltip = () => (
<div className="relative flex-1">
<h4
<h4
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
onClick={handleTitleClick}
title="Cliquer pour éditer"
>
{titleWithoutEmojis}
</h4>
</div>
);
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
let displayEmojis: string[] = titleEmojis;
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
const firstTag = availableTags.find((tag) => tag.name === task.tags[0]);
if (firstTag) {
const tagEmojis = firstTag.name.match(emojiRegex);
if (tagEmojis && tagEmojis.length > 0) {
@@ -188,30 +199,44 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
}
}
// Styles spéciaux pour les tâches Jira
const isJiraTask = task.source === 'jira';
const jiraStyles = isJiraTask ? {
border: '1px solid rgba(0, 130, 201, 0.3)',
borderLeft: '3px solid #0082C9',
background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)'
} : {};
const jiraStyles = isJiraTask
? {
border: '1px solid rgba(0, 130, 201, 0.3)',
borderLeft: '3px solid #0082C9',
background:
'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)',
}
: {};
// Styles spéciaux pour les tâches TFS
const isTfsTask = task.source === 'tfs';
const tfsStyles = isTfsTask
? {
border: '1px solid rgba(255, 165, 0, 0.3)',
borderLeft: '3px solid #FFA500',
background:
'linear-gradient(135deg, rgba(255, 165, 0, 0.05) 0%, rgba(255, 165, 0, 0.02) 100%)',
}
: {};
// Combiner les styles spéciaux
const specialStyles = { ...jiraStyles, ...tfsStyles };
// Vue compacte : seulement le titre
if (compactView) {
return (
<Card
<Card
ref={setNodeRef}
style={{ ...style, ...jiraStyles }}
style={{ ...style, ...specialStyles }}
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
} ${
task.status === 'done' ? 'opacity-60' : ''
} ${
} ${task.status === 'done' ? 'opacity-60' : ''} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
isTfsTask ? 'tfs-task' : ''
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
>
@@ -219,12 +244,13 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{displayEmojis.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{displayEmojis.slice(0, 1).map((emoji, index) => (
<span
key={index}
<span
key={index}
className="text-base opacity-90 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal'
style={{
fontFamily:
'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal',
}}
>
{emoji}
@@ -232,7 +258,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
))}
</div>
)}
{isEditingTitle ? (
<input
type="text"
@@ -246,7 +272,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
) : (
<TitleWithTooltip />
)}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Boutons d'action compacts - masqués en mode édition */}
{!isEditingTitle && onEdit && (
@@ -259,7 +285,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
</button>
)}
{!isEditingTitle && (
<button
onClick={handleDelete}
@@ -270,11 +296,15 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
×
</button>
)}
{/* Indicateur de priorité compact */}
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
<div
className="w-1.5 h-1.5 rounded-full"
style={{
backgroundColor: getPriorityColorHex(
getPriorityConfig(task.priority).color
),
}}
/>
</div>
</div>
@@ -284,18 +314,16 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
// Vue détaillée : version complète
return (
<Card
<Card
ref={setNodeRef}
style={{ ...style, ...jiraStyles }}
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
style={{ ...style, ...specialStyles }}
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
} ${
task.status === 'done' ? 'opacity-60' : ''
} ${
} ${task.status === 'done' ? 'opacity-60' : ''} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
isTfsTask ? 'tfs-task' : ''
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
>
@@ -304,12 +332,13 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{displayEmojis.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{displayEmojis.slice(0, 2).map((emoji, index) => (
<span
key={index}
<span
key={index}
className="text-sm opacity-80 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal'
style={{
fontFamily:
'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal',
}}
>
{emoji}
@@ -317,7 +346,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
))}
</div>
)}
{isEditingTitle ? (
<input
type="text"
@@ -331,7 +360,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
) : (
<TitleWithTooltip />
)}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Bouton d'édition discret - masqué en mode édition */}
{!isEditingTitle && onEdit && (
@@ -344,7 +373,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
</button>
)}
{/* Bouton de suppression discret - masqué en mode édition */}
{!isEditingTitle && (
<button
@@ -356,13 +385,15 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
×
</button>
)}
{/* Indicateur de priorité tech */}
<div
<div
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
style={{
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
style={{
backgroundColor: getPriorityColorHex(
getPriorityConfig(task.priority).color
),
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`,
}}
/>
</div>
@@ -370,18 +401,24 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{/* Description tech */}
{task.description && (
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
<p
className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}
>
{task.description}
</p>
)}
{/* Tags avec couleurs */}
{task.tags && task.tags.length > 0 && (
<div className={
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
? "mb-3"
: "mb-0"
}>
<div
className={
task.dueDate ||
(task.source && task.source !== 'manual') ||
task.completedAt
? 'mb-3'
: 'mb-0'
}
>
<TagDisplay
tags={task.tags}
availableTags={availableTags}
@@ -393,24 +430,29 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
)}
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
{(task.dueDate ||
(task.source && task.source !== 'manual') ||
task.completedAt) && (
<div className="pt-2 border-t border-[var(--border)]/50">
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
<div
className={`flex items-center justify-between ${fontClasses.meta}`}
>
{task.dueDate ? (
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
<span className="text-[var(--primary)]"></span>
{formatDistanceToNow(task.dueDate, {
addSuffix: true,
locale: fr
{formatDistanceToNow(task.dueDate, {
addSuffix: true,
locale: fr,
})}
</span>
) : (
<div></div>
)}
<div className="flex items-center gap-2">
{task.source !== 'manual' && task.source && (
task.source === 'jira' && task.jiraKey ? (
{task.source !== 'manual' &&
task.source &&
(task.source === 'jira' && task.jiraKey ? (
preferences.jiraConfig.baseUrl ? (
<a
href={getJiraTicketUrl(task.jiraKey)}
@@ -419,7 +461,11 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
onClick={(e) => e.stopPropagation()}
className="hover:scale-105 transition-transform"
>
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
<Badge
variant="outline"
size="sm"
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
>
{task.jiraKey}
</Badge>
</a>
@@ -428,27 +474,74 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{task.jiraKey}
</Badge>
)
) : task.source === 'tfs' && task.tfsPullRequestId ? (
preferences.tfsConfig &&
(preferences.tfsConfig as TfsConfig).organizationUrl ? (
<a
href={getTfsPullRequestUrl(
task.tfsPullRequestId,
task.tfsProject || '',
task.tfsRepository || ''
)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="hover:scale-105 transition-transform"
>
<Badge
variant="outline"
size="sm"
className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer"
>
PR-{task.tfsPullRequestId}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
PR-{task.tfsPullRequestId}
</Badge>
)
) : (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
)
)}
{task.jiraProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
{task.jiraProject}
</Badge>
)}
))}
{/* Badges spécifiques TFS */}
{task.tfsRepository && (
<Badge
variant="outline"
size="sm"
className="text-orange-400 border-orange-400/30"
>
{task.tfsRepository}
</Badge>
)}
{task.jiraProject && (
<Badge
variant="outline"
size="sm"
className="text-blue-400 border-blue-400/30"
>
{task.jiraProject}
</Badge>
)}
{task.jiraType && (
<Badge
variant="outline"
size="sm"
className="text-purple-400 border-purple-400/30"
>
{task.jiraType}
</Badge>
)}
{task.jiraType && (
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
{task.jiraType}
</Badge>
)}
{task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span>
<span className="text-emerald-400 font-mono font-bold">
DONE
</span>
)}
</div>
</div>

View File

@@ -1,20 +1,25 @@
'use client';
import { JiraConfig } from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
import { JiraSync } from '@/components/jira/JiraSync';
import { JiraLogs } from '@/components/jira/JiraLogs';
import { JiraSchedulerConfig } from '@/components/jira/JiraSchedulerConfig';
import { TfsConfigForm } from '@/components/settings/TfsConfigForm';
import { TfsSync } from '@/components/tfs/TfsSync';
import Link from 'next/link';
interface IntegrationsSettingsPageClientProps {
initialJiraConfig: JiraConfig;
initialTfsConfig: TfsConfig;
}
export function IntegrationsSettingsPageClient({
initialJiraConfig
initialJiraConfig,
initialTfsConfig
}: IntegrationsSettingsPageClientProps) {
return (
<div className="min-h-screen bg-[var(--background)]">
@@ -44,122 +49,125 @@ export function IntegrationsSettingsPageClient({
</p>
</div>
{/* Layout en 2 colonnes pour optimiser l'espace */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Colonne principale: Configuration Jira */}
<div className="xl:col-span-2 space-y-6">
<Card>
<CardHeader>
<h2 className="text-xl font-semibold flex items-center gap-2">
<span className="text-blue-600">🏢</span>
Jira Cloud
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Synchronisation automatique des tickets Jira vers TowerControl
</p>
</CardHeader>
<CardContent>
<JiraConfigForm />
</CardContent>
</Card>
{/* Futures intégrations */}
<Card>
<CardHeader>
<h2 className="text-xl font-semibold">Autres intégrations</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Intégrations prévues pour les prochaines versions
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📧</span>
<h3 className="font-medium">Slack/Teams</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Notifications et commandes via chat
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🐙</span>
<h3 className="font-medium">GitHub/GitLab</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Synchronisation des issues et PR
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📊</span>
<h3 className="font-medium">Calendriers</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Google Calendar, Outlook, etc.
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<h3 className="font-medium">Time tracking</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Toggl, RescueTime, etc.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Section Jira */}
<div className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-2 flex items-center gap-2">
<span className="text-blue-600">🏢</span>
Jira Cloud
</h2>
<p className="text-[var(--muted-foreground)]">
Synchronisation automatique des tickets Jira vers TowerControl
</p>
</div>
{/* Colonne latérale: Actions et Logs Jira */}
<div className="space-y-4">
{initialJiraConfig?.enabled && (
<>
{/* Dashboard Analytics */}
{initialJiraConfig.projectKey && (
<Card>
<CardHeader>
<h3 className="text-sm font-semibold">📊 Analytics d&apos;équipe</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-[var(--muted-foreground)]">
Surveillance du projet {initialJiraConfig.projectKey}
</p>
<Link
href="/jira-dashboard"
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
>
Voir le Dashboard
</Link>
</CardContent>
</Card>
)}
<JiraSchedulerConfig />
<JiraSync />
<JiraLogs />
</>
)}
{!initialJiraConfig?.enabled && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Configuration Jira */}
<div className="xl:col-span-2">
<Card>
<CardContent className="p-4">
<div className="text-center py-6">
<span className="text-4xl mb-4 block">🔧</span>
<p className="text-sm text-[var(--muted-foreground)]">
Configurez Jira pour accéder aux outils de synchronisation
</p>
</div>
<CardContent>
<JiraConfigForm />
</CardContent>
</Card>
)}
<div className="mt-6">
<JiraLogs />
</div>
</div>
{/* Actions Jira */}
<div className="space-y-4">
{initialJiraConfig?.enabled ? (
<>
{/* Dashboard Analytics */}
{initialJiraConfig.projectKey && (
<Card>
<CardHeader>
<h3 className="text-sm font-semibold">📊 Analytics d&apos;équipe</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-[var(--muted-foreground)]">
Surveillance du projet {initialJiraConfig.projectKey}
</p>
<Link
href="/jira-dashboard"
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
>
Voir le Dashboard
</Link>
</CardContent>
</Card>
)}
<JiraSchedulerConfig />
<JiraSync />
</>
) : (
<Card>
<CardContent className="p-4">
<div className="text-center py-6">
<span className="text-4xl mb-4 block">🏢</span>
<p className="text-sm text-[var(--muted-foreground)]">
Configurez Jira pour accéder aux outils de synchronisation
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
{/* Diviseur entre les sections */}
<div className="relative my-12">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-[var(--border)]"></div>
</div>
<div className="relative flex justify-center text-sm uppercase">
<span className="bg-[var(--background)] px-6 text-[var(--muted-foreground)] font-medium tracking-wider">
</span>
</div>
</div>
{/* Section TFS */}
<div>
<div className="mb-6">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-2 flex items-center gap-2">
<span className="text-blue-500">🔧</span>
Azure DevOps / TFS
</h2>
<p className="text-[var(--muted-foreground)]">
Synchronisation des Pull Requests depuis Azure DevOps vers TowerControl
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Configuration TFS */}
<div className="xl:col-span-2">
<Card>
<CardContent>
<TfsConfigForm />
</CardContent>
</Card>
</div>
{/* Actions TFS */}
<div className="space-y-4">
{initialTfsConfig?.enabled ? (
<TfsSync />
) : (
<Card>
<CardContent className="p-4">
<div className="text-center py-6">
<span className="text-4xl mb-4 block">🔧</span>
<p className="text-sm text-[var(--muted-foreground)]">
Configurez Azure DevOps pour accéder aux outils de synchronisation
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@ export function JiraConfigForm() {
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
const [formData, setFormData] = useState({
enabled: false,
baseUrl: '',
email: '',
apiToken: '',
@@ -26,6 +27,7 @@ export function JiraConfigForm() {
useEffect(() => {
if (config) {
setFormData({
enabled: config.enabled || false,
baseUrl: config.baseUrl || '',
email: config.email || '',
apiToken: config.apiToken || '',
@@ -87,6 +89,7 @@ export function JiraConfigForm() {
if (result.success) {
setFormData({
enabled: false,
baseUrl: '',
email: '',
apiToken: '',
@@ -228,6 +231,27 @@ export function JiraConfigForm() {
{/* Formulaire de configuration */}
{showForm && (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Toggle d'activation */}
<div className="flex items-center justify-between p-4 bg-[var(--muted)] rounded-lg">
<div>
<h4 className="font-medium">Activer l&apos;intégration Jira</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Synchroniser les tickets Jira vers TowerControl
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => setFormData(prev => ({ ...prev, enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{formData.enabled && (
<>
<div>
<label className="block text-sm font-medium mb-2">
URL de base Jira Cloud
@@ -362,6 +386,8 @@ export function JiraConfigForm() {
</div>
)}
</div>
</>
)}
<div className="flex gap-3">
<Button
@@ -399,7 +425,7 @@ export function JiraConfigForm() {
<li>Copiez le token généré</li>
</ul>
<p className="mt-3 text-xs">
<strong>Note:</strong> Ces variables doivent être configurées dans l&apos;environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)
<strong>Note:</strong> Les tickets Jira seront synchronisés comme tâches dans TowerControl pour faciliter le suivi.
</p>
</div>
</div>

View File

@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import { TfsConfig } from '@/services/tfs';
import { getTfsConfig, saveTfsConfig, deleteAllTfsTasks } from '@/actions/tfs';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
export function TfsConfigForm() {
const [config, setConfig] = useState<TfsConfig>({
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
});
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
const [testingConnection, setTestingConnection] = useState(false);
const [showForm, setShowForm] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [deletingTasks, setDeletingTasks] = useState(false);
// Charger la configuration existante
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
const result = await getTfsConfig();
if (result.success) {
setConfig(result.data);
// Afficher le formulaire par défaut si TFS n'est pas configuré
const isConfigured =
result.data?.enabled &&
result.data?.organizationUrl &&
result.data?.personalAccessToken;
if (!isConfigured) {
setShowForm(true);
}
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors du chargement de la configuration',
});
setShowForm(true); // Afficher le formulaire en cas d'erreur
}
} catch (error) {
console.error('Erreur chargement config TFS:', error);
setMessage({
type: 'error',
text: 'Erreur lors du chargement de la configuration',
});
setShowForm(true);
} finally {
setIsLoading(false);
}
};
const handleSaveConfig = () => {
startTransition(async () => {
setMessage(null);
const result = await saveTfsConfig(config);
if (result.success) {
setMessage({
type: 'success',
text: result.message || 'Configuration sauvegardée',
});
// Masquer le formulaire après une sauvegarde réussie
setShowForm(false);
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la sauvegarde',
});
}
});
};
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration TFS ?')) {
return;
}
startTransition(async () => {
setMessage(null);
// Réinitialiser la config
const resetConfig = {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
};
const result = await saveTfsConfig(resetConfig);
if (result.success) {
setConfig(resetConfig);
setMessage({ type: 'success', text: 'Configuration TFS supprimée' });
setShowForm(true); // Afficher le formulaire pour reconfigurer
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la suppression',
});
}
});
};
const testConnection = async () => {
try {
setTestingConnection(true);
setMessage(null);
// Sauvegarder d'abord la config
const saveResult = await saveTfsConfig(config);
if (!saveResult.success) {
setMessage({
type: 'error',
text: saveResult.error || 'Erreur lors de la sauvegarde',
});
return;
}
// Attendre un peu que la configuration soit prise en compte
await new Promise((resolve) => setTimeout(resolve, 1000));
// Tester la connexion avec la route dédiée
const response = await fetch('/api/tfs/test', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
console.log('Test TFS - Réponse:', { status: response.status, result });
if (response.ok && result.connected) {
setMessage({
type: 'success',
text: `Connexion Azure DevOps réussie ! ${result.message || ''}`,
});
} else {
const errorMessage =
result.error || result.details || 'Erreur de connexion inconnue';
setMessage({
type: 'error',
text: `Connexion échouée: ${errorMessage}`,
});
console.error('Test TFS échoué:', result);
}
} catch (error) {
console.error('Erreur test connexion TFS:', error);
setMessage({
type: 'error',
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
});
} finally {
setTestingConnection(false);
}
};
const handleDeleteAllTasks = async () => {
const confirmation = confirm(
'Êtes-vous sûr de vouloir supprimer TOUTES les tâches TFS de la base locale ?\n\n' +
'Cette action est irréversible et supprimera définitivement toutes les tâches ' +
'synchronisées depuis Azure DevOps/TFS.\n\n' +
'Cliquez sur OK pour confirmer la suppression.'
);
if (!confirmation) {
return;
}
try {
setDeletingTasks(true);
setMessage(null);
const result = await deleteAllTfsTasks();
if (result.success) {
setMessage({
type: 'success',
text:
result.message ||
'Toutes les tâches TFS ont été supprimées avec succès',
});
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la suppression des tâches TFS',
});
}
} catch (error) {
console.error('Erreur suppression tâches TFS:', error);
setMessage({
type: 'error',
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
});
} finally {
setDeletingTasks(false);
}
};
const updateConfig = (
field: keyof TfsConfig,
value: string | boolean | string[]
) => {
setConfig((prev) => ({ ...prev, [field]: value }));
};
const updateArrayField = (
field: 'repositories' | 'ignoredRepositories',
value: string
) => {
const array = value
.split(',')
.map((item) => item.trim())
.filter((item) => item);
updateConfig(field, array);
};
const isTfsConfigured =
config?.enabled && config?.organizationUrl && config?.personalAccessToken;
const isLoadingState = isLoading || isPending || deletingTasks;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-[var(--muted-foreground)]">
Chargement...
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Statut actuel */}
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
<div>
<h3 className="font-medium">Statut de l&apos;intégration</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{isTfsConfigured
? 'Azure DevOps est configuré et prêt à être utilisé'
: "Azure DevOps n'est pas configuré"}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={isTfsConfigured ? 'success' : 'danger'}>
{isTfsConfigured ? '✓ Configuré' : '✗ Non configuré'}
</Badge>
<Button
variant="secondary"
size="sm"
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Masquer' : isTfsConfigured ? 'Modifier' : 'Configurer'}
</Button>
</div>
</div>
{isTfsConfigured && (
<div className="p-4 bg-[var(--card)] rounded border">
<h3 className="font-medium mb-2">Configuration actuelle</h3>
<div className="space-y-2 text-sm">
<div>
<span className="text-[var(--muted-foreground)]">
URL d&apos;organisation:
</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.organizationUrl || 'Non définie'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Projet:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.projectName || "Toute l'organisation"}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Token PAT:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.personalAccessToken ? '••••••••' : 'Non défini'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">
Repositories surveillés:
</span>{' '}
{config?.repositories && config.repositories.length > 0 ? (
<div className="mt-1 space-x-1">
{config.repositories.map((repo) => (
<code
key={repo}
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
) : (
<span className="text-xs">Tous les repositories</span>
)}
</div>
<div>
<span className="text-[var(--muted-foreground)]">
Repositories ignorés:
</span>{' '}
{config?.ignoredRepositories &&
config.ignoredRepositories.length > 0 ? (
<div className="mt-1 space-x-1">
{config.ignoredRepositories.map((repo) => (
<code
key={repo}
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
) : (
<span className="text-xs">Aucun</span>
)}
</div>
</div>
</div>
)}
{/* Actions de gestion des données TFS */}
{isTfsConfigured && (
<div className="p-4 bg-[var(--card)] rounded border border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-orange-800 dark:text-orange-200">
Gestion des données
</h3>
<p className="text-sm text-orange-600 dark:text-orange-300">
Supprimez toutes les tâches TFS synchronisées de la base locale
</p>
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1">
<strong>Attention:</strong> Cette action est irréversible et
supprimera définitivement toutes les tâches importées depuis
Azure DevOps.
</p>
</div>
<Button
type="button"
variant="danger"
onClick={handleDeleteAllTasks}
disabled={deletingTasks}
className="px-6"
>
{deletingTasks
? 'Suppression...'
: '🗑️ Supprimer toutes les tâches TFS'}
</Button>
</div>
</div>
)}
{/* Formulaire de configuration */}
{showForm && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveConfig();
}}
className="space-y-4"
>
{/* Toggle d'activation */}
<div className="flex items-center justify-between p-4 bg-[var(--muted)] rounded-lg">
<div>
<h4 className="font-medium">Activer l&apos;intégration TFS</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Synchroniser les Pull Requests depuis Azure DevOps
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig('enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{config.enabled && (
<>
<div>
<label className="block text-sm font-medium mb-2">
URL de l&apos;organisation Azure DevOps
</label>
<input
type="url"
value={config.organizationUrl || ''}
onChange={(e) =>
updateConfig('organizationUrl', e.target.value)
}
placeholder="https://dev.azure.com/votre-organisation"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
required
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
L&apos;URL de base de votre organisation Azure DevOps (ex:
https://dev.azure.com/monentreprise)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Nom du projet (optionnel)
</label>
<input
type="text"
value={config.projectName || ''}
onChange={(e) => updateConfig('projectName', e.target.value)}
placeholder="MonProjet (laisser vide pour toute l'organisation)"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Nom du projet spécifique ou laisser vide pour synchroniser les
PRs de toute l&apos;organisation
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Personal Access Token (PAT)
</label>
<input
type="password"
value={config.personalAccessToken || ''}
onChange={(e) =>
updateConfig('personalAccessToken', e.target.value)
}
placeholder="Votre token d'accès personnel"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
required
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Créez un PAT depuis{' '}
<a
href="https://dev.azure.com/"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline"
>
Azure DevOps
</a>{' '}
avec les permissions Code (read) et Pull Request (read)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Repositories à surveiller (optionnel)
</label>
<input
type="text"
value={config.repositories?.join(', ') || ''}
onChange={(e) =>
updateArrayField('repositories', e.target.value)
}
placeholder="repo1, repo2, repo3"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Liste séparée par des virgules. Laisser vide pour surveiller
tous les repositories.
</p>
{config.repositories && config.repositories.length > 0 && (
<div className="mt-2 space-x-1">
<span className="text-xs text-[var(--muted-foreground)]">
Repositories surveillés:
</span>
{config.repositories.map((repo) => (
<code
key={repo}
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2">
Repositories à ignorer (optionnel)
</label>
<input
type="text"
value={config.ignoredRepositories?.join(', ') || ''}
onChange={(e) =>
updateArrayField('ignoredRepositories', e.target.value)
}
placeholder="test-repo, demo-repo"
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Repositories à exclure de la synchronisation, séparés par des
virgules (ex: test-repo, demo-repo).
</p>
{config.ignoredRepositories &&
config.ignoredRepositories.length > 0 && (
<div className="mt-2 space-x-1">
<span className="text-xs text-[var(--muted-foreground)]">
Repositories ignorés:
</span>
{config.ignoredRepositories.map((repo) => (
<code
key={repo}
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
)}
</div>
</>
)}
<div className="flex gap-3">
<Button type="submit" disabled={isLoadingState} className="flex-1">
{isLoadingState
? 'Sauvegarde...'
: 'Sauvegarder la configuration'}
</Button>
<Button
type="button"
variant="secondary"
onClick={testConnection}
disabled={
testingConnection ||
!config.organizationUrl ||
!config.personalAccessToken
}
className="px-6"
>
{testingConnection ? 'Test...' : 'Tester'}
</Button>
{isTfsConfigured && (
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={isLoadingState}
className="px-6"
>
Supprimer
</Button>
)}
</div>
{/* Instructions */}
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<h3 className="font-medium mb-2">
💡 Instructions de configuration
</h3>
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
<p>
<strong>1. URL d&apos;organisation:</strong> Votre domaine Azure
DevOps (ex: https://dev.azure.com/monentreprise)
</p>
<p>
<strong>2. Nom du projet (optionnel):</strong> Spécifiez un
projet pour limiter la synchronisation, ou laissez vide pour
toute l&apos;organisation
</p>
<p>
<strong>3. Personal Access Token:</strong> Créez un PAT depuis
Azure DevOps :
</p>
<ul className="ml-4 space-y-1 list-disc">
<li>
Allez sur{' '}
<a
href="https://dev.azure.com/"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline"
>
dev.azure.com
</a>
</li>
<li>Cliquez sur votre profil » Personal access tokens</li>
<li>Cliquez sur &quot;New Token&quot;</li>
<li>
Sélectionnez les scopes: Code (read) et Pull Request (read)
</li>
<li>Copiez le token généré</li>
</ul>
<p className="mt-3 text-xs">
<strong>🎯 Synchronisation intelligente:</strong> TowerControl
récupère automatiquement toutes les Pull Requests vous
concernant (créées par vous ou vous êtes reviewer) dans
l&apos;organisation ou le projet configuré.
</p>
<p className="text-xs">
<strong>Note:</strong> Les PRs seront synchronisées comme tâches
pour un suivi centralisé de vos activités.
</p>
</div>
</div>
</form>
)}
{message && (
<div
className={`p-4 rounded border ${
message.type === 'success'
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
}`}
>
{message.text}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useState, useTransition } from 'react';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { syncTfsPullRequests } from '@/actions/tfs';
export function TfsSync() {
const [isPending, startTransition] = useTransition();
const [lastSync, setLastSync] = useState<{
success: boolean;
message: string;
stats?: {
created: number;
updated: number;
skipped: number;
deleted: number;
}
} | null>(null);
const handleSync = () => {
startTransition(async () => {
setLastSync(null);
const result = await syncTfsPullRequests();
if (result.success) {
setLastSync({
success: true,
message: result.message || 'Synchronisation réussie',
stats: result.data ? {
created: result.data.pullRequestsCreated,
updated: result.data.pullRequestsUpdated,
skipped: result.data.pullRequestsSkipped,
deleted: result.data.pullRequestsDeleted
} : undefined
});
} else {
setLastSync({
success: false,
message: result.error || 'Erreur lors de la synchronisation'
});
}
});
};
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center gap-2">
<span className="text-blue-600">🔄</span>
Synchronisation TFS
</h3>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-[var(--muted-foreground)]">
Synchronise manuellement les Pull Requests depuis Azure DevOps
</p>
{/* Résultat de la dernière synchronisation */}
{lastSync && (
<div className={`p-3 rounded-lg text-sm ${
lastSync.success
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
<div className="font-medium mb-1">
{lastSync.success ? '✅' : '❌'} {lastSync.message}
</div>
{lastSync.stats && (
<div className="text-xs opacity-80">
Créées: {lastSync.stats.created} |
Mises à jour: {lastSync.stats.updated} |
Ignorées: {lastSync.stats.skipped} |
Supprimées: {lastSync.stats.deleted}
</div>
)}
</div>
)}
<button
onClick={handleSync}
disabled={isPending}
className="w-full px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isPending && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{isPending ? 'Synchronisation en cours...' : 'Synchroniser maintenant'}
</button>
<div className="text-xs text-[var(--muted-foreground)] text-center">
Les Pull Requests seront importées comme tâches dans le tableau Kanban
</div>
</CardContent>
</Card>
);
}