버튼 정렬기능 수정
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 강제하는 규칙
|
||||
---
|
||||
|
||||
# API 클라이언트 사용 규칙
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
**절대 `fetch`를 직접 사용하지 않고, 반드시 전용 API 클라이언트를 사용해야 합니다.**
|
||||
|
||||
## 이유
|
||||
|
||||
1. **환경별 URL 자동 처리**: 프로덕션(`v1.vexplor.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청
|
||||
2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링
|
||||
3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가
|
||||
4. **유지보수성**: API 변경 시 한 곳에서만 수정
|
||||
|
||||
## API 클라이언트 위치
|
||||
|
||||
```
|
||||
frontend/lib/api/
|
||||
├── client.ts # Axios 기반 공통 클라이언트
|
||||
├── flow.ts # 플로우 관리 API
|
||||
├── dashboard.ts # 대시보드 API
|
||||
├── mail.ts # 메일 API
|
||||
├── externalCall.ts # 외부 호출 API
|
||||
├── company.ts # 회사 관리 API
|
||||
└── file.ts # 파일 업로드/다운로드 API
|
||||
```
|
||||
|
||||
## 올바른 사용법
|
||||
|
||||
### ❌ 잘못된 방법 (절대 사용 금지)
|
||||
|
||||
```typescript
|
||||
// 직접 fetch 사용 - 환경별 URL이 자동 처리되지 않음
|
||||
const response = await fetch("/api/flow/definitions/29/steps");
|
||||
const data = await response.json();
|
||||
|
||||
// 상대 경로 - 프로덕션에서 잘못된 도메인으로 요청
|
||||
const response = await fetch(`/api/flow/${flowId}/steps`);
|
||||
```
|
||||
|
||||
### ✅ 올바른 방법
|
||||
|
||||
```typescript
|
||||
// 1. API 클라이언트 함수 import
|
||||
import { getFlowSteps } from "@/lib/api/flow";
|
||||
|
||||
// 2. 함수 호출
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
if (stepsResponse.success && stepsResponse.data) {
|
||||
setSteps(stepsResponse.data);
|
||||
}
|
||||
```
|
||||
|
||||
## 주요 API 클라이언트 함수
|
||||
|
||||
### 플로우 관리 ([flow.ts](mdc:frontend/lib/api/flow.ts))
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getFlowDefinitions, // 플로우 목록
|
||||
getFlowById, // 플로우 상세
|
||||
createFlowDefinition, // 플로우 생성
|
||||
updateFlowDefinition, // 플로우 수정
|
||||
deleteFlowDefinition, // 플로우 삭제
|
||||
getFlowSteps, // 스텝 목록 ⭐
|
||||
createFlowStep, // 스텝 생성
|
||||
updateFlowStep, // 스텝 수정
|
||||
deleteFlowStep, // 스텝 삭제
|
||||
getFlowConnections, // 연결 목록 ⭐
|
||||
createFlowConnection, // 연결 생성
|
||||
deleteFlowConnection, // 연결 삭제
|
||||
getStepDataCount, // 스텝 데이터 카운트
|
||||
getStepDataList, // 스텝 데이터 목록
|
||||
getAllStepCounts, // 모든 스텝 카운트
|
||||
moveData, // 데이터 이동
|
||||
moveBatchData, // 배치 데이터 이동
|
||||
getAuditLogs, // 오딧 로그
|
||||
} from "@/lib/api/flow";
|
||||
```
|
||||
|
||||
### Axios 클라이언트 ([client.ts](mdc:frontend/lib/api/client.ts))
|
||||
|
||||
```typescript
|
||||
import apiClient from "@/lib/api/client";
|
||||
|
||||
// GET 요청
|
||||
const response = await apiClient.get("/api/endpoint");
|
||||
|
||||
// POST 요청
|
||||
const response = await apiClient.post("/api/endpoint", { data });
|
||||
|
||||
// PUT 요청
|
||||
const response = await apiClient.put("/api/endpoint", { data });
|
||||
|
||||
// DELETE 요청
|
||||
const response = await apiClient.delete("/api/endpoint");
|
||||
```
|
||||
|
||||
## 새로운 API 함수 추가 가이드
|
||||
|
||||
기존 API 클라이언트에 함수가 없는 경우:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/api/yourModule.ts
|
||||
|
||||
// 1. API URL 동적 설정 (필수)
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (process.env.NEXT_PUBLIC_API_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_URL;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.vexplor.com → api.vexplor.com
|
||||
if (currentHost === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
return "/api";
|
||||
};
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
// 2. API 함수 작성
|
||||
export async function getYourData(id: number): Promise<ApiResponse<YourType>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/your-endpoint/${id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 환경별 URL 매핑
|
||||
|
||||
API 클라이언트는 자동으로 환경을 감지합니다:
|
||||
|
||||
| 현재 호스트 | 백엔드 API URL |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `v1.vexplor.com` | `https://api.vexplor.com/api` |
|
||||
| `localhost:9771` | `http://localhost:8080/api` |
|
||||
| `localhost:3000` | `http://localhost:8080/api` |
|
||||
|
||||
## 체크리스트
|
||||
|
||||
코드 작성 시 다음을 확인하세요:
|
||||
|
||||
- [ ] `fetch('/api/...')` 직접 사용하지 않음
|
||||
- [ ] 적절한 API 클라이언트 함수를 import 함
|
||||
- [ ] API 응답의 `success` 필드를 체크함
|
||||
- [ ] 에러 처리를 구현함
|
||||
- [ ] 새로운 API가 필요하면 `lib/api/` 에 함수 추가
|
||||
|
||||
## 예외 상황
|
||||
|
||||
다음 경우에만 `fetch`를 직접 사용할 수 있습니다:
|
||||
|
||||
1. **외부 서비스 호출**: 다른 도메인의 API 호출 시
|
||||
2. **특수한 헤더가 필요한 경우**: FormData, Blob 등
|
||||
|
||||
이 경우에도 가능하면 전용 API 클라이언트 함수로 래핑하세요.
|
||||
|
||||
## 실제 적용 예시
|
||||
|
||||
### 플로우 위젯 ([FlowWidget.tsx](mdc:frontend/components/screen/widgets/FlowWidget.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
const connectionsResponse = await getFlowConnections(flowId);
|
||||
```
|
||||
|
||||
### 플로우 가시성 패널 ([FlowVisibilityConfigPanel.tsx](mdc:frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx))
|
||||
|
||||
```typescript
|
||||
// ❌ 이전 코드
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
|
||||
// ✅ 수정된 코드
|
||||
const stepsResponse = await getFlowSteps(flowId);
|
||||
```
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [API 클라이언트 공통 설정](mdc:frontend/lib/api/client.ts)
|
||||
- [플로우 API 클라이언트](mdc:frontend/lib/api/flow.ts)
|
||||
- [API URL 유틸리티](mdc:frontend/lib/utils/apiUrl.ts)
|
||||
@@ -0,0 +1,343 @@
|
||||
# 고정 헤더 테이블 표준 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
|
||||
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
|
||||
|
||||
## 필수 구조
|
||||
|
||||
### 1. 기본 HTML 구조
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 1
|
||||
</TableHead>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더 2
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{/* 데이터 행들 */}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. 필수 클래스 설명
|
||||
|
||||
#### 스크롤 컨테이너 (외부 div)
|
||||
|
||||
```tsx
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
```
|
||||
|
||||
**필수 요소:**
|
||||
|
||||
- `relative`: sticky positioning의 기준점
|
||||
- `overflow-auto`: 스크롤 활성화
|
||||
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
|
||||
|
||||
#### Table 컴포넌트
|
||||
|
||||
```tsx
|
||||
<Table noWrapper>
|
||||
```
|
||||
|
||||
**필수 props:**
|
||||
|
||||
- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!)
|
||||
- 이것이 없으면 sticky header가 작동하지 않음
|
||||
|
||||
#### TableHead (헤더 셀)
|
||||
|
||||
```tsx
|
||||
className =
|
||||
"bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
**필수 클래스:**
|
||||
|
||||
- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록)
|
||||
- `sticky top-0`: 상단 고정
|
||||
- `z-10`: 다른 요소 위에 표시
|
||||
- `border-b`: 하단 테두리
|
||||
- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분)
|
||||
|
||||
### 3. 왼쪽 열 고정 (체크박스 등)
|
||||
|
||||
첫 번째 열도 고정하려면:
|
||||
|
||||
```tsx
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox />
|
||||
</TableHead>
|
||||
```
|
||||
|
||||
**z-index 규칙:**
|
||||
|
||||
- 왼쪽+상단 고정: `z-20`
|
||||
- 상단만 고정: `z-10`
|
||||
- 왼쪽만 고정: `z-10`
|
||||
- 일반 셀: z-index 없음
|
||||
|
||||
### 4. 완전한 예제 (체크박스 포함)
|
||||
|
||||
```tsx
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 왼쪽 고정 체크박스 열 */}
|
||||
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
|
||||
{/* 일반 헤더 열들 */}
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
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}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{/* 왼쪽 고정 체크박스 */}
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRow(index)}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 데이터 셀들 */}
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col} className="border-b px-3 py-2">
|
||||
{row[col]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 반응형 대응
|
||||
|
||||
### 모바일: 카드 뷰
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모바일: 카드 뷰 */
|
||||
}
|
||||
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
|
||||
<div className="space-y-2 p-3">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="bg-card rounded-md border p-3">
|
||||
{/* 카드 내용 */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 데스크톱: 테이블 뷰 */
|
||||
}
|
||||
<div
|
||||
className="relative hidden overflow-auto sm:block"
|
||||
style={{ height: "450px" }}
|
||||
>
|
||||
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 자주하는 실수
|
||||
|
||||
### ❌ 잘못된 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 1. noWrapper 없음 - sticky 작동 안함 */
|
||||
}
|
||||
<Table>
|
||||
<TableHeader>...</TableHeader>
|
||||
</Table>;
|
||||
|
||||
{
|
||||
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
|
||||
}
|
||||
<TableHead className="sticky top-0">헤더</TableHead>;
|
||||
|
||||
{
|
||||
/* 3. relative 없음 - sticky 기준점 없음 */
|
||||
}
|
||||
<div className="overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
|
||||
{
|
||||
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
|
||||
}
|
||||
<div className="relative overflow-auto">
|
||||
<Table noWrapper>...</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
### ✅ 올바른 예시
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 모든 필수 요소 포함 */
|
||||
}
|
||||
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||
<Table noWrapper>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||
헤더
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>...</TableBody>
|
||||
</Table>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## 높이 설정 가이드
|
||||
|
||||
### 권장 높이값
|
||||
|
||||
- **소형 리스트**: `300px` ~ `400px`
|
||||
- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준)
|
||||
- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준)
|
||||
|
||||
### 동적 높이 계산
|
||||
|
||||
```tsx
|
||||
// 화면 높이의 60%
|
||||
style={{ height: "60vh" }}
|
||||
|
||||
// 화면 높이 - 헤더/푸터 제외
|
||||
style={{ height: "calc(100vh - 250px)" }}
|
||||
|
||||
// 부모 요소 기준
|
||||
className="h-full overflow-auto"
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 1. 가상 스크롤 (대량 데이터)
|
||||
|
||||
데이터가 1000건 이상인 경우 `react-virtual` 사용 권장:
|
||||
|
||||
```tsx
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 50, // 행 높이
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 페이지네이션
|
||||
|
||||
대량 데이터는 페이지 단위로 렌더링:
|
||||
|
||||
```tsx
|
||||
const paginatedData = data.slice((page - 1) * pageSize, page * pageSize);
|
||||
```
|
||||
|
||||
## 접근성
|
||||
|
||||
### ARIA 레이블
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative overflow-auto"
|
||||
style={{ height: "450px" }}
|
||||
role="region"
|
||||
aria-label="스크롤 가능한 데이터 테이블"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Table noWrapper aria-label="데이터 목록">
|
||||
{/* 테이블 내용 */}
|
||||
</Table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 키보드 네비게이션
|
||||
|
||||
```tsx
|
||||
<TableRow
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 행 내용 */}
|
||||
</TableRow>
|
||||
```
|
||||
|
||||
## 다크 모드 대응
|
||||
|
||||
### 배경색
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 라이트/다크 모드 모두 대응 */
|
||||
}
|
||||
className = "bg-background"; // ✅ 권장
|
||||
|
||||
{
|
||||
/* 고정 색상 - 다크 모드 문제 */
|
||||
}
|
||||
className = "bg-white"; // ❌ 비권장
|
||||
```
|
||||
|
||||
### 그림자
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* 다크 모드에서도 보이는 그림자 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_hsl(var(--border))]";
|
||||
|
||||
{
|
||||
/* 또는 */
|
||||
}
|
||||
className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]";
|
||||
```
|
||||
|
||||
## 참조 파일
|
||||
|
||||
- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820)
|
||||
- **Table 컴포넌트**: `frontend/components/ui/table.tsx`
|
||||
|
||||
## 체크리스트
|
||||
|
||||
테이블 구현 시 다음을 확인하세요:
|
||||
|
||||
- [ ] 외부 div에 `relative overflow-auto` 적용
|
||||
- [ ] 외부 div에 고정 높이 설정
|
||||
- [ ] `<Table noWrapper>` 사용
|
||||
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
|
||||
- [ ] TableHead에 `border-b shadow-[...]` 적용
|
||||
- [ ] 왼쪽 고정 열은 `z-20` 사용
|
||||
- [ ] 모바일 반응형 대응 (카드 뷰)
|
||||
- [ ] 다크 모드 호환 색상 사용
|
||||
Reference in New Issue
Block a user