All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
405 lines
14 KiB
TypeScript
405 lines
14 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,
|
|
Select,
|
|
IconButton,
|
|
IconEdit,
|
|
IconTrash,
|
|
} 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 statusOptions = [
|
|
{ value: 'todo', label: '📋 À faire' },
|
|
{ value: 'in_progress', label: '⏳ En cours' },
|
|
{ value: 'done', label: '✅ 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'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">
|
|
<IconButton
|
|
icon={<IconEdit />}
|
|
label="Modifier"
|
|
onClick={() => openEditModal(action)}
|
|
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<IconButton
|
|
icon={<IconTrash />}
|
|
label="Supprimer"
|
|
variant="destructive"
|
|
onClick={() => handleDelete(action.id)}
|
|
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
</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)}
|
|
options={statusOptions}
|
|
size="xs"
|
|
wrapperClassName="!w-auto shrink-0"
|
|
className="!w-auto"
|
|
disabled={isPending}
|
|
/>
|
|
</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"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPriority(index)}
|
|
className={`
|
|
flex-1
|
|
${
|
|
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" size="sm" onClick={closeModal}>
|
|
Annuler
|
|
</Button>
|
|
<Button type="submit" size="sm" loading={isPending}>
|
|
{editingAction ? 'Enregistrer' : "Créer l'action"}
|
|
</Button>
|
|
</ModalFooter>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|