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:
343
src/components/swot/ActionPanel.tsx
Normal file
343
src/components/swot/ActionPanel.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user