refactor: improve team management, OKRs, and session components
This commit is contained in:
@@ -18,9 +18,15 @@ interface ShareModalConfig {
|
||||
interface BaseSessionLiveWrapperConfig {
|
||||
apiPath: LiveApiPath;
|
||||
shareModal: ShareModalConfig;
|
||||
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
|
||||
onShareWithEmail: (
|
||||
email: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onRemoveShare: (userId: string) => Promise<unknown>;
|
||||
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
|
||||
onShareWithTeam?: (
|
||||
teamId: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface BaseSessionLiveWrapperProps {
|
||||
|
||||
@@ -23,8 +23,14 @@ interface ShareModalProps {
|
||||
isOwner: boolean;
|
||||
userTeams?: TeamWithMembers[];
|
||||
currentUserId?: string;
|
||||
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
|
||||
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
|
||||
onShareWithEmail: (
|
||||
email: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onShareWithTeam?: (
|
||||
teamId: string,
|
||||
role: ShareRole
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
onRemoveShare: (userId: string) => Promise<unknown>;
|
||||
helpText?: React.ReactNode;
|
||||
}
|
||||
@@ -76,7 +82,7 @@ export function ShareModal({
|
||||
} else {
|
||||
const targetEmail =
|
||||
shareType === 'teamMember'
|
||||
? teamMembers.find((m) => m.id === selectedMemberId)?.email ?? ''
|
||||
? (teamMembers.find((m) => m.id === selectedMemberId)?.email ?? '')
|
||||
: email;
|
||||
result = await onShareWithEmail(targetEmail, role);
|
||||
}
|
||||
@@ -154,8 +160,8 @@ export function ShareModal({
|
||||
<div className="space-y-2">
|
||||
{teamMembers.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
Vous n'êtes membre d'aucune équipe ou vos équipes n'ont pas d'autres membres.
|
||||
Créez une équipe depuis la page{' '}
|
||||
Vous n'êtes membre d'aucune équipe ou vos équipes n'ont pas
|
||||
d'autres membres. Créez une équipe depuis la page{' '}
|
||||
<Link href="/teams" className="text-primary hover:underline">
|
||||
Équipes
|
||||
</Link>
|
||||
@@ -271,7 +277,12 @@ export function ShareModal({
|
||||
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">
|
||||
<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"
|
||||
|
||||
@@ -150,33 +150,33 @@ export function Header() {
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👤 Mon Profil
|
||||
</Link>
|
||||
<Link
|
||||
href="/users"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👥 Utilisateurs
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👤 Mon Profil
|
||||
</Link>
|
||||
<Link
|
||||
href="/users"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
|
||||
>
|
||||
👥 Utilisateurs
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -183,4 +183,3 @@ export function KeyResultItem({ keyResult, okrId, canEdit, onUpdate }: KeyResult
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,9 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
||||
onClick={() => setShowAllPeriods(!showAllPeriods)}
|
||||
className="text-sm"
|
||||
>
|
||||
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
|
||||
{showAllPeriods
|
||||
? `Afficher ${currentQuarterPeriod} uniquement`
|
||||
: 'Afficher tous les OKR'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,9 +109,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
|
||||
{!showAllPeriods && (
|
||||
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
|
||||
)}
|
||||
{!showAllPeriods && <span className="text-sm text-muted">({currentQuarterPeriod})</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -117,7 +117,9 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
|
||||
onClick={() => setShowAllPeriods(!showAllPeriods)}
|
||||
className="text-sm"
|
||||
>
|
||||
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
|
||||
{showAllPeriods
|
||||
? `Afficher ${currentQuarterPeriod} uniquement`
|
||||
: 'Afficher tous les OKR'}
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
value={cardViewMode}
|
||||
|
||||
@@ -351,9 +351,7 @@ export function ActionPanel({
|
||||
})}
|
||||
</div>
|
||||
{editingSelectedItems.length < 2 && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Sélectionnez au moins 2 items SWOT
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-destructive">Sélectionnez au moins 2 items SWOT</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,70 +21,71 @@ const categoryStyles: Record<SwotCategory, { ring: string; text: string }> = {
|
||||
THREAT: { ring: 'ring-threat', text: 'text-threat' },
|
||||
};
|
||||
|
||||
export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
|
||||
(
|
||||
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [content, setContent] = useState(item.content);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
export const SwotCard = memo(
|
||||
forwardRef<HTMLDivElement, SwotCardProps>(
|
||||
(
|
||||
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [content, setContent] = useState(item.content);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const styles = categoryStyles[item.category];
|
||||
const styles = categoryStyles[item.category];
|
||||
|
||||
async function handleSave() {
|
||||
if (content.trim() === item.content) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
async function handleSave() {
|
||||
if (content.trim() === item.content) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// If empty, delete
|
||||
startTransition(async () => {
|
||||
await deleteSwotItem(item.id, sessionId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
await updateSwotItem(item.id, sessionId, { content: content.trim() });
|
||||
setIsEditing(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// If empty, delete
|
||||
async function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deleteSwotItem(item.id, sessionId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
await updateSwotItem(item.id, sessionId, { content: content.trim() });
|
||||
setIsEditing(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deleteSwotItem(item.id, sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
startTransition(async () => {
|
||||
await duplicateSwotItem(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);
|
||||
async function handleDuplicate() {
|
||||
startTransition(async () => {
|
||||
await duplicateSwotItem(item.id, sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (linkMode) {
|
||||
onSelect();
|
||||
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}
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
function handleClick() {
|
||||
if (linkMode) {
|
||||
onSelect();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
group relative rounded-lg border bg-card p-3 shadow-sm transition-all
|
||||
${isDragging ? 'shadow-lg ring-2 ring-primary' : 'border-border'}
|
||||
${isSelected ? `ring-2 ${styles.ring}` : ''}
|
||||
@@ -92,109 +93,110 @@ export const SwotCard = memo(forwardRef<HTMLDivElement, SwotCardProps>(
|
||||
${linkMode ? 'cursor-pointer hover:ring-2 hover:ring-primary/50' : ''}
|
||||
${isPending ? 'opacity-50' : ''}
|
||||
`}
|
||||
{...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>
|
||||
{...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) */}
|
||||
{!linkMode && (
|
||||
<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"
|
||||
{/* Actions (visible on hover) */}
|
||||
{!linkMode && (
|
||||
<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"
|
||||
>
|
||||
<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();
|
||||
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"
|
||||
<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();
|
||||
handleDuplicate();
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
|
||||
aria-label="Dupliquer"
|
||||
>
|
||||
<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();
|
||||
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"
|
||||
<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();
|
||||
handleDelete();
|
||||
}}
|
||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Selection indicator in link mode */}
|
||||
{linkMode && isSelected && (
|
||||
<div
|
||||
className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
));
|
||||
{/* Selection indicator in link mode */}
|
||||
{linkMode && isSelected && (
|
||||
<div
|
||||
className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
SwotCard.displayName = 'SwotCard';
|
||||
|
||||
@@ -21,7 +21,12 @@ interface AddMemberModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }: AddMemberModalProps) {
|
||||
export function AddMemberModal({
|
||||
teamId,
|
||||
existingMemberIds,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AddMemberModalProps) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
@@ -71,7 +76,7 @@ export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de l\'ajout du membre');
|
||||
alert(error.error || "Erreur lors de l'ajout du membre");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +84,7 @@ export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error adding member:', error);
|
||||
alert('Erreur lors de l\'ajout du membre');
|
||||
alert("Erreur lors de l'ajout du membre");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -157,4 +162,3 @@ export function AddMemberModal({ teamId, existingMemberIds, onClose, onSuccess }
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir retirer ce membre de l\'équipe ?')) {
|
||||
if (!confirm("Êtes-vous sûr de vouloir retirer ce membre de l'équipe ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,4 +172,3 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ export function TeamCard({ team }: TeamCardProps) {
|
||||
<span className="text-2xl">👥</span>
|
||||
<CardTitle>{team.name}</CardTitle>
|
||||
</div>
|
||||
{team.description && <CardDescription className="mt-2">{team.description}</CardDescription>}
|
||||
{team.description && (
|
||||
<CardDescription className="mt-2">{team.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Badge
|
||||
@@ -49,11 +51,15 @@ export function TeamCard({ team }: TeamCardProps) {
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{memberCount} membre{memberCount !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{memberCount} membre{memberCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-lg">🎯</span>
|
||||
<span>{okrCount} OKR{okrCount !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{okrCount} OKR{okrCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -61,4 +67,3 @@ export function TeamCard({ team }: TeamCardProps) {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export function TeamDetailClient({ members, teamId, isAdmin }: TeamDetailClientP
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return <MembersList members={members} teamId={teamId} isAdmin={isAdmin} onMemberUpdate={handleMemberUpdate} />;
|
||||
return (
|
||||
<MembersList
|
||||
members={members}
|
||||
teamId={teamId}
|
||||
isAdmin={isAdmin}
|
||||
onMemberUpdate={handleMemberUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ export function EditableMotivatorTitle({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ export function EditableSessionTitle({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,19 +9,17 @@ interface EditableTitleProps {
|
||||
onUpdate: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export function EditableTitle({
|
||||
sessionId,
|
||||
initialTitle,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: EditableTitleProps) {
|
||||
export function EditableTitle({ sessionId, initialTitle, canEdit, onUpdate }: EditableTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use editingTitle when editing, otherwise use initialTitle (synced from SSE)
|
||||
const title = useMemo(() => (isEditing ? editingTitle : initialTitle), [isEditing, editingTitle, initialTitle]);
|
||||
const title = useMemo(
|
||||
() => (isEditing ? editingTitle : initialTitle),
|
||||
[isEditing, editingTitle, initialTitle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
@@ -110,4 +108,3 @@ export function EditableTitle({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ export function EditableYearReviewTitle({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +90,16 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className={`pointer-events-none absolute ${iconPosition} top-1/2 -translate-y-1/2 text-muted-foreground`}>
|
||||
<div
|
||||
className={`pointer-events-none absolute ${iconPosition} top-1/2 -translate-y-1/2 text-muted-foreground`}
|
||||
>
|
||||
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,9 @@ export function ToggleGroup<T extends string>({
|
||||
className = '',
|
||||
}: ToggleGroupProps<T>) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 rounded-lg border border-border bg-card p-1 ${className}`}>
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-1 ${className}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -30,9 +32,10 @@ export function ToggleGroup<T extends string>({
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`
|
||||
flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors
|
||||
${value === option.value
|
||||
? 'bg-[#8b5cf6] text-white shadow-sm'
|
||||
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
||||
${
|
||||
value === option.value
|
||||
? 'bg-[#8b5cf6] text-white shadow-sm'
|
||||
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -43,4 +46,3 @@ export function ToggleGroup<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,4 @@ export { Select } from './Select';
|
||||
export type { SelectOption } from './Select';
|
||||
export { Textarea } from './Textarea';
|
||||
export { ToggleGroup } from './ToggleGroup';
|
||||
export type { ToggleOption } from './ToggleGroup';
|
||||
export type { ToggleOption } from './ToggleGroup';
|
||||
|
||||
@@ -24,9 +24,7 @@ export function WeatherAverageBar({ entries }: WeatherAverageBarProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
Moyenne équipe
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Moyenne équipe</span>
|
||||
{AXES.map(({ key, label }) => {
|
||||
const avg = getAverageEmoji(entries.map((e) => e[key]));
|
||||
return (
|
||||
|
||||
@@ -61,15 +61,15 @@ export function WeatherBoard({
|
||||
// Get all users who have access: owner + shared users
|
||||
const allUsers = useMemo(() => {
|
||||
const usersMap = new Map<string, { id: string; name: string | null; email: string }>();
|
||||
|
||||
|
||||
// Add owner
|
||||
usersMap.set(owner.id, owner);
|
||||
|
||||
|
||||
// Add shared users
|
||||
shares.forEach((share) => {
|
||||
usersMap.set(share.userId, share.user);
|
||||
});
|
||||
|
||||
|
||||
return Array.from(usersMap.values());
|
||||
}, [owner, shares]);
|
||||
|
||||
|
||||
@@ -89,7 +89,13 @@ function EvolutionIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId, entry, canEdit, previousEntry }: WeatherCardProps) {
|
||||
export const WeatherCard = memo(function WeatherCard({
|
||||
sessionId,
|
||||
currentUserId,
|
||||
entry,
|
||||
canEdit,
|
||||
previousEntry,
|
||||
}: WeatherCardProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// Track entry version to reset local state when props change (SSE refresh)
|
||||
const [entryVersion, setEntryVersion] = useState(entry);
|
||||
@@ -112,7 +118,10 @@ export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId,
|
||||
const isCurrentUser = entry.userId === currentUserId;
|
||||
const canEditThis = canEdit && isCurrentUser;
|
||||
|
||||
function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) {
|
||||
function handleEmojiChange(
|
||||
axis: 'performance' | 'moral' | 'flux' | 'valueCreation',
|
||||
emoji: string | null
|
||||
) {
|
||||
if (!canEditThis) return;
|
||||
|
||||
// Calculate new values
|
||||
@@ -190,12 +199,18 @@ export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId,
|
||||
wrapperClassName="!w-fit"
|
||||
className="!w-16 min-w-16 text-center text-lg py-2.5"
|
||||
/>
|
||||
<EvolutionIndicator current={performanceEmoji} previous={previousEntry?.performanceEmoji ?? null} />
|
||||
<EvolutionIndicator
|
||||
current={performanceEmoji}
|
||||
previous={previousEntry?.performanceEmoji ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-2xl">{performanceEmoji || '-'}</span>
|
||||
<EvolutionIndicator current={performanceEmoji} previous={previousEntry?.performanceEmoji ?? null} />
|
||||
<EvolutionIndicator
|
||||
current={performanceEmoji}
|
||||
previous={previousEntry?.performanceEmoji ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
@@ -256,12 +271,18 @@ export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId,
|
||||
wrapperClassName="!w-fit"
|
||||
className="!w-16 min-w-16 text-center text-lg py-2.5"
|
||||
/>
|
||||
<EvolutionIndicator current={valueCreationEmoji} previous={previousEntry?.valueCreationEmoji ?? null} />
|
||||
<EvolutionIndicator
|
||||
current={valueCreationEmoji}
|
||||
previous={previousEntry?.valueCreationEmoji ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-2xl">{valueCreationEmoji || '-'}</span>
|
||||
<EvolutionIndicator current={valueCreationEmoji} previous={previousEntry?.valueCreationEmoji ?? null} />
|
||||
<EvolutionIndicator
|
||||
current={valueCreationEmoji}
|
||||
previous={previousEntry?.valueCreationEmoji ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -11,7 +11,9 @@ export function WeatherInfoPanel() {
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground">Les 4 axes de la météo personnelle</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Les 4 axes de la météo personnelle
|
||||
</h3>
|
||||
<svg
|
||||
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
|
||||
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
|
||||
import {
|
||||
shareWeatherSession,
|
||||
shareWeatherSessionToTeam,
|
||||
removeWeatherShare,
|
||||
} from '@/actions/weather';
|
||||
import type { TeamWithMembers, Share } from '@/lib/share-utils';
|
||||
|
||||
interface WeatherLiveWrapperProps {
|
||||
|
||||
@@ -47,86 +47,99 @@ export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQua
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<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">
|
||||
<EditableObjective
|
||||
okr={okr}
|
||||
canEdit={canEdit}
|
||||
onUpdate={() => router.refresh()}
|
||||
/>
|
||||
<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 whitespace-nowrap">{okr.progress}%</span>
|
||||
<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">
|
||||
<EditableObjective
|
||||
okr={okr}
|
||||
canEdit={canEdit}
|
||||
onUpdate={() => router.refresh()}
|
||||
/>
|
||||
<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 whitespace-nowrap">
|
||||
{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, 5).map((kr) => (
|
||||
<EditableKeyResultRow
|
||||
key={kr.id}
|
||||
kr={kr}
|
||||
okrId={okr.id}
|
||||
canEdit={canEdit}
|
||||
onUpdate={() => router.refresh()}
|
||||
/>
|
||||
))}
|
||||
{okr.keyResults.length > 5 && (
|
||||
<li className="text-xs text-muted pl-3.5">
|
||||
+{okr.keyResults.length - 5} autre
|
||||
{okr.keyResults.length - 5 > 1 ? 's' : ''}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{okr.team && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-muted">Équipe: {okr.team.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</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, 5).map((kr) => (
|
||||
<EditableKeyResultRow
|
||||
key={kr.id}
|
||||
kr={kr}
|
||||
okrId={okr.id}
|
||||
canEdit={canEdit}
|
||||
onUpdate={() => router.refresh()}
|
||||
/>
|
||||
))}
|
||||
{okr.keyResults.length > 5 && (
|
||||
<li className="text-xs text-muted pl-3.5">
|
||||
+{okr.keyResults.length - 5} autre{okr.keyResults.length - 5 > 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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
@@ -222,8 +235,7 @@ function EditableKeyResultRow({
|
||||
const [currentValue, setCurrentValue] = useState(kr.currentValue);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const krProgress =
|
||||
kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0;
|
||||
const krProgress = kr.targetValue > 0 ? Math.round((kr.currentValue / kr.targetValue) * 100) : 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
setUpdating(true);
|
||||
@@ -263,7 +275,9 @@ function EditableKeyResultRow({
|
||||
step="0.1"
|
||||
className="h-6 w-16 text-xs"
|
||||
/>
|
||||
<span className="text-muted">/ {kr.targetValue} {kr.unit}</span>
|
||||
<span className="text-muted">
|
||||
/ {kr.targetValue} {kr.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button size="sm" onClick={handleSave} disabled={updating} className="h-6 text-xs">
|
||||
|
||||
@@ -12,188 +12,200 @@ interface WeeklyCheckInCardProps {
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export const WeeklyCheckInCard = memo(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();
|
||||
export const WeeklyCheckInCard = memo(
|
||||
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];
|
||||
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;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// If empty, delete
|
||||
async function handleDelete() {
|
||||
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);
|
||||
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={`
|
||||
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);
|
||||
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);
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
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`,
|
||||
/>
|
||||
<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);
|
||||
}}
|
||||
title={emotionConfig.label}
|
||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span>{emotionConfig.icon}</span>
|
||||
<span>{emotionConfig.label}</span>
|
||||
</div>
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
));
|
||||
{/* 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';
|
||||
|
||||
@@ -11,120 +11,132 @@ interface YearReviewCardProps {
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export const YearReviewCard = memo(forwardRef<HTMLDivElement, YearReviewCardProps>(
|
||||
({ item, sessionId, isDragging, ...props }, ref) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [content, setContent] = useState(item.content);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
export const YearReviewCard = memo(
|
||||
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];
|
||||
const config = YEAR_REVIEW_BY_CATEGORY[item.category];
|
||||
|
||||
async function handleSave() {
|
||||
if (content.trim() === item.content) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
// If empty, delete
|
||||
async function handleDelete() {
|
||||
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);
|
||||
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={`
|
||||
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>
|
||||
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>
|
||||
);
|
||||
}
|
||||
));
|
||||
{/* 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';
|
||||
|
||||
@@ -55,4 +55,3 @@ export function YearReviewLiveWrapper({
|
||||
</BaseSessionLiveWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user