feat: /dev/clinics 페이지 + DevOnly 라우트 가드
GET /api/clinics 응답을 표로 보는 dev 전용 페이지 추가. /dev/* 경로 전체를 DevOnly layout route 로 감싸 로컬호스트 (localhost/127.0.0.1/0.0.0.0/::1) 외 도메인에선 자동으로 루트로 리다이렉트. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>main
parent
e0610df826
commit
670535c112
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* DevOnly — `/dev/*` 라우트 가드.
|
||||||
|
*
|
||||||
|
* `window.location.hostname` 이 로컬호스트 계열이 아닐 경우 루트로 리다이렉트.
|
||||||
|
* 클라이언트 사이드 가드라 보안 의미보다는 "운영 도메인에서 실수로 노출되는 것 방지" 용도.
|
||||||
|
* 진짜 차단이 필요하면 서버/CDN 레벨에서 경로를 막아야 한다.
|
||||||
|
*/
|
||||||
|
import { Navigate, Outlet } from 'react-router';
|
||||||
|
|
||||||
|
const LOCAL_HOSTNAMES = new Set([
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'0.0.0.0',
|
||||||
|
'::1',
|
||||||
|
'[::1]',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isLocalHost(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return LOCAL_HOSTNAMES.has(window.location.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevOnly() {
|
||||||
|
if (!isLocalHost()) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
/**
|
||||||
|
* Dev: 클리닉 리스트 페이지.
|
||||||
|
*
|
||||||
|
* 백엔드 `GET /api/clinics` (listClinics) 응답을 표로 확인하는 개발용 화면.
|
||||||
|
* 운영 도메인 노출 방지는 라우트 단의 DevOnly 가드에 위임.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useListClinics } from '@/shared/api/generated/clinics/clinics';
|
||||||
|
import { PageContainer } from '@/shared/ui/page-container';
|
||||||
|
import { Spinner } from '@/shared/ui/spinner';
|
||||||
|
import { EmptyState } from '@/shared/ui/empty-state';
|
||||||
|
import { Button } from '@/shared/ui/button';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function formatDate(raw: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(raw).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClinicsPage() {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch, isFetching } = useListClinics(
|
||||||
|
{ limit: PAGE_SIZE, offset },
|
||||||
|
{ query: { staleTime: 0 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = data?.status === 200 ? data.data : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-20 pb-16">
|
||||||
|
<PageContainer>
|
||||||
|
<header className="flex items-end justify-between mb-6 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-[#6C5CE7] tracking-widest uppercase mb-2">
|
||||||
|
Dev · Clinics
|
||||||
|
</p>
|
||||||
|
<h1 className="font-serif text-3xl font-bold text-[#0A1128]">
|
||||||
|
클리닉 리스트
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
GET /api/clinics · limit={PAGE_SIZE} · offset={offset}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{isFetching ? <Spinner size="xs" /> : null}
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Spinner size="lg" className="text-[#6C5CE7]" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
클리닉 리스트 조회 실패: {String((error as Error)?.message ?? error)}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState size="lg" hint="등록된 클리닉이 없습니다." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-600">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">병원명</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">EN</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">URL</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">주소</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">생성일</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium font-mono text-xs">hospital_id</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{items.map((c) => (
|
||||||
|
<tr key={c.hospital_id} className="hover:bg-slate-50/50">
|
||||||
|
<td className="px-4 py-3 font-medium text-[#0A1128]">{c.hospital_name}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">{c.hospital_name_en ?? '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-700">
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600 max-w-[240px] truncate">
|
||||||
|
{c.url ? (
|
||||||
|
<a
|
||||||
|
href={c.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[#6C5CE7] hover:underline"
|
||||||
|
>
|
||||||
|
{c.url}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600 max-w-[260px] truncate">{c.road_address ?? '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDate(c.created_at)}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-slate-400">{c.hospital_id}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 — 정확한 total 이 없어서 단순 prev/next 만 노출 */}
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
|
||||||
|
disabled={offset === 0 || isFetching}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOffset((o) => o + PAGE_SIZE)}
|
||||||
|
disabled={items.length < PAGE_SIZE || isFetching}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { lazy } from 'react'
|
import { lazy } from 'react'
|
||||||
|
import DevOnly from './components/DevOnly'
|
||||||
|
|
||||||
const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
|
const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
|
||||||
|
const ClinicsPage = lazy(() => import('./pages/ClinicsPage'))
|
||||||
|
|
||||||
|
// `/dev/*` 는 DevOnly 가드를 거쳐 로컬호스트에서만 접근 가능.
|
||||||
export const devRoutes = [
|
export const devRoutes = [
|
||||||
{ path: 'dev/components', element: <ComponentsPage /> },
|
{
|
||||||
|
element: <DevOnly />,
|
||||||
|
children: [
|
||||||
|
{ path: 'dev/components', element: <ComponentsPage /> },
|
||||||
|
{ path: 'dev/clinics', element: <ClinicsPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue