/ frontend / src / app / views / teams / TeamView.jsx
TeamView.jsx
  1  import { useState, useEffect, Fragment } from "react";
  2  import {
  3    Box,
  4    Grid,
  5    styled,
  6    Card,
  7    Typography,
  8    Button,
  9    Tabs,
 10    Tab,
 11    List,
 12    ListItem,
 13    ListItemText,
 14    ListItemAvatar,
 15    Avatar,
 16    Divider,
 17    IconButton,
 18    Tooltip,
 19    CircularProgress,
 20    LinearProgress
 21  } from "@mui/material";
 22  import TableRow from '@mui/material/TableRow';
 23  import TableCell from '@mui/material/TableCell';
 24  import { useNavigate, useParams } from "react-router-dom";
 25  import useAuth from "app/hooks/useAuth";
 26  import { Breadcrumb } from "app/components";
 27  import { toast } from 'react-toastify';
 28  import { useTranslation } from "react-i18next";
 29  import { Person, Settings, Delete, Group, Code, Psychology, AccountBalanceWallet, Receipt, AllInclusive, Image, Speaker } from "@mui/icons-material";
 30  import MUIDataTable from "mui-datatables";
 31  import ReactJson from '@microlink/react-json-view';
 32  import api from "app/utils/api";
 33  
 34  const Container = styled("div")(({ theme }) => ({
 35    margin: "30px",
 36    [theme.breakpoints.down("sm")]: { margin: "16px" },
 37    "& .breadcrumb": {
 38      marginBottom: "30px",
 39      [theme.breakpoints.down("sm")]: { marginBottom: "16px" }
 40    }
 41  }));
 42  
 43  const StyledCard = styled(Card)(({ theme }) => ({
 44    padding: theme.spacing(3),
 45    marginBottom: theme.spacing(3)
 46  }));
 47  
 48  function TabPanel(props) {
 49    const { children, value, index, ...other } = props;
 50  
 51    return (
 52      <div
 53        role="tabpanel"
 54        hidden={value !== index}
 55        id={`team-tabpanel-${index}`}
 56        aria-labelledby={`team-tab-${index}`}
 57        {...other}
 58      >
 59        {value === index && <Box sx={{ p: 3 }}>{children}</Box>}
 60      </div>
 61    );
 62  }
 63  
 64  export default function TeamView() {
 65    const { t } = useTranslation();
 66    const { id } = useParams();
 67    const [team, setTeam] = useState(null);
 68    const [tabValue, setTabValue] = useState(0);
 69    const navigate = useNavigate();
 70    const { user } = useAuth();
 71    const [loading, setLoading] = useState(true);
 72  
 73    const [transactions, setTransactions] = useState([]);
 74    const [txPage, setTxPage] = useState(0);
 75    const [txRows, setTxRows] = useState(100);
 76    const [txCount, setTxCount] = useState(0);
 77    const [txLog, setTxLog] = useState({});
 78    const [txRowsExpanded, setTxRowsExpanded] = useState([]);
 79    const [txLoaded, setTxLoaded] = useState(false);
 80  
 81    const isTeamAdmin = team?.admins?.some(admin => admin.id === user.id) || user.is_admin;
 82  
 83    const fetchTeam = async () => {
 84      setLoading(true);
 85      try {
 86        const data = await api.get(`/teams/${id}`, user.token);
 87        setTeam(data);
 88      } catch (error) {
 89        // errors auto-toasted
 90      } finally {
 91        setLoading(false);
 92      }
 93    };
 94  
 95    useEffect(() => {
 96      fetchTeam();
 97    }, [id]);
 98  
 99    useEffect(() => {
100      if (team) {
101        document.title = `${process.env.REACT_APP_RESTAI_NAME || "RESTai"} - Team: ${team.name}`;
102      }
103    }, [team]);
104  
105    const fetchTransactions = async () => {
106      const txStart = txPage * txRows;
107      const txEnd = txStart + txRows;
108      try {
109        const data = await api.get(`/teams/${id}/transactions?start=${txStart}&end=${txEnd}`, user.token);
110        if (data.transactions) {
111          setTransactions(data.transactions);
112          setTxCount(data.total);
113        }
114      } catch (error) {
115        // errors auto-toasted
116      }
117    };
118  
119    useEffect(() => {
120      if (tabValue === 3 && team) {
121        fetchTransactions();
122        setTxLoaded(true);
123      }
124    }, [tabValue, team, txPage, txRows]);
125  
126    const handleTabChange = (event, newValue) => {
127      setTabValue(newValue);
128    };
129  
130    const handleEditTeam = () => {
131      navigate(`/team/${id}/edit`);
132    };
133  
134    const handleRemoveUser = async (username) => {
135      if (!window.confirm(t("teams.view.confirmRemove", { name: username }))) {
136        return;
137      }
138  
139      try {
140        await api.delete(`/teams/${id}/users/${username}`, user.token);
141        toast.success(t("teams.view.removed", { name: username }));
142        fetchTeam();
143      } catch (error) {
144        // errors auto-toasted
145      }
146    };
147  
148    const handleRemoveAdmin = async (username) => {
149      if (!window.confirm(t("teams.view.confirmRemoveAdmin", { name: username }))) {
150        return;
151      }
152  
153      try {
154        await api.delete(`/teams/${id}/admins/${username}`, user.token);
155        toast.success(t("teams.view.adminRemoved", { name: username }));
156        fetchTeam();
157      } catch (error) {
158        // errors auto-toasted
159      }
160    };
161  
162    const handleRemoveProject = async (projectId) => {
163      if (!window.confirm(t("teams.view.confirmRemoveProject"))) {
164        return;
165      }
166  
167      try {
168        await api.delete(`/teams/${id}/projects/${projectId}`, user.token);
169        toast.success(t("teams.view.projectRemoved"));
170        fetchTeam();
171      } catch (error) {
172        // errors auto-toasted
173      }
174    };
175  
176    const handleRemoveLLM = async (llm) => {
177      if (!window.confirm(t("teams.view.confirmRemove", { name: llm.name }))) {
178        return;
179      }
180  
181      try {
182        await api.delete(`/teams/${id}/llms/${llm.id}`, user.token);
183        toast.success(t("teams.view.removed", { name: llm.name }));
184        fetchTeam();
185      } catch (error) {
186        // errors auto-toasted
187      }
188    };
189  
190    const handleRemoveEmbedding = async (embedding) => {
191      if (!window.confirm(t("teams.view.confirmRemove", { name: embedding.name }))) {
192        return;
193      }
194  
195      try {
196        await api.delete(`/teams/${id}/embeddings/${embedding.id}`, user.token);
197        toast.success(t("teams.view.removed", { name: embedding.name }));
198        fetchTeam();
199      } catch (error) {
200        // errors auto-toasted
201      }
202    };
203  
204    const handleRemoveImageGenerator = async (generatorName) => {
205      if (!window.confirm(t("teams.view.confirmRemove", { name: generatorName }))) {
206        return;
207      }
208  
209      try {
210        await api.delete(`/teams/${id}/image_generators/${generatorName}`, user.token);
211        toast.success(t("teams.view.removed", { name: generatorName }));
212        fetchTeam();
213      } catch (error) {
214        // errors auto-toasted
215      }
216    };
217  
218    const handleRemoveAudioGenerator = async (generatorName) => {
219      if (!window.confirm(t("teams.view.confirmRemove", { name: generatorName }))) {
220        return;
221      }
222  
223      try {
224        await api.delete(`/teams/${id}/audio_generators/${generatorName}`, user.token);
225        toast.success(t("teams.view.removed", { name: generatorName }));
226        fetchTeam();
227      } catch (error) {
228        // errors auto-toasted
229      }
230    };
231  
232    if (loading) {
233      return (
234        <Container>
235          <Box className="breadcrumb">
236            <Breadcrumb routeSegments={[
237              { name: t("nav.teams"), path: "/teams" },
238              { name: t("common.loading"), path: `/teams/${id}` }
239            ]} />
240          </Box>
241          <Typography>{t("teams.view.loading")}</Typography>
242        </Container>
243      );
244    }
245  
246    if (!team) {
247      return (
248        <Container>
249          <Box className="breadcrumb">
250            <Breadcrumb routeSegments={[
251              { name: t("nav.teams"), path: "/teams" },
252              { name: t("teams.view.notFoundTitle"), path: `/teams/${id}` }
253            ]} />
254          </Box>
255          <Typography>{t("teams.view.notFound")}</Typography>
256        </Container>
257      );
258    }
259  
260    return (
261      <Container>
262        <Box className="breadcrumb">
263          <Breadcrumb routeSegments={[
264            { name: t("nav.teams"), path: "/teams" },
265            { name: team.name, path: `/teams/${id}` }
266          ]} />
267        </Box>
268  
269        <StyledCard>
270          <Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
271            <Box>
272              <Typography variant="h4">{team.name}</Typography>
273              <Typography variant="body1" color="textSecondary">
274                {team.description || t("teams.view.noDescription")}
275              </Typography>
276              {team.budget >= 0 ? (() => {
277                const spent = team.spending ?? 0;
278                const budget = team.budget;
279                const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
280                const barColor = pct > 90 ? "error" : pct > 70 ? "warning" : "primary";
281                return (
282                  <Box mt={1.5} maxWidth={400}>
283                    <Box display="flex" justifyContent="space-between" mb={0.5}>
284                      <Box display="flex" alignItems="center" gap={0.5}>
285                        <AccountBalanceWallet sx={{ fontSize: 16, color: "text.secondary" }} />
286                        <Typography variant="caption" color="text.secondary" fontWeight={600}>
287                          {t("teams.view.spent")} ${spent.toFixed(2)} / ${budget.toFixed(2)}
288                        </Typography>
289                      </Box>
290                      <Typography variant="caption" color="text.secondary" fontWeight={600}>
291                        ${(team.remaining ?? 0).toFixed(2)} {t("teams.view.left")}
292                      </Typography>
293                    </Box>
294                    <LinearProgress
295                      variant="determinate"
296                      value={pct}
297                      color={barColor}
298                      sx={{ height: 8, borderRadius: 4 }}
299                    />
300                  </Box>
301                );
302              })() : (
303                <Box display="flex" alignItems="center" gap={0.5} mt={1}>
304                  <AllInclusive sx={{ fontSize: 16, color: "text.disabled" }} />
305                  <Typography variant="caption" color="text.disabled" fontWeight={600}>
306                    {t("teams.view.unlimited")}
307                  </Typography>
308                </Box>
309              )}
310            </Box>
311            {isTeamAdmin && (
312              <Button
313                variant="contained"
314                color="primary"
315                startIcon={<Settings />}
316                onClick={handleEditTeam}
317              >
318                {t("teams.view.edit")}
319              </Button>
320            )}
321          </Box>
322          
323          <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
324            <Tabs value={tabValue} onChange={handleTabChange} aria-label="team tabs">
325              <Tab label={t("teams.edit.tabs.users")} icon={<Group />} iconPosition="start" />
326              <Tab label={t("teams.edit.tabs.projects")} icon={<Code />} iconPosition="start" />
327              <Tab label={t("teams.edit.tabs.models")} icon={<Psychology />} iconPosition="start" />
328              {isTeamAdmin && (
329                <Tab label={t("teams.view.tabs.transactions")} icon={<Receipt />} iconPosition="start" />
330              )}
331            </Tabs>
332          </Box>
333          
334          <TabPanel value={tabValue} index={0}>
335            <Grid container spacing={3}>
336              <Grid item xs={12} md={6}>
337                <Typography variant="h6" gutterBottom>{t("teams.edit.members")}</Typography>
338                <List>
339                  {team.users?.length > 0 ? (
340                    team.users.map((member) => (
341                      <Fragment key={member.id}>
342                        <ListItem secondaryAction={
343                          isTeamAdmin && (
344                            <Tooltip title={t("teams.view.removeUser")}>
345                              <IconButton edge="end" onClick={() => handleRemoveUser(member.username)}>
346                                <Delete />
347                              </IconButton>
348                            </Tooltip>
349                          )
350                        }>
351                          <ListItemAvatar>
352                            <Avatar>
353                              <Person />
354                            </Avatar>
355                          </ListItemAvatar>
356                          <ListItemText primary={member.username} />
357                        </ListItem>
358                        <Divider variant="inset" component="li" />
359                      </Fragment>
360                    ))
361                  ) : (
362                    <Typography variant="body2" color="textSecondary">{t("teams.view.noMembers")}</Typography>
363                  )}
364                </List>
365              </Grid>
366              
367              <Grid item xs={12} md={6}>
368                <Typography variant="h6" gutterBottom>{t("teams.edit.admins")}</Typography>
369                <List>
370                  {team.admins?.length > 0 ? (
371                    team.admins.map((admin) => (
372                      <Fragment key={admin.id}>
373                        <ListItem secondaryAction={
374                          isTeamAdmin && (
375                            <Tooltip title={t("teams.view.removeAdmin")}>
376                              <IconButton edge="end" onClick={() => handleRemoveAdmin(admin.username)}>
377                                <Delete />
378                              </IconButton>
379                            </Tooltip>
380                          )
381                        }>
382                          <ListItemAvatar>
383                            <Avatar>
384                              <Person />
385                            </Avatar>
386                          </ListItemAvatar>
387                          <ListItemText 
388                            primary={admin.username} 
389                            secondary={admin.id === user.id ? t("teams.view.you") : ""}
390                          />
391                        </ListItem>
392                        <Divider variant="inset" component="li" />
393                      </Fragment>
394                    ))
395                  ) : (
396                    <Typography variant="body2" color="textSecondary">{t("teams.view.noAdmins")}</Typography>
397                  )}
398                </List>
399              </Grid>
400            </Grid>
401          </TabPanel>
402          
403          <TabPanel value={tabValue} index={1}>
404            <Typography variant="h6" gutterBottom>{t("teams.edit.projectsHeading")}</Typography>
405            <List>
406              {team.projects?.length > 0 ? (
407                team.projects.map((project) => (
408                  <Fragment key={project.id}>
409                    <ListItem 
410                      button
411                      onClick={() => navigate(`/project/${project.id}`)}
412                      secondaryAction={
413                        isTeamAdmin && (
414                          <Tooltip title={t("teams.view.removeProject")}>
415                            <IconButton edge="end" onClick={(e) => {
416                              e.stopPropagation();
417                              handleRemoveProject(project.id);
418                            }}>
419                              <Delete />
420                            </IconButton>
421                          </Tooltip>
422                        )
423                      }
424                    >
425                      <ListItemAvatar>
426                        <Avatar>
427                          <Code />
428                        </Avatar>
429                      </ListItemAvatar>
430                      <ListItemText primary={project.name} />
431                    </ListItem>
432                    <Divider variant="inset" component="li" />
433                  </Fragment>
434                ))
435              ) : (
436                <Typography variant="body2" color="textSecondary">{t("teams.view.noProjects")}</Typography>
437              )}
438            </List>
439          </TabPanel>
440          
441          <TabPanel value={tabValue} index={2}>
442            <Grid container spacing={3}>
443              <Grid item xs={12} md={6}>
444                <Typography variant="h6" gutterBottom>{t("teams.edit.llms")}</Typography>
445                <List>
446                  {team.llms?.length > 0 ? (
447                    team.llms.map((llm) => (
448                      <Fragment key={llm.id}>
449                        <ListItem secondaryAction={
450                          isTeamAdmin && (
451                            <Tooltip title={t("teams.view.removeLlm")}>
452                              <IconButton edge="end" onClick={() => handleRemoveLLM(llm)}>
453                                <Delete />
454                              </IconButton>
455                            </Tooltip>
456                          )
457                        }>
458                          <ListItemAvatar>
459                            <Avatar>
460                              <Psychology />
461                            </Avatar>
462                          </ListItemAvatar>
463                          <ListItemText primary={llm.name} />
464                        </ListItem>
465                        <Divider variant="inset" component="li" />
466                      </Fragment>
467                    ))
468                  ) : (
469                    <Typography variant="body2" color="textSecondary">{t("teams.view.noLlms")}</Typography>
470                  )}
471                </List>
472              </Grid>
473              
474              <Grid item xs={12} md={6}>
475                <Typography variant="h6" gutterBottom>{t("teams.edit.embeddings")}</Typography>
476                <List>
477                  {team.embeddings?.length > 0 ? (
478                    team.embeddings.map((embedding) => (
479                      <Fragment key={embedding.id}>
480                        <ListItem secondaryAction={
481                          isTeamAdmin && (
482                            <Tooltip title={t("teams.view.removeEmbedding")}>
483                              <IconButton edge="end" onClick={() => handleRemoveEmbedding(embedding)}>
484                                <Delete />
485                              </IconButton>
486                            </Tooltip>
487                          )
488                        }>
489                          <ListItemAvatar>
490                            <Avatar>
491                              <Psychology />
492                            </Avatar>
493                          </ListItemAvatar>
494                          <ListItemText primary={embedding.name} />
495                        </ListItem>
496                        <Divider variant="inset" component="li" />
497                      </Fragment>
498                    ))
499                  ) : (
500                    <Typography variant="body2" color="textSecondary">{t("teams.view.noEmbeddings")}</Typography>
501                  )}
502                </List>
503              </Grid>
504  
505              <Grid item xs={12} md={6}>
506                <Typography variant="h6" gutterBottom>{t("teams.edit.imageGen")}</Typography>
507                <List>
508                  {team.image_generators?.length > 0 ? (
509                    team.image_generators.map((gen) => (
510                      <Fragment key={gen}>
511                        <ListItem secondaryAction={
512                          isTeamAdmin && (
513                            <Tooltip title={t("teams.view.removeImageGen")}>
514                              <IconButton edge="end" onClick={() => handleRemoveImageGenerator(gen)}>
515                                <Delete />
516                              </IconButton>
517                            </Tooltip>
518                          )
519                        }>
520                          <ListItemAvatar>
521                            <Avatar>
522                              <Image />
523                            </Avatar>
524                          </ListItemAvatar>
525                          <ListItemText primary={gen} />
526                        </ListItem>
527                        <Divider variant="inset" component="li" />
528                      </Fragment>
529                    ))
530                  ) : (
531                    <Typography variant="body2" color="textSecondary">{t("teams.view.noImageGen")}</Typography>
532                  )}
533                </List>
534              </Grid>
535  
536              <Grid item xs={12} md={6}>
537                <Typography variant="h6" gutterBottom>{t("teams.edit.audioGen")}</Typography>
538                <List>
539                  {team.audio_generators?.length > 0 ? (
540                    team.audio_generators.map((gen) => (
541                      <Fragment key={gen}>
542                        <ListItem secondaryAction={
543                          isTeamAdmin && (
544                            <Tooltip title={t("teams.view.removeAudioGen")}>
545                              <IconButton edge="end" onClick={() => handleRemoveAudioGenerator(gen)}>
546                                <Delete />
547                              </IconButton>
548                            </Tooltip>
549                          )
550                        }>
551                          <ListItemAvatar>
552                            <Avatar>
553                              <Speaker />
554                            </Avatar>
555                          </ListItemAvatar>
556                          <ListItemText primary={gen} />
557                        </ListItem>
558                        <Divider variant="inset" component="li" />
559                      </Fragment>
560                    ))
561                  ) : (
562                    <Typography variant="body2" color="textSecondary">{t("teams.view.noAudioGen")}</Typography>
563                  )}
564                </List>
565              </Grid>
566            </Grid>
567          </TabPanel>
568  
569          <TabPanel value={tabValue} index={3}>
570              <MUIDataTable
571                title={t("teams.view.transactions")}
572                options={{
573                  print: false,
574                  selectableRows: "none",
575                  expandableRows: true,
576                  expandableRowsHeader: false,
577                  expandableRowsOnClick: true,
578                  download: false,
579                  filter: false,
580                  viewColumns: false,
581                  rowsExpanded: txRowsExpanded,
582                  rowsPerPage: txRows,
583                  rowsPerPageOptions: [50, 100, 500],
584                  elevation: 0,
585                  count: txCount,
586                  page: txPage,
587                  serverSide: true,
588                  textLabels: {
589                    body: {
590                      noMatch: t("teams.view.tx.noTransactions"),
591                    },
592                  },
593                  onTableChange: (action, tableState) => {
594                    switch (action) {
595                      case 'changePage':
596                        setTxPage(tableState.page);
597                        break;
598                      case 'changeRowsPerPage':
599                        setTxRows(tableState.rowsPerPage);
600                        setTxPage(0);
601                        break;
602                      default:
603                        break;
604                    }
605                  },
606                  isRowExpandable: () => true,
607                  renderExpandableRow: (rowData, rowMeta) => {
608                    const colSpan = rowData.length + 1;
609                    return (
610                      <TableRow>
611                        <TableCell sx={{ p: 2, backgroundColor: "#f0f0f0" }} colSpan={colSpan}>
612                          <ReactJson src={txLog} enableClipboard={false} />
613                        </TableCell>
614                      </TableRow>
615                    );
616                  },
617                  onRowExpansionChange: (_, allRowsExpanded) => {
618                    setTxRowsExpanded(allRowsExpanded.slice(-1).map(item => item.index));
619                    if (allRowsExpanded.length > 0) {
620                      setTxLog(transactions[allRowsExpanded[0].dataIndex]);
621                    }
622                  },
623                }}
624                data={transactions.map(tx => [
625                  tx.date,
626                  tx.project,
627                  tx.user,
628                  tx.llm,
629                  tx.input_tokens,
630                  tx.output_tokens,
631                  (tx.total_cost || 0),
632                ])}
633                columns={[
634                  {
635                    name: t("teams.view.tx.date"),
636                    options: {
637                      customHeadRender: ({ index, ...column }) => (
638                        <TableCell key={index} style={{ width: "180px" }}>{column.label}</TableCell>
639                      ),
640                      customBodyRender: (value) => new Date(value).toLocaleString(),
641                    },
642                  },
643                  { name: t("teams.view.tx.project") },
644                  { name: t("teams.view.tx.user") },
645                  { name: t("teams.view.tx.llm") },
646                  { name: t("teams.view.tx.inTokens"), options: { customBodyRender: (value) => (value || 0).toLocaleString() } },
647                  { name: t("teams.view.tx.outTokens"), options: { customBodyRender: (value) => (value || 0).toLocaleString() } },
648                  {
649                    name: t("teams.view.tx.cost"),
650                    options: {
651                      customBodyRender: (value) => (value || 0).toFixed(4),
652                    },
653                  },
654                ]}
655              />
656          </TabPanel>
657        </StyledCard>
658      </Container>
659    );
660  }