feat: add Weekly Check-in feature with models, UI components, and session management for enhanced team collaboration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m24s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m24s
This commit is contained in:
28
src/components/ui/EditableWeeklyCheckInTitle.tsx
Normal file
28
src/components/ui/EditableWeeklyCheckInTitle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { EditableTitle } from './EditableTitle';
|
||||
import { updateWeeklyCheckInSession } from '@/actions/weekly-checkin';
|
||||
|
||||
interface EditableWeeklyCheckInTitleProps {
|
||||
sessionId: string;
|
||||
initialTitle: string;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function EditableWeeklyCheckInTitle({
|
||||
sessionId,
|
||||
initialTitle,
|
||||
isOwner,
|
||||
}: EditableWeeklyCheckInTitleProps) {
|
||||
return (
|
||||
<EditableTitle
|
||||
sessionId={sessionId}
|
||||
initialTitle={initialTitle}
|
||||
isOwner={isOwner}
|
||||
onUpdate={async (id, title) => {
|
||||
const result = await updateWeeklyCheckInSession(id, { title });
|
||||
return result;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { EditableTitle } from './EditableTitle';
|
||||
export { EditableSessionTitle } from './EditableSessionTitle';
|
||||
export { EditableMotivatorTitle } from './EditableMotivatorTitle';
|
||||
export { EditableYearReviewTitle } from './EditableYearReviewTitle';
|
||||
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
|
||||
export { Input } from './Input';
|
||||
export { Modal, ModalFooter } from './Modal';
|
||||
export { Select } from './Select';
|
||||
|
||||
161
src/components/weekly-checkin/CurrentQuarterOKRs.tsx
Normal file
161
src/components/weekly-checkin/CurrentQuarterOKRs.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
|
||||
import { Badge } from '@/components/ui';
|
||||
import type { OKR } from '@/lib/types';
|
||||
import { OKR_STATUS_LABELS } from '@/lib/types';
|
||||
|
||||
type OKRWithTeam = OKR & {
|
||||
team?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
interface CurrentQuarterOKRsProps {
|
||||
okrs: OKRWithTeam[];
|
||||
period: string;
|
||||
}
|
||||
|
||||
export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (okrs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span>🎯</span>
|
||||
<span>Objectifs du trimestre ({period})</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{okrs.map((okr) => {
|
||||
const statusColors = getOKRStatusColor(okr.status);
|
||||
return (
|
||||
<div
|
||||
key={okr.id}
|
||||
className="rounded-lg border border-border bg-card p-3 hover:bg-card-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-foreground">{okr.objective}</h4>
|
||||
<Badge
|
||||
variant="default"
|
||||
style={{
|
||||
backgroundColor: statusColors.bg,
|
||||
color: statusColors.color,
|
||||
borderColor: statusColors.color + '30',
|
||||
}}
|
||||
>
|
||||
{OKR_STATUS_LABELS[okr.status]}
|
||||
</Badge>
|
||||
{okr.progress !== undefined && (
|
||||
<span className="text-xs text-muted">{okr.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{okr.description && (
|
||||
<p className="text-sm text-muted mb-2">{okr.description}</p>
|
||||
)}
|
||||
{okr.keyResults && okr.keyResults.length > 0 && (
|
||||
<ul className="space-y-1 mt-2">
|
||||
{okr.keyResults.slice(0, 3).map((kr) => {
|
||||
const krProgress = kr.targetValue > 0
|
||||
? Math.round((kr.currentValue / kr.targetValue) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<li key={kr.id} className="text-xs text-muted flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
<span className="flex-1">{kr.title}</span>
|
||||
<span className="text-muted">
|
||||
{kr.currentValue}/{kr.targetValue} {kr.unit}
|
||||
</span>
|
||||
<span className="text-xs">({krProgress}%)</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{okr.keyResults.length > 3 && (
|
||||
<li className="text-xs text-muted pl-3.5">
|
||||
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 > 1 ? 's' : ''}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{okr.team && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-muted">Équipe: {okr.team.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<Link
|
||||
href="/objectives"
|
||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
Voir tous les objectifs
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getOKRStatusColor(status: OKR['status']): { bg: string; color: string } {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #3b82f6 15%, transparent)',
|
||||
color: '#3b82f6',
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #10b981 15%, transparent)',
|
||||
color: '#10b981',
|
||||
};
|
||||
case 'CANCELLED':
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #ef4444 15%, transparent)',
|
||||
color: '#ef4444',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'color-mix(in srgb, #6b7280 15%, transparent)',
|
||||
color: '#6b7280',
|
||||
};
|
||||
}
|
||||
}
|
||||
94
src/components/weekly-checkin/WeeklyCheckInBoard.tsx
Normal file
94
src/components/weekly-checkin/WeeklyCheckInBoard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import type { WeeklyCheckInItem, WeeklyCheckInCategory } from '@prisma/client';
|
||||
import { WeeklyCheckInSection } from './WeeklyCheckInSection';
|
||||
import { WeeklyCheckInCard } from './WeeklyCheckInCard';
|
||||
import { moveWeeklyCheckInItem, reorderWeeklyCheckInItems } from '@/actions/weekly-checkin';
|
||||
import { WEEKLY_CHECK_IN_SECTIONS } from '@/lib/types';
|
||||
|
||||
interface WeeklyCheckInBoardProps {
|
||||
sessionId: string;
|
||||
items: WeeklyCheckInItem[];
|
||||
}
|
||||
|
||||
export function WeeklyCheckInBoard({ sessionId, items }: WeeklyCheckInBoardProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const itemsByCategory = WEEKLY_CHECK_IN_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<WeeklyCheckInCategory, WeeklyCheckInItem[]>
|
||||
);
|
||||
|
||||
function handleDragEnd(result: DropResult) {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination, draggableId } = result;
|
||||
const sourceCategory = source.droppableId as WeeklyCheckInCategory;
|
||||
const destCategory = destination.droppableId as WeeklyCheckInCategory;
|
||||
|
||||
// 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 reorderWeeklyCheckInItems(sessionId, sourceCategory, itemIds);
|
||||
} else {
|
||||
// Different category - move item
|
||||
await moveWeeklyCheckInItem(draggableId, sessionId, destCategory, destination.index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
{/* Weekly Check-in Sections */}
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{WEEKLY_CHECK_IN_SECTIONS.map((section) => (
|
||||
<Droppable key={section.category} droppableId={section.category}>
|
||||
{(provided, snapshot) => (
|
||||
<WeeklyCheckInSection
|
||||
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) => (
|
||||
<WeeklyCheckInCard
|
||||
item={item}
|
||||
sessionId={sessionId}
|
||||
isDragging={dragSnapshot.isDragging}
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</WeeklyCheckInSection>
|
||||
)}
|
||||
</Droppable>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/components/weekly-checkin/WeeklyCheckInCard.tsx
Normal file
200
src/components/weekly-checkin/WeeklyCheckInCard.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useState, useTransition } from 'react';
|
||||
import type { WeeklyCheckInItem } from '@prisma/client';
|
||||
import { updateWeeklyCheckInItem, deleteWeeklyCheckInItem } from '@/actions/weekly-checkin';
|
||||
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
|
||||
interface WeeklyCheckInCardProps {
|
||||
item: WeeklyCheckInItem;
|
||||
sessionId: string;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export const WeeklyCheckInCard = forwardRef<HTMLDivElement, WeeklyCheckInCardProps>(
|
||||
({ item, sessionId, isDragging, ...props }, ref) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [content, setContent] = useState(item.content);
|
||||
const [emotion, setEmotion] = useState(item.emotion);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category];
|
||||
const emotionConfig = EMOTION_BY_TYPE[item.emotion];
|
||||
|
||||
async function handleSave() {
|
||||
if (content.trim() === item.content && emotion === item.emotion) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// If empty, delete
|
||||
startTransition(async () => {
|
||||
await deleteWeeklyCheckInItem(item.id, sessionId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
await updateWeeklyCheckInItem(item.id, sessionId, {
|
||||
content: content.trim(),
|
||||
emotion,
|
||||
});
|
||||
setIsEditing(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deleteWeeklyCheckInItem(item.id, sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setContent(item.content);
|
||||
setEmotion(item.emotion);
|
||||
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 ? (
|
||||
<div
|
||||
className="space-y-2"
|
||||
onBlur={(e) => {
|
||||
// Don't close if focus moves to another element in this container
|
||||
const currentTarget = e.currentTarget;
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget && currentTarget.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
// Only save on blur if content changed
|
||||
if (content.trim() !== item.content || emotion !== item.emotion) {
|
||||
handleSave();
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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}
|
||||
/>
|
||||
<Select
|
||||
value={emotion}
|
||||
onChange={(e) => setEmotion(e.target.value as typeof emotion)}
|
||||
className="text-xs"
|
||||
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
|
||||
value: em.emotion,
|
||||
label: `${em.icon} ${em.label}`,
|
||||
}))}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setContent(item.content);
|
||||
setEmotion(item.emotion);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || !content.trim()}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '...' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap flex-1">{item.content}</p>
|
||||
{emotion !== 'NONE' && (
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${emotionConfig.color}15`,
|
||||
color: emotionConfig.color,
|
||||
border: `1px solid ${emotionConfig.color}30`,
|
||||
}}
|
||||
title={emotionConfig.label}
|
||||
>
|
||||
<span>{emotionConfig.icon}</span>
|
||||
<span>{emotionConfig.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';
|
||||
132
src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx
Normal file
132
src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive';
|
||||
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
|
||||
import { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
|
||||
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 WeeklyCheckInLiveWrapperProps {
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
currentUserId: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function WeeklyCheckInLiveWrapper({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
currentUserId,
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
children,
|
||||
}: WeeklyCheckInLiveWrapperProps) {
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
|
||||
|
||||
const handleEvent = useCallback((event: WeeklyCheckInLiveEvent) => {
|
||||
// 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 } = useWeeklyCheckInLive({
|
||||
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 */}
|
||||
<WeeklyCheckInShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
173
src/components/weekly-checkin/WeeklyCheckInSection.tsx
Normal file
173
src/components/weekly-checkin/WeeklyCheckInSection.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
|
||||
import type { WeeklyCheckInCategory } from '@prisma/client';
|
||||
import { createWeeklyCheckInItem } from '@/actions/weekly-checkin';
|
||||
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
|
||||
interface WeeklyCheckInSectionProps {
|
||||
category: WeeklyCheckInCategory;
|
||||
sessionId: string;
|
||||
isDraggingOver: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WeeklyCheckInSection = forwardRef<HTMLDivElement, WeeklyCheckInSectionProps>(
|
||||
({ category, sessionId, isDraggingOver, children, ...props }, ref) => {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newContent, setNewContent] = useState('');
|
||||
const [newEmotion, setNewEmotion] = useState<'NONE'>('NONE');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
const config = WEEKLY_CHECK_IN_BY_CATEGORY[category];
|
||||
|
||||
async function handleAdd() {
|
||||
if (isSubmittingRef.current || !newContent.trim()) {
|
||||
setIsAdding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
startTransition(async () => {
|
||||
await createWeeklyCheckInItem(sessionId, {
|
||||
content: newContent.trim(),
|
||||
category,
|
||||
emotion: newEmotion,
|
||||
});
|
||||
setNewContent('');
|
||||
setNewEmotion('NONE');
|
||||
setIsAdding(false);
|
||||
isSubmittingRef.current = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsAdding(false);
|
||||
setNewContent('');
|
||||
setNewEmotion('NONE');
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
onBlur={(e) => {
|
||||
// Don't close if focus moves to another element in this container
|
||||
const currentTarget = e.currentTarget;
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget && currentTarget.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
// Only add on blur if content is not empty
|
||||
if (newContent.trim()) {
|
||||
handleAdd();
|
||||
} else {
|
||||
setIsAdding(false);
|
||||
setNewContent('');
|
||||
setNewEmotion('NONE');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={newContent}
|
||||
onChange={(e) => setNewContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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-2 flex items-center justify-between gap-2">
|
||||
<Select
|
||||
value={newEmotion}
|
||||
onChange={(e) => setNewEmotion(e.target.value as typeof newEmotion)}
|
||||
className="text-xs flex-1"
|
||||
options={Object.values(EMOTION_BY_TYPE).map((em) => ({
|
||||
value: em.emotion,
|
||||
label: `${em.icon} ${em.label}`,
|
||||
}))}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewContent('');
|
||||
setNewEmotion('NONE');
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Prevent blur from textarea
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
WeeklyCheckInSection.displayName = 'WeeklyCheckInSection';
|
||||
172
src/components/weekly-checkin/WeeklyCheckInShareModal.tsx
Normal file
172
src/components/weekly-checkin/WeeklyCheckInShareModal.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'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 { shareWeeklyCheckInSession, removeWeeklyCheckInShare } from '@/actions/weekly-checkin';
|
||||
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 WeeklyCheckInShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function WeeklyCheckInShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
shares,
|
||||
isOwner,
|
||||
}: WeeklyCheckInShareModalProps) {
|
||||
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 shareWeeklyCheckInSession(sessionId, email, role);
|
||||
if (result.success) {
|
||||
setEmail('');
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du partage');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
startTransition(async () => {
|
||||
await removeWeeklyCheckInShare(sessionId, userId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Partager le check-in">
|
||||
<div className="space-y-6">
|
||||
{/* Session info */}
|
||||
<div>
|
||||
<p className="text-sm text-muted">Check-in hebdomadaire</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>
|
||||
);
|
||||
}
|
||||
6
src/components/weekly-checkin/index.ts
Normal file
6
src/components/weekly-checkin/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { WeeklyCheckInBoard } from './WeeklyCheckInBoard';
|
||||
export { WeeklyCheckInCard } from './WeeklyCheckInCard';
|
||||
export { WeeklyCheckInSection } from './WeeklyCheckInSection';
|
||||
export { WeeklyCheckInLiveWrapper } from './WeeklyCheckInLiveWrapper';
|
||||
export { WeeklyCheckInShareModal } from './WeeklyCheckInShareModal';
|
||||
export { CurrentQuarterOKRs } from './CurrentQuarterOKRs';
|
||||
Reference in New Issue
Block a user