This commit is contained in:
@@ -48,7 +48,8 @@ export function DataTableCard<T>({
|
|||||||
pageSize = 8,
|
pageSize = 8,
|
||||||
minWidth = 980,
|
minWidth = 980,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyMessage = '데이터가 없습니다.'
|
emptyMessage = '데이터가 없습니다.',
|
||||||
|
serverPagination
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -62,12 +63,23 @@ export function DataTableCard<T>({
|
|||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
serverPagination?: {
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onSearchChange?: (keyword: string) => void;
|
||||||
|
filterValues?: Record<string, string>;
|
||||||
|
onFilterChange?: (key: string, value: string) => void;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const serverMode = Boolean(serverPagination);
|
||||||
|
const activeFilterValues = serverPagination?.filterValues ?? filterValues;
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
|
if (serverMode) return rows;
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
@@ -75,24 +87,37 @@ export function DataTableCard<T>({
|
|||||||
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
||||||
: true;
|
: true;
|
||||||
const matchesFilters = filters.every((filter) => {
|
const matchesFilters = filters.every((filter) => {
|
||||||
const selected = filterValues[filter.key];
|
const selected = activeFilterValues[filter.key];
|
||||||
return !selected || filter.getValue(row) === selected;
|
return !selected || filter.getValue(row) === selected;
|
||||||
});
|
});
|
||||||
|
|
||||||
return matchesSearch && matchesFilters;
|
return matchesSearch && matchesFilters;
|
||||||
});
|
});
|
||||||
}, [filterValues, filters, getSearchText, rows, search]);
|
}, [activeFilterValues, filters, getSearchText, rows, search, serverMode]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
const totalCount = serverPagination?.total ?? filteredRows.length;
|
||||||
const currentPage = Math.min(page, totalPages);
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const currentPage = Math.min(serverPagination?.page ?? page, totalPages);
|
||||||
|
const pageRows = serverMode ? rows : filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
||||||
|
|
||||||
const updateFilter = (key: string, value: string) => {
|
const updateFilter = (key: string, value: string) => {
|
||||||
|
if (serverPagination?.onFilterChange) {
|
||||||
|
serverPagination.onFilterChange(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const movePage = (nextPage: number) => {
|
||||||
|
if (serverPagination) {
|
||||||
|
serverPagination.onPageChange(nextPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(nextPage);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="min-w-0 overflow-hidden">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
@@ -108,7 +133,9 @@ export function DataTableCard<T>({
|
|||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSearch(event.target.value);
|
const value = event.target.value;
|
||||||
|
setSearch(value);
|
||||||
|
serverPagination?.onSearchChange?.(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -120,7 +147,7 @@ export function DataTableCard<T>({
|
|||||||
{filter.label}
|
{filter.label}
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={filterValues[filter.key] ?? ''}
|
value={activeFilterValues[filter.key] ?? ''}
|
||||||
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
@@ -210,14 +237,14 @@ export function DataTableCard<T>({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
|
{loading ? '불러오는 중' : `총 ${totalCount}건 · ${currentPage} / ${totalPages}`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
onClick={() => movePage(Math.max(1, currentPage - 1))}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
이전
|
이전
|
||||||
@@ -226,7 +253,7 @@ export function DataTableCard<T>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 px-3 text-[13px]"
|
className="h-8 px-3 text-[13px]"
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
onClick={() => movePage(Math.min(totalPages, currentPage + 1))}
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
deleteAdminSshKey,
|
deleteAdminSshKey,
|
||||||
loadAdminAppPermissions,
|
loadAdminAppPermissions,
|
||||||
loadAdminRoles,
|
loadAdminRoles,
|
||||||
loadAdmins,
|
loadAdminsPage,
|
||||||
resetAdminPassword,
|
resetAdminPassword,
|
||||||
updateAdminAppPermissions,
|
updateAdminAppPermissions,
|
||||||
updateAdminRole,
|
updateAdminRole,
|
||||||
@@ -44,6 +44,10 @@ const fallbackAdminRoles: AdminRoleOption[] = [
|
|||||||
export function AdminManagementPage() {
|
export function AdminManagementPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
const [admins, setAdmins] = useState<AdminAccount[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
const [adminRoles, setAdminRoles] = useState<AdminRoleOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -75,9 +79,13 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([loadAdmins(), loadAdminRoles().catch(() => fallbackAdminRoles)])
|
Promise.all([
|
||||||
.then(([nextAdmins, nextRoles]) => {
|
loadAdminsPage({ page, limit: 6, keyword, role: filterValues.role, state: filterValues.state }),
|
||||||
setAdmins(nextAdmins);
|
loadAdminRoles().catch(() => fallbackAdminRoles)
|
||||||
|
])
|
||||||
|
.then(([result, nextRoles]) => {
|
||||||
|
setAdmins(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
setAdminRoles(nextRoles.filter((role) => role.isActive));
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
@@ -91,7 +99,7 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, [page, keyword, filterValues.role, filterValues.state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuAdmin) return;
|
if (!menuAdmin) return;
|
||||||
@@ -110,7 +118,7 @@ export function AdminManagementPage() {
|
|||||||
}, [menuAdmin]);
|
}, [menuAdmin]);
|
||||||
|
|
||||||
const rows = useMemo<AdminRow[]>(
|
const rows = useMemo<AdminRow[]>(
|
||||||
() => admins.map((admin, index) => ({ ...admin, no: admins.length - index, state: admin.isActive ? '활성' : '비활성' })),
|
() => admins.map((admin) => ({ ...admin, no: admin.no ?? 0, state: admin.isActive ? '활성' : '비활성' })),
|
||||||
[admins]
|
[admins]
|
||||||
);
|
);
|
||||||
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
const roleOptions = adminRoles.length ? adminRoles : fallbackAdminRoles;
|
||||||
@@ -318,10 +326,10 @@ export function AdminManagementPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
const filters = useMemo<Array<DataTableFilter<AdminRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'role', label: '권한', options: [...new Set(rows.map((row) => row.role))].map((role) => ({ label: role, value: role })), getValue: (row) => row.role },
|
{ key: 'role', label: '권한', options: roleOptions.map((role) => ({ label: role.name, value: role.code })), getValue: (row) => row.role },
|
||||||
{ key: 'state', label: '상태', options: [...new Set(rows.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
{ key: 'state', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }], getValue: (row) => row.isActive ? 'active' : 'inactive' }
|
||||||
],
|
],
|
||||||
[rows]
|
[roleOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -354,6 +362,20 @@ export function AdminManagementPage() {
|
|||||||
minWidth={1040}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 관리자가 없습니다."
|
emptyMessage="등록된 관리자가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{menuAdmin ? (() => {
|
{menuAdmin ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuAdmin.id);
|
const row = rows.find((item) => item.id === menuAdmin.id);
|
||||||
|
|||||||
@@ -8,31 +8,23 @@ import { formatDateOnly } from '../../lib/date-format';
|
|||||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||||
import {
|
import {
|
||||||
assignUserLicense,
|
assignUserLicense,
|
||||||
|
loadLicenseAppPermissions,
|
||||||
|
loadLicenseAssignments,
|
||||||
loadLicensePlans,
|
loadLicensePlans,
|
||||||
loadLicenseUsers,
|
loadLicenseUsers,
|
||||||
loadLauncherUserAppPermissions,
|
loadLauncherUserAppPermissions,
|
||||||
revokeUserLicense,
|
revokeUserLicense,
|
||||||
|
updateLicensePlan,
|
||||||
updateLauncherUserAppPermissions,
|
updateLauncherUserAppPermissions,
|
||||||
updateUserLicense,
|
updateUserLicense,
|
||||||
type AuthLicenseUser,
|
type AuthLicenseUser,
|
||||||
type LauncherUserAppPermission,
|
type LauncherUserAppPermission,
|
||||||
|
type LicenseAppPermission,
|
||||||
|
type LicenseAssignment,
|
||||||
type LicensePlan
|
type LicensePlan
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
|
|
||||||
type LicenseAssignmentRow = {
|
type LicenseAssignmentRow = LicenseAssignment;
|
||||||
id: string;
|
|
||||||
no: number;
|
|
||||||
userId: string;
|
|
||||||
user: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
code: string;
|
|
||||||
licenseName: string;
|
|
||||||
startsAt: string;
|
|
||||||
expiresAt?: string | null;
|
|
||||||
assignedBy?: string | null;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function today() {
|
function today() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
@@ -59,28 +51,15 @@ function toDateInputValue(value?: string | null) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentRows(users: AuthLicenseUser[]) {
|
|
||||||
return users.flatMap((user) =>
|
|
||||||
user.licenses.map((license) => ({
|
|
||||||
id: license.id,
|
|
||||||
userId: user.id,
|
|
||||||
user: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
code: license.license.code,
|
|
||||||
licenseName: license.license.name,
|
|
||||||
startsAt: license.startsAt,
|
|
||||||
expiresAt: license.expiresAt,
|
|
||||||
assignedBy: license.assignedById,
|
|
||||||
status: license.status
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LicensesPage() {
|
export function LicensesPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
const [plans, setPlans] = useState<LicensePlan[]>([]);
|
||||||
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
const [users, setUsers] = useState<AuthLicenseUser[]>([]);
|
||||||
|
const [rows, setRows] = useState<LicenseAssignmentRow[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [assignOpen, setAssignOpen] = useState(false);
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
const [editingRow, setEditingRow] = useState<LicenseAssignmentRow | null>(null);
|
||||||
@@ -88,8 +67,18 @@ export function LicensesPage() {
|
|||||||
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
const [appPermissionTarget, setAppPermissionTarget] = useState<LicenseAssignmentRow | null>(null);
|
||||||
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
const [appPermissions, setAppPermissions] = useState<LauncherUserAppPermission[]>([]);
|
||||||
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
const [appPermissionLoading, setAppPermissionLoading] = useState(false);
|
||||||
|
const [planEditTarget, setPlanEditTarget] = useState<LicensePlan | null>(null);
|
||||||
|
const [planStatusTarget, setPlanStatusTarget] = useState<LicensePlan | null>(null);
|
||||||
|
const [licenseAppPermissions, setLicenseAppPermissions] = useState<LicenseAppPermission[]>([]);
|
||||||
|
const [licenseAppLoading, setLicenseAppLoading] = useState(false);
|
||||||
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuRow, setMenuRow] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const [planMenu, setPlanMenu] = useState<{ code: string; x: number; y: number } | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [planForm, setPlanForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
defaultDurationDays: ''
|
||||||
|
});
|
||||||
const [assignForm, setAssignForm] = useState({
|
const [assignForm, setAssignForm] = useState({
|
||||||
userId: '',
|
userId: '',
|
||||||
licenseCode: '',
|
licenseCode: '',
|
||||||
@@ -99,10 +88,6 @@ export function LicensesPage() {
|
|||||||
const [userKeyword, setUserKeyword] = useState('');
|
const [userKeyword, setUserKeyword] = useState('');
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const assignments = assignmentRows(users);
|
|
||||||
return assignments.map((assignment, index) => ({ ...assignment, no: assignments.length - index }));
|
|
||||||
}, [users]);
|
|
||||||
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
const calculateExpiresAt = (licenseCode: string, startsAt: string) => {
|
||||||
const plan = plans.find((item) => item.code === licenseCode);
|
const plan = plans.find((item) => item.code === licenseCode);
|
||||||
return addDays(startsAt, plan?.defaultDurationDays);
|
return addDays(startsAt, plan?.defaultDurationDays);
|
||||||
@@ -120,8 +105,17 @@ export function LicensesPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
const [nextPlans, nextUsers] = await Promise.all([loadLicensePlans(), loadLicenseUsers()]);
|
||||||
|
const nextAssignments = await loadLicenseAssignments({
|
||||||
|
page,
|
||||||
|
limit: 6,
|
||||||
|
keyword,
|
||||||
|
licenseCode: filterValues.licenseName,
|
||||||
|
status: filterValues.status,
|
||||||
|
});
|
||||||
setPlans(nextPlans);
|
setPlans(nextPlans);
|
||||||
setUsers(nextUsers);
|
setUsers(nextUsers);
|
||||||
|
setRows(nextAssignments.items);
|
||||||
|
setTotal(nextAssignments.total);
|
||||||
setAssignForm((prev) => ({
|
setAssignForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
licenseCode: prev.licenseCode || nextPlans[0]?.code || '',
|
||||||
@@ -136,14 +130,17 @@ export function LicensesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, [page, keyword, filterValues.licenseName, filterValues.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuRow) return;
|
if (!menuRow && !planMenu) return;
|
||||||
|
|
||||||
const close = () => setMenuRow(null);
|
const close = () => {
|
||||||
|
setMenuRow(null);
|
||||||
|
setPlanMenu(null);
|
||||||
|
};
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') setMenuRow(null);
|
if (event.key === 'Escape') close();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('click', close);
|
window.addEventListener('click', close);
|
||||||
@@ -152,7 +149,7 @@ export function LicensesPage() {
|
|||||||
window.removeEventListener('click', close);
|
window.removeEventListener('click', close);
|
||||||
window.removeEventListener('keydown', closeOnEscape);
|
window.removeEventListener('keydown', closeOnEscape);
|
||||||
};
|
};
|
||||||
}, [menuRow]);
|
}, [menuRow, planMenu]);
|
||||||
|
|
||||||
const openAssignModal = () => {
|
const openAssignModal = () => {
|
||||||
const startsAt = today();
|
const startsAt = today();
|
||||||
@@ -280,6 +277,73 @@ export function LicensesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openPlanEditModal = async (plan: LicensePlan) => {
|
||||||
|
setPlanMenu(null);
|
||||||
|
setPlanEditTarget(plan);
|
||||||
|
setPlanForm({
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description ?? '',
|
||||||
|
defaultDurationDays: plan.defaultDurationDays ? String(plan.defaultDurationDays) : ''
|
||||||
|
});
|
||||||
|
setLicenseAppPermissions([]);
|
||||||
|
setLicenseAppLoading(true);
|
||||||
|
try {
|
||||||
|
setLicenseAppPermissions(await loadLicenseAppPermissions(plan.code));
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 앱 설정을 불러오지 못했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setLicenseAppLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlanEditModal = () => {
|
||||||
|
setPlanEditTarget(null);
|
||||||
|
setLicenseAppPermissions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePlanEdit = async () => {
|
||||||
|
if (!planEditTarget || !planForm.name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateLicensePlan(planEditTarget.code, {
|
||||||
|
name: planForm.name.trim(),
|
||||||
|
description: planForm.description.trim() || null,
|
||||||
|
defaultDurationDays: planForm.defaultDurationDays ? Number(planForm.defaultDurationDays) : null,
|
||||||
|
permissions: licenseAppPermissions.map((permission) => ({
|
||||||
|
appKey: permission.appKey,
|
||||||
|
canLaunch: permission.canLaunch
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
toast({ type: 'success', message: `라이센스를 수정했습니다. ${result.updatedUserCount ?? 0}명에게 반영했습니다.` });
|
||||||
|
closePlanEditModal();
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 수정에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlanActive = async (plan: LicensePlan) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateLicensePlan(plan.code, { isActive: !plan.isActive });
|
||||||
|
toast({ type: 'success', message: plan.isActive ? '라이센스를 비활성화했습니다.' : '라이센스를 활성화했습니다.' });
|
||||||
|
setPlanStatusTarget(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ type: 'error', message: error instanceof Error ? error.message : '라이센스 상태 변경에 실패했습니다.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLicenseAppLaunch = (appKey: string) => {
|
||||||
|
setLicenseAppPermissions((prev) => prev.map((permission) =>
|
||||||
|
permission.appKey === appKey ? { ...permission, canLaunch: !permission.canLaunch, canView: true } : permission
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
const columns = useMemo<Array<DataTableColumn<LicenseAssignmentRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span className="block text-center font-semibold">{row.no}</span> },
|
||||||
@@ -327,10 +391,10 @@ export function LicensesPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
const filters = useMemo<Array<DataTableFilter<LicenseAssignmentRow>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.name })), getValue: (row) => row.licenseName },
|
{ key: 'licenseName', label: '라이센스', options: plans.map((plan) => ({ label: plan.name, value: plan.code })), getValue: (row) => row.code },
|
||||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status }
|
{ key: 'status', label: '상태', options: [{ label: '활성', value: 'active' }, { label: '만료', value: 'expired' }], getValue: (row) => row.status === '활성' ? 'active' : 'expired' }
|
||||||
],
|
],
|
||||||
[plans, rows]
|
[plans]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -354,7 +418,22 @@ export function LicensesPage() {
|
|||||||
<p className="text-sm text-foreground">{plan.name}</p>
|
<p className="text-sm text-foreground">{plan.name}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{plan.description || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={plan.isActive ? '활성' : '비활성'} />
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{!plan.isActive ? <StatusBadge status="비활성" /> : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 px-0"
|
||||||
|
title="메뉴"
|
||||||
|
aria-label="메뉴"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setPlanMenu((prev) => (prev?.code === plan.code ? null : { code: plan.code, x: rect.right, y: rect.bottom + 6 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -379,6 +458,31 @@ export function LicensesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{planMenu ? (() => {
|
||||||
|
const plan = plans.find((item) => item.code === planMenu.code);
|
||||||
|
if (!plan) return null;
|
||||||
|
return (
|
||||||
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: planMenu.x, top: planMenu.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => void openPlanEditModal(plan)}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setPlanMenu(null);
|
||||||
|
setPlanStatusTarget(plan);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.isActive ? '비활성' : '활성'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="목록"
|
title="목록"
|
||||||
@@ -392,6 +496,20 @@ export function LicensesPage() {
|
|||||||
minWidth={980}
|
minWidth={980}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="부여된 라이센스가 없습니다."
|
emptyMessage="부여된 라이센스가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -550,6 +668,110 @@ export function LicensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(planEditTarget)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Pencil className="size-4 text-primary" />
|
||||||
|
라이센스 수정
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={closePlanEditModal}
|
||||||
|
className="max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={closePlanEditModal} disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={savePlanEdit} disabled={saving || licenseAppLoading || !planForm.name.trim()}>
|
||||||
|
{saving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">라이센스명 <b className="text-foreground">*</b></span>
|
||||||
|
<input
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={planForm.name}
|
||||||
|
onChange={(event) => setPlanForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">기본 기간</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={planForm.defaultDurationDays}
|
||||||
|
onChange={(event) => setPlanForm((prev) => ({ ...prev, defaultDurationDays: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||||
|
<textarea
|
||||||
|
className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||||
|
value={planForm.description}
|
||||||
|
onChange={(event) => setPlanForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">앱 실행 설정</span>
|
||||||
|
<div className="mt-2 max-h-64 overflow-y-auto rounded-md border border-border">
|
||||||
|
{licenseAppLoading ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">앱 설정을 불러오는 중입니다.</div>
|
||||||
|
) : licenseAppPermissions.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">런처에 연결된 앱이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
licenseAppPermissions.map((permission) => (
|
||||||
|
<div key={permission.appKey} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/20">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm text-foreground">{permission.displayName}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">{permission.appKey}</p>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
checked={permission.canLaunch}
|
||||||
|
onChange={() => toggleLicenseAppLaunch(permission.appKey)}
|
||||||
|
/>
|
||||||
|
실행
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
open={Boolean(planStatusTarget)}
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-4 text-primary" />
|
||||||
|
라이센스 상태 변경
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClose={() => setPlanStatusTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setPlanStatusTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button
|
||||||
|
className={planStatusTarget?.isActive ? 'border border-amber-500/40 bg-amber-500/15 text-amber-100 hover:bg-amber-500/25' : undefined}
|
||||||
|
onClick={() => planStatusTarget ? void togglePlanActive(planStatusTarget) : undefined}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '처리 중' : planStatusTarget?.isActive ? '비활성' : '활성'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="pb-6 text-sm text-foreground">
|
||||||
|
{planStatusTarget?.name ?? '라이센스'}를 {planStatusTarget?.isActive ? '비활성화' : '활성화'}할까요?
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { listApps } from '../applications/application-api';
|
|||||||
import {
|
import {
|
||||||
createLauncherNotice,
|
createLauncherNotice,
|
||||||
deleteLauncherNotice,
|
deleteLauncherNotice,
|
||||||
loadLauncherNotices,
|
loadLauncherNoticesPage,
|
||||||
updateLauncherNotice,
|
updateLauncherNotice,
|
||||||
type LauncherNotice
|
type LauncherNotice
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
@@ -123,6 +123,10 @@ export function NoticesPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -136,11 +140,12 @@ export function NoticesPage() {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadLauncherNotices(),
|
loadLauncherNoticesPage({ page, limit: 6, keyword, appKey: filterValues.appKey }),
|
||||||
listApps().catch(() => [])
|
listApps().catch(() => [])
|
||||||
])
|
])
|
||||||
.then(([items, appItems]) => {
|
.then(([result, appItems]) => {
|
||||||
setNotices(items);
|
setNotices(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setApps(appItems);
|
setApps(appItems);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
@@ -154,7 +159,7 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, [page, keyword, filterValues.appKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuNotice) return;
|
if (!menuNotice) return;
|
||||||
@@ -184,7 +189,7 @@ export function NoticesPage() {
|
|||||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
() => notices.map((notice) => ({ ...notice, no: notice.no ?? 0, appName: appLabel(notice.appKey) })),
|
||||||
[appNameByKey, notices]
|
[appNameByKey, notices]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,9 +308,14 @@ export function NoticesPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey }
|
{
|
||||||
|
key: 'appKey',
|
||||||
|
label: '대상 앱',
|
||||||
|
options: ['Launcher', ...apps.map((app) => app.key)].map((appKey) => ({ label: appLabel(appKey), value: appKey })),
|
||||||
|
getValue: (row) => row.appKey
|
||||||
|
}
|
||||||
],
|
],
|
||||||
[rows]
|
[apps, appNameByKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,6 +348,20 @@ export function NoticesPage() {
|
|||||||
minWidth={1040}
|
minWidth={1040}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="등록된 공지사항이 없습니다."
|
emptyMessage="등록된 공지사항이 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{menuNotice ? (() => {
|
{menuNotice ? (() => {
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { formatDateTime } from '../../lib/date-format';
|
|||||||
import {
|
import {
|
||||||
createOrganization,
|
createOrganization,
|
||||||
deleteOrganization,
|
deleteOrganization,
|
||||||
loadOrganizations,
|
loadLicensePlans,
|
||||||
|
loadOrganizationsPage,
|
||||||
updateOrganization,
|
updateOrganization,
|
||||||
|
updateOrganizationLicense,
|
||||||
|
type LicensePlan,
|
||||||
type Organization,
|
type Organization,
|
||||||
type OrganizationType
|
type OrganizationType
|
||||||
} from './operations-api';
|
} from './operations-api';
|
||||||
@@ -36,11 +39,17 @@ function organizationTypeLabel(type: OrganizationType) {
|
|||||||
export function OrganizationsPage() {
|
export function OrganizationsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Organization | null>(null);
|
const [editing, setEditing] = useState<Organization | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Organization | null>(null);
|
||||||
|
const [licenseTarget, setLicenseTarget] = useState<{ organization: Organization; licenseCode: string } | null>(null);
|
||||||
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuOrganization, setMenuOrganization] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
@@ -48,9 +57,10 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadOrganizations()
|
loadOrganizationsPage({ page, limit: 10, keyword, type: filterValues.type })
|
||||||
.then((items) => {
|
.then((result) => {
|
||||||
setOrganizations(items);
|
setOrganizations(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
@@ -63,7 +73,10 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
loadLicensePlans()
|
||||||
|
.then(setLicensePlans)
|
||||||
|
.catch(() => setLicensePlans([]));
|
||||||
|
}, [page, keyword, filterValues.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOrganization) return;
|
if (!menuOrganization) return;
|
||||||
@@ -82,7 +95,7 @@ export function OrganizationsPage() {
|
|||||||
}, [menuOrganization]);
|
}, [menuOrganization]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => organizations.map((organization, index) => ({ ...organization, no: organizations.length - index })),
|
() => organizations.map((organization) => ({ ...organization, no: organization.no ?? 0 })),
|
||||||
[organizations]
|
[organizations]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,6 +114,7 @@ export function OrganizationsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||||
|
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
||||||
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
||||||
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
|
{ key: 'createdAt', header: '등록일', className: 'w-40 px-5 py-3', cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(row.createdAt)}</span> },
|
||||||
{
|
{
|
||||||
@@ -241,6 +255,20 @@ export function OrganizationsPage() {
|
|||||||
minWidth={980}
|
minWidth={980}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 조직 데이터가 없습니다."
|
emptyMessage="표시할 조직 데이터가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{menuOrganization ? (() => {
|
{menuOrganization ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuOrganization.id);
|
const row = rows.find((item) => item.id === menuOrganization.id);
|
||||||
@@ -248,6 +276,16 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
|
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-32 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuOrganization.x, top: menuOrganization.y, transform: 'translateX(-100%)' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOrganization(null);
|
||||||
|
setLicenseTarget({ organization: row, licenseCode: row.defaultLicenseCode ?? '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
라이센스 설정
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||||
@@ -271,6 +309,62 @@ export function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})() : null}
|
})() : null}
|
||||||
|
<Modal
|
||||||
|
open={Boolean(licenseTarget)}
|
||||||
|
title="조직 라이센스 설정"
|
||||||
|
onClose={() => setLicenseTarget(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setLicenseTarget(null)} disabled={saving}>취소</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || !licenseTarget}
|
||||||
|
onClick={() => {
|
||||||
|
if (!licenseTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
updateOrganizationLicense(licenseTarget.organization.id, licenseTarget.licenseCode || null)
|
||||||
|
.then((result) => {
|
||||||
|
toast({
|
||||||
|
type: 'success',
|
||||||
|
title: '라이센스 설정 완료',
|
||||||
|
message: `${result.updatedUserCount}명의 사용자 라이센스를 갱신했습니다.`
|
||||||
|
});
|
||||||
|
setLicenseTarget(null);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast({ type: 'error', title: '라이센스 설정 실패', message: error.message });
|
||||||
|
})
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{licenseTarget ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-background/70 px-3 py-2 text-sm">
|
||||||
|
<p className="font-semibold text-foreground">{licenseTarget.organization.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">저장하면 해당 조직에 소속된 사용자의 현재 라이센스가 선택한 라이센스로 갱신됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">라이센스</span>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={licenseTarget.licenseCode}
|
||||||
|
onChange={(event) => setLicenseTarget((prev) => prev ? { ...prev, licenseCode: event.target.value } : prev)}
|
||||||
|
>
|
||||||
|
<option value="">없음</option>
|
||||||
|
{licensePlans.map((plan) => (
|
||||||
|
<option key={plan.code} value={plan.code}>{plan.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title={editing ? '조직 수정' : '조직 추가'}
|
title={editing ? '조직 수정' : '조직 추가'}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
createManagedUser,
|
createManagedUser,
|
||||||
loadLicensePlans,
|
loadLicensePlans,
|
||||||
loadOrganizations,
|
loadOrganizations,
|
||||||
loadUserSessions,
|
loadUserSessionsPage,
|
||||||
lockUser,
|
lockUser,
|
||||||
terminateUserSession,
|
terminateUserSession,
|
||||||
unlockUser,
|
unlockUser,
|
||||||
@@ -32,6 +32,10 @@ const emptyCreateForm = {
|
|||||||
export function UserSessionsPage() {
|
export function UserSessionsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
const [licensePlans, setLicensePlans] = useState<LicensePlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -45,9 +49,10 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
loadUserSessions()
|
loadUserSessionsPage({ page, limit: 6, keyword, state: filterValues.state })
|
||||||
.then((nextSessions) => {
|
.then((result) => {
|
||||||
setSessions(nextSessions);
|
setSessions(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -71,7 +76,7 @@ export function UserSessionsPage() {
|
|||||||
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
licenseCode: (current.licenseCode || nextLicensePlans[0]?.code || 'EDU') as 'FREE' | 'EDU' | 'PRO'
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}, []);
|
}, [page, keyword, filterValues.state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuSession) return;
|
if (!menuSession) return;
|
||||||
@@ -89,7 +94,7 @@ export function UserSessionsPage() {
|
|||||||
};
|
};
|
||||||
}, [menuSession]);
|
}, [menuSession]);
|
||||||
|
|
||||||
const rows = useMemo(() => sessions.map((session, index) => ({ ...session, no: sessions.length - index })), [sessions]);
|
const rows = sessions;
|
||||||
|
|
||||||
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
const columns = useMemo<Array<DataTableColumn<UserSession>>>(
|
||||||
() => [
|
() => [
|
||||||
@@ -140,9 +145,9 @@ export function UserSessionsPage() {
|
|||||||
|
|
||||||
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
const filters = useMemo<Array<DataTableFilter<UserSession>>>(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'state', label: '상태', options: [...new Set(sessions.map((row) => row.state))].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
{ key: 'state', label: '상태', options: ['유효토큰', '세션 없음', '잠김', '만료', '종료'].map((state) => ({ label: state, value: state })), getValue: (row) => row.state }
|
||||||
],
|
],
|
||||||
[sessions]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
@@ -208,6 +213,20 @@ export function UserSessionsPage() {
|
|||||||
minWidth={900}
|
minWidth={900}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
emptyMessage="표시할 사용자/세션 데이터가 없습니다."
|
||||||
|
serverPagination={{
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSearchChange: (value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
filterValues,
|
||||||
|
onFilterChange: (key, value) => {
|
||||||
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{menuSession ? (() => {
|
{menuSession ? (() => {
|
||||||
const row = rows.find((item) => item.id === menuSession.id);
|
const row = rows.find((item) => item.id === menuSession.id);
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import { apiRequest } from '../../lib/api';
|
import { apiRequest } from '../../lib/api';
|
||||||
|
|
||||||
|
export type PageParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
keyword?: string;
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagedResponse<T> = {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageQuery(params?: PageParams) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== '') searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminSession = {
|
export type AdminSession = {
|
||||||
id?: string;
|
id?: string;
|
||||||
revokedAt?: string | null;
|
revokedAt?: string | null;
|
||||||
@@ -13,6 +36,7 @@ export type AdminSshKey = {
|
|||||||
|
|
||||||
export type AdminAccount = {
|
export type AdminAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
|
no?: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
@@ -64,6 +88,8 @@ export type Organization = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
|
defaultLicenseCode?: string | null;
|
||||||
|
defaultLicenseName?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,18 +112,24 @@ export type LauncherNotice = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadAdmins() {
|
export async function loadAdminsPage(params?: PageParams & { role?: string; state?: string }): Promise<PagedResponse<AdminAccount>> {
|
||||||
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
type RawAdminAccount = Omit<AdminAccount, 'appPermissions'> & {
|
||||||
appPermissions?: Array<string | { appId: string }>;
|
appPermissions?: Array<string | { appId: string }>;
|
||||||
};
|
};
|
||||||
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[] }>('/admins');
|
const response = await apiRequest<RawAdminAccount[] | { admins?: RawAdminAccount[]; total?: number; page?: number; limit?: number }>(`/admins${pageQuery(params)}`);
|
||||||
const admins = Array.isArray(response) ? response : response.admins ?? [];
|
const admins = Array.isArray(response) ? response : response.admins ?? [];
|
||||||
return admins.map((admin) => ({
|
const items = admins.map((admin) => ({
|
||||||
...admin,
|
...admin,
|
||||||
appPermissions: (admin.appPermissions ?? []).map((permission) => (
|
appPermissions: (admin.appPermissions ?? []).map((permission) => (
|
||||||
typeof permission === 'string' ? permission : permission.appId
|
typeof permission === 'string' ? permission : permission.appId
|
||||||
))
|
))
|
||||||
}));
|
}));
|
||||||
|
if (Array.isArray(response)) return { items, total: items.length, page: params?.page ?? 1, limit: params?.limit ?? items.length };
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAdmins() {
|
||||||
|
return (await loadAdminsPage({ limit: 1000 })).items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAdminRoles() {
|
export function loadAdminRoles() {
|
||||||
@@ -165,13 +197,18 @@ export function updateAdminAppPermissions(adminId: string, appIds: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserSessions() {
|
export async function loadUserSessionsPage(params?: PageParams & { state?: string }): Promise<PagedResponse<UserSession>> {
|
||||||
const response = await apiRequest<{ sessions?: UserSession[] }>('/users/sessions');
|
const response = await apiRequest<{ sessions?: UserSession[]; total?: number; page?: number; limit?: number }>(`/users/sessions${pageQuery(params)}`);
|
||||||
return (response.sessions ?? []).map((session) => ({
|
const items = (response.sessions ?? []).map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
session: session.sessionId ? session.session : '-',
|
session: session.sessionId ? session.session : '-',
|
||||||
state: session.state
|
state: session.state
|
||||||
}));
|
}));
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserSessions() {
|
||||||
|
return (await loadUserSessionsPage({ limit: 1000 })).items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lockUser(userId: string) {
|
export function lockUser(userId: string) {
|
||||||
@@ -207,9 +244,14 @@ export function createManagedUser(payload: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadOrganizationsPage(params?: PageParams & { type?: string }): Promise<PagedResponse<Organization>> {
|
||||||
|
const response = await apiRequest<{ organizations?: Organization[]; total?: number; page?: number; limit?: number }>(`/organizations${pageQuery(params)}`);
|
||||||
|
const items = response.organizations ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadOrganizations() {
|
export async function loadOrganizations() {
|
||||||
const response = await apiRequest<{ organizations?: Organization[] }>('/organizations');
|
return (await loadOrganizationsPage({ limit: 1000 })).items;
|
||||||
return response.organizations ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
export function createOrganization(payload: { name: string; type: OrganizationType; domain?: string | null }) {
|
||||||
@@ -232,9 +274,21 @@ export function deleteOrganization(organizationId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateOrganizationLicense(organizationId: string, licenseCode: string | null) {
|
||||||
|
return apiRequest<{ organization: Organization; updatedUserCount: number }>(`/organizations/${organizationId}/license`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ licenseCode })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLauncherNoticesPage(params?: PageParams & { appKey?: string }): Promise<PagedResponse<LauncherNotice>> {
|
||||||
|
const response = await apiRequest<{ notices?: LauncherNotice[]; total?: number; page?: number; limit?: number }>(`/notices${pageQuery(params)}`);
|
||||||
|
const items = response.notices ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLauncherNotices() {
|
export async function loadLauncherNotices() {
|
||||||
const response = await apiRequest<{ notices?: LauncherNotice[] }>('/notices');
|
return (await loadLauncherNoticesPage({ limit: 1000 })).items;
|
||||||
return response.notices ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLauncherNotice(payload: {
|
export function createLauncherNotice(payload: {
|
||||||
@@ -323,6 +377,19 @@ export async function loadLicensePlans() {
|
|||||||
return response.plans ?? [];
|
return response.plans ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateLicensePlan(licenseCode: string, payload: {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
defaultDurationDays?: number | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
permissions?: Array<{ appKey: string; canLaunch: boolean }>;
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ plan: LicensePlan; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?: string }) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
if (params?.keyword) searchParams.set('keyword', params.keyword);
|
||||||
@@ -332,6 +399,27 @@ export async function loadLicenseUsers(params?: { keyword?: string; licenseCode?
|
|||||||
return response.users ?? [];
|
return response.users ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadLicenseAssignments(params?: PageParams & { licenseCode?: string; status?: string }): Promise<PagedResponse<LicenseAssignment>> {
|
||||||
|
const response = await apiRequest<{ assignments?: LicenseAssignment[]; total?: number; page?: number; limit?: number }>(`/licenses/assignments${pageQuery(params)}`);
|
||||||
|
const items = response.assignments ?? [];
|
||||||
|
return { items, total: response.total ?? items.length, page: response.page ?? params?.page ?? 1, limit: response.limit ?? params?.limit ?? items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseAssignment = {
|
||||||
|
id: string;
|
||||||
|
no?: number;
|
||||||
|
userId: string;
|
||||||
|
user: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
code: string;
|
||||||
|
licenseName: string;
|
||||||
|
startsAt: string;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
assignedBy?: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function assignUserLicense(userId: string, payload: {
|
export function assignUserLicense(userId: string, payload: {
|
||||||
licenseCode: string;
|
licenseCode: string;
|
||||||
startsAt?: string;
|
startsAt?: string;
|
||||||
@@ -373,6 +461,33 @@ export type LauncherUserAppPermission = {
|
|||||||
assignedAt?: string | null;
|
assignedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LicenseAppPermission = {
|
||||||
|
appId: string;
|
||||||
|
appKey: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
policyId?: string | null;
|
||||||
|
canView: boolean;
|
||||||
|
canLaunch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadLicenseAppPermissions(licenseCode: string) {
|
||||||
|
const response = await apiRequest<{ permissions?: LicenseAppPermission[] }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`);
|
||||||
|
return response.permissions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLicenseAppPermissions(licenseCode: string, permissions: Array<{
|
||||||
|
appKey: string;
|
||||||
|
canLaunch: boolean;
|
||||||
|
}>) {
|
||||||
|
return apiRequest<{ permissions?: LicenseAppPermission[]; updatedUserCount?: number }>(`/licenses/plans/${encodeURIComponent(licenseCode)}/app-permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ permissions })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadLauncherUserAppPermissions(userId: string) {
|
export async function loadLauncherUserAppPermissions(userId: string) {
|
||||||
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
const response = await apiRequest<{ permissions?: LauncherUserAppPermission[] }>(`/launcher/users/${userId}/app-permissions`);
|
||||||
return response.permissions ?? [];
|
return response.permissions ?? [];
|
||||||
|
|||||||
Reference in New Issue
Block a user