Enhance HouseManagement and HousesPage components: Introduce invitation management features, including fetching and displaying pending invitations. Refactor data handling and UI updates for improved user experience and maintainability. Optimize state management with useCallback and useEffect for better performance.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m43s

This commit is contained in:
Julien Froidefond
2025-12-18 09:16:13 +01:00
parent f5dab3cb95
commit 0b56d625ec
2 changed files with 261 additions and 91 deletions

View File

@@ -5,7 +5,11 @@ import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import HousesSection from "@/components/houses/HousesSection"; import HousesSection from "@/components/houses/HousesSection";
import { houseService } from "@/services/houses/house.service"; import { houseService } from "@/services/houses/house.service";
import { prisma } from "@/services/database"; import { prisma } from "@/services/database";
import type { House, HouseMembership, HouseInvitation } from "@/prisma/generated/prisma/client"; import type {
House,
HouseMembership,
HouseInvitation,
} from "@/prisma/generated/prisma/client";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,15 +21,17 @@ type HouseWithRelations = House & {
avatar: string | null; avatar: string | null;
} | null; } | null;
creatorId?: string; creatorId?: string;
memberships?: Array<HouseMembership & { memberships?: Array<
user: { HouseMembership & {
id: string; user: {
username: string; id: string;
avatar: string | null; username: string;
score: number | null; avatar: string | null;
level: number | null; score: number | null;
}; level: number | null;
}>; };
}
>;
}; };
type InvitationWithRelations = HouseInvitation & { type InvitationWithRelations = HouseInvitation & {
@@ -47,10 +53,39 @@ export default async function HousesPage() {
redirect("/login"); redirect("/login");
} }
const [housesData, myHouseData, invitationsData, users, backgroundImage] = await Promise.all([ const [housesData, myHouseData, invitationsData, users, backgroundImage] =
// Récupérer les maisons await Promise.all([
houseService.getAllHouses({ // Récupérer les maisons
include: { houseService.getAllHouses({
include: {
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
orderBy: [
{ role: "asc" }, // OWNER, ADMIN, MEMBER
{ user: { score: "desc" } }, // Puis par score décroissant
],
},
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
},
}),
// Récupérer la maison de l'utilisateur
houseService.getUserHouse(session.user.id, {
memberships: { memberships: {
include: { include: {
user: { user: {
@@ -63,10 +98,6 @@ export default async function HousesPage() {
}, },
}, },
}, },
orderBy: [
{ role: "asc" }, // OWNER, ADMIN, MEMBER
{ user: { score: "desc" } }, // Puis par score décroissant
],
}, },
creator: { creator: {
select: { select: {
@@ -75,78 +106,66 @@ export default async function HousesPage() {
avatar: true, avatar: true,
}, },
}, },
}, }),
}), // Récupérer les invitations de l'utilisateur
// Récupérer la maison de l'utilisateur houseService.getUserInvitations(session.user.id, "PENDING"),
houseService.getUserHouse(session.user.id, { // Récupérer tous les utilisateurs sans maison pour les invitations
memberships: { prisma.user.findMany({
include: { where: {
user: { houseMemberships: {
select: { none: {},
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
}, },
}, },
},
creator: {
select: { select: {
id: true, id: true,
username: true, username: true,
avatar: true, avatar: true,
}, },
}, orderBy: {
}), username: "asc",
// Récupérer les invitations de l'utilisateur
houseService.getUserInvitations(session.user.id, "PENDING"),
// Récupérer tous les utilisateurs sans maison pour les invitations
prisma.user.findMany({
where: {
houseMemberships: {
none: {},
}, },
}, }),
select: { getBackgroundImage("challenges", "/got-2.jpg"),
id: true, ]);
username: true,
avatar: true,
},
orderBy: {
username: "asc",
},
}),
getBackgroundImage("challenges", "/got-2.jpg"),
]);
// Sérialiser les données pour le client // Sérialiser les données pour le client
const houses = (housesData as HouseWithRelations[]).map((house: HouseWithRelations) => ({ const houses = (housesData as HouseWithRelations[]).map(
id: house.id, (house: HouseWithRelations) => ({
name: house.name, id: house.id,
description: house.description, name: house.name,
creator: house.creator || { id: house.creatorId || "", username: "Unknown", avatar: null }, description: house.description,
memberships: (house.memberships || []).map((m) => ({ creator: house.creator || {
id: m.id, id: house.creatorId || "",
role: m.role, username: "Unknown",
user: { avatar: null,
id: m.user.id,
username: m.user.username,
avatar: m.user.avatar,
score: m.user.score ?? 0,
level: m.user.level ?? 1,
}, },
})), memberships: (house.memberships || []).map((m) => ({
})); id: m.id,
role: m.role,
user: {
id: m.user.id,
username: m.user.username,
avatar: m.user.avatar,
score: m.user.score ?? 0,
level: m.user.level ?? 1,
},
})),
})
);
const myHouse = myHouseData const myHouse = myHouseData
? { ? {
id: myHouseData.id, id: myHouseData.id,
name: myHouseData.name, name: myHouseData.name,
description: myHouseData.description, description: myHouseData.description,
creator: (myHouseData as HouseWithRelations).creator || { id: (myHouseData as HouseWithRelations).creatorId || "", username: "Unknown", avatar: null }, creator: (myHouseData as HouseWithRelations).creator || {
memberships: ((myHouseData as HouseWithRelations).memberships || []).map((m) => ({ id: (myHouseData as HouseWithRelations).creatorId || "",
username: "Unknown",
avatar: null,
},
memberships: (
(myHouseData as HouseWithRelations).memberships || []
).map((m) => ({
id: m.id, id: m.id,
role: m.role, role: m.role,
user: { user: {
@@ -160,16 +179,18 @@ export default async function HousesPage() {
} }
: null; : null;
const invitations = (invitationsData as InvitationWithRelations[]).map((inv: InvitationWithRelations) => ({ const invitations = (invitationsData as InvitationWithRelations[]).map(
id: inv.id, (inv: InvitationWithRelations) => ({
house: { id: inv.id,
id: inv.house.id, house: {
name: inv.house.name, id: inv.house.id,
}, name: inv.house.name,
inviter: inv.inviter, },
status: inv.status, inviter: inv.inviter,
createdAt: inv.createdAt.toISOString(), status: inv.status,
})); createdAt: inv.createdAt.toISOString(),
})
);
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useTransition } from "react"; import { useState, useEffect, useTransition, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
@@ -8,7 +8,7 @@ import HouseForm from "./HouseForm";
import RequestList from "./RequestList"; import RequestList from "./RequestList";
import Alert from "@/components/ui/Alert"; import Alert from "@/components/ui/Alert";
import { deleteHouse, leaveHouse, removeMember } from "@/actions/houses/update"; import { deleteHouse, leaveHouse, removeMember } from "@/actions/houses/update";
import { inviteUser } from "@/actions/houses/invitations"; import { inviteUser, cancelInvitation } from "@/actions/houses/invitations";
interface House { interface House {
id: string; id: string;
@@ -38,6 +38,22 @@ interface User {
avatar: string | null; avatar: string | null;
} }
interface HouseInvitation {
id: string;
invitee: {
id: string;
username: string;
avatar: string | null;
};
inviter: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}
interface HouseManagementProps { interface HouseManagementProps {
house: House | null; house: House | null;
users?: User[]; users?: User[];
@@ -76,6 +92,7 @@ export default function HouseManagement({
const [showInviteForm, setShowInviteForm] = useState(false); const [showInviteForm, setShowInviteForm] = useState(false);
const [selectedUserId, setSelectedUserId] = useState(""); const [selectedUserId, setSelectedUserId] = useState("");
const [requests, setRequests] = useState<Request[]>(initialRequests); const [requests, setRequests] = useState<Request[]>(initialRequests);
const [invitations, setInvitations] = useState<HouseInvitation[]>([]);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@@ -105,6 +122,34 @@ export default function HouseManagement({
fetchRequests(); fetchRequests();
}, [house, isAdmin]); }, [house, isAdmin]);
const fetchInvitations = useCallback(async () => {
if (!house || !isAdmin) return;
try {
const response = await fetch(
`/api/houses/${house.id}/invitations?status=PENDING`
);
if (response.ok) {
const data = await response.json();
setInvitations(data);
}
} catch (error) {
console.error("Error fetching invitations:", error);
}
}, [house, isAdmin]);
useEffect(() => {
// Utiliser un timeout pour éviter l'appel synchrone de setState dans l'effect
const timeout = setTimeout(() => {
fetchInvitations();
}, 0);
return () => clearTimeout(timeout);
}, [fetchInvitations]);
const handleUpdate = useCallback(() => {
fetchInvitations();
onUpdate?.();
}, [fetchInvitations, onUpdate]);
const handleDelete = () => { const handleDelete = () => {
if ( if (
!house || !house ||
@@ -119,7 +164,7 @@ export default function HouseManagement({
if (result.success) { if (result.success) {
// Rafraîchir le score dans le header (le créateur perd des points) // Rafraîchir le score dans le header (le créateur perd des points)
window.dispatchEvent(new Event("refreshUserScore")); window.dispatchEvent(new Event("refreshUserScore"));
onUpdate?.(); handleUpdate();
} else { } else {
setError(result.error || "Erreur lors de la suppression"); setError(result.error || "Erreur lors de la suppression");
} }
@@ -136,7 +181,7 @@ export default function HouseManagement({
const result = await leaveHouse(house.id); const result = await leaveHouse(house.id);
if (result.success) { if (result.success) {
window.dispatchEvent(new Event("refreshUserScore")); window.dispatchEvent(new Event("refreshUserScore"));
onUpdate?.(); handleUpdate();
} else { } else {
setError(result.error || "Erreur lors de la sortie"); setError(result.error || "Erreur lors de la sortie");
} }
@@ -157,7 +202,9 @@ export default function HouseManagement({
setSuccess("Invitation envoyée"); setSuccess("Invitation envoyée");
setShowInviteForm(false); setShowInviteForm(false);
setSelectedUserId(""); setSelectedUserId("");
onUpdate?.(); // Rafraîchir la liste des invitations
await fetchInvitations();
handleUpdate();
} else { } else {
setError(result.error || "Erreur lors de l'envoi de l'invitation"); setError(result.error || "Erreur lors de l'envoi de l'invitation");
} }
@@ -271,7 +318,7 @@ export default function HouseManagement({
house={house} house={house}
onSuccess={() => { onSuccess={() => {
setIsEditing(false); setIsEditing(false);
onUpdate?.(); handleUpdate();
}} }}
onCancel={() => setIsEditing(false)} onCancel={() => setIsEditing(false)}
/> />
@@ -286,6 +333,11 @@ export default function HouseManagement({
}} }}
> >
Membres ({house.memberships?.length ?? 0}) Membres ({house.memberships?.length ?? 0})
{isAdmin && invitations.length > 0 && (
<span className="ml-2 text-xs normal-case" style={{ color: "var(--muted-foreground)" }}>
{invitations.length} invitation{invitations.length > 1 ? "s" : ""} en cours
</span>
)}
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2">
{(house.memberships || []).map((membership) => { {(house.memberships || []).map((membership) => {
@@ -379,7 +431,7 @@ export default function HouseManagement({
window.dispatchEvent( window.dispatchEvent(
new Event("refreshUserScore") new Event("refreshUserScore")
); );
onUpdate?.(); handleUpdate();
} else { } else {
setError( setError(
result.error || result.error ||
@@ -402,6 +454,103 @@ export default function HouseManagement({
})} })}
</div> </div>
{isAdmin && invitations.length > 0 && (
<div className="mt-4">
<h5
className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{
color: "var(--primary)",
opacity: 0.7,
}}
>
Invitations en cours
</h5>
<div className="space-y-2">
{invitations
.filter((inv) => inv.status === "PENDING")
.map((invitation) => (
<div
key={invitation.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded"
style={{
backgroundColor: "var(--card-hover)",
borderLeft: `3px solid var(--primary)`,
opacity: 0.8,
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{invitation.invitee.avatar && (
<img
src={invitation.invitee.avatar}
alt={invitation.invitee.username}
className="w-8 h-8 rounded-full flex-shrink-0 border-2"
style={{ borderColor: "var(--primary)" }}
/>
)}
<div className="min-w-0">
<span
className="font-semibold block sm:inline"
style={{ color: "var(--foreground)" }}
>
{invitation.invitee.username}
</span>
<span
className="text-xs block sm:inline sm:ml-2"
style={{ color: "var(--muted-foreground)" }}
>
Invité par {invitation.inviter.username}
</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className="text-xs uppercase px-2 py-1 rounded font-bold"
style={{
color: "var(--primary)",
backgroundColor: `color-mix(in srgb, var(--primary) 15%, transparent)`,
border: `1px solid color-mix(in srgb, var(--primary) 30%, transparent)`,
}}
>
En attente
</span>
<Button
onClick={() => {
if (
confirm(
`Êtes-vous sûr de vouloir annuler l'invitation pour ${invitation.invitee.username} ?`
)
) {
startTransition(async () => {
const result = await cancelInvitation(
invitation.id
);
if (result.success) {
window.dispatchEvent(
new Event("refreshInvitations")
);
handleUpdate();
} else {
setError(
result.error ||
"Erreur lors de l'annulation"
);
}
});
}
}}
disabled={isPending}
variant="danger"
size="sm"
>
Annuler
</Button>
</div>
</div>
))}
</div>
</div>
)}
{isAdmin && ( {isAdmin && (
<div className="mt-4"> <div className="mt-4">
{showInviteForm ? ( {showInviteForm ? (
@@ -471,7 +620,7 @@ export default function HouseManagement({
> >
Demandes d&apos;adhésion Demandes d&apos;adhésion
</h2> </h2>
<RequestList requests={pendingRequests} onUpdate={onUpdate} /> <RequestList requests={pendingRequests} onUpdate={handleUpdate} />
</Card> </Card>
)} )}
</div> </div>