Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
a2e58c3848
|
|
@ -278,4 +278,117 @@ const hiddenColumns = new Set([
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11. 화면관리 시스템 위젯 개발 가이드
|
||||||
|
|
||||||
|
### 위젯 크기 설정의 핵심 원칙
|
||||||
|
|
||||||
|
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
|
||||||
|
|
||||||
|
#### ✅ 올바른 크기 설정 패턴
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 위젯 컴포넌트 내부
|
||||||
|
export function YourWidget({ component }: YourWidgetProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full w-full items-center justify-between gap-2"
|
||||||
|
style={{
|
||||||
|
padding: component.style?.padding || "0.75rem",
|
||||||
|
backgroundColor: component.style?.backgroundColor,
|
||||||
|
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 위젯 내용 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ❌ 잘못된 크기 설정 패턴
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 이렇게 하면 안 됩니다!
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
|
||||||
|
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
|
||||||
|
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이유
|
||||||
|
|
||||||
|
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const baseStyle = {
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
width: getWidth(), // size.width 사용
|
||||||
|
height: getHeight(), // size.height 사용
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 위젯 내부에서 크기를 다시 설정하면:
|
||||||
|
- 중복 설정으로 인한 충돌
|
||||||
|
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
|
||||||
|
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
|
||||||
|
|
||||||
|
### 위젯이 관리해야 할 스타일
|
||||||
|
|
||||||
|
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
|
||||||
|
|
||||||
|
- ✅ `padding`: 내부 여백
|
||||||
|
- ✅ `backgroundColor`: 배경색
|
||||||
|
- ✅ `border`, `borderRadius`: 테두리
|
||||||
|
- ✅ `gap`: 자식 요소 간격
|
||||||
|
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
|
||||||
|
|
||||||
|
### 위젯 등록 시 defaultSize
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
ComponentRegistry.registerComponent({
|
||||||
|
id: "your-widget",
|
||||||
|
name: "위젯 이름",
|
||||||
|
category: "utility",
|
||||||
|
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
|
||||||
|
component: YourWidget,
|
||||||
|
defaultProps: {
|
||||||
|
style: {
|
||||||
|
padding: "0.75rem",
|
||||||
|
// width, height는 defaultSize로 제어되므로 여기 불필요
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 레이아웃 구조
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 전체 높이를 차지하고 내부 요소를 정렬
|
||||||
|
<div className="flex h-full w-full items-center justify-between gap-2">
|
||||||
|
{/* 왼쪽 컨텐츠 */}
|
||||||
|
<div className="flex items-center gap-3">{/* ... */}</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 버튼들 */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 체크리스트
|
||||||
|
|
||||||
|
위젯 개발 시 다음을 확인하세요:
|
||||||
|
|
||||||
|
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
|
||||||
|
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
|
||||||
|
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
|
||||||
|
- [ ] `defaultSize`에 적절한 기본 크기 설정
|
||||||
|
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
|
||||||
|
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ export class CommonCodeController {
|
||||||
*/
|
*/
|
||||||
async getCategories(req: AuthenticatedRequest, res: Response) {
|
async getCategories(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { search, isActive, page = "1", size = "20" } = req.query;
|
const { search, isActive, page = "1", size = "20", menuObjid } = req.query;
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||||
|
|
||||||
const categories = await this.commonCodeService.getCategories(
|
const categories = await this.commonCodeService.getCategories(
|
||||||
{
|
{
|
||||||
|
|
@ -35,7 +36,8 @@ export class CommonCodeController {
|
||||||
page: parseInt(page as string),
|
page: parseInt(page as string),
|
||||||
size: parseInt(size as string),
|
size: parseInt(size as string),
|
||||||
},
|
},
|
||||||
userCompanyCode
|
userCompanyCode,
|
||||||
|
menuObjidNum
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -61,8 +63,9 @@ export class CommonCodeController {
|
||||||
async getCodes(req: AuthenticatedRequest, res: Response) {
|
async getCodes(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { categoryCode } = req.params;
|
const { categoryCode } = req.params;
|
||||||
const { search, isActive, page, size } = req.query;
|
const { search, isActive, page, size, menuObjid } = req.query;
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
|
||||||
|
|
||||||
const result = await this.commonCodeService.getCodes(
|
const result = await this.commonCodeService.getCodes(
|
||||||
categoryCode,
|
categoryCode,
|
||||||
|
|
@ -77,7 +80,8 @@ export class CommonCodeController {
|
||||||
page: page ? parseInt(page as string) : undefined,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
size: size ? parseInt(size as string) : undefined,
|
size: size ? parseInt(size as string) : undefined,
|
||||||
},
|
},
|
||||||
userCompanyCode
|
userCompanyCode,
|
||||||
|
menuObjidNum
|
||||||
);
|
);
|
||||||
|
|
||||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||||
|
|
@ -131,6 +135,7 @@ export class CommonCodeController {
|
||||||
const categoryData: CreateCategoryData = req.body;
|
const categoryData: CreateCategoryData = req.body;
|
||||||
const userId = req.user?.userId || "SYSTEM";
|
const userId = req.user?.userId || "SYSTEM";
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const menuObjid = req.body.menuObjid;
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
if (!categoryData.categoryCode || !categoryData.categoryName) {
|
if (!categoryData.categoryCode || !categoryData.categoryName) {
|
||||||
|
|
@ -140,10 +145,18 @@ export class CommonCodeController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "메뉴 OBJID는 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const category = await this.commonCodeService.createCategory(
|
const category = await this.commonCodeService.createCategory(
|
||||||
categoryData,
|
categoryData,
|
||||||
userId,
|
userId,
|
||||||
companyCode
|
companyCode,
|
||||||
|
Number(menuObjid)
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
|
|
@ -263,6 +276,7 @@ export class CommonCodeController {
|
||||||
const codeData: CreateCodeData = req.body;
|
const codeData: CreateCodeData = req.body;
|
||||||
const userId = req.user?.userId || "SYSTEM";
|
const userId = req.user?.userId || "SYSTEM";
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const menuObjid = req.body.menuObjid;
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
if (!codeData.codeValue || !codeData.codeName) {
|
if (!codeData.codeValue || !codeData.codeName) {
|
||||||
|
|
@ -272,11 +286,19 @@ export class CommonCodeController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "메뉴 OBJID는 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const code = await this.commonCodeService.createCode(
|
const code = await this.commonCodeService.createCode(
|
||||||
categoryCode,
|
categoryCode,
|
||||||
codeData,
|
codeData,
|
||||||
userId,
|
userId,
|
||||||
companyCode
|
companyCode,
|
||||||
|
Number(menuObjid)
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,24 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
||||||
|
|
||||||
|
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
|
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
|
||||||
|
|
||||||
|
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
|
||||||
|
companyCode,
|
||||||
|
menuObjid,
|
||||||
|
rulesCount: rules.length
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({ success: true, data: rules });
|
return res.json({ success: true, data: rules });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("메뉴별 사용 가능한 규칙 조회 실패", {
|
logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
errorCode: error.code,
|
||||||
|
errorStack: error.stack,
|
||||||
|
companyCode,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
});
|
});
|
||||||
return res.status(500).json({ success: false, error: error.message });
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
|
@ -100,6 +112,17 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const ruleConfig = req.body;
|
const ruleConfig = req.body;
|
||||||
|
|
||||||
|
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
ruleId: ruleConfig.ruleId,
|
||||||
|
ruleName: ruleConfig.ruleName,
|
||||||
|
scopeType: ruleConfig.scopeType,
|
||||||
|
menuObjid: ruleConfig.menuObjid,
|
||||||
|
tableName: ruleConfig.tableName,
|
||||||
|
partsCount: ruleConfig.parts?.length,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||||
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||||
|
|
@ -110,12 +133,22 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
||||||
|
|
||||||
|
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
|
||||||
|
ruleId: newRule.ruleId,
|
||||||
|
menuObjid: newRule.menuObjid,
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(201).json({ success: true, data: newRule });
|
return res.status(201).json({ success: true, data: newRule });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === "23505") {
|
if (error.code === "23505") {
|
||||||
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||||
}
|
}
|
||||||
logger.error("규칙 생성 실패", { error: error.message });
|
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
return res.status(500).json({ success: false, error: error.message });
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,29 @@ export const getScreen = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화면에 할당된 메뉴 조회
|
||||||
|
export const getScreenMenu = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
|
||||||
|
const menuInfo = await screenManagementService.getMenuByScreen(
|
||||||
|
parseInt(id),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: menuInfo });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 메뉴 조회 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 메뉴 조회에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 화면 생성
|
// 화면 생성
|
||||||
export const createScreen = async (
|
export const createScreen = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,31 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
|
*
|
||||||
|
* Query Parameters:
|
||||||
|
* - menuObjid: 메뉴 OBJID (선택사항, 제공 시 형제 메뉴의 카테고리 값 포함)
|
||||||
|
* - includeInactive: 비활성 값 포함 여부
|
||||||
*/
|
*/
|
||||||
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const includeInactive = req.query.includeInactive === "true";
|
const includeInactive = req.query.includeInactive === "true";
|
||||||
|
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||||
|
|
||||||
|
logger.info("카테고리 값 조회 요청", {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
const values = await tableCategoryValueService.getCategoryValues(
|
const values = await tableCategoryValueService.getCategoryValues(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
companyCode,
|
companyCode,
|
||||||
includeInactive
|
includeInactive,
|
||||||
|
menuObjid // ← menuObjid 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -61,18 +74,37 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 추가
|
* 카테고리 값 추가 (메뉴 스코프)
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - menuObjid: 메뉴 OBJID (필수)
|
||||||
|
* - 나머지 카테고리 값 정보
|
||||||
*/
|
*/
|
||||||
export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
|
export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const value = req.body;
|
const { menuObjid, ...value } = req.body;
|
||||||
|
|
||||||
|
if (!menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "menuObjid는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("카테고리 값 추가 요청", {
|
||||||
|
tableName: value.tableName,
|
||||||
|
columnName: value.columnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
const newValue = await tableCategoryValueService.addCategoryValue(
|
const newValue = await tableCategoryValueService.addCategoryValue(
|
||||||
value,
|
value,
|
||||||
companyCode,
|
companyCode,
|
||||||
userId
|
userId,
|
||||||
|
Number(menuObjid) // ← menuObjid 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
|
|
|
||||||
|
|
@ -1599,3 +1599,116 @@ export async function toggleLogTable(
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||||
|
*
|
||||||
|
* @route GET /api/table-management/menu/:menuObjid/category-columns
|
||||||
|
* @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회
|
||||||
|
*/
|
||||||
|
export async function getCategoryColumnsByMenu(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { menuObjid } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
|
||||||
|
|
||||||
|
if (!menuObjid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "메뉴 OBJID가 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 형제 메뉴 조회
|
||||||
|
const { getSiblingMenuObjids } = await import("../services/menuService");
|
||||||
|
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
|
||||||
|
|
||||||
|
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
|
||||||
|
|
||||||
|
// 2. 형제 메뉴들이 사용하는 테이블 조회
|
||||||
|
const { getPool } = await import("../database/db");
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const tablesQuery = `
|
||||||
|
SELECT DISTINCT sd.table_name
|
||||||
|
FROM screen_menu_assignments sma
|
||||||
|
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||||
|
WHERE sma.menu_objid = ANY($1)
|
||||||
|
AND sma.company_code = $2
|
||||||
|
AND sd.table_name IS NOT NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
|
||||||
|
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
|
||||||
|
|
||||||
|
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
|
||||||
|
|
||||||
|
if (tableNames.length === 0) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
message: "형제 메뉴에 연결된 테이블이 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)
|
||||||
|
logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode });
|
||||||
|
|
||||||
|
const columnsQuery = `
|
||||||
|
SELECT
|
||||||
|
ttc.table_name AS "tableName",
|
||||||
|
COALESCE(
|
||||||
|
tl.table_label,
|
||||||
|
initcap(replace(ttc.table_name, '_', ' '))
|
||||||
|
) AS "tableLabel",
|
||||||
|
ttc.column_name AS "columnName",
|
||||||
|
COALESCE(
|
||||||
|
cl.column_label,
|
||||||
|
initcap(replace(ttc.column_name, '_', ' '))
|
||||||
|
) AS "columnLabel",
|
||||||
|
ttc.input_type AS "inputType"
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
LEFT JOIN column_labels cl
|
||||||
|
ON ttc.table_name = cl.table_name
|
||||||
|
AND ttc.column_name = cl.column_name
|
||||||
|
LEFT JOIN table_labels tl
|
||||||
|
ON ttc.table_name = tl.table_name
|
||||||
|
WHERE ttc.table_name = ANY($1)
|
||||||
|
AND ttc.company_code = $2
|
||||||
|
AND ttc.input_type = 'category'
|
||||||
|
ORDER BY ttc.table_name, ttc.column_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info("🔍 카테고리 컬럼 쿼리 실행 중...");
|
||||||
|
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
|
||||||
|
logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length });
|
||||||
|
|
||||||
|
logger.info("✅ 카테고리 컬럼 조회 완료", {
|
||||||
|
columnCount: columnsResult.rows.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: columnsResult.rows,
|
||||||
|
message: "카테고리 컬럼 조회 성공",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패");
|
||||||
|
logger.error("에러 메시지:", error.message);
|
||||||
|
logger.error("에러 스택:", error.stack);
|
||||||
|
logger.error("에러 전체:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 컬럼 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack, // 디버깅용
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import {
|
import {
|
||||||
getScreens,
|
getScreens,
|
||||||
getScreen,
|
getScreen,
|
||||||
|
getScreenMenu,
|
||||||
createScreen,
|
createScreen,
|
||||||
updateScreen,
|
updateScreen,
|
||||||
updateScreenInfo,
|
updateScreenInfo,
|
||||||
|
|
@ -33,6 +34,7 @@ router.use(authenticateToken);
|
||||||
// 화면 관리
|
// 화면 관리
|
||||||
router.get("/screens", getScreens);
|
router.get("/screens", getScreens);
|
||||||
router.get("/screens/:id", getScreen);
|
router.get("/screens/:id", getScreen);
|
||||||
|
router.get("/screens/:id/menu", getScreenMenu); // 화면에 할당된 메뉴 조회
|
||||||
router.post("/screens", createScreen);
|
router.post("/screens", createScreen);
|
||||||
router.put("/screens/:id", updateScreen);
|
router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
getLogConfig,
|
getLogConfig,
|
||||||
getLogData,
|
getLogData,
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -187,4 +188,14 @@ router.get("/tables/:tableName/log", getLogData);
|
||||||
*/
|
*/
|
||||||
router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메뉴 기반 카테고리 관리 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||||
|
* GET /api/table-management/menu/:menuObjid/category-columns
|
||||||
|
*/
|
||||||
|
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ export interface CodeInfo {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
is_active: string;
|
is_active: string;
|
||||||
company_code: string; // 추가
|
company_code: string;
|
||||||
|
menu_objid?: number | null; // 메뉴 기반 코드 관리용
|
||||||
created_date?: Date | null;
|
created_date?: Date | null;
|
||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
updated_date?: Date | null;
|
updated_date?: Date | null;
|
||||||
|
|
@ -66,7 +67,7 @@ export class CommonCodeService {
|
||||||
/**
|
/**
|
||||||
* 카테고리 목록 조회
|
* 카테고리 목록 조회
|
||||||
*/
|
*/
|
||||||
async getCategories(params: GetCategoriesParams, userCompanyCode?: string) {
|
async getCategories(params: GetCategoriesParams, userCompanyCode?: string, menuObjid?: number) {
|
||||||
try {
|
try {
|
||||||
const { search, isActive, page = 1, size = 20 } = params;
|
const { search, isActive, page = 1, size = 20 } = params;
|
||||||
|
|
||||||
|
|
@ -74,6 +75,16 @@ export class CommonCodeService {
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 메뉴별 필터링 (형제 메뉴 포함)
|
||||||
|
if (menuObjid) {
|
||||||
|
const { getSiblingMenuObjids } = await import('./menuService');
|
||||||
|
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
|
||||||
|
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
|
||||||
|
values.push(siblingMenuObjids);
|
||||||
|
paramIndex++;
|
||||||
|
logger.info(`메뉴별 코드 카테고리 필터링: ${menuObjid}, 형제 메뉴: ${siblingMenuObjids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
|
@ -142,15 +153,43 @@ export class CommonCodeService {
|
||||||
async getCodes(
|
async getCodes(
|
||||||
categoryCode: string,
|
categoryCode: string,
|
||||||
params: GetCodesParams,
|
params: GetCodesParams,
|
||||||
userCompanyCode?: string
|
userCompanyCode?: string,
|
||||||
|
menuObjid?: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { search, isActive, page = 1, size = 20 } = params;
|
const { search, isActive, page = 1, size = 20 } = params;
|
||||||
|
|
||||||
|
logger.info(`🔍 [getCodes] 코드 조회 시작:`, {
|
||||||
|
categoryCode,
|
||||||
|
menuObjid,
|
||||||
|
hasMenuObjid: !!menuObjid,
|
||||||
|
userCompanyCode,
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
|
||||||
const whereConditions: string[] = ["code_category = $1"];
|
const whereConditions: string[] = ["code_category = $1"];
|
||||||
const values: any[] = [categoryCode];
|
const values: any[] = [categoryCode];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 메뉴별 필터링 (형제 메뉴 포함)
|
||||||
|
if (menuObjid) {
|
||||||
|
const { getSiblingMenuObjids } = await import('./menuService');
|
||||||
|
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
|
||||||
|
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
|
||||||
|
values.push(siblingMenuObjids);
|
||||||
|
paramIndex++;
|
||||||
|
logger.info(`📋 [getCodes] 메뉴별 코드 필터링:`, {
|
||||||
|
menuObjid,
|
||||||
|
siblingMenuObjids,
|
||||||
|
siblingCount: siblingMenuObjids.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ [getCodes] menuObjid 없음 - 전역 코드 조회`);
|
||||||
|
}
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
|
@ -178,6 +217,13 @@ export class CommonCodeService {
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
|
logger.info(`📝 [getCodes] 실행할 쿼리:`, {
|
||||||
|
whereClause,
|
||||||
|
values,
|
||||||
|
whereConditions,
|
||||||
|
paramIndex,
|
||||||
|
});
|
||||||
|
|
||||||
// 코드 조회
|
// 코드 조회
|
||||||
const codes = await query<CodeInfo>(
|
const codes = await query<CodeInfo>(
|
||||||
`SELECT * FROM code_info
|
`SELECT * FROM code_info
|
||||||
|
|
@ -196,9 +242,20 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
|
||||||
|
categoryCode,
|
||||||
|
menuObjid,
|
||||||
|
codes: codes.map((c) => ({
|
||||||
|
code_value: c.code_value,
|
||||||
|
code_name: c.code_name,
|
||||||
|
menu_objid: c.menu_objid,
|
||||||
|
company_code: c.company_code,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
return { data: codes, total };
|
return { data: codes, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
|
@ -212,14 +269,15 @@ export class CommonCodeService {
|
||||||
async createCategory(
|
async createCategory(
|
||||||
data: CreateCategoryData,
|
data: CreateCategoryData,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
companyCode: string
|
companyCode: string,
|
||||||
|
menuObjid: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const category = await queryOne<CodeCategory>(
|
const category = await queryOne<CodeCategory>(
|
||||||
`INSERT INTO code_category
|
`INSERT INTO code_category
|
||||||
(category_code, category_name, category_name_eng, description, sort_order,
|
(category_code, category_name, category_name_eng, description, sort_order,
|
||||||
is_active, company_code, created_by, updated_by, created_date, updated_date)
|
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, $9, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.categoryCode,
|
data.categoryCode,
|
||||||
|
|
@ -227,6 +285,7 @@ export class CommonCodeService {
|
||||||
data.categoryNameEng || null,
|
data.categoryNameEng || null,
|
||||||
data.description || null,
|
data.description || null,
|
||||||
data.sortOrder || 0,
|
data.sortOrder || 0,
|
||||||
|
menuObjid,
|
||||||
companyCode,
|
companyCode,
|
||||||
createdBy,
|
createdBy,
|
||||||
createdBy,
|
createdBy,
|
||||||
|
|
@ -234,7 +293,7 @@ export class CommonCodeService {
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`카테고리 생성 완료: ${data.categoryCode} (회사: ${companyCode})`
|
`카테고리 생성 완료: ${data.categoryCode} (메뉴: ${menuObjid}, 회사: ${companyCode})`
|
||||||
);
|
);
|
||||||
return category;
|
return category;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -352,14 +411,15 @@ export class CommonCodeService {
|
||||||
categoryCode: string,
|
categoryCode: string,
|
||||||
data: CreateCodeData,
|
data: CreateCodeData,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
companyCode: string
|
companyCode: string,
|
||||||
|
menuObjid: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const code = await queryOne<CodeInfo>(
|
const code = await queryOne<CodeInfo>(
|
||||||
`INSERT INTO code_info
|
`INSERT INTO code_info
|
||||||
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
||||||
is_active, company_code, created_by, updated_by, created_date, updated_date)
|
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
categoryCode,
|
categoryCode,
|
||||||
|
|
@ -368,6 +428,7 @@ export class CommonCodeService {
|
||||||
data.codeNameEng || null,
|
data.codeNameEng || null,
|
||||||
data.description || null,
|
data.description || null,
|
||||||
data.sortOrder || 0,
|
data.sortOrder || 0,
|
||||||
|
menuObjid,
|
||||||
companyCode,
|
companyCode,
|
||||||
createdBy,
|
createdBy,
|
||||||
createdBy,
|
createdBy,
|
||||||
|
|
@ -375,7 +436,7 @@ export class CommonCodeService {
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`코드 생성 완료: ${categoryCode}.${data.codeValue} (회사: ${companyCode})`
|
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})`
|
||||||
);
|
);
|
||||||
return code;
|
return code;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export class DDLExecutionService {
|
||||||
await this.saveTableMetadata(client, tableName, description);
|
await this.saveTableMetadata(client, tableName, description);
|
||||||
|
|
||||||
// 5-3. 컬럼 메타데이터 저장
|
// 5-3. 컬럼 메타데이터 저장
|
||||||
await this.saveColumnMetadata(client, tableName, columns);
|
await this.saveColumnMetadata(client, tableName, columns, userCompanyCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. 성공 로그 기록
|
// 6. 성공 로그 기록
|
||||||
|
|
@ -272,7 +272,7 @@ export class DDLExecutionService {
|
||||||
await client.query(ddlQuery);
|
await client.query(ddlQuery);
|
||||||
|
|
||||||
// 6-2. 컬럼 메타데이터 저장
|
// 6-2. 컬럼 메타데이터 저장
|
||||||
await this.saveColumnMetadata(client, tableName, [column]);
|
await this.saveColumnMetadata(client, tableName, [column], userCompanyCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. 성공 로그 기록
|
// 7. 성공 로그 기록
|
||||||
|
|
@ -446,7 +446,8 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
private async saveColumnMetadata(
|
private async saveColumnMetadata(
|
||||||
client: any,
|
client: any,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columns: CreateColumnDefinition[]
|
columns: CreateColumnDefinition[],
|
||||||
|
companyCode: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
|
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -508,19 +509,19 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO table_type_columns (
|
INSERT INTO table_type_columns (
|
||||||
table_name, column_name, input_type, detail_settings,
|
table_name, column_name, company_code, input_type, detail_settings,
|
||||||
is_nullable, display_order, created_date, updated_date
|
is_nullable, display_order, created_date, updated_date
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, '{}',
|
$1, $2, $3, $4, '{}',
|
||||||
'Y', $4, now(), now()
|
'Y', $5, now(), now()
|
||||||
)
|
)
|
||||||
ON CONFLICT (table_name, column_name)
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
input_type = $3,
|
input_type = $4,
|
||||||
display_order = $4,
|
display_order = $5,
|
||||||
updated_date = now()
|
updated_date = now()
|
||||||
`,
|
`,
|
||||||
[tableName, defaultCol.name, defaultCol.inputType, defaultCol.order]
|
[tableName, defaultCol.name, companyCode, defaultCol.inputType, defaultCol.order]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,20 +536,20 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO table_type_columns (
|
INSERT INTO table_type_columns (
|
||||||
table_name, column_name, input_type, detail_settings,
|
table_name, column_name, company_code, input_type, detail_settings,
|
||||||
is_nullable, display_order, created_date, updated_date
|
is_nullable, display_order, created_date, updated_date
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4,
|
$1, $2, $3, $4, $5,
|
||||||
'Y', $5, now(), now()
|
'Y', $6, now(), now()
|
||||||
)
|
)
|
||||||
ON CONFLICT (table_name, column_name)
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
input_type = $3,
|
input_type = $4,
|
||||||
detail_settings = $4,
|
detail_settings = $5,
|
||||||
display_order = $5,
|
display_order = $6,
|
||||||
updated_date = now()
|
updated_date = now()
|
||||||
`,
|
`,
|
||||||
[tableName, column.name, inputType, detailSettings, i]
|
[tableName, column.name, companyCode, inputType, detailSettings, i]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,20 +24,19 @@ export class EntityJoinService {
|
||||||
try {
|
try {
|
||||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||||
|
|
||||||
// column_labels에서 entity 타입인 컬럼들 조회
|
// column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
|
||||||
const entityColumns = await query<{
|
const entityColumns = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
|
input_type: string;
|
||||||
reference_table: string;
|
reference_table: string;
|
||||||
reference_column: string;
|
reference_column: string;
|
||||||
display_column: string | null;
|
display_column: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, reference_table, reference_column, display_column
|
`SELECT column_name, input_type, reference_table, reference_column, display_column
|
||||||
FROM column_labels
|
FROM column_labels
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND web_type = $2
|
AND input_type IN ('entity', 'category')`,
|
||||||
AND reference_table IS NOT NULL
|
[tableName]
|
||||||
AND reference_column IS NOT NULL`,
|
|
||||||
[tableName, "entity"]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||||
|
|
@ -77,18 +76,34 @@ export class EntityJoinService {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const column of entityColumns) {
|
for (const column of entityColumns) {
|
||||||
|
// 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정
|
||||||
|
let referenceTable = column.reference_table;
|
||||||
|
let referenceColumn = column.reference_column;
|
||||||
|
let displayColumn = column.display_column;
|
||||||
|
|
||||||
|
if (column.input_type === 'category') {
|
||||||
|
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||||
|
referenceTable = referenceTable || 'table_column_category_values';
|
||||||
|
referenceColumn = referenceColumn || 'value_code';
|
||||||
|
displayColumn = displayColumn || 'value_label';
|
||||||
|
|
||||||
|
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
||||||
|
referenceTable,
|
||||||
|
referenceColumn,
|
||||||
|
displayColumn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||||
column_name: column.column_name,
|
column_name: column.column_name,
|
||||||
reference_table: column.reference_table,
|
input_type: column.input_type,
|
||||||
reference_column: column.reference_column,
|
reference_table: referenceTable,
|
||||||
display_column: column.display_column,
|
reference_column: referenceColumn,
|
||||||
|
display_column: displayColumn,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!column.column_name || !referenceTable || !referenceColumn) {
|
||||||
!column.column_name ||
|
logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`);
|
||||||
!column.reference_table ||
|
|
||||||
!column.reference_column
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,27 +127,28 @@ export class EntityJoinService {
|
||||||
separator,
|
separator,
|
||||||
screenConfig,
|
screenConfig,
|
||||||
});
|
});
|
||||||
} else if (column.display_column && column.display_column !== "none") {
|
} else if (displayColumn && displayColumn !== "none") {
|
||||||
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
|
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
|
||||||
displayColumns = [column.display_column];
|
displayColumns = [displayColumn];
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔧 기존 display_column 사용: ${column.column_name} → ${column.display_column}`
|
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
|
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
|
||||||
// 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
|
let defaultDisplayColumn = referenceColumn;
|
||||||
let defaultDisplayColumn = column.reference_column;
|
if (referenceTable === "dept_info") {
|
||||||
if (column.reference_table === "dept_info") {
|
|
||||||
defaultDisplayColumn = "dept_name";
|
defaultDisplayColumn = "dept_name";
|
||||||
} else if (column.reference_table === "company_info") {
|
} else if (referenceTable === "company_info") {
|
||||||
defaultDisplayColumn = "company_name";
|
defaultDisplayColumn = "company_name";
|
||||||
} else if (column.reference_table === "user_info") {
|
} else if (referenceTable === "user_info") {
|
||||||
defaultDisplayColumn = "user_name";
|
defaultDisplayColumn = "user_name";
|
||||||
|
} else if (referenceTable === "category_values") {
|
||||||
|
defaultDisplayColumn = "category_name";
|
||||||
}
|
}
|
||||||
|
|
||||||
displayColumns = [defaultDisplayColumn];
|
displayColumns = [defaultDisplayColumn];
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${column.reference_table})`
|
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})`
|
||||||
);
|
);
|
||||||
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
|
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
|
||||||
}
|
}
|
||||||
|
|
@ -143,8 +159,8 @@ export class EntityJoinService {
|
||||||
const joinConfig: EntityJoinConfig = {
|
const joinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
sourceColumn: column.column_name,
|
sourceColumn: column.column_name,
|
||||||
referenceTable: column.reference_table,
|
referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
|
||||||
referenceColumn: column.reference_column,
|
referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
|
||||||
displayColumns: displayColumns,
|
displayColumns: displayColumns,
|
||||||
displayColumn: displayColumns[0], // 하위 호환성
|
displayColumn: displayColumns[0], // 하위 호환성
|
||||||
aliasColumn: aliasColumn,
|
aliasColumn: aliasColumn,
|
||||||
|
|
@ -246,10 +262,13 @@ export class EntityJoinService {
|
||||||
];
|
];
|
||||||
const separator = config.separator || " - ";
|
const separator = config.separator || " - ";
|
||||||
|
|
||||||
|
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||||
|
const resultColumns: string[] = [];
|
||||||
|
|
||||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||||
return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
|
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
|
||||||
} else if (displayColumns.length === 1) {
|
} else if (displayColumns.length === 1) {
|
||||||
// 단일 컬럼인 경우
|
// 단일 컬럼인 경우
|
||||||
const col = displayColumns[0];
|
const col = displayColumns[0];
|
||||||
|
|
@ -265,12 +284,18 @@ export class EntityJoinService {
|
||||||
"company_name",
|
"company_name",
|
||||||
"sales_yn",
|
"sales_yn",
|
||||||
"status",
|
"status",
|
||||||
|
"value_label", // table_column_category_values
|
||||||
|
"user_name", // user_info
|
||||||
].includes(col);
|
].includes(col);
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
if (isJoinTableColumn) {
|
||||||
return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`;
|
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||||
|
|
||||||
|
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||||
|
// sourceColumn_label 형식으로 추가
|
||||||
|
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
|
||||||
} else {
|
} else {
|
||||||
return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
|
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 여러 컬럼인 경우 CONCAT으로 연결
|
// 여러 컬럼인 경우 CONCAT으로 연결
|
||||||
|
|
@ -291,6 +316,8 @@ export class EntityJoinService {
|
||||||
"company_name",
|
"company_name",
|
||||||
"sales_yn",
|
"sales_yn",
|
||||||
"status",
|
"status",
|
||||||
|
"value_label", // table_column_category_values
|
||||||
|
"user_name", // user_info
|
||||||
].includes(col);
|
].includes(col);
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
if (isJoinTableColumn) {
|
||||||
|
|
@ -303,8 +330,11 @@ export class EntityJoinService {
|
||||||
})
|
})
|
||||||
.join(` || '${separator}' || `);
|
.join(` || '${separator}' || `);
|
||||||
|
|
||||||
return `(${concatParts}) AS ${config.aliasColumn}`;
|
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모든 resultColumns를 반환
|
||||||
|
return resultColumns.join(", ");
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
|
@ -320,6 +350,12 @@ export class EntityJoinService {
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const alias = aliasMap.get(config.referenceTable);
|
const alias = aliasMap.get(config.referenceTable);
|
||||||
|
|
||||||
|
// table_column_category_values는 특별한 조인 조건 필요
|
||||||
|
if (config.referenceTable === 'table_column_category_values') {
|
||||||
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`;
|
||||||
|
}
|
||||||
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
@ -380,6 +416,14 @@ export class EntityJoinService {
|
||||||
return "join";
|
return "join";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||||
|
if (config.referenceTable === 'table_column_category_values') {
|
||||||
|
logger.info(
|
||||||
|
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||||
|
);
|
||||||
|
return "join";
|
||||||
|
}
|
||||||
|
|
||||||
// 참조 테이블의 캐시 가능성 확인
|
// 참조 테이블의 캐시 가능성 확인
|
||||||
const displayCol =
|
const displayCol =
|
||||||
config.displayColumn ||
|
config.displayColumn ||
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 관련 유틸리티 서비스
|
||||||
|
*
|
||||||
|
* 메뉴 스코프 기반 데이터 공유를 위한 형제 메뉴 조회 기능 제공
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴의 형제 메뉴 및 자식 메뉴 OBJID 목록 조회
|
||||||
|
* (같은 부모를 가진 메뉴들 + 자식 메뉴들)
|
||||||
|
*
|
||||||
|
* 메뉴 스코프 규칙:
|
||||||
|
* - 같은 부모를 가진 형제 메뉴들은 카테고리/채번규칙을 공유
|
||||||
|
* - 자식 메뉴의 데이터도 부모 메뉴에서 조회 가능 (3레벨까지만 존재)
|
||||||
|
* - 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환
|
||||||
|
* - 메뉴를 찾을 수 없으면 안전하게 자기 자신만 반환
|
||||||
|
*
|
||||||
|
* @param menuObjid 현재 메뉴의 OBJID
|
||||||
|
* @returns 형제 메뉴 + 자식 메뉴 OBJID 배열 (자기 자신 포함, 정렬됨)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 영업관리 (200)
|
||||||
|
* // ├── 고객관리 (201)
|
||||||
|
* // │ └── 고객등록 (211)
|
||||||
|
* // ├── 계약관리 (202)
|
||||||
|
* // └── 주문관리 (203)
|
||||||
|
*
|
||||||
|
* await getSiblingMenuObjids(201);
|
||||||
|
* // 결과: [201, 202, 203, 211] - 형제(202, 203) + 자식(211)
|
||||||
|
*/
|
||||||
|
export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug("메뉴 스코프 조회 시작", { menuObjid });
|
||||||
|
|
||||||
|
// 1. 현재 메뉴 정보 조회 (부모 ID 확인)
|
||||||
|
const currentMenuQuery = `
|
||||||
|
SELECT parent_obj_id FROM menu_info
|
||||||
|
WHERE objid = $1
|
||||||
|
`;
|
||||||
|
const currentMenuResult = await pool.query(currentMenuQuery, [menuObjid]);
|
||||||
|
|
||||||
|
if (currentMenuResult.rows.length === 0) {
|
||||||
|
logger.warn("메뉴를 찾을 수 없음, 자기 자신만 반환", { menuObjid });
|
||||||
|
return [menuObjid];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentObjId = Number(currentMenuResult.rows[0].parent_obj_id);
|
||||||
|
|
||||||
|
// 2. 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환
|
||||||
|
if (parentObjId === 0) {
|
||||||
|
logger.debug("최상위 메뉴, 자기 자신만 반환", { menuObjid });
|
||||||
|
return [menuObjid];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 형제 메뉴들 조회 (같은 부모를 가진 메뉴들)
|
||||||
|
const siblingsQuery = `
|
||||||
|
SELECT objid FROM menu_info
|
||||||
|
WHERE parent_obj_id = $1
|
||||||
|
ORDER BY objid
|
||||||
|
`;
|
||||||
|
const siblingsResult = await pool.query(siblingsQuery, [parentObjId]);
|
||||||
|
|
||||||
|
const siblingObjids = siblingsResult.rows.map((row) => Number(row.objid));
|
||||||
|
|
||||||
|
// 4. 각 형제 메뉴(자기 자신 포함)의 자식 메뉴들도 조회
|
||||||
|
const allObjids = [...siblingObjids];
|
||||||
|
|
||||||
|
for (const siblingObjid of siblingObjids) {
|
||||||
|
const childrenQuery = `
|
||||||
|
SELECT objid FROM menu_info
|
||||||
|
WHERE parent_obj_id = $1
|
||||||
|
ORDER BY objid
|
||||||
|
`;
|
||||||
|
const childrenResult = await pool.query(childrenQuery, [siblingObjid]);
|
||||||
|
const childObjids = childrenResult.rows.map((row) => Number(row.objid));
|
||||||
|
allObjids.push(...childObjids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 중복 제거 및 정렬
|
||||||
|
const uniqueObjids = Array.from(new Set(allObjids)).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
logger.debug("메뉴 스코프 조회 완료", {
|
||||||
|
menuObjid,
|
||||||
|
parentObjId,
|
||||||
|
siblingCount: siblingObjids.length,
|
||||||
|
totalCount: uniqueObjids.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueObjids;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("메뉴 스코프 조회 실패", {
|
||||||
|
menuObjid,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
// 에러 발생 시 안전하게 자기 자신만 반환
|
||||||
|
return [menuObjid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
||||||
|
*
|
||||||
|
* 여러 메뉴에 속한 모든 형제 메뉴를 중복 제거하여 반환
|
||||||
|
*
|
||||||
|
* @param menuObjids 메뉴 OBJID 배열
|
||||||
|
* @returns 모든 형제 메뉴 OBJID 배열 (중복 제거, 정렬됨)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 서로 다른 부모를 가진 메뉴들의 형제를 모두 조회
|
||||||
|
* await getAllSiblingMenuObjids([201, 301]);
|
||||||
|
* // 201의 형제: [201, 202, 203]
|
||||||
|
* // 301의 형제: [301, 302]
|
||||||
|
* // 결과: [201, 202, 203, 301, 302]
|
||||||
|
*/
|
||||||
|
export async function getAllSiblingMenuObjids(
|
||||||
|
menuObjids: number[]
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (!menuObjids || menuObjids.length === 0) {
|
||||||
|
logger.warn("getAllSiblingMenuObjids: 빈 배열 입력");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSiblings = new Set<number>();
|
||||||
|
|
||||||
|
for (const objid of menuObjids) {
|
||||||
|
const siblings = await getSiblingMenuObjids(objid);
|
||||||
|
siblings.forEach((s) => allSiblings.add(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Array.from(allSiblings).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
logger.info("여러 메뉴의 형제 조회 완료", {
|
||||||
|
inputMenus: menuObjids,
|
||||||
|
resultCount: result.length,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 정보 조회
|
||||||
|
*
|
||||||
|
* @param menuObjid 메뉴 OBJID
|
||||||
|
* @returns 메뉴 정보 (없으면 null)
|
||||||
|
*/
|
||||||
|
export async function getMenuInfo(menuObjid: number): Promise<any | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
objid,
|
||||||
|
parent_obj_id AS "parentObjId",
|
||||||
|
menu_name_kor AS "menuNameKor",
|
||||||
|
menu_name_eng AS "menuNameEng",
|
||||||
|
menu_url AS "menuUrl",
|
||||||
|
company_code AS "companyCode"
|
||||||
|
FROM menu_info
|
||||||
|
WHERE objid = $1
|
||||||
|
`;
|
||||||
|
const result = await pool.query(query, [menuObjid]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("메뉴 정보 조회 실패", { menuObjid, error: error.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { getSiblingMenuObjids } from "./menuService";
|
||||||
|
|
||||||
interface NumberingRulePart {
|
interface NumberingRulePart {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
@ -150,22 +151,34 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 메뉴에서 사용 가능한 규칙 목록 조회
|
* 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프)
|
||||||
|
*
|
||||||
|
* 메뉴 스코프 규칙:
|
||||||
|
* - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함
|
||||||
|
* - 우선순위: menu (형제 메뉴) > table > global
|
||||||
*/
|
*/
|
||||||
async getAvailableRulesForMenu(
|
async getAvailableRulesForMenu(
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
menuObjid?: number
|
menuObjid?: number
|
||||||
): Promise<NumberingRuleConfig[]> {
|
): Promise<NumberingRuleConfig[]> {
|
||||||
|
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", {
|
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
|
||||||
companyCode,
|
companyCode,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 1. 형제 메뉴 OBJID 조회
|
||||||
|
if (menuObjid) {
|
||||||
|
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
||||||
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||||
|
}
|
||||||
|
|
||||||
// menuObjid가 없으면 global 규칙만 반환
|
// menuObjid가 없으면 global 규칙만 반환
|
||||||
if (!menuObjid) {
|
if (!menuObjid || siblingObjids.length === 0) {
|
||||||
let query: string;
|
let query: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
|
|
@ -261,35 +274,13 @@ class NumberingRuleService {
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기)
|
// 2. 메뉴 스코프: 형제 메뉴의 채번 규칙 조회
|
||||||
const menuHierarchyQuery = `
|
// 우선순위: menu (형제 메뉴) > table > global
|
||||||
WITH RECURSIVE menu_path AS (
|
|
||||||
SELECT objid, objid_parent, menu_level
|
|
||||||
FROM menu_info
|
|
||||||
WHERE objid = $1
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT mi.objid, mi.objid_parent, mi.menu_level
|
|
||||||
FROM menu_info mi
|
|
||||||
INNER JOIN menu_path mp ON mi.objid = mp.objid_parent
|
|
||||||
)
|
|
||||||
SELECT objid, menu_level
|
|
||||||
FROM menu_path
|
|
||||||
WHERE menu_level = 2
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]);
|
|
||||||
const level2MenuObjid =
|
|
||||||
hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null;
|
|
||||||
|
|
||||||
// 사용 가능한 규칙 조회 (멀티테넌시 적용)
|
|
||||||
let query: string;
|
let query: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: 모든 규칙 조회
|
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
|
||||||
query = `
|
query = `
|
||||||
SELECT
|
SELECT
|
||||||
rule_id AS "ruleId",
|
rule_id AS "ruleId",
|
||||||
|
|
@ -309,12 +300,22 @@ class NumberingRuleService {
|
||||||
FROM numbering_rules
|
FROM numbering_rules
|
||||||
WHERE
|
WHERE
|
||||||
scope_type = 'global'
|
scope_type = 'global'
|
||||||
OR (scope_type = 'menu' AND menu_objid = $1)
|
OR scope_type = 'table'
|
||||||
ORDER BY scope_type DESC, created_at DESC
|
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||||
|
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
||||||
|
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||||
|
WHEN scope_type = 'table' THEN 2
|
||||||
|
WHEN scope_type = 'global' THEN 3
|
||||||
|
END,
|
||||||
|
created_at DESC
|
||||||
`;
|
`;
|
||||||
params = [level2MenuObjid];
|
params = [siblingObjids];
|
||||||
|
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 규칙만 조회
|
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
|
||||||
query = `
|
query = `
|
||||||
SELECT
|
SELECT
|
||||||
rule_id AS "ruleId",
|
rule_id AS "ruleId",
|
||||||
|
|
@ -335,17 +336,36 @@ class NumberingRuleService {
|
||||||
WHERE company_code = $1
|
WHERE company_code = $1
|
||||||
AND (
|
AND (
|
||||||
scope_type = 'global'
|
scope_type = 'global'
|
||||||
OR (scope_type = 'menu' AND menu_objid = $2)
|
OR scope_type = 'table'
|
||||||
|
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||||
|
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
||||||
|
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||||
)
|
)
|
||||||
ORDER BY scope_type DESC, created_at DESC
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1
|
||||||
|
WHEN scope_type = 'table' THEN 2
|
||||||
|
WHEN scope_type = 'global' THEN 3
|
||||||
|
END,
|
||||||
|
created_at DESC
|
||||||
`;
|
`;
|
||||||
params = [companyCode, level2MenuObjid];
|
params = [companyCode, siblingObjids];
|
||||||
|
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||||
|
queryPreview: query.substring(0, 200),
|
||||||
|
paramsTypes: params.map(p => typeof p),
|
||||||
|
paramsValues: params,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.debug("채번 규칙 쿼리 성공", { ruleCount: result.rows.length });
|
||||||
|
|
||||||
// 파트 정보 추가
|
// 파트 정보 추가
|
||||||
for (const rule of result.rows) {
|
for (const rule of result.rows) {
|
||||||
|
try {
|
||||||
let partsQuery: string;
|
let partsQuery: string;
|
||||||
let partsParams: any[];
|
let partsParams: any[];
|
||||||
|
|
||||||
|
|
@ -381,12 +401,28 @@ class NumberingRuleService {
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = partsResult.rows;
|
||||||
|
|
||||||
|
logger.info("✅ 규칙 파트 조회 성공", {
|
||||||
|
ruleId: rule.ruleId,
|
||||||
|
ruleName: rule.ruleName,
|
||||||
|
partsCount: partsResult.rows.length,
|
||||||
|
});
|
||||||
|
} catch (partError: any) {
|
||||||
|
logger.error("❌ 규칙 파트 조회 실패", {
|
||||||
|
ruleId: rule.ruleId,
|
||||||
|
ruleName: rule.ruleName,
|
||||||
|
error: partError.message,
|
||||||
|
errorCode: partError.code,
|
||||||
|
errorStack: partError.stack,
|
||||||
|
});
|
||||||
|
throw partError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
||||||
companyCode,
|
companyCode,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
level2MenuObjid,
|
siblingCount: siblingObjids.length,
|
||||||
count: result.rowCount,
|
count: result.rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -394,8 +430,11 @@ class NumberingRuleService {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("메뉴별 채번 규칙 조회 실패", {
|
logger.error("메뉴별 채번 규칙 조회 실패", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
errorCode: error.code,
|
||||||
|
errorStack: error.stack,
|
||||||
companyCode,
|
companyCode,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
|
siblingObjids: siblingObjids || [],
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// ✅ Prisma → Raw Query 전환 (Phase 2.1)
|
// ✅ Prisma → Raw Query 전환 (Phase 2.1)
|
||||||
import { query, transaction } from "../database/db";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import {
|
import {
|
||||||
ScreenDefinition,
|
ScreenDefinition,
|
||||||
CreateScreenRequest,
|
CreateScreenRequest,
|
||||||
|
|
@ -1547,6 +1547,39 @@ export class ScreenManagementService {
|
||||||
return screens.map((screen) => this.mapToScreenDefinition(screen));
|
return screens.map((screen) => this.mapToScreenDefinition(screen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면에 할당된 메뉴 조회 (첫 번째 할당만 반환)
|
||||||
|
* 화면 편집기에서 menuObjid를 가져오기 위해 사용
|
||||||
|
*/
|
||||||
|
async getMenuByScreen(
|
||||||
|
screenId: number,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<{ menuObjid: number; menuName?: string } | null> {
|
||||||
|
const result = await queryOne<{
|
||||||
|
menu_objid: string;
|
||||||
|
menu_name_kor?: string;
|
||||||
|
}>(
|
||||||
|
`SELECT sma.menu_objid, mi.menu_name_kor
|
||||||
|
FROM screen_menu_assignments sma
|
||||||
|
LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid
|
||||||
|
WHERE sma.screen_id = $1
|
||||||
|
AND sma.company_code = $2
|
||||||
|
AND sma.is_active = 'Y'
|
||||||
|
ORDER BY sma.created_date ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[screenId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
menuObjid: parseInt(result.menu_objid),
|
||||||
|
menuName: result.menu_name_kor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료)
|
* 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { getSiblingMenuObjids } from "./menuService";
|
||||||
import {
|
import {
|
||||||
TableCategoryValue,
|
TableCategoryValue,
|
||||||
CategoryColumn,
|
CategoryColumn,
|
||||||
|
|
@ -62,7 +63,9 @@ class TableCategoryValueService {
|
||||||
logger.info("회사별 카테고리 컬럼 조회", { companyCode });
|
logger.info("회사별 카테고리 컬럼 조회", { companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(query, [tableName, companyCode]);
|
// 쿼리 파라미터는 company_code에 따라 다름
|
||||||
|
const params = companyCode === "*" ? [tableName] : [tableName, companyCode];
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -77,30 +80,44 @@ class TableCategoryValueService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 컬럼의 카테고리 값 목록 조회 (테이블 스코프)
|
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
|
*
|
||||||
|
* 메뉴 스코프 규칙:
|
||||||
|
* - menuObjid가 제공되면 해당 메뉴와 형제 메뉴의 카테고리 값을 조회
|
||||||
|
* - menuObjid가 없으면 테이블 스코프로 동작 (하위 호환성)
|
||||||
*/
|
*/
|
||||||
async getCategoryValues(
|
async getCategoryValues(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
includeInactive: boolean = false
|
includeInactive: boolean = false,
|
||||||
|
menuObjid?: number
|
||||||
): Promise<TableCategoryValue[]> {
|
): Promise<TableCategoryValue[]> {
|
||||||
try {
|
try {
|
||||||
logger.info("카테고리 값 목록 조회", {
|
logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
companyCode,
|
companyCode,
|
||||||
includeInactive,
|
includeInactive,
|
||||||
|
menuObjid,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
|
// 1. 메뉴 스코프: 형제 메뉴 OBJID 조회
|
||||||
|
let siblingObjids: number[] = [];
|
||||||
|
if (menuObjid) {
|
||||||
|
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
||||||
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
||||||
let query: string;
|
let query: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: 모든 카테고리 값 조회
|
// 최고 관리자: 모든 카테고리 값 조회
|
||||||
|
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||||
query = `
|
query = `
|
||||||
SELECT
|
SELECT
|
||||||
value_id AS "valueId",
|
value_id AS "valueId",
|
||||||
|
|
@ -117,6 +134,7 @@ class TableCategoryValueService {
|
||||||
is_active AS "isActive",
|
is_active AS "isActive",
|
||||||
is_default AS "isDefault",
|
is_default AS "isDefault",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy",
|
created_by AS "createdBy",
|
||||||
|
|
@ -129,6 +147,7 @@ class TableCategoryValueService {
|
||||||
logger.info("최고 관리자 카테고리 값 조회");
|
logger.info("최고 관리자 카테고리 값 조회");
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 카테고리 값만 조회
|
// 일반 회사: 자신의 카테고리 값만 조회
|
||||||
|
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||||
query = `
|
query = `
|
||||||
SELECT
|
SELECT
|
||||||
value_id AS "valueId",
|
value_id AS "valueId",
|
||||||
|
|
@ -145,6 +164,7 @@ class TableCategoryValueService {
|
||||||
is_active AS "isActive",
|
is_active AS "isActive",
|
||||||
is_default AS "isDefault",
|
is_default AS "isDefault",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy",
|
created_by AS "createdBy",
|
||||||
|
|
@ -173,6 +193,8 @@ class TableCategoryValueService {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
menuObjid,
|
||||||
|
scopeType: menuObjid ? "menu" : "table",
|
||||||
});
|
});
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
|
|
@ -183,17 +205,31 @@ class TableCategoryValueService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 추가
|
* 카테고리 값 추가 (메뉴 스코프)
|
||||||
|
*
|
||||||
|
* @param value 카테고리 값 정보
|
||||||
|
* @param companyCode 회사 코드
|
||||||
|
* @param userId 생성자 ID
|
||||||
|
* @param menuObjid 메뉴 OBJID (필수)
|
||||||
*/
|
*/
|
||||||
async addCategoryValue(
|
async addCategoryValue(
|
||||||
value: TableCategoryValue,
|
value: TableCategoryValue,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
menuObjid: number
|
||||||
): Promise<TableCategoryValue> {
|
): Promise<TableCategoryValue> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 중복 코드 체크 (멀티테넌시 적용)
|
logger.info("카테고리 값 추가 (메뉴 스코프)", {
|
||||||
|
tableName: value.tableName,
|
||||||
|
columnName: value.columnName,
|
||||||
|
valueCode: value.valueCode,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 중복 코드 체크 (멀티테넌시 + 메뉴 스코프)
|
||||||
let duplicateQuery: string;
|
let duplicateQuery: string;
|
||||||
let duplicateParams: any[];
|
let duplicateParams: any[];
|
||||||
|
|
||||||
|
|
@ -205,8 +241,9 @@ class TableCategoryValueService {
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
AND value_code = $3
|
AND value_code = $3
|
||||||
|
AND menu_objid = $4
|
||||||
`;
|
`;
|
||||||
duplicateParams = [value.tableName, value.columnName, value.valueCode];
|
duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid];
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 회사에서만 중복 체크
|
// 일반 회사: 자신의 회사에서만 중복 체크
|
||||||
duplicateQuery = `
|
duplicateQuery = `
|
||||||
|
|
@ -215,9 +252,10 @@ class TableCategoryValueService {
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
AND value_code = $3
|
AND value_code = $3
|
||||||
AND company_code = $4
|
AND menu_objid = $4
|
||||||
|
AND company_code = $5
|
||||||
`;
|
`;
|
||||||
duplicateParams = [value.tableName, value.columnName, value.valueCode, companyCode];
|
duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode];
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateResult = await pool.query(duplicateQuery, duplicateParams);
|
const duplicateResult = await pool.query(duplicateQuery, duplicateParams);
|
||||||
|
|
@ -230,8 +268,8 @@ class TableCategoryValueService {
|
||||||
INSERT INTO table_column_category_values (
|
INSERT INTO table_column_category_values (
|
||||||
table_name, column_name, value_code, value_label, value_order,
|
table_name, column_name, value_code, value_label, value_order,
|
||||||
parent_value_id, depth, description, color, icon,
|
parent_value_id, depth, description, color, icon,
|
||||||
is_active, is_default, company_code, created_by
|
is_active, is_default, company_code, menu_objid, created_by
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
RETURNING
|
RETURNING
|
||||||
value_id AS "valueId",
|
value_id AS "valueId",
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
|
|
@ -247,6 +285,7 @@ class TableCategoryValueService {
|
||||||
is_active AS "isActive",
|
is_active AS "isActive",
|
||||||
is_default AS "isDefault",
|
is_default AS "isDefault",
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
created_by AS "createdBy"
|
created_by AS "createdBy"
|
||||||
`;
|
`;
|
||||||
|
|
@ -265,6 +304,7 @@ class TableCategoryValueService {
|
||||||
value.isActive !== false,
|
value.isActive !== false,
|
||||||
value.isDefault || false,
|
value.isDefault || false,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
menuObjid, // ← 메뉴 OBJID 저장
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -272,6 +312,7 @@ class TableCategoryValueService {
|
||||||
valueId: result.rows[0].valueId,
|
valueId: result.rows[0].valueId,
|
||||||
tableName: value.tableName,
|
tableName: value.tableName,
|
||||||
columnName: value.columnName,
|
columnName: value.columnName,
|
||||||
|
menuObjid,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
|
|
|
||||||
|
|
@ -1069,12 +1069,28 @@ export class TableManagementService {
|
||||||
paramCount: number;
|
paramCount: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
|
// 🔧 {value, operator} 형태의 필터 객체 처리
|
||||||
|
let actualValue = value;
|
||||||
|
let operator = "contains"; // 기본값
|
||||||
|
|
||||||
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
|
actualValue = value.value;
|
||||||
|
operator = value.operator || "contains";
|
||||||
|
|
||||||
|
logger.info("🔍 필터 객체 처리:", {
|
||||||
|
columnName,
|
||||||
|
originalValue: value,
|
||||||
|
actualValue,
|
||||||
|
operator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
||||||
if (
|
if (
|
||||||
value === "__ALL__" ||
|
actualValue === "__ALL__" ||
|
||||||
value === "" ||
|
actualValue === "" ||
|
||||||
value === null ||
|
actualValue === null ||
|
||||||
value === undefined
|
actualValue === undefined
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1083,12 +1099,22 @@ export class TableManagementService {
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||||
|
|
||||||
if (!columnInfo) {
|
if (!columnInfo) {
|
||||||
// 컬럼 정보가 없으면 기본 문자열 검색
|
// 컬럼 정보가 없으면 operator에 따른 기본 검색
|
||||||
|
switch (operator) {
|
||||||
|
case "equals":
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
values: [`%${value}%`],
|
values: [actualValue],
|
||||||
paramCount: 1,
|
paramCount: 1,
|
||||||
};
|
};
|
||||||
|
case "contains":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
|
values: [`%${actualValue}%`],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const webType = columnInfo.webType;
|
const webType = columnInfo.webType;
|
||||||
|
|
@ -1097,17 +1123,17 @@ export class TableManagementService {
|
||||||
switch (webType) {
|
switch (webType) {
|
||||||
case "date":
|
case "date":
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
return this.buildDateRangeCondition(columnName, actualValue, paramIndex);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
case "decimal":
|
case "decimal":
|
||||||
return this.buildNumberRangeCondition(columnName, value, paramIndex);
|
return this.buildNumberRangeCondition(columnName, actualValue, paramIndex);
|
||||||
|
|
||||||
case "code":
|
case "code":
|
||||||
return await this.buildCodeSearchCondition(
|
return await this.buildCodeSearchCondition(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
value,
|
actualValue,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1115,15 +1141,15 @@ export class TableManagementService {
|
||||||
return await this.buildEntitySearchCondition(
|
return await this.buildEntitySearchCondition(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
value,
|
actualValue,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 기본 문자열 검색
|
// 기본 문자열 검색 (actualValue 사용)
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${value}%`],
|
values: [`%${actualValue}%`],
|
||||||
paramCount: 1,
|
paramCount: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1133,9 +1159,14 @@ export class TableManagementService {
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
// 오류 시 기본 검색으로 폴백
|
// 오류 시 기본 검색으로 폴백
|
||||||
|
let fallbackValue = value;
|
||||||
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
|
fallbackValue = value.value;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${value}%`],
|
values: [`%${fallbackValue}%`],
|
||||||
paramCount: 1,
|
paramCount: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1494,6 +1525,7 @@ export class TableManagementService {
|
||||||
search?: Record<string, any>;
|
search?: Record<string, any>;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -1503,7 +1535,7 @@ export class TableManagementService {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
|
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||||
|
|
@ -1517,6 +1549,14 @@ export class TableManagementService {
|
||||||
let searchValues: any[] = [];
|
let searchValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터 추가 (company_code)
|
||||||
|
if (companyCode) {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
searchValues.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (search && Object.keys(search).length > 0) {
|
if (search && Object.keys(search).length > 0) {
|
||||||
for (const [column, value] of Object.entries(search)) {
|
for (const [column, value] of Object.entries(search)) {
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
|
@ -2048,6 +2088,7 @@ export class TableManagementService {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
enableEntityJoin?: boolean;
|
enableEntityJoin?: boolean;
|
||||||
|
companyCode?: string; // 멀티테넌시 필터용
|
||||||
additionalJoinColumns?: Array<{
|
additionalJoinColumns?: Array<{
|
||||||
sourceTable: string;
|
sourceTable: string;
|
||||||
sourceColumn: string;
|
sourceColumn: string;
|
||||||
|
|
@ -2213,11 +2254,20 @@ export class TableManagementService {
|
||||||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||||
|
|
||||||
// WHERE 절 구성
|
// WHERE 절 구성
|
||||||
const whereClause = await this.buildWhereClause(
|
let whereClause = await this.buildWhereClause(
|
||||||
tableName,
|
tableName,
|
||||||
options.search
|
options.search
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 멀티테넌시 필터 추가 (company_code)
|
||||||
|
if (options.companyCode) {
|
||||||
|
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
|
||||||
|
whereClause = whereClause
|
||||||
|
? `${whereClause} AND ${companyFilter}`
|
||||||
|
: companyFilter;
|
||||||
|
logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ORDER BY 절 구성
|
// ORDER BY 절 구성
|
||||||
const orderBy = options.sortBy
|
const orderBy = options.sortBy
|
||||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
|
|
@ -2343,6 +2393,7 @@ export class TableManagementService {
|
||||||
search?: Record<string, any>;
|
search?: Record<string, any>;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
|
companyCode?: string;
|
||||||
},
|
},
|
||||||
startTime: number
|
startTime: number
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
|
|
@ -2530,11 +2581,11 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
basicResult = await this.getTableData(tableName, fallbackOptions);
|
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
||||||
basicResult = await this.getTableData(tableName, options);
|
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 값들을 캐시에서 룩업하여 변환
|
// Entity 값들을 캐시에서 룩업하여 변환
|
||||||
|
|
@ -2807,10 +2858,14 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
||||||
else {
|
else {
|
||||||
|
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
||||||
|
const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/);
|
||||||
|
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
||||||
|
|
||||||
return await this.executeCachedLookup(
|
return await this.executeCachedLookup(
|
||||||
tableName,
|
tableName,
|
||||||
cacheableJoins,
|
cacheableJoins,
|
||||||
{ page: Math.floor(offset / limit) + 1, size: limit, search: {} },
|
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2831,6 +2886,13 @@ export class TableManagementService {
|
||||||
const dbJoins: EntityJoinConfig[] = [];
|
const dbJoins: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
|
if (config.referenceTable === 'table_column_category_values') {
|
||||||
|
dbJoins.push(config);
|
||||||
|
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 캐시 가능성 확인
|
// 캐시 가능성 확인
|
||||||
const cachedData = await referenceCacheService.getCachedReference(
|
const cachedData = await referenceCacheService.getCachedReference(
|
||||||
config.referenceTable,
|
config.referenceTable,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
|
|
@ -18,18 +18,27 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
|
||||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
||||||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
||||||
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
||||||
|
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
||||||
|
|
||||||
export default function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const screenId = parseInt(params.screenId as string);
|
const screenId = parseInt(params.screenId as string);
|
||||||
|
|
||||||
|
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||||
|
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||||
|
|
||||||
// 🆕 현재 로그인한 사용자 정보
|
// 🆕 현재 로그인한 사용자 정보
|
||||||
const { user, userName, companyCode } = useAuth();
|
const { user, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
// 🆕 모바일 환경 감지
|
// 🆕 모바일 환경 감지
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
|
||||||
|
// 🆕 TableSearchWidget 높이 관리
|
||||||
|
const { getHeightDiff } = useTableSearchWidgetHeight();
|
||||||
|
|
||||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -294,6 +303,7 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
|
<TableOptionsProvider>
|
||||||
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden">
|
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden">
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
|
|
@ -387,10 +397,49 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||||
|
|
||||||
|
// TableSearchWidget들을 먼저 찾기
|
||||||
|
const tableSearchWidgets = regularComponents.filter(
|
||||||
|
(c) => (c as any).componentId === "table-search-widget"
|
||||||
|
);
|
||||||
|
|
||||||
|
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
|
||||||
|
const adjustedComponents = regularComponents.map((component) => {
|
||||||
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||||
|
|
||||||
|
if (isTableSearchWidget) {
|
||||||
|
// TableSearchWidget 자체는 조정하지 않음
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalHeightAdjustment = 0;
|
||||||
|
|
||||||
|
for (const widget of tableSearchWidgets) {
|
||||||
|
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
|
||||||
|
const isBelow = component.position.y > widget.position.y;
|
||||||
|
const heightDiff = getHeightDiff(screenId, widget.id);
|
||||||
|
|
||||||
|
if (isBelow && heightDiff > 0) {
|
||||||
|
totalHeightAdjustment += heightDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalHeightAdjustment > 0) {
|
||||||
|
return {
|
||||||
|
...component,
|
||||||
|
position: {
|
||||||
|
...component.position,
|
||||||
|
y: component.position.y + totalHeightAdjustment,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return component;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 일반 컴포넌트들 */}
|
{/* 일반 컴포넌트들 */}
|
||||||
{regularComponents.map((component) => {
|
{adjustedComponents.map((component) => {
|
||||||
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
||||||
return (
|
return (
|
||||||
<RealtimePreview
|
<RealtimePreview
|
||||||
|
|
@ -399,11 +448,13 @@ export default function ScreenViewPage() {
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
|
menuObjid={menuObjid}
|
||||||
screenId={screenId}
|
screenId={screenId}
|
||||||
tableName={screen?.tableName}
|
tableName={screen?.tableName}
|
||||||
userId={user?.userId}
|
userId={user?.userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
|
menuObjid={menuObjid}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
sortBy={tableSortBy}
|
sortBy={tableSortBy}
|
||||||
sortOrder={tableSortOrder}
|
sortOrder={tableSortOrder}
|
||||||
|
|
@ -463,11 +514,13 @@ export default function ScreenViewPage() {
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
|
menuObjid={menuObjid}
|
||||||
screenId={screenId}
|
screenId={screenId}
|
||||||
tableName={screen?.tableName}
|
tableName={screen?.tableName}
|
||||||
userId={user?.userId}
|
userId={user?.userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
|
menuObjid={menuObjid}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
sortBy={tableSortBy}
|
sortBy={tableSortBy}
|
||||||
sortOrder={tableSortOrder}
|
sortOrder={tableSortOrder}
|
||||||
|
|
@ -698,6 +751,18 @@ export default function ScreenViewPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</TableOptionsProvider>
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 실제 컴포넌트를 Provider로 감싸기
|
||||||
|
function ScreenViewPageWrapper() {
|
||||||
|
return (
|
||||||
|
<TableSearchWidgetHeightProvider>
|
||||||
|
<ScreenViewPage />
|
||||||
|
</TableSearchWidgetHeightProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScreenViewPageWrapper;
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,18 @@ select:focus-visible {
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TableSearchWidget의 SelectTrigger 포커스 스타일 제거 */
|
||||||
|
[role="combobox"]:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[role="combobox"]:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-color: hsl(var(--input)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Scrollbar Styles (Optional) ===== */
|
/* ===== Scrollbar Styles (Optional) ===== */
|
||||||
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
|
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|
|
||||||
|
|
@ -853,7 +853,9 @@ export function CanvasElement({
|
||||||
)}
|
)}
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
{!element.type || element.type !== "chart" ? (
|
{!element.type || element.type !== "chart" ? (
|
||||||
|
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
setCustomTitle(element.customTitle || "");
|
setCustomTitle(element.customTitle || "");
|
||||||
setShowHeader(element.showHeader !== false);
|
setShowHeader(element.showHeader !== false);
|
||||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||||
setDataSources(element.dataSources || []);
|
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴
|
||||||
|
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
|
||||||
setQueryResult(null);
|
setQueryResult(null);
|
||||||
|
|
||||||
// 리스트 위젯 설정 초기화
|
// 리스트 위젯 설정 초기화
|
||||||
|
|
@ -297,10 +298,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
...(needsDataSource(element.subtype)
|
...(needsDataSource(element.subtype)
|
||||||
? {
|
? {
|
||||||
dataSource,
|
dataSource,
|
||||||
// 다중 데이터 소스 위젯은 dataSources도 포함
|
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
...(isMultiDataSourceWidget
|
...(isMultiDataSourceWidget
|
||||||
? {
|
? {
|
||||||
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
|
dataSources: dataSources,
|
||||||
|
// chartConfig에도 dataSources 포함 (일부 위젯은 chartConfig에서 읽음)
|
||||||
|
chartConfig: { ...chartConfig, dataSources: dataSources },
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
@ -316,14 +319,14 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
element.subtype === "chart" ||
|
element.subtype === "chart" ||
|
||||||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
|
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
|
||||||
? {
|
? {
|
||||||
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함
|
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
chartConfig: isMultiDataSourceWidget
|
chartConfig: isMultiDataSourceWidget
|
||||||
? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] }
|
? { ...chartConfig, dataSources: dataSources }
|
||||||
: chartConfig,
|
: chartConfig,
|
||||||
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함
|
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
...(isMultiDataSourceWidget
|
...(isMultiDataSourceWidget
|
||||||
? {
|
? {
|
||||||
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
|
dataSources: dataSources,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
@ -520,7 +523,39 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 지도 설정 */}
|
{/* 지도 설정 */}
|
||||||
{element.subtype === "map-summary-v2" && <MapConfigSection queryResult={queryResult} />}
|
{element.subtype === "map-summary-v2" && (
|
||||||
|
<MapConfigSection
|
||||||
|
queryResult={queryResult}
|
||||||
|
refreshInterval={element.chartConfig?.refreshInterval || 5}
|
||||||
|
markerType={element.chartConfig?.markerType || "circle"}
|
||||||
|
onRefreshIntervalChange={(interval) => {
|
||||||
|
setElement((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
chartConfig: {
|
||||||
|
...prev.chartConfig,
|
||||||
|
refreshInterval: interval,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onMarkerTypeChange={(type) => {
|
||||||
|
setElement((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
chartConfig: {
|
||||||
|
...prev.chartConfig,
|
||||||
|
markerType: type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 리스크 알림 설정 */}
|
{/* 리스크 알림 설정 */}
|
||||||
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
|
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
|
||||||
|
|
@ -534,7 +569,22 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
|
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleApply} className="h-9 flex-1 text-sm">
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-9 flex-1 text-sm"
|
||||||
|
disabled={
|
||||||
|
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화
|
||||||
|
// (데이터 소스가 없는 건 OK - 연결 해제하는 경우)
|
||||||
|
(element?.subtype === "map-summary-v2" ||
|
||||||
|
element?.subtype === "chart" ||
|
||||||
|
element?.subtype === "list-v2" ||
|
||||||
|
element?.subtype === "custom-metric-v2" ||
|
||||||
|
element?.subtype === "risk-alert-v2") &&
|
||||||
|
dataSources &&
|
||||||
|
dataSources.length > 0 &&
|
||||||
|
dataSources.some(ds => ds.type === "api" && !ds.endpoint)
|
||||||
|
}
|
||||||
|
>
|
||||||
적용
|
적용
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -530,31 +530,50 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 자동 새로고침 설정 */}
|
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
<Label htmlFor="marker-refresh-interval" className="text-xs">
|
||||||
자동 새로고침 간격
|
마커 새로고침 간격
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(dataSource.refreshInterval || 0)}
|
value={(dataSource.refreshInterval ?? 5).toString()}
|
||||||
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger id="marker-refresh-interval" className="h-9 text-xs">
|
||||||
<SelectValue placeholder="새로고침 안 함" />
|
<SelectValue placeholder="간격 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0">새로고침 안 함</SelectItem>
|
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||||
<SelectItem value="10">10초마다</SelectItem>
|
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||||
<SelectItem value="30">30초마다</SelectItem>
|
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||||
<SelectItem value="60">1분마다</SelectItem>
|
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||||
<SelectItem value="300">5분마다</SelectItem>
|
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||||
<SelectItem value="600">10분마다</SelectItem>
|
|
||||||
<SelectItem value="1800">30분마다</SelectItem>
|
|
||||||
<SelectItem value="3600">1시간마다</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
설정한 간격마다 자동으로 데이터를 다시 불러옵니다
|
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 마커 종류 선택 (MapTestWidgetV2 전용) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="marker-type" className="text-xs">
|
||||||
|
마커 종류
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={dataSource.markerType || "circle"}
|
||||||
|
onValueChange={(value) => onChange({ markerType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="마커 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||||
|
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
지도에 표시할 마커의 모양을 선택합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -892,6 +911,128 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */}
|
||||||
|
{availableColumns.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="popup-fields" className="text-xs">
|
||||||
|
팝업 표시 필드
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 기존 팝업 필드 목록 */}
|
||||||
|
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dataSource.popupFields.map((field, index) => (
|
||||||
|
<div key={index} className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">필드 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.splice(index, 1);
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">필드명</Label>
|
||||||
|
<Select
|
||||||
|
value={field.fieldName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].fieldName = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">한글 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].label = e.target.value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
placeholder="예: 차량 번호"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포맷 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">표시 포맷</Label>
|
||||||
|
<Select
|
||||||
|
value={field.format || "text"}
|
||||||
|
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].format = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text" className="text-xs">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number" className="text-xs">숫자</SelectItem>
|
||||||
|
<SelectItem value="date" className="text-xs">날짜</SelectItem>
|
||||||
|
<SelectItem value="datetime" className="text-xs">날짜시간</SelectItem>
|
||||||
|
<SelectItem value="url" className="text-xs">URL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.push({
|
||||||
|
fieldName: availableColumns[0] || "",
|
||||||
|
label: "",
|
||||||
|
format: "text",
|
||||||
|
});
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-8 w-full gap-2 text-xs"
|
||||||
|
disabled={availableColumns.length === 0}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Loader2, CheckCircle, XCircle } from "lucide-react";
|
import { Loader2, CheckCircle, XCircle, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
interface MultiDatabaseConfigProps {
|
interface MultiDatabaseConfigProps {
|
||||||
dataSource: ChartDataSource;
|
dataSource: ChartDataSource;
|
||||||
|
|
@ -47,11 +47,13 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||||
|
|
||||||
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
||||||
setExternalConnections(connections.map((conn: any) => ({
|
setExternalConnections(
|
||||||
|
connections.map((conn: any) => ({
|
||||||
id: String(conn.id),
|
id: String(conn.id),
|
||||||
name: conn.connection_name,
|
name: conn.connection_name,
|
||||||
type: conn.db_type,
|
type: conn.db_type,
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
||||||
setExternalConnections([]);
|
setExternalConnections([]);
|
||||||
|
|
@ -79,7 +81,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||||
parseInt(dataSource.externalConnectionId),
|
parseInt(dataSource.externalConnectionId),
|
||||||
dataSource.query
|
dataSource.query,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
|
|
@ -93,7 +95,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
|
|
||||||
// 컬럼 타입 분석
|
// 컬럼 타입 분석
|
||||||
const types: Record<string, string> = {};
|
const types: Record<string, string> = {};
|
||||||
columns.forEach(col => {
|
columns.forEach((col) => {
|
||||||
const value = rows[0][col];
|
const value = rows[0][col];
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
types[col] = "unknown";
|
types[col] = "unknown";
|
||||||
|
|
@ -142,7 +144,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
|
|
||||||
// 컬럼 타입 분석
|
// 컬럼 타입 분석
|
||||||
const types: Record<string, string> = {};
|
const types: Record<string, string> = {};
|
||||||
columns.forEach(col => {
|
columns.forEach((col) => {
|
||||||
const value = result.rows[0][col];
|
const value = result.rows[0][col];
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
types[col] = "unknown";
|
types[col] = "unknown";
|
||||||
|
|
@ -194,25 +196,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
<Label className="text-xs">데이터베이스 연결</Label>
|
<Label className="text-xs">데이터베이스 연결</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={dataSource.connectionType || "current"}
|
value={dataSource.connectionType || "current"}
|
||||||
onValueChange={(value: "current" | "external") =>
|
onValueChange={(value: "current" | "external") => onChange({ connectionType: value })}
|
||||||
onChange({ connectionType: value })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="current" id={`current-\${dataSource.id}`} />
|
<RadioGroupItem value="current" id={"current-${dataSource.id}"} />
|
||||||
<Label
|
<Label htmlFor={"current-${dataSource.id}"} className="text-xs font-normal">
|
||||||
htmlFor={`current-\${dataSource.id}`}
|
|
||||||
className="text-xs font-normal"
|
|
||||||
>
|
|
||||||
현재 데이터베이스
|
현재 데이터베이스
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="external" id={`external-\${dataSource.id}`} />
|
<RadioGroupItem value="external" id={"external-${dataSource.id}"} />
|
||||||
<Label
|
<Label htmlFor={"external-${dataSource.id}"} className="text-xs font-normal">
|
||||||
htmlFor={`external-\${dataSource.id}`}
|
|
||||||
className="text-xs font-normal"
|
|
||||||
>
|
|
||||||
외부 데이터베이스
|
외부 데이터베이스
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,12 +216,12 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
{/* 외부 DB 선택 */}
|
{/* 외부 DB 선택 */}
|
||||||
{dataSource.connectionType === "external" && (
|
{dataSource.connectionType === "external" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`external-conn-\${dataSource.id}`} className="text-xs">
|
<Label htmlFor={"external-conn-${dataSource.id}"} className="text-xs">
|
||||||
외부 데이터베이스 선택 *
|
외부 데이터베이스 선택 *
|
||||||
</Label>
|
</Label>
|
||||||
{loadingConnections ? (
|
{loadingConnections ? (
|
||||||
<div className="flex h-10 items-center justify-center rounded-md border">
|
<div className="flex h-10 items-center justify-center rounded-md border">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -252,10 +246,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
||||||
{/* SQL 쿼리 */}
|
{/* SQL 쿼리 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
<Label htmlFor={"query-${dataSource.id}"} className="text-xs">
|
||||||
SQL 쿼리 *
|
SQL 쿼리 *
|
||||||
</Label>
|
</Label>
|
||||||
<Select onValueChange={(value) => {
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
const samples = {
|
const samples = {
|
||||||
users: `SELECT
|
users: `SELECT
|
||||||
dept_name as 부서명,
|
dept_name as 부서명,
|
||||||
|
|
@ -293,21 +288,32 @@ GROUP BY parent_dept_code
|
||||||
ORDER BY 하위부서수 DESC`,
|
ORDER BY 하위부서수 DESC`,
|
||||||
};
|
};
|
||||||
onChange({ query: samples[value as keyof typeof samples] || "" });
|
onChange({ query: samples[value as keyof typeof samples] || "" });
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-7 w-32 text-xs">
|
<SelectTrigger className="h-7 w-32 text-xs">
|
||||||
<SelectValue placeholder="샘플 쿼리" />
|
<SelectValue placeholder="샘플 쿼리" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="users" className="text-xs">부서별 회원수</SelectItem>
|
<SelectItem value="users" className="text-xs">
|
||||||
<SelectItem value="dept" className="text-xs">부서 목록</SelectItem>
|
부서별 회원수
|
||||||
<SelectItem value="usersByDate" className="text-xs">월별 신규사용자</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="usersByPosition" className="text-xs">직급별 인원수</SelectItem>
|
<SelectItem value="dept" className="text-xs">
|
||||||
<SelectItem value="deptHierarchy" className="text-xs">부서 계층구조</SelectItem>
|
부서 목록
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="usersByDate" className="text-xs">
|
||||||
|
월별 신규사용자
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="usersByPosition" className="text-xs">
|
||||||
|
직급별 인원수
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deptHierarchy" className="text-xs">
|
||||||
|
부서 계층구조
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`query-\${dataSource.id}`}
|
id={"query-${dataSource.id}"}
|
||||||
value={dataSource.query || ""}
|
value={dataSource.query || ""}
|
||||||
onChange={(e) => onChange({ query: e.target.value })}
|
onChange={(e) => onChange({ query: e.target.value })}
|
||||||
placeholder="SELECT * FROM table_name WHERE ..."
|
placeholder="SELECT * FROM table_name WHERE ..."
|
||||||
|
|
@ -315,33 +321,41 @@ ORDER BY 하위부서수 DESC`,
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 자동 새로고침 설정 */}
|
{/* 마커 polling 간격 설정 (MapTestWidgetV2 전용) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
<Label htmlFor="marker-refresh-interval" className="text-xs">
|
||||||
자동 새로고침 간격
|
데이터 새로고침 간격
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(dataSource.refreshInterval || 0)}
|
value={(dataSource.refreshInterval ?? 5).toString()}
|
||||||
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger id="marker-refresh-interval" className="h-8 text-xs">
|
||||||
<SelectValue placeholder="새로고침 안 함" />
|
<SelectValue placeholder="간격 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0">새로고침 안 함</SelectItem>
|
<SelectItem value="0" className="text-xs">
|
||||||
<SelectItem value="10">10초마다</SelectItem>
|
없음
|
||||||
<SelectItem value="30">30초마다</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="60">1분마다</SelectItem>
|
<SelectItem value="5" className="text-xs">
|
||||||
<SelectItem value="300">5분마다</SelectItem>
|
5초
|
||||||
<SelectItem value="600">10분마다</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="1800">30분마다</SelectItem>
|
<SelectItem value="10" className="text-xs">
|
||||||
<SelectItem value="3600">1시간마다</SelectItem>
|
10초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">
|
||||||
|
30초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">
|
||||||
|
1분
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-[10px]">마커 데이터를 자동으로 갱신하는 주기를 설정합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-2">
|
<div className="bg-muted/30 space-y-2 rounded-lg border p-2">
|
||||||
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
||||||
|
|
||||||
{/* 색상 팔레트 */}
|
{/* 색상 팔레트 */}
|
||||||
|
|
@ -361,11 +375,13 @@ ORDER BY 하위부서수 DESC`,
|
||||||
<button
|
<button
|
||||||
key={color.name}
|
key={color.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange({
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
markerColor: color.marker,
|
markerColor: color.marker,
|
||||||
polygonColor: color.polygon,
|
polygonColor: color.polygon,
|
||||||
polygonOpacity: 0.5,
|
polygonOpacity: 0.5,
|
||||||
})}
|
})
|
||||||
|
}
|
||||||
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
|
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary bg-primary/10 shadow-md"
|
? "border-primary bg-primary/10 shadow-md"
|
||||||
|
|
@ -405,21 +421,13 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
||||||
testResult.success
|
testResult.success ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
? "bg-success/10 text-success"
|
|
||||||
: "bg-destructive/10 text-destructive"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{testResult.success ? (
|
{testResult.success ? <CheckCircle className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
|
||||||
<CheckCircle className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
{testResult.message}
|
{testResult.message}
|
||||||
{testResult.rowCount !== undefined && (
|
{testResult.rowCount !== undefined && <span className="ml-1">({testResult.rowCount}행)</span>}
|
||||||
<span className="ml-1">({testResult.rowCount}행)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -431,7 +439,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||||
? `${dataSource.selectedColumns.length}개 선택됨`
|
? `${dataSource.selectedColumns.length}개 선택됨`
|
||||||
: "모든 컬럼 표시"}
|
: "모든 컬럼 표시"}
|
||||||
|
|
@ -468,12 +476,9 @@ ORDER BY 하위부서수 DESC`,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 카드 그리드 */}
|
{/* 컬럼 카드 그리드 */}
|
||||||
<div className="grid grid-cols-1 gap-1.5 max-h-60 overflow-y-auto">
|
<div className="grid max-h-60 grid-cols-1 gap-1.5 overflow-y-auto">
|
||||||
{availableColumns
|
{availableColumns
|
||||||
.filter(col =>
|
.filter((col) => !columnSearchTerm || col.toLowerCase().includes(columnSearchTerm.toLowerCase()))
|
||||||
!columnSearchTerm ||
|
|
||||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
!dataSource.selectedColumns ||
|
!dataSource.selectedColumns ||
|
||||||
|
|
@ -487,7 +492,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
date: "📅",
|
date: "📅",
|
||||||
boolean: "✓",
|
boolean: "✓",
|
||||||
object: "📦",
|
object: "📦",
|
||||||
unknown: "❓"
|
unknown: "❓",
|
||||||
}[type];
|
}[type];
|
||||||
|
|
||||||
const typeColor = {
|
const typeColor = {
|
||||||
|
|
@ -496,42 +501,44 @@ ORDER BY 하위부서수 DESC`,
|
||||||
date: "text-primary bg-primary/10",
|
date: "text-primary bg-primary/10",
|
||||||
boolean: "text-success bg-success/10",
|
boolean: "text-success bg-success/10",
|
||||||
object: "text-warning bg-warning/10",
|
object: "text-warning bg-warning/10",
|
||||||
unknown: "text-muted-foreground bg-muted"
|
unknown: "text-muted-foreground bg-muted",
|
||||||
}[type];
|
}[type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col}
|
key={col}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
const currentSelected =
|
||||||
|
dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||||
? dataSource.selectedColumns
|
? dataSource.selectedColumns
|
||||||
: availableColumns;
|
: availableColumns;
|
||||||
|
|
||||||
const newSelected = isSelected
|
const newSelected = isSelected
|
||||||
? currentSelected.filter(c => c !== col)
|
? currentSelected.filter((c) => c !== col)
|
||||||
: [...currentSelected, col];
|
: [...currentSelected, col];
|
||||||
|
|
||||||
onChange({ selectedColumns: newSelected });
|
onChange({ selectedColumns: newSelected });
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`relative flex cursor-pointer items-start gap-2 rounded-lg border p-2 transition-all ${
|
||||||
relative flex items-start gap-2 rounded-lg border p-2 cursor-pointer transition-all
|
isSelected
|
||||||
${isSelected
|
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
||||||
}
|
} `}
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
<div className="mt-0.5 flex-shrink-0">
|
||||||
<div className={`
|
<div
|
||||||
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
className={`flex h-4 w-4 items-center justify-center rounded border-2 transition-colors ${
|
||||||
${isSelected
|
isSelected ? "border-primary bg-primary" : "border-border bg-background"
|
||||||
? "border-primary bg-primary"
|
} `}
|
||||||
: "border-border bg-background"
|
>
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
|
className="text-primary-foreground h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
@ -539,17 +546,17 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 정보 */}
|
{/* 컬럼 정보 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium truncate">{col}</span>
|
<span className="truncate text-sm font-medium">{col}</span>
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${typeColor}`}>
|
<span className={`rounded px-1.5 py-0.5 text-xs ${typeColor}`}>
|
||||||
{typeIcon} {type}
|
{typeIcon} {type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 샘플 데이터 */}
|
{/* 샘플 데이터 */}
|
||||||
{sampleData.length > 0 && (
|
{sampleData.length > 0 && (
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-1.5 text-xs">
|
||||||
<span className="font-medium">예시:</span>{" "}
|
<span className="font-medium">예시:</span>{" "}
|
||||||
{sampleData.slice(0, 2).map((row, i) => (
|
{sampleData.slice(0, 2).map((row, i) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
|
|
@ -567,10 +574,10 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 결과 없음 */}
|
{/* 검색 결과 없음 */}
|
||||||
{columnSearchTerm && availableColumns.filter(col =>
|
{columnSearchTerm &&
|
||||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
availableColumns.filter((col) => col.toLowerCase().includes(columnSearchTerm.toLowerCase())).length ===
|
||||||
).length === 0 && (
|
0 && (
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -579,21 +586,16 @@ ORDER BY 하위부서수 DESC`,
|
||||||
|
|
||||||
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
||||||
{testResult?.success && availableColumns.length > 0 && (
|
{testResult?.success && availableColumns.length > 0 && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
<div className="bg-muted/30 space-y-3 rounded-lg border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => onChange({ columnMapping: {} })} className="h-7 text-xs">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onChange({ columnMapping: {} })}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
>
|
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -605,11 +607,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
||||||
<div key={original} className="flex items-center gap-2">
|
<div key={original} className="flex items-center gap-2">
|
||||||
{/* 원본 컬럼 (읽기 전용) */}
|
{/* 원본 컬럼 (읽기 전용) */}
|
||||||
<Input
|
<Input value={original} disabled className="bg-muted h-8 flex-1 text-xs" />
|
||||||
value={original}
|
|
||||||
disabled
|
|
||||||
className="h-8 flex-1 text-xs bg-muted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 화살표 */}
|
{/* 화살표 */}
|
||||||
<span className="text-muted-foreground text-xs">→</span>
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
|
@ -658,18 +656,147 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableColumns
|
{availableColumns
|
||||||
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
.filter((col) => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
||||||
.map(col => (
|
.map((col) => (
|
||||||
<SelectItem key={col} value={col} className="text-xs">
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
{col}
|
{col}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다</p>
|
||||||
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */}
|
||||||
|
{availableColumns.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="popup-fields" className="text-xs">
|
||||||
|
팝업 표시 필드
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 기존 팝업 필드 목록 */}
|
||||||
|
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dataSource.popupFields.map((field, index) => (
|
||||||
|
<div key={index} className="bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">필드 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.splice(index, 1);
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">필드명</Label>
|
||||||
|
<Select
|
||||||
|
value={field.fieldName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].fieldName = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">한글 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].label = e.target.value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
placeholder="예: 차량 번호"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포맷 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">표시 포맷</Label>
|
||||||
|
<Select
|
||||||
|
value={field.format || "text"}
|
||||||
|
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields[index].format = value;
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text" className="text-xs">
|
||||||
|
텍스트
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="number" className="text-xs">
|
||||||
|
숫자
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="date" className="text-xs">
|
||||||
|
날짜
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="datetime" className="text-xs">
|
||||||
|
날짜시간
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="url" className="text-xs">
|
||||||
|
URL
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필드 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newFields = [...(dataSource.popupFields || [])];
|
||||||
|
newFields.push({
|
||||||
|
fieldName: availableColumns[0] || "",
|
||||||
|
label: "",
|
||||||
|
format: "text",
|
||||||
|
});
|
||||||
|
onChange({ popupFields: newFields });
|
||||||
|
}}
|
||||||
|
className="h-8 w-full gap-2 text-xs"
|
||||||
|
disabled={availableColumns.length === 0}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export type ElementSubtype =
|
||||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||||
| "work-history" // 작업 이력 위젯
|
| "work-history" // 작업 이력 위젯
|
||||||
| "transport-stats"; // 커스텀 통계 카드 위젯
|
| "transport-stats"; // 커스텀 통계 카드 위젯
|
||||||
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
||||||
|
|
||||||
// 차트 분류
|
// 차트 분류
|
||||||
export type ChartCategory = "axis-based" | "circular";
|
export type ChartCategory = "axis-based" | "circular";
|
||||||
|
|
@ -164,12 +164,20 @@ export interface ChartDataSource {
|
||||||
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
||||||
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
||||||
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
||||||
|
markerType?: string; // 마커 종류 (circle, arrow)
|
||||||
|
|
||||||
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
||||||
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
||||||
|
|
||||||
// 메트릭 설정 (CustomMetricTestWidget용)
|
// 메트릭 설정 (CustomMetricTestWidget용)
|
||||||
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
||||||
|
|
||||||
|
// 지도 팝업 설정 (MapTestWidgetV2용)
|
||||||
|
popupFields?: {
|
||||||
|
fieldName: string; // DB 컬럼명 (예: vehicle_number)
|
||||||
|
label: string; // 표시할 한글명 (예: 차량 번호)
|
||||||
|
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartConfig {
|
export interface ChartConfig {
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,30 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { QueryResult } from "../types";
|
import { QueryResult } from "../types";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface MapConfigSectionProps {
|
interface MapConfigSectionProps {
|
||||||
queryResult: QueryResult | null;
|
queryResult: QueryResult | null;
|
||||||
|
refreshInterval?: number;
|
||||||
|
markerType?: string;
|
||||||
|
onRefreshIntervalChange?: (interval: number) => void;
|
||||||
|
onMarkerTypeChange?: (type: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지도 위젯 설정 섹션
|
* 지도 위젯 설정 섹션
|
||||||
* - 위도/경도 매핑
|
* - 자동 새로고침 간격 설정
|
||||||
*
|
* - 마커 종류 선택
|
||||||
* TODO: 상세 설정 UI 추가 필요
|
|
||||||
*/
|
*/
|
||||||
export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
|
export function MapConfigSection({
|
||||||
|
queryResult,
|
||||||
|
refreshInterval = 5,
|
||||||
|
markerType = "circle",
|
||||||
|
onRefreshIntervalChange,
|
||||||
|
onMarkerTypeChange
|
||||||
|
}: MapConfigSectionProps) {
|
||||||
// 쿼리 결과가 없으면 안내 메시지 표시
|
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||||
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,13 +44,56 @@ export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
<Label className="mb-3 block text-xs font-semibold">지도 설정</Label>
|
||||||
<Alert>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
<div className="space-y-3">
|
||||||
<AlertDescription className="text-xs">
|
{/* 자동 새로고침 간격 */}
|
||||||
지도 상세 설정 UI는 추후 추가 예정입니다.
|
<div className="space-y-1.5">
|
||||||
</AlertDescription>
|
<Label htmlFor="refresh-interval" className="text-xs">
|
||||||
</Alert>
|
자동 새로고침 간격
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={refreshInterval.toString()}
|
||||||
|
onValueChange={(value) => onRefreshIntervalChange?.(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="refresh-interval" className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="새로고침 간격 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||||
|
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||||
|
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 마커 종류 선택 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="marker-type" className="text-xs">
|
||||||
|
마커 종류
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={markerType}
|
||||||
|
onValueChange={(value) => onMarkerTypeChange?.(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="마커 종류 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||||
|
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
지도에 표시할 마커의 모양을 선택합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import {
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
// 위젯 동적 import - 모든 위젯
|
// 위젯 동적 import - 모든 위젯
|
||||||
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
// const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
||||||
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
// const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
||||||
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
||||||
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
||||||
const ListTestWidget = dynamic(
|
const ListTestWidget = dynamic(
|
||||||
|
|
@ -27,7 +27,7 @@ const ListTestWidget = dynamic(
|
||||||
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
||||||
const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
|
const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
|
||||||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
||||||
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
// const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
||||||
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
||||||
const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false });
|
const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false });
|
||||||
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
|
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
|
||||||
|
|
@ -51,10 +51,10 @@ const ClockWidget = dynamic(
|
||||||
() => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
|
() => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
const ListWidget = dynamic(
|
// const ListWidget = dynamic(
|
||||||
() => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
|
// () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
|
||||||
{ ssr: false },
|
// { ssr: false },
|
||||||
);
|
// );
|
||||||
|
|
||||||
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
|
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -68,9 +68,9 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
|
// const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
|
||||||
ssr: false,
|
// ssr: false,
|
||||||
});
|
// });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
|
||||||
|
|
@ -91,10 +91,10 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <CalculatorWidget element={element} />;
|
return <CalculatorWidget element={element} />;
|
||||||
case "clock":
|
case "clock":
|
||||||
return <ClockWidget element={element} />;
|
return <ClockWidget element={element} />;
|
||||||
case "map-summary":
|
// case "map-summary":
|
||||||
return <MapSummaryWidget element={element} />;
|
// return <MapSummaryWidget element={element} />;
|
||||||
case "map-test":
|
// case "map-test":
|
||||||
return <MapTestWidget element={element} />;
|
// return <MapTestWidget element={element} />;
|
||||||
case "map-summary-v2":
|
case "map-summary-v2":
|
||||||
return <MapTestWidgetV2 element={element} />;
|
return <MapTestWidgetV2 element={element} />;
|
||||||
case "chart":
|
case "chart":
|
||||||
|
|
@ -105,14 +105,14 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <CustomMetricTestWidget element={element} />;
|
return <CustomMetricTestWidget element={element} />;
|
||||||
case "risk-alert-v2":
|
case "risk-alert-v2":
|
||||||
return <RiskAlertTestWidget element={element} />;
|
return <RiskAlertTestWidget element={element} />;
|
||||||
case "risk-alert":
|
// case "risk-alert":
|
||||||
return <RiskAlertWidget element={element} />;
|
// return <RiskAlertWidget element={element} />;
|
||||||
case "calendar":
|
case "calendar":
|
||||||
return <CalendarWidget element={element} />;
|
return <CalendarWidget element={element} />;
|
||||||
case "status-summary":
|
case "status-summary":
|
||||||
return <StatusSummaryWidget element={element} />;
|
return <StatusSummaryWidget element={element} />;
|
||||||
case "custom-metric":
|
// case "custom-metric":
|
||||||
return <CustomMetricWidget element={element} />;
|
// return <CustomMetricWidget element={element} />;
|
||||||
|
|
||||||
// === 운영/작업 지원 ===
|
// === 운영/작업 지원 ===
|
||||||
case "todo":
|
case "todo":
|
||||||
|
|
@ -122,8 +122,8 @@ function renderWidget(element: DashboardElement) {
|
||||||
return <BookingAlertWidget element={element} />;
|
return <BookingAlertWidget element={element} />;
|
||||||
case "document":
|
case "document":
|
||||||
return <DocumentWidget element={element} />;
|
return <DocumentWidget element={element} />;
|
||||||
case "list":
|
// case "list":
|
||||||
return <ListWidget element={element} />;
|
// return <ListWidget element={element} />;
|
||||||
|
|
||||||
case "yard-management-3d":
|
case "yard-management-3d":
|
||||||
// console.log("🏗️ 야드관리 위젯 렌더링:", {
|
// console.log("🏗️ 야드관리 위젯 렌더링:", {
|
||||||
|
|
@ -171,7 +171,7 @@ function renderWidget(element: DashboardElement) {
|
||||||
// === 기본 fallback ===
|
// === 기본 fallback ===
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted-foreground p-4 text-white">
|
<div className="from-muted to-muted-foreground flex h-full w-full items-center justify-center bg-gradient-to-br p-4 text-white">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-3xl">❓</div>
|
<div className="mb-2 text-3xl">❓</div>
|
||||||
<div className="text-sm">알 수 없는 위젯 타입: {element.subtype}</div>
|
<div className="text-sm">알 수 없는 위젯 타입: {element.subtype}</div>
|
||||||
|
|
@ -212,7 +212,7 @@ export function DashboardViewer({
|
||||||
dataUrl: string,
|
dataUrl: string,
|
||||||
format: "png" | "pdf",
|
format: "png" | "pdf",
|
||||||
canvasWidth: number,
|
canvasWidth: number,
|
||||||
canvasHeight: number
|
canvasHeight: number,
|
||||||
) => {
|
) => {
|
||||||
if (format === "png") {
|
if (format === "png") {
|
||||||
console.log("💾 PNG 다운로드 시작...");
|
console.log("💾 PNG 다운로드 시작...");
|
||||||
|
|
@ -274,6 +274,7 @@ export function DashboardViewer({
|
||||||
|
|
||||||
console.log("📸 html-to-image 로딩 중...");
|
console.log("📸 html-to-image 로딩 중...");
|
||||||
// html-to-image 동적 import
|
// html-to-image 동적 import
|
||||||
|
// @ts-expect-error - html-to-image 타입 선언 누락
|
||||||
const { toPng } = await import("html-to-image");
|
const { toPng } = await import("html-to-image");
|
||||||
|
|
||||||
console.log("📸 캔버스 캡처 중...");
|
console.log("📸 캔버스 캡처 중...");
|
||||||
|
|
@ -297,7 +298,7 @@ export function DashboardViewer({
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
bottom: rect.bottom
|
bottom: rect.bottom,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
||||||
|
|
@ -333,7 +334,7 @@ export function DashboardViewer({
|
||||||
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
||||||
calculated: { width: canvasWidth, height: canvasHeight },
|
calculated: { width: canvasWidth, height: canvasHeight },
|
||||||
maxBottom: maxBottom,
|
maxBottom: maxBottom,
|
||||||
webglCount: webglImages.length
|
webglCount: webglImages.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
||||||
|
|
@ -344,8 +345,8 @@ export function DashboardViewer({
|
||||||
pixelRatio: 2, // 고해상도
|
pixelRatio: 2, // 고해상도
|
||||||
cacheBust: true,
|
cacheBust: true,
|
||||||
skipFonts: false,
|
skipFonts: false,
|
||||||
preferredFontFormat: 'woff2',
|
preferredFontFormat: "woff2",
|
||||||
filter: (node) => {
|
filter: (node: Node) => {
|
||||||
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
||||||
if (node instanceof HTMLCanvasElement) {
|
if (node instanceof HTMLCanvasElement) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -409,7 +410,8 @@ export function DashboardViewer({
|
||||||
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[backgroundColor, dashboardTitle],
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[backgroundColor, dashboardTitle, handleDownloadWithDataUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 캔버스 설정 계산
|
// 캔버스 설정 계산
|
||||||
|
|
@ -528,11 +530,11 @@ export function DashboardViewer({
|
||||||
// 요소가 없는 경우
|
// 요소가 없는 경우
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-muted">
|
<div className="bg-muted flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 text-6xl">📊</div>
|
<div className="mb-4 text-6xl">📊</div>
|
||||||
<div className="mb-2 text-xl font-medium text-foreground">표시할 요소가 없습니다</div>
|
<div className="text-foreground mb-2 text-xl font-medium">표시할 요소가 없습니다</div>
|
||||||
<div className="text-sm text-muted-foreground">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
<div className="text-muted-foreground text-sm">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -541,8 +543,8 @@ export function DashboardViewer({
|
||||||
return (
|
return (
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||||
<div className="hidden min-h-screen bg-muted py-8 lg:block" style={{ backgroundColor }}>
|
<div className="bg-muted hidden min-h-screen py-8 lg:block" style={{ backgroundColor }}>
|
||||||
<div className="mx-auto px-4" style={{ width: '100%', maxWidth: 'none' }}>
|
<div className="mx-auto px-4" style={{ width: "100%", maxWidth: "none" }}>
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="mb-4 flex justify-end">
|
<div className="mb-4 flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -584,7 +586,7 @@ export function DashboardViewer({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||||
<div className="block min-h-screen bg-muted p-4 lg:hidden" style={{ backgroundColor }}>
|
<div className="bg-muted block min-h-screen p-4 lg:hidden" style={{ backgroundColor }}>
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -646,38 +648,21 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
// 태블릿 이하: 세로 스택 카드 스타일
|
// 태블릿 이하: 세로 스택 카드 스타일
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-lg border border-border bg-background shadow-sm"
|
className="border-border bg-background relative overflow-hidden rounded-lg border shadow-sm"
|
||||||
style={{ minHeight: "300px" }}
|
style={{ minHeight: "300px" }}
|
||||||
>
|
>
|
||||||
{element.showHeader !== false && (
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<h3 className="text-xs font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
{/* map-summary-v2는 customTitle이 없으면 제목 숨김 */}
|
||||||
<button
|
{element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
onClick={onRefresh}
|
<h3 className="text-foreground text-xs font-semibold">{element.customTitle || element.title}</h3>
|
||||||
disabled={isLoading}
|
)}
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
||||||
title="새로고침"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={element.showHeader !== false ? "p-2" : "p-2"} style={{ minHeight: "250px" }}>
|
<div className={element.showHeader !== false ? "p-2" : "p-2"} style={{ minHeight: "250px" }}>
|
||||||
{!isMounted ? (
|
{!isMounted ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "chart" ? (
|
) : element.type === "chart" ? (
|
||||||
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
||||||
|
|
@ -686,10 +671,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-background">
|
<div className="bg-opacity-75 bg-background absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<div className="text-sm text-foreground">업데이트 중...</div>
|
<div className="text-foreground text-sm">업데이트 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -704,7 +689,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute overflow-hidden rounded-lg border border-border bg-background shadow-sm"
|
className="border-border bg-background absolute overflow-hidden rounded-lg border shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
left: `${leftPercentage}%`,
|
left: `${leftPercentage}%`,
|
||||||
top: element.position.y,
|
top: element.position.y,
|
||||||
|
|
@ -714,33 +699,16 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
>
|
>
|
||||||
{element.showHeader !== false && (
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<h3 className="text-xs font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
{/* map-summary-v2는 customTitle이 없으면 제목 숨김 */}
|
||||||
<button
|
{element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
onClick={onRefresh}
|
<h3 className="text-foreground text-xs font-semibold">{element.customTitle || element.title}</h3>
|
||||||
disabled={isLoading}
|
)}
|
||||||
className="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
||||||
title="새로고침"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={element.showHeader !== false ? "h-[calc(100%-32px)] w-full" : "h-full w-full"}>
|
<div className={element.showHeader !== false ? "h-[calc(100%-32px)] w-full" : "h-full w-full"}>
|
||||||
{!isMounted ? (
|
{!isMounted ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "chart" ? (
|
) : element.type === "chart" ? (
|
||||||
<ChartRenderer
|
<ChartRenderer
|
||||||
|
|
@ -754,10 +722,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-background">
|
<div className="bg-opacity-75 bg-background absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<div className="text-sm text-foreground">업데이트 중...</div>
|
<div className="text-foreground text-sm">업데이트 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -259,6 +259,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
if (menu.hasChildren) {
|
if (menu.hasChildren) {
|
||||||
toggleMenu(menu.id);
|
toggleMenu(menu.id);
|
||||||
} else {
|
} else {
|
||||||
|
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
|
||||||
|
const menuName = menu.label || menu.name || "메뉴";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("currentMenuName", menuName);
|
||||||
|
}
|
||||||
|
|
||||||
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
||||||
try {
|
try {
|
||||||
const menuObjid = menu.objid || menu.id;
|
const menuObjid = menu.objid || menu.id;
|
||||||
|
|
@ -268,10 +274,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
||||||
const firstScreen = assignedScreens[0];
|
const firstScreen = assignedScreens[0];
|
||||||
|
|
||||||
// 관리자 모드 상태를 쿼리 파라미터로 전달
|
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
|
||||||
const screenPath = isAdminMode
|
const params = new URLSearchParams();
|
||||||
? `/screens/${firstScreen.screenId}?mode=admin`
|
if (isAdminMode) {
|
||||||
: `/screens/${firstScreen.screenId}`;
|
params.set("mode", "admin");
|
||||||
|
}
|
||||||
|
params.set("menuObjid", menuObjid.toString());
|
||||||
|
|
||||||
|
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
|
||||||
|
|
||||||
router.push(screenPath);
|
router.push(screenPath);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
|
||||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||||
import {
|
import {
|
||||||
getNumberingRules,
|
getAvailableNumberingRules,
|
||||||
createNumberingRule,
|
createNumberingRule,
|
||||||
updateNumberingRule,
|
updateNumberingRule,
|
||||||
deleteNumberingRule,
|
deleteNumberingRule,
|
||||||
|
|
@ -26,6 +26,7 @@ interface NumberingRuleDesignerProps {
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
||||||
|
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
|
|
@ -36,6 +37,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
className = "",
|
className = "",
|
||||||
currentTableName,
|
currentTableName,
|
||||||
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||||
|
|
@ -53,7 +55,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const loadRules = useCallback(async () => {
|
const loadRules = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getNumberingRules();
|
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작:", {
|
||||||
|
menuObjid,
|
||||||
|
hasMenuObjid: !!menuObjid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await getAvailableNumberingRules(menuObjid);
|
||||||
|
|
||||||
|
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답:", {
|
||||||
|
menuObjid,
|
||||||
|
success: response.success,
|
||||||
|
rulesCount: response.data?.length || 0,
|
||||||
|
rules: response.data,
|
||||||
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setSavedRules(response.data);
|
setSavedRules(response.data);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -64,7 +79,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [menuObjid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule) {
|
if (currentRule) {
|
||||||
|
|
@ -133,19 +148,23 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
try {
|
try {
|
||||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||||
|
|
||||||
// 저장 전에 현재 화면의 테이블명 자동 설정
|
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||||
const ruleToSave = {
|
const ruleToSave = {
|
||||||
...currentRule,
|
...currentRule,
|
||||||
scopeType: "table" as const, // 항상 table로 고정
|
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||||
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
|
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
|
||||||
|
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("💾 채번 규칙 저장:", {
|
console.log("💾 채번 규칙 저장:", {
|
||||||
currentTableName,
|
currentTableName,
|
||||||
|
menuObjid,
|
||||||
"currentRule.tableName": currentRule.tableName,
|
"currentRule.tableName": currentRule.tableName,
|
||||||
|
"currentRule.menuObjid": currentRule.menuObjid,
|
||||||
"ruleToSave.tableName": ruleToSave.tableName,
|
"ruleToSave.tableName": ruleToSave.tableName,
|
||||||
|
"ruleToSave.menuObjid": ruleToSave.menuObjid,
|
||||||
"ruleToSave.scopeType": ruleToSave.scopeType,
|
"ruleToSave.scopeType": ruleToSave.scopeType,
|
||||||
ruleToSave
|
ruleToSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
|
|
@ -213,7 +232,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewRule = useCallback(() => {
|
const handleNewRule = useCallback(() => {
|
||||||
console.log("📋 새 규칙 생성 - currentTableName:", currentTableName);
|
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
|
||||||
|
|
||||||
const newRule: NumberingRuleConfig = {
|
const newRule: NumberingRuleConfig = {
|
||||||
ruleId: `rule-${Date.now()}`,
|
ruleId: `rule-${Date.now()}`,
|
||||||
|
|
@ -222,8 +241,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
separator: "-",
|
separator: "-",
|
||||||
resetPeriod: "none",
|
resetPeriod: "none",
|
||||||
currentSequence: 1,
|
currentSequence: 1,
|
||||||
scopeType: "table", // 기본값을 table로 설정
|
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||||
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
||||||
|
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📋 생성된 규칙 정보:", newRule);
|
console.log("📋 생성된 규칙 정보:", newRule);
|
||||||
|
|
@ -232,7 +252,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
setCurrentRule(newRule);
|
setCurrentRule(newRule);
|
||||||
|
|
||||||
toast.success("새 규칙이 생성되었습니다");
|
toast.success("새 규칙이 생성되었습니다");
|
||||||
}, [currentTableName]);
|
}, [currentTableName, menuObjid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex h-full gap-4 ${className}`}>
|
<div className={`flex h-full gap-4 ${className}`}>
|
||||||
|
|
@ -273,7 +293,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
savedRules.map((rule) => (
|
savedRules.map((rule) => (
|
||||||
<Card
|
<Card
|
||||||
key={rule.ruleId}
|
key={rule.ruleId}
|
||||||
className={`py-2 border-border hover:bg-accent cursor-pointer transition-colors ${
|
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
|
||||||
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSelectRule(rule)}
|
onClick={() => handleSelectRule(rule)}
|
||||||
|
|
@ -356,7 +376,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
{currentTableName && (
|
{currentTableName && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">적용 테이블</Label>
|
<Label className="text-sm font-medium">적용 테이블</Label>
|
||||||
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
<div className="border-input bg-muted text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
|
||||||
{currentTableName}
|
{currentTableName}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Printer, FileDown, FileText } from "lucide-react";
|
import { Printer, FileDown, FileText } from "lucide-react";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
|
|
@ -895,7 +895,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -911,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
{isExporting ? "생성 중..." : "WORD"}
|
{isExporting ? "생성 중..." : "WORD"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -131,7 +131,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -145,7 +145,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||||
"저장"
|
"저장"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -52,6 +52,8 @@ import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||||
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
||||||
import { SaveModal } from "./SaveModal";
|
import { SaveModal } from "./SaveModal";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
|
|
@ -102,6 +104,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
|
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
||||||
|
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
|
|
@ -114,6 +118,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||||
const isResizingRef = useRef(false);
|
const isResizingRef = useRef(false);
|
||||||
|
|
||||||
|
// TableOptions 상태
|
||||||
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [grouping, setGrouping] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
||||||
// SaveModal 상태 (등록/수정 통합)
|
// SaveModal 상태 (등록/수정 통합)
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
const [saveModalData, setSaveModalData] = useState<Record<string, any> | undefined>(undefined);
|
const [saveModalData, setSaveModalData] = useState<Record<string, any> | undefined>(undefined);
|
||||||
|
|
@ -147,6 +156,33 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
|
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
|
||||||
|
|
||||||
|
// 테이블 등록 (Context에 등록)
|
||||||
|
const tableId = `datatable-${component.id}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!component.tableName || !component.columns) return;
|
||||||
|
|
||||||
|
registerTable({
|
||||||
|
tableId,
|
||||||
|
label: component.title || "데이터 테이블",
|
||||||
|
tableName: component.tableName,
|
||||||
|
columns: component.columns.map((col) => ({
|
||||||
|
columnName: col.field,
|
||||||
|
columnLabel: col.label,
|
||||||
|
inputType: col.inputType || "text",
|
||||||
|
visible: col.visible !== false,
|
||||||
|
width: col.width || 150,
|
||||||
|
sortable: col.sortable,
|
||||||
|
filterable: col.filterable !== false,
|
||||||
|
})),
|
||||||
|
onFilterChange: setFilters,
|
||||||
|
onGroupChange: setGrouping,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unregisterTable(tableId);
|
||||||
|
}, [component.id, component.tableName, component.columns, component.title]);
|
||||||
|
|
||||||
// 공통코드 옵션 가져오기
|
// 공통코드 옵션 가져오기
|
||||||
const loadCodeOptions = useCallback(
|
const loadCodeOptions = useCallback(
|
||||||
async (categoryCode: string) => {
|
async (categoryCode: string) => {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -57,6 +59,7 @@ interface InteractiveScreenViewerProps {
|
||||||
id: number;
|
id: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
};
|
};
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||||
// 새로운 검증 관련 옵션들
|
// 새로운 검증 관련 옵션들
|
||||||
enableEnhancedValidation?: boolean;
|
enableEnhancedValidation?: boolean;
|
||||||
tableColumns?: ColumnInfo[];
|
tableColumns?: ColumnInfo[];
|
||||||
|
|
@ -76,6 +79,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
screenInfo,
|
screenInfo,
|
||||||
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
enableEnhancedValidation = false,
|
enableEnhancedValidation = false,
|
||||||
tableColumns = [],
|
tableColumns = [],
|
||||||
showValidationPanel = false,
|
showValidationPanel = false,
|
||||||
|
|
@ -1090,15 +1094,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
|
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
|
||||||
|
|
||||||
console.log("🔍 InteractiveScreenViewer - Code 위젯 (공통코드 선택):", {
|
console.log(`🔍 [InteractiveScreenViewer] Code 위젯 렌더링:`, {
|
||||||
componentId: widget.id,
|
componentId: widget.id,
|
||||||
widgetType: widget.widgetType,
|
|
||||||
columnName: widget.columnName,
|
columnName: widget.columnName,
|
||||||
fieldName,
|
|
||||||
currentValue,
|
|
||||||
formData,
|
|
||||||
config,
|
|
||||||
codeCategory: config?.codeCategory,
|
codeCategory: config?.codeCategory,
|
||||||
|
menuObjid,
|
||||||
|
hasMenuObjid: !!menuObjid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// code 타입은 공통코드 선택박스로 처리
|
// code 타입은 공통코드 선택박스로 처리
|
||||||
|
|
@ -1117,6 +1118,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
required: required,
|
required: required,
|
||||||
placeholder: config?.placeholder || "코드를 선택하세요...",
|
placeholder: config?.placeholder || "코드를 선택하세요...",
|
||||||
className: "w-full h-full",
|
className: "w-full h-full",
|
||||||
|
menuObjid: menuObjid, // 🆕 메뉴 OBJID 전달
|
||||||
}}
|
}}
|
||||||
config={{
|
config={{
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -1885,8 +1887,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TableOptionsProvider>
|
||||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 테이블 옵션 툴바 */}
|
||||||
|
<TableOptionsToolbar />
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
{shouldShowLabel && (
|
{shouldShowLabel && (
|
||||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
|
@ -1898,6 +1905,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||||
{showValidationPanel && enhancedValidation && (
|
{showValidationPanel && enhancedValidation && (
|
||||||
|
|
@ -1986,6 +1994,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</TableOptionsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
||||||
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
||||||
|
|
||||||
|
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||||
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||||
|
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
left: position?.x || 0,
|
left: position?.x || 0,
|
||||||
|
|
@ -690,7 +693,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||||
height: size?.height || 10,
|
height: isTableSearchWidget ? "auto" : (size?.height || 10),
|
||||||
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ interface RealtimePreviewProps {
|
||||||
userId?: string; // 🆕 현재 사용자 ID
|
userId?: string; // 🆕 현재 사용자 ID
|
||||||
userName?: string; // 🆕 현재 사용자 이름
|
userName?: string; // 🆕 현재 사용자 이름
|
||||||
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
|
|
@ -107,6 +108,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
userId, // 🆕 사용자 ID
|
userId, // 🆕 사용자 ID
|
||||||
userName, // 🆕 사용자 이름
|
userName, // 🆕 사용자 이름
|
||||||
companyCode, // 🆕 회사 코드
|
companyCode, // 🆕 회사 코드
|
||||||
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
onSelectedRowsChange,
|
onSelectedRowsChange,
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
|
|
@ -344,6 +346,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
|
menuObjid={menuObjid}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
onSelectedRowsChange={onSelectedRowsChange}
|
onSelectedRowsChange={onSelectedRowsChange}
|
||||||
flowSelectedData={flowSelectedData}
|
flowSelectedData={flowSelectedData}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ import {
|
||||||
} from "@/lib/utils/flowButtonGroupUtils";
|
} from "@/lib/utils/flowButtonGroupUtils";
|
||||||
import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog";
|
import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog";
|
||||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
|
|
||||||
// 새로운 통합 UI 컴포넌트
|
// 새로운 통합 UI 컴포넌트
|
||||||
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
|
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
|
||||||
|
|
@ -143,6 +144,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 화면에 할당된 메뉴 OBJID
|
||||||
|
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
// 메뉴 할당 모달 상태
|
// 메뉴 할당 모달 상태
|
||||||
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
||||||
|
|
||||||
|
|
@ -880,6 +884,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
const loadLayout = async () => {
|
const loadLayout = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 🆕 화면에 할당된 메뉴 조회
|
||||||
|
const menuInfo = await screenApi.getScreenMenu(selectedScreen.screenId);
|
||||||
|
if (menuInfo) {
|
||||||
|
setMenuObjid(menuInfo.menuObjid);
|
||||||
|
console.log("🔗 화면에 할당된 메뉴:", menuInfo);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await screenApi.getLayout(selectedScreen.screenId);
|
const response = await screenApi.getLayout(selectedScreen.screenId);
|
||||||
if (response) {
|
if (response) {
|
||||||
// 🔄 마이그레이션 필요 여부 확인
|
// 🔄 마이그레이션 필요 여부 확인
|
||||||
|
|
@ -4129,6 +4142,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
|
<TableOptionsProvider>
|
||||||
<div className="bg-background flex h-full w-full flex-col">
|
<div className="bg-background flex h-full w-full flex-col">
|
||||||
{/* 상단 슬림 툴바 */}
|
{/* 상단 슬림 툴바 */}
|
||||||
<SlimToolbar
|
<SlimToolbar
|
||||||
|
|
@ -4205,6 +4219,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
currentResolution={screenResolution}
|
currentResolution={screenResolution}
|
||||||
onResolutionChange={handleResolutionChange}
|
onResolutionChange={handleResolutionChange}
|
||||||
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
||||||
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -4404,7 +4419,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
| FlowVisibilityConfig
|
| FlowVisibilityConfig
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
if (
|
||||||
|
flowConfig?.enabled &&
|
||||||
|
flowConfig.layoutBehavior === "auto-compact" &&
|
||||||
|
flowConfig.groupId
|
||||||
|
) {
|
||||||
if (!buttonGroups[flowConfig.groupId]) {
|
if (!buttonGroups[flowConfig.groupId]) {
|
||||||
buttonGroups[flowConfig.groupId] = [];
|
buttonGroups[flowConfig.groupId] = [];
|
||||||
}
|
}
|
||||||
|
|
@ -4497,6 +4516,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onDragStart={(e) => startComponentDrag(component, e)}
|
onDragStart={(e) => startComponentDrag(component, e)}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
// onZoneComponentDrop 제거
|
// onZoneComponentDrop 제거
|
||||||
onZoneClick={handleZoneClick}
|
onZoneClick={handleZoneClick}
|
||||||
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
||||||
|
|
@ -4671,10 +4691,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 🆕 그룹 전체가 선택되었는지 확인
|
// 🆕 그룹 전체가 선택되었는지 확인
|
||||||
const isGroupSelected = buttons.every(
|
const isGroupSelected = buttons.every(
|
||||||
(btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
|
(btn) =>
|
||||||
|
selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
|
||||||
);
|
);
|
||||||
const hasAnySelected = buttons.some(
|
const hasAnySelected = buttons.some(
|
||||||
(btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
|
(btn) =>
|
||||||
|
selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -4911,6 +4933,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TableOptionsProvider>
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database } from "lucide-react";
|
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
import TablesPanel from "./TablesPanel";
|
import TablesPanel from "./TablesPanel";
|
||||||
|
|
||||||
|
|
@ -64,6 +64,7 @@ export function ComponentsPanel({
|
||||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||||
|
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
|
||||||
};
|
};
|
||||||
}, [allComponents]);
|
}, [allComponents]);
|
||||||
|
|
||||||
|
|
@ -184,7 +185,7 @@ export function ComponentsPanel({
|
||||||
|
|
||||||
{/* 카테고리 탭 */}
|
{/* 카테고리 탭 */}
|
||||||
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
||||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-5 gap-1 p-1">
|
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-6 gap-1 p-1">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="tables"
|
value="tables"
|
||||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||||
|
|
@ -221,6 +222,14 @@ export function ComponentsPanel({
|
||||||
<Layers className="h-3 w-3" />
|
<Layers className="h-3 w-3" />
|
||||||
<span className="hidden">레이아웃</span>
|
<span className="hidden">레이아웃</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="utility"
|
||||||
|
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||||
|
title="유틸리티"
|
||||||
|
>
|
||||||
|
<Wrench className="h-3 w-3" />
|
||||||
|
<span className="hidden">유틸리티</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 테이블 탭 */}
|
{/* 테이블 탭 */}
|
||||||
|
|
@ -271,6 +280,13 @@ export function ComponentsPanel({
|
||||||
? getFilteredComponents("layout").map(renderComponentCard)
|
? getFilteredComponents("layout").map(renderComponentCard)
|
||||||
: renderEmptyState()}
|
: renderEmptyState()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 유틸리티 컴포넌트 */}
|
||||||
|
<TabsContent value="utility" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||||
|
{getFilteredComponents("utility").length > 0
|
||||||
|
? getFilteredComponents("utility").map(renderComponentCard)
|
||||||
|
: renderEmptyState()}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 도움말 */}
|
{/* 도움말 */}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ interface UnifiedPropertiesPanelProps {
|
||||||
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
|
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
|
||||||
// 🆕 플로우 위젯 감지용
|
// 🆕 플로우 위젯 감지용
|
||||||
allComponents?: ComponentData[];
|
allComponents?: ComponentData[];
|
||||||
|
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
||||||
|
menuObjid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
|
|
@ -98,6 +100,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
currentTableName,
|
currentTableName,
|
||||||
dragState,
|
dragState,
|
||||||
onStyleChange,
|
onStyleChange,
|
||||||
|
menuObjid,
|
||||||
currentResolution,
|
currentResolution,
|
||||||
onResolutionChange,
|
onResolutionChange,
|
||||||
allComponents = [], // 🆕 기본값 빈 배열
|
allComponents = [], // 🆕 기본값 빈 배열
|
||||||
|
|
@ -685,6 +688,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
|
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
|
|
@ -848,6 +852,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||||||
// 전체 componentConfig를 업데이트
|
// 전체 componentConfig를 업데이트
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface TextTypeConfigPanelProps {
|
||||||
config: TextTypeConfig;
|
config: TextTypeConfig;
|
||||||
onConfigChange: (config: TextTypeConfig) => void;
|
onConfigChange: (config: TextTypeConfig) => void;
|
||||||
tableName?: string; // 화면의 테이블명 (선택)
|
tableName?: string; // 화면의 테이블명 (선택)
|
||||||
menuObjid?: number; // 메뉴 objid (선택)
|
menuObjid?: number; // 메뉴 objid (선택) - 사용자가 선택한 부모 메뉴
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
|
|
@ -45,6 +45,10 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [loadingRules, setLoadingRules] = useState(false);
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
|
||||||
|
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
|
||||||
|
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
||||||
|
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(menuObjid);
|
||||||
|
|
||||||
// 로컬 상태로 실시간 입력 관리
|
// 로컬 상태로 실시간 입력 관리
|
||||||
const [localValues, setLocalValues] = useState({
|
const [localValues, setLocalValues] = useState({
|
||||||
minLength: safeConfig.minLength?.toString() || "",
|
minLength: safeConfig.minLength?.toString() || "",
|
||||||
|
|
@ -60,31 +64,61 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
numberingRuleId: safeConfig.numberingRuleId,
|
numberingRuleId: safeConfig.numberingRuleId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 채번 규칙 목록 로드
|
// 부모 메뉴 목록 로드 (최상위 메뉴 또는 레벨 2 메뉴)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadParentMenus = async () => {
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
// 관리자 메뉴와 사용자 메뉴 모두 가져오기
|
||||||
|
const [adminResponse, userResponse] = await Promise.all([
|
||||||
|
apiClient.get("/admin/menus", { params: { menuType: "0" } }),
|
||||||
|
apiClient.get("/admin/menus", { params: { menuType: "1" } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allMenus = [
|
||||||
|
...(adminResponse.data?.data || []),
|
||||||
|
...(userResponse.data?.data || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
// 레벨 2 이하 메뉴만 선택 가능 (부모가 있는 메뉴)
|
||||||
|
const parentMenuList = allMenus.filter((menu: any) => {
|
||||||
|
const level = menu.lev || menu.LEV || 0;
|
||||||
|
return level >= 2; // 레벨 2 이상만 표시 (형제 메뉴가 있을 가능성)
|
||||||
|
});
|
||||||
|
|
||||||
|
setParentMenus(parentMenuList);
|
||||||
|
console.log("✅ 부모 메뉴 목록 로드:", parentMenuList.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 부모 메뉴 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadParentMenus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
console.log("🔄 채번 규칙 로드 시작:", {
|
console.log("🔄 채번 규칙 로드 시작:", {
|
||||||
autoValueType: localValues.autoValueType,
|
autoValueType: localValues.autoValueType,
|
||||||
|
selectedMenuObjid,
|
||||||
tableName,
|
tableName,
|
||||||
hasTableName: !!tableName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 메뉴를 선택하지 않으면 로드하지 않음
|
||||||
|
if (!selectedMenuObjid) {
|
||||||
|
console.warn("⚠️ 메뉴를 선택해야 채번 규칙을 조회할 수 있습니다");
|
||||||
|
setNumberingRules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingRules(true);
|
setLoadingRules(true);
|
||||||
try {
|
try {
|
||||||
let response;
|
// 선택된 메뉴의 채번 규칙 조회 (메뉴 스코프)
|
||||||
|
console.log("📋 메뉴 기반 채번 규칙 조회 API 호출:", { menuObjid: selectedMenuObjid });
|
||||||
// 테이블명이 있으면 테이블 기반 필터링 사용
|
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
||||||
if (tableName) {
|
|
||||||
console.log("📋 테이블 기반 채번 규칙 조회 API 호출:", { tableName });
|
|
||||||
response = await getAvailableNumberingRulesForScreen(tableName);
|
|
||||||
console.log("📋 API 응답:", response);
|
console.log("📋 API 응답:", response);
|
||||||
} else {
|
|
||||||
// 테이블명이 없으면 빈 배열 (테이블 필수)
|
|
||||||
console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다");
|
|
||||||
setNumberingRules([]);
|
|
||||||
setLoadingRules(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setNumberingRules(response.data);
|
setNumberingRules(response.data);
|
||||||
|
|
@ -93,7 +127,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
rules: response.data.map((r: any) => ({
|
rules: response.data.map((r: any) => ({
|
||||||
ruleId: r.ruleId,
|
ruleId: r.ruleId,
|
||||||
ruleName: r.ruleName,
|
ruleName: r.ruleName,
|
||||||
tableName: r.tableName,
|
menuObjid: selectedMenuObjid,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -115,7 +149,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
} else {
|
} else {
|
||||||
console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType);
|
console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType);
|
||||||
}
|
}
|
||||||
}, [localValues.autoValueType, tableName]);
|
}, [localValues.autoValueType, selectedMenuObjid]);
|
||||||
|
|
||||||
// config가 변경될 때 로컬 상태 동기화
|
// config가 변경될 때 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -314,7 +348,54 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
console.log("🔍 메뉴 선택 UI 렌더링 체크:", {
|
||||||
|
autoValueType: localValues.autoValueType,
|
||||||
|
isNumberingRule: localValues.autoValueType === "numbering_rule",
|
||||||
|
parentMenusCount: parentMenus.length,
|
||||||
|
selectedMenuObjid,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
{localValues.autoValueType === "numbering_rule" && (
|
{localValues.autoValueType === "numbering_rule" && (
|
||||||
|
<>
|
||||||
|
{/* 부모 메뉴 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="parentMenu" className="text-sm font-medium">
|
||||||
|
대상 메뉴 선택 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedMenuObjid?.toString() || ""}
|
||||||
|
onValueChange={(value) => setSelectedMenuObjid(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 w-full px-2 py-0 text-xs">
|
||||||
|
<SelectValue placeholder="채번 규칙을 사용할 메뉴 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{parentMenus.length === 0 ? (
|
||||||
|
<SelectItem value="no-menus" disabled>
|
||||||
|
사용 가능한 메뉴가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
parentMenus.map((menu) => {
|
||||||
|
const objid = menu.objid || menu.OBJID;
|
||||||
|
const menuName = menu.menu_name_kor || menu.MENU_NAME_KOR;
|
||||||
|
return (
|
||||||
|
<SelectItem key={objid} value={objid.toString()}>
|
||||||
|
{menuName}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
이 필드가 어느 메뉴에서 사용될 것인지 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 채번 규칙 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="numberingRuleId" className="text-sm font-medium">
|
<Label htmlFor="numberingRuleId" className="text-sm font-medium">
|
||||||
채번 규칙 선택 <span className="text-destructive">*</span>
|
채번 규칙 선택 <span className="text-destructive">*</span>
|
||||||
|
|
@ -322,15 +403,25 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
<Select
|
<Select
|
||||||
value={localValues.numberingRuleId}
|
value={localValues.numberingRuleId}
|
||||||
onValueChange={(value) => updateConfig("numberingRuleId", value)}
|
onValueChange={(value) => updateConfig("numberingRuleId", value)}
|
||||||
disabled={loadingRules}
|
disabled={loadingRules || !selectedMenuObjid}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
<SelectTrigger className="mt-1 h-8 w-full px-2 py-0 text-xs">
|
||||||
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
!selectedMenuObjid
|
||||||
|
? "먼저 메뉴를 선택하세요"
|
||||||
|
: loadingRules
|
||||||
|
? "규칙 로딩 중..."
|
||||||
|
: "채번 규칙 선택"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{numberingRules.length === 0 ? (
|
{numberingRules.length === 0 ? (
|
||||||
<SelectItem value="no-rules" disabled>
|
<SelectItem value="no-rules" disabled>
|
||||||
사용 가능한 규칙이 없습니다
|
{!selectedMenuObjid
|
||||||
|
? "메뉴를 먼저 선택하세요"
|
||||||
|
: "사용 가능한 규칙이 없습니다"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
numberingRules.map((rule) => (
|
numberingRules.map((rule) => (
|
||||||
|
|
@ -342,9 +433,10 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
선택한 메뉴와 형제 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localValues.autoValueType === "custom" && (
|
{localValues.autoValueType === "custom" && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { GripVertical, Eye, EyeOff } from "lucide-react";
|
||||||
|
import { ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { getTable, selectedTableId } = useTableOptions();
|
||||||
|
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||||
|
|
||||||
|
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 테이블 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (table) {
|
||||||
|
setLocalColumns(
|
||||||
|
table.columns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
visible: col.visible,
|
||||||
|
width: col.width,
|
||||||
|
order: 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [table]);
|
||||||
|
|
||||||
|
const handleVisibilityChange = (columnName: string, visible: boolean) => {
|
||||||
|
setLocalColumns((prev) =>
|
||||||
|
prev.map((col) =>
|
||||||
|
col.columnName === columnName ? { ...col, visible } : col
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWidthChange = (columnName: string, width: number) => {
|
||||||
|
setLocalColumns((prev) =>
|
||||||
|
prev.map((col) =>
|
||||||
|
col.columnName === columnName ? { ...col, width } : col
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveColumn = (fromIndex: number, toIndex: number) => {
|
||||||
|
const newColumns = [...localColumns];
|
||||||
|
const [movedItem] = newColumns.splice(fromIndex, 1);
|
||||||
|
newColumns.splice(toIndex, 0, movedItem);
|
||||||
|
setLocalColumns(newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return;
|
||||||
|
moveColumn(draggedIndex, index);
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
table?.onColumnVisibilityChange(localColumns);
|
||||||
|
|
||||||
|
// 컬럼 순서 변경 콜백 호출
|
||||||
|
if (table?.onColumnOrderChange) {
|
||||||
|
const newOrder = localColumns
|
||||||
|
.map((col) => col.columnName)
|
||||||
|
.filter((name) => name !== "__checkbox__");
|
||||||
|
table.onColumnOrderChange(newOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (table) {
|
||||||
|
setLocalColumns(
|
||||||
|
table.columns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
visible: true,
|
||||||
|
width: 150,
|
||||||
|
order: 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleCount = localColumns.filter((col) => col.visible).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
테이블 옵션
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
컬럼 표시/숨기기, 순서 변경, 너비 등을 설정할 수 있습니다. 모든
|
||||||
|
테두리를 드래그하여 크기를 조정할 수 있습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 상태 표시 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
|
||||||
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||||
|
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 리스트 */}
|
||||||
|
<ScrollArea className="h-[300px] sm:h-[400px]">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{localColumns.map((col, index) => {
|
||||||
|
const columnMeta = table?.columns.find(
|
||||||
|
(c) => c.columnName === col.columnName
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.columnName}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50 cursor-move"
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
|
||||||
|
{/* 체크박스 */}
|
||||||
|
<Checkbox
|
||||||
|
checked={col.visible}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleVisibilityChange(
|
||||||
|
col.columnName,
|
||||||
|
checked as boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 가시성 아이콘 */}
|
||||||
|
{col.visible ? (
|
||||||
|
<Eye className="h-4 w-4 shrink-0 text-primary" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼명 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs font-medium sm:text-sm">
|
||||||
|
{columnMeta?.columnLabel}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
{col.columnName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 너비 설정 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
너비:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || 150}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleWidthChange(
|
||||||
|
col.columnName,
|
||||||
|
parseInt(e.target.value) || 150
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
|
||||||
|
min={50}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { TableFilter } from "@/types/table-options";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 타입별 연산자
|
||||||
|
const operatorsByType: Record<string, Record<string, string>> = {
|
||||||
|
text: {
|
||||||
|
contains: "포함",
|
||||||
|
equals: "같음",
|
||||||
|
startsWith: "시작",
|
||||||
|
endsWith: "끝",
|
||||||
|
notEquals: "같지 않음",
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
equals: "같음",
|
||||||
|
gt: "보다 큼",
|
||||||
|
lt: "보다 작음",
|
||||||
|
gte: "이상",
|
||||||
|
lte: "이하",
|
||||||
|
notEquals: "같지 않음",
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
equals: "같음",
|
||||||
|
gt: "이후",
|
||||||
|
lt: "이전",
|
||||||
|
gte: "이후 포함",
|
||||||
|
lte: "이전 포함",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
equals: "같음",
|
||||||
|
notEquals: "같지 않음",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 필터 설정 인터페이스
|
||||||
|
interface ColumnFilterConfig {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
inputType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
filterType: "text" | "number" | "date" | "select";
|
||||||
|
width?: number; // 필터 입력 필드 너비 (px)
|
||||||
|
selectOptions?: Array<{ label: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
|
||||||
|
const { getTable, selectedTableId } = useTableOptions();
|
||||||
|
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||||
|
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
|
||||||
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
|
|
||||||
|
// localStorage에서 저장된 필터 설정 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (table?.columns && table?.tableName) {
|
||||||
|
const storageKey = `table_filters_${table.tableName}`;
|
||||||
|
const savedFilters = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
let filters: ColumnFilterConfig[];
|
||||||
|
|
||||||
|
if (savedFilters) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[];
|
||||||
|
// 저장된 설정과 현재 컬럼 병합
|
||||||
|
filters = table.columns
|
||||||
|
.filter((col) => col.filterable !== false)
|
||||||
|
.map((col) => {
|
||||||
|
const saved = parsed.find((f) => f.columnName === col.columnName);
|
||||||
|
return saved || {
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel,
|
||||||
|
inputType: col.inputType || "text",
|
||||||
|
enabled: false,
|
||||||
|
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장된 필터 설정 불러오기 실패:", error);
|
||||||
|
filters = table.columns
|
||||||
|
.filter((col) => col.filterable !== false)
|
||||||
|
.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel,
|
||||||
|
inputType: col.inputType || "text",
|
||||||
|
enabled: false,
|
||||||
|
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filters = table.columns
|
||||||
|
.filter((col) => col.filterable !== false)
|
||||||
|
.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel,
|
||||||
|
inputType: col.inputType || "text",
|
||||||
|
enabled: false,
|
||||||
|
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setColumnFilters(filters);
|
||||||
|
}
|
||||||
|
}, [table?.columns, table?.tableName]);
|
||||||
|
|
||||||
|
// inputType을 filterType으로 매핑
|
||||||
|
const mapInputTypeToFilterType = (
|
||||||
|
inputType: string
|
||||||
|
): "text" | "number" | "date" | "select" => {
|
||||||
|
if (inputType.includes("number") || inputType.includes("decimal")) {
|
||||||
|
return "number";
|
||||||
|
}
|
||||||
|
if (inputType.includes("date") || inputType.includes("time")) {
|
||||||
|
return "date";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
inputType.includes("select") ||
|
||||||
|
inputType.includes("code") ||
|
||||||
|
inputType.includes("category")
|
||||||
|
) {
|
||||||
|
return "select";
|
||||||
|
}
|
||||||
|
return "text";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const toggleSelectAll = (checked: boolean) => {
|
||||||
|
setSelectAll(checked);
|
||||||
|
setColumnFilters((prev) =>
|
||||||
|
prev.map((filter) => ({ ...filter, enabled: checked }))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 필터 토글
|
||||||
|
const toggleFilter = (columnName: string) => {
|
||||||
|
setColumnFilters((prev) =>
|
||||||
|
prev.map((filter) =>
|
||||||
|
filter.columnName === columnName
|
||||||
|
? { ...filter, enabled: !filter.enabled }
|
||||||
|
: filter
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 타입 변경
|
||||||
|
const updateFilterType = (
|
||||||
|
columnName: string,
|
||||||
|
filterType: "text" | "number" | "date" | "select"
|
||||||
|
) => {
|
||||||
|
setColumnFilters((prev) =>
|
||||||
|
prev.map((filter) =>
|
||||||
|
filter.columnName === columnName ? { ...filter, filterType } : filter
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const applyFilters = () => {
|
||||||
|
// enabled된 필터들만 TableFilter로 변환
|
||||||
|
const activeFilters: TableFilter[] = columnFilters
|
||||||
|
.filter((cf) => cf.enabled)
|
||||||
|
.map((cf) => ({
|
||||||
|
columnName: cf.columnName,
|
||||||
|
operator: "contains", // 기본 연산자
|
||||||
|
value: "",
|
||||||
|
filterType: cf.filterType,
|
||||||
|
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// localStorage에 저장
|
||||||
|
if (table?.tableName) {
|
||||||
|
const storageKey = `table_filters_${table.tableName}`;
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
table?.onFilterChange(activeFilters);
|
||||||
|
|
||||||
|
// 콜백으로 활성화된 필터 정보 전달
|
||||||
|
onFiltersApplied?.(activeFilters);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화 (즉시 저장 및 적용)
|
||||||
|
const clearFilters = () => {
|
||||||
|
const clearedFilters = columnFilters.map((filter) => ({
|
||||||
|
...filter,
|
||||||
|
enabled: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
setColumnFilters(clearedFilters);
|
||||||
|
setSelectAll(false);
|
||||||
|
|
||||||
|
// localStorage에서 제거
|
||||||
|
if (table?.tableName) {
|
||||||
|
const storageKey = `table_filters_${table.tableName}`;
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 필터 배열로 적용
|
||||||
|
table?.onFilterChange([]);
|
||||||
|
|
||||||
|
// 콜백으로 빈 필터 정보 전달
|
||||||
|
onFiltersApplied?.([]);
|
||||||
|
|
||||||
|
// 즉시 닫기
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const enabledCount = columnFilters.filter((f) => f.enabled).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
검색 필터 설정
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 전체 선택/해제 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectAll}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleSelectAll(checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">전체 선택/해제</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{enabledCount} / {columnFilters.length}개
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 필터 리스트 */}
|
||||||
|
<ScrollArea className="h-[400px] sm:h-[450px]">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{columnFilters.map((filter) => (
|
||||||
|
<div
|
||||||
|
key={filter.columnName}
|
||||||
|
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
{/* 체크박스 */}
|
||||||
|
<Checkbox
|
||||||
|
checked={filter.enabled}
|
||||||
|
onCheckedChange={() => toggleFilter(filter.columnName)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 컬럼 정보 */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{filter.columnLabel}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{filter.columnName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 타입 선택 */}
|
||||||
|
<Select
|
||||||
|
value={filter.filterType}
|
||||||
|
onValueChange={(val: any) =>
|
||||||
|
updateFilterType(filter.columnName, val)
|
||||||
|
}
|
||||||
|
disabled={!filter.enabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[110px] text-xs sm:h-9 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number">숫자</SelectItem>
|
||||||
|
<SelectItem value="date">날짜</SelectItem>
|
||||||
|
<SelectItem value="select">선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 너비 입력 */}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filter.width || 200}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newWidth = parseInt(e.target.value) || 200;
|
||||||
|
setColumnFilters((prev) =>
|
||||||
|
prev.map((f) =>
|
||||||
|
f.columnName === filter.columnName
|
||||||
|
? { ...f, width: newWidth }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!filter.enabled}
|
||||||
|
placeholder="너비"
|
||||||
|
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
|
||||||
|
min={50}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">px</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 안내 메시지 */}
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3 text-center text-xs text-muted-foreground">
|
||||||
|
검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={applyFilters}
|
||||||
|
disabled={enabledCount === 0}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { ArrowRight, GripVertical, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupingPanel: React.FC<Props> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { getTable, selectedTableId } = useTableOptions();
|
||||||
|
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||||
|
|
||||||
|
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const toggleColumn = (columnName: string) => {
|
||||||
|
if (selectedColumns.includes(columnName)) {
|
||||||
|
setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
|
||||||
|
} else {
|
||||||
|
setSelectedColumns([...selectedColumns, columnName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeColumn = (columnName: string) => {
|
||||||
|
setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveColumn = (fromIndex: number, toIndex: number) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
const [movedItem] = newColumns.splice(fromIndex, 1);
|
||||||
|
newColumns.splice(toIndex, 0, movedItem);
|
||||||
|
setSelectedColumns(newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return;
|
||||||
|
moveColumn(draggedIndex, index);
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyGrouping = () => {
|
||||||
|
table?.onGroupChange(selectedColumns);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearGrouping = () => {
|
||||||
|
setSelectedColumns([]);
|
||||||
|
table?.onGroupChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
데이터를 그룹화할 컬럼을 선택하세요
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 선택된 컬럼 (드래그 가능) */}
|
||||||
|
{selectedColumns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="text-xs font-medium sm:text-sm">
|
||||||
|
그룹화 순서 ({selectedColumns.length}개)
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearGrouping}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
전체 해제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedColumns.map((colName, index) => {
|
||||||
|
const col = table?.columns.find(
|
||||||
|
(c) => c.columnName === colName
|
||||||
|
);
|
||||||
|
if (!col) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colName}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-primary/5 p-2 sm:p-3 transition-colors hover:bg-primary/10 cursor-move"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground flex-shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium sm:text-sm truncate">
|
||||||
|
{col.columnLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeColumn(colName)}
|
||||||
|
className="h-6 w-6 p-0 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹화 순서 미리보기 */}
|
||||||
|
<div className="mt-2 rounded-lg border bg-muted/30 p-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{selectedColumns.map((colName, index) => {
|
||||||
|
const col = table?.columns.find(
|
||||||
|
(c) => c.columnName === colName
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={colName}>
|
||||||
|
<span className="font-medium">{col?.columnLabel}</span>
|
||||||
|
{index < selectedColumns.length - 1 && (
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용 가능한 컬럼 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-medium sm:text-sm">
|
||||||
|
사용 가능한 컬럼
|
||||||
|
</div>
|
||||||
|
<ScrollArea className={selectedColumns.length > 0 ? "h-[280px] sm:h-[320px]" : "h-[400px] sm:h-[450px]"}>
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{table?.columns
|
||||||
|
.filter((col) => !selectedColumns.includes(col.columnName))
|
||||||
|
.map((col) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.columnName}
|
||||||
|
className="flex items-center gap-3 rounded-lg border bg-background p-2 sm:p-3 transition-colors hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => toggleColumn(col.columnName)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={false}
|
||||||
|
onCheckedChange={() => toggleColumn(col.columnName)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium sm:text-sm truncate">
|
||||||
|
{col.columnLabel}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground sm:text-xs truncate">
|
||||||
|
{col.columnName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={applyGrouping}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Settings, Filter, Layers } from "lucide-react";
|
||||||
|
import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel";
|
||||||
|
import { FilterPanel } from "./FilterPanel";
|
||||||
|
import { GroupingPanel } from "./GroupingPanel";
|
||||||
|
|
||||||
|
export const TableOptionsToolbar: React.FC = () => {
|
||||||
|
const { registeredTables, selectedTableId, setSelectedTableId } =
|
||||||
|
useTableOptions();
|
||||||
|
|
||||||
|
const [columnPanelOpen, setColumnPanelOpen] = useState(false);
|
||||||
|
const [filterPanelOpen, setFilterPanelOpen] = useState(false);
|
||||||
|
const [groupPanelOpen, setGroupPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
const tableList = Array.from(registeredTables.values());
|
||||||
|
const selectedTable = selectedTableId
|
||||||
|
? registeredTables.get(selectedTableId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 테이블이 없으면 표시하지 않음
|
||||||
|
if (tableList.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 border-b bg-background p-2">
|
||||||
|
{/* 테이블 선택 (2개 이상일 때만 표시) */}
|
||||||
|
{tableList.length > 1 && (
|
||||||
|
<Select
|
||||||
|
value={selectedTableId || ""}
|
||||||
|
onValueChange={setSelectedTableId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-48 text-xs sm:h-9 sm:w-64 sm:text-sm">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<SelectItem key={table.tableId} value={table.tableId}>
|
||||||
|
{table.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블이 1개일 때는 이름만 표시 */}
|
||||||
|
{tableList.length === 1 && (
|
||||||
|
<div className="text-xs font-medium sm:text-sm">
|
||||||
|
{tableList[0].label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 수 표시 */}
|
||||||
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||||
|
전체 {selectedTable?.columns.length || 0}개
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* 옵션 버튼들 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setColumnPanelOpen(true)}
|
||||||
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
|
disabled={!selectedTableId}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
테이블 옵션
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterPanelOpen(true)}
|
||||||
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
|
disabled={!selectedTableId}
|
||||||
|
>
|
||||||
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
|
필터 설정
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setGroupPanelOpen(true)}
|
||||||
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
|
disabled={!selectedTableId}
|
||||||
|
>
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 패널들 */}
|
||||||
|
{selectedTableId && (
|
||||||
|
<>
|
||||||
|
<ColumnVisibilityPanel
|
||||||
|
tableId={selectedTableId}
|
||||||
|
open={columnPanelOpen}
|
||||||
|
onOpenChange={setColumnPanelOpen}
|
||||||
|
/>
|
||||||
|
<FilterPanel
|
||||||
|
tableId={selectedTableId}
|
||||||
|
open={filterPanelOpen}
|
||||||
|
onOpenChange={setFilterPanelOpen}
|
||||||
|
/>
|
||||||
|
<GroupingPanel
|
||||||
|
tableId={selectedTableId}
|
||||||
|
open={groupPanelOpen}
|
||||||
|
onOpenChange={setGroupPanelOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -6,19 +6,53 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
|
|
||||||
interface CategoryWidgetProps {
|
interface CategoryWidgetProps {
|
||||||
widgetId: string;
|
widgetId?: string;
|
||||||
tableName: string; // 현재 화면의 테이블
|
tableName?: string; // 현재 화면의 테이블 (옵션 - 형제 메뉴 전체 표시)
|
||||||
|
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) - 필수
|
||||||
|
component?: any; // DynamicComponentRenderer에서 전달되는 컴포넌트 정보
|
||||||
|
[key: string]: any; // 추가 props 허용
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 관리 위젯 (좌우 분할)
|
* 카테고리 관리 위젯 (좌우 분할)
|
||||||
* - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록
|
* - 좌측: 형제 메뉴들의 모든 카테고리 타입 컬럼 목록 (메뉴 스코프)
|
||||||
* - 우측: 선택된 컬럼의 카테고리 값 관리 (테이블 스코프)
|
* - 우측: 선택된 컬럼의 카테고리 값 관리 (메뉴 스코프)
|
||||||
*/
|
*/
|
||||||
export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
|
export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...props }: CategoryWidgetProps) {
|
||||||
|
// menuObjid가 없으면 경고 로그
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("🔍 CategoryWidget 받은 props:", {
|
||||||
|
widgetId,
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
hasComponent: !!component,
|
||||||
|
propsKeys: Object.keys(props),
|
||||||
|
propsMenuObjid: props.menuObjid,
|
||||||
|
allProps: { widgetId, tableName, menuObjid, ...props },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!menuObjid && !props.menuObjid) {
|
||||||
|
console.warn("⚠️ CategoryWidget: menuObjid가 전달되지 않았습니다", {
|
||||||
|
component,
|
||||||
|
props,
|
||||||
|
allAvailableProps: { widgetId, tableName, menuObjid, ...props }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("✅ CategoryWidget 렌더링", {
|
||||||
|
widgetId,
|
||||||
|
tableName,
|
||||||
|
menuObjid: menuObjid || props.menuObjid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [menuObjid, widgetId, tableName, component, props]);
|
||||||
|
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
|
||||||
|
const effectiveMenuObjid = menuObjid || props.menuObjid;
|
||||||
|
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{
|
const [selectedColumn, setSelectedColumn] = useState<{
|
||||||
|
uniqueKey: string; // 테이블명.컬럼명 형식
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
|
tableName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
|
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
|
||||||
|
|
@ -65,10 +99,13 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
|
||||||
<div style={{ width: `${leftWidth}%` }} className="pr-3">
|
<div style={{ width: `${leftWidth}%` }} className="pr-3">
|
||||||
<CategoryColumnList
|
<CategoryColumnList
|
||||||
tableName={tableName}
|
tableName={tableName}
|
||||||
selectedColumn={selectedColumn?.columnName || null}
|
selectedColumn={selectedColumn?.uniqueKey || null}
|
||||||
onColumnSelect={(columnName, columnLabel) =>
|
onColumnSelect={(uniqueKey, columnLabel, tableName) => {
|
||||||
setSelectedColumn({ columnName, columnLabel })
|
// uniqueKey는 "테이블명.컬럼명" 형식
|
||||||
}
|
const columnName = uniqueKey.split('.')[1];
|
||||||
|
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
||||||
|
}}
|
||||||
|
menuObjid={effectiveMenuObjid}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -84,9 +121,11 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
|
||||||
<div style={{ width: `${100 - leftWidth - 1}%` }} className="pl-3">
|
<div style={{ width: `${100 - leftWidth - 1}%` }} className="pl-3">
|
||||||
{selectedColumn ? (
|
{selectedColumn ? (
|
||||||
<CategoryValueManager
|
<CategoryValueManager
|
||||||
tableName={tableName}
|
key={selectedColumn.uniqueKey} // 테이블명.컬럼명으로 컴포넌트 재생성
|
||||||
|
tableName={selectedColumn.tableName}
|
||||||
columnName={selectedColumn.columnName}
|
columnName={selectedColumn.columnName}
|
||||||
columnLabel={selectedColumn.columnLabel}
|
columnLabel={selectedColumn.columnLabel}
|
||||||
|
menuObjid={effectiveMenuObjid}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
// 그룹화된 데이터 인터페이스
|
// 그룹화된 데이터 인터페이스
|
||||||
interface GroupedData {
|
interface GroupedData {
|
||||||
|
|
@ -65,6 +67,12 @@ export function FlowWidget({
|
||||||
}: FlowWidgetProps) {
|
}: FlowWidgetProps) {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
|
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
||||||
|
|
||||||
|
// TableOptions 상태
|
||||||
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [grouping, setGrouping] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
||||||
// 숫자 포맷팅 함수
|
// 숫자 포맷팅 함수
|
||||||
const formatValue = (value: any): string => {
|
const formatValue = (value: any): string => {
|
||||||
|
|
@ -301,6 +309,36 @@ export function FlowWidget({
|
||||||
toast.success("그룹이 해제되었습니다");
|
toast.success("그룹이 해제되었습니다");
|
||||||
}, [groupSettingKey]);
|
}, [groupSettingKey]);
|
||||||
|
|
||||||
|
// 테이블 등록 (선택된 스텝이 있을 때)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedStepId || !stepDataColumns || stepDataColumns.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableId = `flow-widget-${component.id}-step-${selectedStepId}`;
|
||||||
|
const currentStep = steps.find((s) => s.id === selectedStepId);
|
||||||
|
|
||||||
|
registerTable({
|
||||||
|
tableId,
|
||||||
|
label: `${flowName || "플로우"} - ${currentStep?.name || "스텝"}`,
|
||||||
|
tableName: "flow_step_data",
|
||||||
|
columns: stepDataColumns.map((col) => ({
|
||||||
|
columnName: col,
|
||||||
|
columnLabel: columnLabels[col] || col,
|
||||||
|
inputType: "text",
|
||||||
|
visible: true,
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
})),
|
||||||
|
onFilterChange: setFilters,
|
||||||
|
onGroupChange: setGrouping,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unregisterTable(tableId);
|
||||||
|
}, [selectedStepId, stepDataColumns, columnLabels, flowName, steps, component.id]);
|
||||||
|
|
||||||
// 🆕 데이터 그룹화
|
// 🆕 데이터 그룹화
|
||||||
const groupedData = useMemo((): GroupedData[] => {
|
const groupedData = useMemo((): GroupedData[] => {
|
||||||
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
import { FolderTree, Loader2 } from "lucide-react";
|
import { FolderTree, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface CategoryColumn {
|
interface CategoryColumn {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string; // 테이블 라벨 추가
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
inputType: string;
|
inputType: string;
|
||||||
|
|
@ -13,94 +15,85 @@ interface CategoryColumn {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryColumnListProps {
|
interface CategoryColumnListProps {
|
||||||
tableName: string;
|
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시)
|
||||||
selectedColumn: string | null;
|
selectedColumn: string | null;
|
||||||
onColumnSelect: (columnName: string, columnLabel: string) => void;
|
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void;
|
||||||
|
menuObjid?: number; // 현재 메뉴 OBJID (필수)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 컬럼 목록 (좌측 패널)
|
* 카테고리 컬럼 목록 (좌측 패널)
|
||||||
* - 현재 테이블에서 input_type='category'인 컬럼들을 표시 (테이블 스코프)
|
* - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프)
|
||||||
*/
|
*/
|
||||||
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }: CategoryColumnListProps) {
|
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) {
|
||||||
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategoryColumns();
|
if (menuObjid) {
|
||||||
}, [tableName]);
|
loadCategoryColumnsByMenu();
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다");
|
||||||
|
setColumns([]);
|
||||||
|
}
|
||||||
|
}, [menuObjid]);
|
||||||
|
|
||||||
const loadCategoryColumns = async () => {
|
const loadCategoryColumnsByMenu = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// table_type_columns에서 input_type = 'category'인 컬럼 조회
|
console.log("🔍 형제 메뉴의 카테고리 컬럼 조회 시작", { menuObjid });
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
||||||
|
|
||||||
console.log("🔍 테이블 컬럼 API 응답:", {
|
// 새 API: 형제 메뉴들의 카테고리 컬럼 조회
|
||||||
tableName,
|
const response = await apiClient.get(`/table-management/menu/${menuObjid}/category-columns`);
|
||||||
|
|
||||||
|
console.log("✅ 메뉴별 카테고리 컬럼 API 응답:", {
|
||||||
|
menuObjid,
|
||||||
response: response.data,
|
response: response.data,
|
||||||
type: typeof response.data,
|
|
||||||
isArray: Array.isArray(response.data),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API 응답 구조 파싱 (여러 가능성 대응)
|
let categoryColumns: any[] = [];
|
||||||
let allColumns: any[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(response.data)) {
|
if (response.data.success && response.data.data) {
|
||||||
// response.data가 직접 배열인 경우
|
categoryColumns = response.data.data;
|
||||||
allColumns = response.data;
|
} else if (Array.isArray(response.data)) {
|
||||||
} else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) {
|
categoryColumns = response.data;
|
||||||
// response.data.data.columns가 배열인 경우 (table-management API)
|
|
||||||
allColumns = response.data.data.columns;
|
|
||||||
} else if (response.data.data && Array.isArray(response.data.data)) {
|
|
||||||
// response.data.data가 배열인 경우
|
|
||||||
allColumns = response.data.data;
|
|
||||||
} else if (response.data.columns && Array.isArray(response.data.columns)) {
|
|
||||||
// response.data.columns가 배열인 경우
|
|
||||||
allColumns = response.data.columns;
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data);
|
console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data);
|
||||||
allColumns = [];
|
categoryColumns = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 파싱된 컬럼 목록:", {
|
console.log("✅ 카테고리 컬럼 파싱 완료:", {
|
||||||
totalColumns: allColumns.length,
|
|
||||||
sample: allColumns.slice(0, 3),
|
|
||||||
});
|
|
||||||
|
|
||||||
// category 타입만 필터링
|
|
||||||
const categoryColumns = allColumns.filter(
|
|
||||||
(col: any) => col.inputType === "category" || col.input_type === "category",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ 카테고리 컬럼:", {
|
|
||||||
count: categoryColumns.length,
|
count: categoryColumns.length,
|
||||||
columns: categoryColumns.map((c: any) => ({
|
columns: categoryColumns.map((c: any) => ({
|
||||||
name: c.columnName || c.column_name,
|
table: c.tableName,
|
||||||
type: c.inputType || c.input_type,
|
column: c.columnName,
|
||||||
|
label: c.columnLabel,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 각 컬럼의 값 개수 가져오기
|
||||||
const columnsWithCount = await Promise.all(
|
const columnsWithCount = await Promise.all(
|
||||||
categoryColumns.map(async (col: any) => {
|
categoryColumns.map(async (col: any) => {
|
||||||
const colName = col.columnName || col.column_name;
|
const colTable = col.tableName;
|
||||||
const colLabel = col.columnLabel || col.column_label || col.displayName || colName;
|
const colName = col.columnName;
|
||||||
|
const colLabel = col.columnLabel || colName;
|
||||||
|
|
||||||
// 각 컬럼의 값 개수 가져오기
|
|
||||||
let valueCount = 0;
|
let valueCount = 0;
|
||||||
try {
|
try {
|
||||||
const valuesResult = await getCategoryValues(tableName, colName, false);
|
const valuesResult = await getCategoryValues(colTable, colName, false, menuObjid);
|
||||||
if (valuesResult.success && valuesResult.data) {
|
if (valuesResult.success && valuesResult.data) {
|
||||||
valueCount = valuesResult.data.length;
|
valueCount = valuesResult.data.length;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`항목 개수 조회 실패 (${colName}):`, error);
|
console.error(`항목 개수 조회 실패 (${colTable}.${colName}):`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
tableName: colTable,
|
||||||
|
tableLabel: col.tableLabel || colTable, // 테이블 라벨 추가
|
||||||
columnName: colName,
|
columnName: colName,
|
||||||
columnLabel: colLabel,
|
columnLabel: colLabel,
|
||||||
inputType: col.inputType || col.input_type,
|
inputType: col.inputType,
|
||||||
valueCount,
|
valueCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
@ -111,7 +104,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }
|
||||||
// 첫 번째 컬럼 자동 선택
|
// 첫 번째 컬럼 자동 선택
|
||||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||||
const firstCol = columnsWithCount[0];
|
const firstCol = columnsWithCount[0];
|
||||||
onColumnSelect(firstCol.columnName, firstCol.columnLabel);
|
onColumnSelect(firstCol.columnName, firstCol.columnLabel, firstCol.tableName);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
||||||
|
|
@ -152,27 +145,32 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => {
|
||||||
|
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||||
|
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={uniqueKey}
|
||||||
onClick={() => onColumnSelect(column.columnName, column.columnLabel || column.columnName)}
|
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||||
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
|
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
|
||||||
selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FolderTree
|
<FolderTree
|
||||||
className={`h-4 w-4 ${selectedColumn === column.columnName ? "text-primary" : "text-muted-foreground"}`}
|
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
||||||
|
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs font-medium">
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface CategoryValueManagerProps {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
onValueCountChange?: (count: number) => void;
|
onValueCountChange?: (count: number) => void;
|
||||||
|
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
|
|
@ -36,6 +37,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
columnName,
|
columnName,
|
||||||
columnLabel,
|
columnLabel,
|
||||||
onValueCountChange,
|
onValueCountChange,
|
||||||
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [values, setValues] = useState<TableCategoryValue[]>([]);
|
const [values, setValues] = useState<TableCategoryValue[]>([]);
|
||||||
|
|
@ -81,7 +83,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// includeInactive: true로 비활성 값도 포함
|
// includeInactive: true로 비활성 값도 포함
|
||||||
const response = await getCategoryValues(tableName, columnName, true);
|
const response = await getCategoryValues(tableName, columnName, true, menuObjid);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setValues(response.data);
|
setValues(response.data);
|
||||||
setFilteredValues(response.data);
|
setFilteredValues(response.data);
|
||||||
|
|
@ -101,11 +103,23 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
|
|
||||||
const handleAddValue = async (newValue: TableCategoryValue) => {
|
const handleAddValue = async (newValue: TableCategoryValue) => {
|
||||||
try {
|
try {
|
||||||
const response = await addCategoryValue({
|
if (!menuObjid) {
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "메뉴 정보가 없습니다. 카테고리 값을 추가할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await addCategoryValue(
|
||||||
|
{
|
||||||
...newValue,
|
...newValue,
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
});
|
},
|
||||||
|
menuObjid
|
||||||
|
);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
await loadCategoryValues();
|
await loadCategoryValues();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
TableRegistration,
|
||||||
|
TableOptionsContextValue,
|
||||||
|
} from "@/types/table-options";
|
||||||
|
|
||||||
|
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [registeredTables, setRegisteredTables] = useState<
|
||||||
|
Map<string, TableRegistration>
|
||||||
|
>(new Map());
|
||||||
|
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 등록
|
||||||
|
*/
|
||||||
|
const registerTable = useCallback((registration: TableRegistration) => {
|
||||||
|
setRegisteredTables((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(registration.tableId, registration);
|
||||||
|
|
||||||
|
// 첫 번째 테이블이면 자동 선택
|
||||||
|
if (newMap.size === 1) {
|
||||||
|
setSelectedTableId(registration.tableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 등록 해제
|
||||||
|
*/
|
||||||
|
const unregisterTable = useCallback(
|
||||||
|
(tableId: string) => {
|
||||||
|
setRegisteredTables((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const removed = newMap.delete(tableId);
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
|
||||||
|
if (selectedTableId === tableId) {
|
||||||
|
const firstTableId = newMap.keys().next().value;
|
||||||
|
setSelectedTableId(firstTableId || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[selectedTableId]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블 조회
|
||||||
|
*/
|
||||||
|
const getTable = useCallback(
|
||||||
|
(tableId: string) => {
|
||||||
|
return registeredTables.get(tableId);
|
||||||
|
},
|
||||||
|
[registeredTables]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 데이터 건수 업데이트
|
||||||
|
*/
|
||||||
|
const updateTableDataCount = useCallback((tableId: string, count: number) => {
|
||||||
|
setRegisteredTables((prev) => {
|
||||||
|
const table = prev.get(tableId);
|
||||||
|
if (table) {
|
||||||
|
// 기존 테이블 정보에 dataCount만 업데이트
|
||||||
|
const updatedTable = { ...table, dataCount: count };
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(tableId, updatedTable);
|
||||||
|
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
|
||||||
|
tableId,
|
||||||
|
count,
|
||||||
|
updated: true,
|
||||||
|
});
|
||||||
|
return newMap;
|
||||||
|
}
|
||||||
|
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableOptionsContext.Provider
|
||||||
|
value={{
|
||||||
|
registeredTables,
|
||||||
|
registerTable,
|
||||||
|
unregisterTable,
|
||||||
|
getTable,
|
||||||
|
updateTableDataCount,
|
||||||
|
selectedTableId,
|
||||||
|
setSelectedTableId,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TableOptionsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context Hook
|
||||||
|
*/
|
||||||
|
export const useTableOptions = () => {
|
||||||
|
const context = useContext(TableOptionsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTableOptions must be used within TableOptionsProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface WidgetHeight {
|
||||||
|
screenId: number;
|
||||||
|
componentId: string;
|
||||||
|
height: number;
|
||||||
|
originalHeight: number; // 디자이너에서 설정한 원래 높이
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableSearchWidgetHeightContextValue {
|
||||||
|
widgetHeights: Map<string, WidgetHeight>;
|
||||||
|
setWidgetHeight: (screenId: number, componentId: string, height: number, originalHeight: number) => void;
|
||||||
|
getWidgetHeight: (screenId: number, componentId: string) => WidgetHeight | undefined;
|
||||||
|
getHeightDiff: (screenId: number, componentId: string) => number; // 실제 높이 - 원래 높이
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableSearchWidgetHeightContext = createContext<TableSearchWidgetHeightContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export function TableSearchWidgetHeightProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [widgetHeights, setWidgetHeights] = useState<Map<string, WidgetHeight>>(new Map());
|
||||||
|
|
||||||
|
const setWidgetHeight = useCallback(
|
||||||
|
(screenId: number, componentId: string, height: number, originalHeight: number) => {
|
||||||
|
const key = `${screenId}_${componentId}`;
|
||||||
|
|
||||||
|
setWidgetHeights((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(key, {
|
||||||
|
screenId,
|
||||||
|
componentId,
|
||||||
|
height,
|
||||||
|
originalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getWidgetHeight = useCallback(
|
||||||
|
(screenId: number, componentId: string): WidgetHeight | undefined => {
|
||||||
|
const key = `${screenId}_${componentId}`;
|
||||||
|
return widgetHeights.get(key);
|
||||||
|
},
|
||||||
|
[widgetHeights]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getHeightDiff = useCallback(
|
||||||
|
(screenId: number, componentId: string): number => {
|
||||||
|
const widgetHeight = getWidgetHeight(screenId, componentId);
|
||||||
|
if (!widgetHeight) return 0;
|
||||||
|
|
||||||
|
const diff = widgetHeight.height - widgetHeight.originalHeight;
|
||||||
|
return diff;
|
||||||
|
},
|
||||||
|
[getWidgetHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableSearchWidgetHeightContext.Provider
|
||||||
|
value={{
|
||||||
|
widgetHeights,
|
||||||
|
setWidgetHeight,
|
||||||
|
getWidgetHeight,
|
||||||
|
getHeightDiff,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TableSearchWidgetHeightContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTableSearchWidgetHeight() {
|
||||||
|
const context = useContext(TableSearchWidgetHeightContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useTableSearchWidgetHeight must be used within TableSearchWidgetHeightProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +33,6 @@ export function useTableCodeCategory(tableName?: string, columnName?: string) {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!tableName || !columnName) return null;
|
if (!tableName || !columnName) return null;
|
||||||
|
|
||||||
console.log(`🔍 [React Query] 테이블 코드 카테고리 조회: ${tableName}.${columnName}`);
|
|
||||||
const columns = await tableTypeApi.getColumns(tableName);
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
const targetColumn = columns.find((col) => col.columnName === columnName);
|
const targetColumn = columns.find((col) => col.columnName === columnName);
|
||||||
|
|
||||||
|
|
@ -41,7 +40,6 @@ export function useTableCodeCategory(tableName?: string, columnName?: string) {
|
||||||
? targetColumn.codeCategory
|
? targetColumn.codeCategory
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
console.log(`✅ [React Query] 테이블 코드 카테고리 결과: ${tableName}.${columnName} -> ${codeCategory}`);
|
|
||||||
return codeCategory;
|
return codeCategory;
|
||||||
},
|
},
|
||||||
enabled: !!(tableName && columnName),
|
enabled: !!(tableName && columnName),
|
||||||
|
|
@ -51,14 +49,32 @@ export function useTableCodeCategory(tableName?: string, columnName?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 코드 옵션 조회 (select용)
|
// 코드 옵션 조회 (select용)
|
||||||
export function useCodeOptions(codeCategory?: string, enabled: boolean = true) {
|
export function useCodeOptions(codeCategory?: string, enabled: boolean = true, menuObjid?: number) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: queryKeys.codes.options(codeCategory || ""),
|
queryKey: menuObjid
|
||||||
|
? [...queryKeys.codes.options(codeCategory || ""), 'menu', menuObjid]
|
||||||
|
: queryKeys.codes.options(codeCategory || ""),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!codeCategory || codeCategory === "none") return [];
|
if (!codeCategory || codeCategory === "none") return [];
|
||||||
|
|
||||||
console.log(`🔍 [React Query] 코드 옵션 조회: ${codeCategory}`);
|
console.log(`🔍 [useCodeOptions] 코드 옵션 조회 시작:`, {
|
||||||
const response = await commonCodeApi.codes.getList(codeCategory, { isActive: true });
|
codeCategory,
|
||||||
|
menuObjid,
|
||||||
|
hasMenuObjid: !!menuObjid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await commonCodeApi.codes.getList(codeCategory, {
|
||||||
|
isActive: true,
|
||||||
|
menuObjid
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📦 [useCodeOptions] API 응답:`, {
|
||||||
|
codeCategory,
|
||||||
|
menuObjid,
|
||||||
|
success: response.success,
|
||||||
|
dataCount: response.data?.length || 0,
|
||||||
|
rawData: response.data,
|
||||||
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const options = response.data.map((code: any) => {
|
const options = response.data.map((code: any) => {
|
||||||
|
|
@ -73,7 +89,13 @@ export function useCodeOptions(codeCategory?: string, enabled: boolean = true) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [React Query] 코드 옵션 결과: ${codeCategory} (${options.length}개)`);
|
console.log(`✅ [useCodeOptions] 옵션 변환 완료:`, {
|
||||||
|
codeCategory,
|
||||||
|
menuObjid,
|
||||||
|
optionsCount: options.length,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,12 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
||||||
if (menu.children && menu.children.length > 0) {
|
if (menu.children && menu.children.length > 0) {
|
||||||
toggleMenu(String(menu.OBJID));
|
toggleMenu(String(menu.OBJID));
|
||||||
} else {
|
} else {
|
||||||
|
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
|
||||||
|
const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴";
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("currentMenuName", menuName);
|
||||||
|
}
|
||||||
|
|
||||||
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
||||||
try {
|
try {
|
||||||
const menuObjid = menu.OBJID || menu.objid;
|
const menuObjid = menu.OBJID || menu.objid;
|
||||||
|
|
@ -170,7 +176,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
||||||
if (assignedScreens.length > 0) {
|
if (assignedScreens.length > 0) {
|
||||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
||||||
const firstScreen = assignedScreens[0];
|
const firstScreen = assignedScreens[0];
|
||||||
router.push(`/screens/${firstScreen.screenId}`);
|
// menuObjid를 쿼리 파라미터로 전달
|
||||||
|
router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,13 +66,14 @@ export const commonCodeApi = {
|
||||||
/**
|
/**
|
||||||
* 카테고리별 코드 목록 조회
|
* 카테고리별 코드 목록 조회
|
||||||
*/
|
*/
|
||||||
async getList(categoryCode: string, params?: GetCodesQuery): Promise<ApiResponse<CodeInfo[]>> {
|
async getList(categoryCode: string, params?: GetCodesQuery & { menuObjid?: number }): Promise<ApiResponse<CodeInfo[]>> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params?.search) searchParams.append("search", params.search);
|
if (params?.search) searchParams.append("search", params.search);
|
||||||
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
|
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
|
||||||
if (params?.page !== undefined) searchParams.append("page", params.page.toString());
|
if (params?.page !== undefined) searchParams.append("page", params.page.toString());
|
||||||
if (params?.size !== undefined) searchParams.append("size", params.size.toString());
|
if (params?.size !== undefined) searchParams.append("size", params.size.toString());
|
||||||
|
if (params?.menuObjid !== undefined) searchParams.append("menuObjid", params.menuObjid.toString());
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
const url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`;
|
const url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ export const screenApi = {
|
||||||
} as ScreenDefinition;
|
} as ScreenDefinition;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 화면에 할당된 메뉴 조회
|
||||||
|
getScreenMenu: async (screenId: number): Promise<{ menuObjid: number; menuName?: string } | null> => {
|
||||||
|
const response = await apiClient.get(`/screen-management/screens/${screenId}/menu`);
|
||||||
|
return response.data?.data || null;
|
||||||
|
},
|
||||||
|
|
||||||
// 화면 생성
|
// 화면 생성
|
||||||
createScreen: async (screenData: CreateScreenRequest): Promise<ScreenDefinition> => {
|
createScreen: async (screenData: CreateScreenRequest): Promise<ScreenDefinition> => {
|
||||||
const response = await apiClient.post("/screen-management/screens", screenData);
|
const response = await apiClient.post("/screen-management/screens", screenData);
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,30 @@ export async function getCategoryColumns(tableName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (테이블 스코프)
|
* 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
|
*
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param columnName 컬럼명
|
||||||
|
* @param includeInactive 비활성 값 포함 여부
|
||||||
|
* @param menuObjid 메뉴 OBJID (선택사항, 제공 시 형제 메뉴의 카테고리 값 포함)
|
||||||
*/
|
*/
|
||||||
export async function getCategoryValues(
|
export async function getCategoryValues(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
includeInactive: boolean = false
|
includeInactive: boolean = false,
|
||||||
|
menuObjid?: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const params: any = { includeInactive };
|
||||||
|
if (menuObjid) {
|
||||||
|
params.menuObjid = menuObjid;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.get<{
|
const response = await apiClient.get<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: TableCategoryValue[];
|
data: TableCategoryValue[];
|
||||||
}>(`/table-categories/${tableName}/${columnName}/values`, {
|
}>(`/table-categories/${tableName}/${columnName}/values`, {
|
||||||
params: { includeInactive },
|
params,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -43,14 +54,23 @@ export async function getCategoryValues(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 추가
|
* 카테고리 값 추가 (메뉴 스코프)
|
||||||
|
*
|
||||||
|
* @param value 카테고리 값 정보
|
||||||
|
* @param menuObjid 메뉴 OBJID (필수)
|
||||||
*/
|
*/
|
||||||
export async function addCategoryValue(value: TableCategoryValue) {
|
export async function addCategoryValue(
|
||||||
|
value: TableCategoryValue,
|
||||||
|
menuObjid: number
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<{
|
const response = await apiClient.post<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: TableCategoryValue;
|
data: TableCategoryValue;
|
||||||
}>("/table-categories/values", value);
|
}>("/table-categories/values", {
|
||||||
|
...value,
|
||||||
|
menuObjid, // ← menuObjid 포함
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("카테고리 값 추가 실패:", error);
|
console.error("카테고리 값 추가 실패:", error);
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ export interface DynamicComponentRendererProps {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요)
|
menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요)
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번)
|
||||||
selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용)
|
selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용)
|
||||||
userId?: string; // 🆕 현재 사용자 ID
|
userId?: string; // 🆕 현재 사용자 ID
|
||||||
userName?: string; // 🆕 현재 사용자 이름
|
userName?: string; // 🆕 현재 사용자 이름
|
||||||
|
|
@ -224,6 +225,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
tableName,
|
tableName,
|
||||||
menuId, // 🆕 메뉴 ID
|
menuId, // 🆕 메뉴 ID
|
||||||
|
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
||||||
selectedScreen, // 🆕 화면 정보
|
selectedScreen, // 🆕 화면 정보
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -319,6 +321,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
||||||
tableName,
|
tableName,
|
||||||
menuId, // 🆕 메뉴 ID
|
menuId, // 🆕 메뉴 ID
|
||||||
|
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
||||||
selectedScreen, // 🆕 화면 정보
|
selectedScreen, // 🆕 화면 정보
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
|
||||||
|
|
@ -552,8 +552,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
lineHeight: "1.25",
|
||||||
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용 (width/height 제외)
|
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||||
...(isInteractive && component.style ? Object.fromEntries(
|
...(component.style ? Object.fromEntries(
|
||||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||||
) : {}),
|
) : {}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||||
import "./flow-widget/FlowWidgetRenderer";
|
import "./flow-widget/FlowWidgetRenderer";
|
||||||
import "./numbering-rule/NumberingRuleRenderer";
|
import "./numbering-rule/NumberingRuleRenderer";
|
||||||
import "./category-manager/CategoryManagerRenderer";
|
import "./category-manager/CategoryManagerRenderer";
|
||||||
|
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface NumberingRuleWrapperProps {
|
||||||
onChange?: (config: NumberingRuleComponentConfig) => void;
|
onChange?: (config: NumberingRuleComponentConfig) => void;
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
tableName?: string; // 현재 화면의 테이블명
|
tableName?: string; // 현재 화면의 테이블명
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||||
|
|
@ -16,8 +17,14 @@ export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
tableName,
|
tableName,
|
||||||
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
console.log("📋 NumberingRuleWrapper: 테이블명 전달", { tableName, config });
|
console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
hasMenuObjid: !!menuObjid,
|
||||||
|
config
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|
@ -26,6 +33,7 @@ export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
|
||||||
isPreview={isPreview}
|
isPreview={isPreview}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
currentTableName={tableName} // 테이블명 전달
|
currentTableName={tableName} // 테이블명 전달
|
||||||
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface SelectBasicComponentProps {
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
value?: any; // 외부에서 전달받는 값
|
value?: any; // 외부에서 전달받는 값
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,19 +47,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
value: externalValue, // 명시적으로 value prop 받기
|
value: externalValue, // 명시적으로 value prop 받기
|
||||||
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 🚨 최우선 디버깅: 컴포넌트가 실행되는지 확인
|
|
||||||
console.log("🚨🚨🚨 SelectBasicComponent 실행됨!!!", {
|
|
||||||
componentId: component?.id,
|
|
||||||
componentType: component?.type,
|
|
||||||
webType: component?.webType,
|
|
||||||
tableName: component?.tableName,
|
|
||||||
columnName: component?.columnName,
|
|
||||||
screenId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||||
|
|
@ -77,30 +68,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// autocomplete의 경우 검색어 관리
|
// autocomplete의 경우 검색어 관리
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
console.log("🔍 SelectBasicComponent 초기화 (React Query):", {
|
|
||||||
componentId: component.id,
|
|
||||||
externalValue,
|
|
||||||
componentConfigValue: componentConfig?.value,
|
|
||||||
webTypeConfigValue: (props as any).webTypeConfig?.value,
|
|
||||||
configValue: config?.value,
|
|
||||||
finalSelectedValue: externalValue || config?.value || "",
|
|
||||||
tableName: component.tableName,
|
|
||||||
columnName: component.columnName,
|
|
||||||
staticCodeCategory: config?.codeCategory,
|
|
||||||
// React Query 디버깅 정보
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
mountCount: ++(window as any).selectMountCount || ((window as any).selectMountCount = 1),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 언마운트 시 로깅
|
|
||||||
useEffect(() => {
|
|
||||||
const componentId = component.id;
|
|
||||||
console.log(`🔍 [${componentId}] SelectBasicComponent 마운트됨`);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log(`🔍 [${componentId}] SelectBasicComponent 언마운트됨`);
|
|
||||||
};
|
|
||||||
}, [component.id]);
|
|
||||||
|
|
||||||
const selectRef = useRef<HTMLDivElement>(null);
|
const selectRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -115,11 +82,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션)
|
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션)
|
||||||
const codeCategory = useMemo(() => {
|
const codeCategory = useMemo(() => {
|
||||||
const category = dynamicCodeCategory || staticCodeCategory;
|
const category = dynamicCodeCategory || staticCodeCategory;
|
||||||
console.log(`🔑 [${component.id}] 코드 카테고리 결정:`, {
|
|
||||||
dynamicCodeCategory,
|
|
||||||
staticCodeCategory,
|
|
||||||
finalCategory: category,
|
|
||||||
});
|
|
||||||
return category;
|
return category;
|
||||||
}, [dynamicCodeCategory, staticCodeCategory, component.id]);
|
}, [dynamicCodeCategory, staticCodeCategory, component.id]);
|
||||||
|
|
||||||
|
|
@ -132,34 +94,27 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
options: codeOptions,
|
options: codeOptions,
|
||||||
isLoading: isLoadingCodes,
|
isLoading: isLoadingCodes,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useCodeOptions(codeCategory, isCodeCategoryValid);
|
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
|
||||||
|
|
||||||
// React Query 상태 디버깅
|
// 디버깅: menuObjid가 제대로 전달되는지 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`🎯 [${component.id}] React Query 상태:`, {
|
if (codeCategory && codeCategory !== "none") {
|
||||||
|
console.log(`🎯 [SelectBasicComponent ${component.id}] 코드 옵션 로드:`, {
|
||||||
codeCategory,
|
codeCategory,
|
||||||
|
menuObjid,
|
||||||
|
hasMenuObjid: !!menuObjid,
|
||||||
isCodeCategoryValid,
|
isCodeCategoryValid,
|
||||||
codeOptionsLength: codeOptions.length,
|
codeOptionsCount: codeOptions.length,
|
||||||
isLoadingCodes,
|
isLoading: isLoadingCodes,
|
||||||
isFetching,
|
|
||||||
cacheStatus: isFetching ? "FETCHING" : "FROM_CACHE",
|
|
||||||
});
|
});
|
||||||
}, [component.id, codeCategory, isCodeCategoryValid, codeOptions.length, isLoadingCodes, isFetching]);
|
}
|
||||||
|
}, [component.id, codeCategory, menuObjid, codeOptions.length, isLoadingCodes, isCodeCategoryValid]);
|
||||||
|
|
||||||
// 외부 value prop 변경 시 selectedValue 업데이트
|
// 외부 value prop 변경 시 selectedValue 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newValue = externalValue || config?.value || "";
|
const newValue = externalValue || config?.value || "";
|
||||||
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
||||||
if (newValue !== selectedValue) {
|
if (newValue !== selectedValue) {
|
||||||
console.log(`🔄 SelectBasicComponent value 업데이트: "${selectedValue}" → "${newValue}"`);
|
|
||||||
console.log("🔍 업데이트 조건 분석:", {
|
|
||||||
externalValue,
|
|
||||||
componentConfigValue: componentConfig?.value,
|
|
||||||
configValue: config?.value,
|
|
||||||
newValue,
|
|
||||||
selectedValue,
|
|
||||||
shouldUpdate: newValue !== selectedValue,
|
|
||||||
});
|
|
||||||
setSelectedValue(newValue);
|
setSelectedValue(newValue);
|
||||||
}
|
}
|
||||||
}, [externalValue, config?.value]);
|
}, [externalValue, config?.value]);
|
||||||
|
|
@ -188,23 +143,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const labelMatch = options.find((option) => option.label === selectedValue);
|
const labelMatch = options.find((option) => option.label === selectedValue);
|
||||||
if (labelMatch) {
|
if (labelMatch) {
|
||||||
newLabel = labelMatch.label;
|
newLabel = labelMatch.label;
|
||||||
console.log(`🔍 [${component.id}] 코드명으로 매치 발견: "${selectedValue}" → "${newLabel}"`);
|
|
||||||
} else {
|
} else {
|
||||||
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
|
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
|
||||||
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
|
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
|
||||||
console.log(`🔍 [${component.id}] 코드값 원본 유지: "${selectedValue}"`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🏷️ [${component.id}] 라벨 업데이트:`, {
|
|
||||||
selectedValue,
|
|
||||||
selectedOption: selectedOption ? { value: selectedOption.value, label: selectedOption.label } : null,
|
|
||||||
newLabel,
|
|
||||||
optionsCount: options.length,
|
|
||||||
allOptionsValues: options.map((o) => o.value),
|
|
||||||
allOptionsLabels: options.map((o) => o.label),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newLabel !== selectedLabel) {
|
if (newLabel !== selectedLabel) {
|
||||||
setSelectedLabel(newLabel);
|
setSelectedLabel(newLabel);
|
||||||
}
|
}
|
||||||
|
|
@ -214,15 +158,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (isDesignMode) return;
|
if (isDesignMode) return;
|
||||||
|
|
||||||
console.log(`🖱️ [${component.id}] 드롭다운 토글 (React Query): ${isOpen} → ${!isOpen}`);
|
|
||||||
console.log(`📊 [${component.id}] 현재 상태:`, {
|
|
||||||
codeCategory,
|
|
||||||
isLoadingCodes,
|
|
||||||
codeOptionsLength: codeOptions.length,
|
|
||||||
tableName: component.tableName,
|
|
||||||
columnName: component.columnName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
};
|
};
|
||||||
|
|
@ -240,17 +175,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
|
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
console.log(`📤 SelectBasicComponent -> onFormDataChange 호출: ${component.columnName} = "${value}"`);
|
|
||||||
onFormDataChange(component.columnName, value);
|
onFormDataChange(component.columnName, value);
|
||||||
} else {
|
|
||||||
console.log("❌ SelectBasicComponent onFormDataChange 조건 미충족:", {
|
|
||||||
isInteractive,
|
|
||||||
hasOnFormDataChange: !!onFormDataChange,
|
|
||||||
hasColumnName: !!component.columnName,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ [${component.id}] 옵션 선택:`, { value, label });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 외부 클릭 시 드롭다운 닫기
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
|
@ -278,12 +204,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
console.log(`🔧 [${component.id}] 옵션 병합:`, {
|
|
||||||
codeOptionsLength: codeOptions.length,
|
|
||||||
codeOptions: codeOptions.map((o: Option) => ({ value: o.value, label: o.label })),
|
|
||||||
configOptionsLength: configOptions.length,
|
|
||||||
configOptions: configOptions.map((o: Option) => ({ value: o.value, label: o.label })),
|
|
||||||
});
|
|
||||||
return [...codeOptions, ...configOptions];
|
return [...codeOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { ComponentRendererProps } from "../../types";
|
import { ComponentRendererProps } from "../../types";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -8,10 +8,14 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2 } from "lucide-react";
|
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2 } from "lucide-react";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
|
|
@ -37,6 +41,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
||||||
const minRightWidth = componentConfig.minRightWidth || 300;
|
const minRightWidth = componentConfig.minRightWidth || 300;
|
||||||
|
|
||||||
|
// TableOptions Context
|
||||||
|
const { registerTable, unregisterTable } = useTableOptions();
|
||||||
|
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
|
||||||
|
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
|
||||||
|
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||||
|
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
||||||
// 데이터 상태
|
// 데이터 상태
|
||||||
const [leftData, setLeftData] = useState<any[]>([]);
|
const [leftData, setLeftData] = useState<any[]>([]);
|
||||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
||||||
|
|
@ -48,6 +62,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
|
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// 추가 모달 상태
|
// 추가 모달 상태
|
||||||
|
|
@ -147,6 +163,84 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return rootItems;
|
return rootItems;
|
||||||
}, [componentConfig.leftPanel?.itemAddConfig]);
|
}, [componentConfig.leftPanel?.itemAddConfig]);
|
||||||
|
|
||||||
|
// 🔧 사용자 ID 가져오기
|
||||||
|
const { userId: currentUserId } = useAuth();
|
||||||
|
|
||||||
|
// 🔄 필터를 searchValues 형식으로 변환
|
||||||
|
const searchValues = useMemo(() => {
|
||||||
|
if (!leftFilters || leftFilters.length === 0) return {};
|
||||||
|
|
||||||
|
const values: Record<string, any> = {};
|
||||||
|
leftFilters.forEach(filter => {
|
||||||
|
if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
|
||||||
|
values[filter.columnName] = {
|
||||||
|
value: filter.value,
|
||||||
|
operator: filter.operator || 'contains',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}, [leftFilters]);
|
||||||
|
|
||||||
|
// 🔄 컬럼 가시성 및 순서 처리
|
||||||
|
const visibleLeftColumns = useMemo(() => {
|
||||||
|
const displayColumns = componentConfig.leftPanel?.columns || [];
|
||||||
|
|
||||||
|
if (displayColumns.length === 0) return [];
|
||||||
|
|
||||||
|
let columns = displayColumns;
|
||||||
|
|
||||||
|
// columnVisibility가 있으면 가시성 적용
|
||||||
|
if (leftColumnVisibility.length > 0) {
|
||||||
|
const visibilityMap = new Map(leftColumnVisibility.map(cv => [cv.columnName, cv.visible]));
|
||||||
|
columns = columns.filter((col: any) => {
|
||||||
|
const colName = typeof col === 'string' ? col : (col.name || col.columnName);
|
||||||
|
return visibilityMap.get(colName) !== false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 컬럼 순서 적용
|
||||||
|
if (leftColumnOrder.length > 0) {
|
||||||
|
const orderMap = new Map(leftColumnOrder.map((name, index) => [name, index]));
|
||||||
|
columns = [...columns].sort((a, b) => {
|
||||||
|
const aName = typeof a === 'string' ? a : (a.name || a.columnName);
|
||||||
|
const bName = typeof b === 'string' ? b : (b.name || b.columnName);
|
||||||
|
const aIndex = orderMap.get(aName) ?? 999;
|
||||||
|
const bIndex = orderMap.get(bName) ?? 999;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}, [componentConfig.leftPanel?.columns, leftColumnVisibility, leftColumnOrder]);
|
||||||
|
|
||||||
|
// 🔄 데이터 그룹화
|
||||||
|
const groupedLeftData = useMemo(() => {
|
||||||
|
if (!leftGrouping || leftGrouping.length === 0 || leftData.length === 0) return [];
|
||||||
|
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
|
leftData.forEach((item) => {
|
||||||
|
// 각 그룹 컬럼의 값을 조합하여 그룹 키 생성
|
||||||
|
const groupKey = leftGrouping.map(col => {
|
||||||
|
const value = item[col];
|
||||||
|
// null/undefined 처리
|
||||||
|
return value === null || value === undefined ? "(비어있음)" : String(value);
|
||||||
|
}).join(" > ");
|
||||||
|
|
||||||
|
if (!grouped.has(groupKey)) {
|
||||||
|
grouped.set(groupKey, []);
|
||||||
|
}
|
||||||
|
grouped.get(groupKey)!.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(grouped.entries()).map(([key, items]) => ({
|
||||||
|
groupKey: key,
|
||||||
|
items,
|
||||||
|
count: items.length,
|
||||||
|
}));
|
||||||
|
}, [leftData, leftGrouping]);
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
const loadLeftData = useCallback(async () => {
|
const loadLeftData = useCallback(async () => {
|
||||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
|
@ -154,12 +248,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
setIsLoadingLeft(true);
|
setIsLoadingLeft(true);
|
||||||
try {
|
try {
|
||||||
const result = await dataApi.getTableData(leftTableName, {
|
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
||||||
|
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
||||||
|
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 100,
|
size: 100,
|
||||||
// searchTerm 제거 - 클라이언트 사이드에서 필터링
|
search: filters, // 필터 조건 전달
|
||||||
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||||
if (leftColumn && result.data.length > 0) {
|
if (leftColumn && result.data.length > 0) {
|
||||||
|
|
@ -183,7 +283,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingLeft(false);
|
setIsLoadingLeft(false);
|
||||||
}
|
}
|
||||||
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]);
|
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy, searchValues]);
|
||||||
|
|
||||||
// 우측 데이터 로드
|
// 우측 데이터 로드
|
||||||
const loadRightData = useCallback(
|
const loadRightData = useCallback(
|
||||||
|
|
@ -270,6 +370,128 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
[rightTableColumns],
|
[rightTableColumns],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔧 컬럼의 고유값 가져오기 함수
|
||||||
|
const getLeftColumnUniqueValues = useCallback(async (columnName: string) => {
|
||||||
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
if (!leftTableName || leftData.length === 0) return [];
|
||||||
|
|
||||||
|
// 현재 로드된 데이터에서 고유값 추출
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
leftData.forEach((item) => {
|
||||||
|
const value = item[columnName];
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
// _name 필드 우선 사용 (category/entity type)
|
||||||
|
const displayValue = item[`${columnName}_name`] || value;
|
||||||
|
uniqueValues.add(String(displayValue));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(uniqueValues).map(value => ({
|
||||||
|
value: value,
|
||||||
|
label: value,
|
||||||
|
}));
|
||||||
|
}, [componentConfig.leftPanel?.tableName, leftData]);
|
||||||
|
|
||||||
|
// 좌측 테이블 등록 (Context에 등록)
|
||||||
|
useEffect(() => {
|
||||||
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
if (!leftTableName || isDesignMode) return;
|
||||||
|
|
||||||
|
const leftTableId = `split-panel-left-${component.id}`;
|
||||||
|
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
|
||||||
|
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
||||||
|
const displayColumns = configuredColumns.map((col: any) => {
|
||||||
|
if (typeof col === 'string') return col;
|
||||||
|
return col.columnName || col.name || col;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
// 화면에 설정된 컬럼이 없으면 등록하지 않음
|
||||||
|
if (displayColumns.length === 0) return;
|
||||||
|
|
||||||
|
// 테이블명이 있으면 등록
|
||||||
|
registerTable({
|
||||||
|
tableId: leftTableId,
|
||||||
|
label: `${component.title || "분할 패널"} (좌측)`,
|
||||||
|
tableName: leftTableName,
|
||||||
|
columns: displayColumns.map((col: string) => ({
|
||||||
|
columnName: col,
|
||||||
|
columnLabel: leftColumnLabels[col] || col,
|
||||||
|
inputType: "text",
|
||||||
|
visible: true,
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
})),
|
||||||
|
onFilterChange: setLeftFilters,
|
||||||
|
onGroupChange: setLeftGrouping,
|
||||||
|
onColumnVisibilityChange: setLeftColumnVisibility,
|
||||||
|
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
|
||||||
|
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unregisterTable(leftTableId);
|
||||||
|
}, [component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, leftColumnLabels, component.title, isDesignMode, getLeftColumnUniqueValues]);
|
||||||
|
|
||||||
|
// 우측 테이블은 검색 컴포넌트 등록 제외 (좌측 마스터 테이블만 검색 가능)
|
||||||
|
// useEffect(() => {
|
||||||
|
// const rightTableName = componentConfig.rightPanel?.tableName;
|
||||||
|
// if (!rightTableName || isDesignMode) return;
|
||||||
|
//
|
||||||
|
// const rightTableId = `split-panel-right-${component.id}`;
|
||||||
|
// // 🔧 화면에 표시되는 컬럼만 등록 (displayColumns 또는 columns)
|
||||||
|
// const displayColumns = componentConfig.rightPanel?.columns || [];
|
||||||
|
// const rightColumns = displayColumns.map((col: any) => col.columnName || col.name || col).filter(Boolean);
|
||||||
|
//
|
||||||
|
// if (rightColumns.length > 0) {
|
||||||
|
// registerTable({
|
||||||
|
// tableId: rightTableId,
|
||||||
|
// label: `${component.title || "분할 패널"} (우측)`,
|
||||||
|
// tableName: rightTableName,
|
||||||
|
// columns: rightColumns.map((col: string) => ({
|
||||||
|
// columnName: col,
|
||||||
|
// columnLabel: rightColumnLabels[col] || col,
|
||||||
|
// inputType: "text",
|
||||||
|
// visible: true,
|
||||||
|
// width: 150,
|
||||||
|
// sortable: true,
|
||||||
|
// filterable: true,
|
||||||
|
// })),
|
||||||
|
// onFilterChange: setRightFilters,
|
||||||
|
// onGroupChange: setRightGrouping,
|
||||||
|
// onColumnVisibilityChange: setRightColumnVisibility,
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return () => unregisterTable(rightTableId);
|
||||||
|
// }
|
||||||
|
// }, [component.id, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, rightColumnLabels, component.title, isDesignMode]);
|
||||||
|
|
||||||
|
// 좌측 테이블 컬럼 라벨 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLeftColumnLabels = async () => {
|
||||||
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
if (!leftTableName || isDesignMode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
columnsResponse.forEach((col: any) => {
|
||||||
|
const columnName = col.columnName || col.column_name;
|
||||||
|
const label = col.columnLabel || col.column_label || col.displayName || columnName;
|
||||||
|
if (columnName) {
|
||||||
|
labels[columnName] = label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLeftColumnLabels(labels);
|
||||||
|
console.log("✅ 좌측 컬럼 라벨 로드:", labels);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLeftColumnLabels();
|
||||||
|
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
|
||||||
|
|
||||||
// 우측 테이블 컬럼 정보 로드
|
// 우측 테이블 컬럼 정보 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadRightTableColumns = async () => {
|
const loadRightTableColumns = async () => {
|
||||||
|
|
@ -279,6 +501,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
try {
|
try {
|
||||||
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
|
||||||
setRightTableColumns(columnsResponse || []);
|
setRightTableColumns(columnsResponse || []);
|
||||||
|
|
||||||
|
// 우측 컬럼 라벨도 함께 로드
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
columnsResponse.forEach((col: any) => {
|
||||||
|
const columnName = col.columnName || col.column_name;
|
||||||
|
const label = col.columnLabel || col.column_label || col.displayName || columnName;
|
||||||
|
if (columnName) {
|
||||||
|
labels[columnName] = label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setRightColumnLabels(labels);
|
||||||
|
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -673,6 +907,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
||||||
|
|
||||||
|
// 🔧 좌측 컬럼 가시성 설정 저장 및 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
if (leftTableName && currentUserId) {
|
||||||
|
// localStorage에서 저장된 설정 불러오기
|
||||||
|
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
|
||||||
|
const savedSettings = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (savedSettings) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
|
||||||
|
setLeftColumnVisibility(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장된 컬럼 설정 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [componentConfig.leftPanel?.tableName, currentUserId]);
|
||||||
|
|
||||||
|
// 🔧 컬럼 가시성 변경 시 localStorage에 저장 및 순서 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
|
||||||
|
if (leftColumnVisibility.length > 0 && leftTableName && currentUserId) {
|
||||||
|
// 순서 업데이트
|
||||||
|
const newOrder = leftColumnVisibility
|
||||||
|
.map((cv) => cv.columnName)
|
||||||
|
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
||||||
|
|
||||||
|
setLeftColumnOrder(newOrder);
|
||||||
|
|
||||||
|
// localStorage에 저장
|
||||||
|
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(leftColumnVisibility));
|
||||||
|
}
|
||||||
|
}, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]);
|
||||||
|
|
||||||
// 초기 데이터 로드
|
// 초기 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
|
|
@ -681,6 +952,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isDesignMode, componentConfig.autoLoad]);
|
}, [isDesignMode, componentConfig.autoLoad]);
|
||||||
|
|
||||||
|
// 🔄 필터 변경 시 데이터 다시 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
|
loadLeftData();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [leftFilters]);
|
||||||
|
|
||||||
// 리사이저 드래그 핸들러
|
// 리사이저 드래그 핸들러
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (!resizable) return;
|
if (!resizable) return;
|
||||||
|
|
@ -784,7 +1063,184 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 좌측 데이터 목록 */}
|
{/* 좌측 데이터 목록/테이블 */}
|
||||||
|
{componentConfig.leftPanel?.displayMode === "table" ? (
|
||||||
|
// 테이블 모드
|
||||||
|
<div className="w-full">
|
||||||
|
{isDesignMode ? (
|
||||||
|
// 디자인 모드: 샘플 테이블
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">컬럼 1</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">컬럼 2</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">컬럼 3</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
<tr className="hover:bg-gray-50 cursor-pointer">
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-sm">데이터 1-1</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-sm">데이터 1-2</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-sm">데이터 1-3</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="hover:bg-gray-50 cursor-pointer">
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-sm">데이터 2-1</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-sm">데이터 2-2</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-sm">데이터 2-3</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : isLoadingLeft ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||||
|
<span className="text-muted-foreground ml-2 text-sm">데이터를 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
// 🔧 로컬 검색 필터 적용
|
||||||
|
const filteredData = leftSearchQuery
|
||||||
|
? leftData.filter((item) => {
|
||||||
|
const searchLower = leftSearchQuery.toLowerCase();
|
||||||
|
return Object.entries(item).some(([key, value]) => {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
return String(value).toLowerCase().includes(searchLower);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: leftData;
|
||||||
|
|
||||||
|
// 🔧 가시성 처리된 컬럼 사용
|
||||||
|
const columnsToShow = visibleLeftColumns.length > 0
|
||||||
|
? visibleLeftColumns.map((col: any) => {
|
||||||
|
const colName = typeof col === 'string' ? col : (col.name || col.columnName);
|
||||||
|
return {
|
||||||
|
name: colName,
|
||||||
|
label: leftColumnLabels[colName] || (typeof col === 'object' ? col.label : null) || colName,
|
||||||
|
width: typeof col === 'object' ? col.width : 150,
|
||||||
|
align: (typeof col === 'object' ? col.align : "left") as "left" | "center" | "right"
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: Object.keys(filteredData[0] || {}).filter(key => key !== 'children' && key !== 'level').slice(0, 5).map(key => ({
|
||||||
|
name: key,
|
||||||
|
label: leftColumnLabels[key] || key,
|
||||||
|
width: 150,
|
||||||
|
align: "left" as const
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 🔧 그룹화된 데이터 렌더링
|
||||||
|
if (groupedLeftData.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
{groupedLeftData.map((group, groupIdx) => (
|
||||||
|
<div key={groupIdx} className="mb-4">
|
||||||
|
<div className="bg-gray-100 px-3 py-2 font-semibold text-sm">
|
||||||
|
{group.groupKey} ({group.count}개)
|
||||||
|
</div>
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
{columnsToShow.map((col, idx) => (
|
||||||
|
<th
|
||||||
|
key={idx}
|
||||||
|
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{group.items.map((item, idx) => {
|
||||||
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
|
||||||
|
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||||
|
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={itemId}
|
||||||
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
|
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||||
|
isSelected ? "bg-primary/10" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columnsToShow.map((col, colIdx) => (
|
||||||
|
<td
|
||||||
|
key={colIdx}
|
||||||
|
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
|
||||||
|
style={{ textAlign: col.align || "left" }}
|
||||||
|
>
|
||||||
|
{item[col.name] !== null && item[col.name] !== undefined
|
||||||
|
? String(item[col.name])
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 일반 테이블 렌더링 (그룹화 없음)
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="sticky top-0 bg-gray-50 z-10">
|
||||||
|
<tr>
|
||||||
|
{columnsToShow.map((col, idx) => (
|
||||||
|
<th
|
||||||
|
key={idx}
|
||||||
|
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{filteredData.map((item, idx) => {
|
||||||
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
|
||||||
|
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||||
|
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={itemId}
|
||||||
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
|
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||||
|
isSelected ? "bg-primary/10" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columnsToShow.map((col, colIdx) => (
|
||||||
|
<td
|
||||||
|
key={colIdx}
|
||||||
|
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
|
||||||
|
style={{ textAlign: col.align || "left" }}
|
||||||
|
>
|
||||||
|
{item[col.name] !== null && item[col.name] !== undefined
|
||||||
|
? String(item[col.name])
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 목록 모드 (기존)
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{isDesignMode ? (
|
{isDesignMode ? (
|
||||||
// 디자인 모드: 샘플 데이터
|
// 디자인 모드: 샘플 데이터
|
||||||
|
|
@ -1002,6 +1458,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
})()
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1081,6 +1538,107 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
})
|
})
|
||||||
: rightData;
|
: rightData;
|
||||||
|
|
||||||
|
// 테이블 모드 체크
|
||||||
|
const isTableMode = componentConfig.rightPanel?.displayMode === "table";
|
||||||
|
|
||||||
|
if (isTableMode) {
|
||||||
|
// 테이블 모드 렌더링
|
||||||
|
const displayColumns = componentConfig.rightPanel?.columns || [];
|
||||||
|
const columnsToShow = displayColumns.length > 0
|
||||||
|
? displayColumns.map(col => ({
|
||||||
|
...col,
|
||||||
|
label: rightColumnLabels[col.name] || col.label || col.name
|
||||||
|
}))
|
||||||
|
: Object.keys(filteredData[0] || {}).filter(key => !key.toLowerCase().includes("password")).slice(0, 5).map(key => ({
|
||||||
|
name: key,
|
||||||
|
label: rightColumnLabels[key] || key,
|
||||||
|
width: 150,
|
||||||
|
align: "left" as const
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-2 text-xs text-muted-foreground">
|
||||||
|
{filteredData.length}개의 관련 데이터
|
||||||
|
{rightSearchQuery && filteredData.length !== rightData.length && (
|
||||||
|
<span className="ml-1 text-primary">(전체 {rightData.length}개 중)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="sticky top-0 bg-gray-50 z-10">
|
||||||
|
<tr>
|
||||||
|
{columnsToShow.map((col, idx) => (
|
||||||
|
<th
|
||||||
|
key={idx}
|
||||||
|
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{!isDesignMode && (
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">작업</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{filteredData.map((item, idx) => {
|
||||||
|
const itemId = item.id || item.ID || idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={itemId}
|
||||||
|
className="hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
{columnsToShow.map((col, colIdx) => (
|
||||||
|
<td
|
||||||
|
key={colIdx}
|
||||||
|
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
|
||||||
|
style={{ textAlign: col.align || "left" }}
|
||||||
|
>
|
||||||
|
{item[col.name] !== null && item[col.name] !== undefined
|
||||||
|
? String(item[col.name])
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{!isDesignMode && (
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditClick("right", item);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 hover:bg-gray-200 transition-colors"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick("right", item);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 hover:bg-red-100 transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목록 모드 (기존)
|
||||||
return filteredData.length > 0 ? (
|
return filteredData.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="mb-2 text-xs text-muted-foreground">
|
<div className="mb-2 text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.leftPanel?.displayMode || "list"}
|
||||||
|
onValueChange={(value: "list" | "table") => updateLeftPanel({ displayMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="표시 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="list">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">목록 (LIST)</span>
|
||||||
|
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">테이블 (TABLE)</span>
|
||||||
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>검색 기능</Label>
|
<Label>검색 기능</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -670,6 +696,185 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 좌측 패널 표시 컬럼 설정 */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">표시할 컬럼 선택</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const currentColumns = config.leftPanel?.columns || [];
|
||||||
|
const newColumns = [
|
||||||
|
...currentColumns,
|
||||||
|
{ name: "", label: "", width: 100 },
|
||||||
|
];
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!config.leftPanel?.tableName && !screenTableName}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
좌측 패널에 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(config.leftPanel?.columns || []).length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
||||||
|
<p className="mt-1 text-[10px] text-gray-400">
|
||||||
|
컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(config.leftPanel?.columns || []).map((col, index) => {
|
||||||
|
const isTableMode = config.leftPanel?.displayMode === "table";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="space-y-2 rounded-md border bg-white p-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{col.name || "컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{leftTableColumns.map((column) => (
|
||||||
|
<CommandItem
|
||||||
|
key={column.columnName}
|
||||||
|
value={column.columnName}
|
||||||
|
onSelect={(value) => {
|
||||||
|
const newColumns = [...(config.leftPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
name: value,
|
||||||
|
label: column.columnLabel || value,
|
||||||
|
};
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
col.name === column.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
<span className="ml-2 text-[10px] text-gray-500">
|
||||||
|
({column.columnName})
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (config.leftPanel?.columns || []).filter(
|
||||||
|
(_, i) => i !== index
|
||||||
|
);
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 모드 전용 옵션 */}
|
||||||
|
{isTableMode && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 pt-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="50"
|
||||||
|
value={col.width || 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.leftPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
width: parseInt(e.target.value) || 100,
|
||||||
|
};
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={col.align || "left"}
|
||||||
|
onValueChange={(value: "left" | "center" | "right") => {
|
||||||
|
const newColumns = [...(config.leftPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
align: value,
|
||||||
|
};
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">왼쪽</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="right">오른쪽</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex h-7 items-center gap-1 text-[10px] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.sortable ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.leftPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
sortable: e.target.checked,
|
||||||
|
};
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
정렬가능
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 좌측 패널 추가 모달 컬럼 설정 */}
|
{/* 좌측 패널 추가 모달 컬럼 설정 */}
|
||||||
{config.leftPanel?.showAdd && (
|
{config.leftPanel?.showAdd && (
|
||||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||||
|
|
@ -895,6 +1100,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.displayMode || "list"}
|
||||||
|
onValueChange={(value: "list" | "table") => updateRightPanel({ displayMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white">
|
||||||
|
<SelectValue placeholder="표시 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="list">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">목록 (LIST)</span>
|
||||||
|
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">테이블 (TABLE)</span>
|
||||||
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
||||||
{relationshipType !== "detail" && (
|
{relationshipType !== "detail" && (
|
||||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
|
|
@ -1057,11 +1288,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(config.rightPanel?.columns || []).map((col, index) => (
|
(config.rightPanel?.columns || []).map((col, index) => {
|
||||||
|
const isTableMode = config.rightPanel?.displayMode === "table";
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center gap-2 rounded-md border bg-white p-2"
|
className="space-y-2 rounded-md border bg-white p-2"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -1125,7 +1360,73 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
|
{/* 테이블 모드 전용 옵션 */}
|
||||||
|
{isTableMode && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 pt-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="50"
|
||||||
|
value={col.width || 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
width: parseInt(e.target.value) || 100,
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={col.align || "left"}
|
||||||
|
onValueChange={(value: "left" | "center" | "right") => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
align: value,
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">왼쪽</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="right">오른쪽</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex h-7 items-center gap-1 text-[10px] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.sortable ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
sortable: e.target.checked,
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
정렬가능
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface SplitPanelLayoutConfig {
|
||||||
title: string;
|
title: string;
|
||||||
tableName?: string; // 데이터베이스 테이블명
|
tableName?: string; // 데이터베이스 테이블명
|
||||||
dataSource?: string; // API 엔드포인트
|
dataSource?: string; // API 엔드포인트
|
||||||
|
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showEdit?: boolean; // 수정 버튼
|
showEdit?: boolean; // 수정 버튼
|
||||||
|
|
@ -16,6 +17,8 @@ export interface SplitPanelLayoutConfig {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||||
|
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||||
}>;
|
}>;
|
||||||
// 추가 모달에서 입력받을 컬럼 설정
|
// 추가 모달에서 입력받을 컬럼 설정
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
@ -38,6 +41,17 @@ export interface SplitPanelLayoutConfig {
|
||||||
// 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code)
|
// 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code)
|
||||||
sourceColumn: string;
|
sourceColumn: string;
|
||||||
};
|
};
|
||||||
|
// 테이블 모드 설정
|
||||||
|
tableConfig?: {
|
||||||
|
showCheckbox?: boolean; // 체크박스 표시 여부
|
||||||
|
showRowNumber?: boolean; // 행 번호 표시 여부
|
||||||
|
rowHeight?: number; // 행 높이
|
||||||
|
headerHeight?: number; // 헤더 높이
|
||||||
|
striped?: boolean; // 줄무늬 배경
|
||||||
|
bordered?: boolean; // 테두리 표시
|
||||||
|
hoverable?: boolean; // 호버 효과
|
||||||
|
stickyHeader?: boolean; // 헤더 고정
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 우측 패널 설정
|
// 우측 패널 설정
|
||||||
|
|
@ -45,6 +59,7 @@ export interface SplitPanelLayoutConfig {
|
||||||
title: string;
|
title: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
dataSource?: string;
|
dataSource?: string;
|
||||||
|
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showEdit?: boolean; // 수정 버튼
|
showEdit?: boolean; // 수정 버튼
|
||||||
|
|
@ -53,6 +68,8 @@ export interface SplitPanelLayoutConfig {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||||
|
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||||
}>;
|
}>;
|
||||||
// 추가 모달에서 입력받을 컬럼 설정
|
// 추가 모달에서 입력받을 컬럼 설정
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
@ -76,6 +93,18 @@ export interface SplitPanelLayoutConfig {
|
||||||
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
|
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
|
||||||
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
|
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 테이블 모드 설정
|
||||||
|
tableConfig?: {
|
||||||
|
showCheckbox?: boolean; // 체크박스 표시 여부
|
||||||
|
showRowNumber?: boolean; // 행 번호 표시 여부
|
||||||
|
rowHeight?: number; // 행 높이
|
||||||
|
headerHeight?: number; // 헤더 높이
|
||||||
|
striped?: boolean; // 줄무늬 배경
|
||||||
|
bordered?: boolean; // 테두리 표시
|
||||||
|
hoverable?: boolean; // 호버 효과
|
||||||
|
stickyHeader?: boolean; // 헤더 고정
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
|
||||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||||
import { CardModeRenderer } from "./CardModeRenderer";
|
import { CardModeRenderer } from "./CardModeRenderer";
|
||||||
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 인터페이스
|
// 인터페이스
|
||||||
|
|
@ -243,6 +246,72 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
|
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
|
||||||
|
const { userId: authUserId } = useAuth();
|
||||||
|
const currentUserId = userId || authUserId;
|
||||||
|
|
||||||
|
// TableOptions Context
|
||||||
|
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||||
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [grouping, setGrouping] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
||||||
|
// filters가 변경되면 searchValues 업데이트 (실시간 검색)
|
||||||
|
useEffect(() => {
|
||||||
|
const newSearchValues: Record<string, any> = {};
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
if (filter.value) {
|
||||||
|
newSearchValues[filter.columnName] = filter.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 [TableListComponent] filters → searchValues:", {
|
||||||
|
filters: filters.length,
|
||||||
|
searchValues: newSearchValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchValues(newSearchValues);
|
||||||
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
// grouping이 변경되면 groupByColumns 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
setGroupByColumns(grouping);
|
||||||
|
}, [grouping]);
|
||||||
|
|
||||||
|
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableConfig.selectedTable && currentUserId) {
|
||||||
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
||||||
|
const savedSettings = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (savedSettings) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
|
||||||
|
setColumnVisibility(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장된 컬럼 설정 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tableConfig.selectedTable, currentUserId]);
|
||||||
|
|
||||||
|
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
|
||||||
|
useEffect(() => {
|
||||||
|
if (columnVisibility.length > 0) {
|
||||||
|
const newOrder = columnVisibility
|
||||||
|
.map((cv) => cv.columnName)
|
||||||
|
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
||||||
|
setColumnOrder(newOrder);
|
||||||
|
|
||||||
|
// localStorage에 저장 (사용자별)
|
||||||
|
if (tableConfig.selectedTable && currentUserId) {
|
||||||
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [columnVisibility, tableConfig.selectedTable, currentUserId]);
|
||||||
|
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -288,6 +357,156 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 테이블 등록 (Context에 등록)
|
||||||
|
const tableId = `table-list-${component.id}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음)
|
||||||
|
const columnsToRegister = (tableConfig.columns || [])
|
||||||
|
.filter((col) => col.visible !== false && col.columnName !== "__checkbox__");
|
||||||
|
|
||||||
|
if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼의 고유 값 조회 함수
|
||||||
|
const getColumnUniqueValues = async (columnName: string) => {
|
||||||
|
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
|
||||||
|
columnName,
|
||||||
|
dataLength: data.length,
|
||||||
|
columnMeta: columnMeta[columnName],
|
||||||
|
sampleData: data[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = columnMeta[columnName];
|
||||||
|
const inputType = meta?.inputType || "text";
|
||||||
|
|
||||||
|
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
|
||||||
|
if (inputType === "category") {
|
||||||
|
try {
|
||||||
|
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
columnName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 클라이언트 사용 (쿠키 인증 자동 처리)
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const categoryOptions = response.data.data.map((item: any) => ({
|
||||||
|
value: item.valueCode, // 카멜케이스
|
||||||
|
label: item.valueLabel, // 카멜케이스
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
|
||||||
|
columnName,
|
||||||
|
count: categoryOptions.length,
|
||||||
|
options: categoryOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return categoryOptions;
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
|
||||||
|
error: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status,
|
||||||
|
columnName,
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
});
|
||||||
|
// 에러 시 현재 데이터 기반으로 fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
||||||
|
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
||||||
|
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
||||||
|
|
||||||
|
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
|
||||||
|
columnName,
|
||||||
|
inputType,
|
||||||
|
isLabelType,
|
||||||
|
labelField,
|
||||||
|
hasLabelField: data[0] && labelField in data[0],
|
||||||
|
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 로드된 데이터에서 고유 값 추출
|
||||||
|
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
||||||
|
|
||||||
|
data.forEach((row) => {
|
||||||
|
const value = row[columnName];
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
||||||
|
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
||||||
|
uniqueValuesMap.set(String(value), label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
||||||
|
const result = Array.from(uniqueValuesMap.entries())
|
||||||
|
.map(([value, label]) => ({
|
||||||
|
value: value,
|
||||||
|
label: label,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
|
||||||
|
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
|
||||||
|
columnName,
|
||||||
|
inputType,
|
||||||
|
isLabelType,
|
||||||
|
labelField,
|
||||||
|
uniqueCount: result.length,
|
||||||
|
values: result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registration = {
|
||||||
|
tableId,
|
||||||
|
label: tableLabel || tableConfig.selectedTable,
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
dataCount: totalItems || data.length, // 초기 데이터 건수 포함
|
||||||
|
columns: columnsToRegister.map((col) => ({
|
||||||
|
columnName: col.columnName || col.field,
|
||||||
|
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
|
||||||
|
inputType: columnMeta[col.columnName]?.inputType || "text",
|
||||||
|
visible: col.visible !== false,
|
||||||
|
width: columnWidths[col.columnName] || col.width || 150,
|
||||||
|
sortable: col.sortable !== false,
|
||||||
|
filterable: col.searchable !== false,
|
||||||
|
})),
|
||||||
|
onFilterChange: setFilters,
|
||||||
|
onGroupChange: setGrouping,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
||||||
|
};
|
||||||
|
|
||||||
|
registerTable(registration);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterTable(tableId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
tableId,
|
||||||
|
tableConfig.selectedTable,
|
||||||
|
tableConfig.columns,
|
||||||
|
columnLabels,
|
||||||
|
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
|
||||||
|
columnWidths,
|
||||||
|
tableLabel,
|
||||||
|
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
||||||
|
totalItems, // 전체 항목 수가 변경되면 재등록
|
||||||
|
registerTable,
|
||||||
|
unregisterTable,
|
||||||
|
]);
|
||||||
|
|
||||||
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableConfig.selectedTable || !userId) return;
|
if (!tableConfig.selectedTable || !userId) return;
|
||||||
|
|
@ -481,43 +700,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
.filter(([_, meta]) => meta.inputType === "category")
|
.filter(([_, meta]) => meta.inputType === "category")
|
||||||
.map(([columnName, _]) => columnName);
|
.map(([columnName, _]) => columnName);
|
||||||
|
|
||||||
console.log("🔍 [TableList] 카테고리 컬럼 추출:", {
|
|
||||||
columnMeta,
|
|
||||||
categoryColumns: cols,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [columnMeta]);
|
}, [columnMeta]);
|
||||||
|
|
||||||
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
console.log("🔄 [TableList] loadCategoryMappings 트리거:", {
|
|
||||||
hasTable: !!tableConfig.selectedTable,
|
|
||||||
table: tableConfig.selectedTable,
|
|
||||||
categoryColumnsLength: categoryColumns.length,
|
|
||||||
categoryColumns,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tableConfig.selectedTable) {
|
if (!tableConfig.selectedTable) {
|
||||||
console.log("⏭️ [TableList] 테이블 선택 안됨, 카테고리 매핑 로드 스킵");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryColumns.length === 0) {
|
if (categoryColumns.length === 0) {
|
||||||
console.log("⏭️ [TableList] 카테고리 컬럼 없음, 카테고리 매핑 로드 스킵");
|
|
||||||
setCategoryMappings({});
|
setCategoryMappings({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🚀 [TableList] 카테고리 매핑 로드 시작:", {
|
|
||||||
table: tableConfig.selectedTable,
|
|
||||||
categoryColumns,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
|
|
||||||
|
|
@ -952,8 +1149,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
||||||
|
|
||||||
|
// columnVisibility가 있으면 가시성 적용
|
||||||
|
if (columnVisibility.length > 0) {
|
||||||
|
cols = cols.filter((col) => {
|
||||||
|
const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName);
|
||||||
|
return visibilityConfig ? visibilityConfig.visible : true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 체크박스 컬럼 (나중에 위치 결정)
|
||||||
|
let checkboxCol: ColumnConfig | null = null;
|
||||||
if (tableConfig.checkbox?.enabled) {
|
if (tableConfig.checkbox?.enabled) {
|
||||||
const checkboxCol: ColumnConfig = {
|
checkboxCol = {
|
||||||
columnName: "__checkbox__",
|
columnName: "__checkbox__",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|
@ -963,15 +1170,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
align: "center",
|
align: "center",
|
||||||
order: -1,
|
order: -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tableConfig.checkbox.position === "right") {
|
|
||||||
cols = [...cols, checkboxCol];
|
|
||||||
} else {
|
|
||||||
cols = [checkboxCol, ...cols];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// columnOrder 상태가 있으면 그 순서대로 정렬
|
// columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외)
|
||||||
if (columnOrder.length > 0) {
|
if (columnOrder.length > 0) {
|
||||||
const orderedCols = columnOrder
|
const orderedCols = columnOrder
|
||||||
.map((colName) => cols.find((c) => c.columnName === colName))
|
.map((colName) => cols.find((c) => c.columnName === colName))
|
||||||
|
|
@ -980,17 +1181,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// columnOrder에 없는 새로운 컬럼들 추가
|
// columnOrder에 없는 새로운 컬럼들 추가
|
||||||
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
|
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
|
||||||
|
|
||||||
console.log("🔄 columnOrder 기반 정렬:", {
|
cols = [...orderedCols, ...remainingCols];
|
||||||
columnOrder,
|
} else {
|
||||||
orderedColsCount: orderedCols.length,
|
cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
remainingColsCount: remainingCols.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...orderedCols, ...remainingCols];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
// 체크박스를 맨 앞 또는 맨 뒤에 추가
|
||||||
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
|
if (checkboxCol) {
|
||||||
|
if (tableConfig.checkbox.position === "right") {
|
||||||
|
cols = [...cols, checkboxCol];
|
||||||
|
} else {
|
||||||
|
cols = [checkboxCol, ...cols];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]);
|
||||||
|
|
||||||
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
|
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
|
||||||
const lastColumnOrderRef = useRef<string>("");
|
const lastColumnOrderRef = useRef<string>("");
|
||||||
|
|
@ -1451,9 +1657,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
data.forEach((item) => {
|
data.forEach((item) => {
|
||||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||||
const keyParts = groupByColumns.map((col) => {
|
const keyParts = groupByColumns.map((col) => {
|
||||||
const value = item[col];
|
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
||||||
|
const inputType = columnMeta?.[col]?.inputType;
|
||||||
|
let displayValue = item[col];
|
||||||
|
|
||||||
|
if (inputType === 'category' || inputType === 'entity' || inputType === 'code') {
|
||||||
|
// _name 필드가 있으면 사용 (예: division_name, writer_name)
|
||||||
|
const nameField = `${col}_name`;
|
||||||
|
if (item[nameField] !== undefined && item[nameField] !== null) {
|
||||||
|
displayValue = item[nameField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const label = columnLabels[col] || col;
|
const label = columnLabels[col] || col;
|
||||||
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
|
return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`;
|
||||||
});
|
});
|
||||||
const groupKey = keyParts.join(" > ");
|
const groupKey = keyParts.join(" > ");
|
||||||
|
|
||||||
|
|
@ -1476,7 +1693,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [data, groupByColumns, columnLabels]);
|
}, [data, groupByColumns, columnLabels, columnMeta]);
|
||||||
|
|
||||||
// 저장된 그룹 설정 불러오기
|
// 저장된 그룹 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1659,124 +1876,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (tableConfig.stickyHeader && !isDesignMode) {
|
if (tableConfig.stickyHeader && !isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div {...domProps}>
|
<div {...domProps}>
|
||||||
{tableConfig.filter?.enabled && (
|
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
||||||
<div className="border-border border-b px-4 py-2 sm:px-6 sm:py-2">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<AdvancedSearchFilters
|
|
||||||
filters={activeFilters}
|
|
||||||
searchValues={searchValues}
|
|
||||||
onSearchValueChange={handleSearchValueChange}
|
|
||||||
onSearch={handleAdvancedSearch}
|
|
||||||
onClearFilters={handleClearAdvancedFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* 전체 개수 */}
|
|
||||||
<div className="hidden sm:block text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
전체 <span className="font-semibold text-foreground">{totalItems.toLocaleString()}</span>개
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsTableOptionsOpen(true)}
|
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
|
||||||
>
|
|
||||||
<TableIcon className="mr-2 h-4 w-4" />
|
|
||||||
테이블 옵션
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
|
||||||
>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
필터 설정
|
|
||||||
</Button>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
|
||||||
>
|
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
|
||||||
그룹 설정
|
|
||||||
{groupByColumns.length > 0 && (
|
|
||||||
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
|
||||||
{groupByColumns.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">그룹 설정</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
데이터를 그룹화할 컬럼을 선택하세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컬럼 목록 */}
|
|
||||||
<div className="max-h-[300px] space-y-2 overflow-y-auto">
|
|
||||||
{visibleColumns
|
|
||||||
.filter((col) => col.columnName !== "__checkbox__")
|
|
||||||
.map((col) => (
|
|
||||||
<div
|
|
||||||
key={col.columnName}
|
|
||||||
className="flex items-center gap-3 rounded p-2 hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`group-dropdown-${col.columnName}`}
|
|
||||||
checked={groupByColumns.includes(col.columnName)}
|
|
||||||
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`group-dropdown-${col.columnName}`}
|
|
||||||
className="flex-1 cursor-pointer text-xs font-normal"
|
|
||||||
>
|
|
||||||
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 선택된 그룹 안내 */}
|
|
||||||
{groupByColumns.length > 0 && (
|
|
||||||
<div className="rounded bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
||||||
<span className="font-semibold text-foreground">
|
|
||||||
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 초기화 버튼 */}
|
|
||||||
{groupByColumns.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setGroupByColumns([]);
|
|
||||||
if (groupSettingKey) {
|
|
||||||
localStorage.removeItem(groupSettingKey);
|
|
||||||
}
|
|
||||||
toast.success("그룹 설정이 초기화되었습니다");
|
|
||||||
}}
|
|
||||||
className="w-full text-xs"
|
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 그룹 표시 배지 */}
|
{/* 그룹 표시 배지 */}
|
||||||
{groupByColumns.length > 0 && (
|
{groupByColumns.length > 0 && (
|
||||||
|
|
@ -1839,125 +1939,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div {...domProps}>
|
<div {...domProps}>
|
||||||
{/* 필터 */}
|
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
||||||
{tableConfig.filter?.enabled && (
|
|
||||||
<div className="border-border flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<AdvancedSearchFilters
|
|
||||||
filters={activeFilters}
|
|
||||||
searchValues={searchValues}
|
|
||||||
onSearchValueChange={handleSearchValueChange}
|
|
||||||
onSearch={handleAdvancedSearch}
|
|
||||||
onClearFilters={handleClearAdvancedFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* 전체 개수 */}
|
|
||||||
<div className="hidden sm:block text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
전체 <span className="font-semibold text-foreground">{totalItems.toLocaleString()}</span>개
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsTableOptionsOpen(true)}
|
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
|
||||||
>
|
|
||||||
<TableIcon className="mr-2 h-4 w-4" />
|
|
||||||
테이블 옵션
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsFilterSettingOpen(true)}
|
|
||||||
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
|
|
||||||
>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
필터 설정
|
|
||||||
</Button>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
|
||||||
>
|
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
|
||||||
그룹 설정
|
|
||||||
{groupByColumns.length > 0 && (
|
|
||||||
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
|
||||||
{groupByColumns.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">그룹 설정</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
데이터를 그룹화할 컬럼을 선택하세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컬럼 목록 */}
|
|
||||||
<div className="max-h-[300px] space-y-2 overflow-y-auto">
|
|
||||||
{visibleColumns
|
|
||||||
.filter((col) => col.columnName !== "__checkbox__")
|
|
||||||
.map((col) => (
|
|
||||||
<div
|
|
||||||
key={col.columnName}
|
|
||||||
className="flex items-center gap-3 rounded p-2 hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`group-dropdown-2-${col.columnName}`}
|
|
||||||
checked={groupByColumns.includes(col.columnName)}
|
|
||||||
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`group-dropdown-2-${col.columnName}`}
|
|
||||||
className="flex-1 cursor-pointer text-xs font-normal"
|
|
||||||
>
|
|
||||||
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 선택된 그룹 안내 */}
|
|
||||||
{groupByColumns.length > 0 && (
|
|
||||||
<div className="rounded bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
||||||
<span className="font-semibold text-foreground">
|
|
||||||
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 초기화 버튼 */}
|
|
||||||
{groupByColumns.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setGroupByColumns([]);
|
|
||||||
if (groupSettingKey) {
|
|
||||||
localStorage.removeItem(groupSettingKey);
|
|
||||||
}
|
|
||||||
toast.success("그룹 설정이 초기화되었습니다");
|
|
||||||
}}
|
|
||||||
className="w-full text-xs"
|
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 그룹 표시 배지 */}
|
{/* 그룹 표시 배지 */}
|
||||||
{groupByColumns.length > 0 && (
|
{groupByColumns.length > 0 && (
|
||||||
|
|
@ -1992,7 +1974,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
>
|
>
|
||||||
{/* 스크롤 영역 */}
|
{/* 스크롤 영역 */}
|
||||||
<div
|
<div
|
||||||
className="bg-background h-[400px] w-full max-w-full overflow-x-auto overflow-y-scroll sm:h-[500px]"
|
className="bg-background flex-1 w-full max-w-full overflow-x-auto overflow-y-auto"
|
||||||
style={{ position: "relative" }}
|
style={{ position: "relative" }}
|
||||||
>
|
>
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Settings, Filter, Layers, X } from "lucide-react";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
||||||
|
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
||||||
|
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
|
||||||
|
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
||||||
|
import { TableFilter } from "@/types/table-options";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface TableSearchWidgetProps {
|
||||||
|
component: {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
style?: {
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
padding?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
};
|
||||||
|
componentConfig?: {
|
||||||
|
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
|
||||||
|
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
||||||
|
};
|
||||||
|
};
|
||||||
|
screenId?: number; // 화면 ID
|
||||||
|
onHeightChange?: (height: number) => void; // 높이 변화 콜백
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
|
||||||
|
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
|
||||||
|
|
||||||
|
// 높이 관리 context (실제 화면에서만 사용)
|
||||||
|
let setWidgetHeight:
|
||||||
|
| ((screenId: number, componentId: string, height: number, originalHeight: number) => void)
|
||||||
|
| undefined;
|
||||||
|
try {
|
||||||
|
const heightContext = useTableSearchWidgetHeight();
|
||||||
|
setWidgetHeight = heightContext.setWidgetHeight;
|
||||||
|
} catch (e) {
|
||||||
|
// Context가 없으면 (디자이너 모드) 무시
|
||||||
|
setWidgetHeight = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [groupingOpen, setGroupingOpen] = useState(false);
|
||||||
|
|
||||||
|
// 활성화된 필터 목록
|
||||||
|
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
|
// select 타입 필터의 옵션들
|
||||||
|
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
|
||||||
|
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 높이 감지를 위한 ref
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
||||||
|
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||||
|
|
||||||
|
// Map을 배열로 변환
|
||||||
|
const tableList = Array.from(registeredTables.values());
|
||||||
|
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||||
|
|
||||||
|
// 첫 번째 테이블 자동 선택
|
||||||
|
useEffect(() => {
|
||||||
|
const tables = Array.from(registeredTables.values());
|
||||||
|
|
||||||
|
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
|
||||||
|
setSelectedTableId(tables[0].tableId);
|
||||||
|
}
|
||||||
|
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
||||||
|
|
||||||
|
// 현재 테이블의 저장된 필터 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTable?.tableName) {
|
||||||
|
const storageKey = `table_filters_${currentTable.tableName}`;
|
||||||
|
const savedFilters = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (savedFilters) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedFilters) as Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
inputType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
filterType: "text" | "number" | "date" | "select";
|
||||||
|
width?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// enabled된 필터들만 activeFilters로 설정
|
||||||
|
const activeFiltersList: TableFilter[] = parsed
|
||||||
|
.filter((f) => f.enabled)
|
||||||
|
.map((f) => ({
|
||||||
|
columnName: f.columnName,
|
||||||
|
operator: "contains",
|
||||||
|
value: "",
|
||||||
|
filterType: f.filterType,
|
||||||
|
width: f.width || 200, // 저장된 너비 포함
|
||||||
|
}));
|
||||||
|
|
||||||
|
setActiveFilters(activeFiltersList);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장된 필터 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTable?.tableName]);
|
||||||
|
|
||||||
|
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSelectOptions = async () => {
|
||||||
|
const selectFilters = activeFilters.filter((f) => f.filterType === "select");
|
||||||
|
|
||||||
|
if (selectFilters.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
|
||||||
|
|
||||||
|
for (const filter of selectFilters) {
|
||||||
|
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
|
||||||
|
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await currentTable.getColumnUniqueValues(filter.columnName);
|
||||||
|
newOptions[filter.columnName] = options;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectOptions(newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSelectOptions();
|
||||||
|
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
|
||||||
|
|
||||||
|
// 높이 변화 감지 및 알림 (실제 화면에서만)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !screenId || !setWidgetHeight) return;
|
||||||
|
|
||||||
|
// 컴포넌트의 원래 높이 (디자이너에서 설정한 높이)
|
||||||
|
const originalHeight = (component as any).size?.height || 50;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const newHeight = entry.contentRect.height;
|
||||||
|
|
||||||
|
// Context에 높이 저장 (다른 컴포넌트 위치 조정에 사용)
|
||||||
|
setWidgetHeight(screenId, component.id, newHeight, originalHeight);
|
||||||
|
|
||||||
|
// localStorage에 높이 저장 (새로고침 시 복원용)
|
||||||
|
localStorage.setItem(
|
||||||
|
`table_search_widget_height_screen_${screenId}_${component.id}`,
|
||||||
|
JSON.stringify({ height: newHeight, originalHeight }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 콜백이 있으면 호출
|
||||||
|
if (onHeightChange) {
|
||||||
|
onHeightChange(newHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [screenId, component.id, setWidgetHeight, onHeightChange]);
|
||||||
|
|
||||||
|
// 화면 로딩 시 저장된 높이 복원
|
||||||
|
useEffect(() => {
|
||||||
|
if (!screenId || !setWidgetHeight) return;
|
||||||
|
|
||||||
|
const storageKey = `table_search_widget_height_screen_${screenId}_${component.id}`;
|
||||||
|
const savedData = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (savedData) {
|
||||||
|
try {
|
||||||
|
const { height, originalHeight } = JSON.parse(savedData);
|
||||||
|
setWidgetHeight(screenId, component.id, height, originalHeight);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장된 높이 복원 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [screenId, component.id, setWidgetHeight]);
|
||||||
|
|
||||||
|
const hasMultipleTables = tableList.length > 1;
|
||||||
|
|
||||||
|
// 필터 값 변경 핸들러
|
||||||
|
const handleFilterChange = (columnName: string, value: string) => {
|
||||||
|
const newValues = {
|
||||||
|
...filterValues,
|
||||||
|
[columnName]: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFilterValues(newValues);
|
||||||
|
|
||||||
|
// 실시간 검색: 값 변경 시 즉시 필터 적용
|
||||||
|
applyFilters(newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 적용 함수
|
||||||
|
const applyFilters = (values: Record<string, string> = filterValues) => {
|
||||||
|
// 빈 값이 아닌 필터만 적용
|
||||||
|
const filtersWithValues = activeFilters
|
||||||
|
.map((filter) => ({
|
||||||
|
...filter,
|
||||||
|
value: values[filter.columnName] || "",
|
||||||
|
}))
|
||||||
|
.filter((f) => f.value !== "");
|
||||||
|
|
||||||
|
currentTable?.onFilterChange(filtersWithValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setFilterValues({});
|
||||||
|
setSelectedLabels({});
|
||||||
|
currentTable?.onFilterChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 입력 필드 렌더링
|
||||||
|
const renderFilterInput = (filter: TableFilter) => {
|
||||||
|
const column = currentTable?.columns.find((c) => c.columnName === filter.columnName);
|
||||||
|
const value = filterValues[filter.columnName] || "";
|
||||||
|
const width = filter.width || 200; // 기본 너비 200px
|
||||||
|
|
||||||
|
switch (filter.filterType) {
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
||||||
|
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||||
|
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||||
|
placeholder={column?.columnLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
||||||
|
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||||
|
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||||
|
placeholder={column?.columnLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select": {
|
||||||
|
let options = selectOptions[filter.columnName] || [];
|
||||||
|
|
||||||
|
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
|
||||||
|
if (value && !options.find((opt) => opt.value === value)) {
|
||||||
|
const savedLabel = selectedLabels[filter.columnName] || value;
|
||||||
|
options = [{ value, label: savedLabel }, ...options];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 제거 (value 기준)
|
||||||
|
const uniqueOptions = options.reduce(
|
||||||
|
(acc, option) => {
|
||||||
|
if (!acc.find((opt) => opt.value === option.value)) {
|
||||||
|
acc.push(option);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[] as Array<{ value: string; label: string }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
// 선택한 값의 라벨 저장
|
||||||
|
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
|
||||||
|
if (selectedOption) {
|
||||||
|
setSelectedLabels((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[filter.columnName]: selectedOption.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
handleFilterChange(filter.columnName, val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||||
|
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={column?.columnLabel || "선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uniqueOptions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-1.5 text-xs">옵션 없음</div>
|
||||||
|
) : (
|
||||||
|
uniqueOptions.map((option, index) => (
|
||||||
|
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default: // text
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
||||||
|
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||||
|
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||||
|
placeholder={column?.columnLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="bg-card flex w-full flex-wrap items-center gap-2 border-b"
|
||||||
|
style={{
|
||||||
|
padding: component.style?.padding || "0.75rem",
|
||||||
|
backgroundColor: component.style?.backgroundColor,
|
||||||
|
minHeight: "48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 필터 입력 필드들 */}
|
||||||
|
{activeFilters.length > 0 && (
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
|
{activeFilters.map((filter) => (
|
||||||
|
<div key={filter.columnName}>{renderFilterInput(filter)}</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 초기화 버튼 */}
|
||||||
|
<Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm">
|
||||||
|
<X className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터가 없을 때는 빈 공간 */}
|
||||||
|
{activeFilters.length === 0 && <div className="flex-1" />}
|
||||||
|
|
||||||
|
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
{/* 데이터 건수 표시 */}
|
||||||
|
{currentTable?.dataCount !== undefined && (
|
||||||
|
<div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-xs font-medium sm:text-sm">
|
||||||
|
{currentTable.dataCount.toLocaleString()}건
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setColumnVisibilityOpen(true)}
|
||||||
|
disabled={!selectedTableId}
|
||||||
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
테이블 옵션
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilterOpen(true)}
|
||||||
|
disabled={!selectedTableId}
|
||||||
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
필터 설정
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setGroupingOpen(true)}
|
||||||
|
disabled={!selectedTableId}
|
||||||
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
그룹 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 패널들 */}
|
||||||
|
<ColumnVisibilityPanel isOpen={columnVisibilityOpen} onClose={() => setColumnVisibilityOpen(false)} />
|
||||||
|
<FilterPanel
|
||||||
|
isOpen={filterOpen}
|
||||||
|
onClose={() => setFilterOpen(false)}
|
||||||
|
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
||||||
|
/>
|
||||||
|
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface TableSearchWidgetConfigPanelProps {
|
||||||
|
component: any;
|
||||||
|
onUpdateProperty: (property: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSearchWidgetConfigPanel({
|
||||||
|
component,
|
||||||
|
onUpdateProperty,
|
||||||
|
}: TableSearchWidgetConfigPanelProps) {
|
||||||
|
const [localAutoSelect, setLocalAutoSelect] = useState(
|
||||||
|
component.componentConfig?.autoSelectFirstTable ?? true
|
||||||
|
);
|
||||||
|
const [localShowSelector, setLocalShowSelector] = useState(
|
||||||
|
component.componentConfig?.showTableSelector ?? true
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
|
||||||
|
setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
|
||||||
|
}, [component.componentConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">검색 필터 위젯 설정</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
이 위젯은 화면 내의 테이블들을 자동으로 감지하여 검색, 필터, 그룹 기능을 제공합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첫 번째 테이블 자동 선택 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autoSelectFirstTable"
|
||||||
|
checked={localAutoSelect}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLocalAutoSelect(checked as boolean);
|
||||||
|
onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
|
||||||
|
첫 번째 테이블 자동 선택
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 선택 드롭다운 표시 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showTableSelector"
|
||||||
|
checked={localShowSelector}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLocalShowSelector(checked as boolean);
|
||||||
|
onUpdateProperty("componentConfig.showTableSelector", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
|
||||||
|
테이블 선택 드롭다운 표시 (여러 테이블이 있을 때)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted p-3 text-xs">
|
||||||
|
<p className="font-medium mb-1">참고사항:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
|
<li>테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다</li>
|
||||||
|
<li>여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다</li>
|
||||||
|
<li>선택한 테이블의 컬럼 정보가 자동으로 로드됩니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from "react";
|
||||||
|
import { TableSearchWidget } from "./TableSearchWidget";
|
||||||
|
|
||||||
|
export class TableSearchWidgetRenderer {
|
||||||
|
static render(component: any) {
|
||||||
|
return <TableSearchWidget component={component} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||||
|
import { TableSearchWidget } from "./TableSearchWidget";
|
||||||
|
import { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
|
||||||
|
import { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";
|
||||||
|
|
||||||
|
// 검색 필터 위젯 등록
|
||||||
|
ComponentRegistry.registerComponent({
|
||||||
|
id: "table-search-widget",
|
||||||
|
name: "검색 필터",
|
||||||
|
nameEng: "Table Search Widget",
|
||||||
|
category: "utility", // 유틸리티 컴포넌트로 분류
|
||||||
|
description: "화면 내 테이블을 자동 감지하여 검색, 필터, 그룹 기능을 제공하는 위젯",
|
||||||
|
icon: "Search",
|
||||||
|
tags: ["table", "search", "filter", "group", "search-widget"],
|
||||||
|
webType: "custom",
|
||||||
|
defaultSize: { width: 1920, height: 80 }, // 픽셀 단위: 전체 너비 × 80px 높이
|
||||||
|
component: TableSearchWidget,
|
||||||
|
defaultProps: {
|
||||||
|
title: "테이블 검색",
|
||||||
|
style: {
|
||||||
|
width: "100%",
|
||||||
|
height: "80px",
|
||||||
|
padding: "0.75rem",
|
||||||
|
},
|
||||||
|
componentConfig: {
|
||||||
|
autoSelectFirstTable: true,
|
||||||
|
showTableSelector: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: TableSearchWidgetRenderer.render,
|
||||||
|
configPanel: TableSearchWidgetConfigPanel,
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "WACE",
|
||||||
|
});
|
||||||
|
|
||||||
|
export { TableSearchWidget } from "./TableSearchWidget";
|
||||||
|
export { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
|
||||||
|
export { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";
|
||||||
|
|
||||||
|
|
@ -15,49 +15,86 @@ export interface TextInputConfigPanelProps {
|
||||||
config: TextInputConfig;
|
config: TextInputConfig;
|
||||||
onChange: (config: Partial<TextInputConfig>) => void;
|
onChange: (config: Partial<TextInputConfig>) => void;
|
||||||
screenTableName?: string; // 🆕 현재 화면의 테이블명
|
screenTableName?: string; // 🆕 현재 화면의 테이블명
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID (사용자 선택)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextInput 설정 패널
|
* TextInput 설정 패널
|
||||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||||
*/
|
*/
|
||||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName }) => {
|
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName, menuObjid }) => {
|
||||||
// 채번 규칙 목록 상태
|
// 채번 규칙 목록 상태
|
||||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [loadingRules, setLoadingRules] = useState(false);
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
|
||||||
// 채번 규칙 목록 로드
|
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
|
||||||
|
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// useState 초기값에서 저장된 값 복원 (우선순위: 저장된 값 > menuObjid prop)
|
||||||
|
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
|
||||||
|
return config.autoGeneration?.selectedMenuObjid || menuObjid;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loadingMenus, setLoadingMenus] = useState(false);
|
||||||
|
|
||||||
|
// 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMenus = async () => {
|
||||||
|
setLoadingMenus(true);
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.get("/admin/menus");
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const allMenus = response.data.data;
|
||||||
|
|
||||||
|
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
|
||||||
|
const level2UserMenus = allMenus.filter((menu: any) =>
|
||||||
|
menu.menu_type === '1' && menu.lev === 2
|
||||||
|
);
|
||||||
|
|
||||||
|
setParentMenus(level2UserMenus);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("부모 메뉴 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMenus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadMenus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
|
// autoGeneration.type이 numbering_rule이 아니면 로드하지 않음
|
||||||
|
if (config.autoGeneration?.type !== "numbering_rule") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메뉴가 선택되지 않았으면 로드하지 않음
|
||||||
|
if (!selectedMenuObjid) {
|
||||||
|
setNumberingRules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingRules(true);
|
setLoadingRules(true);
|
||||||
try {
|
try {
|
||||||
let response;
|
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
||||||
|
|
||||||
// 🆕 테이블명이 있으면 테이블 기반 필터링, 없으면 전체 조회
|
|
||||||
if (screenTableName) {
|
|
||||||
console.log("🔍 TextInputConfigPanel: 테이블 기반 채번 규칙 로드", { screenTableName });
|
|
||||||
response = await getAvailableNumberingRulesForScreen(screenTableName);
|
|
||||||
} else {
|
|
||||||
console.log("🔍 TextInputConfigPanel: 전체 채번 규칙 로드 (테이블명 없음)");
|
|
||||||
response = await getAvailableNumberingRules();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setNumberingRules(response.data);
|
setNumberingRules(response.data);
|
||||||
console.log("✅ 채번 규칙 로드 완료:", response.data.length, "개");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("채번 규칙 목록 로드 실패:", error);
|
console.error("채번 규칙 목록 로드 실패:", error);
|
||||||
|
setNumberingRules([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRules(false);
|
setLoadingRules(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// autoGeneration.type이 numbering_rule일 때만 로드
|
|
||||||
if (config.autoGeneration?.type === "numbering_rule") {
|
|
||||||
loadRules();
|
loadRules();
|
||||||
}
|
}, [selectedMenuObjid, config.autoGeneration?.type]);
|
||||||
}, [config.autoGeneration?.type, screenTableName]);
|
|
||||||
|
|
||||||
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
onChange({ [key]: value });
|
||||||
|
|
@ -157,6 +194,55 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
||||||
|
|
||||||
{/* 채번 규칙 선택 */}
|
{/* 채번 규칙 선택 */}
|
||||||
{config.autoGeneration?.type === "numbering_rule" && (
|
{config.autoGeneration?.type === "numbering_rule" && (
|
||||||
|
<>
|
||||||
|
{/* 부모 메뉴 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="targetMenu">
|
||||||
|
대상 메뉴 선택 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedMenuObjid?.toString() || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const menuObjid = parseInt(value);
|
||||||
|
setSelectedMenuObjid(menuObjid);
|
||||||
|
|
||||||
|
// 컴포넌트 설정에 저장하여 언마운트 시에도 유지
|
||||||
|
handleChange("autoGeneration", {
|
||||||
|
...config.autoGeneration,
|
||||||
|
selectedMenuObjid: menuObjid,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={loadingMenus}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{parentMenus.length === 0 ? (
|
||||||
|
<SelectItem value="no-menus" disabled>
|
||||||
|
사용 가능한 메뉴가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
parentMenus.map((menu) => (
|
||||||
|
<SelectItem key={menu.objid} value={menu.objid.toString()}>
|
||||||
|
{menu.menu_name_kor}
|
||||||
|
{menu.menu_name_eng && (
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
|
({menu.menu_name_eng})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
이 입력 필드가 어느 메뉴에 속할지 선택하세요 (해당 메뉴의 채번규칙이 적용됩니다)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 채번 규칙 선택 (메뉴 선택 후) */}
|
||||||
|
{selectedMenuObjid ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="numberingRuleId">
|
<Label htmlFor="numberingRuleId">
|
||||||
채번 규칙 선택 <span className="text-destructive">*</span>
|
채번 규칙 선택 <span className="text-destructive">*</span>
|
||||||
|
|
@ -181,7 +267,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{numberingRules.length === 0 ? (
|
{numberingRules.length === 0 ? (
|
||||||
<SelectItem value="no-rules" disabled>
|
<SelectItem value="no-rules" disabled>
|
||||||
사용 가능한 규칙이 없습니다
|
선택된 메뉴에 사용 가능한 규칙이 없습니다
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
numberingRules.map((rule) => (
|
numberingRules.map((rule) => (
|
||||||
|
|
@ -198,9 +284,15 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
선택된 메뉴 및 형제 메뉴에서 사용 가능한 채번 규칙만 표시됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
먼저 대상 메뉴를 선택하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1970,7 +1970,7 @@ export class ButtonActionExecutor {
|
||||||
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
|
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
|
||||||
search: filterConditions, // ✅ 필터 조건
|
search: filterConditions, // ✅ 필터 조건
|
||||||
enableEntityJoin: true, // ✅ Entity 조인
|
enableEntityJoin: true, // ✅ Entity 조인
|
||||||
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
|
// autoFilter는 entityJoinApi.getTableDataWithJoins 내부에서 자동으로 적용됨
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
|
||||||
|
|
@ -2027,8 +2027,18 @@ export class ButtonActionExecutor {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일명 생성
|
// 파일명 생성 (메뉴 이름 우선 사용)
|
||||||
const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
let defaultFileName = context.tableName || "데이터";
|
||||||
|
|
||||||
|
// localStorage에서 메뉴 이름 가져오기
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const menuName = localStorage.getItem("currentMenuName");
|
||||||
|
if (menuName) {
|
||||||
|
defaultFileName = menuName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
const sheetName = config.excelSheetName || "Sheet1";
|
const sheetName = config.excelSheetName || "Sheet1";
|
||||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export interface ComponentConfigPanelProps {
|
||||||
screenTableName?: string; // 화면에서 지정한 테이블명
|
screenTableName?: string; // 화면에서 지정한 테이블명
|
||||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||||
tables?: any[]; // 전체 테이블 목록
|
tables?: any[]; // 전체 테이블 목록
|
||||||
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||||
|
|
@ -116,6 +117,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
screenTableName,
|
screenTableName,
|
||||||
tableColumns,
|
tableColumns,
|
||||||
tables,
|
tables,
|
||||||
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
||||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||||
|
|
@ -259,6 +261,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
tables={tables} // 기본 테이블 목록 (현재 화면의 테이블만)
|
tables={tables} // 기본 테이블 목록 (현재 화면의 테이블만)
|
||||||
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
||||||
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
||||||
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 테이블 옵션 관련 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 필터 조건
|
||||||
|
*/
|
||||||
|
export interface TableFilter {
|
||||||
|
columnName: string;
|
||||||
|
operator:
|
||||||
|
| "equals"
|
||||||
|
| "contains"
|
||||||
|
| "startsWith"
|
||||||
|
| "endsWith"
|
||||||
|
| "gt"
|
||||||
|
| "lt"
|
||||||
|
| "gte"
|
||||||
|
| "lte"
|
||||||
|
| "notEquals";
|
||||||
|
value: string | number | boolean;
|
||||||
|
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
|
||||||
|
width?: number; // 필터 입력 필드 너비 (px)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 표시 설정
|
||||||
|
*/
|
||||||
|
export interface ColumnVisibility {
|
||||||
|
columnName: string;
|
||||||
|
visible: boolean;
|
||||||
|
width?: number;
|
||||||
|
order?: number;
|
||||||
|
fixed?: boolean; // 좌측 고정 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼 정보
|
||||||
|
*/
|
||||||
|
export interface TableColumn {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
inputType: string;
|
||||||
|
visible: boolean;
|
||||||
|
width: number;
|
||||||
|
sortable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 등록 정보
|
||||||
|
*/
|
||||||
|
export interface TableRegistration {
|
||||||
|
tableId: string; // 고유 ID (예: "table-list-123")
|
||||||
|
label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
|
||||||
|
tableName: string; // 실제 DB 테이블명 (예: "item_info")
|
||||||
|
columns: TableColumn[];
|
||||||
|
dataCount?: number; // 현재 표시된 데이터 건수
|
||||||
|
|
||||||
|
// 콜백 함수들
|
||||||
|
onFilterChange: (filters: TableFilter[]) => void;
|
||||||
|
onGroupChange: (groups: string[]) => void;
|
||||||
|
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
||||||
|
|
||||||
|
// 데이터 조회 함수 (선택 타입 필터용)
|
||||||
|
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context 값 타입
|
||||||
|
*/
|
||||||
|
export interface TableOptionsContextValue {
|
||||||
|
registeredTables: Map<string, TableRegistration>;
|
||||||
|
registerTable: (registration: TableRegistration) => void;
|
||||||
|
unregisterTable: (tableId: string) => void;
|
||||||
|
getTable: (tableId: string) => TableRegistration | undefined;
|
||||||
|
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
|
||||||
|
selectedTableId: string | null;
|
||||||
|
setSelectedTableId: (tableId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,977 @@
|
||||||
|
# 카테고리 컴포넌트 메뉴 기반 전환 계획서
|
||||||
|
|
||||||
|
## 📋 현재 문제점
|
||||||
|
|
||||||
|
### 테이블 기반 스코프의 한계
|
||||||
|
|
||||||
|
**현재 상황**:
|
||||||
|
|
||||||
|
- 카테고리와 채번 컴포넌트가 **테이블 기준**으로 데이터를 불러옴
|
||||||
|
- `table_column_category_values` 테이블에서 `table_name + column_name`으로 카테고리 조회
|
||||||
|
|
||||||
|
**문제 발생**:
|
||||||
|
|
||||||
|
```
|
||||||
|
영업관리 (menu_id: 200)
|
||||||
|
├── 고객관리 (menu_id: 201) - 테이블: customer_info
|
||||||
|
├── 계약관리 (menu_id: 202) - 테이블: contract_info
|
||||||
|
├── 주문관리 (menu_id: 203) - 테이블: order_info
|
||||||
|
└── 영업관리 공통코드 (menu_id: 204) - 어떤 테이블 선택?
|
||||||
|
```
|
||||||
|
|
||||||
|
**문제**:
|
||||||
|
|
||||||
|
- 영업관리 전체에서 사용할 공통 코드/카테고리를 관리하고 싶은데
|
||||||
|
- 각 하위 메뉴가 서로 다른 테이블을 사용하므로
|
||||||
|
- 특정 테이블 하나를 선택하면 다른 메뉴에서 사용할 수 없음
|
||||||
|
|
||||||
|
### 예시: 영업관리 공통 코드 관리 불가
|
||||||
|
|
||||||
|
**원하는 동작**:
|
||||||
|
|
||||||
|
- "영업관리 > 공통코드 관리" 메뉴에서 카테고리 생성
|
||||||
|
- 이 카테고리는 영업관리의 **모든 하위 메뉴**에서 사용 가능
|
||||||
|
- 고객관리, 계약관리, 주문관리 화면 모두에서 같은 카테고리 공유
|
||||||
|
|
||||||
|
**현재 동작**:
|
||||||
|
|
||||||
|
- 테이블별로 카테고리가 격리됨
|
||||||
|
- `customer_info` 테이블의 카테고리는 `contract_info`에서 사용 불가
|
||||||
|
- 각 테이블마다 동일한 카테고리를 중복 생성해야 함 (비효율)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 해결 방안: 메뉴 기반 스코프
|
||||||
|
|
||||||
|
### 핵심 개념
|
||||||
|
|
||||||
|
**메뉴 계층 구조를 카테고리 스코프로 사용**:
|
||||||
|
|
||||||
|
- 카테고리를 생성할 때 `menu_id`를 기록
|
||||||
|
- 같은 부모 메뉴를 가진 **형제 메뉴들**이 카테고리를 공유
|
||||||
|
- 테이블과 무관하게 메뉴 구조에 따라 스코프 결정
|
||||||
|
|
||||||
|
### 메뉴 스코프 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
영업관리 (parent_id: 0, menu_id: 200)
|
||||||
|
├── 고객관리 (parent_id: 200, menu_id: 201)
|
||||||
|
├── 계약관리 (parent_id: 200, menu_id: 202)
|
||||||
|
├── 주문관리 (parent_id: 200, menu_id: 203)
|
||||||
|
└── 공통코드 관리 (parent_id: 200, menu_id: 204) ← 여기서 카테고리 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
**스코프 규칙**:
|
||||||
|
|
||||||
|
- 204번 메뉴에서 카테고리 생성 → `menu_id = 204`로 저장
|
||||||
|
- 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능**
|
||||||
|
- 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 데이터베이스 설계
|
||||||
|
|
||||||
|
### 기존 테이블 수정
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- table_column_category_values 테이블에 menu_id 추가
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ADD COLUMN menu_id INTEGER;
|
||||||
|
|
||||||
|
-- 외래키 추가
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ADD CONSTRAINT fk_category_value_menu
|
||||||
|
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
|
||||||
|
|
||||||
|
-- UNIQUE 제약조건 수정 (menu_id 추가)
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
DROP CONSTRAINT IF EXISTS unique_category_value;
|
||||||
|
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ADD CONSTRAINT unique_category_value
|
||||||
|
UNIQUE (table_name, column_name, value_code, menu_id, company_code);
|
||||||
|
|
||||||
|
-- 인덱스 추가
|
||||||
|
CREATE INDEX idx_category_value_menu
|
||||||
|
ON table_column_category_values(menu_id, table_name, column_name, company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 필드 설명
|
||||||
|
|
||||||
|
| 필드 | 설명 | 예시 |
|
||||||
|
| -------------- | ------------------------ | --------------------- |
|
||||||
|
| `table_name` | 어떤 테이블의 컬럼인지 | `customer_info` |
|
||||||
|
| `column_name` | 어떤 컬럼의 값인지 | `customer_type` |
|
||||||
|
| `menu_id` | 어느 메뉴에서 생성했는지 | `204` (공통코드 관리) |
|
||||||
|
| `company_code` | 멀티테넌시 | `COMPANY_A` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 백엔드 구현
|
||||||
|
|
||||||
|
### 1. 메뉴 스코프 로직 추가
|
||||||
|
|
||||||
|
#### 형제 메뉴 조회 함수
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/services/menuService.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴의 형제 메뉴 ID 목록 조회
|
||||||
|
* (같은 부모를 가진 메뉴들)
|
||||||
|
*/
|
||||||
|
export async function getSiblingMenuIds(menuId: number): Promise<number[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 1. 현재 메뉴의 부모 찾기
|
||||||
|
const parentQuery = `
|
||||||
|
SELECT parent_id FROM menu_info WHERE menu_id = $1
|
||||||
|
`;
|
||||||
|
const parentResult = await pool.query(parentQuery, [menuId]);
|
||||||
|
|
||||||
|
if (parentResult.rows.length === 0) {
|
||||||
|
return [menuId]; // 메뉴가 없으면 자기 자신만
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = parentResult.rows[0].parent_id;
|
||||||
|
|
||||||
|
if (!parentId || parentId === 0) {
|
||||||
|
// 최상위 메뉴인 경우 자기 자신만
|
||||||
|
return [menuId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 같은 부모를 가진 형제 메뉴들 조회
|
||||||
|
const siblingsQuery = `
|
||||||
|
SELECT menu_id FROM menu_info WHERE parent_id = $1
|
||||||
|
`;
|
||||||
|
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
|
||||||
|
|
||||||
|
return siblingsResult.rows.map((row) => row.menu_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 카테고리 값 조회 API 수정
|
||||||
|
|
||||||
|
#### 서비스 로직 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/services/tableCategoryValueService.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
|
*/
|
||||||
|
async getCategoryValues(
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
menuId: number, // ← 추가
|
||||||
|
companyCode: string,
|
||||||
|
includeInactive: boolean = false
|
||||||
|
): Promise<TableCategoryValue[]> {
|
||||||
|
logger.info("카테고리 값 조회 (메뉴 스코프)", {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
menuId,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 1. 형제 메뉴 ID 조회
|
||||||
|
const siblingMenuIds = await getSiblingMenuIds(menuId);
|
||||||
|
|
||||||
|
logger.info("형제 메뉴 ID 목록", { menuId, siblingMenuIds });
|
||||||
|
|
||||||
|
// 2. 카테고리 값 조회
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 회사 데이터 조회
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
value_id AS "valueId",
|
||||||
|
table_name AS "tableName",
|
||||||
|
column_name AS "columnName",
|
||||||
|
value_code AS "valueCode",
|
||||||
|
value_label AS "valueLabel",
|
||||||
|
value_order AS "valueOrder",
|
||||||
|
parent_value_id AS "parentValueId",
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_id AS "menuId",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
created_by AS "createdBy"
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
AND menu_id = ANY($3) -- ← 형제 메뉴 포함
|
||||||
|
${!includeInactive ? 'AND is_active = true' : ''}
|
||||||
|
ORDER BY value_order, value_label
|
||||||
|
`;
|
||||||
|
params = [tableName, columnName, siblingMenuIds];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 데이터만 조회
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
value_id AS "valueId",
|
||||||
|
table_name AS "tableName",
|
||||||
|
column_name AS "columnName",
|
||||||
|
value_code AS "valueCode",
|
||||||
|
value_label AS "valueLabel",
|
||||||
|
value_order AS "valueOrder",
|
||||||
|
parent_value_id AS "parentValueId",
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_id AS "menuId",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
created_by AS "createdBy"
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
AND menu_id = ANY($3) -- ← 형제 메뉴 포함
|
||||||
|
AND company_code = $4 -- ← 회사별 필터링
|
||||||
|
${!includeInactive ? 'AND is_active = true' : ''}
|
||||||
|
ORDER BY value_order, value_label
|
||||||
|
`;
|
||||||
|
params = [tableName, columnName, siblingMenuIds, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 카테고리 값 추가 API 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 카테고리 값 추가 (menu_id 저장)
|
||||||
|
*/
|
||||||
|
async addCategoryValue(
|
||||||
|
value: TableCategoryValue,
|
||||||
|
menuId: number, // ← 추가
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<TableCategoryValue> {
|
||||||
|
logger.info("카테고리 값 추가 (메뉴 스코프)", {
|
||||||
|
tableName: value.tableName,
|
||||||
|
columnName: value.columnName,
|
||||||
|
valueCode: value.valueCode,
|
||||||
|
menuId,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO table_column_category_values (
|
||||||
|
table_name, column_name,
|
||||||
|
value_code, value_label, value_order,
|
||||||
|
parent_value_id, depth,
|
||||||
|
description, color, icon,
|
||||||
|
is_active, is_default,
|
||||||
|
company_code, menu_id, -- ← menu_id 추가
|
||||||
|
created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
RETURNING
|
||||||
|
value_id AS "valueId",
|
||||||
|
table_name AS "tableName",
|
||||||
|
column_name AS "columnName",
|
||||||
|
value_code AS "valueCode",
|
||||||
|
value_label AS "valueLabel",
|
||||||
|
value_order AS "valueOrder",
|
||||||
|
parent_value_id AS "parentValueId",
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_id AS "menuId",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
created_by AS "createdBy"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
value.tableName,
|
||||||
|
value.columnName,
|
||||||
|
value.valueCode,
|
||||||
|
value.valueLabel,
|
||||||
|
value.valueOrder || 0,
|
||||||
|
value.parentValueId || null,
|
||||||
|
value.depth || 1,
|
||||||
|
value.description || null,
|
||||||
|
value.color || null,
|
||||||
|
value.icon || null,
|
||||||
|
value.isActive !== false,
|
||||||
|
value.isDefault || false,
|
||||||
|
companyCode,
|
||||||
|
menuId, // ← 카테고리 관리 화면의 menu_id
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("카테고리 값 추가 성공", {
|
||||||
|
valueId: result.rows[0].valueId,
|
||||||
|
menuId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 컨트롤러 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/controllers/tableCategoryValueController.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getCategoryValues(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, columnName } = req.params;
|
||||||
|
const { menuId, includeInactive } = req.query; // ← menuId 추가
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
if (!menuId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "menuId는 필수입니다",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = new TableCategoryValueService();
|
||||||
|
const values = await service.getCategoryValues(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
Number(menuId), // ← menuId 전달
|
||||||
|
companyCode,
|
||||||
|
includeInactive === "true"
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: values,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("카테고리 값 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 값 조회 중 오류 발생",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 추가
|
||||||
|
*/
|
||||||
|
export async function addCategoryValue(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { menuId, ...value } = req.body; // ← menuId 추가
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
if (!menuId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "menuId는 필수입니다",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = new TableCategoryValueService();
|
||||||
|
const newValue = await service.addCategoryValue(
|
||||||
|
value,
|
||||||
|
menuId, // ← menuId 전달
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: newValue,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("카테고리 값 추가 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 값 추가 중 오류 발생",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 프론트엔드 구현
|
||||||
|
|
||||||
|
### 1. API 클라이언트 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/api/tableCategoryValue.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
|
*/
|
||||||
|
export async function getCategoryValues(
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
menuId: number, // ← 추가
|
||||||
|
includeInactive: boolean = false
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: TableCategoryValue[];
|
||||||
|
}>(`/table-categories/${tableName}/${columnName}/values`, {
|
||||||
|
params: {
|
||||||
|
menuId, // ← menuId 쿼리 파라미터 추가
|
||||||
|
includeInactive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("카테고리 값 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 추가
|
||||||
|
*/
|
||||||
|
export async function addCategoryValue(
|
||||||
|
value: TableCategoryValue,
|
||||||
|
menuId: number // ← 추가
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: TableCategoryValue;
|
||||||
|
}>("/table-categories/values", {
|
||||||
|
...value,
|
||||||
|
menuId, // ← menuId 포함
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("카테고리 값 추가 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. CategoryColumnList 컴포넌트 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/table-category/CategoryColumnList.tsx
|
||||||
|
|
||||||
|
interface CategoryColumnListProps {
|
||||||
|
tableName: string;
|
||||||
|
menuId: number; // ← 추가
|
||||||
|
selectedColumn: string | null;
|
||||||
|
onColumnSelect: (columnName: string, columnLabel: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryColumnList({
|
||||||
|
tableName,
|
||||||
|
menuId, // ← 추가
|
||||||
|
selectedColumn,
|
||||||
|
onColumnSelect,
|
||||||
|
}: CategoryColumnListProps) {
|
||||||
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategoryColumns();
|
||||||
|
}, [tableName, menuId]); // ← menuId 의존성 추가
|
||||||
|
|
||||||
|
const loadCategoryColumns = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// table_type_columns에서 input_type='category'인 컬럼 조회
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/table-management/tables/${tableName}/columns`
|
||||||
|
);
|
||||||
|
|
||||||
|
const allColumns = Array.isArray(response.data)
|
||||||
|
? response.data
|
||||||
|
: response.data.data?.columns || [];
|
||||||
|
|
||||||
|
// category 타입만 필터링
|
||||||
|
const categoryColumns = allColumns.filter(
|
||||||
|
(col: any) =>
|
||||||
|
col.inputType === "category" || col.input_type === "category"
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnsWithCount = await Promise.all(
|
||||||
|
categoryColumns.map(async (col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
const colLabel = col.columnLabel || col.column_label || colName;
|
||||||
|
|
||||||
|
// 각 컬럼의 값 개수 가져오기 (menuId 전달)
|
||||||
|
let valueCount = 0;
|
||||||
|
try {
|
||||||
|
const valuesResult = await getCategoryValues(
|
||||||
|
tableName,
|
||||||
|
colName,
|
||||||
|
menuId, // ← menuId 전달
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (valuesResult.success && valuesResult.data) {
|
||||||
|
valueCount = valuesResult.data.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`항목 개수 조회 실패 (${colName}):`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnName: colName,
|
||||||
|
columnLabel: colLabel,
|
||||||
|
inputType: col.inputType || col.input_type,
|
||||||
|
valueCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setColumns(columnsWithCount);
|
||||||
|
|
||||||
|
// 첫 번째 컬럼 자동 선택
|
||||||
|
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||||
|
const firstCol = columnsWithCount[0];
|
||||||
|
onColumnSelect(firstCol.columnName, firstCol.columnLabel);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
||||||
|
setColumns([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... 나머지 렌더링 로직
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. CategoryValueManager 컴포넌트 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/table-category/CategoryValueManager.tsx
|
||||||
|
|
||||||
|
interface CategoryValueManagerProps {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
menuId: number; // ← 추가
|
||||||
|
columnLabel?: string;
|
||||||
|
onValueCountChange?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryValueManager({
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
menuId, // ← 추가
|
||||||
|
columnLabel,
|
||||||
|
onValueCountChange,
|
||||||
|
}: CategoryValueManagerProps) {
|
||||||
|
const [values, setValues] = useState<TableCategoryValue[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategoryValues();
|
||||||
|
}, [tableName, columnName, menuId]); // ← menuId 의존성 추가
|
||||||
|
|
||||||
|
const loadCategoryValues = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getCategoryValues(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
menuId, // ← menuId 전달
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setValues(response.data);
|
||||||
|
onValueCountChange?.(response.data.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 값 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddValue = async (newValue: TableCategoryValue) => {
|
||||||
|
try {
|
||||||
|
const response = await addCategoryValue(
|
||||||
|
{
|
||||||
|
...newValue,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
},
|
||||||
|
menuId // ← menuId 전달
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
loadCategoryValues();
|
||||||
|
toast.success("카테고리 값이 추가되었습니다");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 값 추가 실패:", error);
|
||||||
|
toast.error("카테고리 값 추가 중 오류가 발생했습니다");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... 나머지 CRUD 로직 (menuId를 항상 포함)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 화면관리 시스템에서 menuId 전달
|
||||||
|
|
||||||
|
#### 화면 디자이너에서 menuId 추출
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/screen/ScreenDesigner.tsx
|
||||||
|
|
||||||
|
export function ScreenDesigner() {
|
||||||
|
const [selectedScreen, setSelectedScreen] = useState<Screen | null>(null);
|
||||||
|
|
||||||
|
// 선택된 화면의 menuId 추출
|
||||||
|
const currentMenuId = selectedScreen?.menuId;
|
||||||
|
|
||||||
|
// CategoryWidget 렌더링 시 menuId 전달
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ... */}
|
||||||
|
<CategoryWidget
|
||||||
|
tableName={selectedScreen?.tableName}
|
||||||
|
menuId={currentMenuId} // ← menuId 전달
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CategoryWidget 컴포넌트 (신규 또는 수정)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/screen/widgets/CategoryWidget.tsx
|
||||||
|
|
||||||
|
interface CategoryWidgetProps {
|
||||||
|
tableName: string;
|
||||||
|
menuId: number; // ← 추가
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryWidget({ tableName, menuId }: CategoryWidgetProps) {
|
||||||
|
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
|
||||||
|
const [selectedColumnLabel, setSelectedColumnLabel] = useState<string>("");
|
||||||
|
|
||||||
|
const handleColumnSelect = (columnName: string, columnLabel: string) => {
|
||||||
|
setSelectedColumn(columnName);
|
||||||
|
setSelectedColumnLabel(columnLabel);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-6">
|
||||||
|
{/* 좌측: 카테고리 컬럼 리스트 */}
|
||||||
|
<div className="w-[30%] border-r pr-6">
|
||||||
|
<CategoryColumnList
|
||||||
|
tableName={tableName}
|
||||||
|
menuId={menuId} // ← menuId 전달
|
||||||
|
selectedColumn={selectedColumn}
|
||||||
|
onColumnSelect={handleColumnSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 카테고리 값 관리 */}
|
||||||
|
<div className="w-[70%]">
|
||||||
|
{selectedColumn ? (
|
||||||
|
<CategoryValueManager
|
||||||
|
tableName={tableName}
|
||||||
|
columnName={selectedColumn}
|
||||||
|
menuId={menuId} // ← menuId 전달
|
||||||
|
columnLabel={selectedColumnLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-12 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
좌측에서 카테고리 컬럼을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 기존 데이터 마이그레이션
|
||||||
|
|
||||||
|
### 마이그레이션 스크립트
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- db/migrations/047_add_menu_id_to_category_values.sql
|
||||||
|
|
||||||
|
-- 1. menu_id 컬럼 추가 (NULL 허용)
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ADD COLUMN IF NOT EXISTS menu_id INTEGER;
|
||||||
|
|
||||||
|
-- 2. 기존 데이터에 임시 menu_id 설정
|
||||||
|
-- (관리자가 수동으로 올바른 menu_id로 변경해야 함)
|
||||||
|
UPDATE table_column_category_values
|
||||||
|
SET menu_id = 1
|
||||||
|
WHERE menu_id IS NULL;
|
||||||
|
|
||||||
|
-- 3. menu_id를 NOT NULL로 변경
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ALTER COLUMN menu_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- 4. 외래키 추가
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ADD CONSTRAINT fk_category_value_menu
|
||||||
|
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
|
||||||
|
|
||||||
|
-- 5. UNIQUE 제약조건 재생성
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
DROP CONSTRAINT IF EXISTS unique_category_value;
|
||||||
|
|
||||||
|
ALTER TABLE table_column_category_values
|
||||||
|
ADD CONSTRAINT unique_category_value
|
||||||
|
UNIQUE (table_name, column_name, value_code, menu_id, company_code);
|
||||||
|
|
||||||
|
-- 6. 인덱스 추가
|
||||||
|
CREATE INDEX idx_category_value_menu
|
||||||
|
ON table_column_category_values(menu_id, table_name, column_name, company_code);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN table_column_category_values.menu_id IS '카테고리를 생성한 메뉴 ID (형제 메뉴에서 공유)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 사용 시나리오
|
||||||
|
|
||||||
|
### 시나리오: 영업관리 공통코드 관리
|
||||||
|
|
||||||
|
#### 1단계: 메뉴 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
영업관리 (parent_id: 0, menu_id: 200)
|
||||||
|
├── 고객관리 (parent_id: 200, menu_id: 201) - customer_info 테이블
|
||||||
|
├── 계약관리 (parent_id: 200, menu_id: 202) - contract_info 테이블
|
||||||
|
├── 주문관리 (parent_id: 200, menu_id: 203) - order_info 테이블
|
||||||
|
└── 공통코드 관리 (parent_id: 200, menu_id: 204) - 카테고리 관리 전용
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2단계: 카테고리 관리 화면 생성
|
||||||
|
|
||||||
|
1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_id: 204)
|
||||||
|
2. **화면 생성**: 화면관리 시스템에서 화면 생성
|
||||||
|
3. **테이블 선택**: 영업관리에서 사용할 **아무 테이블** (예: `customer_info`)
|
||||||
|
- 테이블 선택은 컬럼 목록을 가져오기 위한 것일 뿐
|
||||||
|
- 실제 스코프는 `menu_id`로 결정됨
|
||||||
|
4. **위젯 배치**: 카테고리 관리 위젯 드래그앤드롭
|
||||||
|
|
||||||
|
#### 3단계: 카테고리 값 등록
|
||||||
|
|
||||||
|
1. **좌측 패널**: `customer_info` 테이블의 카테고리 컬럼 표시
|
||||||
|
|
||||||
|
- `customer_type` (고객 유형)
|
||||||
|
- `customer_grade` (고객 등급)
|
||||||
|
|
||||||
|
2. **컬럼 선택**: `customer_type` 클릭
|
||||||
|
|
||||||
|
3. **우측 패널**: 카테고리 값 관리
|
||||||
|
- 추가 버튼 클릭
|
||||||
|
- 코드: `REGULAR`, 라벨: `일반 고객`
|
||||||
|
- 색상: `#3b82f6`
|
||||||
|
- **저장 시 `menu_id = 204`로 자동 저장됨**
|
||||||
|
|
||||||
|
#### 4단계: 다른 화면에서 사용
|
||||||
|
|
||||||
|
##### ✅ 형제 메뉴에서 사용 가능
|
||||||
|
|
||||||
|
**고객관리 화면** (menu_id: 201):
|
||||||
|
|
||||||
|
- `customer_type` 컬럼을 category-select 위젯으로 배치
|
||||||
|
- 드롭다운에 `일반 고객`, `VIP 고객` 등 표시됨 ✅
|
||||||
|
- **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴
|
||||||
|
|
||||||
|
**계약관리 화면** (menu_id: 202):
|
||||||
|
|
||||||
|
- `contract_info` 테이블에 `customer_type` 컬럼이 있다면
|
||||||
|
- 동일한 카테고리 값 사용 가능 ✅
|
||||||
|
- **이유**: 202와 204도 형제 메뉴
|
||||||
|
|
||||||
|
**주문관리 화면** (menu_id: 203):
|
||||||
|
|
||||||
|
- `order_info` 테이블에 `customer_type` 컬럼이 있다면
|
||||||
|
- 동일한 카테고리 값 사용 가능 ✅
|
||||||
|
- **이유**: 203과 204도 형제 메뉴
|
||||||
|
|
||||||
|
##### ❌ 다른 부모 메뉴에서 사용 불가
|
||||||
|
|
||||||
|
**구매관리 > 발주관리** (parent_id: 300):
|
||||||
|
|
||||||
|
- `purchase_orders` 테이블에 `customer_type` 컬럼이 있어도
|
||||||
|
- 영업관리의 카테고리는 표시되지 않음 ❌
|
||||||
|
- **이유**: 다른 부모 메뉴이므로 스코프가 다름
|
||||||
|
- 구매관리는 자체 카테고리를 별도로 생성해야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 구현 순서
|
||||||
|
|
||||||
|
### Phase 1: 데이터베이스 마이그레이션 (30분)
|
||||||
|
|
||||||
|
1. ✅ 마이그레이션 파일 작성 (`047_add_menu_id_to_category_values.sql`)
|
||||||
|
2. ⏳ DB 마이그레이션 실행
|
||||||
|
3. ⏳ 기존 데이터 임시 menu_id 설정 (관리자 수동 정리 필요)
|
||||||
|
|
||||||
|
### Phase 2: 백엔드 구현 (2-3시간)
|
||||||
|
|
||||||
|
4. ⏳ `menuService.ts`에 `getSiblingMenuIds()` 함수 추가
|
||||||
|
5. ⏳ `tableCategoryValueService.ts`에 menu_id 로직 추가
|
||||||
|
- `getCategoryValues()` 메서드에 menuId 파라미터 추가
|
||||||
|
- `addCategoryValue()` 메서드에 menuId 파라미터 추가
|
||||||
|
6. ⏳ `tableCategoryValueController.ts` 수정
|
||||||
|
- 쿼리 파라미터에서 menuId 추출
|
||||||
|
- 서비스 호출 시 menuId 전달
|
||||||
|
7. ⏳ 백엔드 테스트
|
||||||
|
|
||||||
|
### Phase 3: 프론트엔드 API 클라이언트 (30분)
|
||||||
|
|
||||||
|
8. ⏳ `frontend/lib/api/tableCategoryValue.ts` 수정
|
||||||
|
- `getCategoryValues()` 함수에 menuId 파라미터 추가
|
||||||
|
- `addCategoryValue()` 함수에 menuId 파라미터 추가
|
||||||
|
|
||||||
|
### Phase 4: 프론트엔드 컴포넌트 (2-3시간)
|
||||||
|
|
||||||
|
9. ⏳ `CategoryColumnList.tsx` 수정
|
||||||
|
- props에 `menuId` 추가
|
||||||
|
- `getCategoryValues()` 호출 시 menuId 전달
|
||||||
|
10. ⏳ `CategoryValueManager.tsx` 수정
|
||||||
|
- props에 `menuId` 추가
|
||||||
|
- 모든 API 호출 시 menuId 전달
|
||||||
|
11. ⏳ `CategoryWidget.tsx` 수정 또는 신규 생성
|
||||||
|
- `menuId` prop 추가
|
||||||
|
- 하위 컴포넌트에 menuId 전달
|
||||||
|
|
||||||
|
### Phase 5: 화면관리 시스템 통합 (1-2시간)
|
||||||
|
|
||||||
|
12. ⏳ 화면 정보에서 menuId 추출 로직 추가
|
||||||
|
13. ⏳ CategoryWidget에 menuId 전달
|
||||||
|
14. ⏳ 카테고리 관리 화면 테스트
|
||||||
|
|
||||||
|
### Phase 6: 테스트 및 문서화 (1시간)
|
||||||
|
|
||||||
|
15. ⏳ 전체 플로우 테스트
|
||||||
|
16. ⏳ 메뉴 스코프 동작 검증
|
||||||
|
17. ⏳ 사용 가이드 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 체크리스트
|
||||||
|
|
||||||
|
### 백엔드 테스트
|
||||||
|
|
||||||
|
- [ ] `getSiblingMenuIds()` 함수가 올바른 형제 메뉴 반환
|
||||||
|
- [ ] 최상위 메뉴의 경우 자기 자신만 반환
|
||||||
|
- [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함
|
||||||
|
- [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음
|
||||||
|
- [ ] 멀티테넌시 필터링 정상 작동
|
||||||
|
|
||||||
|
### 프론트엔드 테스트
|
||||||
|
|
||||||
|
- [ ] 카테고리 컬럼 목록 정상 표시
|
||||||
|
- [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함)
|
||||||
|
- [ ] 카테고리 값 추가 시 menuId 포함
|
||||||
|
- [ ] 카테고리 값 수정/삭제 정상 작동
|
||||||
|
|
||||||
|
### 통합 테스트
|
||||||
|
|
||||||
|
- [ ] 영업관리 > 공통코드 관리에서 카테고리 생성
|
||||||
|
- [ ] 영업관리 > 고객관리에서 카테고리 사용 가능
|
||||||
|
- [ ] 영업관리 > 계약관리에서 카테고리 사용 가능
|
||||||
|
- [ ] 구매관리에서는 영업관리 카테고리 사용 불가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 예상 소요 시간
|
||||||
|
|
||||||
|
| Phase | 작업 내용 | 예상 시간 |
|
||||||
|
| ---------------- | ------------------- | ------------ |
|
||||||
|
| Phase 1 | DB 마이그레이션 | 30분 |
|
||||||
|
| Phase 2 | 백엔드 구현 | 2-3시간 |
|
||||||
|
| Phase 3 | API 클라이언트 | 30분 |
|
||||||
|
| Phase 4 | 프론트엔드 컴포넌트 | 2-3시간 |
|
||||||
|
| Phase 5 | 화면관리 통합 | 1-2시간 |
|
||||||
|
| Phase 6 | 테스트 및 문서 | 1시간 |
|
||||||
|
| **총 예상 시간** | | **7-11시간** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 이점
|
||||||
|
|
||||||
|
### 1. 메뉴별 독립 관리
|
||||||
|
|
||||||
|
- 영업관리, 구매관리, 생산관리 등 각 부서별 카테고리 독립 관리
|
||||||
|
- 부서 간 카테고리 충돌 방지
|
||||||
|
|
||||||
|
### 2. 형제 메뉴 간 공유
|
||||||
|
|
||||||
|
- 같은 부서의 화면들이 카테고리 공유
|
||||||
|
- 중복 생성 불필요
|
||||||
|
|
||||||
|
### 3. 테이블 독립성
|
||||||
|
|
||||||
|
- 테이블이 달라도 같은 카테고리 사용 가능
|
||||||
|
- 테이블 구조 변경에 영향 없음
|
||||||
|
|
||||||
|
### 4. 직관적인 관리
|
||||||
|
|
||||||
|
- 메뉴 구조가 곧 카테고리 스코프
|
||||||
|
- 이해하기 쉬운 권한 체계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 다음 단계
|
||||||
|
|
||||||
|
### 1. 계획 승인 후 즉시 구현 시작
|
||||||
|
|
||||||
|
이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다.
|
||||||
|
|
||||||
|
### 2. 채번규칙 시스템도 동일하게 전환
|
||||||
|
|
||||||
|
카테고리 시스템 전환이 완료되면, 채번규칙 시스템도 동일한 메뉴 기반 스코프로 전환합니다.
|
||||||
|
|
||||||
|
### 3. 공통 유틸리티 함수 재사용
|
||||||
|
|
||||||
|
`getSiblingMenuIds()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다.
|
||||||
|
바로 구현을 시작할까요?
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue