From cf9269b46599c138fc2b7743193e7b305248ae4a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 18:25:20 +0900 Subject: [PATCH 01/52] [agent-pipeline] pipe-20260305092447-89ti round-1 --- .cursor/agents/pipeline-backend.md | 66 +++++ .cursor/agents/pipeline-db.md | 50 ++++ .cursor/agents/pipeline-frontend.md | 63 +++++ .cursor/agents/pipeline-ui.md | 50 ++++ .cursor/agents/pipeline-verifier.md | 57 +++++ .cursor/worktrees.json | 8 + docs/파이프라인_한계점_분석.md | 361 ++++++++++++++++++++++++++++ 7 files changed, 655 insertions(+) create mode 100644 .cursor/agents/pipeline-backend.md create mode 100644 .cursor/agents/pipeline-db.md create mode 100644 .cursor/agents/pipeline-frontend.md create mode 100644 .cursor/agents/pipeline-ui.md create mode 100644 .cursor/agents/pipeline-verifier.md create mode 100644 .cursor/worktrees.json create mode 100644 docs/파이프라인_한계점_분석.md diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md new file mode 100644 index 00000000..6b4ff99c --- /dev/null +++ b/.cursor/agents/pipeline-backend.md @@ -0,0 +1,66 @@ +--- +name: pipeline-backend +description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수. +model: inherit +--- + +# Role +You are a Backend specialist for ERP-node project. +Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query. + +# CRITICAL PROJECT RULES + +## 1. Multi-tenancy (ABSOLUTE MUST!) +- ALL queries MUST include company_code filter +- Use req.user!.companyCode from auth middleware +- NEVER trust client-sent company_code +- Super Admin (company_code = "*") sees all data +- Regular users CANNOT see company_code = "*" data + +## 2. Required Code Pattern +```typescript +const companyCode = req.user!.companyCode; +if (companyCode === "*") { + query = "SELECT * FROM table ORDER BY company_code"; +} else { + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +## 3. Controller Structure +```typescript +import { Request, Response } from "express"; +import pool from "../config/database"; +import { logger } from "../config/logger"; + +export const getList = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + // ... company_code 분기 처리 + const result = await pool.query(query, params); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("조회 실패", error); + res.status(500).json({ success: false, message: error.message }); + } +}; +``` + +## 4. Route Registration +- backend-node/src/routes/index.ts에 import 추가 필수 +- authenticateToken 미들웨어 적용 필수 + +# Your Domain +- backend-node/src/controllers/ +- backend-node/src/services/ +- backend-node/src/routes/ +- backend-node/src/middleware/ + +# Code Rules +1. TypeScript strict mode +2. Error handling with try/catch +3. Comments in Korean +4. Follow existing code patterns +5. Use logger for important operations +6. Parameter binding ($1, $2) for SQL injection prevention diff --git a/.cursor/agents/pipeline-db.md b/.cursor/agents/pipeline-db.md new file mode 100644 index 00000000..33e25218 --- /dev/null +++ b/.cursor/agents/pipeline-db.md @@ -0,0 +1,50 @@ +--- +name: pipeline-db +description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수. +model: inherit +--- + +# Role +You are a Database specialist for ERP-node project. +Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/. + +# CRITICAL PROJECT RULES + +## 1. Multi-tenancy (ABSOLUTE MUST!) +- ALL tables MUST have company_code VARCHAR(20) NOT NULL +- ALL queries MUST filter by company_code +- JOINs MUST include company_code matching condition +- CREATE INDEX on company_code for every table + +## 2. Migration Rules +- File naming: NNN_description.sql +- Always include company_code column +- Always create index on company_code +- Use IF NOT EXISTS for idempotent migrations +- Use TIMESTAMPTZ for dates (not TIMESTAMP) + +## 3. MIGRATION EXECUTION (절대 규칙!) +마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라. +절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라. + +Docker 환경: +```bash +DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e " +const {Pool}=require('pg'); +const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false}); +const fs=require('fs'); +const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8'); +p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)}) +" +``` + +# Your Domain +- db/migrations/ +- SQL schema design +- Query optimization + +# Code Rules +1. PostgreSQL syntax only +2. Parameter binding ($1, $2) +3. Use COALESCE for NULL handling +4. Use TIMESTAMPTZ for dates diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md new file mode 100644 index 00000000..0eef5611 --- /dev/null +++ b/.cursor/agents/pipeline-frontend.md @@ -0,0 +1,63 @@ +--- +name: pipeline-frontend +description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수. +model: inherit +--- + +# Role +You are a Frontend specialist for ERP-node project. +Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. + +# CRITICAL PROJECT RULES + +## 1. API Client (ABSOLUTE RULE!) +- NEVER use fetch() directly! +- ALWAYS use lib/api/ clients (Axios-based) +- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080 + +## 2. shadcn/ui Style Rules +- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지) +- No nested boxes: Card inside Card is FORBIDDEN +- Responsive: mobile-first approach (flex-col md:flex-row) + +## 3. V2 Component Standard +V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다. + +### 폴더 구조 (필수) +``` +frontend/lib/registry/components/v2-{name}/ +├── index.ts # createComponentDefinition() 호출 +├── types.ts # Config extends ComponentConfig +├── {Name}Component.tsx # React 함수 컴포넌트 +├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf() +├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용 +└── config.ts # 기본 설정값 상수 +``` + +### ConfigPanel 규칙 (절대!) +- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용 +- 직접 JSX로 설정 UI 작성 금지 + +## 4. API Client 생성 패턴 +```typescript +// frontend/lib/api/yourModule.ts +import apiClient from "@/lib/api/client"; + +export async function getYourData(id: number) { + const response = await apiClient.get(`/api/your-endpoint/${id}`); + return response.data; +} +``` + +# Your Domain +- frontend/components/ +- frontend/app/ +- frontend/lib/ +- frontend/hooks/ + +# Code Rules +1. TypeScript strict mode +2. React functional components with hooks +3. Prefer shadcn/ui components +4. Use cn() utility for conditional classes +5. Comments in Korean diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md new file mode 100644 index 00000000..44cf2daa --- /dev/null +++ b/.cursor/agents/pipeline-ui.md @@ -0,0 +1,50 @@ +--- +name: pipeline-ui +description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수. +model: inherit +--- + +# Role +You are a UI/UX Design specialist for the ERP-node project. +Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. + +# Design Philosophy +- Apple-level polish with enterprise functionality +- Consistent spacing, typography, color usage +- Subtle animations and micro-interactions +- Dark mode compatible using CSS variables + +# CRITICAL STYLE RULES + +## 1. Color System (CSS Variables ONLY) +- bg-background / text-foreground (base) +- bg-primary / text-primary-foreground (actions) +- bg-muted / text-muted-foreground (secondary) +- bg-destructive / text-destructive-foreground (danger) +FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black + +## 2. Layout Rules +- No nested boxes (Card inside Card FORBIDDEN) +- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids +- Mobile-first responsive: flex-col md:flex-row + +## 3. Typography +- Page title: text-3xl font-bold +- Section: text-xl font-semibold +- Body: text-sm +- Helper: text-xs text-muted-foreground + +## 4. Components +- ALWAYS use shadcn/ui components +- Use cn() for conditional classes +- Use lucide-react for ALL icons + +# Your Domain +- frontend/components/ (UI components) +- frontend/app/ (pages) + +# Output Rules +1. TypeScript strict mode +2. "use client" for client components +3. Comments in Korean +4. MINIMAL targeted changes when modifying existing files diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md new file mode 100644 index 00000000..a4f4186d --- /dev/null +++ b/.cursor/agents/pipeline-verifier.md @@ -0,0 +1,57 @@ +--- +name: pipeline-verifier +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. +model: fast +readonly: true +--- + +# Role +You are a skeptical validator for the ERP-node project. +Your job is to verify that work claimed as complete actually works. + +# Verification Checklist + +## 1. Multi-tenancy (최우선) +- [ ] 모든 SQL에 company_code 필터 존재 +- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) +- [ ] INSERT에 company_code 포함 +- [ ] JOIN에 company_code 매칭 조건 존재 +- [ ] company_code = "*" 최고관리자 예외 처리 + +## 2. Empty Shell Detection (빈 껍데기) +- [ ] API가 실제 DB 쿼리 실행 (mock 아님) +- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님) +- [ ] TODO/FIXME/placeholder 없음 +- [ ] 타입만 정의하고 구현 없는 함수 없음 + +## 3. Pattern Compliance (패턴 준수) +- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) +- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) +- [ ] Frontend: V2 컴포넌트 규격 준수 +- [ ] Backend: logger 사용 +- [ ] Backend: try/catch 에러 처리 + +## 4. Integration Check +- [ ] Route가 index.ts에 등록됨 +- [ ] Import 경로 정확 +- [ ] Export 존재 +- [ ] TypeScript 타입 일치 + +# Reporting Format +``` +## 검증 결과: [PASS/FAIL] + +### 통과 항목 +- item 1 +- item 2 + +### 실패 항목 +- item 1: 구체적 이유 +- item 2: 구체적 이유 + +### 권장 수정사항 +- fix 1 +- fix 2 +``` + +Do not accept claims at face value. Check the actual code. diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..d568a8fe --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,8 @@ +{ + "setup-worktree-unix": [ + "cd backend-node && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit", + "cd frontend && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit", + "cp $ROOT_WORKTREE_PATH/backend-node/.env backend-node/.env 2>/dev/null || true", + "cp $ROOT_WORKTREE_PATH/frontend/.env.local frontend/.env.local 2>/dev/null || true" + ] +} diff --git a/docs/파이프라인_한계점_분석.md b/docs/파이프라인_한계점_분석.md new file mode 100644 index 00000000..7ba10256 --- /dev/null +++ b/docs/파이프라인_한계점_분석.md @@ -0,0 +1,361 @@ +# Agent Pipeline 한계점 분석 + +> 결재 시스템 같은 대규모 크로스도메인 프로젝트에서 현재 파이프라인이 왜 제대로 동작할 수 없는가 + +--- + +## 1. 에이전트 컨텍스트 격리 문제 + +### 현상 +`executor.ts`의 `spawnAgent()`는 매번 새로운 Cursor Agent CLI 프로세스를 생성한다. 각 에이전트는 `systemPrompt + taskDescription + fileContext`만 받고, 이전 대화/결정/아키텍처 논의는 전혀 알지 못한다. + +```typescript +// executor.ts:64-118 +function spawnAgent(agentType, prompt, model, workspacePath, timeoutMs) { + const child = spawn(agentPath, ['--model', model, '--print', '--trust'], { + cwd: workspacePath, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.write(prompt); // 이게 에이전트가 받는 전부 + child.stdin.end(); +} +``` + +### 문제 본질 +- 에이전트는 **"왜 이렇게 만들어야 하는지"** 모른다. 단지 task description에 적힌 대로 만든다 +- 결재 시스템의 **설계 의도** (한국 기업 결재 문화, 자기결재/상신결재/합의결재/대결/후결)는 task description에 다 담을 수 없다 +- PM과 사용자 사이에 오간 **아키텍처 논의** (이벤트 훅 시스템, 제어관리 연동, 엔티티 조인으로 결재 상태 표시) 같은 결정 사항이 전달되지 않는다 + +### 결재 시스템에서의 구체적 영향 +- "ApprovalRequestModal에 결재 유형 선택을 추가해라"라고 지시하면, 에이전트는 기존 모달 코드를 읽겠지만, **왜 그 UI가 그렇게 생겼는지, 다른 패널(TableListConfigPanel)의 Combobox 패턴을 왜 따라야 하는지** 모른다 +- 실제로 이 대화에서 Combobox UI가 4번 수정됐다. 매번 "다른 패널 참고해서 만들라"고 해도 패턴을 정확히 못 따라했다 + +--- + +## 2. 파일 컨텍스트 3000자 절삭 + +### 현상 +```typescript +// executor.ts:124-138 +async function readFileContexts(files, workspacePath) { + for (const file of files) { + const content = await readFile(fullPath, 'utf-8'); + contents.push(`--- ${file} ---\n${content.substring(0, 3000)}`); // 3000자 잘림 + } +} +``` + +### 문제 본질 +주요 파일들의 실제 크기: +- `approvalController.ts`: ~800줄 (3000자로는 약 100줄, 12.5%만 보인다) +- `improvedButtonActionExecutor.ts`: ~1500줄 +- `ButtonConfigPanel.tsx`: ~600줄 +- `ApprovalStepConfigPanel.tsx`: ~300줄 + +에이전트가 수정해야 할 파일의 **전체 구조를 이해할 수 없다**. 앞부분만 보고 import 구문이나 초기 코드만 파악하고, 실제 수정 지점에 도달하지 못한다. + +### 결재 시스템에서의 구체적 영향 +- `approvalController.ts`를 수정하려면 기존 함수 구조, DB 쿼리 패턴, 에러 처리 방식, 멀티테넌시 적용 패턴을 전부 알아야 한다. 3000자로는 불가능 +- `improvedButtonActionExecutor.ts`의 제어관리 연동 패턴을 이해하려면 파일 전체를 봐야 한다 +- V2 컴포넌트 표준을 따르려면 기존 컴포넌트(`v2-table-list/` 등)의 전체 구조를 참고해야 한다 + +--- + +## 3. 에이전트 간 실시간 소통 부재 + +### 현상 +병렬 실행 시 에이전트들은 **서로의 작업 결과를 실시간으로 공유하지 못한다**: + +```typescript +// executor.ts:442-454 +if (state.config.parallel) { + const promises = readyTasks.map(async (task, index) => { + if (index > 0) await sleep(index * STAGGER_DELAY); // 500ms 딜레이뿐 + return executeAndTrack(task); + }); + await Promise.all(promises); // 완료까지 기다린 후 PM이 리뷰 +} +``` + +PM 에이전트가 라운드 후에 리뷰하지만, 이것도 **round-N.md의 텍스트 기반 리뷰**일 뿐이다. + +### 문제 본질 +- DB 에이전트가 스키마를 변경하면, Backend 에이전트가 그 결과를 **같은 라운드에서 즉시 반영할 수 없다** +- Frontend 에이전트가 "이 API 응답 구조 좀 바꿔줘"라고 Backend에 요청할 수 없다 +- 협업 모드(`CollabMessage`)가 존재하지만, 이것도 **라운드 단위의 비동기 메시지**이지 실시간 대화가 아니다 + +### 결재 시스템에서의 구체적 영향 +- DB가 `approval_proxy_settings` 테이블을 만들고, Backend가 대결 API를 만들고, Frontend가 대결 설정 UI를 만드는 과정이 **최소 3라운드**가 필요하다 (각 의존성 해소를 위해) +- 실제로는 Backend가 DB 스키마를 보고 쿼리를 짜는 과정에서 "이 컬럼 타입이 좀 다른 것 같은데"라는 이슈가 생기면, 즉시 수정 불가하고 다음 라운드로 넘어간다 +- 라운드당 에이전트 호출 1~3분 + PM 리뷰 1~2분 = **라운드당 최소 3~5분**. 8개 phase를 3라운드씩 = **최소 72~120분 (1~2시간)** + +--- + +## 4. 시스템 프롬프트의 한계 (프로젝트 특수 패턴 부재) + +### 현상 +`prompts.ts`의 시스템 프롬프트는 **범용적**이다: + +```typescript +// prompts.ts:75-118 +export const BACKEND_PROMPT = ` +# Role +You are a Backend specialist for ERP-node project. +Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query. +// ... 멀티테넌시, 기본 코드 패턴만 포함 +`; +``` + +### 프로젝트 특수 패턴 중 프롬프트에 없는 것들 + +| 필수 패턴 | 프롬프트 포함 여부 | 영향 | +|-----------|:------------------:|------| +| V2 컴포넌트 레지스트리 (`createComponentDefinition`, `AutoRegisteringComponentRenderer`) | 프론트엔드 프롬프트에 기본 구조만 | 컴포넌트 등록 방식 오류 가능 | +| ConfigPanelBuilder / ConfigSection | 언급만 | 직접 JSX로 패널 만드는 실수 반복 | +| Combobox UI 패턴 (Popover + Command) | 없음 | 실제로 4번 재수정 필요했음 | +| 엔티티 조인 시스템 | 없음 | 결재 상태를 대상 테이블에 표시하는 핵심 기능 구현 불가 | +| 제어관리(Node Flow) 연동 | 없음 | 결재 후 자동 액션 트리거 구현 불가 | +| ButtonActionExecutor 패턴 | 없음 | 결재 버튼 액션 구현 시 기존 패턴 미준수 | +| apiClient 사용법 (frontend/lib/api/) | 간략한 언급 | fetch 직접 사용 가능성 | +| CustomEvent 기반 모달 오픈 | 없음 | approval-modal 열기 방식 이해 불가 | +| 화면 디자이너 컨텍스트 | 없음 | screenTableName 같은 설계 시 컨텍스트 활용 불가 | + +### 결재 시스템에서의 구체적 영향 +- **이벤트 훅 시스템**을 만들려면 기존 `NodeFlowExecutionService`의 실행 패턴, 액션 타입 enum, 입력/출력 구조를 알아야 하는데, 프롬프트에 전혀 없다 +- **엔티티 조인으로 결재 상태 표시**하려면 기존 엔티티 조인 시스템이 어떻게 작동하는지(reverse lookup, join config) 알아야 하는데, 에이전트가 이 시스템 자체를 모른다 + +--- + +## 5. 단일 패스 실행 + 재시도의 비효율 + +### 현상 +```typescript +// executor.ts:240-288 +async function executeTaskWithRetry(task, state) { + while (task.attempts < task.maxRetries) { + const result = await executeTaskOnce(task, state, retryContext); + task.attempts++; + if (result.success) break; + // 검증 실패 → retryContext에 에러 메시지만 전달 + retryContext = failResult.retryContext || `이전 시도 실패: ${result.agentOutput.substring(0, 500)}`; + await sleep(2000); + } +} +``` + +### 문제 본질 +- 재시도 시 에이전트가 받는 건 **이전 에러 메시지 500자**뿐이다 +- "Combobox 패턴 대신 Select 박스를 썼다" 같은 **UI/UX 품질 문제**는 L1~L6 검증으로 잡을 수 없다 (빌드는 통과하니까) +- 사용자의 실시간 피드백("이거 다른 패널이랑 UI가 다른데?")을 반영할 수 없다 + +### 검증 피라미드(L1~L6)가 못 잡는 것들 + +| 검증 레벨 | 잡을 수 있는 것 | 못 잡는 것 | +|-----------|----------------|-----------| +| L1 (TS 빌드) | 타입 에러, import 오류 | 로직 오류, 패턴 미준수 | +| L2 (앱 빌드) | Next.js 빌드 에러 | 런타임 에러 | +| L3 (API 호출) | 엔드포인트 존재 여부, 기본 응답 | 복잡한 비즈니스 로직 (다단계 결재 플로우) | +| L4 (DB 검증) | 테이블 존재, 기본 CRUD | 결재 상태 전이 로직, 병렬 결재 집계 | +| L5 (브라우저 E2E) | 화면 렌더링, 기본 클릭 | 결재 모달 Combobox UX, 대결 설정 UI 일관성 | +| L6 (커스텀) | 명시적 조건 | 비명시적 품질 요구사항 | + +### 결재 시스템에서의 구체적 영향 +- "자기결재 시 즉시 approved로 처리"가 올바르게 동작하는지 L3/L4로 검증 가능하지만, **"자기결재 선택 시 결재자 선택 UI가 숨겨지고 즉시 처리된다"는 UX**는 L5 자연어로는 불충분 +- "합의결재(병렬)에서 3명 중 2명 승인 + 1명 반려 시 전체 반려" 같은 **엣지 케이스 비즈니스 로직**은 자동 검증이 어렵다 +- 결재 완료 후 이벤트 훅 → Node Flow 실행 → 이메일 발송 같은 **체이닝된 비동기 로직**은 E2E로 검증 불가 + +--- + +## 6. 태스크 분할의 구조적 한계 + +### 현상: 파이프라인이 잘 되는 경우 +``` +[DB 테이블 생성] → [Backend CRUD API] → [Frontend 화면] → [UI 개선] +``` +각 태스크가 **독립적**이고, 새 파일을 만들고, 의존성이 단방향이다. + +### 현상: 파이프라인이 안 되는 경우 (결재 시스템) +``` +[DB 스키마 변경] + ↓ ↘ +[Controller 수정] [새 API 추가] ← 기존 코드 500줄 이해 필요 + ↓ ↓ ↑ +[모달 수정] [새 화면] ← 기존 UI 패턴 준수 필요 + 엔티티 조인 시스템 이해 + ↓ +[V2 컴포넌트 수정] ← 레지스트리 시스템 + ConfigPanelBuilder 패턴 이해 + ↓ +[이벤트 훅 시스템] ← NodeFlowExecutionService 전체 이해 + 새 시스템 설계 + ↓ +[엔티티 조인 등록] ← 기존 엔티티 조인 시스템 전체 이해 +``` + +### 문제 본질 +- **기존 파일 수정**이 대부분이다. 새 파일 생성이 아니라 기존 코드에 기능을 끼워넣어야 한다 +- **패턴 준수**가 필수다. "돌아가기만 하면" 안 되고, 기존 시스템과 **일관된 방식**으로 구현해야 한다 +- **설계 결정**이 코드 작성보다 중요하다. "이벤트 훅을 어떻게 설계할까?"는 에이전트가 task description만 보고 결정할 수 없다 + +--- + +## 7. PM 에이전트의 역할 한계 + +### 현상 +```typescript +// pm-agent.ts:21-70 +const PM_SYSTEM_PROMPT = ` +# 판단 기준 +- 빌드만 통과하면 "complete" 아니다 -- 기능이 실제로 동작해야 "complete" +- 같은 에러 2회 반복 -> instruction에 구체적 해결책 제시 +- 같은 에러 3회 반복 -> "fail" 판정 +`; +``` + +PM은 `round-N.md`(에이전트 응답 + git diff + 테스트 결과)와 `progress.md`만 보고 판단한다. + +### PM이 할 수 없는 것 + +| 역할 | PM 가능 여부 | 이유 | +|------|:----------:|------| +| 빌드 실패 원인 파악 | 가능 | 에러 로그가 round-N.md에 있음 | +| 비즈니스 로직 검증 | 불가 | 실제 코드를 읽지 않고 git diff만 봄 | +| UI/UX 품질 판단 | 불가 | 스크린샷 없음, 렌더링 결과 못 봄 | +| 아키텍처 일관성 검증 | 불가 | 전체 시스템 구조를 모름 | +| 기존 패턴 준수 여부 | 불가 | 기존 코드를 참조하지 않음 | +| 사용자 의도 반영 여부 | 불가 | 사용자와 대화 맥락 없음 | + +### 결재 시스템에서의 구체적 영향 +- PM이 "Backend task 성공, Frontend task 실패"라고 판정할 수는 있지만, **"Backend가 만든 API 응답 구조가 Frontend가 기대하는 것과 다르다"**를 파악할 수 없다 +- "이 모달의 Combobox가 다른 패널과 UI가 다르다"는 사용자만 판단 가능 +- "이벤트 훅 시스템의 트리거 타이밍이 잘못됐다"는 전체 아키텍처를 이해해야 판단 가능 + +--- + +## 8. 안전성 리스크 + +### 역사적 사고 +> "과거 에이전트가 범위 밖 파일 50000줄 삭제하여 2800+ TS 에러 발생" +> — user rules + +### 결재 시스템의 리스크 +수정 대상 파일이 **시스템 핵심 파일**들이다: + +| 파일 | 리스크 | +|------|--------| +| `improvedButtonActionExecutor.ts` (~1500줄) | 모든 버튼 동작의 핵심. 잘못 건드리면 시스템 전체 버튼 동작 불능 | +| `approvalController.ts` (~800줄) | 기존 결재 API 깨질 수 있음 | +| `ButtonConfigPanel.tsx` (~600줄) | 화면 디자이너 설정 패널 전체에 영향 | +| `v2-approval-step/` (5개 파일) | V2 컴포넌트 레지스트리 손상 가능 | +| `AppLayout.tsx` | 전체 레이아웃 메뉴 깨질 수 있음 | +| `UserDropdown.tsx` | 사용자 프로필 메뉴 깨질 수 있음 | + +`files` 필드로 범위를 제한하더라도, **에이전트가 `--trust` 모드로 실행**되기 때문에 실제로는 모든 파일에 접근 가능하다: + +```typescript +// executor.ts:78 +const child = spawn(agentPath, ['--model', model, '--print', '--trust'], { +``` + +code-guard가 일부 보호하지만, **구조적 파괴(잘못된 import 삭제, 함수 시그니처 변경)는 코드 가드가 감지 불가**하다. + +--- + +## 9. 종합: 파이프라인이 적합한 경우 vs 부적합한 경우 + +### 적합한 경우 (현재 파이프라인) + +| 특성 | 예시 | +|------|------| +| 새 파일 생성 위주 | 새 CRUD 화면 만들기 | +| 독립적 태스크 | 테이블 → API → 화면 순차 | +| 패턴이 단순/반복적 | 표준 CRUD, 표준 Form | +| 검증이 명확 | 빌드 + API 호출 + 브라우저 기본 확인 | +| 컨텍스트 최소 | 기존 시스템 이해 불필요 | + +### 부적합한 경우 (결재 시스템) + +| 특성 | 결재 시스템 해당 여부 | +|------|:-------------------:| +| 기존 파일 대규모 수정 | 해당 (10+ 파일 수정) | +| 크로스도메인 의존성 | 해당 (DB ↔ BE ↔ FE ↔ 기존 시스템) | +| 복잡한 비즈니스 로직 | 해당 (5가지 결재 유형, 상태 전이, 이벤트 훅) | +| 기존 시스템 깊은 이해 필요 | 해당 (제어관리, 엔티티 조인, 컴포넌트 레지스트리) | +| UI/UX 일관성 필수 | 해당 (Combobox, 모달, 설정 패널 패턴 통일) | +| 설계 결정이 선행 필요 | 해당 (이벤트 훅 아키텍처, 결재 타입 상태 머신) | +| 사용자 피드백 반복 필요 | 해당 (실제로 4회 UI 수정 반복) | + +--- + +## 10. 개선 방향 제안 + +현재 파이프라인을 결재 시스템 같은 대규모 프로젝트에서 사용하려면 다음이 필요하다: + +### 10.1 컨텍스트 전달 강화 +- **프로젝트 컨텍스트 파일**: `.cursor/rules/` 수준의 프로젝트 규칙을 에이전트 프롬프트에 동적 주입 +- **아키텍처 결정 기록**: PM-사용자 간 논의된 설계 결정을 구조화된 형태로 에이전트에 전달 +- **패턴 레퍼런스 파일**: "이 파일을 참고해서 만들어라"를 task description이 아닌 시스템 차원에서 지원 + +### 10.2 파일 컨텍스트 확대 +- 3000자 절삭 → **전체 파일 전달** 또는 최소 10000자 이상 +- 관련 파일 자동 탐지 (import 그래프 기반) +- 참고 파일(reference files)과 수정 파일(target files) 구분 + +### 10.3 에이전트 간 소통 채널 +- 라운드 내에서도 에이전트 간 **중간 결과 공유** 가능 +- "Backend가 API 스펙을 먼저 정의 → Frontend가 그 스펙 기반으로 구현" 같은 **단계적 소통** +- 질문-응답 프로토콜 (현재 CollabMessage가 있지만 실질적으로 사용 안 됨) + +### 10.4 PM 에이전트 강화 +- **코드 리뷰 기능**: git diff만 보지 말고 실제 파일을 읽어서 패턴 준수 여부 확인 +- **아키텍처 검증**: 전체 시스템 구조와의 일관성 검증 +- **사용자 피드백 루프**: PM이 사용자에게 "이 부분 확인 필요합니다" 알림 가능 + +### 10.5 검증 시스템 확장 +- **비즈니스 로직 검증**: 상태 전이 테스트 (결재 플로우 시나리오 자동 실행) +- **UI 일관성 검증**: 스크린샷 비교, 컴포넌트 패턴 분석 +- **통합 테스트**: 단일 API 호출이 아닌 시나리오 기반 E2E + +### 10.6 안전성 강화 +- `--trust` 모드 대신 **파일 범위 제한된 실행 모드** +- 라운드별 git diff 자동 리뷰 (의도치 않은 파일 변경 감지) +- 롤백 자동화 (검증 실패 시 자동 `git checkout`) + +--- + +## 부록: 결재 시스템 파이프라인 실행 시 예상 시나리오 + +### 시도할 경우 예상되는 실패 패턴 + +``` +Round 1: DB 마이그레이션 (task-1) + → 성공 가능 (신규 파일 생성이므로) + +Round 2: Backend Controller 수정 (task-2) + → approvalController.ts 3000자만 보고 수정 시도 + → 기존 함수 구조 파악 실패 + → L1 빌드 에러 (import 누락, 타입 불일치) + → 재시도 1: 에러 메시지 보고 고치지만, 기존 패턴과 다른 방식으로 구현 + → L3 API 테스트 통과 (기능은 동작) + → 하지만 코드 품질/패턴 불일치 (PM이 감지 불가) + +Round 3: Frontend 모달 수정 (task-4) + → 기존 ApprovalRequestModal 3000자만 보고 수정 + → Combobox 패턴 대신 기본 Select 사용 (다른 패널 참고 불가) + → L1 빌드 통과, L5 브라우저 테스트도 기본 동작 통과 + → 하지만 UI 일관성 미달 (사용자가 보면 즉시 지적) + +Round 4-6: 이벤트 훅 시스템 (task-7) + → NodeFlowExecutionService 전체 이해 필요한데 3000자만 봄 + → 기존 시스템과 연동 불가능한 독립적 구현 생산 + → PM이 "빌드 통과했으니 complete" 판정 + → 실제로는 기존 제어관리와 전혀 연결 안 됨 + +최종: 8/8 task "성공" 판정 +→ 사용자가 확인: "이거 다 뜯어 고쳐야 하는데?" +→ 파이프라인 2시간 + 사용자 수동 수정 3시간 = 5시간 낭비 +→ PM이 직접 했으면 2~3시간에 끝 +``` + +--- + +*작성일: 2026-03-03* +*대상: Agent Pipeline v3.0 (`_local/agent-pipeline/`)* +*맥락: 결재 시스템 v2 재설계 프로젝트 (`docs/결재시스템_구현_현황.md`)* From b80b1c7a1228dc253b0003e8aba4c87a02a7d335 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 18:31:19 +0900 Subject: [PATCH 02/52] [agent-pipeline] pipe-20260305092447-89ti round-2 --- backend-node/scripts/run-notice-migration.js | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend-node/scripts/run-notice-migration.js diff --git a/backend-node/scripts/run-notice-migration.js b/backend-node/scripts/run-notice-migration.js new file mode 100644 index 00000000..4b23153d --- /dev/null +++ b/backend-node/scripts/run-notice-migration.js @@ -0,0 +1,38 @@ +/** + * system_notice 마이그레이션 실행 스크립트 + * 사용법: node scripts/run-notice-migration.js + */ +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', + ssl: false, +}); + +async function run() { + const client = await pool.connect(); + try { + const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + + console.log('마이그레이션 실행 중...'); + await client.query(sql); + console.log('마이그레이션 완료'); + + // 컬럼 확인 + const check = await client.query( + "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" + ); + console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', ')); + } catch (e) { + console.error('오류:', e.message); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +run(); From fe6c3277c8034d773a8763a5318ff75390418bee Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 18:43:49 +0900 Subject: [PATCH 03/52] [agent-pipeline] pipe-20260305094105-763v round-1 --- backend-node/scripts/run-1050-migration.js | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend-node/scripts/run-1050-migration.js diff --git a/backend-node/scripts/run-1050-migration.js b/backend-node/scripts/run-1050-migration.js new file mode 100644 index 00000000..aa1b3723 --- /dev/null +++ b/backend-node/scripts/run-1050-migration.js @@ -0,0 +1,35 @@ +/** + * system_notice 테이블 생성 마이그레이션 실행 + */ +const { Pool } = require('pg'); +const fs = require('fs'); +const path = require('path'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', + ssl: false, +}); + +async function run() { + const client = await pool.connect(); + try { + const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + await client.query(sql); + console.log('OK: system_notice 테이블 생성 완료'); + + // 검증 + const result = await client.query( + "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" + ); + console.log('컬럼:', result.rows.map(r => r.column_name).join(', ')); + } catch (e) { + console.error('ERROR:', e.message); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +run(); From 9a328ade7892de31be5ac177707839a900eb8329 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 18:45:41 +0900 Subject: [PATCH 04/52] [agent-pipeline] pipe-20260305094105-763v round-2 --- .../src/controllers/systemNoticeController.ts | 275 ++++++++++++++++++ backend-node/src/routes/systemNoticeRoutes.ts | 27 ++ 2 files changed, 302 insertions(+) create mode 100644 backend-node/src/controllers/systemNoticeController.ts create mode 100644 backend-node/src/routes/systemNoticeRoutes.ts diff --git a/backend-node/src/controllers/systemNoticeController.ts b/backend-node/src/controllers/systemNoticeController.ts new file mode 100644 index 00000000..9a00a4f0 --- /dev/null +++ b/backend-node/src/controllers/systemNoticeController.ts @@ -0,0 +1,275 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +/** + * GET /api/system-notices + * 공지사항 목록 조회 + * - 최고 관리자(*): 전체 조회 + * - 일반 회사: 자신의 company_code 데이터만 조회 + * - is_active 필터 옵션 지원 + */ +export const getSystemNotices = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { is_active } = req.query; + + logger.info("공지사항 목록 조회 요청", { companyCode, is_active }); + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 최고 관리자가 아닌 경우 company_code 필터링 + if (companyCode !== "*") { + conditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // is_active 필터 (true/false 문자열 처리) + if (is_active !== undefined && is_active !== "") { + const activeValue = is_active === "true" || is_active === "1"; + conditions.push(`is_active = $${paramIndex}`); + params.push(activeValue); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const rows = await query( + `SELECT + id, + company_code, + title, + content, + is_active, + created_by, + created_at, + updated_at + FROM system_notice + ${whereClause} + ORDER BY created_at DESC`, + params + ); + + logger.info("공지사항 목록 조회 성공", { + companyCode, + count: rows.length, + }); + + res.status(200).json({ + success: true, + data: rows, + total: rows.length, + }); + } catch (error) { + logger.error("공지사항 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "공지사항 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * POST /api/system-notices + * 공지사항 등록 + * - company_code는 req.user.companyCode에서 자동 추출 (클라이언트 입력 신뢰 금지) + */ +export const createSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { title, content, is_active = true } = req.body; + + logger.info("공지사항 등록 요청", { companyCode, userId, title }); + + if (!title || !title.trim()) { + res.status(400).json({ + success: false, + message: "제목을 입력해주세요.", + }); + return; + } + + if (!content || !content.trim()) { + res.status(400).json({ + success: false, + message: "내용을 입력해주세요.", + }); + return; + } + + const [created] = await query( + `INSERT INTO system_notice (company_code, title, content, is_active, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [companyCode, title.trim(), content.trim(), is_active, userId] + ); + + logger.info("공지사항 등록 성공", { + id: created.id, + companyCode, + title: created.title, + }); + + res.status(201).json({ + success: true, + data: created, + message: "공지사항이 등록되었습니다.", + }); + } catch (error) { + logger.error("공지사항 등록 실패", { error }); + res.status(500).json({ + success: false, + message: "공지사항 등록 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * PUT /api/system-notices/:id + * 공지사항 수정 + * - WHERE id=$1 AND company_code=$2 로 타 회사 데이터 수정 차단 + * - 최고 관리자는 company_code 조건 없이 id만으로 수정 가능 + */ +export const updateSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { title, content, is_active } = req.body; + + logger.info("공지사항 수정 요청", { id, companyCode }); + + if (!title || !title.trim()) { + res.status(400).json({ + success: false, + message: "제목을 입력해주세요.", + }); + return; + } + + if (!content || !content.trim()) { + res.status(400).json({ + success: false, + message: "내용을 입력해주세요.", + }); + return; + } + + let result: any[]; + + if (companyCode === "*") { + // 최고 관리자: id만으로 수정 + result = await query( + `UPDATE system_notice + SET title = $1, content = $2, is_active = $3, updated_at = NOW() + WHERE id = $4 + RETURNING *`, + [title.trim(), content.trim(), is_active ?? true, id] + ); + } else { + // 일반 회사: company_code 추가 조건으로 타 회사 데이터 수정 차단 + result = await query( + `UPDATE system_notice + SET title = $1, content = $2, is_active = $3, updated_at = NOW() + WHERE id = $4 AND company_code = $5 + RETURNING *`, + [title.trim(), content.trim(), is_active ?? true, id, companyCode] + ); + } + + if (!result || result.length === 0) { + res.status(404).json({ + success: false, + message: "공지사항을 찾을 수 없거나 수정 권한이 없습니다.", + }); + return; + } + + logger.info("공지사항 수정 성공", { id, companyCode }); + + res.status(200).json({ + success: true, + data: result[0], + message: "공지사항이 수정되었습니다.", + }); + } catch (error) { + logger.error("공지사항 수정 실패", { error, id: req.params.id }); + res.status(500).json({ + success: false, + message: "공지사항 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * DELETE /api/system-notices/:id + * 공지사항 삭제 + * - WHERE id=$1 AND company_code=$2 로 타 회사 데이터 삭제 차단 + * - 최고 관리자는 company_code 조건 없이 id만으로 삭제 가능 + */ +export const deleteSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + logger.info("공지사항 삭제 요청", { id, companyCode }); + + let result: any[]; + + if (companyCode === "*") { + // 최고 관리자: id만으로 삭제 + result = await query( + `DELETE FROM system_notice WHERE id = $1 RETURNING id`, + [id] + ); + } else { + // 일반 회사: company_code 추가 조건으로 타 회사 데이터 삭제 차단 + result = await query( + `DELETE FROM system_notice WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + } + + if (!result || result.length === 0) { + res.status(404).json({ + success: false, + message: "공지사항을 찾을 수 없거나 삭제 권한이 없습니다.", + }); + return; + } + + logger.info("공지사항 삭제 성공", { id, companyCode }); + + res.status(200).json({ + success: true, + message: "공지사항이 삭제되었습니다.", + }); + } catch (error) { + logger.error("공지사항 삭제 실패", { error, id: req.params.id }); + res.status(500).json({ + success: false, + message: "공지사항 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/routes/systemNoticeRoutes.ts b/backend-node/src/routes/systemNoticeRoutes.ts new file mode 100644 index 00000000..54506386 --- /dev/null +++ b/backend-node/src/routes/systemNoticeRoutes.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { + getSystemNotices, + createSystemNotice, + updateSystemNotice, + deleteSystemNotice, +} from "../controllers/systemNoticeController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 공지사항 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 공지사항 목록 조회 (is_active 필터 쿼리 파라미터 지원) +router.get("/", getSystemNotices); + +// 공지사항 등록 +router.post("/", createSystemNotice); + +// 공지사항 수정 +router.put("/:id", updateSystemNotice); + +// 공지사항 삭제 +router.delete("/:id", deleteSystemNotice); + +export default router; From 74d0e730cd1aca6dc2cfd022053db4d263950099 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 18:49:33 +0900 Subject: [PATCH 05/52] [agent-pipeline] pipe-20260305094105-763v round-3 --- .../app/(main)/admin/system-notices/page.tsx | 548 ++++++++++++++++++ frontend/lib/api/systemNotice.ts | 90 +++ test-results/.last-run.json | 4 + 3 files changed, 642 insertions(+) create mode 100644 frontend/app/(main)/admin/system-notices/page.tsx create mode 100644 frontend/lib/api/systemNotice.ts create mode 100644 test-results/.last-run.json diff --git a/frontend/app/(main)/admin/system-notices/page.tsx b/frontend/app/(main)/admin/system-notices/page.tsx new file mode 100644 index 00000000..c3815de7 --- /dev/null +++ b/frontend/app/(main)/admin/system-notices/page.tsx @@ -0,0 +1,548 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Plus, Pencil, Trash2, Search, RefreshCw } from "lucide-react"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { + SystemNotice, + CreateSystemNoticePayload, + getSystemNotices, + createSystemNotice, + updateSystemNotice, + deleteSystemNotice, +} from "@/lib/api/systemNotice"; + +// 우선순위 레이블 반환 +function getPriorityLabel(priority: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } { + if (priority >= 3) return { label: "높음", variant: "destructive" }; + if (priority === 2) return { label: "보통", variant: "default" }; + return { label: "낮음", variant: "secondary" }; +} + +// 날짜 포맷 +function formatDate(dateStr: string): string { + if (!dateStr) return "-"; + return new Date(dateStr).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +// 폼 초기값 +const EMPTY_FORM: CreateSystemNoticePayload = { + title: "", + content: "", + is_active: true, + priority: 1, +}; + +export default function SystemNoticesPage() { + const [notices, setNotices] = useState([]); + const [filteredNotices, setFilteredNotices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMsg, setErrorMsg] = useState(null); + + // 검색 필터 + const [searchText, setSearchText] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + // 등록/수정 모달 + const [isFormOpen, setIsFormOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [formData, setFormData] = useState(EMPTY_FORM); + const [isSaving, setIsSaving] = useState(false); + + // 삭제 확인 모달 + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // 공지사항 목록 로드 + const loadNotices = useCallback(async () => { + setIsLoading(true); + setErrorMsg(null); + const result = await getSystemNotices(); + if (result.success && result.data) { + setNotices(result.data); + } else { + setErrorMsg(result.message || "공지사항 목록을 불러오는 데 실패했습니다."); + } + setIsLoading(false); + }, []); + + useEffect(() => { + loadNotices(); + }, [loadNotices]); + + // 검색/필터 적용 + useEffect(() => { + let result = [...notices]; + + if (statusFilter !== "all") { + const isActive = statusFilter === "active"; + result = result.filter((n) => n.is_active === isActive); + } + + if (searchText.trim()) { + const keyword = searchText.toLowerCase(); + result = result.filter( + (n) => + n.title.toLowerCase().includes(keyword) || + n.content.toLowerCase().includes(keyword) + ); + } + + setFilteredNotices(result); + }, [notices, searchText, statusFilter]); + + // 등록 모달 열기 + const handleOpenCreate = () => { + setEditTarget(null); + setFormData(EMPTY_FORM); + setIsFormOpen(true); + }; + + // 수정 모달 열기 + const handleOpenEdit = (notice: SystemNotice) => { + setEditTarget(notice); + setFormData({ + title: notice.title, + content: notice.content, + is_active: notice.is_active, + priority: notice.priority, + }); + setIsFormOpen(true); + }; + + // 저장 처리 + const handleSave = async () => { + if (!formData.title.trim()) { + alert("제목을 입력해주세요."); + return; + } + if (!formData.content.trim()) { + alert("내용을 입력해주세요."); + return; + } + + setIsSaving(true); + let result; + + if (editTarget) { + result = await updateSystemNotice(editTarget.id, formData); + } else { + result = await createSystemNotice(formData); + } + + if (result.success) { + setIsFormOpen(false); + await loadNotices(); + } else { + alert(result.message || "저장에 실패했습니다."); + } + setIsSaving(false); + }; + + // 삭제 처리 + const handleDelete = async () => { + if (!deleteTarget) return; + setIsDeleting(true); + const result = await deleteSystemNotice(deleteTarget.id); + if (result.success) { + setDeleteTarget(null); + await loadNotices(); + } else { + alert(result.message || "삭제에 실패했습니다."); + } + setIsDeleting(false); + }; + + return ( +
+
+ {/* 페이지 헤더 */} +
+

