feat: implement Year Review feature with session management, item categorization, and real-time collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s
This commit is contained in:
94
src/components/year-review/YearReviewBoard.tsx
Normal file
94
src/components/year-review/YearReviewBoard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import type { YearReviewItem, YearReviewCategory } from '@prisma/client';
|
||||
import { YearReviewSection } from './YearReviewSection';
|
||||
import { YearReviewCard } from './YearReviewCard';
|
||||
import { moveYearReviewItem, reorderYearReviewItems } from '@/actions/year-review';
|
||||
import { YEAR_REVIEW_SECTIONS } from '@/lib/types';
|
||||
|
||||
interface YearReviewBoardProps {
|
||||
sessionId: string;
|
||||
items: YearReviewItem[];
|
||||
}
|
||||
|
||||
export function YearReviewBoard({ sessionId, items }: YearReviewBoardProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const itemsByCategory = YEAR_REVIEW_SECTIONS.reduce(
|
||||
(acc, section) => {
|
||||
acc[section.category] = items
|
||||
.filter((item) => item.category === section.category)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<YearReviewCategory, YearReviewItem[]>
|
||||
);
|
||||
|
||||
function handleDragEnd(result: DropResult) {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination, draggableId } = result;
|
||||
const sourceCategory = source.droppableId as YearReviewCategory;
|
||||
const destCategory = destination.droppableId as YearReviewCategory;
|
||||
|
||||
// If same position, do nothing
|
||||
if (sourceCategory === destCategory && source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
if (sourceCategory === destCategory) {
|
||||
// Same category - just reorder
|
||||
const categoryItems = itemsByCategory[sourceCategory];
|
||||
const itemIds = categoryItems.map((item) => item.id);
|
||||
const [removed] = itemIds.splice(source.index, 1);
|
||||
itemIds.splice(destination.index, 0, removed);
|
||||
await reorderYearReviewItems(sessionId, sourceCategory, itemIds);
|
||||
} else {
|
||||
// Different category - move item
|
||||
await moveYearReviewItem(draggableId, sessionId, destCategory, destination.index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
{/* Year Review Sections */}
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{YEAR_REVIEW_SECTIONS.map((section) => (
|
||||
<Droppable key={section.category} droppableId={section.category}>
|
||||
{(provided, snapshot) => (
|
||||
<YearReviewSection
|
||||
category={section.category}
|
||||
sessionId={sessionId}
|
||||
isDraggingOver={snapshot.isDraggingOver}
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{itemsByCategory[section.category].map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(dragProvided, dragSnapshot) => (
|
||||
<YearReviewCard
|
||||
item={item}
|
||||
sessionId={sessionId}
|
||||
isDragging={dragSnapshot.isDragging}
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</YearReviewSection>
|
||||
)}
|
||||
</Droppable>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/year-review/YearReviewCard.tsx
Normal file
131
src/components/year-review/YearReviewCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useState, useTransition } from 'react';
|
||||
import type { YearReviewItem } from '@prisma/client';
|
||||
import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review';
|
||||
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
|
||||
|
||||
interface YearReviewCardProps {
|
||||
item: YearReviewItem;
|
||||
sessionId: string;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export const YearReviewCard = forwardRef<HTMLDivElement, YearReviewCardProps>(
|
||||
({ item, sessionId, isDragging, ...props }, ref) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [content, setContent] = useState(item.content);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
|
||||
|
||||
async function handleSave() {
|
||||
if (content.trim() === item.content) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// If empty, delete
|
||||
startTransition(async () => {
|
||||
await deleteYearReviewItem(item.id, sessionId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
await updateYearReviewItem(item.id, sessionId, { content: content.trim() });
|
||||
setIsEditing(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deleteYearReviewItem(item.id, sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setContent(item.content);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
||||
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
||||
${isPending ? 'opacity-50' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderLeftColor: config.color,
|
||||
borderLeftWidth: '3px',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||
rows={2}
|
||||
disabled={isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{item.content}</p>
|
||||
|
||||
{/* Actions (visible on hover) */}
|
||||
<div className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
|
||||
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={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
YearReviewCard.displayName = 'YearReviewCard';
|
||||
133
src/components/year-review/YearReviewLiveWrapper.tsx
Normal file
133
src/components/year-review/YearReviewLiveWrapper.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
|
||||
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
|
||||
import { YearReviewShareModal } from './YearReviewShareModal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import type { ShareRole } from '@prisma/client';
|
||||
|
||||
interface ShareUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
role: ShareRole;
|
||||
user: ShareUser;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface YearReviewLiveWrapperProps {
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
currentUserId: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function YearReviewLiveWrapper({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
currentUserId,
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
children,
|
||||
}: YearReviewLiveWrapperProps) {
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
|
||||
|
||||
const handleEvent = useCallback((event: YearReviewLiveEvent) => {
|
||||
// Show who made the last change
|
||||
if (event.user?.name || event.user?.email) {
|
||||
setLastEventUser(event.user.name || event.user.email);
|
||||
// Clear after 3 seconds
|
||||
setTimeout(() => setLastEventUser(null), 3000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { isConnected, error } = useYearReviewLive({
|
||||
sessionId,
|
||||
currentUserId,
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header toolbar */}
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<LiveIndicator isConnected={isConnected} error={error} />
|
||||
|
||||
{lastEventUser && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
||||
<span>✏️</span>
|
||||
<span>{lastEventUser} édite...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canEdit && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
|
||||
<span>👁️</span>
|
||||
<span>Mode lecture</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Collaborators avatars */}
|
||||
{shares.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{shares.slice(0, 3).map((share) => (
|
||||
<Avatar
|
||||
key={share.id}
|
||||
email={share.user.email}
|
||||
name={share.user.name}
|
||||
size={32}
|
||||
className="border-2 border-card"
|
||||
/>
|
||||
))}
|
||||
{shares.length > 3 && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
|
||||
+{shares.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
|
||||
</svg>
|
||||
Partager
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
|
||||
|
||||
{/* Share Modal */}
|
||||
<YearReviewShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
134
src/components/year-review/YearReviewSection.tsx
Normal file
134
src/components/year-review/YearReviewSection.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useState, useTransition, ReactNode } from 'react';
|
||||
import type { YearReviewCategory } from '@prisma/client';
|
||||
import { createYearReviewItem } from '@/actions/year-review';
|
||||
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
|
||||
|
||||
interface YearReviewSectionProps {
|
||||
category: YearReviewCategory;
|
||||
sessionId: string;
|
||||
isDraggingOver: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const YearReviewSection = forwardRef<HTMLDivElement, YearReviewSectionProps>(
|
||||
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newContent, setNewContent] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const config = YEAR_REVIEW_BY_CATEGORY[category];
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newContent.trim()) {
|
||||
setIsAdding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
await createYearReviewItem(sessionId, {
|
||||
content: newContent.trim(),
|
||||
category,
|
||||
});
|
||||
setNewContent('');
|
||||
setIsAdding(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsAdding(false);
|
||||
setNewContent('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
rounded-xl border-2 p-4 min-h-[200px] transition-colors
|
||||
bg-card border-border
|
||||
${isDraggingOver ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderLeftColor: config.color,
|
||||
borderLeftWidth: '4px',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{config.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{config.title}</h3>
|
||||
<p className="text-xs text-muted">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-card-hover text-muted hover:text-foreground"
|
||||
aria-label={`Ajouter un item ${config.title}`}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-2">
|
||||
{children}
|
||||
|
||||
{/* Add Form */}
|
||||
{isAdding && (
|
||||
<div className="rounded-lg border border-border bg-card p-2 shadow-sm">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={newContent}
|
||||
onChange={(e) => setNewContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleAdd}
|
||||
placeholder={`Décrivez ${config.title.toLowerCase()}...`}
|
||||
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
|
||||
rows={2}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewContent('');
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={isPending || !newContent.trim()}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '...' : 'Ajouter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
YearReviewSection.displayName = 'YearReviewSection';
|
||||
|
||||
173
src/components/year-review/YearReviewShareModal.tsx
Normal file
173
src/components/year-review/YearReviewShareModal.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
|
||||
import type { ShareRole } from '@prisma/client';
|
||||
|
||||
interface ShareUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
role: ShareRole;
|
||||
user: ShareUser;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface YearReviewShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function YearReviewShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
shares,
|
||||
isOwner,
|
||||
}: YearReviewShareModalProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<ShareRole>('EDITOR');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function handleShare(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await shareYearReviewSession(sessionId, email, role);
|
||||
if (result.success) {
|
||||
setEmail('');
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du partage');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
startTransition(async () => {
|
||||
await removeYearReviewShare(sessionId, userId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Partager le bilan">
|
||||
<div className="space-y-6">
|
||||
{/* Session info */}
|
||||
<div>
|
||||
<p className="text-sm text-muted">Bilan annuel</p>
|
||||
<p className="font-medium text-foreground">{sessionTitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Share form (only for owner) */}
|
||||
{isOwner && (
|
||||
<form onSubmit={handleShare} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email de l'utilisateur"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="flex-1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
||||
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="EDITOR">Éditeur</option>
|
||||
<option value="VIEWER">Lecteur</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={isPending || !email} className="w-full">
|
||||
{isPending ? 'Partage...' : 'Partager'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Current shares */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
|
||||
|
||||
{shares.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{shares.map((share) => (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar email={share.user.email} name={share.user.name} size={32} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{share.user.name || share.user.email}
|
||||
</p>
|
||||
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||
</Badge>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleRemove(share.user.id)}
|
||||
disabled={isPending}
|
||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Retirer l'accès"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="rounded-lg bg-primary/5 p-3">
|
||||
<p className="text-xs text-muted">
|
||||
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
|
||||
<br />
|
||||
<strong>Lecteur</strong> : peut uniquement consulter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/components/year-review/index.ts
Normal file
5
src/components/year-review/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { YearReviewBoard } from './YearReviewBoard';
|
||||
export { YearReviewCard } from './YearReviewCard';
|
||||
export { YearReviewSection } from './YearReviewSection';
|
||||
export { YearReviewLiveWrapper } from './YearReviewLiveWrapper';
|
||||
export { YearReviewShareModal } from './YearReviewShareModal';
|
||||
Reference in New Issue
Block a user