Merge branch 'master' into master_api_test개발

# Conflicts:
#	src/app/[locale]/(protected)/sales/pricing-management/page.tsx
This commit is contained in:
2025-12-23 16:36:21 +09:00
50 changed files with 664 additions and 244 deletions

View File

@@ -1,6 +1,6 @@
# claudedocs 문서 맵
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-16)
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-20)
## ⭐ 빠른 참조
@@ -134,7 +134,9 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[GUIDE-2025-12-16] options-vs-flattened-data.md` | 🔴 **NEW** - options vs 평탄화 데이터 패턴 (API 응답 매핑 시 options 직접 파싱 금지) |
| `[PLAN-2025-12-19] project-health-improvement.md` | **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) |
| `[PLAN-2025-12-19] page-layout-standardization.md` | 🔴 **NEW** - 페이지 레이아웃 표준화 계획 |
| `[GUIDE-2025-12-16] options-vs-flattened-data.md` | options vs 평탄화 데이터 패턴 (API 응답 매핑 시 options 직접 파싱 금지) |
| `[GUIDE] large-file-handling-strategy.md` | 대용량 파일 처리 전략 (100MB+ CAD 도면, 청크 업로드, 스트리밍 다운로드) |
| `[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | ⭐ **핵심** - Radix UI Select 버그 해결 (Edit 모드 값 표시 안됨 → key prop 강제 리마운트) |
| `i18n-usage-guide.md` | 다국어 사용 가이드 |

View File

@@ -0,0 +1,417 @@
# 프로젝트 헬스 개선 계획서
> 작성일: 2025-12-19
> 최종 업데이트: 2025-12-20
> 목적: 프로젝트 구조, 성능, 안정성 개선
---
## 현황 요약
| 영역 | 상태 | 핵심 이슈 |
|------|------|----------|
| 빌드 설정 | ✅ 해결됨 | ~~98개 타입 에러 무시~~ → 0개, ~~API 키 노출~~ → 서버 사이드 이동 |
| 상태관리 | 🟡 개선 필요 | ItemMasterContext 과부하 (13개 상태) |
| Next.js 활용 | 🔴 심각 | 259개 'use client', Server Component 미활용 |
| 디자인 일관성 | ✅ 완료 | ~~다크모드 일부 미완성~~ → 다크모드 스타일 완성 (2025-12-20) |
---
## Phase 1: 긴급 (이번 주)
### 1.1 TypeScript 에러 해결 + ignoreBuildErrors 제거
**현재 상태:**
```typescript
// next.config.ts
typescript: { ignoreBuildErrors: true } // 98개 에러 숨김
eslint: { ignoreDuringBuilds: true }
```
**작업 내용:**
#### Step 1: 타입 에러 카테고리 분류
| 카테고리 | 개수 | 예시 |
|---------|------|------|
| 모델 타입 불일치 | ~26개 | Employee에 `concurrentPosition` 없음 |
| Props 미스매치 | ~35개 | IntegratedListTemplateV2 props 변경 |
| 배열 타입 불일치 | ~9개 | PricingListItem 타입 정의 |
| 기타 | ~28개 | - |
#### Step 2: 수정 순서
1. [ ] `src/types/` 폴더의 모델 타입 정의 업데이트
2. [ ] `IntegratedListTemplateV2` Props 인터페이스 정리
3. [ ] 페이지별 타입 에러 수정
4. [ ] `ignoreBuildErrors: false` 변경
5. [ ] `npm run build` 성공 확인
**예상 소요:** 2-3시간
**위험도:** 🔴 높음 (빌드 실패 가능)
---
### 1.2 API 키 서버 사이드 이동
**현재 상태:**
```env
# .env.local
NEXT_PUBLIC_API_KEY=42Jfwc6EaR... # 브라우저에서 노출됨!
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=... # 브라우저에서 노출됨!
```
**문제점:**
- `NEXT_PUBLIC_` 접두사 → 클라이언트 번들에 포함
- 브라우저 개발자도구에서 확인 가능
- API 남용/해킹 위험
**작업 내용:**
#### Step 1: 환경변수 이름 변경
```env
# .env.local (수정 후)
API_KEY=42Jfwc6EaR... # 서버만 접근
GOOGLE_MAPS_API_KEY=AIzaSyAS3bA... # 서버만 접근
# 클라이언트에서 필요한 공개 정보만
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
```
#### Step 2: 서버 사이드 프록시 확인
```typescript
// src/app/api/proxy/[...path]/route.ts
// 이미 구현됨 - API_KEY를 서버에서 주입
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`, // 서버에서만 접근
},
});
```
#### Step 3: Google Maps 처리
```typescript
// 옵션 A: 서버 사이드 렌더링
// 옵션 B: API 라우트로 프록시
// 옵션 C: Maps Embed API 사용 (키 제한 설정)
```
**예상 소요:** 30분-1시간
**위험도:** 🟡 중간 (dev 서버 재시작 필요)
---
### 1.3 ThemeContext SSR 수정
**현재 상태:**
```typescript
// src/contexts/ThemeContext.tsx
const [theme, setThemeState] = useState<Theme>("light");
useEffect(() => {
const savedTheme = localStorage.getItem("theme"); // SSR에서 에러 가능
if (savedTheme) {
setThemeState(savedTheme);
}
}, []);
```
**문제점:**
- 서버에서 `localStorage` 접근 시 에러
- Hydration mismatch 발생 가능
**작업 내용:**
#### 수정 코드
```typescript
// src/contexts/ThemeContext.tsx (수정 후)
const [theme, setThemeState] = useState<Theme>(() => {
// SSR 안전 체크
if (typeof window === 'undefined') return 'light';
const savedTheme = localStorage.getItem('theme');
return (savedTheme as Theme) || 'light';
});
// 또는 useEffect 패턴 유지 (더 안전)
const [theme, setThemeState] = useState<Theme>('light');
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setThemeState(savedTheme as Theme);
}
}, []);
```
**예상 소요:** 15분
**위험도:** 🟢 낮음 (HMR 즉시 반영)
---
## Phase 2: 단기 (2주)
### 2.1 ItemMasterContext 분할
**현재 상태:**
```
ItemMasterContext
├── 품목 데이터 (3개 상태)
├── 기준정보 (7개 상태)
├── 폼 구조 (4개 상태)
└── 50개+ 메서드
→ ANY 상태 변경 시 전체 리렌더링
```
**개선 방향:**
```
ItemMasterDataContext → 품목 기본 데이터
ItemFormContext → 페이지/섹션/필드 구조
ItemLookupContext → 단위/재질/처리방식 등 기준정보
```
**작업 내용:**
1. [ ] Context 분할 설계
2. [ ] 각 Context별 Provider 구현
3. [ ] 기존 useItemMaster → 새 Context hooks로 마이그레이션
4. [ ] 테스트
**예상 소요:** 1-2일
**위험도:** 🟡 중간 (기존 코드 변경 필요)
---
### 2.2 IntegratedListTemplate → Zustand Store
**현재 상태:**
```typescript
// 20개+ props 전달
<IntegratedListTemplateV2
searchValue={searchValue}
onSearchChange={setSearchValue}
currentPage={currentPage}
onPageChange={setCurrentPage}
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
// ... 더 많은 props
/>
```
**개선 방향:**
```typescript
// Zustand store
const useListStore = create((set) => ({
// 페이지네이션
currentPage: 1,
pageSize: 20,
setPage: (page) => set({ currentPage: page }),
// 필터/검색
searchValue: '',
filters: {},
setSearch: (value) => set({ searchValue: value }),
// 선택
selectedIds: new Set(),
toggleSelection: (id) => set((state) => { /* ... */ }),
}));
// 컴포넌트에서 사용
function MyListPage() {
const { currentPage, setPage } = useListStore();
return <IntegratedListTemplateV2 />; // props 최소화
}
```
**작업 내용:**
1. [ ] `src/store/listStore.ts` 생성
2. [ ] 공통 리스트 상태 추출
3. [ ] 페이지별 점진적 마이그레이션
4. [ ] IntegratedListTemplateV2 리팩토링
**예상 소요:** 2-3일
**위험도:** 🟡 중간
---
### 2.3 다크모드 스타일 완성
**현재 상태:**
```typescript
// Button - 일부 variant만 dark: 정의
ghost: "hover:bg-accent hover:text-accent-foreground" // dark: 없음
outline: "border-input bg-background" // dark: 없음
```
**작업 내용:**
1. [ ] 모든 UI 컴포넌트 다크모드 스타일 점검
2. [ ] Button, Select, Input 등 주요 컴포넌트 수정
3. [ ] 색상 대비 검증 (WCAG AA 기준)
4. [ ] 다크모드 테스트
**예상 소요:** 1일
**위험도:** 🟢 낮음
---
## Phase 3: 중기 (1개월)
### 3.1 주요 페이지 Server Component 전환
**현재 상태:**
- 259개 'use client' 컴포넌트
- 모든 데이터 페칭: useEffect 내 클라이언트 fetch
- 초기 로딩 지연
**개선 방향:**
```typescript
// Before (Client Component)
'use client';
export default function ItemPage() {
const [items, setItems] = useState([]);
useEffect(() => {
fetch('/api/items').then(r => r.json()).then(setItems);
}, []);
return <ItemList items={items} />;
}
// After (Server Component)
export default async function ItemPage() {
const items = await fetch('/api/items').then(r => r.json());
return <ItemList items={items} />; // 클라이언트로 props 전달
}
```
**마이그레이션 우선순위:**
1. [ ] 정적 페이지 (설정, 정보 페이지)
2. [ ] 리스트 페이지 (items, employees)
3. [ ] 상세 페이지
**예상 소요:** 1-2주
**위험도:** 🟡 중간
---
### 3.2 캐싱 전략 수립
**현재 상태:**
```typescript
cache: 'no-store' // 모든 fetch에 적용 → 성능 저하
```
**개선 방향:**
| 데이터 유형 | 캐싱 전략 | TTL |
|------------|----------|-----|
| 정적 데이터 (카테고리, 단위) | `force-cache` | 1시간 |
| 사용자 데이터 | `no-store` | - |
| 리스트 데이터 | `revalidate: 60` | 1분 |
| 상세 데이터 | `revalidate: 300` | 5분 |
**작업 내용:**
1. [ ] 데이터 유형별 분류
2. [ ] fetch 옵션 표준화
3. [ ] revalidateTag/revalidatePath 활용
4. [ ] 성능 측정
**예상 소요:** 3-5일
**위험도:** 🟢 낮음
---
### 3.3 TanStack Query 도입 검토
**도입 이점:**
- API 상태 자동 관리 (loading, error, data)
- 캐싱 및 백그라운드 리페치
- 낙관적 업데이트
- DevTools 지원
**도입 시 구조:**
```typescript
// hooks/useItems.ts
export function useItems() {
return useQuery({
queryKey: ['items'],
queryFn: () => itemApi.getAll(),
staleTime: 5 * 60 * 1000, // 5분
});
}
// 컴포넌트에서 사용
function ItemList() {
const { data, isLoading, error } = useItems();
// 자동으로 loading/error 처리
}
```
**검토 포인트:**
- [ ] 현재 API 호출 패턴 분석
- [ ] 도입 시 마이그레이션 범위
- [ ] 번들 사이즈 영향 (~20KB)
- [ ] 팀 학습 비용
**예상 소요:** 1-2주 (검토 + 파일럿)
**위험도:** 🟡 중간 (큰 변화)
---
## 체크리스트 요약
### Phase 1 (긴급) ⏰ ✅ 완료 (2025-12-20)
- [x] 1.1 타입 에러 해결 + ignoreBuildErrors 제거 ✅
- 98개 → 0개 에러 수정
- `npx tsc --noEmit` 성공
- `npm run build` 성공
- [x] 1.2 API 키 서버 사이드 이동 ✅
- `NEXT_PUBLIC_API_KEY``API_KEY` 변경
- 프록시 라우트에서 서버 사이드 주입
- [x] 1.3 ThemeContext SSR 수정 ✅
- `typeof window` 체크 추가
### Phase 2 (단기) 📅
- [ ] 2.1 ItemMasterContext 3개로 분할
- [ ] 2.2 IntegratedListTemplate → Zustand store
- [x] 2.3 다크모드 스타일 완성 ✅ (2025-12-20)
- Textarea: Input과 스타일 통일 (`dark:bg-input/30` 추가)
- 모든 UI 컴포넌트 다크모드 지원 확인:
- Button, Select, Input ✅ (dark: 스타일 적용됨)
- Card, Dialog, Sheet, Popover ✅ (CSS 변수로 처리)
- Table, DropdownMenu ✅ (CSS 변수 + dark: 스타일)
- Badge, Checkbox, RadioGroup, Switch ✅ (dark: 스타일 적용됨)
- Alert, Tabs ✅ (CSS 변수 + dark: 스타일)
- [x] 2.4 로딩 스피너 표준화 ✅ (2025-12-20)
- `loading-spinner.tsx` 5가지 변형 컴포넌트 구현:
- `LoadingSpinner`: 인라인/버튼용 (xs, sm, md, lg 사이즈)
- `ContentLoadingSpinner`: 상세/수정 페이지용 (min-h-[400px])
- `PageLoadingSpinner`: 페이지 전환용 (min-h-[calc(100vh-200px)])
- `TableLoadingSpinner`: 테이블/리스트용 (py-16)
- `ButtonSpinner`: 버튼 내부 스피너
- 18개+ 페이지 표준화 적용:
- HR 페이지 (사원, 휴가, 부서, 급여, 근태관리)
- 품목기준정보관리, 게시판, 팝업관리
- 견적관리 상세/수정
- 빌드 테스트 성공 (231 pages)
### Phase 3 (중기) 📆
- [ ] 3.1 주요 페이지 Server Component 전환
- [ ] 3.2 캐싱 전략 수립
- [ ] 3.3 TanStack Query 도입 검토
---
## 참고 자료
- 분석 리포트: 2025-12-19 프로젝트 헬스체크
- 관련 문서:
- `claudedocs/guides/[GUIDE-2025-12-16] options-vs-flattened-data.md`
- `claudedocs/guides/[PLAN-2025-12-19] page-layout-standardization.md`

View File

@@ -3,6 +3,7 @@
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Board, BoardFormData } from '@/components/board/BoardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
@@ -35,14 +36,7 @@ export default function BoardEditPage() {
};
if (!board) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
}
return (

View File

@@ -13,6 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Board } from '@/components/board/BoardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
@@ -54,14 +55,7 @@ export default function BoardDetailPage() {
};
if (!board) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
}
return (

View File

@@ -10,6 +10,7 @@
import { Suspense } from 'react';
import { AttendanceManagement } from '@/components/hr/AttendanceManagement';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
/**
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
export default function AttendanceManagementPage() {
return (
<div>
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
}>
<AttendanceManagement />
</Suspense>
</div>
<Suspense fallback={<ContentLoadingSpinner text="근태 정보를 불러오는 중..." />}>
<AttendanceManagement />
</Suspense>
);
}

View File

@@ -3,6 +3,7 @@
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { CardForm } from '@/components/hr/CardManagement/CardForm';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
@@ -45,14 +46,7 @@ export default function CardEditPage() {
};
if (!card) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
}
return (

View File

@@ -13,6 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Card } from '@/components/hr/CardManagement/types';
// TODO: 실제 API에서 데이터 가져오기
@@ -64,14 +65,7 @@ export default function CardDetailPage() {
};
if (!card) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
}
return (

View File

@@ -7,6 +7,7 @@
import { Suspense } from 'react';
import { DepartmentManagement } from '@/components/hr/DepartmentManagement';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
/**
@@ -19,17 +20,8 @@ export const metadata: Metadata = {
export default function DepartmentManagementPage() {
return (
<div>
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
}>
<DepartmentManagement />
</Suspense>
</div>
<Suspense fallback={<ContentLoadingSpinner text="부서 정보를 불러오는 중..." />}>
<DepartmentManagement />
</Suspense>
);
}

View File

@@ -3,6 +3,7 @@
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Employee, EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
// TODO: 실제 API에서 데이터 가져오기
@@ -58,14 +59,7 @@ export default function EmployeeEditPage() {
};
if (!employee) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="사원 정보를 불러오는 중..." />;
}
return <EmployeeForm mode="edit" employee={employee} onSave={handleSave} />;

View File

@@ -13,6 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Employee } from '@/components/hr/EmployeeManagement/types';
// TODO: 실제 API에서 데이터 가져오기
@@ -77,14 +78,7 @@ export default function EmployeeDetailPage() {
};
if (!employee) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="사원 정보를 불러오는 중..." />;
}
return (

View File

@@ -10,6 +10,7 @@
import { Suspense } from 'react';
import { EmployeeManagement } from '@/components/hr/EmployeeManagement';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
/**
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
export default function EmployeeManagementPage() {
return (
<div>
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
}>
<EmployeeManagement />
</Suspense>
</div>
<Suspense fallback={<ContentLoadingSpinner text="사원 정보를 불러오는 중..." />}>
<EmployeeManagement />
</Suspense>
);
}

View File

@@ -10,6 +10,7 @@
import { Suspense } from 'react';
import { SalaryManagement } from '@/components/hr/SalaryManagement';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
/**
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
export default function SalaryManagementPage() {
return (
<div>
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
}>
<SalaryManagement />
</Suspense>
</div>
<Suspense fallback={<ContentLoadingSpinner text="급여 정보를 불러오는 중..." />}>
<SalaryManagement />
</Suspense>
);
}

View File

@@ -10,6 +10,7 @@
import { Suspense } from 'react';
import { VacationManagement } from '@/components/hr/VacationManagement';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
/**
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
export default function VacationManagementPage() {
return (
<div>
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
}>
<VacationManagement />
</Suspense>
</div>
<Suspense fallback={<ContentLoadingSpinner text="휴가 정보를 불러오는 중..." />}>
<VacationManagement />
</Suspense>
);
}

View File

@@ -302,7 +302,7 @@ export default function EditItemPage() {
// - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙)
// - Material(SM, RM, CS): material_code = 품목명-규격
// 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation)
let submitData = { ...data, item_type: itemType };
let submitData: DynamicFormData = { ...data, item_type: itemType };
if (itemType === 'FG') {
// FG는 품목명이 품목코드가 되므로 name 값으로 code 설정

View File

@@ -7,13 +7,8 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
* - AuthenticatedLayout 내에서 표시됨 (사이드바, 헤더 유지)
* - React Suspense 자동 적용
* - 페이지 전환 시 즉각적인 피드백
* - 공통 레이아웃 스타일로 통일
* - 공통 레이아웃 스타일로 통일 (min-h-[calc(100vh-200px)])
*/
export default function ProtectedLoading() {
return (
<PageLoadingSpinner
text="페이지를 불러오는 중..."
minHeight="min-h-[calc(100vh-200px)]"
/>
);
return <PageLoadingSpinner />;
}

View File

@@ -7,6 +7,7 @@
import { Suspense } from 'react';
import { ItemMasterDataManagement } from '@/components/items/ItemMasterDataManagement';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { Metadata } from 'next';
/**
@@ -19,17 +20,8 @@ export const metadata: Metadata = {
export default function ItemMasterDataManagementPage() {
return (
<div>
<Suspense fallback={
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
}>
<ItemMasterDataManagement />
</Suspense>
</div>
<Suspense fallback={<ContentLoadingSpinner text="품목기준정보를 불러오는 중..." />}>
<ItemMasterDataManagement />
</Suspense>
);
}

View File

@@ -13,7 +13,7 @@ import {
clientToFormData,
} from "@/hooks/useClientList";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
export default function ClientEditPage() {
const router = useRouter();
@@ -60,11 +60,7 @@ export default function ClientEditPage() {
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
return <ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />;
}
if (!editingClient) {

View File

@@ -19,7 +19,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Loader2 } from "lucide-react";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
export default function ClientDetailPage() {
const router = useRouter();
@@ -80,11 +80,7 @@ export default function ClientDetailPage() {
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
return <ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />;
}
if (!client) {

View File

@@ -8,6 +8,7 @@ import { useRouter, useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { QuoteRegistration, QuoteFormData, INITIAL_QUOTE_FORM } from "@/components/quotes/QuoteRegistration";
import { toast } from "sonner";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
// 샘플 견적 데이터 (TODO: API에서 가져오기)
const SAMPLE_QUOTE: QuoteFormData = {
@@ -82,14 +83,7 @@ export default function QuoteEditPage() {
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
}
return (

View File

@@ -39,6 +39,7 @@ import {
FileCheck,
ShoppingCart,
} from "lucide-react";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
// 샘플 견적 데이터 (TODO: API에서 가져오기)
const SAMPLE_QUOTE: QuoteFormData = {
@@ -140,16 +141,7 @@ export default function QuoteDetailPage() {
}, 0) || 0;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
<p className="mt-2 text-sm text-gray-500">
...
</p>
</div>
</div>
);
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
}
if (!quote) {

View File

@@ -13,6 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { MOCK_POPUPS, type Popup } from '@/components/settings/PopupManagement/types';
export default function PopupDetailPage() {
@@ -43,14 +44,7 @@ export default function PopupDetailPage() {
};
if (!popup) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
return <ContentLoadingSpinner text="팝업 정보를 불러오는 중..." />;
}
return (

View File

@@ -60,7 +60,7 @@ export async function GET(request: NextRequest) {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({
refresh_token: refreshToken,

View File

@@ -109,7 +109,7 @@ export async function POST(request: NextRequest) {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({ user_id, user_pwd }),
});

View File

@@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
});
console.log('✅ Backend logout API called successfully');

View File

@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({
refresh_token: refreshToken,

View File

@@ -64,7 +64,7 @@ export async function POST(request: NextRequest) {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify(body),
});

View File

@@ -45,7 +45,7 @@ async function refreshAccessToken(refreshToken: string): Promise<{
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({
refresh_token: refreshToken,
@@ -88,7 +88,7 @@ async function executeBackendRequest(
// FormData인 경우 Content-Type을 생략해야 브라우저가 boundary를 자동 설정
const headers: Record<string, string> = {
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
'Authorization': token ? `Bearer ${token}` : '',
};

View File

@@ -491,8 +491,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
getItemId={(item: BillRecord) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
addButtonLabel="어음 등록"
onAddClick={() => router.push('/ko/accounting/bills/new')}
pagination={{
currentPage,
totalPages,

View File

@@ -92,6 +92,7 @@ const generateMockData = (): PurchaseRecord[] => {
sourceDocument: i % 3 === 0 ? {
type: i % 2 === 0 ? 'proposal' : 'expense_report',
documentNo: `DOC-2025-${String(i + 1).padStart(4, '0')}`,
title: `${i % 2 === 0 ? '품의' : '지출'} 건 - ${vendors[i % vendors.length]}`,
expectedCost: supplyAmount,
} : undefined,
withdrawalAccount: {

View File

@@ -168,7 +168,7 @@ export function DocumentCreate() {
})),
cardInfo: expenseReportData.cardId || '-',
totalAmount: expenseReportData.totalAmount,
attachments: expenseReportData.attachments,
attachments: expenseReportData.attachments.map(f => f.name),
approvers,
drafter,
};
@@ -182,7 +182,7 @@ export function DocumentCreate() {
description: proposalData.description || '-',
reason: proposalData.reason || '-',
estimatedCost: proposalData.estimatedCost,
attachments: proposalData.attachments,
attachments: proposalData.attachments.map(f => f.name),
approvers,
drafter,
};

View File

@@ -57,6 +57,10 @@ const initialFormData: EmployeeFormData = {
confirmPassword: '',
role: 'user',
accountStatus: 'active',
clockInLocation: '',
clockOutLocation: '',
resignationDate: '',
resignationReason: '',
};
export function EmployeeDialog({
@@ -103,6 +107,10 @@ export function EmployeeDialog({
confirmPassword: '',
role: employee.userInfo?.role || 'user',
accountStatus: employee.userInfo?.accountStatus || 'active',
clockInLocation: employee.clockInLocation || '',
clockOutLocation: employee.clockOutLocation || '',
resignationDate: employee.resignationDate || '',
resignationReason: employee.resignationReason || '',
});
} else if (open && mode === 'create') {
setFormData(initialFormData);

View File

@@ -125,6 +125,8 @@ export interface Employee {
clockOutLocation?: string; // 퇴근 위치
resignationDate?: string; // 퇴사일
resignationReason?: string; // 퇴직사유
concurrentPosition?: string; // 겸직 직위
concurrentReason?: string; // 겸직 사유
// 사용자 정보 (시스템 계정)
userInfo?: UserInfo;

View File

@@ -19,7 +19,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { VacationUsageRecord, VacationAdjustment, VacationType } from './types';
import type { VacationUsageRecord, VacationAdjustment, AdjustableVacationType } from './types';
import { VACATION_TYPE_LABELS } from './types';
interface VacationAdjustDialogProps {
@@ -62,7 +62,7 @@ export function VacationAdjustDialog({
}, [open]);
// 조정값 증가
const handleIncrease = (type: VacationType) => {
const handleIncrease = (type: AdjustableVacationType) => {
setAdjustments(prev => ({
...prev,
[type]: prev[type] + 1,
@@ -70,7 +70,7 @@ export function VacationAdjustDialog({
};
// 조정값 감소
const handleDecrease = (type: VacationType) => {
const handleDecrease = (type: AdjustableVacationType) => {
setAdjustments(prev => ({
...prev,
[type]: prev[type] - 1,
@@ -78,7 +78,7 @@ export function VacationAdjustDialog({
};
// 조정값 직접 입력
const handleInputChange = (type: VacationType, value: string) => {
const handleInputChange = (type: AdjustableVacationType, value: string) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
setAdjustments(prev => ({
@@ -97,7 +97,7 @@ export function VacationAdjustDialog({
const handleSave = () => {
const adjustmentList: VacationAdjustment[] = [];
(Object.keys(adjustments) as VacationType[]).forEach((type) => {
(Object.keys(adjustments) as AdjustableVacationType[]).forEach((type) => {
if (adjustments[type] !== 0) {
adjustmentList.push({
vacationType: type,
@@ -116,7 +116,7 @@ export function VacationAdjustDialog({
if (!record) return null;
const vacationTypes: VacationType[] = ['annual', 'monthly', 'reward', 'other'];
const vacationTypes: AdjustableVacationType[] = ['annual', 'monthly', 'reward', 'other'];
return (
<Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -9,6 +9,9 @@ export type MainTabType = 'usage' | 'grant' | 'request';
// 휴가 유형
export type VacationType = 'annual' | 'monthly' | 'reward' | 'condolence' | 'other';
// 조정 가능한 휴가 유형 (VacationAdjustDialog에서 사용)
export type AdjustableVacationType = 'annual' | 'monthly' | 'reward' | 'other';
// 필터 옵션
export type FilterOption = 'all' | 'hasVacation' | 'noVacation';

View File

@@ -55,7 +55,24 @@ export function DropdownField({
unitOptions,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const stringValue = value !== null && value !== undefined ? String(value) : '';
// is_active 필드인지 확인
const isActiveField = fieldKey === 'is_active' || fieldKey.endsWith('_is_active');
// 옵션을 먼저 정규화 (is_active 값 변환에 필요)
const rawOptions = normalizeOptions(field.options);
// is_active 필드일 때 boolean 값을 옵션에 맞게 변환
let stringValue = '';
if (value !== null && value !== undefined) {
if (isActiveField && rawOptions.length >= 2) {
// boolean/숫자 값을 첫번째(활성) 또는 두번째(비활성) 옵션 값으로 매핑
const isActive = value === true || value === 'true' || value === 1 || value === '1' || value === '활성';
stringValue = isActive ? rawOptions[0].value : rawOptions[1].value;
} else {
stringValue = String(value);
}
}
// field_key 또는 field_name이 '단위'/'unit' 관련이면 unitOptions 사용
const isUnitField =
@@ -73,8 +90,8 @@ export function DropdownField({
value: u.value,
}));
} else {
// field.options를 정규화
options = normalizeOptions(field.options);
// rawOptions는 이미 위에서 정규화
options = rawOptions;
}
// 옵션이 없으면 드롭다운을 disabled로 표시

View File

@@ -1,8 +1,7 @@
'use client';
import { useMemo } from 'react';
import { DynamicFormData, ItemType, StructuredFieldConfig } from '../types';
import { ItemFieldResponse } from '@/types/item';
import { DynamicFormData, ItemType, StructuredFieldConfig, ItemFieldResponse } from '../types';
/**
* 부품 유형 탐지 결과
@@ -27,7 +26,7 @@ export interface UseFieldDetectionParams {
/** 폼 구조 정보 */
structure: StructuredFieldConfig | null;
/** 현재 선택된 품목 유형 (FG, PT, SM, RM, CS) */
selectedItemType: ItemType;
selectedItemType: ItemType | '';
/** 현재 폼 데이터 */
formData: DynamicFormData;
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useRef } from 'react';
import { DynamicFormData, ItemType, StructuredFieldConfig } from '../types';
import { BendingFieldKeys, CategoryKeyWithId } from './useItemCodeGeneration';
import { BendingDetail } from '@/types/item';
import type { DynamicFormData, DynamicFieldValue, ItemType, StructuredFieldConfig } from '../types';
import type { BendingFieldKeys, CategoryKeyWithId } from './useItemCodeGeneration';
import type { BendingDetail } from '@/types/item';
/**
* usePartTypeHandling 훅 입력 파라미터
@@ -20,7 +20,7 @@ export interface UsePartTypeHandlingParams {
/** 품목명 필드 키 */
itemNameKey: string;
/** 필드 값 설정 함수 */
setFieldValue: (key: string, value: unknown) => void;
setFieldValue: (key: string, value: DynamicFieldValue) => void;
/** 현재 폼 데이터 */
formData: DynamicFormData;
/** 절곡부품 필드 키 정보 */

View File

@@ -12,6 +12,10 @@ import type {
PageStructureResponse,
} from '@/types/item-master-api';
// Re-export types for hooks
export type { ItemFieldResponse } from '@/types/item-master-api';
export type { ItemType } from '@/types/item';
// ============================================
// 조건부 표시 타입
// ============================================
@@ -244,4 +248,14 @@ export function convertToFormStructure(
orderNo: f.order_no,
})),
};
}
}
// ============================================
// 타입 별칭 (하위 호환성)
// ============================================
/**
* StructuredFieldConfig는 DynamicFormStructure의 별칭
* (hooks에서 사용하는 이름)
*/
export type StructuredFieldConfig = DynamicFormStructure;

View File

@@ -27,6 +27,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Search, Plus, Edit, Trash2, Package, Loader2 } from 'lucide-react';
import { TableLoadingSpinner } from '@/components/ui/loading-spinner';
import { useItemList } from '@/hooks/useItemList';
import {
IntegratedListTemplateV2,
@@ -129,12 +130,7 @@ export default function ItemListClient() {
// 로딩 상태
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground"> ...</span>
</div>
);
return <TableLoadingSpinner text="품목 목록을 불러오는 중..." />;
}
// 유형 변경 핸들러

View File

@@ -47,11 +47,17 @@ export interface ListMobileCardProps {
/** 카드 클릭 핸들러 */
onCardClick?: () => void;
/** 카드 클릭 핸들러 (onCardClick 별칭) */
onClick?: () => void;
/** 체크박스 표시 여부 */
showCheckbox?: boolean;
/** 헤더 영역 뱃지들 (번호, 코드 등) */
headerBadges?: ReactNode;
/** 카드 제목 (주요 정보) */
title: string;
title: string | ReactNode;
/** 상태 뱃지 (우측 상단) */
statusBadge?: ReactNode;
@@ -81,11 +87,13 @@ export interface InfoFieldProps {
label: string;
value: string | number | ReactNode;
valueClassName?: string;
/** 추가 className */
className?: string;
}
export function InfoField({ label, value, valueClassName = "" }: InfoFieldProps) {
export function InfoField({ label, value, valueClassName = "", className = "" }: InfoFieldProps) {
return (
<div className="space-y-0.5">
<div className={`space-y-0.5 ${className}`}>
<p className="text-xs text-muted-foreground">{label}</p>
<div className={`text-sm font-medium ${valueClassName}`}>{value}</div>
</div>
@@ -97,6 +105,8 @@ export function ListMobileCard({
isSelected,
onToggleSelection,
onCardClick,
onClick,
showCheckbox = true,
headerBadges,
title,
statusBadge,
@@ -106,6 +116,7 @@ export function ListMobileCard({
topContent,
bottomContent
}: ListMobileCardProps) {
const handleCardClick = onClick || onCardClick;
return (
<div
className={`border rounded-lg p-5 space-y-4 bg-white dark:bg-card transition-all cursor-pointer ${
@@ -113,7 +124,7 @@ export function ListMobileCard({
? 'border-blue-500 bg-blue-50/50'
: 'border-gray-200 hover:border-primary/50'
} ${className}`}
onClick={onCardClick}
onClick={handleCardClick}
>
{/* 상단 추가 콘텐츠 */}
{topContent}
@@ -121,12 +132,14 @@ export function ListMobileCard({
{/* 헤더: 체크박스 + 뱃지 + 제목 / 우측에 상태 뱃지 */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 h-5 w-5"
/>
{showCheckbox && (
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelection}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 h-5 w-5"
/>
)}
<div className="flex-1 min-w-0">
{/* 헤더 뱃지들 (번호, 코드 등) */}
{headerBadges && (

View File

@@ -3,12 +3,14 @@
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
interface PageHeaderProps {
export interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
icon?: LucideIcon;
versionBadge?: ReactNode;
/** 뒤로가기 핸들러 */
onBack?: () => void;
}
export function PageHeader({ title, description, actions, icon: Icon, versionBadge }: PageHeaderProps) {

View File

@@ -94,7 +94,7 @@ async function getApiHeaders(): Promise<HeadersInit> {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
};
}

View File

@@ -1,50 +1,114 @@
// 로딩 스피너 컴포넌트
// API 호출 중 로딩 상태 표시용
// 대시보드 스타일로 통일 (border-4 border-solid border-primary border-r-transparent)
/**
* 로딩 스피너 컴포넌트 (표준화됨)
*
* 사용 가이드:
* - LoadingSpinner: 인라인/버튼 내부/작은 영역용
* - ContentLoadingSpinner: 컨텐츠 영역 로딩용 (상세/수정 페이지)
* - PageLoadingSpinner: 페이지 전환용 (loading.tsx, 전체 페이지)
*
* 스타일: border-4 border-solid border-primary border-r-transparent
*/
import React from 'react';
// ============================================
// 1. 기본 스피너 (인라인/버튼 내부용)
// ============================================
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
size?: 'xs' | 'sm' | 'md' | 'lg';
className?: string;
text?: string;
}
const sizeClasses = {
xs: 'h-3 w-3 border-2',
sm: 'h-4 w-4 border-2',
md: 'h-8 w-8 border-3',
lg: 'h-12 w-12 border-4'
};
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
className = '',
text
}) => {
const sizeClasses = {
sm: 'h-4 w-4 border-2',
md: 'h-8 w-8 border-4',
lg: 'h-12 w-12 border-4'
};
return (
<div className={`flex flex-col items-center justify-center gap-2 ${className}`}>
<div className={`animate-spin rounded-full border-solid border-primary border-r-transparent ${sizeClasses[size]}`} />
<div
className={`animate-spin rounded-full border-solid border-primary border-r-transparent ${sizeClasses[size]}`}
/>
{text && <p className="text-sm text-muted-foreground">{text}</p>}
</div>
);
};
// 페이지 레벨 로딩 스피너 (전체 화면 중앙 배치)
// ============================================
// 2. 컨텐츠 영역 스피너 (상세/수정 페이지용)
// ============================================
interface ContentLoadingSpinnerProps {
text?: string;
}
export const ContentLoadingSpinner: React.FC<ContentLoadingSpinnerProps> = ({
text = '불러오는 중...'
}) => {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center space-y-3">
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
<p className="text-sm text-muted-foreground">{text}</p>
</div>
</div>
);
};
// ============================================
// 3. 페이지 레벨 스피너 (페이지 전환용)
// ============================================
interface PageLoadingSpinnerProps {
text?: string;
minHeight?: string;
}
export const PageLoadingSpinner: React.FC<PageLoadingSpinnerProps> = ({
text = '불러오는 중...',
minHeight = 'min-h-[60vh]'
text = '페이지를 불러오는 중...'
}) => {
return (
<div className={`flex items-center justify-center ${minHeight}`}>
<div className="flex items-center justify-center min-h-[calc(100vh-200px)]">
<div className="text-center space-y-4">
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
<p className="text-muted-foreground font-medium">{text}</p>
</div>
</div>
);
};
// ============================================
// 4. 테이블/리스트 오버레이 스피너
// ============================================
interface TableLoadingSpinnerProps {
text?: string;
rows?: number;
}
export const TableLoadingSpinner: React.FC<TableLoadingSpinnerProps> = ({
text = '데이터를 불러오는 중...',
rows = 5
}) => {
return (
<div className="flex items-center justify-center py-16">
<div className="text-center space-y-3">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
<p className="text-sm text-muted-foreground">{text}</p>
</div>
</div>
);
};
// ============================================
// 5. 버튼 내부 스피너 (저장 중 등)
// ============================================
export const ButtonSpinner: React.FC = () => {
return (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent" />
);
};

View File

@@ -8,8 +8,11 @@ const Textarea = React.forwardRef<
>(({ className, ...props }, ref) => {
return (
<textarea
data-slot="textarea"
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
ref={ref}

View File

@@ -6,6 +6,8 @@ import { useState, useEffect } from 'react';
*/
export function useUserRole() {
const [userRole, setUserRole] = useState<string>(() => {
// SSR-safe: 서버에서는 기본값 반환
if (typeof window === 'undefined') return "CEO";
const userDataStr = localStorage.getItem("user");
const userData = userDataStr ? JSON.parse(userDataStr) : null;
return userData?.role || "CEO";

View File

@@ -4,33 +4,29 @@
/**
* API 요청에 사용할 헤더 생성 (프록시 패턴용)
* - Content-Type: application/json
* - X-API-KEY: 환경변수에서 로드
* - Accept: Laravel expectsJson() 체크용
*
* ⚠️ 중요: Authorization 헤더는 Next.js 프록시에서 서버사이드로 처리
* ⚠️ 중요: Authorization과 X-API-KEY는 Next.js 프록시에서 서버사이드로 처리
* - HttpOnly 쿠키는 JavaScript로 읽을 수 없음 (보안)
* - /api/proxy/* 라우트가 서버에서 쿠키 읽어 Authorization 헤더 추가
* - /api/proxy/* 라우트가 서버에서 쿠키와 API_KEY를 헤더 추가
*/
export const getAuthHeaders = (): HeadersInit => {
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
};
};
/**
* Multipart/form-data 요청에 사용할 헤더 생성 (프록시 패턴용)
* - Content-Type은 브라우저가 자동으로 설정 (boundary 포함)
* - X-API-KEY만 포함
*
* ⚠️ 중요: Authorization 헤더는 Next.js 프록시에서 서버사이드로 처리
* - /api/proxy/* 라우트가 서버에서 쿠키 읽어 Authorization 헤더 추가
* ⚠️ 중요: Authorization과 X-API-KEY는 Next.js 프록시에서 서버사이드로 처리
* - /api/proxy/* 라우트가 서버에서 쿠키와 API_KEY를 헤더 추가
*/
export const getMultipartHeaders = (): HeadersInit => {
return {
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
// Content-Type은 명시하지 않음 (multipart/form-data; boundary=... 자동 설정)
};
};

View File

@@ -53,7 +53,7 @@ export async function proxyToPhpBackend(
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
...options?.headers,
},
});

View File

@@ -53,7 +53,7 @@ export async function refreshTokenServer(refreshToken: string): Promise<{
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${refreshToken}`,
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'X-API-KEY': process.env.API_KEY || '',
},
});

View File

@@ -28,7 +28,7 @@ export const useThemeStore = create<ThemeState>()(
},
}),
{
name: 'sam-theme',
name: 'theme', // ThemeContext와 동일한 키 사용 (마이그레이션 호환성)
// Zustand persist 재수화 시 HTML 클래스 복원
onRehydrateStorage: () => (state) => {
if (state?.theme) {

File diff suppressed because one or more lines are too long