1139 lines
29 KiB
Markdown
1139 lines
29 KiB
Markdown
|
|
# 플로우 관리 시스템 구현 계획서
|
||
|
|
|
||
|
|
## 1. 개요
|
||
|
|
|
||
|
|
### 1.1 목적
|
||
|
|
|
||
|
|
제품의 수명주기나 업무 프로세스를 시각적인 플로우로 정의하고 관리하는 시스템을 구축합니다.
|
||
|
|
각 플로우 단계는 데이터베이스 테이블의 레코드 조건으로 정의되며, 데이터를 플로우 단계 간 이동시키고 이력을 관리할 수 있습니다.
|
||
|
|
|
||
|
|
### 1.2 주요 기능
|
||
|
|
|
||
|
|
- 플로우 정의 및 시각적 편집
|
||
|
|
- 플로우 단계별 조건 설정
|
||
|
|
- 화면관리 시스템과 연동하여 플로우 위젯 배치
|
||
|
|
- 플로우 단계별 데이터 카운트 및 리스트 조회
|
||
|
|
- 데이터의 플로우 단계 이동
|
||
|
|
- 상태 변경 이력 관리 (오딧 로그)
|
||
|
|
|
||
|
|
### 1.3 사용 예시
|
||
|
|
|
||
|
|
**DTG 제품 수명주기 관리**
|
||
|
|
|
||
|
|
- 플로우 이름: "DTG 제품 라이프사이클"
|
||
|
|
- 연결 테이블: `product_dtg`
|
||
|
|
- 플로우 단계:
|
||
|
|
1. 구매 (조건: `status = '구매완료' AND install_date IS NULL`)
|
||
|
|
2. 설치 (조건: `status = '설치완료' AND disposal_date IS NULL`)
|
||
|
|
3. 폐기 (조건: `status = '폐기완료'`)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. 데이터베이스 스키마
|
||
|
|
|
||
|
|
### 2.1 flow_definition (플로우 정의)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE flow_definition (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
name VARCHAR(200) NOT NULL, -- 플로우 이름
|
||
|
|
description TEXT, -- 플로우 설명
|
||
|
|
table_name VARCHAR(200) NOT NULL, -- 연결된 테이블명
|
||
|
|
is_active BOOLEAN DEFAULT true, -- 활성화 여부
|
||
|
|
created_by VARCHAR(100),
|
||
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_flow_definition_table ON flow_definition(table_name);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.2 flow_step (플로우 단계)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE flow_step (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
|
||
|
|
step_name VARCHAR(200) NOT NULL, -- 단계 이름
|
||
|
|
step_order INTEGER NOT NULL, -- 단계 순서
|
||
|
|
condition_json JSONB, -- 조건 설정 (JSON 형태)
|
||
|
|
color VARCHAR(50) DEFAULT '#3B82F6', -- 단계 표시 색상
|
||
|
|
position_x INTEGER DEFAULT 0, -- 캔버스 상의 X 좌표
|
||
|
|
position_y INTEGER DEFAULT 0, -- 캔버스 상의 Y 좌표
|
||
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_flow_step_definition ON flow_step(flow_definition_id);
|
||
|
|
```
|
||
|
|
|
||
|
|
**condition_json 예시:**
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"type": "AND",
|
||
|
|
"conditions": [
|
||
|
|
{
|
||
|
|
"column": "status",
|
||
|
|
"operator": "equals",
|
||
|
|
"value": "구매완료"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"column": "install_date",
|
||
|
|
"operator": "is_null",
|
||
|
|
"value": null
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.3 flow_step_connection (플로우 단계 연결)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE flow_step_connection (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
|
||
|
|
from_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE,
|
||
|
|
to_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE,
|
||
|
|
label VARCHAR(200), -- 연결선 라벨 (선택사항)
|
||
|
|
created_at TIMESTAMP DEFAULT NOW()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_flow_connection_definition ON flow_step_connection(flow_definition_id);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.4 flow_data_status (데이터의 현재 플로우 상태)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE flow_data_status (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
|
||
|
|
table_name VARCHAR(200) NOT NULL, -- 원본 테이블명
|
||
|
|
record_id VARCHAR(100) NOT NULL, -- 원본 테이블의 레코드 ID
|
||
|
|
current_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL,
|
||
|
|
updated_by VARCHAR(100),
|
||
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||
|
|
UNIQUE(flow_definition_id, table_name, record_id)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_flow_data_status_record ON flow_data_status(table_name, record_id);
|
||
|
|
CREATE INDEX idx_flow_data_status_step ON flow_data_status(current_step_id);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.5 flow_audit_log (플로우 상태 변경 이력)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE flow_audit_log (
|
||
|
|
id SERIAL PRIMARY KEY,
|
||
|
|
flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
|
||
|
|
table_name VARCHAR(200) NOT NULL,
|
||
|
|
record_id VARCHAR(100) NOT NULL,
|
||
|
|
from_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL,
|
||
|
|
to_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL,
|
||
|
|
changed_by VARCHAR(100),
|
||
|
|
changed_at TIMESTAMP DEFAULT NOW(),
|
||
|
|
note TEXT -- 변경 사유
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_flow_audit_record ON flow_audit_log(table_name, record_id, changed_at DESC);
|
||
|
|
CREATE INDEX idx_flow_audit_definition ON flow_audit_log(flow_definition_id);
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. 백엔드 API 설계
|
||
|
|
|
||
|
|
### 3.1 플로우 정의 관리 API
|
||
|
|
|
||
|
|
#### 3.1.1 플로우 생성
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/flow/definitions
|
||
|
|
Body: {
|
||
|
|
name: string;
|
||
|
|
description?: string;
|
||
|
|
tableName: string;
|
||
|
|
}
|
||
|
|
Response: { success: boolean; data: FlowDefinition }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.1.2 플로우 목록 조회
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /api/flow/definitions
|
||
|
|
Query: { tableName?: string; isActive?: boolean }
|
||
|
|
Response: { success: boolean; data: FlowDefinition[] }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.1.3 플로우 상세 조회
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /api/flow/definitions/:id
|
||
|
|
Response: {
|
||
|
|
success: boolean;
|
||
|
|
data: {
|
||
|
|
definition: FlowDefinition;
|
||
|
|
steps: FlowStep[];
|
||
|
|
connections: FlowStepConnection[];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.1.4 플로우 수정
|
||
|
|
|
||
|
|
```
|
||
|
|
PUT /api/flow/definitions/:id
|
||
|
|
Body: { name?: string; description?: string; isActive?: boolean }
|
||
|
|
Response: { success: boolean; data: FlowDefinition }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.1.5 플로우 삭제
|
||
|
|
|
||
|
|
```
|
||
|
|
DELETE /api/flow/definitions/:id
|
||
|
|
Response: { success: boolean }
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.2 플로우 단계 관리 API
|
||
|
|
|
||
|
|
#### 3.2.1 단계 추가
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/flow/definitions/:flowId/steps
|
||
|
|
Body: {
|
||
|
|
stepName: string;
|
||
|
|
stepOrder: number;
|
||
|
|
conditionJson?: object;
|
||
|
|
color?: string;
|
||
|
|
positionX?: number;
|
||
|
|
positionY?: number;
|
||
|
|
}
|
||
|
|
Response: { success: boolean; data: FlowStep }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.2.2 단계 수정
|
||
|
|
|
||
|
|
```
|
||
|
|
PUT /api/flow/steps/:stepId
|
||
|
|
Body: {
|
||
|
|
stepName?: string;
|
||
|
|
stepOrder?: number;
|
||
|
|
conditionJson?: object;
|
||
|
|
color?: string;
|
||
|
|
positionX?: number;
|
||
|
|
positionY?: number;
|
||
|
|
}
|
||
|
|
Response: { success: boolean; data: FlowStep }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.2.3 단계 삭제
|
||
|
|
|
||
|
|
```
|
||
|
|
DELETE /api/flow/steps/:stepId
|
||
|
|
Response: { success: boolean }
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.3 플로우 연결 관리 API
|
||
|
|
|
||
|
|
#### 3.3.1 단계 연결 생성
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/flow/connections
|
||
|
|
Body: {
|
||
|
|
flowDefinitionId: number;
|
||
|
|
fromStepId: number;
|
||
|
|
toStepId: number;
|
||
|
|
label?: string;
|
||
|
|
}
|
||
|
|
Response: { success: boolean; data: FlowStepConnection }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.3.2 연결 삭제
|
||
|
|
|
||
|
|
```
|
||
|
|
DELETE /api/flow/connections/:connectionId
|
||
|
|
Response: { success: boolean }
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.4 플로우 실행 API
|
||
|
|
|
||
|
|
#### 3.4.1 단계별 데이터 카운트 조회
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /api/flow/:flowId/step/:stepId/count
|
||
|
|
Response: {
|
||
|
|
success: boolean;
|
||
|
|
data: { count: number }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.4.2 단계별 데이터 리스트 조회
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /api/flow/:flowId/step/:stepId/data
|
||
|
|
Query: { page?: number; pageSize?: number }
|
||
|
|
Response: {
|
||
|
|
success: boolean;
|
||
|
|
data: {
|
||
|
|
records: any[];
|
||
|
|
total: number;
|
||
|
|
page: number;
|
||
|
|
pageSize: number;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.4.3 데이터를 다음 단계로 이동
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/flow/move
|
||
|
|
Body: {
|
||
|
|
flowId: number;
|
||
|
|
recordId: string;
|
||
|
|
toStepId: number;
|
||
|
|
note?: string;
|
||
|
|
}
|
||
|
|
Response: { success: boolean }
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.4.4 데이터의 플로우 이력 조회
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /api/flow/audit/:flowId/:recordId
|
||
|
|
Response: {
|
||
|
|
success: boolean;
|
||
|
|
data: FlowAuditLog[]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. 프론트엔드 구조
|
||
|
|
|
||
|
|
### 4.1 플로우 관리 화면 (`/flow-management`)
|
||
|
|
|
||
|
|
#### 4.1.1 파일 구조
|
||
|
|
|
||
|
|
```
|
||
|
|
frontend/src/app/flow-management/
|
||
|
|
├── page.tsx # 메인 페이지
|
||
|
|
├── components/
|
||
|
|
│ ├── FlowList.tsx # 플로우 목록
|
||
|
|
│ ├── FlowEditor.tsx # 플로우 편집기 (React Flow)
|
||
|
|
│ ├── FlowStepPanel.tsx # 단계 속성 편집 패널
|
||
|
|
│ ├── FlowConditionBuilder.tsx # 조건 설정 빌더
|
||
|
|
│ └── FlowPreview.tsx # 플로우 미리보기
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 4.1.2 주요 컴포넌트
|
||
|
|
|
||
|
|
**FlowEditor.tsx**
|
||
|
|
|
||
|
|
- React Flow 라이브러리 사용
|
||
|
|
- 플로우 단계를 노드로 표시
|
||
|
|
- 단계 간 연결선 표시
|
||
|
|
- 드래그앤드롭으로 노드 위치 조정
|
||
|
|
- 노드 클릭 시 FlowStepPanel 표시
|
||
|
|
|
||
|
|
**FlowConditionBuilder.tsx**
|
||
|
|
|
||
|
|
- 테이블 컬럼 선택
|
||
|
|
- 연산자 선택 (equals, not_equals, in, not_in, greater_than, less_than, is_null, is_not_null)
|
||
|
|
- 값 입력
|
||
|
|
- AND/OR 조건 그룹핑
|
||
|
|
- 조건 추가/제거
|
||
|
|
|
||
|
|
### 4.2 화면관리 연동
|
||
|
|
|
||
|
|
#### 4.2.1 새로운 컴포넌트 타입 추가
|
||
|
|
|
||
|
|
**types/screen.ts에 추가:**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export interface FlowWidgetComponent extends BaseComponent {
|
||
|
|
type: "flow-widget";
|
||
|
|
flowId?: number;
|
||
|
|
layout?: "horizontal" | "vertical";
|
||
|
|
cardWidth?: string;
|
||
|
|
cardHeight?: string;
|
||
|
|
showCount?: boolean;
|
||
|
|
showConnections?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type ComponentData =
|
||
|
|
| ContainerComponent
|
||
|
|
| WidgetComponent
|
||
|
|
| GroupComponent
|
||
|
|
| DataTableComponent
|
||
|
|
| ButtonComponent
|
||
|
|
| SplitPanelComponent
|
||
|
|
| RepeaterComponent
|
||
|
|
| FlowWidgetComponent; // 추가
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 4.2.2 FlowWidgetConfigPanel.tsx 생성
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 플로우 위젯 설정 패널
|
||
|
|
interface FlowWidgetConfigPanelProps {
|
||
|
|
component: FlowWidgetComponent;
|
||
|
|
onUpdateProperty: (property: string, value: any) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FlowWidgetConfigPanel({
|
||
|
|
component,
|
||
|
|
onUpdateProperty,
|
||
|
|
}: FlowWidgetConfigPanelProps) {
|
||
|
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
// 플로우 목록 불러오기
|
||
|
|
loadFlows();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<Label>플로우 선택</Label>
|
||
|
|
<Select
|
||
|
|
value={component.flowId?.toString()}
|
||
|
|
onValueChange={(v) => onUpdateProperty("flowId", parseInt(v))}
|
||
|
|
>
|
||
|
|
{flows.map((flow) => (
|
||
|
|
<SelectItem key={flow.id} value={flow.id.toString()}>
|
||
|
|
{flow.name}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label>레이아웃</Label>
|
||
|
|
<Select
|
||
|
|
value={component.layout}
|
||
|
|
onValueChange={(v) => onUpdateProperty("layout", v)}
|
||
|
|
>
|
||
|
|
<SelectItem value="horizontal">가로</SelectItem>
|
||
|
|
<SelectItem value="vertical">세로</SelectItem>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label>카드 너비</Label>
|
||
|
|
<Input
|
||
|
|
value={component.cardWidth || "200px"}
|
||
|
|
onChange={(e) => onUpdateProperty("cardWidth", e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label>데이터 카운트 표시</Label>
|
||
|
|
<Checkbox
|
||
|
|
checked={component.showCount ?? true}
|
||
|
|
onCheckedChange={(checked) => onUpdateProperty("showCount", checked)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label>연결선 표시</Label>
|
||
|
|
<Checkbox
|
||
|
|
checked={component.showConnections ?? true}
|
||
|
|
onCheckedChange={(checked) =>
|
||
|
|
onUpdateProperty("showConnections", checked)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 4.2.3 RealtimePreview.tsx 수정
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// flow-widget 타입 렌더링 추가
|
||
|
|
if (component.type === "flow-widget" && component.flowId) {
|
||
|
|
return <FlowWidgetPreview component={component} />;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 4.2.4 FlowWidgetPreview.tsx 생성
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface FlowWidgetPreviewProps {
|
||
|
|
component: FlowWidgetComponent;
|
||
|
|
interactive?: boolean; // InteractiveScreenViewer에서 true
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FlowWidgetPreview({
|
||
|
|
component,
|
||
|
|
interactive,
|
||
|
|
}: FlowWidgetPreviewProps) {
|
||
|
|
const [flowData, setFlowData] = useState<any>(null);
|
||
|
|
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (component.flowId) {
|
||
|
|
loadFlowData(component.flowId);
|
||
|
|
}
|
||
|
|
}, [component.flowId]);
|
||
|
|
|
||
|
|
const loadFlowData = async (flowId: number) => {
|
||
|
|
const response = await fetch(`/api/flow/definitions/${flowId}`);
|
||
|
|
const result = await response.json();
|
||
|
|
if (result.success) {
|
||
|
|
setFlowData(result.data);
|
||
|
|
// 각 단계별 데이터 카운트 조회
|
||
|
|
loadStepCounts(result.data.steps);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadStepCounts = async (steps: FlowStep[]) => {
|
||
|
|
const counts: Record<number, number> = {};
|
||
|
|
for (const step of steps) {
|
||
|
|
const response = await fetch(
|
||
|
|
`/api/flow/${component.flowId}/step/${step.id}/count`
|
||
|
|
);
|
||
|
|
const result = await response.json();
|
||
|
|
if (result.success) {
|
||
|
|
counts[step.id] = result.data.count;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setStepCounts(counts);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleStepClick = async (stepId: number) => {
|
||
|
|
if (!interactive) return;
|
||
|
|
// 단계 클릭 시 데이터 리스트 모달 표시
|
||
|
|
// TODO: 구현
|
||
|
|
};
|
||
|
|
|
||
|
|
const layout = component.layout || "horizontal";
|
||
|
|
const cardWidth = component.cardWidth || "200px";
|
||
|
|
const cardHeight = component.cardHeight || "120px";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={`flex gap-4 ${
|
||
|
|
layout === "vertical" ? "flex-col" : "flex-row"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{flowData?.steps.map((step: FlowStep, index: number) => (
|
||
|
|
<React.Fragment key={step.id}>
|
||
|
|
<Card
|
||
|
|
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||
|
|
style={{
|
||
|
|
width: cardWidth,
|
||
|
|
height: cardHeight,
|
||
|
|
borderColor: step.color,
|
||
|
|
}}
|
||
|
|
onClick={() => handleStepClick(step.id)}
|
||
|
|
>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-sm">{step.stepName}</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{component.showCount && (
|
||
|
|
<div className="text-2xl font-bold">
|
||
|
|
{stepCounts[step.id] || 0}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{component.showConnections && index < flowData.steps.length - 1 && (
|
||
|
|
<div
|
||
|
|
className={`flex items-center justify-center ${
|
||
|
|
layout === "vertical" ? "h-8" : "w-8"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{layout === "vertical" ? "↓" : "→"}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</React.Fragment>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.3 데이터 리스트 모달
|
||
|
|
|
||
|
|
#### 4.3.1 FlowStepDataModal.tsx 생성
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface FlowStepDataModalProps {
|
||
|
|
flowId: number;
|
||
|
|
stepId: number;
|
||
|
|
stepName: string;
|
||
|
|
isOpen: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FlowStepDataModal({
|
||
|
|
flowId,
|
||
|
|
stepId,
|
||
|
|
stepName,
|
||
|
|
isOpen,
|
||
|
|
onClose,
|
||
|
|
}: FlowStepDataModalProps) {
|
||
|
|
const [data, setData] = useState<any[]>([]);
|
||
|
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen) {
|
||
|
|
loadStepData();
|
||
|
|
}
|
||
|
|
}, [isOpen, stepId]);
|
||
|
|
|
||
|
|
const loadStepData = async () => {
|
||
|
|
const response = await fetch(`/api/flow/${flowId}/step/${stepId}/data`);
|
||
|
|
const result = await response.json();
|
||
|
|
if (result.success) {
|
||
|
|
setData(result.data.records);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleMoveToNextStep = async () => {
|
||
|
|
// 선택된 레코드들을 다음 단계로 이동
|
||
|
|
// TODO: 구현
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
|
|
<DialogContent className="max-w-5xl max-h-[80vh]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{stepName} - 데이터 목록</DialogTitle>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-auto">
|
||
|
|
{/* 데이터 테이블 */}
|
||
|
|
<DataTable data={data} onSelectionChange={setSelectedRows} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={onClose}>
|
||
|
|
닫기
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleMoveToNextStep}
|
||
|
|
disabled={selectedRows.size === 0}
|
||
|
|
>
|
||
|
|
다음 단계로 이동
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.4 오딧 로그 화면
|
||
|
|
|
||
|
|
#### 4.4.1 FlowAuditLog.tsx 생성
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface FlowAuditLogProps {
|
||
|
|
flowId: number;
|
||
|
|
recordId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FlowAuditLog({ flowId, recordId }: FlowAuditLogProps) {
|
||
|
|
const [logs, setLogs] = useState<FlowAuditLog[]>([]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadAuditLogs();
|
||
|
|
}, [flowId, recordId]);
|
||
|
|
|
||
|
|
const loadAuditLogs = async () => {
|
||
|
|
const response = await fetch(`/api/flow/audit/${flowId}/${recordId}`);
|
||
|
|
const result = await response.json();
|
||
|
|
if (result.success) {
|
||
|
|
setLogs(result.data);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<h3 className="text-lg font-semibold">상태 변경 이력</h3>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
{logs.map((log) => (
|
||
|
|
<Card key={log.id}>
|
||
|
|
<CardContent className="pt-4">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Badge>{log.fromStepName || "시작"}</Badge>
|
||
|
|
<span>→</span>
|
||
|
|
<Badge variant="outline">{log.toStepName}</Badge>
|
||
|
|
</div>
|
||
|
|
<div className="text-sm text-gray-500 mt-2">
|
||
|
|
<div>변경자: {log.changedBy}</div>
|
||
|
|
<div>변경일시: {new Date(log.changedAt).toLocaleString()}</div>
|
||
|
|
{log.note && <div>변경사유: {log.note}</div>}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. 백엔드 구현
|
||
|
|
|
||
|
|
### 5.1 서비스 파일 구조
|
||
|
|
|
||
|
|
```
|
||
|
|
backend-node/src/services/
|
||
|
|
├── flowDefinitionService.ts # 플로우 정의 관리
|
||
|
|
├── flowStepService.ts # 플로우 단계 관리
|
||
|
|
├── flowConnectionService.ts # 플로우 연결 관리
|
||
|
|
├── flowExecutionService.ts # 플로우 실행 (카운트, 데이터 조회)
|
||
|
|
├── flowDataMoveService.ts # 데이터 이동 및 오딧 로그
|
||
|
|
└── flowConditionParser.ts # 조건 JSON을 SQL WHERE절로 변환
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.2 flowConditionParser.ts 핵심 로직
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export interface FlowCondition {
|
||
|
|
column: string;
|
||
|
|
operator:
|
||
|
|
| "equals"
|
||
|
|
| "not_equals"
|
||
|
|
| "in"
|
||
|
|
| "not_in"
|
||
|
|
| "greater_than"
|
||
|
|
| "less_than"
|
||
|
|
| "is_null"
|
||
|
|
| "is_not_null";
|
||
|
|
value: any;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface FlowConditionGroup {
|
||
|
|
type: "AND" | "OR";
|
||
|
|
conditions: FlowCondition[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export class FlowConditionParser {
|
||
|
|
/**
|
||
|
|
* 조건 JSON을 SQL WHERE 절로 변환
|
||
|
|
*/
|
||
|
|
static toSqlWhere(conditionGroup: FlowConditionGroup): {
|
||
|
|
where: string;
|
||
|
|
params: any[];
|
||
|
|
} {
|
||
|
|
const conditions: string[] = [];
|
||
|
|
const params: any[] = [];
|
||
|
|
let paramIndex = 1;
|
||
|
|
|
||
|
|
for (const condition of conditionGroup.conditions) {
|
||
|
|
const column = this.sanitizeColumnName(condition.column);
|
||
|
|
|
||
|
|
switch (condition.operator) {
|
||
|
|
case "equals":
|
||
|
|
conditions.push(`${column} = $${paramIndex}`);
|
||
|
|
params.push(condition.value);
|
||
|
|
paramIndex++;
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "not_equals":
|
||
|
|
conditions.push(`${column} != $${paramIndex}`);
|
||
|
|
params.push(condition.value);
|
||
|
|
paramIndex++;
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "in":
|
||
|
|
if (Array.isArray(condition.value) && condition.value.length > 0) {
|
||
|
|
const placeholders = condition.value
|
||
|
|
.map(() => `$${paramIndex++}`)
|
||
|
|
.join(", ");
|
||
|
|
conditions.push(`${column} IN (${placeholders})`);
|
||
|
|
params.push(...condition.value);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "not_in":
|
||
|
|
if (Array.isArray(condition.value) && condition.value.length > 0) {
|
||
|
|
const placeholders = condition.value
|
||
|
|
.map(() => `$${paramIndex++}`)
|
||
|
|
.join(", ");
|
||
|
|
conditions.push(`${column} NOT IN (${placeholders})`);
|
||
|
|
params.push(...condition.value);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "greater_than":
|
||
|
|
conditions.push(`${column} > $${paramIndex}`);
|
||
|
|
params.push(condition.value);
|
||
|
|
paramIndex++;
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "less_than":
|
||
|
|
conditions.push(`${column} < $${paramIndex}`);
|
||
|
|
params.push(condition.value);
|
||
|
|
paramIndex++;
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "is_null":
|
||
|
|
conditions.push(`${column} IS NULL`);
|
||
|
|
break;
|
||
|
|
|
||
|
|
case "is_not_null":
|
||
|
|
conditions.push(`${column} IS NOT NULL`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND ";
|
||
|
|
const where = conditions.length > 0 ? conditions.join(joinOperator) : "1=1";
|
||
|
|
|
||
|
|
return { where, params };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* SQL 인젝션 방지를 위한 컬럼명 검증
|
||
|
|
*/
|
||
|
|
private static sanitizeColumnName(columnName: string): string {
|
||
|
|
// 알파벳, 숫자, 언더스코어만 허용
|
||
|
|
if (!/^[a-zA-Z0-9_]+$/.test(columnName)) {
|
||
|
|
throw new Error(`Invalid column name: ${columnName}`);
|
||
|
|
}
|
||
|
|
return columnName;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.3 flowExecutionService.ts 핵심 로직
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class FlowExecutionService {
|
||
|
|
/**
|
||
|
|
* 특정 플로우 단계에 해당하는 데이터 카운트
|
||
|
|
*/
|
||
|
|
async getStepDataCount(flowId: number, stepId: number): Promise<number> {
|
||
|
|
// 1. 플로우 정의 조회
|
||
|
|
const flowDef = await this.getFlowDefinition(flowId);
|
||
|
|
|
||
|
|
// 2. 플로우 단계 조회
|
||
|
|
const step = await this.getFlowStep(stepId);
|
||
|
|
|
||
|
|
// 3. 조건 JSON을 SQL WHERE절로 변환
|
||
|
|
const { where, params } = FlowConditionParser.toSqlWhere(
|
||
|
|
step.conditionJson
|
||
|
|
);
|
||
|
|
|
||
|
|
// 4. 카운트 쿼리 실행
|
||
|
|
const query = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`;
|
||
|
|
const result = await db.query(query, params);
|
||
|
|
|
||
|
|
return parseInt(result.rows[0].count);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 플로우 단계에 해당하는 데이터 리스트
|
||
|
|
*/
|
||
|
|
async getStepDataList(
|
||
|
|
flowId: number,
|
||
|
|
stepId: number,
|
||
|
|
page: number = 1,
|
||
|
|
pageSize: number = 20
|
||
|
|
): Promise<{ records: any[]; total: number }> {
|
||
|
|
const flowDef = await this.getFlowDefinition(flowId);
|
||
|
|
const step = await this.getFlowStep(stepId);
|
||
|
|
const { where, params } = FlowConditionParser.toSqlWhere(
|
||
|
|
step.conditionJson
|
||
|
|
);
|
||
|
|
|
||
|
|
const offset = (page - 1) * pageSize;
|
||
|
|
|
||
|
|
// 전체 카운트
|
||
|
|
const countQuery = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`;
|
||
|
|
const countResult = await db.query(countQuery, params);
|
||
|
|
const total = parseInt(countResult.rows[0].count);
|
||
|
|
|
||
|
|
// 데이터 조회
|
||
|
|
const dataQuery = `
|
||
|
|
SELECT * FROM ${flowDef.tableName}
|
||
|
|
WHERE ${where}
|
||
|
|
ORDER BY id DESC
|
||
|
|
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||
|
|
`;
|
||
|
|
const dataResult = await db.query(dataQuery, [...params, pageSize, offset]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
records: dataResult.rows,
|
||
|
|
total,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.4 flowDataMoveService.ts 핵심 로직
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export class FlowDataMoveService {
|
||
|
|
/**
|
||
|
|
* 데이터를 다음 플로우 단계로 이동
|
||
|
|
*/
|
||
|
|
async moveDataToStep(
|
||
|
|
flowId: number,
|
||
|
|
recordId: string,
|
||
|
|
toStepId: number,
|
||
|
|
userId: string,
|
||
|
|
note?: string
|
||
|
|
): Promise<void> {
|
||
|
|
const client = await db.getClient();
|
||
|
|
|
||
|
|
try {
|
||
|
|
await client.query("BEGIN");
|
||
|
|
|
||
|
|
// 1. 현재 상태 조회
|
||
|
|
const currentStatus = await this.getCurrentStatus(
|
||
|
|
client,
|
||
|
|
flowId,
|
||
|
|
recordId
|
||
|
|
);
|
||
|
|
const fromStepId = currentStatus?.currentStepId || null;
|
||
|
|
|
||
|
|
// 2. flow_data_status 업데이트 또는 삽입
|
||
|
|
if (currentStatus) {
|
||
|
|
await client.query(
|
||
|
|
`
|
||
|
|
UPDATE flow_data_status
|
||
|
|
SET current_step_id = $1, updated_by = $2, updated_at = NOW()
|
||
|
|
WHERE flow_definition_id = $3 AND record_id = $4
|
||
|
|
`,
|
||
|
|
[toStepId, userId, flowId, recordId]
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
const flowDef = await this.getFlowDefinition(flowId);
|
||
|
|
await client.query(
|
||
|
|
`
|
||
|
|
INSERT INTO flow_data_status
|
||
|
|
(flow_definition_id, table_name, record_id, current_step_id, updated_by)
|
||
|
|
VALUES ($1, $2, $3, $4, $5)
|
||
|
|
`,
|
||
|
|
[flowId, flowDef.tableName, recordId, toStepId, userId]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. 오딧 로그 기록
|
||
|
|
await client.query(
|
||
|
|
`
|
||
|
|
INSERT INTO flow_audit_log
|
||
|
|
(flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
|
|
`,
|
||
|
|
[
|
||
|
|
flowId,
|
||
|
|
currentStatus?.tableName || recordId,
|
||
|
|
recordId,
|
||
|
|
fromStepId,
|
||
|
|
toStepId,
|
||
|
|
userId,
|
||
|
|
note,
|
||
|
|
]
|
||
|
|
);
|
||
|
|
|
||
|
|
await client.query("COMMIT");
|
||
|
|
} catch (error) {
|
||
|
|
await client.query("ROLLBACK");
|
||
|
|
throw error;
|
||
|
|
} finally {
|
||
|
|
client.release();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 데이터의 플로우 이력 조회
|
||
|
|
*/
|
||
|
|
async getAuditLogs(flowId: number, recordId: string): Promise<any[]> {
|
||
|
|
const query = `
|
||
|
|
SELECT
|
||
|
|
fal.*,
|
||
|
|
fs_from.step_name as from_step_name,
|
||
|
|
fs_to.step_name as to_step_name
|
||
|
|
FROM flow_audit_log fal
|
||
|
|
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
|
||
|
|
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
|
||
|
|
WHERE fal.flow_definition_id = $1 AND fal.record_id = $2
|
||
|
|
ORDER BY fal.changed_at DESC
|
||
|
|
`;
|
||
|
|
|
||
|
|
const result = await db.query(query, [flowId, recordId]);
|
||
|
|
return result.rows;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. 구현 단계
|
||
|
|
|
||
|
|
### Phase 1: 기본 구조 (1주)
|
||
|
|
|
||
|
|
- [ ] 데이터베이스 마이그레이션 생성 및 실행
|
||
|
|
- [ ] 백엔드 서비스 기본 구조 생성
|
||
|
|
- [ ] 플로우 정의 CRUD API 구현
|
||
|
|
- [ ] 플로우 단계 CRUD API 구현
|
||
|
|
- [ ] 플로우 연결 API 구현
|
||
|
|
|
||
|
|
### Phase 2: 플로우 편집기 (1주)
|
||
|
|
|
||
|
|
- [ ] React Flow 라이브러리 설치 및 설정
|
||
|
|
- [ ] FlowEditor 컴포넌트 구현
|
||
|
|
- [ ] FlowList 컴포넌트 구현
|
||
|
|
- [ ] FlowStepPanel 구현 (단계 속성 편집)
|
||
|
|
- [ ] FlowConditionBuilder 구현 (조건 설정 UI)
|
||
|
|
- [ ] 플로우 저장/불러오기 기능
|
||
|
|
|
||
|
|
### Phase 3: 화면관리 연동 (3일)
|
||
|
|
|
||
|
|
- [ ] FlowWidgetComponent 타입 정의
|
||
|
|
- [ ] FlowWidgetConfigPanel 구현
|
||
|
|
- [ ] FlowWidgetPreview 구현
|
||
|
|
- [ ] RealtimePreview에 flow-widget 렌더링 추가
|
||
|
|
- [ ] InteractiveScreenViewer에 flow-widget 렌더링 추가
|
||
|
|
|
||
|
|
### Phase 4: 플로우 실행 기능 (1주)
|
||
|
|
|
||
|
|
- [ ] FlowConditionParser 구현 (조건 → SQL 변환)
|
||
|
|
- [ ] FlowExecutionService 구현 (카운트, 데이터 조회)
|
||
|
|
- [ ] 단계별 데이터 카운트 API
|
||
|
|
- [ ] 단계별 데이터 리스트 API
|
||
|
|
- [ ] FlowStepDataModal 구현
|
||
|
|
- [ ] 데이터 선택 및 표시 기능
|
||
|
|
|
||
|
|
### Phase 5: 데이터 이동 및 오딧 (3일)
|
||
|
|
|
||
|
|
- [ ] FlowDataMoveService 구현
|
||
|
|
- [ ] 데이터 이동 API
|
||
|
|
- [ ] 오딧 로그 API
|
||
|
|
- [ ] FlowAuditLog 컴포넌트 구현
|
||
|
|
- [ ] 데이터 이동 시 트랜잭션 처리
|
||
|
|
|
||
|
|
### Phase 6: 테스트 및 최적화 (3일)
|
||
|
|
|
||
|
|
- [ ] 단위 테스트 작성
|
||
|
|
- [ ] 통합 테스트 작성
|
||
|
|
- [ ] 성능 최적화 (인덱스, 쿼리 최적화)
|
||
|
|
- [ ] 사용자 테스트 및 피드백 반영
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. 기술 스택
|
||
|
|
|
||
|
|
### 7.1 프론트엔드
|
||
|
|
|
||
|
|
- **React Flow**: 플로우 시각화 및 편집
|
||
|
|
- **Shadcn/ui**: UI 컴포넌트
|
||
|
|
- **TanStack Query**: 데이터 페칭 및 캐싱
|
||
|
|
|
||
|
|
### 7.2 백엔드
|
||
|
|
|
||
|
|
- **Node.js + Express**: API 서버
|
||
|
|
- **PostgreSQL**: 데이터베이스
|
||
|
|
- **TypeScript**: 타입 안전성
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. 주요 고려사항
|
||
|
|
|
||
|
|
### 8.1 성능
|
||
|
|
|
||
|
|
- 플로우 단계별 데이터 카운트 조회 시 인덱스 활용
|
||
|
|
- 대용량 데이터 처리 시 페이징 필수
|
||
|
|
- 조건 파싱 결과 캐싱
|
||
|
|
|
||
|
|
### 8.2 보안
|
||
|
|
|
||
|
|
- SQL 인젝션 방지: 파라미터 바인딩 사용
|
||
|
|
- 컬럼명 검증: 알파벳, 숫자, 언더스코어만 허용
|
||
|
|
- 사용자 권한 확인
|
||
|
|
|
||
|
|
### 8.3 확장성
|
||
|
|
|
||
|
|
- 복잡한 조건 (중첩 AND/OR) 지원 가능하도록 설계
|
||
|
|
- 플로우 분기 (조건부 분기) 지원 가능하도록 설계
|
||
|
|
- 자동 플로우 이동 (트리거) 추후 추가 가능
|
||
|
|
|
||
|
|
### 8.4 사용성
|
||
|
|
|
||
|
|
- 플로우 편집기의 직관적인 UI
|
||
|
|
- 드래그앤드롭으로 쉬운 조작
|
||
|
|
- 실시간 데이터 카운트 표시
|
||
|
|
- 오딧 로그를 통한 추적 가능성
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. 예상 일정
|
||
|
|
|
||
|
|
| Phase | 기간 | 담당 |
|
||
|
|
| ---------------------------- | ---------- | ------------------ |
|
||
|
|
| Phase 1: 기본 구조 | 1주 | Backend |
|
||
|
|
| Phase 2: 플로우 편집기 | 1주 | Frontend |
|
||
|
|
| Phase 3: 화면관리 연동 | 3일 | Frontend |
|
||
|
|
| Phase 4: 플로우 실행 | 1주 | Backend + Frontend |
|
||
|
|
| Phase 5: 데이터 이동 및 오딧 | 3일 | Backend + Frontend |
|
||
|
|
| Phase 6: 테스트 및 최적화 | 3일 | 전체 |
|
||
|
|
| **총 예상 기간** | **약 4주** | |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. 향후 확장 계획
|
||
|
|
|
||
|
|
### 10.1 자동 플로우 이동
|
||
|
|
|
||
|
|
- 특정 조건 충족 시 자동으로 다음 단계로 이동
|
||
|
|
- 예: 결재 승인 시 자동으로 '승인완료' 단계로 이동
|
||
|
|
|
||
|
|
### 10.2 플로우 분기
|
||
|
|
|
||
|
|
- 조건에 따라 다른 경로로 분기
|
||
|
|
- 예: 금액에 따라 '일반 승인' 또는 '특별 승인' 경로
|
||
|
|
|
||
|
|
### 10.3 플로우 알림
|
||
|
|
|
||
|
|
- 특정 단계 진입 시 담당자에게 알림
|
||
|
|
- 이메일, 시스템 알림 등
|
||
|
|
|
||
|
|
### 10.4 플로우 템플릿
|
||
|
|
|
||
|
|
- 자주 사용하는 플로우 패턴을 템플릿으로 저장
|
||
|
|
- 템플릿에서 새 플로우 생성
|
||
|
|
|
||
|
|
### 10.5 플로우 통계 대시보드
|
||
|
|
|
||
|
|
- 단계별 체류 시간 분석
|
||
|
|
- 병목 구간 식별
|
||
|
|
- 처리 속도 통계
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 11. 참고 자료
|
||
|
|
|
||
|
|
### 11.1 React Flow
|
||
|
|
|
||
|
|
- 공식 문서: https://reactflow.dev/
|
||
|
|
- 예제: https://reactflow.dev/examples
|
||
|
|
|
||
|
|
### 11.2 유사 시스템
|
||
|
|
|
||
|
|
- Jira Workflow
|
||
|
|
- Trello Board
|
||
|
|
- GitHub Projects
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 12. 결론
|
||
|
|
|
||
|
|
이 플로우 관리 시스템은 제품 수명주기, 업무 프로세스, 승인 프로세스 등 다양한 비즈니스 워크플로우를 시각적으로 정의하고 관리할 수 있는 강력한 도구입니다.
|
||
|
|
|
||
|
|
화면관리 시스템과의 긴밀한 통합을 통해 사용자는 코드 없이도 복잡한 워크플로우를 구축하고 실행할 수 있으며, 오딧 로그를 통해 모든 상태 변경을 추적할 수 있습니다.
|
||
|
|
|
||
|
|
단계별 구현을 통해 안정적으로 개발하고, 향후 확장 가능성을 염두에 두어 장기적으로 유지보수하기 쉬운 시스템을 구축할 수 있습니다.
|