개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -29,10 +29,14 @@ import {
|
|||
} from "../components/ui";
|
||||
import {
|
||||
adminApi,
|
||||
adminProtocolsApi,
|
||||
adminUsersApi,
|
||||
type AdminHealthResponse,
|
||||
type AdminHealthStatus,
|
||||
type AdminManagedUser,
|
||||
type AdminProtocol,
|
||||
type AdminProtocolCreateRequest,
|
||||
type AdminProtocolStatus,
|
||||
type AdminTicketFilters,
|
||||
type AdminSupportTicket,
|
||||
type AdminTicketsResponse,
|
||||
|
|
@ -58,6 +62,7 @@ type UserDraft = Pick<
|
|||
| "display_name"
|
||||
| "role"
|
||||
| "admin_access"
|
||||
| "learner_feedback_enabled"
|
||||
| "account_status"
|
||||
| "affiliation"
|
||||
| "cohort_ids"
|
||||
|
|
@ -67,10 +72,17 @@ type NewUserDraft = Required<
|
|||
> &
|
||||
Pick<
|
||||
UserDraft,
|
||||
"admin_access" | "account_status" | "affiliation" | "cohort_ids"
|
||||
>;
|
||||
| "admin_access"
|
||||
| "learner_feedback_enabled"
|
||||
| "affiliation"
|
||||
| "cohort_ids"
|
||||
> & {
|
||||
account_status: "pending";
|
||||
};
|
||||
type UserTab = "approval" | "manage" | "register" | "activity";
|
||||
type AccessTab = "roles" | "groups" | "matrix";
|
||||
type AccessTab = "roles" | "groups" | "matrix" | "protocols";
|
||||
type ProtocolStatusFilter = AdminProtocolStatus | "all";
|
||||
type ProtocolDraft = AdminProtocolCreateRequest;
|
||||
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
|
||||
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
|
||||
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
|
||||
|
|
@ -116,11 +128,46 @@ const EMPTY_NEW_USER: NewUserDraft = {
|
|||
display_name: "",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
account_status: "approved",
|
||||
learner_feedback_enabled: true,
|
||||
account_status: "pending",
|
||||
affiliation: "",
|
||||
cohort_ids: [],
|
||||
};
|
||||
|
||||
const EMPTY_PROTOCOL_DRAFT: ProtocolDraft = {
|
||||
title: "",
|
||||
source: "",
|
||||
version: 1,
|
||||
license: "B",
|
||||
external_llm_ok: false,
|
||||
content: "",
|
||||
};
|
||||
|
||||
function protocolStatusLabel(status: AdminProtocolStatus): string {
|
||||
if (status === "draft") return "초안";
|
||||
if (status === "active") return "활성";
|
||||
return "퇴역";
|
||||
}
|
||||
|
||||
function protocolStatusTone(
|
||||
status: AdminProtocolStatus,
|
||||
): "warn" | "pos" | "neutral" {
|
||||
if (status === "draft") return "warn";
|
||||
if (status === "active") return "pos";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function protocolAllowsExternalLlm(license: ProtocolDraft["license"]): boolean {
|
||||
return license === "A" || license === "B";
|
||||
}
|
||||
|
||||
function protocolVersionError(version: number): string | null {
|
||||
if (!Number.isInteger(version) || version < 1 || version > 1_000_000) {
|
||||
return "버전은 1부터 1,000,000 사이의 정수여야 합니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROLE_POLICIES = [
|
||||
{
|
||||
role: "관리자",
|
||||
|
|
@ -516,6 +563,7 @@ function userDraftFrom(user: AdminManagedUser): UserDraft {
|
|||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
admin_access: user.admin_access,
|
||||
learner_feedback_enabled: user.learner_feedback_enabled ?? true,
|
||||
account_status: user.account_status,
|
||||
affiliation: user.affiliation,
|
||||
cohort_ids: user.cohort_ids,
|
||||
|
|
@ -827,6 +875,18 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
|
||||
const [userTab, setUserTab] = useState<UserTab>("approval");
|
||||
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
|
||||
const [protocols, setProtocols] = useState<AdminProtocol[]>([]);
|
||||
const [protocolsLoaded, setProtocolsLoaded] = useState(false);
|
||||
const [protocolsLoading, setProtocolsLoading] = useState(false);
|
||||
const [protocolsError, setProtocolsError] = useState<string | null>(null);
|
||||
const [protocolDraft, setProtocolDraft] = useState<ProtocolDraft>(
|
||||
EMPTY_PROTOCOL_DRAFT,
|
||||
);
|
||||
const [protocolSaving, setProtocolSaving] = useState(false);
|
||||
const [protocolActionId, setProtocolActionId] = useState<string | null>(null);
|
||||
const [protocolSearch, setProtocolSearch] = useState("");
|
||||
const [protocolStatusFilter, setProtocolStatusFilter] =
|
||||
useState<ProtocolStatusFilter>("all");
|
||||
const canGrantAdminAccess = currentUser?.superAdmin === true;
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
|
|
@ -940,6 +1000,24 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
ticketStatusFilter,
|
||||
]);
|
||||
|
||||
const loadProtocols = useCallback(async () => {
|
||||
setProtocolsLoading(true);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const response = await adminProtocolsApi.list();
|
||||
setProtocols(response.protocols);
|
||||
setProtocolsLoaded(true);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "상담 프로토콜 목록을 불러오지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setProtocolsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
void loadUsers();
|
||||
|
|
@ -951,6 +1029,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
void loadTickets();
|
||||
}, [loadTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (section === "access" && accessTab === "protocols" && !protocolsLoaded) {
|
||||
void loadProtocols();
|
||||
}
|
||||
}, [accessTab, loadProtocols, protocolsLoaded, section]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([
|
||||
loadHealth(),
|
||||
|
|
@ -961,6 +1045,109 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
]);
|
||||
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
|
||||
|
||||
const visibleProtocols = useMemo(() => {
|
||||
const query = protocolSearch.trim().toLocaleLowerCase("ko-KR");
|
||||
return protocols.filter((protocol) => {
|
||||
if (
|
||||
protocolStatusFilter !== "all" &&
|
||||
protocol.status !== protocolStatusFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!query) return true;
|
||||
return [protocol.title, protocol.source, protocol.source_id]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("ko-KR")
|
||||
.includes(query);
|
||||
});
|
||||
}, [protocolSearch, protocolStatusFilter, protocols]);
|
||||
|
||||
const createProtocol = async () => {
|
||||
if (
|
||||
!protocolDraft.title.trim() ||
|
||||
!protocolDraft.source.trim() ||
|
||||
!protocolDraft.content.trim()
|
||||
) {
|
||||
setProtocolsError("제목, 출처, 원문을 모두 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
const versionError = protocolVersionError(protocolDraft.version);
|
||||
if (versionError) {
|
||||
setProtocolsError(versionError);
|
||||
return;
|
||||
}
|
||||
setProtocolSaving(true);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const created = await adminProtocolsApi.create({
|
||||
...protocolDraft,
|
||||
title: protocolDraft.title.trim(),
|
||||
source: protocolDraft.source.trim(),
|
||||
content: protocolDraft.content.trim(),
|
||||
external_llm_ok:
|
||||
protocolAllowsExternalLlm(protocolDraft.license)
|
||||
? protocolDraft.external_llm_ok
|
||||
: false,
|
||||
});
|
||||
setProtocols((current) => [
|
||||
created,
|
||||
...current.filter((item) => item.protocol_id !== created.protocol_id),
|
||||
]);
|
||||
setProtocolDraft(EMPTY_PROTOCOL_DRAFT);
|
||||
setProtocolsLoaded(true);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "상담 프로토콜 초안을 등록하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setProtocolSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activateProtocol = async (protocol: AdminProtocol) => {
|
||||
setProtocolActionId(protocol.protocol_id);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const response = await adminProtocolsApi.activate(protocol.protocol_id);
|
||||
setProtocols((current) =>
|
||||
current.map((item) =>
|
||||
item.protocol_id === protocol.protocol_id ? response.protocol : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `“${protocol.title}” 프로토콜을 활성화하지 못했습니다.`,
|
||||
);
|
||||
} finally {
|
||||
setProtocolActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const retireProtocol = async (protocol: AdminProtocol) => {
|
||||
setProtocolActionId(protocol.protocol_id);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const retired = await adminProtocolsApi.retire(protocol.protocol_id);
|
||||
setProtocols((current) =>
|
||||
current.map((item) =>
|
||||
item.protocol_id === protocol.protocol_id ? retired : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `“${protocol.title}” 프로토콜을 퇴역하지 못했습니다.`,
|
||||
);
|
||||
} finally {
|
||||
setProtocolActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
|
||||
setUserDrafts((current) => ({
|
||||
...current,
|
||||
|
|
@ -969,6 +1156,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
display_name: "",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
learner_feedback_enabled: true,
|
||||
account_status: "approved",
|
||||
affiliation: "",
|
||||
cohort_ids: [],
|
||||
|
|
@ -1017,6 +1205,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
display_name: displayName,
|
||||
affiliation: newUser.affiliation.trim(),
|
||||
cohort_ids: newUser.cohort_ids,
|
||||
account_status: "pending",
|
||||
});
|
||||
setNewUser(EMPTY_NEW_USER);
|
||||
await loadUsers();
|
||||
|
|
@ -1388,6 +1577,31 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "learner_feedback_enabled",
|
||||
accessorFn: (user) => Number(user.learner_feedback_enabled),
|
||||
header: "AI 피드백",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const draft = userDrafts[user.user_id] ?? user;
|
||||
return (
|
||||
<label className="vgops-user-table__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.learner_feedback_enabled}
|
||||
onChange={(event) =>
|
||||
updateDraft(user.user_id, {
|
||||
learner_feedback_enabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!usersWritable || savingUserId === user.user_id}
|
||||
aria-label={`${user.email} 학습자 AI 피드백`}
|
||||
/>
|
||||
<span>{draft.learner_feedback_enabled ? "켜짐" : "꺼짐"}</span>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "affiliation",
|
||||
accessorFn: (user) => user.affiliation,
|
||||
|
|
@ -1465,6 +1679,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
draft.display_name !== user.display_name ||
|
||||
draft.role !== user.role ||
|
||||
draft.admin_access !== user.admin_access ||
|
||||
draft.learner_feedback_enabled !==
|
||||
(user.learner_feedback_enabled ?? true) ||
|
||||
draft.account_status !== user.account_status ||
|
||||
draft.affiliation !== user.affiliation ||
|
||||
cohortInputValue(draft.cohort_ids) !==
|
||||
|
|
@ -1868,7 +2084,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
`가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
|
||||
],
|
||||
["manage", "사용자 목록"],
|
||||
["register", "사용자 등록"],
|
||||
["register", "외부 연구참여자 사전등록"],
|
||||
["activity", "활동 요약"],
|
||||
]}
|
||||
value={userTab}
|
||||
|
|
@ -2012,12 +2228,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const renderUserCreate = () => (
|
||||
<section className={surfaceClassName("vgops-panel")}>
|
||||
<div className="vgops-section__head">
|
||||
<h2>새 사용자 등록</h2>
|
||||
<h2>외부 연구참여자 사전등록</h2>
|
||||
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
|
||||
</div>
|
||||
<div className="vgops-users-note">
|
||||
역할과 코호트는 접근 범위를 결정합니다. 허용 도메인 밖 이메일은 정확히
|
||||
등록된 계정만 로그인 가능합니다.
|
||||
허용 도메인 밖 참여자는 관리자가 입력한 정확한 이메일 주소로만 Google·개발
|
||||
로그인을 통과합니다. 사전등록 계정은 승인 대기 상태이며, 승인 전에는 학습
|
||||
기능에 접근할 수 없습니다.
|
||||
</div>
|
||||
{!usersWritable ? (
|
||||
<div className="vgops-users-note vgops-users-note--warn" role="alert">
|
||||
|
|
@ -2034,6 +2251,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
<label>
|
||||
<span>이메일</span>
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(event) =>
|
||||
setNewUser((current) => ({
|
||||
|
|
@ -2041,7 +2259,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
email: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="name@example.com"
|
||||
placeholder="participant@example.com"
|
||||
autoComplete="email"
|
||||
disabled={!usersWritable || creatingUser}
|
||||
aria-label="새 사용자 이메일"
|
||||
/>
|
||||
|
|
@ -2100,24 +2319,30 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
/>
|
||||
<span>관리자 페이지 권한</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>승인 상태</span>
|
||||
<select
|
||||
value={newUser.account_status}
|
||||
<label className="vgops-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newUser.learner_feedback_enabled}
|
||||
onChange={(event) =>
|
||||
setNewUser((current) => ({
|
||||
...current,
|
||||
account_status: event.target
|
||||
.value as AdminManagedUser["account_status"],
|
||||
learner_feedback_enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
disabled={!usersWritable || creatingUser}
|
||||
aria-label="새 사용자 학습자 AI 피드백"
|
||||
/>
|
||||
<span>학습자 AI 피드백</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>승인 상태</span>
|
||||
<output
|
||||
className="vgops-readonly-control"
|
||||
aria-label="새 사용자 승인 상태"
|
||||
data-value="pending"
|
||||
>
|
||||
<option value="approved">승인됨</option>
|
||||
<option value="pending">승인 대기</option>
|
||||
<option value="suspended">보류</option>
|
||||
</select>
|
||||
승인 대기
|
||||
</output>
|
||||
</label>
|
||||
<label>
|
||||
<span>코호트</span>
|
||||
|
|
@ -2154,7 +2379,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
leading={<Icon name="users" size={15} />}
|
||||
disabled={!usersWritable || creatingUser}
|
||||
>
|
||||
{creatingUser ? "등록 중" : "사용자 등록"}
|
||||
{creatingUser ? "사전등록 중" : "연구참여자 사전등록"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -2354,23 +2579,303 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</section>
|
||||
);
|
||||
|
||||
const renderProtocols = () => (
|
||||
<section className="vgops-protocols" aria-label="상담 프로토콜 관리">
|
||||
<section className={surfaceClassName("vgops-panel vgops-protocol-create") }>
|
||||
<div className="vgops-section__head">
|
||||
<h2>상담 프로토콜 초안 등록</h2>
|
||||
<Badge tone="neutral">평가자 전용</Badge>
|
||||
</div>
|
||||
<p className="vgops-protocol-intro">
|
||||
초안은 활성화할 때만 RAG에 색인되며, 라이선스 C/D 원문은 외부 LLM에
|
||||
전달할 수 없습니다.
|
||||
</p>
|
||||
<form
|
||||
className="vgops-protocol-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createProtocol();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>제목</span>
|
||||
<input
|
||||
required
|
||||
value={protocolDraft.title}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 제목"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<label className="vgops-protocol-form__source">
|
||||
<span>출처</span>
|
||||
<input
|
||||
required
|
||||
value={protocolDraft.source}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
source: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="공식 문서 URL 또는 출처 식별자"
|
||||
aria-label="프로토콜 출처"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1_000_000}
|
||||
required
|
||||
value={protocolDraft.version}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
version: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 버전"
|
||||
aria-invalid={protocolVersionError(protocolDraft.version) !== null}
|
||||
aria-describedby="protocol-version-error"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
{protocolVersionError(protocolDraft.version) ? (
|
||||
<small
|
||||
id="protocol-version-error"
|
||||
className="vgops-protocol-field-error"
|
||||
role="alert"
|
||||
>
|
||||
{protocolVersionError(protocolDraft.version)}
|
||||
</small>
|
||||
) : null}
|
||||
</label>
|
||||
<label>
|
||||
<span>라이선스</span>
|
||||
<select
|
||||
value={protocolDraft.license}
|
||||
onChange={(event) => {
|
||||
const license = event.target.value as ProtocolDraft["license"];
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
license,
|
||||
external_llm_ok:
|
||||
protocolAllowsExternalLlm(license)
|
||||
? current.external_llm_ok
|
||||
: false,
|
||||
}));
|
||||
}}
|
||||
aria-label="프로토콜 라이선스"
|
||||
disabled={protocolSaving}
|
||||
>
|
||||
{(["A", "B", "C", "D"] as const).map((license) => (
|
||||
<option value={license} key={license}>
|
||||
{license}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="vgops-protocol-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={protocolDraft.external_llm_ok}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
external_llm_ok: event.target.checked,
|
||||
}))
|
||||
}
|
||||
aria-label="외부 LLM 사용 허용"
|
||||
disabled={
|
||||
protocolSaving ||
|
||||
protocolDraft.license === "C" ||
|
||||
protocolDraft.license === "D"
|
||||
}
|
||||
/>
|
||||
<span>외부 LLM 사용 허용</span>
|
||||
</label>
|
||||
<p className="vgops-protocol-policy" aria-live="polite">
|
||||
{protocolDraft.license === "C" || protocolDraft.license === "D"
|
||||
? "라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."
|
||||
: "A/B도 명시적으로 허용한 경우에만 외부 LLM에 전달됩니다."}
|
||||
</p>
|
||||
<label className="vgops-protocol-form__content">
|
||||
<span>프로토콜 원문</span>
|
||||
<textarea
|
||||
required
|
||||
value={protocolDraft.content}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
content: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 원문"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
className="vgops-protocol-submit"
|
||||
type="submit"
|
||||
disabled={protocolSaving}
|
||||
leading={<Icon name="plus" size={16} />}
|
||||
>
|
||||
{protocolSaving ? "초안 등록 중" : "초안 등록"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={surfaceClassName("vgops-panel vgops-protocol-list") }>
|
||||
<div className="vgops-section__head">
|
||||
<h2>등록 프로토콜</h2>
|
||||
<span>{protocols.length}개</span>
|
||||
</div>
|
||||
<div className="vgops-protocol-toolbar">
|
||||
<label>
|
||||
<span>검색</span>
|
||||
<input
|
||||
type="search"
|
||||
value={protocolSearch}
|
||||
onChange={(event) => setProtocolSearch(event.target.value)}
|
||||
placeholder="제목 또는 출처"
|
||||
aria-label="프로토콜 검색"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>상태</span>
|
||||
<select
|
||||
value={protocolStatusFilter}
|
||||
onChange={(event) =>
|
||||
setProtocolStatusFilter(event.target.value as ProtocolStatusFilter)
|
||||
}
|
||||
aria-label="프로토콜 상태 필터"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="draft">초안</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="retired">퇴역</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadProtocols()}
|
||||
disabled={protocolsLoading}
|
||||
>
|
||||
{protocolsLoading ? "불러오는 중" : "새로고침"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{protocolsError ? (
|
||||
<div className="vgops-protocol-error">
|
||||
<InlineError message={protocolsError} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadProtocols()}
|
||||
disabled={protocolsLoading}
|
||||
>
|
||||
목록 다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{protocolsLoading && !protocolsLoaded ? (
|
||||
<div className="vgops-users-note" role="status">
|
||||
상담 프로토콜 목록을 불러오는 중입니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!protocolsLoading && protocolsLoaded && visibleProtocols.length === 0 ? (
|
||||
<EmptyState
|
||||
title="조건에 맞는 프로토콜이 없습니다"
|
||||
body="새 초안을 등록하거나 검색·상태 필터를 바꿔 주세요."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="vgops-protocol-cards">
|
||||
{visibleProtocols.map((protocol) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-protocol-card", {
|
||||
variant: "inset",
|
||||
})}
|
||||
key={protocol.protocol_id}
|
||||
data-protocol-status={protocol.status}
|
||||
>
|
||||
<div className="vgops-protocol-card__copy">
|
||||
<div className="vgops-protocol-card__title">
|
||||
<h3>{protocol.title}</h3>
|
||||
<Badge tone={protocolStatusTone(protocol.status)}>
|
||||
{protocolStatusLabel(protocol.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{protocol.source}</p>
|
||||
<div className="vgops-chip-row">
|
||||
<span>v{protocol.version}</span>
|
||||
<span>라이선스 {protocol.license}</span>
|
||||
<span>
|
||||
외부 LLM {protocol.external_llm_ok ? "허용" : "차단"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vgops-protocol-card__actions">
|
||||
{protocol.status === "draft" ? (
|
||||
<Button
|
||||
onClick={() => void activateProtocol(protocol)}
|
||||
disabled={protocolActionId === protocol.protocol_id}
|
||||
aria-label={`${protocol.title} 활성화`}
|
||||
>
|
||||
{protocolActionId === protocol.protocol_id
|
||||
? "활성화 중"
|
||||
: "활성화"}
|
||||
</Button>
|
||||
) : null}
|
||||
{protocol.status === "active" ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void retireProtocol(protocol)}
|
||||
disabled={protocolActionId === protocol.protocol_id}
|
||||
aria-label={`${protocol.title} 퇴역`}
|
||||
>
|
||||
{protocolActionId === protocol.protocol_id ? "퇴역 중" : "퇴역"}
|
||||
</Button>
|
||||
) : null}
|
||||
{protocol.status === "retired" ? (
|
||||
<span className="vgops-protocol-card__terminal">변경 불가</span>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderAccess = () => (
|
||||
<>
|
||||
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
|
||||
<PageHeader
|
||||
title="역할, 그룹, 접근 범위"
|
||||
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
|
||||
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
|
||||
/>
|
||||
<div className="vgops-users-note">
|
||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
||||
기준을 먼저 고정합니다.
|
||||
</div>
|
||||
{accessTab !== "protocols" ? (
|
||||
<div className="vgops-users-note">
|
||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
||||
기준을 먼저 고정합니다.
|
||||
</div>
|
||||
) : null}
|
||||
<TabBar
|
||||
ariaLabel="접근 권한 탭"
|
||||
items={[
|
||||
["roles", "역할"],
|
||||
["groups", "그룹"],
|
||||
["matrix", "권한 매트릭스"],
|
||||
["protocols", "상담 프로토콜"],
|
||||
]}
|
||||
value={accessTab}
|
||||
onChange={setAccessTab}
|
||||
|
|
@ -2447,6 +2952,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "protocols" ? renderProtocols() : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue