feat: implement Weather Workshop feature with models, UI components, and session management for enhanced team visibility and personal well-being tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m16s

This commit is contained in:
Julien Froidefond
2026-02-03 18:08:06 +01:00
parent 3a2eb83197
commit 163caa398c
20 changed files with 2287 additions and 28 deletions

View File

@@ -0,0 +1,146 @@
'use client';
import { useMemo } from 'react';
import { WeatherCard } from './WeatherCard';
interface WeatherEntry {
id: string;
userId: string;
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
notes: string | null;
user: {
id: string;
name: string | null;
email: string;
};
}
interface Share {
id: string;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface WeatherBoardProps {
sessionId: string;
currentUserId: string;
currentUser: {
id: string;
name: string | null;
email: string;
};
entries: WeatherEntry[];
shares: Share[];
owner: {
id: string;
name: string | null;
email: string;
};
canEdit: boolean;
}
export function WeatherBoard({
sessionId,
currentUserId,
entries,
shares,
owner,
canEdit,
}: WeatherBoardProps) {
// 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]);
// Create entries map for quick lookup
const entriesMap = useMemo(() => {
const map = new Map<string, WeatherEntry>();
entries.forEach((entry) => {
map.set(entry.userId, entry);
});
return map;
}, [entries]);
// Create entries for all users (with placeholder entries for users without entries)
const allEntries = useMemo(() => {
return allUsers.map((user) => {
const existingEntry = entriesMap.get(user.id);
if (existingEntry) {
return existingEntry;
}
// Create placeholder entry for user without entry
return {
id: '',
userId: user.id,
performanceEmoji: null,
moralEmoji: null,
fluxEmoji: null,
valueCreationEmoji: null,
notes: null,
user,
};
});
}, [allUsers, entriesMap]);
// Sort: current user first, then owner, then others
const sortedEntries = useMemo(() => {
return [...allEntries].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
if (a.userId === owner.id) return -1;
if (b.userId === owner.id) return 1;
return (a.user.name || a.user.email).localeCompare(b.user.name || b.user.email, 'fr');
});
}, [allEntries, currentUserId, owner.id]);
return (
<div className="overflow-x-auto rounded-lg border border-border bg-card">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-card-column">
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Membre</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Performance
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Moral</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Flux</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Création de valeur
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[300px]">
Notes
</th>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry) => (
<WeatherCard
key={entry.userId}
sessionId={sessionId}
currentUserId={currentUserId}
entry={entry}
canEdit={canEdit}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState, useTransition } from 'react';
import { createOrUpdateWeatherEntry } from '@/actions/weather';
import { Avatar } from '@/components/ui/Avatar';
import { Textarea } from '@/components/ui/Textarea';
const WEATHER_EMOJIS = [
{ emoji: '', label: 'Aucun' },
{ emoji: '☀️', label: 'Soleil' },
{ emoji: '🌤️', label: 'Soleil derrière nuage' },
{ emoji: '⛅', label: 'Soleil et nuages' },
{ emoji: '☁️', label: 'Nuages' },
{ emoji: '🌦️', label: 'Soleil et pluie' },
{ emoji: '🌧️', label: 'Pluie' },
{ emoji: '⛈️', label: 'Orage et pluie' },
{ emoji: '🌩️', label: 'Éclair' },
{ emoji: '❄️', label: 'Neige' },
{ emoji: '🌨️', label: 'Neige qui tombe' },
{ emoji: '🌪️', label: 'Tornade' },
{ emoji: '🌫️', label: 'Brouillard' },
{ emoji: '🌈', label: 'Arc-en-ciel' },
{ emoji: '🌊', label: 'Vague' },
{ emoji: '🔥', label: 'Feu' },
{ emoji: '💨', label: 'Vent' },
{ emoji: '⭐', label: 'Étoile' },
{ emoji: '🌟', label: 'Étoile brillante' },
{ emoji: '✨', label: 'Étincelles' },
];
interface WeatherEntry {
id: string;
userId: string;
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
notes: string | null;
user: {
id: string;
name: string | null;
email: string;
};
}
interface WeatherCardProps {
sessionId: string;
currentUserId: string;
entry: WeatherEntry;
canEdit: boolean;
}
export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: WeatherCardProps) {
const [isPending, startTransition] = useTransition();
const [notes, setNotes] = useState(entry.notes || '');
const [performanceEmoji, setPerformanceEmoji] = useState(entry.performanceEmoji || null);
const [moralEmoji, setMoralEmoji] = useState(entry.moralEmoji || null);
const [fluxEmoji, setFluxEmoji] = useState(entry.fluxEmoji || null);
const [valueCreationEmoji, setValueCreationEmoji] = useState(entry.valueCreationEmoji || null);
const isCurrentUser = entry.userId === currentUserId;
const canEditThis = canEdit && isCurrentUser;
function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) {
if (!canEditThis) return;
// Calculate new values
const newPerformanceEmoji = axis === 'performance' ? emoji : performanceEmoji;
const newMoralEmoji = axis === 'moral' ? emoji : moralEmoji;
const newFluxEmoji = axis === 'flux' ? emoji : fluxEmoji;
const newValueCreationEmoji = axis === 'valueCreation' ? emoji : valueCreationEmoji;
// Update local state immediately
if (axis === 'performance') {
setPerformanceEmoji(emoji);
} else if (axis === 'moral') {
setMoralEmoji(emoji);
} else if (axis === 'flux') {
setFluxEmoji(emoji);
} else if (axis === 'valueCreation') {
setValueCreationEmoji(emoji);
}
// Save to server with new values
startTransition(async () => {
await createOrUpdateWeatherEntry(sessionId, {
performanceEmoji: newPerformanceEmoji,
moralEmoji: newMoralEmoji,
fluxEmoji: newFluxEmoji,
valueCreationEmoji: newValueCreationEmoji,
notes,
});
});
}
function handleNotesChange(newNotes: string) {
if (!canEditThis) return;
setNotes(newNotes);
}
function handleNotesBlur() {
if (!canEditThis) return;
startTransition(async () => {
await createOrUpdateWeatherEntry(sessionId, {
performanceEmoji,
moralEmoji,
fluxEmoji,
valueCreationEmoji,
notes,
});
});
}
// For current user without entry, we need to get user info from somewhere
// For now, we'll use a placeholder - in real app, you'd pass user info as prop
const user = entry.user;
return (
<tr className={`border-b border-border ${isPending ? 'opacity-50' : ''}`}>
{/* User column */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Avatar email={user.email} name={user.name} size={32} />
<span className="text-sm font-medium text-foreground">
{user.name || user.email || 'Vous'}
</span>
</div>
</td>
{/* Performance */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={performanceEmoji || ''}
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</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 className="text-2xl text-center">{performanceEmoji || '-'}</div>
)}
</td>
{/* Moral */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={moralEmoji || ''}
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</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 className="text-2xl text-center">{moralEmoji || '-'}</div>
)}
</td>
{/* Flux */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={fluxEmoji || ''}
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</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 className="text-2xl text-center">{fluxEmoji || '-'}</div>
)}
</td>
{/* Création de valeur */}
<td className="px-4 py-3">
{canEditThis ? (
<div className="relative">
<select
value={valueCreationEmoji || ''}
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji, label }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji} {label}
</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 className="text-2xl text-center">{valueCreationEmoji || '-'}</div>
)}
</td>
{/* Notes */}
<td className="px-4 py-3 min-w-[300px]">
{canEditThis ? (
<Textarea
value={notes}
onChange={(e) => handleNotesChange(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Notes globales..."
className="min-h-[120px] w-full resize-y"
rows={5}
/>
) : (
<div className="text-sm text-foreground whitespace-pre-wrap min-h-[120px]">
{notes || '-'}
</div>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
export function WeatherInfoPanel() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mb-6 rounded-lg border border-border bg-card-hover">
<button
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>
<svg
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="border-t border-border px-4 py-3">
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs font-medium text-foreground mb-0.5"> Performance</p>
<p className="text-xs text-muted leading-relaxed">
Votre performance personnelle et l&apos;atteinte de vos objectifs
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">😊 Moral</p>
<p className="text-xs text-muted leading-relaxed">
Votre moral actuel et votre ressenti
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">🌊 Flux</p>
<p className="text-xs text-muted leading-relaxed">
Votre flux de travail personnel et les blocages éventuels
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">💎 Création de valeur</p>
<p className="text-xs text-muted leading-relaxed">
Votre création de valeur et votre apport
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeatherShareModal } from './WeatherShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: Team[];
children: React.ReactNode;
}
export function WeatherLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: WeatherLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeatherLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useWeatherLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<WeatherShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
/>
</>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: Team[];
}
export function WeatherShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
userTeams = [],
}: WeatherShareModalProps) {
const [shareType, setShareType] = useState<'user' | 'team'>('user');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
let result;
if (shareType === 'team') {
result = await shareWeatherSessionToTeam(sessionId, teamId, role);
} else {
result = await shareWeatherSession(sessionId, email, role);
}
if (result.success) {
setEmail('');
setTeamId('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeatherShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la météo">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Météo personnelle</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
{/* Share type selector */}
<div className="flex gap-2 border-b border-border pb-3">
<button
type="button"
onClick={() => {
setShareType('user');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👤 Utilisateur
</button>
<button
type="button"
onClick={() => {
setShareType('team');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'team'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👥 Équipe
</button>
</div>
{/* User share */}
{shareType === 'user' && (
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="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"
>
<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>
)}
{/* Team share */}
{shareType === 'team' && (
<div className="space-y-2">
{userTeams.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<>
<div className="relative">
<select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
className="w-full 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"
required
>
<option value="">Sélectionner une équipe</option>
{userTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name} {team.userRole === 'ADMIN' && '(Admin)'}
</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 className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="w-full 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"
>
<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>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
disabled={isPending || (shareType === 'user' && !email) || (shareType === 'team' && !teamId)}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? 'Partager à l&apos;équipe' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { WeatherBoard } from './WeatherBoard';
export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherShareModal } from './WeatherShareModal';
export { WeatherInfoPanel } from './WeatherInfoPanel';