Amaranth(Wehago) ERP REST API 연계 + 배치 시스템 강화
부팅 시 자동 시드: - 외부 REST API 연결 6종 (부서/사원/거래처/창고/계정과목/Wehago 사용자) - 매칭 배치 6개 + Wehago HMAC-SHA256 서명 자동 부착 (erpApiClient/erpPresetSeedService/erpBatchSeedService) - 동기화 대상 테이블/컬럼 보장 idempotent 마이그레이션 (erpTableMigration) 배치 기능 확장: - 조건부 매핑 (mapping_type='conditional') — when/then/default 규칙으로 값 변환 (예: enrlFg=J01→active) - 행 단위 제외 필터 (row_filter_config) — 특정 API 필드 값 가진 행 동기화 제외 (예: loginId=wace 통합 ERP 계정 제외) - save_mode/conflict_key 기반 UPSERT, data_array_path 응답 배열 추출 UI 정비: - 배치/플로우/메일/REST API 목록에 페이징 + FullHD 컴팩트 레이아웃 - 배치 편집 화면 한 화면 풀 활용 — TO 패널 가로 그리드, FROM 패널 등록 연결 한 줄 요약, 응답/JSON/파라미터 details 접힘 - ResponsiveDataView/AdminPageRenderer 쿼리 파라미터(?edit=N) 파싱 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ export function AuthenticationConfig({
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||
<SelectItem value="wehago">Wehago / Amaranth (아마란스)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -281,6 +282,78 @@ export function AuthenticationConfig({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "wehago" && (
|
||||
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Wehago / Amaranth (아마란스) 설정</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onAuthConfigChange({
|
||||
callerName: "API_gcmsAmaranth40578",
|
||||
accessToken: "MN5KzKBWRAa92BPxDlRLl3GcsxeZXc",
|
||||
hashKey: "22519103205540290721741689643674301018832465",
|
||||
groupSeq: "gcmsAmaranth40578",
|
||||
})
|
||||
}
|
||||
className="text-xs text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
RPS Amaranth 기본값 채우기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-caller">callerName</Label>
|
||||
<Input
|
||||
id="wehago-caller"
|
||||
type="text"
|
||||
value={authConfig.callerName || ""}
|
||||
onChange={(e) => updateAuthConfig("callerName", e.target.value)}
|
||||
placeholder="예: API_gcmsAmaranth40578"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-token">accessToken</Label>
|
||||
<Input
|
||||
id="wehago-token"
|
||||
type="password"
|
||||
value={authConfig.accessToken || ""}
|
||||
onChange={(e) => updateAuthConfig("accessToken", e.target.value)}
|
||||
placeholder="Bearer 토큰으로 사용됩니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-hash-key">hashKey</Label>
|
||||
<Input
|
||||
id="wehago-hash-key"
|
||||
type="password"
|
||||
value={authConfig.hashKey || ""}
|
||||
onChange={(e) => updateAuthConfig("hashKey", e.target.value)}
|
||||
placeholder="HMAC-SHA256 서명 키"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehago-group-seq">groupSeq</Label>
|
||||
<Input
|
||||
id="wehago-group-seq"
|
||||
type="text"
|
||||
value={authConfig.groupSeq || ""}
|
||||
onChange={(e) => updateAuthConfig("groupSeq", e.target.value)}
|
||||
placeholder="예: gcmsAmaranth40578"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* 매 요청마다 32자리 transaction-id, Unix timestamp, wehago-sign(HMAC-SHA256(accessToken+transactionId+timestamp+urlPath, hashKey) → Base64)을 자동 생성합니다.
|
||||
<br />
|
||||
* Wehago/RPS ERP API와 호환되는 인증 헤더(callerName, Authorization, transaction-id, timestamp, groupSeq, wehago-sign)가 자동 부착됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "none" && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
인증이 필요하지 않은 공개 API입니다.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ExternalRestApiConnectionFilter,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
import { RestApiConnectionModal } from "./RestApiConnectionModal";
|
||||
import { Pagination } from "@/components/common/Pagination";
|
||||
|
||||
// 인증 타입 라벨
|
||||
const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -34,6 +35,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
basic: "Basic Auth",
|
||||
oauth2: "OAuth 2.0",
|
||||
"db-token": "DB 토큰",
|
||||
wehago: "Wehago/Amaranth",
|
||||
};
|
||||
|
||||
// 활성 상태 옵션
|
||||
@@ -60,6 +62,10 @@ export function RestApiConnectionList() {
|
||||
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
|
||||
const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map());
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
// 데이터 로딩
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
@@ -101,6 +107,19 @@ export function RestApiConnectionList() {
|
||||
loadConnections();
|
||||
}, [searchTerm, authTypeFilter, activeStatusFilter]);
|
||||
|
||||
// 필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, authTypeFilter, activeStatusFilter]);
|
||||
|
||||
// 페이지네이션 계산
|
||||
const totalItems = connections.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const startIdx = (safePage - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
|
||||
const pagedConnections = connections.slice(startIdx, endIdx);
|
||||
|
||||
// 새 연결 추가
|
||||
const handleAddConnection = () => {
|
||||
setEditingConnection(undefined);
|
||||
@@ -217,24 +236,24 @@ export function RestApiConnectionList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 검색 및 필터 — 컴팩트 */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* 검색 */}
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="연결명 또는 URL로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입 필터 */}
|
||||
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[160px]">
|
||||
<SelectValue placeholder="인증 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -248,7 +267,7 @@ export function RestApiConnectionList() {
|
||||
|
||||
{/* 활성 상태 필터 */}
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -259,11 +278,21 @@ export function RestApiConnectionList() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 총 건수 표시 */}
|
||||
<div className="text-muted-foreground hidden whitespace-nowrap text-xs sm:block">
|
||||
총 <span className="text-foreground font-semibold">{totalItems}</span> 건
|
||||
{totalPages > 1 && (
|
||||
<span className="ml-1.5">
|
||||
({safePage} / {totalPages} 페이지)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 연결 추가
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1.5 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -279,62 +308,62 @@ export function RestApiConnectionList() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card">
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">헤더 수</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">회사</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">헤더</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">상태</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-right text-xs font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connections.map((connection) => (
|
||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
{pagedConnections.map((connection) => (
|
||||
<TableRow key={connection.id} className="transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<div className="max-w-[200px]">
|
||||
<div className="truncate font-medium" title={connection.connection_name}>
|
||||
{connection.connection_name}
|
||||
</div>
|
||||
{connection.description && (
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}>
|
||||
<div className="text-muted-foreground truncate text-[10px]" title={connection.description}>
|
||||
{connection.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
{(connection as any).company_name || connection.company_code}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
||||
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
||||
<TableCell className="h-11 px-3 py-1.5 font-mono text-xs">
|
||||
<div className="max-w-[260px] truncate" title={connection.base_url}>
|
||||
{connection.base_url}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-center text-sm">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-center text-xs">
|
||||
{Object.keys(connection.default_headers || {}).length}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
{connection.last_test_date ? (
|
||||
<div>
|
||||
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px]">{new Date(connection.last_test_date).toLocaleDateString()}</span>
|
||||
<Badge
|
||||
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
|
||||
className="mt-1"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{connection.last_test_result === "Y" ? "성공" : "실패"}
|
||||
</Badge>
|
||||
@@ -343,41 +372,41 @@ export function RestApiConnectionList() {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(connection)}
|
||||
disabled={testingConnections.has(connection.id!)}
|
||||
className="h-9 text-sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(connection.id!) && (
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<TableCell className="h-11 px-3 py-1.5 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditConnection(connection)}
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConnection(connection)}
|
||||
className="text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -388,6 +417,29 @@ export function RestApiConnectionList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 — 0건이어도 페이지당 선택 UI는 보이도록 항상 렌더 */}
|
||||
{!loading && (
|
||||
<div className="rounded-lg border bg-card p-3 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연결 설정 모달 */}
|
||||
{isModalOpen && (
|
||||
<RestApiConnectionModal
|
||||
@@ -424,6 +476,6 @@ export function RestApiConnectionList() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||
setIsActive(connection.is_active === "Y");
|
||||
setSaveToHistory(connection.save_to_history === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
// 테스트 초기값 설정 — 저장된 endpoint_path/default_body 를 그대로 채움 (비워두면 베이스URL 직접 호출 → 405)
|
||||
setTestEndpoint(connection.endpoint_path || "");
|
||||
setTestMethod(connection.default_method || "GET");
|
||||
setTestBody(connection.default_body || "");
|
||||
} else {
|
||||
@@ -129,14 +129,15 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
// 사용자가 테스트하려는 실제 외부 API URL 설정
|
||||
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl;
|
||||
// 테스트 엔드포인트 비어있으면 저장된 endpoint_path 사용 (비워두면 베이스URL 직접 호출 → 405 가능)
|
||||
const effectiveEndpoint = testEndpoint || endpointPath || "";
|
||||
const fullUrl = effectiveEndpoint ? `${baseUrl}${effectiveEndpoint}` : baseUrl;
|
||||
setTestRequestUrl(fullUrl);
|
||||
|
||||
try {
|
||||
const testRequest: RestApiTestRequest = {
|
||||
base_url: baseUrl,
|
||||
endpoint: testEndpoint || undefined,
|
||||
endpoint: effectiveEndpoint || undefined,
|
||||
method: testMethod as any,
|
||||
headers: defaultHeaders,
|
||||
body: testBody ? JSON.parse(testBody) : undefined,
|
||||
@@ -583,6 +584,77 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||
)}
|
||||
|
||||
{testResult.error_details && <p className="mt-2 text-xs text-destructive">{testResult.error_details}</p>}
|
||||
|
||||
{/* 응답 본문 표시 — 배치 매핑 시 필드 확인용 */}
|
||||
{testResult.response_data !== undefined && testResult.response_data !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-xs font-medium">응답 데이터 (Response Body)</div>
|
||||
{(() => {
|
||||
// 응답에서 배열을 자동 탐색해서 필드 수 표시
|
||||
const data = testResult.response_data as any;
|
||||
const findArray = (o: any, depth = 0): any[] | null => {
|
||||
if (Array.isArray(o)) return o;
|
||||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||||
for (const v of Object.values(o)) {
|
||||
const arr = findArray(v, depth + 1);
|
||||
if (arr) return arr;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const arr = findArray(data);
|
||||
const fields =
|
||||
arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null
|
||||
? Object.keys(arr[0])
|
||||
: data && typeof data === "object" && !Array.isArray(data)
|
||||
? Object.keys(data)
|
||||
: [];
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{arr ? `배열 ${arr.length}건 / ` : ""}필드 {fields.length}개
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<pre className="max-h-[320px] overflow-auto rounded bg-background/60 p-2 text-[11px] leading-relaxed font-mono">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(testResult.response_data, null, 2);
|
||||
} catch {
|
||||
return String(testResult.response_data);
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
{/* 필드 미리보기 (배열의 첫 항목 기준) */}
|
||||
{(() => {
|
||||
const data = testResult.response_data as any;
|
||||
const findArray = (o: any, depth = 0): any[] | null => {
|
||||
if (Array.isArray(o)) return o;
|
||||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||||
for (const v of Object.values(o)) {
|
||||
const arr = findArray(v, depth + 1);
|
||||
if (arr) return arr;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const arr = findArray(data);
|
||||
if (!arr || arr.length === 0 || typeof arr[0] !== "object") return null;
|
||||
const fields = Object.keys(arr[0]);
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">매핑 가능 필드 (DB 컬럼 매핑에 사용)</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fields.map((f) => (
|
||||
<Badge key={f} variant="outline" className="font-mono text-[10px]">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ResponsiveDataView<T>({
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
@@ -122,7 +122,7 @@ export function ResponsiveDataView<T>({
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
@@ -133,15 +133,15 @@ export function ResponsiveDataView<T>({
|
||||
{Array.from({ length: skeletonCount }).map((_, rowIdx) => (
|
||||
<TableRow key={rowIdx} className="border-b">
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} className="h-16">
|
||||
<TableCell key={col.key} className="h-11">
|
||||
<div className="h-4 animate-pulse rounded bg-muted" />
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||
<TableCell className="h-11">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
|
||||
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -217,7 +217,7 @@ export function ResponsiveDataView<T>({
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
@@ -225,7 +225,7 @@ export function ResponsiveDataView<T>({
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
className="h-9 px-3 text-xs font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
@@ -245,7 +245,7 @@ export function ResponsiveDataView<T>({
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn("h-16 text-sm", col.className)}
|
||||
className={cn("h-11 px-3 py-1.5 text-xs", col.className)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
@@ -253,8 +253,8 @@ export function ResponsiveDataView<T>({
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16 text-sm">
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
<TableCell className="h-11 px-3 py-1.5 text-xs">
|
||||
<div className="flex gap-1.5">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
@@ -357,12 +357,25 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
const companyCode = user?.companyCode || user?.company_code;
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
|
||||
// 쿼리스트링 파싱 → adminParams 으로 자식에게 전달 (편집 모드 ?edit=N 등)
|
||||
const queryParams = useMemo(() => {
|
||||
const params: Record<string, string> = {};
|
||||
const queryStr = url.split("?")[1]?.split("#")[0];
|
||||
if (queryStr) {
|
||||
queryStr.split("&").forEach((kv) => {
|
||||
const [k, v = ""] = kv.split("=");
|
||||
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v);
|
||||
});
|
||||
}
|
||||
return params;
|
||||
}, [url]);
|
||||
|
||||
// 회사별 커스텀 페이지: companyCode를 prefix로 붙여 경로 변환
|
||||
const resolvedUrl = (companyCode && isCompanyPage(cleanUrl))
|
||||
? `/${companyCode}${cleanUrl}`
|
||||
: cleanUrl;
|
||||
|
||||
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode });
|
||||
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode, queryParams });
|
||||
|
||||
// 화면 할당: /screens/[id]
|
||||
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
|
||||
@@ -392,7 +405,12 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
|
||||
if (PageComponent) {
|
||||
console.log("[AdminPageRenderer] → 레지스트리 매칭:", resolvedUrl || cleanUrl);
|
||||
return <PageComponent />;
|
||||
// 쿼리 파라미터가 있으면 adminParams 로 전달 (편집 모드 ?edit=N 등)
|
||||
const PC = PageComponent as any;
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
return <PC adminParams={queryParams} />;
|
||||
}
|
||||
return <PC />;
|
||||
}
|
||||
|
||||
// 레지스트리에 없으면 동적 import 시도
|
||||
@@ -400,7 +418,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) {
|
||||
const match = cleanUrl.match(pattern);
|
||||
if (match) {
|
||||
const params = extractParams(match);
|
||||
const params = { ...extractParams(match), ...queryParams };
|
||||
console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params);
|
||||
return <DynamicAdminLoader url={cleanUrl} params={params} />;
|
||||
}
|
||||
@@ -408,5 +426,10 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
|
||||
// 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도
|
||||
console.log("[AdminPageRenderer] → 자동 import 시도:", resolvedUrl);
|
||||
return <DynamicAdminLoader url={resolvedUrl} />;
|
||||
return (
|
||||
<DynamicAdminLoader
|
||||
url={resolvedUrl}
|
||||
params={Object.keys(queryParams).length > 0 ? queryParams : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user