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 }