chore: mark completion of sessions and SWOT components in devbook.md; add @hello-pangea/dnd dependency for drag & drop functionality

This commit is contained in:
Julien Froidefond
2025-11-27 13:15:56 +01:00
parent 27e409fb76
commit 628d64a5c6
12 changed files with 1398 additions and 45 deletions

156
src/actions/swot.ts Normal file
View File

@@ -0,0 +1,156 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as sessionsService from '@/services/sessions';
import type { SwotCategory } from '@prisma/client';
// ============================================
// SWOT Items Actions
// ============================================
export async function createSwotItem(
sessionId: string,
data: { content: string; category: SwotCategory }
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.createSwotItem(sessionId, data);
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error creating SWOT item:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateSwotItem(
itemId: string,
sessionId: string,
data: { content?: string; category?: SwotCategory; order?: number }
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.updateSwotItem(itemId, data);
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error updating SWOT item:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteSwotItem(itemId: string, sessionId: string) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await sessionsService.deleteSwotItem(itemId);
revalidatePath(`/sessions/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting SWOT item:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
export async function moveSwotItem(
itemId: string,
sessionId: string,
newCategory: SwotCategory,
newOrder: number
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error moving SWOT item:', error);
return { success: false, error: 'Erreur lors du déplacement' };
}
}
// ============================================
// Actions CRUD
// ============================================
export async function createAction(
sessionId: string,
data: {
title: string;
description?: string;
priority?: number;
linkedItemIds: string[];
}
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const action = await sessionsService.createAction(sessionId, data);
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: action };
} catch (error) {
console.error('Error creating action:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateAction(
actionId: string,
sessionId: string,
data: {
title?: string;
description?: string;
priority?: number;
status?: string;
}
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const action = await sessionsService.updateAction(actionId, data);
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: action };
} catch (error) {
console.error('Error updating action:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteAction(actionId: string, sessionId: string) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await sessionsService.deleteAction(actionId);
revalidatePath(`/sessions/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error deleting action:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}

View File

@@ -0,0 +1,68 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionById } from '@/services/sessions';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { Badge } from '@/components/ui';
interface SessionPageProps {
params: Promise<{ id: string }>;
}
export default async function SessionPage({ params }: SessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions" className="hover:text-foreground">
Mes Sessions
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">{session.title}</h1>
<p className="mt-1 text-lg text-muted">
👤 {session.collaborator}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
<Badge variant="success">{session.actions.length} actions</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* SWOT Board */}
<SwotBoard
sessionId={session.id}
items={session.items}
actions={session.actions}
/>
</main>
);
}

View File

