feat: add duplicate functionality for SWOT items, enhance ActionPanel layout, and update SwotCard with duplicate action

This commit is contained in:
Julien Froidefond
2025-11-27 13:22:57 +01:00
parent 628d64a5c6
commit 9ce2b62bc6
5 changed files with 124 additions and 53 deletions

BIN
dev.db

Binary file not shown.

View File

@@ -64,6 +64,22 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
} }
} }
export async function duplicateSwotItem(itemId: string, sessionId: string) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const item = await sessionsService.duplicateSwotItem(itemId);
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
console.error('Error duplicating SWOT item:', error);
return { success: false, error: 'Erreur lors de la duplication' };
}
}
export async function moveSwotItem( export async function moveSwotItem(
itemId: string, itemId: string,
sessionId: string, sessionId: string,

View File

@@ -172,18 +172,74 @@ export function ActionPanel({
Créez des actions en sélectionnant plusieurs items SWOT. Créez des actions en sélectionnant plusieurs items SWOT.
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{actions.map((action) => ( {actions.map((action) => (
<div <div
key={action.id} key={action.id}
className="group rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/30" 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))} onMouseEnter={() => onActionHover(action.links.map((l) => l.swotItemId))}
onMouseLeave={onActionLeave} onMouseLeave={onActionLeave}
> >
<div className="flex items-start justify-between gap-4"> {/* Header with title & actions */}
<div className="flex-1"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2"> <h3 className="font-medium text-foreground line-clamp-2">{action.title}</h3>
<h3 className="font-medium text-foreground">{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 <Badge
variant={ variant={
action.priority === 2 action.priority === 2
@@ -195,65 +251,16 @@ export function ActionPanel({
> >
{priorityLabels[action.priority]} {priorityLabels[action.priority]}
</Badge> </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 <select
value={action.status} value={action.status}
onChange={(e) => handleStatusChange(action, e.target.value)} onChange={(e) => handleStatusChange(action, e.target.value)}
className="rounded-lg border border-border bg-card px-2 py-1 text-sm text-foreground" className="rounded border border-border bg-card px-2 py-1 text-xs text-foreground"
disabled={isPending} disabled={isPending}
> >
<option value="todo">{statusLabels.todo}</option> <option value="todo">{statusLabels.todo}</option>
<option value="in_progress">{statusLabels.in_progress}</option> <option value="in_progress">{statusLabels.in_progress}</option>
<option value="done">{statusLabels.done}</option> <option value="done">{statusLabels.done}</option>
</select> </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> </div>
))} ))}

View File

@@ -2,7 +2,7 @@
import { forwardRef, useState, useTransition } from 'react'; import { forwardRef, useState, useTransition } from 'react';
import type { SwotItem, SwotCategory } from '@prisma/client'; import type { SwotItem, SwotCategory } from '@prisma/client';
import { updateSwotItem, deleteSwotItem } from '@/actions/swot'; import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
interface SwotCardProps { interface SwotCardProps {
item: SwotItem; item: SwotItem;
@@ -58,6 +58,12 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
}); });
} }
async function handleDuplicate() {
startTransition(async () => {
await duplicateSwotItem(item.id, sessionId);
});
}
function handleKeyDown(e: React.KeyboardEvent) { function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -105,7 +111,7 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
{/* Actions (visible on hover) */} {/* Actions (visible on hover) */}
{!linkMode && ( {!linkMode && (
<div className="absolute right-1 top-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -123,6 +129,23 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
/> />
</svg> </svg>
</button> </button>
<button
onClick={(e) => {
e.stopPropagation();
handleDuplicate();
}}
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
aria-label="Dupliquer"
>
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -109,6 +109,31 @@ export async function deleteSwotItem(itemId: string) {
}); });
} }
export async function duplicateSwotItem(itemId: string) {
const original = await prisma.swotItem.findUnique({
where: { id: itemId },
});
if (!original) {
throw new Error('Item not found');
}
// Get max order for this category
const maxOrder = await prisma.swotItem.aggregate({
where: { sessionId: original.sessionId, category: original.category },
_max: { order: true },
});
return prisma.swotItem.create({
data: {
content: original.content,
category: original.category,
sessionId: original.sessionId,
order: (maxOrder._max.order ?? -1) + 1,
},
});
}
export async function reorderSwotItems( export async function reorderSwotItems(
sessionId: string, sessionId: string,
category: SwotCategory, category: SwotCategory,