Compare commits

..

2 Commits

11 changed files with 404 additions and 220 deletions

View File

@@ -1,21 +1,23 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import { WORKSHOPS } from '@/lib/workshops'; import { WORKSHOPS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
export function NewWorkshopDropdown() { export function NewWorkshopDropdown() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useClickOutside(containerRef, () => setOpen(false), open);
return ( return (
<div className="relative"> <div ref={containerRef} className="relative">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="gap-1.5" className="gap-1.5"
> >
Nouvel atelier Nouvel atelier

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; import { useState, useTransition, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { import {
@@ -25,6 +25,7 @@ import {
getWorkshop, getWorkshop,
getSessionPath, getSessionPath,
} from '@/lib/workshops'; } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
const TYPE_TABS = [ const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' }, { value: 'all' as const, icon: '📋', label: 'Tous' },
@@ -429,13 +430,14 @@ function TypeFilterDropdown({
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0]; const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson'; const isTypeSelected = activeTab !== 'all' && activeTab !== 'byPerson';
const totalCount = typeTabs.reduce((s, t) => s + (counts[t.value] ?? 0), 0); const totalCount = typeTabs.reduce((s, t) => s + (counts[t.value] ?? 0), 0);
const containerRef = useRef<HTMLDivElement>(null);
useClickOutside(containerRef, () => onOpenChange(false), open);
return ( return (
<div className="relative"> <div ref={containerRef} className="relative">
<button <button
type="button" type="button"
onClick={() => onOpenChange(!open)} onClick={() => onOpenChange(!open)}
onBlur={() => setTimeout(() => onOpenChange(false), 150)}
className={` className={`
flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-colors
${isTypeSelected ? 'bg-primary text-primary-foreground' : 'text-muted hover:bg-card-hover hover:text-foreground'} ${isTypeSelected ? 'bg-primary text-primary-foreground' : 'text-muted hover:bg-card-hover hover:text-foreground'}

View File

@@ -87,9 +87,25 @@ export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckIn
</div> </div>
</div> </div>
{/* Current Quarter OKRs */} {/* Current Quarter OKRs - editable by participant or team admin */}
{currentQuarterOKRs.length > 0 && ( {currentQuarterOKRs.length > 0 && (
<CurrentQuarterOKRs okrs={currentQuarterOKRs} period={currentQuarterPeriod} /> <CurrentQuarterOKRs
okrs={currentQuarterOKRs}
period={currentQuarterPeriod}
canEdit={
(!!resolvedParticipant.matchedUser &&
authSession.user.id === resolvedParticipant.matchedUser.id) ||
(() => {
const participantTeamIds = new Set(
currentQuarterOKRs.map((okr) => okr.team?.id).filter(Boolean) as string[]
);
const adminTeamIds = userTeams
.filter((t) => t.userRole === 'ADMIN')
.map((t) => t.id);
return adminTeamIds.some((tid) => participantTeamIds.has(tid));
})()
}
/>
)} )}
{/* Live Wrapper + Board */} {/* Live Wrapper + Board */}

View File

@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { Select } from '@/components/ui/Select';
import { getTeamMembersForShare, type TeamWithMembers } from '@/lib/share-utils'; import { getTeamMembersForShare, type TeamWithMembers } from '@/lib/share-utils';
import type { ShareRole } from '@prisma/client'; import type { ShareRole } from '@prisma/client';
@@ -41,8 +42,10 @@ interface ShareModalProps {
helpText?: React.ReactNode; helpText?: React.ReactNode;
} }
const SELECT_STYLE = const ROLE_OPTIONS = [
'appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20'; { value: 'EDITOR', label: 'Éditeur' },
{ value: 'VIEWER', label: 'Lecteur' },
] as const;
export function ShareModal({ export function ShareModal({
isOpen, isOpen,
@@ -151,10 +154,12 @@ export function ShareModal({
className="flex-1" className="flex-1"
required required
/> />
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={SELECT_STYLE}> <Select
<option value="EDITOR">Éditeur</option> value={role}
<option value="VIEWER">Lecteur</option> onChange={(e) => setRole(e.target.value as ShareRole)}
</select> options={[...ROLE_OPTIONS]}
wrapperClassName="w-auto shrink-0 min-w-[7rem]"
/>
</div> </div>
)} )}
@@ -171,30 +176,25 @@ export function ShareModal({
</p> </p>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <Select
<select value={selectedMemberId}
value={selectedMemberId} onChange={(e) => setSelectedMemberId(e.target.value)}
onChange={(e) => setSelectedMemberId(e.target.value)} options={[
className={`w-full ${SELECT_STYLE}`} { value: '', label: 'Sélectionner un membre', disabled: true },
required ...teamMembers.map((m) => ({
> value: m.id,
<option value="">Sélectionner un membre</option> label: m.name ? `${m.name} (${m.email})` : m.email,
{teamMembers.map((m) => ( })),
<option key={m.id} value={m.id}> ]}
{m.name || m.email} {m.name && `(${m.email})`} wrapperClassName="flex-1 min-w-0"
</option> required
))} />
</select> <Select
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"> value={role}
<svg className="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onChange={(e) => setRole(e.target.value as ShareRole)}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> options={[...ROLE_OPTIONS]}
</svg> wrapperClassName="w-auto shrink-0 min-w-[7rem]"
</div> />
</div>
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={SELECT_STYLE}>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div> </div>
)} )}
</div> </div>
@@ -211,39 +211,27 @@ export function ShareModal({
. .
</p> </p>
) : ( ) : (
<> <div className="flex gap-2">
<div className="relative"> <Select
<select value={teamId}
value={teamId} onChange={(e) => setTeamId(e.target.value)}
onChange={(e) => setTeamId(e.target.value)} options={[
className={`w-full ${SELECT_STYLE}`} { value: '', label: 'Sélectionner une équipe', disabled: true },
required ...userTeams.map((team) => ({
> value: team.id,
<option value="">Sélectionner une équipe</option> label: `${team.name}${team.userRole === 'ADMIN' ? ' (Admin)' : ''}`,
{userTeams.map((team) => ( })),
<option key={team.id} value={team.id}> ]}
{team.name} {team.userRole === 'ADMIN' && '(Admin)'} wrapperClassName="flex-1 min-w-0"
</option> required
))} />
</select> <Select
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"> value={role}
<svg className="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onChange={(e) => setRole(e.target.value as ShareRole)}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> options={[...ROLE_OPTIONS]}
</svg> wrapperClassName="w-auto shrink-0 min-w-[7rem]"
</div> />
</div> </div>
<div className="relative">
<select value={role} onChange={(e) => setRole(e.target.value as ShareRole)} className={`w-full ${SELECT_STYLE}`}>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg className="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</>
)} )}
</div> </div>
)} )}

View File

@@ -4,15 +4,20 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react'; import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react'; import { useState, useRef } from 'react';
import { Avatar, RocketIcon } from '@/components/ui'; import { Avatar, RocketIcon } from '@/components/ui';
import { WORKSHOPS } from '@/lib/workshops'; import { WORKSHOPS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
export function Header() { export function Header() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [workshopsOpen, setWorkshopsOpen] = useState(false); const [workshopsOpen, setWorkshopsOpen] = useState(false);
const workshopsDropdownRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
useClickOutside(workshopsDropdownRef, () => setWorkshopsOpen(false), workshopsOpen);
useClickOutside(userMenuRef, () => setMenuOpen(false), menuOpen);
const pathname = usePathname(); const pathname = usePathname();
const isActiveLink = (path: string) => pathname.startsWith(path); const isActiveLink = (path: string) => pathname.startsWith(path);
@@ -61,10 +66,9 @@ export function Header() {
</Link> </Link>
{/* New Workshop Dropdown */} {/* New Workshop Dropdown */}
<div className="relative"> <div className="relative" ref={workshopsDropdownRef}>
<button <button
onClick={() => setWorkshopsOpen(!workshopsOpen)} onClick={() => setWorkshopsOpen(!workshopsOpen)}
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
className={`flex items-center gap-1 text-sm font-medium transition-colors ${ className={`flex items-center gap-1 text-sm font-medium transition-colors ${
WORKSHOPS.some((w) => isActiveLink(w.path)) WORKSHOPS.some((w) => isActiveLink(w.path))
? 'text-primary' ? 'text-primary'
@@ -120,7 +124,7 @@ export function Header() {
{status === 'loading' ? ( {status === 'loading' ? (
<div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" /> <div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" />
) : status === 'authenticated' && session?.user ? ( ) : status === 'authenticated' && session?.user ? (
<div className="relative"> <div ref={userMenuRef} className="relative">
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card pl-1.5 pr-3 transition-colors hover:bg-card-hover" className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card pl-1.5 pr-3 transition-colors hover:bg-card-hover"
@@ -145,9 +149,7 @@ export function Header() {
</button> </button>
{menuOpen && ( {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="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<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"> <div className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">Connecté en tant que</p> <p className="text-xs text-muted">Connecté en tant que</p>
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
@@ -175,7 +177,6 @@ export function Header() {
Se déconnecter Se déconnecter
</button> </button>
</div> </div>
</>
)} )}
</div> </div>
) : ( ) : (

View File

@@ -2,7 +2,7 @@
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client'; import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
import { Button, Badge, Modal, ModalFooter, Input, Textarea } from '@/components/ui'; import { Button, Badge, Modal, ModalFooter, Input, Textarea, Select } from '@/components/ui';
import { createAction, updateAction, deleteAction } from '@/actions/swot'; import { createAction, updateAction, deleteAction } from '@/actions/swot';
type ActionWithLinks = Action & { type ActionWithLinks = Action & {
@@ -40,11 +40,11 @@ const categoryShort: Record<SwotCategory, string> = {
}; };
const priorityLabels = ['Basse', 'Moyenne', 'Haute']; const priorityLabels = ['Basse', 'Moyenne', 'Haute'];
const statusLabels: Record<string, string> = { const statusOptions = [
todo: 'À faire', { value: 'todo', label: '📋 À faire' },
in_progress: 'En cours', { value: 'in_progress', label: 'En cours' },
done: 'Terminé', { value: 'done', label: 'Terminé' },
}; ];
export function ActionPanel({ export function ActionPanel({
sessionId, sessionId,
@@ -279,16 +279,15 @@ export function ActionPanel({
> >
{priorityLabels[action.priority]} {priorityLabels[action.priority]}
</Badge> </Badge>
<select <Select
value={action.status} value={action.status}
onChange={(e) => handleStatusChange(action, e.target.value)} onChange={(e) => handleStatusChange(action, e.target.value)}
className="rounded border border-border bg-card px-2 py-1 text-xs text-foreground" options={statusOptions}
size="xs"
wrapperClassName="!w-auto shrink-0"
className="!w-auto"
disabled={isPending} disabled={isPending}
> />
<option value="todo">{statusLabels.todo}</option>
<option value="in_progress">{statusLabels.in_progress}</option>
<option value="done">{statusLabels.done}</option>
</select>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,24 +1,63 @@
import { forwardRef, SelectHTMLAttributes } from 'react'; import { forwardRef, SelectHTMLAttributes } from 'react';
interface SelectOption { export interface SelectOption {
value: string; value: string;
label: string; label: string;
disabled?: boolean; disabled?: boolean;
} }
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> { const SIZE_STYLES = {
xs: 'px-2 py-1 pr-7 text-xs',
sm: 'px-2 py-2 pr-8 text-sm',
md: 'px-4 py-2.5 pr-10 text-sm',
lg: 'px-4 py-2.5 pr-10 text-base',
} as const;
const ICON_SIZES = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-5 w-5',
} as const;
const ICON_POSITION = {
xs: 'right-2',
sm: 'right-2',
md: 'right-3',
lg: 'right-3',
} as const;
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children' | 'size'> {
label?: string; label?: string;
error?: string; error?: string;
options: SelectOption[]; options: SelectOption[];
placeholder?: string; placeholder?: string;
size?: keyof typeof SIZE_STYLES;
wrapperClassName?: string;
} }
export const Select = forwardRef<HTMLSelectElement, SelectProps>( export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, id, options, placeholder, ...props }, ref) => { (
{
className = '',
label,
error,
id,
options,
placeholder,
size = 'md',
wrapperClassName = '',
...props
},
ref
) => {
const selectId = id || props.name; const selectId = id || props.name;
const sizeStyles = SIZE_STYLES[size];
const iconSize = ICON_SIZES[size];
const iconPosition = ICON_POSITION[size];
return ( return (
<div className="w-full"> <div className={wrapperClassName || 'w-full'}>
{label && ( {label && (
<label htmlFor={selectId} className="mb-2 block text-sm font-medium text-foreground"> <label htmlFor={selectId} className="mb-2 block text-sm font-medium text-foreground">
{label} {label}
@@ -29,11 +68,13 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
ref={ref} ref={ref}
id={selectId} id={selectId}
className={` className={`
w-full appearance-none rounded-lg border bg-input px-4 py-2.5 pr-10 text-foreground w-full appearance-none rounded-lg border bg-input text-foreground
placeholder:text-muted-foreground placeholder:text-muted-foreground
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:outline-none focus:ring-2 focus:ring-primary/20
disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50
${error ? 'border-destructive focus:border-destructive' : 'border-input-border focus:border-primary'} border-input-border focus:border-primary
${sizeStyles}
${error ? 'border-destructive focus:border-destructive' : ''}
${className} ${className}
`} `}
{...props} {...props}
@@ -49,14 +90,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
</option> </option>
))} ))}
</select> </select>
{/* Custom arrow icon */} <div className={`pointer-events-none absolute ${iconPosition} top-1/2 -translate-y-1/2 text-muted-foreground`}>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"> <svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
className="h-5 w-5 text-muted-foreground"
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> </svg>
</div> </div>
@@ -68,4 +103,3 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
); );
Select.displayName = 'Select'; Select.displayName = 'Select';

View File

@@ -14,6 +14,7 @@ export { ParticipantInput } from './ParticipantInput';
export { Modal, ModalFooter } from './Modal'; export { Modal, ModalFooter } from './Modal';
export { RocketIcon } from './RocketIcon'; export { RocketIcon } from './RocketIcon';
export { Select } from './Select'; export { Select } from './Select';
export type { SelectOption } from './Select';
export { Textarea } from './Textarea'; export { Textarea } from './Textarea';
export { ToggleGroup } from './ToggleGroup'; export { ToggleGroup } from './ToggleGroup';
export type { ToggleOption } from './ToggleGroup'; export type { ToggleOption } from './ToggleGroup';

View File

@@ -4,6 +4,7 @@ import { useState, useTransition, useEffect } from 'react';
import { createOrUpdateWeatherEntry } from '@/actions/weather'; import { createOrUpdateWeatherEntry } from '@/actions/weather';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { Textarea } from '@/components/ui/Textarea'; import { Textarea } from '@/components/ui/Textarea';
import { Select } from '@/components/ui/Select';
const WEATHER_EMOJIS = [ const WEATHER_EMOJIS = [
{ emoji: '', label: 'Aucun' }, { emoji: '', label: 'Aucun' },
@@ -140,29 +141,14 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: Weathe
{/* Performance */} {/* Performance */}
<td className="w-24 px-2 py-3"> <td className="w-24 px-2 py-3">
{canEditThis ? ( {canEditThis ? (
<div className="relative mx-auto w-fit"> <Select
<select value={performanceEmoji || ''}
value={performanceEmoji || ''} onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
onChange={(e) => handleEmojiChange('performance', e.target.value || null)} options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" size="sm"
> wrapperClassName="!w-fit mx-auto"
{WEATHER_EMOJIS.map(({ emoji }) => ( className="!w-16 min-w-16 text-center text-lg py-2.5"
<option key={emoji || 'none'} value={emoji}> />
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : ( ) : (
<div className="text-2xl text-center">{performanceEmoji || '-'}</div> <div className="text-2xl text-center">{performanceEmoji || '-'}</div>
)} )}
@@ -171,29 +157,14 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: Weathe
{/* Moral */} {/* Moral */}
<td className="w-24 px-2 py-3"> <td className="w-24 px-2 py-3">
{canEditThis ? ( {canEditThis ? (
<div className="relative mx-auto w-fit"> <Select
<select value={moralEmoji || ''}
value={moralEmoji || ''} onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
onChange={(e) => handleEmojiChange('moral', e.target.value || null)} options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" size="sm"
> wrapperClassName="!w-fit mx-auto"
{WEATHER_EMOJIS.map(({ emoji }) => ( className="!w-16 min-w-16 text-center text-lg py-2.5"
<option key={emoji || 'none'} value={emoji}> />
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : ( ) : (
<div className="text-2xl text-center">{moralEmoji || '-'}</div> <div className="text-2xl text-center">{moralEmoji || '-'}</div>
)} )}
@@ -202,29 +173,14 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: Weathe
{/* Flux */} {/* Flux */}
<td className="w-24 px-2 py-3"> <td className="w-24 px-2 py-3">
{canEditThis ? ( {canEditThis ? (
<div className="relative mx-auto w-fit"> <Select
<select value={fluxEmoji || ''}
value={fluxEmoji || ''} onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
onChange={(e) => handleEmojiChange('flux', e.target.value || null)} options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" size="sm"
> wrapperClassName="!w-fit mx-auto"
{WEATHER_EMOJIS.map(({ emoji }) => ( className="!w-16 min-w-16 text-center text-lg py-2.5"
<option key={emoji || 'none'} value={emoji}> />
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : ( ) : (
<div className="text-2xl text-center">{fluxEmoji || '-'}</div> <div className="text-2xl text-center">{fluxEmoji || '-'}</div>
)} )}
@@ -233,29 +189,14 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: Weathe
{/* Création de valeur */} {/* Création de valeur */}
<td className="w-24 px-2 py-3"> <td className="w-24 px-2 py-3">
{canEditThis ? ( {canEditThis ? (
<div className="relative mx-auto w-fit"> <Select
<select value={valueCreationEmoji || ''}
value={valueCreationEmoji || ''} onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)} options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" size="sm"
> wrapperClassName="!w-fit mx-auto"
{WEATHER_EMOJIS.map(({ emoji }) => ( className="!w-16 min-w-16 text-center text-lg py-2.5"
<option key={emoji || 'none'} value={emoji}> />
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : ( ) : (
<div className="text-2xl text-center">{valueCreationEmoji || '-'}</div> <div className="text-2xl text-center">{valueCreationEmoji || '-'}</div>
)} )}

View File

@@ -1,10 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import { Badge } from '@/components/ui'; import { Badge } from '@/components/ui';
import type { OKR } from '@/lib/types'; import { Input } from '@/components/ui';
import { Button } from '@/components/ui';
import type { OKR, KeyResult } from '@/lib/types';
import { OKR_STATUS_LABELS } from '@/lib/types'; import { OKR_STATUS_LABELS } from '@/lib/types';
type OKRWithTeam = OKR & { type OKRWithTeam = OKR & {
@@ -17,10 +20,12 @@ type OKRWithTeam = OKR & {
interface CurrentQuarterOKRsProps { interface CurrentQuarterOKRsProps {
okrs: OKRWithTeam[]; okrs: OKRWithTeam[];
period: string; period: string;
canEdit?: boolean;
} }
export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) { export function CurrentQuarterOKRs({ okrs, period, canEdit = false }: CurrentQuarterOKRsProps) {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
const router = useRouter();
if (okrs.length === 0) { if (okrs.length === 0) {
return null; return null;
@@ -60,7 +65,11 @@ export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-foreground">{okr.objective}</h4> <EditableObjective
okr={okr}
canEdit={canEdit}
onUpdate={() => router.refresh()}
/>
<Badge <Badge
variant="default" variant="default"
style={{ style={{
@@ -72,7 +81,7 @@ export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
{OKR_STATUS_LABELS[okr.status]} {OKR_STATUS_LABELS[okr.status]}
</Badge> </Badge>
{okr.progress !== undefined && ( {okr.progress !== undefined && (
<span className="text-xs text-muted">{okr.progress}%</span> <span className="text-xs text-muted whitespace-nowrap">{okr.progress}%</span>
)} )}
</div> </div>
{okr.description && ( {okr.description && (
@@ -80,24 +89,18 @@ export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
)} )}
{okr.keyResults && okr.keyResults.length > 0 && ( {okr.keyResults && okr.keyResults.length > 0 && (
<ul className="space-y-1 mt-2"> <ul className="space-y-1 mt-2">
{okr.keyResults.slice(0, 3).map((kr) => { {okr.keyResults.slice(0, 5).map((kr) => (
const krProgress = kr.targetValue > 0 <EditableKeyResultRow
? Math.round((kr.currentValue / kr.targetValue) * 100) key={kr.id}
: 0; kr={kr}
return ( okrId={okr.id}
<li key={kr.id} className="text-xs text-muted flex items-center gap-2"> canEdit={canEdit}
<span className="w-1.5 h-1.5 rounded-full bg-primary" /> onUpdate={() => router.refresh()}
<span className="flex-1">{kr.title}</span> />
<span className="text-muted"> ))}
{kr.currentValue}/{kr.targetValue} {kr.unit} {okr.keyResults.length > 5 && (
</span>
<span className="text-xs">({krProgress}%)</span>
</li>
);
})}
{okr.keyResults.length > 3 && (
<li className="text-xs text-muted pl-3.5"> <li className="text-xs text-muted pl-3.5">
+{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 > 1 ? 's' : ''} +{okr.keyResults.length - 5} autre{okr.keyResults.length - 5 > 1 ? 's' : ''}
</li> </li>
)} )}
</ul> </ul>
@@ -130,6 +133,179 @@ export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) {
); );
} }
function EditableObjective({
okr,
canEdit,
onUpdate,
}: {
okr: OKRWithTeam;
canEdit: boolean;
onUpdate: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [objective, setObjective] = useState(okr.objective);
const [updating, setUpdating] = useState(false);
const handleSave = async () => {
setUpdating(true);
try {
const res = await fetch(`/api/okrs/${okr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ objective: objective.trim() }),
});
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Erreur lors de la mise à jour');
return;
}
setIsEditing(false);
onUpdate();
} catch (e) {
console.error(e);
alert('Erreur lors de la mise à jour');
} finally {
setUpdating(false);
}
};
if (canEdit && isEditing) {
return (
<div className="flex items-center gap-2 flex-1 min-w-0">
<Input
value={objective}
onChange={(e) => setObjective(e.target.value)}
className="flex-1 h-8 text-sm"
autoFocus
/>
<Button size="sm" onClick={handleSave} disabled={updating} className="h-7 text-xs">
{updating ? '...' : 'OK'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setObjective(okr.objective);
}}
className="h-7 text-xs"
>
Annuler
</Button>
</div>
);
}
return (
<h4
className={`font-medium text-foreground ${canEdit ? 'cursor-pointer hover:underline' : ''}`}
onClick={canEdit ? () => setIsEditing(true) : undefined}
role={canEdit ? 'button' : undefined}
>
{okr.objective}
</h4>
);
}
function EditableKeyResultRow({
kr,
okrId,
canEdit,
onUpdate,
}: {
kr: KeyResult;
okrId: string;
canEdit: boolean;
onUpdate: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
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 handleSave = async () => {
setUpdating(true);
try {
const res = await fetch(`/api/okrs/${okrId}/key-results/${kr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentValue: Number(currentValue) }),
});
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Erreur lors de la mise à jour');
return;
}
setIsEditing(false);
onUpdate();
} catch (e) {
console.error(e);
alert('Erreur lors de la mise à jour');
} finally {
setUpdating(false);
}
};
if (canEdit && isEditing) {
return (
<li className="text-xs flex items-center gap-2 py-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
<span className="flex-1 min-w-0 text-muted">{kr.title}</span>
<div className="flex items-center gap-1 flex-shrink-0 whitespace-nowrap">
<Input
type="number"
value={currentValue}
onChange={(e) => setCurrentValue(Number(e.target.value))}
min={0}
max={kr.targetValue * 2}
step="0.1"
className="h-6 w-16 text-xs"
/>
<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">
{updating ? '...' : 'OK'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setCurrentValue(kr.currentValue);
}}
className="h-6 text-xs"
>
Annuler
</Button>
</div>
</li>
);
}
return (
<li className="text-xs text-muted flex items-center gap-2 py-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
<span className="flex-1 min-w-0">{kr.title}</span>
<span className="text-muted whitespace-nowrap flex-shrink-0">
{kr.currentValue}/{kr.targetValue} {kr.unit}
</span>
<span className="text-xs whitespace-nowrap flex-shrink-0">({krProgress}%)</span>
{canEdit && (
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-primary hover:underline text-xs"
>
Modifier
</button>
)}
</li>
);
}
function getOKRStatusColor(status: OKR['status']): { bg: string; color: string } { function getOKRStatusColor(status: OKR['status']): { bg: string; color: string } {
switch (status) { switch (status) {
case 'NOT_STARTED': case 'NOT_STARTED':

View File

@@ -0,0 +1,24 @@
import { useEffect, RefObject } from 'react';
/**
* Calls callback when a mousedown occurs outside the ref element.
* Uses mousedown (not click) so it fires before blur and gives consistent behavior.
*/
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: () => void,
enabled = true
) {
useEffect(() => {
if (!enabled) return;
const handleMouseDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
handler();
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [ref, handler, enabled]);
}