/ frontend / src / app / views / invitations / Invitations.jsx
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  }