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

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(
itemId: string,
sessionId: string,

View File

@@ -172,64 +172,24 @@ export function ActionPanel({
Créez des actions en sélectionnant plusieurs items SWOT.
</p>
) : (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-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"
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}
>
<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>
{/* 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.5 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
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-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -238,13 +198,12 @@ export function ActionPanel({
/>
</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"
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-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -255,6 +214,54 @@ export function ActionPanel({
</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>

View File

@@ -2,7 +2,7 @@
import { forwardRef, useState, useTransition } from 'react';
import type { SwotItem, SwotCategory } from '@prisma/client';
import { updateSwotItem, deleteSwotItem } from '@/actions/swot';
import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
interface SwotCardProps {
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) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -105,7 +111,7 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
{/* Actions (visible on hover) */}
{!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
onClick={(e) => {
e.stopPropagation();
@@ -123,6 +129,23 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
/>
</svg>
</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
onClick={(e) => {
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(
sessionId: string,
category: SwotCategory,