import inflection from "inflection"; import { flat } from "radashi"; import { z } from "zod"; import { EntityManager } from "../../entity/entity-manager"; import { type EntityNamesRecord } from "../../entity/entity-manager"; import { type RenderingNode, type TemplateOptions } from "../../types/types"; import { getEnumInfoFromColName, getRelationNameFromColumnName, getRelationPropFromColName, } from "../helpers"; import { type RenderedTemplate } from "../template"; import { Template } from "../template"; export class Template__view_list extends Template { constructor() { super("view_list"); } getTargetAndPath(names: EntityNamesRecord) { return { target: "web/src/routes/admin", path: `${names.fsPlural}/index.tsx`, }; } override getRequiredDictKeys(): string[] | null { return ["entity.listManage", "common.all"]; } wrapTc(body: string, key: string, collapsing: boolean = true, className: string = "") { return `${body}`; } renderColumn( entityId: string, col: RenderingNode, names: EntityNamesRecord, parentObj: string = "row", withoutName: boolean = false, ): string { // 중첩 경로 처리 (예: "user.name" -> "row.user?.name") let colName: string; if (withoutName) { colName = parentObj; } else if (col.name.includes(".")) { // 중첩 경로는 optional chaining으로 변환 const parts = col.name.split("."); colName = `${parentObj}.${parts.join("?.")}`; } else { colName = `${parentObj}.${col.name}`; } switch (col.renderType) { case "string-plain": case "string-date": case "number-id": case "string-id": return `<>{${colName}}`; case "number-fk_id": case "string-fk_id": { try { const baseName = col.name.includes(".") ? (col.name.split(".").pop() ?? col.name).replace("_id", "") : col.name.replace("_id", ""); const relPropFk = getRelationPropFromColName(entityId, baseName); return `<>${relPropFk.with}#{${colName}}`; } catch { return `<>{${colName}}`; } } case "datetime": if (col.nullable || col.name.includes(".")) { return `{${colName} ? datetimeF(${colName}) : '-'}`; } else { return `{datetimeF(${colName})}`; } case "string-datetime": if (col.nullable || col.name.includes(".")) { return `{${colName} ? dateF(${colName}) : '-'}`; } else { return `{dateF(${colName})}`; } case "boolean": return `<>{${colName} ? O : X}`; case "enums": { try { const { id: enumId } = getEnumInfoFromColName(entityId, col.name); return `<>{${col.nullable ? `${colName} && ` : ""}${enumId}Label[${colName}]}`; } catch { return `<>{${colName}}`; } } case "array-images": return `
{ ${colName}?.map((r, i) => ${ col.nullable ? `r && ` : "" }{\`${col.label) }
`; case "json-sonamufile": return `
{${colName} ? {${colName}.name} : '-'}
`; case "json-sonamufile-array": return `
{ ${colName}?.map((r, i) => ${ col.nullable ? `r && ` : "" }{\`${col.label) }
`; case "number-plain": return `<>{${col.nullable || col.name.includes(".") ? `${colName} && ` : ""}numF(${colName})}`; case "object": return `{${col.nullable ? `${colName} ? ` : ""}JSON.stringify(${colName})${col.nullable ? ` : '-'` : ""}}`; case "object-pick": { const pickedChild = col.children?.find((child) => child.name === col.config?.picked); if (!pickedChild) { throw new Error(`object-pick 선택 실패 (오브젝트: ${col.name})`); } return this.renderColumn( entityId, pickedChild, names, `${colName}${col.nullable ? "?" : ""}`, ); } case "array": return `<>{ /* array ${colName} */ }`; case "vector": // vector 타입은 차원 수만 표시 (실제 데이터는 너무 김) return `<>{${col.nullable ? `${colName} ? ` : ""}[Vector: {${colName}${col.nullable ? "" : " ?? []"}.length}d]${col.nullable ? " : '-'" : ""}}`; default: throw new Error(`렌더 불가 컬럼 ${col.renderType}`); } } renderColumnImport( entityId: string, col: RenderingNode, names: EntityNamesRecord, ): (string | null)[] { if (col.renderType === "enums") { const { id: enumId } = getEnumInfoFromColName(names.capital, col.name); return [`import { ${enumId}Label } from '@/services/sonamu.generated';`]; } else if (col.renderType === "object") { try { const relProp = getRelationPropFromColName(entityId, col.name); const result = (col.children ?? []).map((child) => { entityId = relProp.with; names = EntityManager.getNamesFromId(relProp.with); return this.renderColumnImport(entityId, child, names); }); return flat(result); } catch { return [null]; } } else if (col.renderType === "array") { if (!col.element) return [null]; return this.renderColumnImport(entityId, col.element, names); } return [null]; } renderFilterImport(entityId: string, col: RenderingNode, names: EntityNamesRecord) { if (col.name === "search") { return `import { ${names.capital}SearchInput } from "@/components/${names.fs}/${names.capital}SearchInput";`; } else if (col.renderType === "enums") { if (col.name === "orderBy") { const componentId = `${names.capital}${inflection.camelize(col.name)}Select`; return `import { ${componentId} } from "@/components/${names.fs}/${componentId}";`; } else { try { const { id, targetEntityNames: targetMDNames } = getEnumInfoFromColName( entityId, col.name, ); const componentId = `${id}Select`; return `import { ${componentId} } from "@/components/${targetMDNames.fs}/${componentId}";`; } catch { return ""; } } } else if (col.renderType === "number-fk_id" || col.renderType === "string-fk_id") { try { const relProp = getRelationPropFromColName(entityId, col.name.replace("_id", "")); const targetNames = EntityManager.getNamesFromId(relProp.with); const componentId = `${relProp.with}IdAsyncSelect`; return `import { ${componentId} } from "@/components/${targetNames.fs}/${componentId}";`; } catch { return ""; } } else { throw new Error(`렌더 불가능한 필터 임포트 ${col.name} ${col.renderType}`); } } renderFilter(entityId: string, col: RenderingNode, names: EntityNamesRecord) { if (col.name === "search") { return ""; } const isClearable = col.optional === true && col.name !== "orderBy"; let componentId: string; if (col.renderType === "enums") { if (col.name === "orderBy") { componentId = `${names.capital}${inflection.camelize(col.name)}Select`; } else { try { const { id } = getEnumInfoFromColName(entityId, col.name); componentId = `${id}Select`; } catch { return ""; } } return `<${componentId} {...register('${col.name}')} ${isClearable ? "clearable" : ""} />`; } else if (col.renderType === "number-fk_id" || col.renderType === "string-fk_id") { try { const relProp = getRelationPropFromColName(entityId, col.name.replace("_id", "")); componentId = `${relProp.with}IdAsyncSelect`; return `<${componentId} {...register('${col.name}')} ${ isClearable ? "clearable" : "" } subset="A" />`; } catch { return ""; } } else { throw new Error(`렌더 불가능한 필터 임포트 ${col.name} ${col.renderType}`); } } getDefault(columns: RenderingNode[]): { orderBy: string; search: string; hasSearch: boolean; hasOrderBy: boolean; } { const def = { orderBy: "", search: "", hasSearch: false, hasOrderBy: false, }; const orderByZodType = columns.find((col) => col.name === "orderBy")?.zodType; if (orderByZodType && orderByZodType instanceof z.ZodEnum) { def.orderBy = Object.keys(orderByZodType.enum)[0]; def.hasOrderBy = true; } const searchZodType = columns.find((col) => col.name === "search")?.zodType; if (searchZodType && searchZodType instanceof z.ZodEnum) { def.search = Object.keys(searchZodType.enum)[0]; def.hasSearch = true; } return def; } async render({ entityId }: TemplateOptions["view_list"]) { const { getColumnsNode } = await import("../entity-converter"); const { getZodTypeById, zodTypeToRenderingNode } = await import("../zod-converter"); const columnsNode = await getColumnsNode(entityId, "A"); const listParamsZodType = await getZodTypeById(`${entityId}ListParams`); const listParamsNode = zodTypeToRenderingNode(listParamsZodType); const names = EntityManager.getNamesFromId(entityId); const entity = EntityManager.get(entityId); // PK 타입 감지 const pkType = entity.getPkType(); const idTsType = pkType === "string" || pkType === "uuid" ? "string" : "number"; // 실제 리스트 컬럼 const columns = (columnsNode.children as RenderingNode[]) .toSorted((a, b) => (a.name === "id" ? -1 : b.name === "id" ? 1 : 0)) .map((col) => { const rendered = this.renderColumn(entityId, col, names); // 라벨 생성: common 필드(created_at)는 SD("common.{field}"), entity 필드는 SD("entity.{Entity}.{field}") let label: string; if (col.name === "id") { label = '"ID"'; } else if (["created_at"].includes(col.name)) { // camelCase로 변환 (created_at -> createdAt) const camelName = col.name.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); label = `SD("common.${camelName}")`; } else { const labelName = getRelationNameFromColumnName(entityId, col.name); label = `SD("entity.${names.capital}.${labelName}")`; } return { name: col.name, label, tc: `(row) => ${rendered}`, fit: col.name === "id" || col.renderType === "number-id" || col.renderType === "datetime" || col.renderType === "string-datetime", align: col.name === "id" || col.renderType === "number-id" ? "center" : undefined, }; }); // 필터 컬럼 const filterColumns = (listParamsNode.children as RenderingNode[]) .filter( (col) => col.name !== "id" && col.name !== "queryMode" && ["enums", "number-id", "number-fk_id", "string-fk_id"].includes(col.renderType), ) // orderBy가 가장 뒤로 오게 순서 조정 .toSorted((a) => { return a.name === "orderBy" ? 1 : -1; }); // 필터 컬럼을 프리 템플릿으로 설정 const preTemplates: RenderedTemplate["preTemplates"] = []; // 컬럼에서 사용하는 enum들 수집 const columnEnums: string[] = []; (columnsNode.children ?? []).forEach((col) => { if (col.renderType === "enums") { try { const { id: enumId } = getEnumInfoFromColName(entityId, col.name); columnEnums.push(enumId); } catch {} } }); // 디폴트 파라미터 // const def = this.getDefault(filterColumns); return { ...this.getTargetAndPath(names), body: ` import { useState, Fragment } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Card, CardContent, CardHeader } from "@sonamu-kit/react-components/components"; import { Badge } from "@sonamu-kit/react-components/components"; import { Button } from "@sonamu-kit/react-components/components"; import { Pagination, Table, TableBody, TableCell, type TableCol, TableHead, TableHeader, TableRow } from "@sonamu-kit/react-components/components"; import { EnumSelect } from "@sonamu-kit/react-components/components"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@sonamu-kit/react-components/components"; import { Input } from "@sonamu-kit/react-components/components"; import { Checkbox } from "@sonamu-kit/react-components/components"; import { SonamuFilterModal, SonamuFilterPopover, extractFieldMetaFromSchema, type Rule } from "@sonamu-kit/react-components/components"; import { useListParams, numF, dateF, datetimeF } from "@sonamu-kit/react-components/lib"; import { ${names.capital}SubsetA, ${names.capital}BaseSchema } from "@/services/sonamu.generated"; import { ${names.capital}Service } from "@/services/services.generated"; import { ${names.capital}ListParams } from "@/services/${names.fs}/${names.fs}.types"; import { ${(() => { // 기본 enum 수집 (filterColumns에 있는 것만) const baseEnums: string[] = []; if (filterColumns.some((col) => col.name === "orderBy")) { baseEnums.push(`${names.capital}OrderBy`, `${names.capital}OrderByLabel`); } if (filterColumns.some((col) => col.name === "search")) { baseEnums.push(`${names.capital}SearchField`, `${names.capital}SearchFieldLabel`); } // 필터 enum 수집 (config.enumId 우선, 없으면 getEnumInfoFromColName) const filterEnumIds = filterColumns .filter( (col) => col.renderType === "enums" && col.name !== "search" && col.name !== "orderBy", ) .map((col) => { if (col.config && "enumId" in col.config) { return (col.config as { enumId: string }).enumId; } try { const { id: enumId } = getEnumInfoFromColName(entityId, col.name); return enumId; } catch { return null; } }) .filter(Boolean) as string[]; // 모든 enum 합치고 중복 제거 const allEnums = [...new Set([...filterEnumIds, ...columnEnums])]; const enumImports = allEnums.flatMap((enumId) => [`${enumId}`, `${enumId}Label`]); return [...baseEnums, ...enumImports].join(", "); })()} } from "@/services/sonamu.generated"; import { IdAsyncSelect } from "@sonamu-kit/react-components/components"; ${(() => { // FK 필드의 AsyncIdConfig import const fkColumns = filterColumns.filter( (col) => col.renderType === "number-fk_id" || col.renderType === "string-fk_id", ); const configNames = fkColumns .map((col) => { try { const relProp = getRelationPropFromColName(entityId, col.name.replace("_id", "")); return `${relProp.with}AsyncIdConfig`; } catch { return ""; } }) .filter(Boolean); return configNames.length > 0 ? `import { ${configNames.join(", ")} } from "@/services/services.generated";` : ""; })()} import EditIcon from "~icons/lucide/square-pen"; import TrashIcon from "~icons/lucide/trash-2"; import FilterIcon from "~icons/mdi/filter-variant"; import ListIcon from "~icons/mdi/format-list-bulleted"; import SearchIcon from "~icons/mdi/magnify"; import { SD } from "@/i18n/sd.generated"; export const Route = createFileRoute("/admin/${names.fsPlural}/")({\n head: () => ({\n meta: [\n { title: "${entity.title ?? names.capital} List" },\n { name: "description", content: SD("entity.listManage")("${entity.title ?? names.capital}") },\n ],\n }),\n component: ${names.capital}List,\n});\n\ntype ${names.capital}ListProps = {}; function ${names.capital}List({}: ${names.capital}ListProps) { const navigate = useNavigate(); // 상태 관리 const [selectedItems, setSelectedItems] = useState>(new Set()); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState<{ id: ${idTsType}; name?: string } | null>(null); const [filterModalOpen, setFilterModalOpen] = useState(false); const [appliedRules, setAppliedRules] = useState([]); // 리스트 필터 const { listParams, register, setListParams } = useListParams(${names.capital}ListParams, { num: 10, page: 1, keyword: "",${ filterColumns.some((col) => col.name === "search") ? ` search: ${names.capital}SearchField.options[0],` : "" }${ filterColumns.some((col) => col.name === "orderBy") ? ` orderBy: ${names.capital}OrderBy.options[0],` : "" } sonamuFilter: {}, }); // 리스트 쿼리 const { data, refetch, isLoading } = ${names.capital}Service.use${names.capitalPlural}("A", listParams); const { rows, total } = data ?? {}; // 현재 경로와 타이틀 const PAGE = { route: "/admin/${names.fsPlural}", title: SD("entity.list")(SD("entity.${names.capital}")), }; // 컬럼 정의 type ${names.capital}Row = NonNullable[number]; const columns: TableCol<${names.capital}Row>[] = [ ${columns .map( (col) => ` { label: ${col.label}, tc: ${col.tc},${ col.fit ? ` fit: true,` : "" }${ col.align ? ` align: "${col.align}",` : "" } }`, ) .join(",\n")}, { label: SD("common.manage"), fit: true, align: "center", tc: (row) => (
), }, ]; // 선택 핸들러 const handleToggleItem = (id: ${idTsType}) => { const newSelection = new Set(selectedItems); if (newSelection.has(id)) { newSelection.delete(id); } else { newSelection.add(id); } setSelectedItems(newSelection); }; const isAllSelected = () => { return (rows?.length ?? 0) > 0 && rows!.every((row) => selectedItems.has(row.id)); }; const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedItems(new Set(rows?.map((row) => row.id) ?? [])); } else { setSelectedItems(new Set()); } }; // 삭제 핸들러 const handleDeleteClick = (id: ${idTsType}, name?: string) => { setItemToDelete({ id, name }); setDeleteDialogOpen(true); }; const handleConfirmDelete = () => { if (itemToDelete) { ${names.capital}Service.del([itemToDelete.id]).then(() => { refetch(); }); } setDeleteDialogOpen(false); setItemToDelete(null); }; return (
{/* Header */}
{PAGE.title}
{/* Filters */}
${ filterColumns.some((col) => col.name === "search") ? ` ` : "" }
string, )} >
${filterColumns .filter((col) => col.name !== "search" && col.name !== "orderBy") .map((col) => { if (col.renderType === "enums") { try { // config.enumId가 있으면 우선 사용, 없으면 getEnumInfoFromColName 시도 const enumId = col.config && "enumId" in col.config ? (col.config as { enumId: string }).enumId : getEnumInfoFromColName(entityId, col.name).id; return ` `; } catch { return ""; } } // FK 필드 (IdAsyncSelect) if (col.renderType === "number-fk_id" || col.renderType === "string-fk_id") { try { const relProp = getRelationPropFromColName(entityId, col.name.replace("_id", "")); return ` `; } catch { return ""; } } return ""; }) .filter(Boolean) .join("\n")} ${ filterColumns.some((col) => col.name === "orderBy") ? ` ` : "" } {SD("common.results")(total ?? 0)}
{/* Table */} {columns.map((col, idx) => ( {col.label} ))} {!isLoading && rows && rows.map((row) => ( handleToggleItem(row.id)} /> {columns.map((col, idx) => ( {col.tc(row)} ))} ))}
{/* Pagination */}
{/* Delete Dialog */} {SD("delete.confirm.title")} {SD("delete.confirm.description")} {SD("common.cancel")} {SD("common.delete")} {/* Sonamu Filter Modal */} { setListParams({ ...listParams, sonamuFilter: filters, page: 1 }); setAppliedRules(rules); }} />
); } `.trim(), importKeys: [], preTemplates, }; } }