feat: add duplicate functionality for SWOT items, enhance ActionPanel layout, and update SwotCard with duplicate action
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -172,18 +172,74 @@ 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>
|
||||
{/* 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
|
||||
@@ -195,65 +251,16 @@ export function ActionPanel({
|
||||
>
|
||||
{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"
|
||||
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>
|
||||
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user