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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m16s
This commit is contained in:
146
src/components/weather/WeatherBoard.tsx
Normal file
146
src/components/weather/WeatherBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
src/components/weather/WeatherCard.tsx
Normal file
273
src/components/weather/WeatherCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/weather/WeatherInfoPanel.tsx
Normal file
56
src/components/weather/WeatherInfoPanel.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
142
src/components/weather/WeatherLiveWrapper.tsx
Normal file
142
src/components/weather/WeatherLiveWrapper.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
307
src/components/weather/WeatherShareModal.tsx
Normal file
307
src/components/weather/WeatherShareModal.tsx
Normal 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'êtes membre d'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'é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>
|
||||
);
|
||||
}
|
||||
5
src/components/weather/index.ts
Normal file
5
src/components/weather/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user