Files
Stirling-PDF/frontend/src/proprietary/components/shared/config/configSections/TeamsSection.tsx
T
Anthony StirlingandGitHub dd09f7b7cf minor changes (#5419)
# Description of Changes

OLD
(MEMBER tag)
<img width="690" height="167" alt="image"
src="https://github.com/user-attachments/assets/079a32b6-2483-46a6-a307-8cacb664cbc8"
/>
<img width="719" height="158" alt="image"
src="https://github.com/user-attachments/assets/cba625f6-56de-4b32-b6ea-22cee59fffbd"
/>

NEW
<img width="748" height="248" alt="image"
src="https://github.com/user-attachments/assets/d3c556da-1859-4241-89c6-d0b96fd6072a"
/>
<img width="752" height="416" alt="image"
src="https://github.com/user-attachments/assets/ddeb7f0d-cccb-4690-b05a-7243695e9b61"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-09 12:28:15 +00:00

492 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Select,
CloseButton,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
export default function TeamsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState('');
const [renameTeamName, setRenameTeamName] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
try {
setLoading(true);
if (loginEnabled) {
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 },
{ id: 3, name: 'Internal', userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' });
} finally {
setLoading(false);
}
};
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.createTeam.nameRequired') });
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') });
setNewTeamName('');
setCreateModalOpened(false);
await fetchTeams();
} catch (error: any) {
console.error('Failed to create team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.createTeam.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.renameTeam.nameRequired') });
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') });
setRenameTeamName('');
setSelectedTeam(null);
setRenameModalOpened(false);
await fetchTeams();
} catch (error: any) {
console.error('Failed to rename team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.renameTeam.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteTeam = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotDeleteInternal') });
return;
}
if (!confirm(t('workspace.teams.confirmDelete'))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') });
await fetchTeams();
} catch (error: any) {
console.error('Failed to delete team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.deleteTeam.error');
alert({ alertType: 'error', title: errorMessage });
}
};
const openRenameModal = (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotRenameInternal') });
return;
}
setSelectedTeam(team);
setRenameTeamName(team.name);
setRenameModalOpened(true);
};
const openAddMemberModal = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotAddToInternal') });
return;
}
setSelectedTeam(team);
try {
// Fetch all users to show in dropdown
const adminData = await userManagementService.getUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error('Failed to fetch users:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.userRequired') });
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') });
setSelectedUserId('');
setSelectedTeam(null);
setAddMemberModalOpened(false);
await fetchTeams();
} catch (error) {
console.error('Failed to add member to team:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
} finally {
setProcessing(false);
}
};
// If viewing team details, render TeamDetailsSection
if (viewingTeamId !== null) {
return (
<TeamDetailsSection
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
fetchTeams(); // Refresh teams list
}}
/>
);
}
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loading', 'Loading teams...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.description')}
</Text>
</div>
{/* Header Actions */}
<Group justify="flex-end">
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)} disabled={!loginEnabled}>
{t('workspace.teams.createNewTeam')}
</Button>
</Group>
{/* Teams Table */}
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('workspace.teams.teamName')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('workspace.teams.totalMembers')}
</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.teams.noTeamsFound')}
</Text>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: loginEnabled ? 'pointer' : 'default' }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Text
size="sm"
fw={500}
maw={200}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{team.name}
</Text>
</Tooltip>
{team.name === 'Internal' && (
<Badge size="xs" color="gray" variant="light">
{t('workspace.teams.system')}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{team.userCount || 0}</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)} disabled={!loginEnabled}>
{t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.addMember')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.renameTeamLabel')}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={!loginEnabled || team.name === 'Internal'}
>
{t('workspace.teams.deleteTeamLabel')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* Create Team Modal */}
<Modal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="group-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.createTeam.title')}
</Text>
</Stack>
<TextInput
label={t('workspace.teams.createTeam.teamName')}
placeholder={t('workspace.teams.createTeam.teamNamePlaceholder')}
value={newTeamName}
onChange={(e) => setNewTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleCreateTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.createTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Rename Team Modal */}
<Modal
opened={renameModalOpened}
onClose={() => setRenameModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.renameTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.renameTeam.renaming')} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t('workspace.teams.renameTeam.newTeamName')}
placeholder={t('workspace.teams.renameTeam.newTeamNamePlaceholder')}
value={renameTeamName}
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleRenameTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.renameTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.addMemberToTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}