Files
workshop-manager/src/components/swot/ActionPanel.tsx
Julien Froidefond d735e1c4c5
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5s
feat: add linked item management to action updates in SWOT analysis
2025-12-15 13:34:09 +01:00

420 lines
15 KiB
TypeScript

'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);
const [editingSelectedItems, setEditingSelectedItems] = useState<string[]>([]);
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);
setEditingSelectedItems([]);
setEditingAction(null);
setShowModal(true);
}
function openEditModal(action: ActionWithLinks) {
setTitle(action.title);
setDescription(action.description || '');
setPriority(action.priority);
setEditingSelectedItems(action.links.map((link) => link.swotItemId));
setEditingAction(action);
setShowModal(true);
}
function closeModal() {
setShowModal(false);
setEditingAction(null);
setEditingSelectedItems([]);
onExitLinkMode();
}
function toggleEditingItem(itemId: string) {
setEditingSelectedItems((prev) =>
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
startTransition(async () => {
if (editingAction) {
if (editingSelectedItems.length < 2) {
alert('Une action doit être liée à au moins 2 items SWOT');
return;
}
await updateAction(editingAction.id, sessionId, {
title: title.trim(),
description: description.trim() || undefined,
priority,
linkedItemIds: editingSelectedItems,
});
} 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="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{actions.map((action) => (
<div
key={action.id}
className="group flex flex-col 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}
>
{/* Header with title & actions */}
<div className="flex items-start justify-between gap-2">
<h3 className="font-medium text-foreground line-clamp-2">{action.title}</h3>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => openEditModal(action)}
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
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={() => handleDelete(action.id)}
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
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>
</div>
{/* Description */}
{action.description && (
<p className="mt-1 text-sm text-muted line-clamp-2">{action.description}</p>
)}
{/* Linked Items */}
<div className="mt-3 flex flex-wrap content-start gap-1.5 max-h-24 overflow-y-auto">
{action.links.map((link) => (
<Badge
key={link.id}
variant={categoryBadgeVariant[link.swotItem.category]}
className="text-xs max-w-full h-fit"
title={link.swotItem.content}
>
<span className="truncate">
{link.swotItem.content.length > 25
? link.swotItem.content.slice(0, 25) + '...'
: link.swotItem.content}
</span>
</Badge>
))}
</div>
{/* Footer with status & priority */}
<div className="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3">
<Badge
variant={
action.priority === 2
? 'destructive'
: action.priority === 1
? 'warning'
: 'default'
}
>
{priorityLabels[action.priority]}
</Badge>
<select
value={action.status}
onChange={(e) => handleStatusChange(action, e.target.value)}
className="rounded border border-border bg-card px-2 py-1 text-xs 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>
</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>
)}
{editingAction && (
<div className="mb-4">
<p className="mb-2 text-sm font-medium text-foreground">
Items liés ({editingSelectedItems.length}) :
</p>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border bg-background p-3">
{allItems.map((item) => {
const isSelected = editingSelectedItems.includes(item.id);
return (
<label
key={item.id}
className={`
flex cursor-pointer items-center gap-2 rounded-lg border p-2 transition-colors
${
isSelected
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:bg-card-hover'
}
`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleEditingItem(item.id)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<Badge variant={categoryBadgeVariant[item.category]} className="shrink-0">
{categoryShort[item.category]}
</Badge>
<span className="text-sm text-foreground">{item.content}</span>
</label>
);
})}
</div>
{editingSelectedItems.length < 2 && (
<p className="mt-2 text-xs text-destructive">
Sélectionnez au moins 2 items SWOT
</p>
)}
</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>
);
}