File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { useCallback, useEffect, useState } from 'react'
import {
getResetPreview,
resetChannels,
RESET_CONFIRM_PHRASE,
type ResetReport,
type ResetTarget,
} from '../api/client'
import { stageLabel } from '../stages'
import HelpButton from '../help/HelpButton'
/**
* 시스템관리 화면. 지금은 [채널 초기화] 하나만 있다.
*
* 되돌리기 번거로운 작업이라 3중으로 막는다.
* 1) 무엇이 사라지는지 목록으로 먼저 보여준다
* 2) 확인 문구를 그대로 입력해야 버튼이 열린다
* 3) 서버도 같은 문구를 다시 검사한다
*
* 지우는 범위는 <b>웹앱이 만든(=DB가 채널 ID를 들고 있는) 채널</b>뿐이다. Mattermost 팀에는
* 엑셀 VBA로 만든 예전 채널과 공용 채널(Town Square/FAQ/대쉬보드/양식 모음/자료실)이 함께
* 있어서, 이름으로 훑어 지우면 그것들까지 날아간다. 사용자 계정도 건드리지 않는다.
*/
interface Props {
/** 초기화가 끝나면 상위(App)가 기관 목록을 다시 읽도록. */
onChanged?: () => void
}
export default function SystemPage({ onChanged }: Props) {
const [targets, setTargets] = useState<ResetTarget[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const [report, setReport] = useState<ResetReport | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const reload = useCallback(async () => {
try {
setTargets(await getResetPreview())
setError(null)
} catch (e) {
setError((e as Error).message)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const channelCount = (targets ?? []).reduce((sum, t) => sum + t.channels.length, 0)
const canRun = confirm.trim() === RESET_CONFIRM_PHRASE && channelCount > 0 && !busy
async function run() {
setBusy(true)
setDialogOpen(false)
try {
const result = await resetChannels(confirm.trim())
setReport(result)
setConfirm('')
await reload()
onChanged?.()
setError(null)
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="space-y-4 p-6">
<section className="rounded-lg border border-gray-200 bg-white p-5">
<h1 className="text-lg font-semibold">시스템관리</h1>
<p className="text-xs text-gray-500">운영 중 되돌리기가 필요한 작업을 모아 둔다.</p>
</section>
<section className="rounded-lg border border-red-200 bg-white p-5">
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-red-700">
채널 초기화
<HelpButton topic="system.reset" />
</h2>
<ul className="mt-3 space-y-1 rounded-md bg-red-50 p-3 text-xs text-red-800">
<li>· 웹앱이 만든 Mattermost 채널을 <b>보관(아카이브)</b> 처리한다. 영구 삭제가 아니라 시스템 콘솔에서 되돌릴 수 있다.</li>
<li>· 기관의 채널 연결과 진행단계가 비워져, 채널관리에서 처음부터 다시 만들 수 있게 된다.</li>
<li>· 담당자 배정, 권리확인·권리처리 자료, 업무메모는 <b>그대로 남는다</b>.</li>
<li>· 공용 채널(Town Square·FAQ·대쉬보드·양식 모음·자료실)과 엑셀로 만든 예전 채널은 대상이 아니다.</li>
<li>· 사용자 계정도 건드리지 않는다.</li>
</ul>
{error && (
<p className="mt-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
)}
{report && (
<div className="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm">
<p>
채널 {report.archivedChannels}개 보관, 기관 {report.clearedOrgs}곳 초기화
{report.failures.length > 0 && `, 실패 ${report.failures.length}건`}
</p>
{report.failures.length > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-red-700">
{report.failures.map((f) => (
<li key={f}>· {f}</li>
))}
</ul>
)}
</div>
)}
<div className="mt-4">
<p className="text-sm font-medium">
대상 {targets?.length ?? 0}개 기관 · 채널 {channelCount}개
</p>
{targets === null ? (
<p className="mt-2 text-sm text-gray-500">불러오는 중…</p>
) : targets.length === 0 ? (
<p className="mt-2 text-sm text-gray-500">초기화할 채널이 없습니다.</p>
) : (
<div className="mt-2 max-h-72 overflow-y-auto rounded-md border border-gray-200">
<table data-testid="reset-targets" className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs text-gray-500">
<th className="px-3 py-2 text-left font-normal">연번</th>
<th className="px-3 py-2 text-left font-normal">기관명</th>
<th className="px-3 py-2 text-left font-normal">현재 단계</th>
<th className="px-3 py-2 text-left font-normal">보관될 채널</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.orgId} className="border-b border-gray-100">
<td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td>
<td className="px-3 py-2 font-medium">{t.orgName}</td>
<td className="px-3 py-2 text-gray-600">
{t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`}
</td>
<td className="px-3 py-2 text-gray-600">
{t.channels.map((c) => c.kind).join(' · ')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<label htmlFor="reset-confirm" className="text-sm text-gray-600">
실행하려면 <b>{RESET_CONFIRM_PHRASE}</b> 를 입력하세요
</label>
<input
id="reset-confirm"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-40 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
<button
type="button"
disabled={!canRun}
onClick={() => setDialogOpen(true)}
className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
채널 초기화 실행
</button>
</div>
</section>
{dialogOpen && (
<div className="fixed inset-0 flex items-center justify-center bg-black/30" role="alertdialog">
<div className="w-96 rounded-lg bg-white p-5">
<h2 className="text-base font-semibold">정말 초기화할까요?</h2>
<p className="mt-3 rounded-md bg-gray-50 p-3 text-sm text-gray-700">
기관 {targets?.length ?? 0}곳의 채널 {channelCount}개가 Mattermost에서 보관 처리되고,
해당 기관의 채널 연결과 진행단계가 비워집니다.
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
disabled={busy}
onClick={() => setDialogOpen(false)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
>
취소
</button>
<button
type="button"
disabled={busy}
onClick={() => void run()}
className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
초기화 실행
</button>
</div>
</div>
</div>
)}
</div>
)
}