회사별 메뉴 분리 및 권한 관리 #153
502
.cursorrules
502
.cursorrules
|
|
@ -947,3 +947,505 @@ const visibleUsers = users.filter(user => {
|
|||
- API 응답에 최고 관리자 정보가 절대 포함되어서는 안 됩니다.
|
||||
- 로그에 필터링 여부를 기록하여 감사 추적을 남기세요.
|
||||
|
||||
---
|
||||
|
||||
## 멀티테넌시(Multi-Tenancy) 필수 규칙
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
**모든 데이터 조회/생성/수정/삭제 로직은 반드시 회사별(company_code)로 격리되어야 합니다.**
|
||||
|
||||
이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다.
|
||||
|
||||
### 1. 데이터베이스 스키마 요구사항
|
||||
|
||||
#### company_code 컬럼 필수
|
||||
|
||||
모든 비즈니스 테이블은 `company_code` 컬럼을 **반드시** 포함해야 합니다:
|
||||
|
||||
```sql
|
||||
CREATE TABLE example_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL, -- 필수!
|
||||
name VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT fk_company FOREIGN KEY (company_code)
|
||||
REFERENCES company_info(company_code)
|
||||
);
|
||||
|
||||
-- 성능을 위한 인덱스 (필수)
|
||||
CREATE INDEX idx_example_company_code ON example_table(company_code);
|
||||
```
|
||||
|
||||
#### 예외 테이블
|
||||
|
||||
다음 테이블들만 `company_code` 없이 전역 데이터를 저장할 수 있습니다:
|
||||
|
||||
- `company_info` (회사 마스터 데이터)
|
||||
- `user_info` (사용자는 company_code 보유)
|
||||
- 시스템 설정 테이블 (`system_config` 등)
|
||||
- 감사 로그 테이블 (`audit_log` 등)
|
||||
|
||||
### 2. 백엔드 API 구현 필수 사항
|
||||
|
||||
#### 조회(SELECT) 쿼리
|
||||
|
||||
**모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다:**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드
|
||||
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode]);
|
||||
|
||||
logger.info("데이터 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
|
||||
// ❌ 잘못된 방법 - company_code 필터링 없음
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출!
|
||||
const result = await pool.query(query);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
#### 생성(INSERT) 쿼리
|
||||
|
||||
**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다:**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
async function createData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, name, description]);
|
||||
|
||||
logger.info("데이터 생성", {
|
||||
companyCode,
|
||||
id: result.rows[0].id
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
|
||||
// ❌ 잘못된 방법 - company_code 누락
|
||||
async function createData(req: Request, res: Response) {
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
INSERT INTO example_table (name, description)
|
||||
VALUES ($1, $2)
|
||||
`; // company_code 누락! 다른 회사 데이터와 섞임
|
||||
|
||||
const result = await pool.query(query, [name, description]);
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### 수정(UPDATE) 쿼리
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다:**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
async function updateData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND company_code = $4
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [name, description, id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다"
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 수정", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
|
||||
// ❌ 잘못된 방법 - 다른 회사 데이터도 수정 가능
|
||||
async function updateData(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
const query = `
|
||||
UPDATE example_table
|
||||
SET name = $1, description = $2
|
||||
WHERE id = $3
|
||||
`; // 다른 회사의 같은 ID 데이터도 수정됨!
|
||||
|
||||
const result = await pool.query(query, [name, description, id]);
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
}
|
||||
```
|
||||
|
||||
#### 삭제(DELETE) 쿼리
|
||||
|
||||
**WHERE 절에 company_code를 반드시 포함해야 합니다:**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
async function deleteData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const query = `
|
||||
DELETE FROM example_table
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터를 찾을 수 없거나 권한이 없습니다"
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
// ❌ 잘못된 방법 - 다른 회사 데이터도 삭제 가능
|
||||
async function deleteData(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
|
||||
const query = `DELETE FROM example_table WHERE id = $1`;
|
||||
const result = await pool.query(query, [id]);
|
||||
return res.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
### 3. company_code = "*" 의미
|
||||
|
||||
**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
|
||||
|
||||
- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
|
||||
- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
|
||||
|
||||
**회사별 데이터 격리 원칙**:
|
||||
- 회사 A (`company_code = "COMPANY_A"`): 회사 A 데이터만 조회/수정/삭제 가능
|
||||
- 회사 B (`company_code = "COMPANY_B"`): 회사 B 데이터만 조회/수정/삭제 가능
|
||||
- 최고 관리자 (`company_code = "*"`): 모든 회사 데이터 + 최고 관리자 전용 데이터 조회 가능
|
||||
|
||||
### 4. 최고 관리자(SUPER_ADMIN) 예외 처리
|
||||
|
||||
**최고 관리자(company_code = "*")는 모든 회사 데이터에 접근할 수 있습니다:**
|
||||
|
||||
```typescript
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 데이터 조회 가능
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
ORDER BY company_code, created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
logger.info("최고 관리자 전체 데이터 조회");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 데이터만 조회 (company_code = "*" 데이터는 제외)
|
||||
query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [companyCode];
|
||||
logger.info("회사별 데이터 조회", { companyCode });
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
}
|
||||
```
|
||||
|
||||
**핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다!
|
||||
|
||||
### 5. JOIN 쿼리에서의 멀티테넌시
|
||||
|
||||
**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다:**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name,
|
||||
c.name as user_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b ON a.category_id = b.id
|
||||
AND a.company_code = b.company_code -- JOIN 조건에도 company_code 필수
|
||||
LEFT JOIN user_info c ON a.user_id = c.user_id
|
||||
AND a.company_code = c.company_code
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
|
||||
// ❌ 잘못된 방법 - JOIN에서 다른 회사 데이터와 섞임
|
||||
const query = `
|
||||
SELECT
|
||||
a.*,
|
||||
b.name as category_name
|
||||
FROM example_table a
|
||||
LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음!
|
||||
WHERE a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 6. 서비스 계층 패턴
|
||||
|
||||
**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다:**
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 서비스 패턴
|
||||
class ExampleService {
|
||||
async findAll(companyCode: string, filters?: any) {
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
return await pool.query(query, [companyCode]);
|
||||
}
|
||||
|
||||
async findById(companyCode: string, id: number) {
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE id = $1 AND company_code = $2
|
||||
`;
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async create(companyCode: string, data: any) {
|
||||
const query = `
|
||||
INSERT INTO example_table (company_code, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(query, [companyCode, data.name, data.description]);
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 컨트롤러에서 사용
|
||||
const exampleService = new ExampleService();
|
||||
|
||||
async function getDataList(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const data = await exampleService.findAll(companyCode, req.query);
|
||||
return res.json({ success: true, data });
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 프론트엔드 고려사항
|
||||
|
||||
프론트엔드에서는 직접 company_code를 다루지 않습니다. 백엔드 API가 자동으로 처리합니다.
|
||||
|
||||
```typescript
|
||||
// ✅ 프론트엔드 - company_code 불필요
|
||||
async function fetchData() {
|
||||
const response = await apiClient.get("/api/example/list");
|
||||
// 백엔드에서 자동으로 현재 사용자의 company_code로 필터링됨
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ❌ 프론트엔드에서 company_code를 수동으로 전달하지 않음
|
||||
async function fetchData(companyCode: string) {
|
||||
const response = await apiClient.get(`/api/example/list?companyCode=${companyCode}`);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 마이그레이션 체크리스트
|
||||
|
||||
새로운 테이블이나 기능을 추가할 때 반드시 확인하세요:
|
||||
|
||||
#### 데이터베이스
|
||||
|
||||
- [ ] 테이블에 `company_code VARCHAR(20) NOT NULL` 컬럼 추가
|
||||
- [ ] `company_info` 테이블에 대한 외래키 제약조건 추가
|
||||
- [ ] `company_code`에 인덱스 생성
|
||||
- [ ] 샘플 데이터에 올바른 `company_code` 값 포함
|
||||
|
||||
#### 백엔드 API
|
||||
|
||||
- [ ] SELECT 쿼리에 `WHERE company_code = $1` 추가
|
||||
- [ ] INSERT 쿼리에 `company_code` 컬럼 포함
|
||||
- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 추가
|
||||
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 추가
|
||||
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리
|
||||
- [ ] 로그에 `companyCode` 정보 포함
|
||||
|
||||
#### 테스트
|
||||
|
||||
- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인
|
||||
- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인
|
||||
- [ ] 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인
|
||||
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인
|
||||
- [ ] 직접 SQL 인젝션 시도하여 다른 회사 데이터 접근 불가능 확인
|
||||
|
||||
### 9. 보안 주의사항
|
||||
|
||||
#### 클라이언트 입력 검증
|
||||
|
||||
```typescript
|
||||
// ❌ 위험 - 클라이언트가 company_code를 지정할 수 있음
|
||||
async function createData(req: Request, res: Response) {
|
||||
const { companyCode, name } = req.body; // 사용자가 임의의 회사 코드 전달 가능!
|
||||
const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
|
||||
await pool.query(query, [companyCode, name]);
|
||||
}
|
||||
|
||||
// ✅ 안전 - 인증된 사용자의 company_code만 사용
|
||||
async function createData(req: Request, res: Response) {
|
||||
const companyCode = req.user!.companyCode; // 서버에서 확정
|
||||
const { name } = req.body;
|
||||
const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
|
||||
await pool.query(query, [companyCode, name]);
|
||||
}
|
||||
```
|
||||
|
||||
#### 감사 로그
|
||||
|
||||
모든 중요한 작업에 회사 정보를 로깅하세요:
|
||||
|
||||
```typescript
|
||||
logger.info("데이터 생성", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
tableName: "example_table",
|
||||
action: "INSERT",
|
||||
recordId: result.rows[0].id,
|
||||
});
|
||||
|
||||
logger.warn("권한 없는 접근 시도", {
|
||||
companyCode: req.user!.companyCode,
|
||||
userId: req.user!.userId,
|
||||
attemptedRecordId: req.params.id,
|
||||
message: "다른 회사의 데이터 접근 시도",
|
||||
});
|
||||
```
|
||||
|
||||
### 10. 일반적인 실수와 해결방법
|
||||
|
||||
#### 실수 1: 서브쿼리에서 company_code 누락
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table WHERE active = true
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table
|
||||
WHERE category_id IN (
|
||||
SELECT id FROM category_table
|
||||
WHERE active = true AND company_code = $1
|
||||
)
|
||||
AND company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### 실수 2: COUNT/SUM 집계 함수
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법 - 모든 회사의 총합
|
||||
const query = `SELECT COUNT(*) as total FROM example_table`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM example_table
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
#### 실수 3: EXISTS 서브쿼리
|
||||
|
||||
```typescript
|
||||
// ❌ 잘못된 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM related_table b WHERE b.example_id = a.id
|
||||
)
|
||||
AND a.company_code = $1
|
||||
`;
|
||||
|
||||
// ✅ 올바른 방법
|
||||
const query = `
|
||||
SELECT * FROM example_table a
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM related_table b
|
||||
WHERE b.example_id = a.id
|
||||
AND b.company_code = a.company_code
|
||||
)
|
||||
AND a.company_code = $1
|
||||
`;
|
||||
```
|
||||
|
||||
### 11. 참고 자료
|
||||
|
||||
- 마이그레이션 파일: `db/migrations/033_add_company_code_to_code_tables.sql`
|
||||
- 멀티테넌시 분석 문서: `docs/멀티테넌시_구현_현황_분석_보고서.md`
|
||||
- 사용자 관리 컨트롤러: `backend-node/src/controllers/adminController.ts`
|
||||
- 인증 미들웨어: `backend-node/src/middleware/authMiddleware.ts`
|
||||
|
||||
### 12. 요약
|
||||
|
||||
**모든 비즈니스 로직에서 회사별 데이터 격리는 필수입니다:**
|
||||
|
||||
1. 모든 테이블에 `company_code` 컬럼 추가
|
||||
2. 모든 쿼리에 `company_code` 필터링 적용
|
||||
3. 인증된 사용자의 `req.user.companyCode` 사용
|
||||
4. 클라이언트 입력으로 `company_code`를 받지 않음
|
||||
5. 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능
|
||||
6. **일반 회사는 `company_code = "*"` 데이터를 볼 수 없음** (최고 관리자 전용)
|
||||
7. JOIN, 서브쿼리, 집계 함수에도 동일하게 적용
|
||||
8. 모든 작업을 로깅하여 감사 추적 가능
|
||||
|
||||
**절대 잊지 마세요: 멀티테넌시는 보안의 핵심입니다!**
|
||||
|
||||
**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||
|
||||
|
|
|
|||
|
|
@ -21,14 +21,22 @@ export class CommonCodeController {
|
|||
async getCategories(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { search, isActive, page = "1", size = "20" } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const categories = await this.commonCodeService.getCategories({
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||
page: parseInt(page as string),
|
||||
size: parseInt(size as string),
|
||||
});
|
||||
const categories = await this.commonCodeService.getCategories(
|
||||
{
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true"
|
||||
? true
|
||||
: isActive === "false"
|
||||
? false
|
||||
: undefined,
|
||||
page: parseInt(page as string),
|
||||
size: parseInt(size as string),
|
||||
},
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -54,14 +62,23 @@ export class CommonCodeController {
|
|||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const { search, isActive, page, size } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const result = await this.commonCodeService.getCodes(categoryCode, {
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true" ? true : isActive === "false" ? false : undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
size: size ? parseInt(size as string) : undefined,
|
||||
});
|
||||
const result = await this.commonCodeService.getCodes(
|
||||
categoryCode,
|
||||
{
|
||||
search: search as string,
|
||||
isActive:
|
||||
isActive === "true"
|
||||
? true
|
||||
: isActive === "false"
|
||||
? false
|
||||
: undefined,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
size: size ? parseInt(size as string) : undefined,
|
||||
},
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||
const transformedData = result.data.map((code: any) => ({
|
||||
|
|
@ -73,7 +90,8 @@ export class CommonCodeController {
|
|||
sortOrder: code.sort_order,
|
||||
isActive: code.is_active,
|
||||
useYn: code.is_active,
|
||||
|
||||
companyCode: code.company_code, // 추가
|
||||
|
||||
// 기존 필드명도 유지 (하위 호환성)
|
||||
code_category: code.code_category,
|
||||
code_value: code.code_value,
|
||||
|
|
@ -81,6 +99,7 @@ export class CommonCodeController {
|
|||
code_name_eng: code.code_name_eng,
|
||||
sort_order: code.sort_order,
|
||||
is_active: code.is_active,
|
||||
company_code: code.company_code, // 추가
|
||||
created_date: code.created_date,
|
||||
created_by: code.created_by,
|
||||
updated_date: code.updated_date,
|
||||
|
|
@ -110,7 +129,8 @@ export class CommonCodeController {
|
|||
async createCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const categoryData: CreateCategoryData = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM"; // 인증 미들웨어에서 설정된 사용자 ID
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 입력값 검증
|
||||
if (!categoryData.categoryCode || !categoryData.categoryName) {
|
||||
|
|
@ -122,7 +142,8 @@ export class CommonCodeController {
|
|||
|
||||
const category = await this.commonCodeService.createCategory(
|
||||
categoryData,
|
||||
userId
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -135,7 +156,7 @@ export class CommonCodeController {
|
|||
|
||||
// PostgreSQL 에러 처리
|
||||
if (
|
||||
((error as any)?.code === "23505") || // PostgreSQL unique_violation
|
||||
(error as any)?.code === "23505" || // PostgreSQL unique_violation
|
||||
(error instanceof Error && error.message.includes("Unique constraint"))
|
||||
) {
|
||||
return res.status(409).json({
|
||||
|
|
@ -161,11 +182,13 @@ export class CommonCodeController {
|
|||
const { categoryCode } = req.params;
|
||||
const categoryData: Partial<CreateCategoryData> = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const category = await this.commonCodeService.updateCategory(
|
||||
categoryCode,
|
||||
categoryData,
|
||||
userId
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -201,8 +224,9 @@ export class CommonCodeController {
|
|||
async deleteCategory(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
await this.commonCodeService.deleteCategory(categoryCode);
|
||||
await this.commonCodeService.deleteCategory(categoryCode, companyCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -238,6 +262,7 @@ export class CommonCodeController {
|
|||
const { categoryCode } = req.params;
|
||||
const codeData: CreateCodeData = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 입력값 검증
|
||||
if (!codeData.codeValue || !codeData.codeName) {
|
||||
|
|
@ -250,7 +275,8 @@ export class CommonCodeController {
|
|||
const code = await this.commonCodeService.createCode(
|
||||
categoryCode,
|
||||
codeData,
|
||||
userId
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
|
|
@ -288,12 +314,14 @@ export class CommonCodeController {
|
|||
const { categoryCode, codeValue } = req.params;
|
||||
const codeData: Partial<CreateCodeData> = req.body;
|
||||
const userId = req.user?.userId || "SYSTEM";
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
const code = await this.commonCodeService.updateCode(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
codeData,
|
||||
userId
|
||||
userId,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -332,8 +360,13 @@ export class CommonCodeController {
|
|||
async deleteCode(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode, codeValue } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
await this.commonCodeService.deleteCode(categoryCode, codeValue);
|
||||
await this.commonCodeService.deleteCode(
|
||||
categoryCode,
|
||||
codeValue,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -370,8 +403,12 @@ export class CommonCodeController {
|
|||
async getCodeOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { categoryCode } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
const options = await this.commonCodeService.getCodeOptions(categoryCode);
|
||||
const options = await this.commonCodeService.getCodeOptions(
|
||||
categoryCode,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -383,6 +383,79 @@ export class DDLController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/:tableName - 테이블 삭제 (최고 관리자 전용)
|
||||
*/
|
||||
static async dropTable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
|
||||
// 입력값 기본 검증
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "테이블명이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("테이블 삭제 요청", {
|
||||
tableName,
|
||||
userId,
|
||||
userCompanyCode,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// DDL 실행 서비스 호출
|
||||
const ddlService = new DDLExecutionService();
|
||||
const result = await ddlService.dropTable(
|
||||
tableName,
|
||||
userCompanyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
tableName,
|
||||
executedQuery: result.executedQuery,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 삭제 컨트롤러 오류:", {
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
userId: req.user?.userId,
|
||||
tableName: req.params.tableName,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
details: "테이블 삭제 중 서버 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -551,6 +551,76 @@ export class FlowController {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우 스텝의 컬럼 라벨 조회
|
||||
*/
|
||||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
|
||||
const step = await this.flowStepService.getById(parseInt(stepId));
|
||||
if (!step) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Step not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.getById(
|
||||
parseInt(flowId)
|
||||
);
|
||||
if (!flowDef) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
if (!tableName) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// column_labels 테이블에서 라벨 정보 조회
|
||||
const { query } = await import("../config/database");
|
||||
const labelRows = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label
|
||||
FROM column_labels
|
||||
WHERE table_name = $1 AND column_label IS NOT NULL`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// { columnName: label } 형태의 객체로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
labelRows.forEach((row) => {
|
||||
if (row.column_label) {
|
||||
labels[row.column_name] = row.column_label;
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: labels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error getting step column labels:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to get step column labels",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 플로우의 모든 단계별 카운트 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -42,6 +42,18 @@ router.post(
|
|||
DDLController.addColumn
|
||||
);
|
||||
|
||||
/**
|
||||
* 테이블 삭제
|
||||
* DELETE /api/ddl/tables/:tableName
|
||||
*/
|
||||
router.delete(
|
||||
"/tables/:tableName",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
validateDDLPermission,
|
||||
DDLController.dropTable
|
||||
);
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||
* POST /api/ddl/validate/table
|
||||
|
|
@ -135,6 +147,7 @@ router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => {
|
|||
tables: {
|
||||
create: "POST /api/ddl/tables",
|
||||
addColumn: "POST /api/ddl/tables/:tableName/columns",
|
||||
drop: "DELETE /api/ddl/tables/:tableName",
|
||||
getInfo: "GET /api/ddl/tables/:tableName/info",
|
||||
getHistory: "GET /api/ddl/tables/:tableName/history",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ router.delete("/connections/:connectionId", flowController.deleteConnection);
|
|||
// ==================== 플로우 실행 ====================
|
||||
router.get("/:flowId/step/:stepId/count", flowController.getStepDataCount);
|
||||
router.get("/:flowId/step/:stepId/list", flowController.getStepDataList);
|
||||
router.get(
|
||||
"/:flowId/step/:stepId/column-labels",
|
||||
flowController.getStepColumnLabels
|
||||
);
|
||||
router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
||||
|
||||
// ==================== 데이터 이동 ====================
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export class AdminService {
|
|||
|
||||
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
||||
let authFilter = "";
|
||||
let unionFilter = ""; // UNION ALL의 하위 메뉴 필터
|
||||
let queryParams: any[] = [userLang];
|
||||
let paramIndex = 2;
|
||||
|
||||
|
|
@ -51,17 +52,36 @@ export class AdminService {
|
|||
if (userRoleGroups.length > 0) {
|
||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
|
||||
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
||||
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
|
||||
queryParams.push(userCompanyCode);
|
||||
const companyParamIndex = paramIndex;
|
||||
paramIndex++;
|
||||
|
||||
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
||||
unionFilter = `
|
||||
AND (
|
||||
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
|
||||
OR (
|
||||
MENU_SUB.COMPANY_CODE = '*'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
queryParams.push(roleObjids);
|
||||
paramIndex += 2;
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
|
||||
);
|
||||
} else {
|
||||
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
|
||||
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
|
||||
queryParams.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
|
|
@ -81,6 +101,15 @@ export class AdminService {
|
|||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
`;
|
||||
unionFilter = `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
`;
|
||||
queryParams.push(roleObjids);
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
|
|
@ -97,6 +126,8 @@ export class AdminService {
|
|||
} else if (menuType !== undefined && userType === "SUPER_ADMIN") {
|
||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
|
||||
}
|
||||
|
||||
// 2. 회사별 필터링 조건 생성
|
||||
|
|
@ -274,19 +305,7 @@ export class AdminService {
|
|||
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
|
||||
WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH)
|
||||
AND MENU_SUB.STATUS = 'active'
|
||||
AND (
|
||||
MENU_SUB.COMPANY_CODE = $2
|
||||
OR (
|
||||
MENU_SUB.COMPANY_CODE = '*'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||
AND rma.auth_objid = ANY($3)
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
)
|
||||
)
|
||||
${unionFilter}
|
||||
)
|
||||
SELECT
|
||||
LEVEL AS LEV,
|
||||
|
|
@ -347,66 +366,82 @@ export class AdminService {
|
|||
|
||||
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
|
||||
|
||||
// 1. 사용자가 속한 권한 그룹 조회
|
||||
const userRoleGroups = await query<any>(
|
||||
`
|
||||
SELECT DISTINCT am.objid AS role_objid, am.auth_name
|
||||
FROM authority_master am
|
||||
JOIN authority_sub_user asu ON am.objid = asu.master_objid
|
||||
WHERE asu.user_id = $1
|
||||
AND am.status = 'active'
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`,
|
||||
{
|
||||
roleGroups: userRoleGroups.map((rg: any) => rg.auth_name),
|
||||
}
|
||||
);
|
||||
|
||||
// 2. 권한 그룹 기반 메뉴 필터 조건 생성
|
||||
// 1. 권한 그룹 기반 필터링 (SUPER_ADMIN은 제외)
|
||||
let authFilter = "";
|
||||
let unionFilter = "";
|
||||
let queryParams: any[] = [userLang];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (userRoleGroups.length > 0) {
|
||||
// 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링
|
||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||
authFilter = `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
`;
|
||||
queryParams.push(roleObjids);
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
`✅ 권한 그룹 기반 메뉴 필터링 적용: ${roleObjids.length}개 그룹`
|
||||
);
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
|
||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
} else {
|
||||
// 권한 그룹이 없는 경우: 메뉴 없음
|
||||
logger.warn(
|
||||
`⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
||||
// 일반 사용자 / 회사 관리자: 권한 그룹 조회 필요
|
||||
const userRoleGroups = await query<any>(
|
||||
`
|
||||
SELECT DISTINCT am.objid AS role_objid, am.auth_name
|
||||
FROM authority_master am
|
||||
JOIN authority_sub_user asu ON am.objid = asu.master_objid
|
||||
WHERE asu.user_id = $1
|
||||
AND am.status = 'active'
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
return [];
|
||||
|
||||
logger.info(
|
||||
`✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`,
|
||||
{
|
||||
roleGroups: userRoleGroups.map((rg: any) => rg.auth_name),
|
||||
}
|
||||
);
|
||||
|
||||
if (userRoleGroups.length > 0) {
|
||||
// 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링
|
||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||
authFilter = `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
`;
|
||||
unionFilter = `
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
`;
|
||||
queryParams.push(roleObjids);
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
`✅ 권한 그룹 기반 메뉴 필터링 적용: ${roleObjids.length}개 그룹`
|
||||
);
|
||||
} else {
|
||||
// 권한 그룹이 없는 경우: 메뉴 없음
|
||||
logger.warn(
|
||||
`⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 회사별 필터링 조건 생성
|
||||
// 2. 회사별 필터링 조건 생성
|
||||
let companyFilter = "";
|
||||
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
// SUPER_ADMIN: 공통 메뉴만 (company_code = '*')
|
||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
|
||||
} else {
|
||||
// COMPANY_ADMIN/USER: 자기 회사 메뉴만
|
||||
logger.info(
|
||||
`✅ 좌측 사이드바 (COMPANY_ADMIN): 회사 ${userCompanyCode} 메뉴만 표시`
|
||||
`✅ 좌측 사이드바 (COMPANY_ADMIN/USER): 회사 ${userCompanyCode} 메뉴만 표시`
|
||||
);
|
||||
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||
queryParams.push(userCompanyCode);
|
||||
|
|
@ -480,7 +515,7 @@ export class AdminService {
|
|||
FROM MENU_INFO MENU_SUB
|
||||
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
|
||||
WHERE MENU_SUB.STATUS = 'active'
|
||||
${authFilter.replace(/MENU\.OBJID/g, "MENU_SUB.OBJID")}
|
||||
${unionFilter}
|
||||
)
|
||||
SELECT
|
||||
LEVEL AS LEV,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface CodeCategory {
|
|||
description?: string | null;
|
||||
sort_order: number;
|
||||
is_active: string;
|
||||
company_code: string; // 추가
|
||||
created_date?: Date | null;
|
||||
created_by?: string | null;
|
||||
updated_date?: Date | null;
|
||||
|
|
@ -22,6 +23,7 @@ export interface CodeInfo {
|
|||
description?: string | null;
|
||||
sort_order: number;
|
||||
is_active: string;
|
||||
company_code: string; // 추가
|
||||
created_date?: Date | null;
|
||||
created_by?: string | null;
|
||||
updated_date?: Date | null;
|
||||
|
|
@ -64,7 +66,7 @@ export class CommonCodeService {
|
|||
/**
|
||||
* 카테고리 목록 조회
|
||||
*/
|
||||
async getCategories(params: GetCategoriesParams) {
|
||||
async getCategories(params: GetCategoriesParams, userCompanyCode?: string) {
|
||||
try {
|
||||
const { search, isActive, page = 1, size = 20 } = params;
|
||||
|
||||
|
|
@ -72,6 +74,17 @@ export class CommonCodeService {
|
|||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
values.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
||||
} else if (userCompanyCode === "*") {
|
||||
// 최고 관리자는 모든 데이터 조회 가능
|
||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(
|
||||
`(category_name ILIKE $${paramIndex} OR category_code ILIKE $${paramIndex})`
|
||||
|
|
@ -110,7 +123,7 @@ export class CommonCodeService {
|
|||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
logger.info(
|
||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개`
|
||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -126,7 +139,11 @@ export class CommonCodeService {
|
|||
/**
|
||||
* 카테고리별 코드 목록 조회
|
||||
*/
|
||||
async getCodes(categoryCode: string, params: GetCodesParams) {
|
||||
async getCodes(
|
||||
categoryCode: string,
|
||||
params: GetCodesParams,
|
||||
userCompanyCode?: string
|
||||
) {
|
||||
try {
|
||||
const { search, isActive, page = 1, size = 20 } = params;
|
||||
|
||||
|
|
@ -134,6 +151,16 @@ export class CommonCodeService {
|
|||
const values: any[] = [categoryCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
values.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(`회사별 코드 필터링: ${userCompanyCode}`);
|
||||
} else if (userCompanyCode === "*") {
|
||||
logger.info(`최고 관리자: 모든 코드 조회`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(
|
||||
`(code_name ILIKE $${paramIndex} OR code_value ILIKE $${paramIndex})`
|
||||
|
|
@ -169,7 +196,7 @@ export class CommonCodeService {
|
|||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
logger.info(
|
||||
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개`
|
||||
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||
);
|
||||
|
||||
return { data: codes, total };
|
||||
|
|
@ -182,13 +209,17 @@ export class CommonCodeService {
|
|||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
async createCategory(data: CreateCategoryData, createdBy: string) {
|
||||
async createCategory(
|
||||
data: CreateCategoryData,
|
||||
createdBy: string,
|
||||
companyCode: string
|
||||
) {
|
||||
try {
|
||||
const category = await queryOne<CodeCategory>(
|
||||
`INSERT INTO code_category
|
||||
(category_code, category_name, category_name_eng, description, sort_order,
|
||||
is_active, created_by, updated_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, NOW(), NOW())
|
||||
is_active, company_code, created_by, updated_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.categoryCode,
|
||||
|
|
@ -196,12 +227,15 @@ export class CommonCodeService {
|
|||
data.categoryNameEng || null,
|
||||
data.description || null,
|
||||
data.sortOrder || 0,
|
||||
companyCode,
|
||||
createdBy,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`카테고리 생성 완료: ${data.categoryCode}`);
|
||||
logger.info(
|
||||
`카테고리 생성 완료: ${data.categoryCode} (회사: ${companyCode})`
|
||||
);
|
||||
return category;
|
||||
} catch (error) {
|
||||
logger.error("카테고리 생성 중 오류:", error);
|
||||
|
|
@ -215,11 +249,12 @@ export class CommonCodeService {
|
|||
async updateCategory(
|
||||
categoryCode: string,
|
||||
data: Partial<CreateCategoryData>,
|
||||
updatedBy: string
|
||||
updatedBy: string,
|
||||
companyCode?: string
|
||||
) {
|
||||
try {
|
||||
// 디버깅: 받은 데이터 로그
|
||||
logger.info(`카테고리 수정 데이터:`, { categoryCode, data });
|
||||
logger.info(`카테고리 수정 데이터:`, { categoryCode, data, companyCode });
|
||||
|
||||
// 동적 UPDATE 쿼리 생성
|
||||
const updateFields: string[] = [
|
||||
|
|
@ -256,15 +291,28 @@ export class CommonCodeService {
|
|||
values.push(activeValue);
|
||||
}
|
||||
|
||||
// WHERE 절 구성
|
||||
let whereClause = `WHERE category_code = $${paramIndex}`;
|
||||
values.push(categoryCode);
|
||||
|
||||
// 회사 필터링 (최고 관리자가 아닌 경우)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
paramIndex++;
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
values.push(companyCode);
|
||||
}
|
||||
|
||||
const category = await queryOne<CodeCategory>(
|
||||
`UPDATE code_category
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE category_code = $${paramIndex}
|
||||
${whereClause}
|
||||
RETURNING *`,
|
||||
[...values, categoryCode]
|
||||
values
|
||||
);
|
||||
|
||||
logger.info(`카테고리 수정 완료: ${categoryCode}`);
|
||||
logger.info(
|
||||
`카테고리 수정 완료: ${categoryCode} (회사: ${companyCode || "전체"})`
|
||||
);
|
||||
return category;
|
||||
} catch (error) {
|
||||
logger.error(`카테고리 수정 중 오류 (${categoryCode}):`, error);
|
||||
|
|
@ -275,13 +323,22 @@ export class CommonCodeService {
|
|||
/**
|
||||
* 카테고리 삭제
|
||||
*/
|
||||
async deleteCategory(categoryCode: string) {
|
||||
async deleteCategory(categoryCode: string, companyCode?: string) {
|
||||
try {
|
||||
await query(`DELETE FROM code_category WHERE category_code = $1`, [
|
||||
categoryCode,
|
||||
]);
|
||||
let sql = `DELETE FROM code_category WHERE category_code = $1`;
|
||||
const values: any[] = [categoryCode];
|
||||
|
||||
logger.info(`카테고리 삭제 완료: ${categoryCode}`);
|
||||
// 회사 필터링 (최고 관리자가 아닌 경우)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
values.push(companyCode);
|
||||
}
|
||||
|
||||
await query(sql, values);
|
||||
|
||||
logger.info(
|
||||
`카테고리 삭제 완료: ${categoryCode} (회사: ${companyCode || "전체"})`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`카테고리 삭제 중 오류 (${categoryCode}):`, error);
|
||||
throw error;
|
||||
|
|
@ -294,14 +351,15 @@ export class CommonCodeService {
|
|||
async createCode(
|
||||
categoryCode: string,
|
||||
data: CreateCodeData,
|
||||
createdBy: string
|
||||
createdBy: string,
|
||||
companyCode: string
|
||||
) {
|
||||
try {
|
||||
const code = await queryOne<CodeInfo>(
|
||||
`INSERT INTO code_info
|
||||
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
||||
is_active, created_by, updated_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, NOW(), NOW())
|
||||
is_active, company_code, created_by, updated_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
categoryCode,
|
||||
|
|
@ -310,12 +368,15 @@ export class CommonCodeService {
|
|||
data.codeNameEng || null,
|
||||
data.description || null,
|
||||
data.sortOrder || 0,
|
||||
companyCode,
|
||||
createdBy,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`);
|
||||
logger.info(
|
||||
`코드 생성 완료: ${categoryCode}.${data.codeValue} (회사: ${companyCode})`
|
||||
);
|
||||
return code;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
@ -333,11 +394,17 @@ export class CommonCodeService {
|
|||
categoryCode: string,
|
||||
codeValue: string,
|
||||
data: Partial<CreateCodeData>,
|
||||
updatedBy: string
|
||||
updatedBy: string,
|
||||
companyCode?: string
|
||||
) {
|
||||
try {
|
||||
// 디버깅: 받은 데이터 로그
|
||||
logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data });
|
||||
logger.info(`코드 수정 데이터:`, {
|
||||
categoryCode,
|
||||
codeValue,
|
||||
data,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 동적 UPDATE 쿼리 생성
|
||||
const updateFields: string[] = [
|
||||
|
|
@ -374,15 +441,28 @@ export class CommonCodeService {
|
|||
values.push(activeValue);
|
||||
}
|
||||
|
||||
// WHERE 절 구성
|
||||
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
|
||||
values.push(categoryCode, codeValue);
|
||||
|
||||
// 회사 필터링 (최고 관리자가 아닌 경우)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
paramIndex++;
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
values.push(companyCode);
|
||||
}
|
||||
|
||||
const code = await queryOne<CodeInfo>(
|
||||
`UPDATE code_info
|
||||
SET ${updateFields.join(", ")}
|
||||
WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}
|
||||
${whereClause}
|
||||
RETURNING *`,
|
||||
[...values, categoryCode, codeValue]
|
||||
values
|
||||
);
|
||||
|
||||
logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`);
|
||||
logger.info(
|
||||
`코드 수정 완료: ${categoryCode}.${codeValue} (회사: ${companyCode || "전체"})`
|
||||
);
|
||||
return code;
|
||||
} catch (error) {
|
||||
logger.error(`코드 수정 중 오류 (${categoryCode}.${codeValue}):`, error);
|
||||
|
|
@ -393,14 +473,26 @@ export class CommonCodeService {
|
|||
/**
|
||||
* 코드 삭제
|
||||
*/
|
||||
async deleteCode(categoryCode: string, codeValue: string) {
|
||||
async deleteCode(
|
||||
categoryCode: string,
|
||||
codeValue: string,
|
||||
companyCode?: string
|
||||
) {
|
||||
try {
|
||||
await query(
|
||||
`DELETE FROM code_info WHERE code_category = $1 AND code_value = $2`,
|
||||
[categoryCode, codeValue]
|
||||
);
|
||||
let sql = `DELETE FROM code_info WHERE code_category = $1 AND code_value = $2`;
|
||||
const values: any[] = [categoryCode, codeValue];
|
||||
|
||||
logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`);
|
||||
// 회사 필터링 (최고 관리자가 아닌 경우)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
sql += ` AND company_code = $3`;
|
||||
values.push(companyCode);
|
||||
}
|
||||
|
||||
await query(sql, values);
|
||||
|
||||
logger.info(
|
||||
`코드 삭제 완료: ${categoryCode}.${codeValue} (회사: ${companyCode || "전체"})`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`코드 삭제 중 오류 (${categoryCode}.${codeValue}):`, error);
|
||||
throw error;
|
||||
|
|
@ -410,20 +502,30 @@ export class CommonCodeService {
|
|||
/**
|
||||
* 카테고리별 옵션 조회 (화면관리용)
|
||||
*/
|
||||
async getCodeOptions(categoryCode: string) {
|
||||
async getCodeOptions(categoryCode: string, userCompanyCode?: string) {
|
||||
try {
|
||||
let sql = `SELECT code_value, code_name, code_name_eng, sort_order
|
||||
FROM code_info
|
||||
WHERE code_category = $1 AND is_active = 'Y'`;
|
||||
const values: any[] = [categoryCode];
|
||||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
sql += ` AND company_code = $2`;
|
||||
values.push(userCompanyCode);
|
||||
logger.info(`회사별 코드 옵션 필터링: ${userCompanyCode}`);
|
||||
} else if (userCompanyCode === "*") {
|
||||
logger.info(`최고 관리자: 모든 코드 옵션 조회`);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY sort_order ASC, code_value ASC`;
|
||||
|
||||
const codes = await query<{
|
||||
code_value: string;
|
||||
code_name: string;
|
||||
code_name_eng: string | null;
|
||||
sort_order: number;
|
||||
}>(
|
||||
`SELECT code_value, code_name, code_name_eng, sort_order
|
||||
FROM code_info
|
||||
WHERE code_category = $1 AND is_active = 'Y'
|
||||
ORDER BY sort_order ASC, code_value ASC`,
|
||||
[categoryCode]
|
||||
);
|
||||
}>(sql, values);
|
||||
|
||||
const options = codes.map((code) => ({
|
||||
value: code.code_value,
|
||||
|
|
@ -431,7 +533,9 @@ export class CommonCodeService {
|
|||
labelEng: code.code_name_eng,
|
||||
}));
|
||||
|
||||
logger.info(`코드 옵션 조회 완료: ${categoryCode} - ${options.length}개`);
|
||||
logger.info(
|
||||
`코드 옵션 조회 완료: ${categoryCode} - ${options.length}개 (회사: ${userCompanyCode || "전체"})`
|
||||
);
|
||||
return options;
|
||||
} catch (error) {
|
||||
logger.error(`코드 옵션 조회 중 오류 (${categoryCode}):`, error);
|
||||
|
|
|
|||
|
|
@ -759,6 +759,124 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 삭제 (DROP TABLE)
|
||||
*/
|
||||
async dropTable(
|
||||
tableName: string,
|
||||
userCompanyCode: string,
|
||||
userId: string
|
||||
): Promise<DDLExecutionResult> {
|
||||
// DDL 실행 시작 로그
|
||||
await DDLAuditLogger.logDDLStart(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"DROP_TABLE",
|
||||
tableName,
|
||||
{}
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. 권한 검증 (최고 관리자만 가능)
|
||||
this.validateSuperAdminPermission(userCompanyCode);
|
||||
|
||||
// 2. 테이블 존재 여부 확인
|
||||
const tableExists = await this.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`;
|
||||
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"DROP_TABLE",
|
||||
tableName,
|
||||
"TABLE_NOT_FOUND",
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "TABLE_NOT_FOUND",
|
||||
details: errorMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3. DDL 쿼리 생성
|
||||
const ddlQuery = `DROP TABLE IF EXISTS "${tableName}" CASCADE`;
|
||||
|
||||
// 4. 트랜잭션으로 안전하게 실행
|
||||
await transaction(async (client) => {
|
||||
// 4-1. 테이블 삭제
|
||||
await client.query(ddlQuery);
|
||||
|
||||
// 4-2. 관련 메타데이터 삭제
|
||||
await client.query(`DELETE FROM column_labels WHERE table_name = $1`, [
|
||||
tableName,
|
||||
]);
|
||||
await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [
|
||||
tableName,
|
||||
]);
|
||||
});
|
||||
|
||||
// 5. 성공 로그 기록
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"DROP_TABLE",
|
||||
tableName,
|
||||
ddlQuery,
|
||||
true
|
||||
);
|
||||
|
||||
logger.info("테이블 삭제 성공", {
|
||||
tableName,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 테이블 삭제 후 관련 캐시 무효화
|
||||
this.invalidateTableCache(tableName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `테이블 '${tableName}'이 성공적으로 삭제되었습니다.`,
|
||||
executedQuery: ddlQuery,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = `테이블 삭제 실패: ${(error as Error).message}`;
|
||||
|
||||
// 실패 로그 기록
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"DROP_TABLE",
|
||||
tableName,
|
||||
`FAILED: ${(error as Error).message}`,
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
logger.error("테이블 삭제 실패:", {
|
||||
tableName,
|
||||
userId,
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "EXECUTION_FAILED",
|
||||
details: (error as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 관련 캐시 무효화
|
||||
* DDL 작업 후 호출하여 캐시된 데이터를 클리어
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Search, Database, RefreshCw, Settings, Plus, Activity } from "lucide-react";
|
||||
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2 } from "lucide-react";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
|
|
@ -15,11 +15,20 @@ import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
|
|||
import { apiClient } from "@/lib/api/client";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
import { TableLogViewer } from "@/components/admin/TableLogViewer";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
|
|
@ -79,6 +88,11 @@ export default function TableManagementPage() {
|
|||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
||||
|
||||
// 테이블 삭제 확인 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
|
||||
const isSuperAdmin = user?.companyCode === "*";
|
||||
|
||||
|
|
@ -523,7 +537,7 @@ export default function TableManagementPage() {
|
|||
useEffect(() => {
|
||||
if (columns.length > 0) {
|
||||
const entityColumns = columns.filter(
|
||||
(col) => col.webType === "entity" && col.referenceTable && col.referenceTable !== "none",
|
||||
(col) => col.inputType === "entity" && col.referenceTable && col.referenceTable !== "none",
|
||||
);
|
||||
|
||||
entityColumns.forEach((col) => {
|
||||
|
|
@ -543,6 +557,43 @@ export default function TableManagementPage() {
|
|||
}
|
||||
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
||||
|
||||
// 테이블 삭제 확인
|
||||
const handleDeleteTableClick = (tableName: string) => {
|
||||
setTableToDelete(tableName);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 테이블 삭제 실행
|
||||
const handleDeleteTable = async () => {
|
||||
if (!tableToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await ddlApi.dropTable(tableToDelete);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`테이블 '${tableToDelete}'이 성공적으로 삭제되었습니다.`);
|
||||
|
||||
// 삭제된 테이블이 선택된 테이블이었다면 선택 해제
|
||||
if (selectedTable === tableToDelete) {
|
||||
setSelectedTable(null);
|
||||
setColumns([]);
|
||||
}
|
||||
|
||||
// 테이블 목록 새로고침
|
||||
await loadTables();
|
||||
} else {
|
||||
toast.error(result.message || "테이블 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "테이블 삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setTableToDelete("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
|
|
@ -656,21 +707,40 @@ export default function TableManagementPage() {
|
|||
.map((table) => (
|
||||
<div
|
||||
key={table.tableName}
|
||||
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${
|
||||
className={`bg-card rounded-lg border p-4 shadow-sm transition-all ${
|
||||
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md"
|
||||
}`}
|
||||
onClick={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<span className="text-muted-foreground text-xs">컬럼</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{table.columnCount}
|
||||
</Badge>
|
||||
<div className="cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
|
||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<span className="text-muted-foreground text-xs">컬럼</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{table.columnCount}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 (최고 관리자만) */}
|
||||
{isSuperAdmin && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 w-full gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteTableClick(table.tableName);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
테이블 삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
@ -1000,6 +1070,57 @@ export default function TableManagementPage() {
|
|||
|
||||
{/* 테이블 로그 뷰어 */}
|
||||
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
|
||||
|
||||
{/* 테이블 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<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="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<p className="text-destructive text-sm font-semibold">경고</p>
|
||||
<p className="text-destructive/80 mt-1.5 text-sm">
|
||||
테이블 <span className="font-mono font-bold">{tableToDelete}</span>과 모든 데이터가 영구적으로
|
||||
삭제됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={isDeleting}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteTable}
|
||||
disabled={isDeleting}
|
||||
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,28 +4,20 @@ import React, { useEffect, useState } from "react";
|
|||
import { FlowComponent } from "@/types/screen-management";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react";
|
||||
import { AlertCircle, Loader2, ChevronUp } from "lucide-react";
|
||||
import {
|
||||
getFlowById,
|
||||
getAllStepCounts,
|
||||
getStepDataList,
|
||||
getFlowAuditLogs,
|
||||
getFlowSteps,
|
||||
getFlowConnections,
|
||||
getStepColumnLabels,
|
||||
} from "@/lib/api/flow";
|
||||
import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow";
|
||||
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
|
|
@ -68,6 +60,7 @@ export function FlowWidget({
|
|||
const [stepDataColumns, setStepDataColumns] = useState<string[]>([]);
|
||||
const [stepDataLoading, setStepDataLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||
|
||||
/**
|
||||
* 🆕 컬럼 표시 결정 함수
|
||||
|
|
@ -93,13 +86,6 @@ export function FlowWidget({
|
|||
const [stepDataPage, setStepDataPage] = useState(1);
|
||||
const [stepDataPageSize, setStepDataPageSize] = useState(10);
|
||||
|
||||
// 오딧 로그 상태
|
||||
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
|
||||
const [auditLogsLoading, setAuditLogsLoading] = useState(false);
|
||||
const [showAuditLogs, setShowAuditLogs] = useState(false);
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [auditPageSize] = useState(10);
|
||||
|
||||
// componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨)
|
||||
const config = (component as any).componentConfig || (component as any).config || {};
|
||||
const flowId = config.flowId || component.flowId;
|
||||
|
|
@ -139,6 +125,12 @@ export function FlowWidget({
|
|||
if (selectedStepId) {
|
||||
setStepDataLoading(true);
|
||||
|
||||
// 컬럼 라벨 조회
|
||||
const labelsResponse = await getStepColumnLabels(flowId, selectedStepId);
|
||||
if (labelsResponse.success && labelsResponse.data) {
|
||||
setColumnLabels(labelsResponse.data);
|
||||
}
|
||||
|
||||
const response = await getStepDataList(flowId, selectedStepId, 1, 100);
|
||||
|
||||
if (!response.success) {
|
||||
|
|
@ -226,6 +218,12 @@ export function FlowWidget({
|
|||
|
||||
// 첫 번째 스텝의 데이터 로드
|
||||
try {
|
||||
// 컬럼 라벨 조회
|
||||
const labelsResponse = await getStepColumnLabels(flowId!, firstStep.id);
|
||||
if (labelsResponse.success && labelsResponse.data) {
|
||||
setColumnLabels(labelsResponse.data);
|
||||
}
|
||||
|
||||
const response = await getStepDataList(flowId!, firstStep.id, 1, 100);
|
||||
if (response.success) {
|
||||
const rows = response.data?.records || [];
|
||||
|
|
@ -297,6 +295,15 @@ export function FlowWidget({
|
|||
onSelectedDataChange?.([], stepId);
|
||||
|
||||
try {
|
||||
// 컬럼 라벨 조회
|
||||
const labelsResponse = await getStepColumnLabels(flowId!, stepId);
|
||||
if (labelsResponse.success && labelsResponse.data) {
|
||||
setColumnLabels(labelsResponse.data);
|
||||
} else {
|
||||
setColumnLabels({});
|
||||
}
|
||||
|
||||
// 데이터 조회
|
||||
const response = await getStepDataList(flowId!, stepId, 1, 100);
|
||||
|
||||
if (!response.success) {
|
||||
|
|
@ -359,35 +366,6 @@ export function FlowWidget({
|
|||
onSelectedDataChange?.(selectedData, selectedStepId);
|
||||
};
|
||||
|
||||
// 오딧 로그 로드
|
||||
const loadAuditLogs = async () => {
|
||||
if (!flowId) return;
|
||||
|
||||
try {
|
||||
setAuditLogsLoading(true);
|
||||
const response = await getFlowAuditLogs(flowId, 100); // 최근 100개
|
||||
if (response.success && response.data) {
|
||||
setAuditLogs(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load audit logs:", err);
|
||||
toast.error("이력 조회 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setAuditLogsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 오딧 로그 모달 열기
|
||||
const handleOpenAuditLogs = () => {
|
||||
setShowAuditLogs(true);
|
||||
setAuditPage(1); // 페이지 초기화
|
||||
loadAuditLogs();
|
||||
};
|
||||
|
||||
// 페이지네이션된 오딧 로그
|
||||
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
|
||||
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
|
||||
|
||||
// 🆕 페이지네이션된 스텝 데이터
|
||||
const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
|
||||
const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize);
|
||||
|
|
@ -438,188 +416,6 @@ export function FlowWidget({
|
|||
<div className="mb-3 flex-shrink-0 sm:mb-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
|
||||
|
||||
{/* 오딧 로그 버튼 */}
|
||||
<Dialog open={showAuditLogs} onOpenChange={setShowAuditLogs}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenAuditLogs} className="gap-1.5">
|
||||
<History className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">변경 이력</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] max-w-[95vw] sm:max-w-[1000px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>플로우 변경 이력</DialogTitle>
|
||||
<DialogDescription>데이터 이동 및 상태 변경 기록 (총 {auditLogs.length}건)</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{auditLogsLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">이력 로딩 중...</span>
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">변경 이력이 없습니다</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 테이블 */}
|
||||
<div className="bg-card overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[140px]">변경일시</TableHead>
|
||||
<TableHead className="w-[80px]">타입</TableHead>
|
||||
<TableHead className="w-[120px]">출발 단계</TableHead>
|
||||
<TableHead className="w-[120px]">도착 단계</TableHead>
|
||||
<TableHead className="w-[100px]">데이터 ID</TableHead>
|
||||
<TableHead className="w-[140px]">상태 변경</TableHead>
|
||||
<TableHead className="w-[100px]">변경자</TableHead>
|
||||
<TableHead className="w-[150px]">DB 연결</TableHead>
|
||||
<TableHead>테이블</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedAuditLogs.map((log) => {
|
||||
const fromStep = steps.find((s) => s.id === log.fromStepId);
|
||||
const toStep = steps.find((s) => s.id === log.toStepId);
|
||||
|
||||
return (
|
||||
<TableRow key={log.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-xs">
|
||||
{new Date(log.changedAt).toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{log.moveType === "status"
|
||||
? "상태"
|
||||
: log.moveType === "table"
|
||||
? "테이블"
|
||||
: "하이브리드"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{fromStep?.stepName || `Step ${log.fromStepId}`}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{toStep?.stepName || `Step ${log.toStepId}`}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{log.sourceDataId || "-"}
|
||||
{log.targetDataId && log.targetDataId !== log.sourceDataId && (
|
||||
<>
|
||||
<br />→ {log.targetDataId}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{log.statusFrom && log.statusTo ? (
|
||||
<span className="font-mono">
|
||||
{log.statusFrom}
|
||||
<br />→ {log.statusTo}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{log.changedBy}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{log.dbConnectionName ? (
|
||||
<span
|
||||
className={
|
||||
log.dbConnectionName === "내부 데이터베이스"
|
||||
? "text-blue-600"
|
||||
: "text-green-600"
|
||||
}
|
||||
>
|
||||
{log.dbConnectionName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{log.sourceTable || "-"}
|
||||
{log.targetTable && log.targetTable !== log.sourceTable && (
|
||||
<>
|
||||
<br />→ {log.targetTable}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalAuditPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "}
|
||||
{auditLogs.length}건
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
|
||||
className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalAuditPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
// 현재 페이지 주변만 표시
|
||||
return (
|
||||
page === 1 ||
|
||||
page === totalAuditPages ||
|
||||
(page >= auditPage - 1 && page <= auditPage + 1)
|
||||
);
|
||||
})
|
||||
.map((page, idx, arr) => (
|
||||
<React.Fragment key={page}>
|
||||
{idx > 0 && arr[idx - 1] !== page - 1 && (
|
||||
<PaginationItem>
|
||||
<span className="text-muted-foreground px-2">...</span>
|
||||
</PaginationItem>
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
onClick={() => setAuditPage(page)}
|
||||
isActive={auditPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
|
||||
className={
|
||||
auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{flowData.description && (
|
||||
|
|
@ -758,7 +554,7 @@ export function FlowWidget({
|
|||
<div className="space-y-1.5">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground font-medium">{col}:</span>
|
||||
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
||||
<span className="text-foreground truncate">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
|
|
@ -793,7 +589,7 @@ export function FlowWidget({
|
|||
key={col}
|
||||
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
{columnLabels[col] || col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ export const ddlApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 테이블 삭제 (DROP TABLE)
|
||||
*/
|
||||
dropTable: async (tableName: string): Promise<DDLExecutionResult> => {
|
||||
const response = await apiClient.delete(`/ddl/tables/${tableName}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -384,6 +384,28 @@ export async function getStepDataList(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 스텝의 컬럼 라벨 조회
|
||||
*/
|
||||
export async function getStepColumnLabels(
|
||||
flowId: number,
|
||||
stepId: number,
|
||||
): Promise<ApiResponse<Record<string, string>>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/column-labels`, {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 단계의 데이터 카운트 조회
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue