Compare commits
2 Commits
d50a8a0266
...
35228441e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35228441e3 | ||
|
|
ee13f8ba99 |
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
24
src/hooks/useClickOutside.ts
Normal file
24
src/hooks/useClickOutside.ts
Normal 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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user