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:
chpark
2026-05-07 09:48:28 +09:00
parent 36e232ba00
commit 97b333dd2e
30 changed files with 4452 additions and 799 deletions
@@ -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}
/>
);
}