@@ -0,0 +1,343 @@
'use client';
import { useState, useTransition } from 'react';
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
import { Button, Badge, Modal, ModalFooter, Input, Textarea } from '@/components/ui';
import { createAction, updateAction, deleteAction } from '@/actions/swot';
type ActionWithLinks = Action & {
links: (ActionLink & { swotItem: SwotItem })[];
};
interface ActionPanelProps {
sessionId: string;
actions: ActionWithLinks[];
allItems: SwotItem[];
linkMode: boolean;
selectedItems: string[];
onEnterLinkMode: () => void;
onExitLinkMode: () => void;
onClearSelection: () => void;
onActionHover: (itemIds: string[]) => void;
onActionLeave: () => void;
}
const categoryBadgeVariant: Record<SwotCategory, 'strength' | 'weakness' | 'opportunity' | 'threat'> = {
STRENGTH: 'strength',
WEAKNESS: 'weakness',
OPPORTUNITY: 'opportunity',
THREAT: 'threat',
};
const categoryShort: Record<SwotCategory, string> = {
STRENGTH: 'S',
WEAKNESS: 'W',
OPPORTUNITY: 'O',
THREAT: 'T',
};
const priorityLabels = ['Basse', 'Moyenne', 'Haute'];
const statusLabels: Record<string, string> = {
todo: 'À faire',
in_progress: 'En cours',
done: 'Terminé',
};
export function ActionPanel({
sessionId,
actions,
allItems,
linkMode,
selectedItems,
onEnterLinkMode,
onExitLinkMode,
onClearSelection,
onActionHover,
onActionLeave,
}: ActionPanelProps) {
const [showModal, setShowModal] = useState(false);
const [editingAction, setEditingAction] = useState<ActionWithLinks | null>(null);
const [isPending, startTransition] = useTransition();
// Form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState(1);
function openCreateModal() {
if (selectedItems.length < 2) {
alert('Sélectionnez au moins 2 items SWOT pour créer une action croisée');
return;
}
setTitle('');
setDescription('');
setPriority(1);
setEditingAction(null);
setShowModal(true);
}
function openEditModal(action: ActionWithLinks) {
setTitle(action.title);
setDescription(action.description || '');
setPriority(action.priority);
setEditingAction(action);
setShowModal(true);
}
function closeModal() {
setShowModal(false);
setEditingAction(null);
onExitLinkMode();
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
startTransition(async () => {
if (editingAction) {
await updateAction(editingAction.id, sessionId, {
title: title.trim(),
description: description.trim() || undefined,
priority,
});
} else {
await createAction(sessionId, {
title: title.trim(),
description: description.trim() || undefined,
priority,
linkedItemIds: selectedItems,
});
onClearSelection();
}
closeModal();
});
}
async function handleDelete(actionId: string) {
if (!confirm('Supprimer cette action ?')) return;
startTransition(async () => {
await deleteAction(actionId, sessionId);
});
}
async function handleStatusChange(action: ActionWithLinks, newStatus: string) {
startTransition(async () => {
await updateAction(action.id, sessionId, { status: newStatus });
});
}
const selectedItemsData = allItems.filter((item) => selectedItems.includes(item.id));
return (
<div className="rounded-xl border border-border bg-card p-6">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">📋</span>
<h2 className="text-lg font-semibold text-foreground">Actions Croisées</h2>
<Badge variant="primary">{actions.length}</Badge>
</div>
{linkMode ? (
<Button onClick={openCreateModal} disabled={selectedItems.length < 2}>
Créer l&apos;action ({selectedItems.length} items)
</Button>
) : (
<Button variant="outline" onClick={onEnterLinkMode}>
<span>🔗</span>
Nouvelle action
</Button>
)}
</div>
{/* Selected Items Preview (in link mode) */}
{linkMode && selectedItemsData.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{selectedItemsData.map((item) => (
<Badge key={item.id} variant={categoryBadgeVariant[item.category]}>
{categoryShort[item.category]}: {item.content.slice(0, 30)}
{item.content.length > 30 ? '...' : ''}
</Badge>
))}
</div>
)}
{/* Actions List */}
{actions.length === 0 ? (
<p className="py-8 text-center text-muted">
Aucune action pour le moment.
<br />
Créez des actions en sélectionnant plusieurs items SWOT.
</p>
) : (
<div className="space-y-3">
{actions.map((action) => (
<div
key={action.id}
className="group rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/30"
onMouseEnter={() => onActionHover(action.links.map((l) => l.swotItemId))}
onMouseLeave={onActionLeave}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-foreground">{action.title}</h3>
<Badge
variant={
action.priority === 2
? 'destructive'
: action.priority === 1
? 'warning'
: 'default'
}
>
{priorityLabels[action.priority]}
</Badge>
</div>
{action.description && (
<p className="mt-1 text-sm text-muted">{action.description}</p>
)}
<div className="mt-2 flex flex-wrap gap-1">
{action.links.map((link) => (
<Badge
key={link.id}
variant={categoryBadgeVariant[link.swotItem.category]}
className="text-xs"
>
{categoryShort[link.swotItem.category]}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<select
value={action.status}
onChange={(e) => handleStatusChange(action, e.target.value)}
className="rounded-lg border border-border bg-card px-2 py-1 text-sm text-foreground"
disabled={isPending}
>
<option value="todo">{statusLabels.todo}</option>
<option value="in_progress">{statusLabels.in_progress}</option>
<option value="done">{statusLabels.done}</option>
</select>
<button
onClick={() => openEditModal(action)}
className="rounded p-1.5 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
aria-label="Modifier"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={() => handleDelete(action.id)}
className="rounded p-1.5 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
aria-label="Supprimer"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={showModal}
onClose={closeModal}
title={editingAction ? 'Modifier l\'action' : 'Nouvelle action croisée'}
size="lg"
>
<form onSubmit={handleSubmit}>
{!editingAction && selectedItemsData.length > 0 && (
<div className="mb-4">
<p className="mb-2 text-sm font-medium text-foreground">Items liés :</p>
<div className="flex flex-wrap gap-2">
{selectedItemsData.map((item) => (
<Badge key={item.id} variant={categoryBadgeVariant[item.category]}>
{categoryShort[item.category]}: {item.content.slice(0, 40)}
{item.content.length > 40 ? '...' : ''}
</Badge>
))}
</div>
</div>
)}
<div className="space-y-4">
<Input
label="Titre de l'action"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Former à la compétence X"
required
/>
<Textarea
label="Description (optionnel)"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Détaillez l'action à mener..."
rows={3}
/>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">Priorité</label>
<div className="flex gap-2">
{priorityLabels.map((label, index) => (
<button
key={index}
type="button"
onClick={() => setPriority(index)}
className={`
flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors
${
priority === index
? index === 2
? 'border-destructive bg-destructive/10 text-destructive'
: index === 1
? 'border-warning bg-warning/10 text-warning'
: 'border-primary bg-primary/10 text-primary'
: 'border-border bg-card text-muted hover:bg-card-hover'
}
`}
>
{label}
</button>
))}
</div>
</div>
</div>
<ModalFooter>
<Button type="button" variant="outline" onClick={closeModal}>
Annuler
</Button>
<Button type="submit" loading={isPending}>
{editingAction ? 'Enregistrer' : 'Créer l\'action'}
</Button>
</ModalFooter>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import { useState, useTransition } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from '@hello-pangea/dnd';
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
import { SwotQuadrant } from './SwotQuadrant';
import { SwotCard } from './SwotCard';
import { ActionPanel } from './ActionPanel';
import { moveSwotItem } from '@/actions/swot';
type ActionWithLinks = Action & {
links: (ActionLink & { swotItem: SwotItem })[];
};
interface SwotBoardProps {
sessionId: string;
items: SwotItem[];
actions: ActionWithLinks[];
}
const QUADRANTS: { category: SwotCategory; title: string; icon: string }[] = [
{ category: 'STRENGTH', title: 'Forces', icon: '💪' },
{ category: 'WEAKNESS', title: 'Faiblesses', icon: '⚠️' },
{ category: 'OPPORTUNITY', title: 'Opportunités', icon: '🚀' },
{ category: 'THREAT', title: 'Menaces', icon: '🛡️' },
];
export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
const [isPending, startTransition] = useTransition();
const [linkMode, setLinkMode] = useState(false);
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [highlightedItems, setHighlightedItems] = useState<string[]>([]);
const itemsByCategory = QUADRANTS.reduce(
(acc, q) => {
acc[q.category] = items
.filter((item) => item.category === q.category)
.sort((a, b) => a.order - b.order);
return acc;
},
{} as Record<SwotCategory, SwotItem[]>
);
function handleDragEnd(result: DropResult) {
if (!result.destination) return;
const { source, destination, draggableId } = result;
const sourceCategory = source.droppableId as SwotCategory;
const destCategory = destination.droppableId as SwotCategory;
// If same position, do nothing
if (sourceCategory === destCategory && source.index === destination.index) {
return;
}
startTransition(async () => {
await moveSwotItem(draggableId, sessionId, destCategory, destination.index);
});
}
function toggleItemSelection(itemId: string) {
if (!linkMode) return;
setSelectedItems((prev) =>
prev.includes(itemId)
? prev.filter((id) => id !== itemId)
: [...prev, itemId]
);
}
function handleActionHover(linkedItemIds: string[]) {
setHighlightedItems(linkedItemIds);
}
function handleActionLeave() {
setHighlightedItems([]);
}
function exitLinkMode() {
setLinkMode(false);
setSelectedItems([]);
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
{/* Link Mode Banner */}
{linkMode && (
<div className="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/10 p-4">
<div className="flex items-center gap-3">
<span className="text-2xl">🔗</span>
<div>
<p className="font-medium text-foreground">Mode Liaison</p>
<p className="text-sm text-muted">
Sélectionnez les items à lier ({selectedItems.length} sélectionné
{selectedItems.length > 1 ? 's' : ''})
</p>
</div>
</div>
<button
onClick={exitLinkMode}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium hover:bg-card-hover"
>
Annuler
</button>
</div>
)}
{/* SWOT Matrix */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{QUADRANTS.map((quadrant) => (
<Droppable key={quadrant.category} droppableId={quadrant.category}>
{(provided, snapshot) => (
<SwotQuadrant
category={quadrant.category}
title={quadrant.title}
icon={quadrant.icon}
sessionId={sessionId}
isDraggingOver={snapshot.isDraggingOver}
ref={provided.innerRef}
{...provided.droppableProps}
>
{itemsByCategory[quadrant.category].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(dragProvided, dragSnapshot) => (
<SwotCard
item={item}
sessionId={sessionId}
isSelected={selectedItems.includes(item.id)}
isHighlighted={highlightedItems.includes(item.id)}
isDragging={dragSnapshot.isDragging}
linkMode={linkMode}
onSelect={() => toggleItemSelection(item.id)}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
/>
)}
</Draggable>
))}
{provided.placeholder}
</SwotQuadrant>
)}
</Droppable>
))}
</div>
</DragDropContext>
{/* Actions Panel */}
<ActionPanel
sessionId={sessionId}
actions={actions}
allItems={items}
linkMode={linkMode}
selectedItems={selectedItems}
onEnterLinkMode={() => setLinkMode(true)}
onExitLinkMode={exitLinkMode}
onClearSelection={() => setSelectedItems([])}
onActionHover={handleActionHover}
onActionLeave={handleActionLeave}
/>
</div>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import { forwardRef, useState, useTransition } from 'react';
import type { SwotItem, SwotCategory } from '@prisma/client';
import { updateSwotItem, deleteSwotItem } from '@/actions/swot';
interface SwotCardProps {
item: SwotItem;
sessionId: string;
isSelected: boolean;
isHighlighted: boolean;
isDragging: boolean;
linkMode: boolean;
onSelect: () => void;
}
const categoryStyles: Record<SwotCategory, { ring: string; text: string }> = {
STRENGTH: { ring: 'ring-strength', text: 'text-strength' },
WEAKNESS: { ring: 'ring-weakness', text: 'text-weakness' },
OPPORTUNITY: { ring: 'ring-opportunity', text: 'text-opportunity' },
THREAT: { ring: 'ring-threat', text: 'text-threat' },
};
export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
(
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
ref
) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
const [isPending, startTransition] = useTransition();
const styles = categoryStyles[item.category];
async function handleSave() {
if (content.trim() === item.content) {
setIsEditing(false);
return;
}
if (!content.trim()) {
// If empty, delete
startTransition(async () => {
await deleteSwotItem(item.id, sessionId);
});
return;
}
startTransition(async () => {
await updateSwotItem(item.id, sessionId, { content: content.trim() });
setIsEditing(false);
});
}
async function handleDelete() {
startTransition(async () => {
await deleteSwotItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setContent(item.content);
setIsEditing(false);
}
}
function handleClick() {
if (linkMode) {
onSelect();
}
}
return (
<div
ref={ref}
onClick={handleClick}
className={`
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
${isSelected ? `ring-2 ${styles.ring}` : ''}
${isHighlighted ? 'ring-2 ring-accent animate-pulse' : ''}
${linkMode ? 'cursor-pointer hover:ring-2 hover:ring-primary/50' : ''}
${isPending ? 'opacity-50' : ''}
`}
{...props}
>
{isEditing ? (
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
) : (
<>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
{/* Actions (visible on hover) */}
{!linkMode && (
<div className="absolute right-1 top-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
{/* Selection indicator in link mode */}
{linkMode && isSelected && (
<div className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
)}
</>
)}
</div>
);
}
);
SwotCard.displayName = 'SwotCard';

View File

@@ -0,0 +1,149 @@
'use client';
import { forwardRef, useState, useTransition, ReactNode } from 'react';
import type { SwotCategory } from '@prisma/client';
import { createSwotItem } from '@/actions/swot';
interface SwotQuadrantProps {
category: SwotCategory;
title: string;
icon: string;
sessionId: string;
isDraggingOver: boolean;
children: ReactNode;
}
const categoryStyles: Record<SwotCategory, { bg: string; border: string; text: string }> = {
STRENGTH: {
bg: 'bg-strength-bg',
border: 'border-strength-border',
text: 'text-strength',
},
WEAKNESS: {
bg: 'bg-weakness-bg',
border: 'border-weakness-border',
text: 'text-weakness',
},
OPPORTUNITY: {
bg: 'bg-opportunity-bg',
border: 'border-opportunity-border',
text: 'text-opportunity',
},
THREAT: {
bg: 'bg-threat-bg',
border: 'border-threat-border',
text: 'text-threat',
},
};
export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
({ category, title, icon, sessionId, isDraggingOver, children, ...props }, ref) => {
const [isAdding, setIsAdding] = useState(false);
const [newContent, setNewContent] = useState('');
const [isPending, startTransition] = useTransition();
const styles = categoryStyles[category];
async function handleAdd() {
if (!newContent.trim()) {
setIsAdding(false);
return;
}
startTransition(async () => {
await createSwotItem(sessionId, {
content: newContent.trim(),
category,
});
setNewContent('');
setIsAdding(false);
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAdd();
} else if (e.key === 'Escape') {
setIsAdding(false);
setNewContent('');
}
}
return (
<div
ref={ref}
className={`
rounded-xl border-2 p-4 min-h-[250px] transition-colors
${styles.bg} ${styles.border}
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
{...props}
>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{icon}</span>
<h3 className={`font-semibold ${styles.text}`}>{title}</h3>
</div>
<button
onClick={() => setIsAdding(true)}
className={`
rounded-lg p-1.5 transition-colors
hover:bg-white/50 ${styles.text}
`}
aria-label={`Ajouter un item ${title}`}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* Items */}
<div className="space-y-2">
{children}
{/* Add Form */}
{isAdding && (
<div className="rounded-lg border border-border bg-card p-2 shadow-sm">
<textarea
autoFocus
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleAdd}
placeholder="Décrivez cet élément..."
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={2}
disabled={isPending}
/>
<div className="mt-1 flex justify-end gap-1">
<button
onClick={() => {
setIsAdding(false);
setNewContent('');
}}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
disabled={isPending}
>
Annuler
</button>
<button
onClick={handleAdd}
disabled={isPending || !newContent.trim()}
className={`rounded px-2 py-1 text-xs font-medium ${styles.text} hover:bg-white/50 disabled:opacity-50`}
>
{isPending ? '...' : 'Ajouter'}
</button>
</div>
</div>
)}
</div>
</div>
);
}
);
SwotQuadrant.displayName = 'SwotQuadrant';

View File

@@ -0,0 +1,5 @@
export { SwotBoard } from './SwotBoard';
export { SwotQuadrant } from './SwotQuadrant';
export { SwotCard } from './SwotCard';
export { ActionPanel } from './ActionPanel';

215
src/services/sessions.ts Normal file
View File

@@ -0,0 +1,215 @@
import { prisma } from '@/services/database';
import type { SwotCategory } from '@prisma/client';
// ============================================
// Session CRUD
// ============================================
export async function getSessionsByUserId(userId: string) {
return prisma.session.findMany({
where: { userId },
include: {
_count: {
select: {
items: true,
actions: true,
},
},
},
orderBy: { updatedAt: 'desc' },
});
}
export async function getSessionById(sessionId: string, userId: string) {
return prisma.session.findFirst({
where: {
id: sessionId,
userId,
},
include: {
items: {
orderBy: { order: 'asc' },
},
actions: {
include: {
links: {
include: {
swotItem: true,
},
},
},
orderBy: { createdAt: 'asc' },
},
},
});
}
export async function createSession(userId: string, data: { title: string; collaborator: string }) {
return prisma.session.create({
data: {
...data,
userId,
},
});
}
export async function updateSession(
sessionId: string,
userId: string,
data: { title?: string; collaborator?: string }
) {
return prisma.session.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteSession(sessionId: string, userId: string) {
return prisma.session.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// SWOT Items CRUD
// ============================================
export async function createSwotItem(
sessionId: string,
data: { content: string; category: SwotCategory }
) {
// Get max order for this category
const maxOrder = await prisma.swotItem.aggregate({
where: { sessionId, category: data.category },
_max: { order: true },
});
return prisma.swotItem.create({
data: {
...data,
sessionId,
order: (maxOrder._max.order ?? -1) + 1,
},
});
}
export async function updateSwotItem(
itemId: string,
data: { content?: string; category?: SwotCategory; order?: number }
) {
return prisma.swotItem.update({
where: { id: itemId },
data,
});
}
export async function deleteSwotItem(itemId: string) {
return prisma.swotItem.delete({
where: { id: itemId },
});
}
export async function reorderSwotItems(
sessionId: string,
category: SwotCategory,
itemIds: string[]
) {
const updates = itemIds.map((id, index) =>
prisma.swotItem.update({
where: { id },
data: { order: index },
})
);
return prisma.$transaction(updates);
}
export async function moveSwotItem(
itemId: string,
newCategory: SwotCategory,
newOrder: number
) {
return prisma.swotItem.update({
where: { id: itemId },
data: {
category: newCategory,
order: newOrder,
},
});
}
// ============================================
// Actions CRUD
// ============================================
export async function createAction(
sessionId: string,
data: {
title: string;
description?: string;
priority?: number;
linkedItemIds: string[];
}
) {
return prisma.action.create({
data: {
title: data.title,
description: data.description,
priority: data.priority ?? 0,
sessionId,
links: {
create: data.linkedItemIds.map((swotItemId) => ({
swotItemId,
})),
},
},
include: {
links: {
include: {
swotItem: true,
},
},
},
});
}
export async function updateAction(
actionId: string,
data: {
title?: string;
description?: string;
priority?: number;
status?: string;
dueDate?: Date | null;
}
) {
return prisma.action.update({
where: { id: actionId },
data,
});
}
export async function deleteAction(actionId: string) {
return prisma.action.delete({
where: { id: actionId },
});
}
export async function linkItemToAction(actionId: string, swotItemId: string) {
return prisma.actionLink.create({
data: {
actionId,
swotItemId,
},
});
}
export async function unlinkItemFromAction(actionId: string, swotItemId: string) {
return prisma.actionLink.deleteMany({
where: {
actionId,
swotItemId,
},
});
}