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(
|
export async function moveSwotItem(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -172,64 +172,24 @@ 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">
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(action)}
|
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"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -238,13 +198,12 @@ export function ActionPanel({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(action.id)}
|
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"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -255,6 +214,54 @@ export function ActionPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user