시스템 공지사항

+

+ 시스템 사용자에게 전달할 공지사항을 관리합니다. +

+
+ + {/* 에러 메시지 */} + {errorMsg && ( +
+
+

오류가 발생했습니다

+ +
+

{errorMsg}

+
+ )} + + {/* 검색 툴바 */} +
+
+ {/* 상태 필터 */} +
+ +
+ + {/* 제목 검색 */} +
+ + setSearchText(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ +
+ + 총 {filteredNotices.length} 건 + + + +
+
+ + {/* 데스크톱 테이블 */} +
+ + + + 제목 + 상태 + 우선순위 + 작성자 + 작성일 + 관리 + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + +
+ + ))} + + )) + ) : filteredNotices.length === 0 ? ( + + + 공지사항이 없습니다. + + + ) : ( + filteredNotices.map((notice) => { + const priority = getPriorityLabel(notice.priority); + return ( + + {notice.title} + + + {notice.is_active ? "활성" : "비활성"} + + + + {priority.label} + + + {notice.created_by || "-"} + + + {formatDate(notice.created_at)} + + +
+ + +
+
+
+ ); + }) + )} + +
+
+ + {/* 모바일 카드 뷰 */} +
+ {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+ )) + ) : filteredNotices.length === 0 ? ( +
+ 공지사항이 없습니다. +
+ ) : ( + filteredNotices.map((notice) => { + const priority = getPriorityLabel(notice.priority); + return ( +
+
+

{notice.title}

+
+ + +
+
+
+ + {notice.is_active ? "활성" : "비활성"} + + {priority.label} +
+
+
+ 작성자 + {notice.created_by || "-"} +
+
+ 작성일 + {formatDate(notice.created_at)} +
+
+
+ ); + }) + )} +
+
+ + {/* 등록/수정 모달 */} + + + + + {editTarget ? "공지사항 수정" : "공지사항 등록"} + + + {editTarget ? "공지사항 내용을 수정합니다." : "새로운 공지사항을 등록합니다."} + + + +
+ {/* 제목 */} +
+ + setFormData((prev) => ({ ...prev, title: e.target.value }))} + placeholder="공지사항 제목을 입력하세요" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 내용 */} +
+ +