This commit is contained in:
2026-07-07 19:52:19 +08:00
16 changed files with 745 additions and 600 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-Dfcsk8qj.js"></script> <script type="module" crossorigin src="/assets/index-kx3oQI_t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+4
View File
@@ -456,6 +456,10 @@ export async function updateUserMenus(userId: string, allowedMenus: string[] | n
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus }); await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
} }
export async function updateUserAdminStatus(userId: string, isAdmin: boolean): Promise<void> {
await api.put(`/admin/users/${userId}/admin-status`, { is_admin: isAdmin });
}
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> { export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword }); await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
} }
@@ -287,7 +287,6 @@ const AdminLayout: React.FC = () => {
bottom: 16, bottom: 16,
left: 16, left: 16,
right: 16, right: 16,
padding: 12,
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)', background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
borderRadius: 12, borderRadius: 12,
border: '1px solid rgba(0, 0, 0, 0.06)', border: '1px solid rgba(0, 0, 0, 0.06)',
+25 -3
View File
@@ -3,7 +3,7 @@ import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
adjustCredits, adjustCredits,
@@ -23,6 +23,7 @@ import {
updateUserTeam, updateUserTeam,
updateSystemConfig, updateSystemConfig,
updateUserMenus, updateUserMenus,
updateUserAdminStatus,
} from '../api'; } from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types'; import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -379,6 +380,16 @@ const AdminUsers: React.FC = () => {
} }
}; };
const handleToggleAdminStatus = async (user: AdminUser) => {
try {
await updateUserAdminStatus(user.id, !user.isAdmin);
message.success(user.isAdmin ? '已取消超级管理员' : '已设为超级管理员');
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
}
};
const isAdminTab = activeTab === 'admin'; const isAdminTab = activeTab === 'admin';
const columns = [ const columns = [
@@ -397,7 +408,7 @@ const AdminUsers: React.FC = () => {
<div> <div>
<div style={{ fontWeight: 600 }}> <div style={{ fontWeight: 600 }}>
{r.username} {r.username}
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>} {r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div> </div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div> <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
</div> </div>
@@ -476,7 +487,7 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
}, },
{ {
title: '操作', key: 'action', width: 540, fixed: 'right' as const, title: '操作', key: 'action', width: 560, fixed: 'right' as const,
render: (_: any, r: AdminUser) => ( render: (_: any, r: AdminUser) => (
<Space size={4} wrap> <Space size={4} wrap>
{!isAdminTab && ( {!isAdminTab && (
@@ -509,6 +520,17 @@ const AdminUsers: React.FC = () => {
{!isAdminTab && r.frontendUserKind === 'internal' && ( {!isAdminTab && r.frontendUserKind === 'internal' && (
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}></Button> <Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}></Button>
)} )}
{isAdminTab && (
<Popconfirm
title={r.isAdmin ? '确定取消该用户的超级管理员权限?' : '确定将该用户设为超级管理员?'}
onConfirm={() => handleToggleAdminStatus(r)}
>
<Button type="link" size="small" style={{ color: r.isAdmin ? '#f59e0b' : '#6366f1' }}
icon={<SecurityScanOutlined />}>
{r.isAdmin ? '取消超级管理员' : '设为超级管理员'}
</Button>
</Popconfirm>
)}
<Button type="link" size="small" icon={<MenuOutlined />} <Button type="link" size="small" icon={<MenuOutlined />}
onClick={() => openMenuModal(r)}> onClick={() => openMenuModal(r)}>
+20
View File
@@ -279,6 +279,26 @@ async def update_user_status(
return {"message": "ok"} return {"message": "ok"}
@router.put("/users/{user_id}/admin-status")
async def update_user_admin_status(
user_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
is_admin = body.get("is_admin", False)
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != "admin":
raise HTTPException(status_code=400, detail="仅后台用户支持设置超级管理员状态")
user.is_admin = is_admin
await db.flush()
await log_operation(db, admin.id, admin.username, f"{is_admin and '设为' or '取消'}超级管理员", "PUT", f"/admin/users/{user_id}/admin-status")
return {"message": "ok"}
@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut) @router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
async def update_user_frontend_kind( async def update_user_frontend_kind(
user_id: str, user_id: str,
+4 -2
View File
@@ -245,13 +245,14 @@ async def get_join_info_public(
@router.get("/join-requests", ) @router.get("/join-requests", )
async def list_join_requests( async def list_join_requests(
status: str | None = Query(None),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
team = await get_managed_team(db, current_user.id) team = await get_managed_team(db, current_user.id)
if not team: if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看") raise HTTPException(status_code=403, detail="只有团队管理人可查看")
requests = await team_invitation_service.get_pending_requests(db, team.id) requests = await team_invitation_service.get_all_requests(db, team.id, status)
# 获取团队名 # 获取团队名
team_name_result = await db.execute( team_name_result = await db.execute(
@@ -269,7 +270,8 @@ async def list_join_requests(
phone=r.get("phone"), phone=r.get("phone"),
status=r["status"], status=r["status"],
note=r.get("note"), note=r.get("note"),
created_at=r.get("created_at"), created_at=r["created_at"],
handled_at=r.get("handled_at"),
) )
for r in requests for r in requests
] ]
+14
View File
@@ -11,5 +11,19 @@ TEAM_STATUS_LABELS = {
TeamStatus.DISABLED.value: "禁用", TeamStatus.DISABLED.value: "禁用",
} }
class TeamJoinRequestStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
TEAM_JOIN_REQUEST_STATUS_LABELS = {
TeamJoinRequestStatus.PENDING.value: "待处理",
TeamJoinRequestStatus.APPROVED.value: "已通过",
TeamJoinRequestStatus.REJECTED.value: "已拒绝",
}
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。 # 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
TEAM_UNASSIGNED_VALUE = "__none__" TEAM_UNASSIGNED_VALUE = "__none__"
@@ -22,6 +22,7 @@ class JoinRequestOut(BaseModel):
status: str status: str
note: str | None = None note: str | None = None
created_at: NaiveDatetimeOptional = None created_at: NaiveDatetimeOptional = None
handled_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -222,6 +222,40 @@ async def get_pending_requests(db: AsyncSession, team_id: str) -> list[dict[str,
"status": req.status, "status": req.status,
"note": req.note, "note": req.note,
"created_at": req.created_at, "created_at": req.created_at,
"handled_at": req.updated_at if req.status != "pending" else None,
}
for req, username, phone in rows
]
async def get_all_requests(
db: AsyncSession,
team_id: str,
status: str | None = None,
) -> list[dict[str, Any]]:
"""获取团队所有加入申请列表,支持按状态筛选。"""
where = [TeamJoinRequest.team_id == team_id]
if status:
where.append(TeamJoinRequest.status == status)
result = await db.execute(
select(TeamJoinRequest, User.username, User.phone)
.join(User, User.id == TeamJoinRequest.user_id)
.where(*where)
.order_by(TeamJoinRequest.created_at.desc())
)
rows = result.all()
return [
{
"id": req.id,
"team_id": req.team_id,
"user_id": req.user_id,
"username": username,
"phone": phone,
"status": req.status,
"note": req.note,
"created_at": req.created_at,
"handled_at": req.updated_at if req.status != "pending" else None,
} }
for req, username, phone in rows for req, username, phone in rows
] ]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <title>民众智创</title>
<script> <script>
(function() { (function() {
var cached = localStorage.getItem('siteInfo'); var cached = localStorage.getItem('siteInfo');
if (cached) { if (cached) {
try { try {
var info = JSON.parse(cached); var info = JSON.parse(cached);
if (info.siteName) { if (info.siteName) {
document.title = info.siteName; document.title = info.siteName;
} }
if (info.siteLogo) { if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]'); var link = document.querySelector('link[rel="icon"]');
if (link) { if (link) {
link.href = info.siteLogo; link.href = info.siteLogo;
link.type = 'image/png'; link.type = 'image/png';
} }
} }
} catch (e) {} } catch (e) {}
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-C6k2TIpY.js"></script> <script type="module" crossorigin src="/assets/index-BIirSavc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css"> <link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+3 -2
View File
@@ -922,8 +922,9 @@ export async function revokeInvitation(invitationId: string): Promise<void> {
await api.delete(`/team/invitations/${invitationId}`); await api.delete(`/team/invitations/${invitationId}`);
} }
export async function getPendingJoinRequests(): Promise<any[]> { export async function getPendingJoinRequests(status?: string): Promise<any[]> {
return api.get('/team/join-requests'); const url = status ? `/team/join-requests?status=${status}` : '/team/join-requests';
return api.get(url);
} }
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> { export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
+90 -43
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { import {
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography, Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Segmented, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd'; } from 'antd';
import { DatePicker } from 'antd'; import { DatePicker } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -190,18 +190,20 @@ const TeamManagementPage: React.FC = () => {
// ── Tab 3: 加入申请 ── // ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]); const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false); const [reqLoading, setReqLoading] = useState(false);
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
const [activeTab, setActiveTab] = useState('members'); const [activeTab, setActiveTab] = useState('members');
const initialNoticeShownRef = useRef(false); const initialNoticeShownRef = useRef(false);
const lastRequestCountRef = useRef(0); const lastRequestCountRef = useRef(0);
const loadRequests = useCallback(async (isInitial = false) => { const loadRequests = useCallback(async (status: string, isInitial = false) => {
setReqLoading(true); setReqLoading(true);
try { try {
const data = await getPendingJoinRequests(); const data = await getPendingJoinRequests(status);
const currentCount = data?.length || 0; const currentCount = data?.length || 0;
setRequests(data || []); setRequests(data || []);
if (currentCount > 0) { // 只有待处理状态才显示弹窗通知
if (status === 'pending' && currentCount > 0) {
if (isInitial && !initialNoticeShownRef.current) { if (isInitial && !initialNoticeShownRef.current) {
initialNoticeShownRef.current = true; initialNoticeShownRef.current = true;
Modal.confirm({ Modal.confirm({
@@ -231,7 +233,9 @@ const TeamManagementPage: React.FC = () => {
} }
} }
lastRequestCountRef.current = currentCount; if (status === 'pending') {
lastRequestCountRef.current = currentCount;
}
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载申请失败'); message.error(e?.message || '加载申请失败');
} finally { } finally {
@@ -240,13 +244,13 @@ const TeamManagementPage: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
loadRequests(true); loadRequests('pending', true);
}, [loadRequests]); }, [loadRequests]);
useEffect(() => { useEffect(() => {
if (!team) return; if (!team) return;
const interval = setInterval(() => { const interval = setInterval(() => {
loadRequests(false); loadRequests('pending', false);
}, 60000); }, 60000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [team, loadRequests]); }, [team, loadRequests]);
@@ -255,7 +259,7 @@ const TeamManagementPage: React.FC = () => {
try { try {
await handleJoinRequest(requestId, action, note); await handleJoinRequest(requestId, action, note);
message.success(action === 'approve' ? '已通过' : '已拒绝'); message.success(action === 'approve' ? '已通过' : '已拒绝');
loadRequests(); loadRequests(reqStatusFilter);
loadMembers(); loadMembers();
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '操作失败'); message.error(e?.message || '操作失败');
@@ -397,33 +401,49 @@ const TeamManagementPage: React.FC = () => {
}, },
]; ];
const renderStatusTag = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待处理' },
approved: { color: 'green', text: '已通过' },
rejected: { color: 'red', text: '已拒绝' },
};
const info = map[status] || { color: 'default', text: status };
return <Tag color={info.color}>{info.text}</Tag>;
};
const reqColumns = [ const reqColumns = [
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> }, { title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' }, { title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) }, { title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '-' },
{ title: '备注', dataIndex: 'note', width: 180, ellipsis: true, render: (v: string) => v || '-' },
{ {
title: '操作', key: 'action', width: 160, title: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => ( render: (_: any, r: TeamJoinRequest) => {
<Space size={4}> if (r.status !== 'pending') return <span style={{ color: '#94a3b8', fontSize: 12 }}></span>;
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button> return (
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => { <Space size={4}>
Modal.confirm({ <Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button>
title: '拒绝申请', <Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
content: ( Modal.confirm({
<Form layout="vertical" style={{ marginTop: 12 }}> title: '拒绝申请',
<Form.Item name="note" label="拒绝原因(可选)"> content: (
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" /> <Form layout="vertical" style={{ marginTop: 12 }}>
</Form.Item> <Form.Item name="note" label="拒绝原因(可选)">
</Form> <Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
), </Form.Item>
onOk: () => { </Form>
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined; ),
handleRequest(r.id, 'reject', note); onOk: () => {
}, const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
}); handleRequest(r.id, 'reject', note);
}}></Button> },
</Space> });
), }}></Button>
</Space>
);
},
}, },
]; ];
@@ -568,20 +588,47 @@ const TeamManagementPage: React.FC = () => {
}, },
{ {
key: 'requests', key: 'requests',
label: <Space><HistoryOutlined />{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>, label: <Space><HistoryOutlined />{requests.length > 0 && reqStatusFilter === 'pending' && <Tag color="red">{requests.length}</Tag>}</Space>,
children: ( children: (
<div style={tableWrapper}> <>
<Table <div style={{ marginBottom: 16, display: 'flex', alignItems: 'center' }}>
columns={reqColumns} <Segmented
dataSource={requests} value={reqStatusFilter}
rowKey="id" onChange={(v) => {
loading={reqLoading} setReqStatusFilter(v as string);
pagination={false} loadRequests(v as string);
bordered={false} }}
scroll={{ x: 600 }} options={[
locale={{ emptyText: <Empty description="暂无待审批申请" /> }} { label: '待处理', value: 'pending' },
/> { label: '已通过', value: 'approved' },
</div> { label: '已拒绝', value: 'rejected' },
{ label: '全部', value: '' },
]}
style={{
borderRadius: 12,
border: '1px solid #e2e8f0',
padding: 3,
background: '#f8fafc',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
'--ant-segmented-item-selected-bg': '#6366f1',
'--ant-segmented-item-selected-color': '#ffffff',
} as React.CSSProperties}
size="middle"
/>
</div>
<div style={tableWrapper}>
<Table
columns={reqColumns}
dataSource={requests}
rowKey="id"
loading={reqLoading}
pagination={false}
bordered={false}
scroll={{ x: 1000 }}
locale={{ emptyText: <Empty description={reqStatusFilter === 'pending' ? '暂无待审批申请' : '暂无记录'} /> }}
/>
</div>
</>
), ),
}, },
]; ];
@@ -599,7 +646,7 @@ const TeamManagementPage: React.FC = () => {
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
<Typography.Text type="secondary">: {team.code || '-'} : {team.memberCount} </Typography.Text> <Typography.Text type="secondary">: {team.code || '-'} : {team.memberCount} </Typography.Text>
</div> </div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button> <Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(reqStatusFilter); loadCreditRecords(); }}></Button>
</div> </div>
) : ( ) : (
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
+1
View File
@@ -59,6 +59,7 @@ export interface TeamJoinRequest {
status: string; status: string;
note?: string | null; note?: string | null;
createdAt?: string | null; createdAt?: string | null;
handledAt?: string | null;
} }
export interface ManagedTeam { export interface ManagedTeam {