Invitations.jsx
1 import { useState, useEffect } from "react"; 2 import { 3 Box, Button, Card, Divider, Grid, styled, Typography, 4 } from "@mui/material"; 5 import { Check, Close, Groups, AccountTree } from "@mui/icons-material"; 6 import Breadcrumb from "app/components/Breadcrumb"; 7 import { H4 } from "app/components/Typography"; 8 import useAuth from "app/hooks/useAuth"; 9 import api from "app/utils/api"; 10 import { toast } from "react-toastify"; 11 import { useTranslation } from "react-i18next"; 12 13 const Container = styled("div")(({ theme }) => ({ 14 margin: 10, 15 [theme.breakpoints.down("sm")]: { margin: 16 }, 16 "& .breadcrumb": { marginBottom: 30, [theme.breakpoints.down("sm")]: { marginBottom: 16 } } 17 })); 18 19 const ContentBox = styled("div")(({ theme }) => ({ 20 margin: "30px", 21 [theme.breakpoints.down("sm")]: { margin: "16px" } 22 })); 23 24 const FlexBox = styled(Box)({ display: "flex", alignItems: "center" }); 25 26 function InvitationCard({ inv, label, onAccept, onDecline }) { 27 const { t } = useTranslation(); 28 return ( 29 <Card variant="outlined" sx={{ p: 2 }}> 30 <Typography variant="h6" gutterBottom>{label}</Typography> 31 <Typography variant="body2" color="text.secondary"> 32 {t("invitations.invitedBy", { username: inv.invited_by })} 33 </Typography> 34 <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 2 }}> 35 {inv.created_at ? new Date(inv.created_at).toLocaleString() : ""} 36 </Typography> 37 <Box sx={{ display: "flex", gap: 1 }}> 38 <Button variant="contained" color="success" size="small" startIcon={<Check />} onClick={onAccept}> 39 {t("invitations.accept")} 40 </Button> 41 <Button variant="outlined" color="error" size="small" startIcon={<Close />} onClick={onDecline}> 42 {t("invitations.decline")} 43 </Button> 44 </Box> 45 </Card> 46 ); 47 } 48 49 function InvitationSection({ icon: Icon, title, emptyText, invites, nameField, onAccept, onDecline, sx }) { 50 return ( 51 <Card elevation={3} sx={sx}> 52 <FlexBox> 53 <Icon sx={{ ml: 2 }} /> 54 <H4 sx={{ p: 2 }}>{title}</H4> 55 </FlexBox> 56 <Divider /> 57 {invites.length === 0 ? ( 58 <Box sx={{ textAlign: "center", py: 4, color: "text.secondary" }}> 59 <Typography variant="body2">{emptyText}</Typography> 60 </Box> 61 ) : ( 62 <Box sx={{ p: 2 }}> 63 <Grid container spacing={2}> 64 {invites.map((inv) => ( 65 <Grid item xs={12} sm={6} md={4} key={`${inv.type}-${inv.id}`}> 66 <InvitationCard 67 inv={inv} 68 label={inv[nameField]} 69 onAccept={() => onAccept(inv)} 70 onDecline={() => onDecline(inv)} 71 /> 72 </Grid> 73 ))} 74 </Grid> 75 </Box> 76 )} 77 </Card> 78 ); 79 } 80 81 export default function Invitations() { 82 const { t } = useTranslation(); 83 const auth = useAuth(); 84 const [invitations, setInvitations] = useState([]); 85 86 const fetchInvitations = () => { 87 api.get("/invitations", auth.user.token) 88 .then(setInvitations) 89 .catch(() => {}); 90 }; 91 92 useEffect(() => { 93 document.title = (process.env.REACT_APP_RESTAI_NAME || "RESTai") + " - " + t("invitations.title"); 94 fetchInvitations(); 95 // eslint-disable-next-line react-hooks/exhaustive-deps 96 }, [t]); 97 98 // Remove the row from local state immediately so the user gets 99 // instant feedback; reconcile on response. On error we refetch to 100 // restore the canonical list. 101 const actOnInvitation = (inv, action) => { 102 const invKey = `${inv.type || "team"}:${inv.id}`; 103 const snapshot = invitations; 104 setInvitations((prev) => prev.filter((i) => `${i.type || "team"}:${i.id}` !== invKey)); 105 106 const base = inv.type === "project" 107 ? `/invitations/projects/${inv.id}` 108 : `/invitations/${inv.id}`; 109 const url = `${base}/${action}`; 110 const label = inv.type === "project" 111 ? (inv.project_name || `project ${inv.id}`) 112 : (inv.team_name || `team ${inv.id}`); 113 const verb = action === "accept" ? "joined" : "declined"; 114 115 api.post(url, {}, auth.user.token, { silent: true }) 116 .then(() => { 117 const msgKey = action === "accept" ? "invitations.accepted" : "invitations.declined"; 118 toast.success(t(msgKey, { name: label }), { position: "top-right" }); 119 window.dispatchEvent(new Event("invitations-changed")); 120 }) 121 .catch(() => { 122 // Revert and refetch so stale state doesn't linger. 123 setInvitations(snapshot); 124 toast.error(t("invitations.failed", { 125 action: action === "accept" ? t("invitations.accept").toLowerCase() : t("invitations.decline").toLowerCase(), 126 name: label, 127 }), { position: "top-right" }); 128 fetchInvitations(); 129 }); 130 }; 131 132 const handleAccept = (inv) => actOnInvitation(inv, "accept"); 133 const handleDecline = (inv) => actOnInvitation(inv, "decline"); 134 135 const teamInvites = invitations.filter((inv) => inv.type !== "project"); 136 const projectInvites = invitations.filter((inv) => inv.type === "project"); 137 138 return ( 139 <Container> 140 <Box className="breadcrumb"> 141 <Breadcrumb routeSegments={[{ name: t("nav.invitations"), path: "/invitations" }]} /> 142 </Box> 143 144 <ContentBox> 145 <InvitationSection 146 icon={Groups} 147 title={t("invitations.teamSection")} 148 emptyText={t("invitations.noInvitations")} 149 invites={teamInvites} 150 nameField="team_name" 151 onAccept={handleAccept} 152 onDecline={handleDecline} 153 sx={{ mb: 3 }} 154 /> 155 <InvitationSection 156 icon={AccountTree} 157 title={t("invitations.projectSection")} 158 emptyText={t("invitations.noInvitations")} 159 invites={projectInvites} 160 nameField="project_name" 161 onAccept={handleAccept} 162 onDecline={handleDecline} 163 /> 164 </ContentBox> 165 </Container> 166 ); 167 }