fix: TypeScript 타입 오류 수정 및 설정 페이지 추가

- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext)
- HeadersInit → Record<string, string> 타입 변경
- Zustand useShallow 마이그레이션 (zustand/react/shallow)
- DataTable, ListPageTemplate 제네릭 타입 제약 추가
- 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한)
- HR 관리 페이지 추가 (급여, 휴가)
- 단가관리 페이지 리팩토링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-09 18:07:47 +09:00
parent 48dbba0e5f
commit ded0bc2439
98 changed files with 10608 additions and 1204 deletions

View File

@@ -0,0 +1,38 @@
/**
* 급여관리 페이지 (Salary Management)
*
* 직원 급여 정보를 관리하는 시스템
* - 급여 목록 조회/검색/필터
* - 지급완료/지급예정 상태 변경
* - 급여 상세 정보 조회
* - 엑셀 다운로드
*/
import { Suspense } from 'react';
import { SalaryManagement } from '@/components/hr/SalaryManagement';
import type { Metadata } from 'next';
/**
* 메타데이터 설정
*/
export const metadata: Metadata = {
title: '급여관리',
description: '직원 급여 정보를 관리합니다',
};
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>
);
}

View File

@@ -0,0 +1,38 @@
/**
* 휴가관리 페이지 (Vacation Management)
*
* 직원 휴가 정보를 관리하는 시스템
* - 휴가 목록 조회/검색/필터
* - 휴가 등록/조정
* - 휴가 종류 설정
* - 엑셀 다운로드
*/
import { Suspense } from 'react';
import { VacationManagement } from '@/components/hr/VacationManagement';
import type { Metadata } from 'next';
/**
* 메타데이터 설정
*/
export const metadata: Metadata = {
title: '휴가관리',
description: '직원 휴가 정보를 관리합니다',
};
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>
);
}

View File

@@ -2,7 +2,7 @@
* 품목 수정 페이지
*
* API 연동:
* - GET /api/proxy/items/code/{itemCode}?include_bom=true (품목 조회)
* - GET /api/proxy/items/{id} (품목 조회 - id 기반 통일)
* - PUT /api/proxy/items/{id} (품목 수정)
*/
@@ -71,6 +71,9 @@ interface ItemApiResponse {
function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
const formData: DynamicFormData = {};
// attributes 객체 추출 (조립부품 등의 동적 필드가 여기에 저장됨)
const attributes = (data.attributes || {}) as Record<string, unknown>;
// 백엔드 Product 모델 필드: code, name, product_type
// 프론트엔드 폼 필드: item_name, item_code 등 (snake_case)
@@ -86,19 +89,29 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
if (data.remarks) formData['note'] = data.remarks; // Material remarks → note 매핑
formData['is_active'] = data.is_active ?? true;
// 부품 관련 필드 (PT)
if (data.part_type) formData['part_type'] = data.part_type;
if (data.part_usage) formData['part_usage'] = data.part_usage;
if (data.material) formData['material'] = data.material;
if (data.length) formData['length'] = data.length;
if (data.thickness) formData['thickness'] = data.thickness;
// 부품 관련 필드 (PT) - data와 attributes 둘 다에서 찾음
const partType = data.part_type || attributes.part_type;
const partUsage = data.part_usage || attributes.part_usage;
const material = data.material || attributes.material;
const length = data.length || attributes.length;
const thickness = data.thickness || attributes.thickness;
if (partType) formData['part_type'] = String(partType);
if (partUsage) formData['part_usage'] = String(partUsage);
if (material) formData['material'] = String(material);
if (length) formData['length'] = String(length);
if (thickness) formData['thickness'] = String(thickness);
// 조립 부품 관련
if (data.installation_type) formData['installation_type'] = data.installation_type;
if (data.assembly_type) formData['assembly_type'] = data.assembly_type;
if (data.assembly_length) formData['assembly_length'] = data.assembly_length;
if (data.side_spec_width) formData['side_spec_width'] = data.side_spec_width;
if (data.side_spec_height) formData['side_spec_height'] = data.side_spec_height;
// 조립 부품 관련 - data와 attributes 둘 다에서 찾음
const installationType = data.installation_type || attributes.installation_type;
const assemblyType = data.assembly_type || attributes.assembly_type;
const assemblyLength = data.assembly_length || attributes.assembly_length;
const sideSpecWidth = data.side_spec_width || attributes.side_spec_width;
const sideSpecHeight = data.side_spec_height || attributes.side_spec_height;
if (installationType) formData['installation_type'] = String(installationType);
if (assemblyType) formData['assembly_type'] = String(assemblyType);
if (assemblyLength) formData['assembly_length'] = String(assemblyLength);
if (sideSpecWidth) formData['side_spec_width'] = String(sideSpecWidth);
if (sideSpecHeight) formData['side_spec_height'] = String(sideSpecHeight);
// 제품 관련 필드 (FG)
if (data.product_category) formData['product_category'] = data.product_category;
@@ -110,11 +123,11 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
if (data.certification_end_date) formData['certification_end_date'] = data.certification_end_date;
// 파일 관련 필드 (edit 모드에서 기존 파일 표시용)
if (data.bending_diagram) formData['bending_diagram'] = data.bending_diagram;
if (data.specification_file) formData['specification_file'] = data.specification_file;
if (data.specification_file_name) formData['specification_file_name'] = data.specification_file_name;
if (data.certification_file) formData['certification_file'] = data.certification_file;
if (data.certification_file_name) formData['certification_file_name'] = data.certification_file_name;
if (data.bending_diagram) formData['bending_diagram'] = String(data.bending_diagram);
if (data.specification_file) formData['specification_file'] = String(data.specification_file);
if (data.specification_file_name) formData['specification_file_name'] = String(data.specification_file_name);
if (data.certification_file) formData['certification_file'] = String(data.certification_file);
if (data.certification_file_name) formData['certification_file_name'] = String(data.certification_file_name);
// Material(SM, RM, CS) options 필드 매핑
// 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨
@@ -179,17 +192,25 @@ export default function EditItemPage() {
let response: Response;
// Materials (SM, RM, CS)는 다른 API 엔드포인트 사용
if (MATERIAL_TYPES.includes(urlItemType) && urlItemId) {
// GET /api/proxy/items/{id}?item_type=MATERIAL
// console.log('[EditItem] Using Material API');
response = await fetch(`/api/proxy/items/${urlItemId}?item_type=MATERIAL`);
} else {
// Products (FG, PT): GET /api/proxy/items/code/{itemCode}?include_bom=true
// console.log('[EditItem] Using Product API');
response = await fetch(`/api/proxy/items/code/${encodeURIComponent(itemCode)}?include_bom=true`);
// 모든 품목: GET /api/proxy/items/{id} (id 기반 통일)
if (!urlItemId) {
setError('품목 ID가 없습니다.');
setIsLoading(false);
return;
}
// Materials (SM, RM, CS)는 item_type=MATERIAL 쿼리 파라미터 추가
const isMaterial = isMaterialType(urlItemType);
const queryParams = new URLSearchParams();
if (isMaterial) {
queryParams.append('item_type', 'MATERIAL');
} else {
queryParams.append('include_bom', 'true');
}
console.log('[EditItem] Fetching:', { urlItemId, urlItemType, isMaterial });
response = await fetch(`/api/proxy/items/${urlItemId}?${queryParams.toString()}`);
if (!response.ok) {
if (response.status === 404) {
setError('품목을 찾을 수 없습니다.');
@@ -206,12 +227,13 @@ export default function EditItemPage() {
if (result.success && result.data) {
const apiData = result.data as ItemApiResponse;
// console.log('========== [EditItem] API 원본 데이터 ==========');
// console.log('is_active:', apiData.is_active);
// console.log('specification:', apiData.specification);
// console.log('unit:', apiData.unit);
// console.log('전체 데이터:', JSON.stringify(apiData, null, 2));
// console.log('================================================');
console.log('========== [EditItem] API 원본 데이터 (백엔드 응답) ==========');
console.log('id:', apiData.id);
console.log('specification:', apiData.specification);
console.log('unit:', apiData.unit);
console.log('is_active:', apiData.is_active);
console.log('전체:', apiData);
console.log('==============================================================');
// ID, 품목 유형 저장
// Product: product_type, Material: material_type 또는 type_code
@@ -222,12 +244,12 @@ export default function EditItemPage() {
// 폼 데이터로 변환
const formData = mapApiResponseToFormData(apiData);
// console.log('========== [EditItem] Mapped form data ==========');
// console.log('is_active:', formData['is_active']);
// console.log('specification:', formData['specification']);
// console.log('unit:', formData['unit']);
// console.log('전체 매핑 데이터:', JSON.stringify(formData, null, 2));
// console.log('=================================================');
console.log('========== [EditItem] 폼에 전달되는 initialData ==========');
console.log('specification:', formData['specification']);
console.log('unit:', formData['unit']);
console.log('is_active:', formData['is_active']);
console.log('전체:', formData);
console.log('==========================================================');
setInitialData(formData);
} else {
setError(result.message || '품목 정보를 불러올 수 없습니다.');
@@ -261,7 +283,7 @@ export default function EditItemPage() {
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용
// Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용
const isMaterial = itemType ? MATERIAL_TYPES.includes(itemType) : false;
const isMaterial = isMaterialType(itemType);
// 디버깅: material_code 생성 관련 변수 확인 (필요 시 주석 해제)
// console.log('========== [EditItem] handleSubmit 디버깅 ==========');
@@ -312,11 +334,14 @@ export default function EditItemPage() {
}
// API 호출
// console.log('========== [EditItem] PUT 요청 데이터 ==========');
// console.log('URL:', updateUrl);
// console.log('Method:', method);
// console.log('전송 데이터:', JSON.stringify(submitData, null, 2));
// console.log('================================================');
console.log('========== [EditItem] 수정 요청 데이터 ==========');
console.log('URL:', updateUrl);
console.log('Method:', method);
console.log('specification:', submitData.specification);
console.log('unit:', submitData.unit);
console.log('is_active:', submitData.is_active);
console.log('전체:', submitData);
console.log('=================================================');
const response = await fetch(updateUrl, {
method,

View File

@@ -1,7 +1,7 @@
/**
* 품목 상세 조회 페이지
*
* API 연동: GET /api/proxy/items/code/{itemCode}?include_bom=true
* API 연동: GET /api/proxy/items/{id} (id 기반 통일)
*/
'use client';
@@ -10,7 +10,7 @@ import { useEffect, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { notFound } from 'next/navigation';
import ItemDetailClient from '@/components/items/ItemDetailClient';
import type { ItemMaster } from '@/types/item';
import type { ItemMaster, ItemType, ProductCategory, PartType, PartUsage } from '@/types/item';
import { Loader2 } from 'lucide-react';
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
@@ -20,6 +20,9 @@ const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
* API 응답을 ItemMaster 타입으로 변환
*/
function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
// attributes 객체 추출 (조립부품 등의 동적 필드가 여기에 저장됨)
const attributes = (data.attributes || {}) as Record<string, unknown>;
return {
id: String(data.id || ''),
// 백엔드 필드 매핑:
@@ -27,7 +30,7 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
// - Material: material_code, name, material_type (또는 type_code)
itemCode: String(data.code || data.material_code || data.item_code || data.itemCode || ''),
itemName: String(data.name || data.item_name || data.itemName || ''),
itemType: String(data.product_type || data.material_type || data.type_code || data.item_type || data.itemType || 'FG'),
itemType: (data.product_type || data.material_type || data.type_code || data.item_type || data.itemType || 'FG') as ItemType,
unit: String(data.unit || 'EA'),
specification: data.specification ? String(data.specification) : undefined,
isActive: Boolean(data.is_active ?? data.isActive ?? true),
@@ -40,7 +43,7 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
processingCost: data.processing_cost ? Number(data.processing_cost) : undefined,
laborCost: data.labor_cost ? Number(data.labor_cost) : undefined,
installCost: data.install_cost ? Number(data.install_cost) : undefined,
productCategory: data.product_category ? String(data.product_category) : undefined,
productCategory: data.product_category ? (data.product_category as ProductCategory) : undefined,
lotAbbreviation: data.lot_abbreviation ? String(data.lot_abbreviation) : undefined,
note: data.note ? String(data.note) : undefined,
description: data.description ? String(data.description) : undefined,
@@ -50,18 +53,18 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
isFinal: Boolean(data.is_final ?? false),
createdAt: String(data.created_at || data.createdAt || ''),
updatedAt: data.updated_at ? String(data.updated_at) : undefined,
// 부품 관련
partType: data.part_type ? String(data.part_type) : undefined,
partUsage: data.part_usage ? String(data.part_usage) : undefined,
installationType: data.installation_type ? String(data.installation_type) : undefined,
assemblyType: data.assembly_type ? String(data.assembly_type) : undefined,
assemblyLength: data.assembly_length ? String(data.assembly_length) : undefined,
material: data.material ? String(data.material) : undefined,
sideSpecWidth: data.side_spec_width ? String(data.side_spec_width) : undefined,
sideSpecHeight: data.side_spec_height ? String(data.side_spec_height) : undefined,
guideRailModelType: data.guide_rail_model_type ? String(data.guide_rail_model_type) : undefined,
guideRailModel: data.guide_rail_model ? String(data.guide_rail_model) : undefined,
length: data.length ? String(data.length) : undefined,
// 부품 관련 - data와 attributes 둘 다에서 찾음
partType: (data.part_type || attributes.part_type) ? ((data.part_type || attributes.part_type) as PartType) : undefined,
partUsage: (data.part_usage || attributes.part_usage) ? ((data.part_usage || attributes.part_usage) as PartUsage) : undefined,
installationType: (data.installation_type || attributes.installation_type) ? String(data.installation_type || attributes.installation_type) : undefined,
assemblyType: (data.assembly_type || attributes.assembly_type) ? String(data.assembly_type || attributes.assembly_type) : undefined,
assemblyLength: (data.assembly_length || attributes.assembly_length || attributes.length) ? String(data.assembly_length || attributes.assembly_length || attributes.length) : undefined,
material: (data.material || attributes.material) ? String(data.material || attributes.material) : undefined,
sideSpecWidth: (data.side_spec_width || attributes.side_spec_width) ? String(data.side_spec_width || attributes.side_spec_width) : undefined,
sideSpecHeight: (data.side_spec_height || attributes.side_spec_height) ? String(data.side_spec_height || attributes.side_spec_height) : undefined,
guideRailModelType: (data.guide_rail_model_type || attributes.guide_rail_model_type) ? String(data.guide_rail_model_type || attributes.guide_rail_model_type) : undefined,
guideRailModel: (data.guide_rail_model || attributes.guide_rail_model) ? String(data.guide_rail_model || attributes.guide_rail_model) : undefined,
length: (data.length || attributes.length) ? String(data.length || attributes.length) : undefined,
// BOM (있으면)
bom: Array.isArray(data.bom) ? data.bom.map((bomItem: Record<string, unknown>) => ({
id: String(bomItem.id || ''),
@@ -117,17 +120,25 @@ export default function ItemDetailPage() {
let response: Response;
// Materials (SM, RM, CS)는 다른 API 엔드포인트 사용
if (MATERIAL_TYPES.includes(itemType) && itemId) {
// GET /api/proxy/items/{id}?item_type=MATERIAL
console.log('[ItemDetail] Using Material API');
response = await fetch(`/api/proxy/items/${itemId}?item_type=MATERIAL`);
} else {
// Products (FG, PT): GET /api/proxy/items/code/{itemCode}?include_bom=true
console.log('[ItemDetail] Using Product API');
response = await fetch(`/api/proxy/items/code/${encodeURIComponent(itemCode)}?include_bom=true`);
// 모든 품목: GET /api/proxy/items/{id} (id 기반 통일)
if (!itemId) {
setError('품목 ID가 없습니다.');
setIsLoading(false);
return;
}
// Materials (SM, RM, CS)는 item_type=MATERIAL 쿼리 파라미터 추가
const isMaterial = MATERIAL_TYPES.includes(itemType);
const queryParams = new URLSearchParams();
if (isMaterial) {
queryParams.append('item_type', 'MATERIAL');
} else {
queryParams.append('include_bom', 'true');
}
console.log('[ItemDetail] Fetching:', { itemId, itemType, isMaterial });
response = await fetch(`/api/proxy/items/${itemId}?${queryParams.toString()}`);
if (!response.ok) {
if (response.status === 404) {
setError('품목을 찾을 수 없습니다.');

View File

@@ -147,7 +147,7 @@ export default async function ItemsPage() {
return (
<div className="p-6">
<Suspense fallback={<div className="text-center py-8"> ...</div>}>
<ItemListClient items={items} />
<ItemListClient />
</Suspense>
</div>
);

View File

@@ -27,6 +27,7 @@ import {
CheckCircle,
XCircle,
Eye,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -71,6 +72,18 @@ export default function CustomerAccountManagementPage() {
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 초기 로딩 완료 여부 (첫 데이터 로드 완료 시 true)
const [isInitialLoaded, setIsInitialLoaded] = useState(false);
// 전체 통계 (검색과 무관하게 고정)
const [totalStats, setTotalStats] = useState({
total: 0,
purchase: 0,
sales: 0,
active: 0,
inactive: 0,
});
// 삭제 확인 다이얼로그 state
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
@@ -82,15 +95,52 @@ export default function CustomerAccountManagementPage() {
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
const sentinelRef = useRef<HTMLDivElement>(null);
// 전체 통계 로드 (최초 1회만)
const loadTotalStats = useCallback(async () => {
try {
// 전체 데이터 조회 (검색 조건 없이)
const response = await fetch("/api/proxy/clients?size=1000");
if (response.ok) {
const result = await response.json();
if (result.success && result.data) {
const allClients = result.data.data || [];
setTotalStats({
total: result.data.total || allClients.length,
purchase: allClients.filter((c: { client_type?: string }) =>
c.client_type === "매입" || c.client_type === "매입매출"
).length,
sales: allClients.filter((c: { client_type?: string }) =>
c.client_type === "매출" || c.client_type === "매입매출"
).length,
active: allClients.filter((c: { is_active?: boolean }) => c.is_active === true).length,
inactive: allClients.filter((c: { is_active?: boolean }) => c.is_active === false).length,
});
}
}
} catch (err) {
console.error("전체 통계 로드 실패:", err);
}
}, []);
// 초기 데이터 로드
useEffect(() => {
fetchClients({
page: currentPage,
size: itemsPerPage,
q: searchTerm || undefined,
onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined,
});
}, [currentPage, filterType, fetchClients]);
const loadData = async () => {
// 최초 로드 시 전체 통계도 함께 로드
if (!isInitialLoaded) {
await loadTotalStats();
}
await fetchClients({
page: currentPage,
size: itemsPerPage,
q: searchTerm || undefined,
onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined,
});
if (!isInitialLoaded) {
setIsInitialLoaded(true);
}
};
loadData();
}, [currentPage, filterType, fetchClients, isInitialLoaded, loadTotalStats]);
// 검색어 변경 시 디바운스 처리
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -119,6 +169,8 @@ export default function CustomerAccountManagementPage() {
const filteredClients = clients.filter((client) => {
if (filterType === "active") return client.status === "활성";
if (filterType === "inactive") return client.status === "비활성";
if (filterType === "purchase") return client.clientType === "매입" || client.clientType === "매입매출";
if (filterType === "sales") return client.clientType === "매출" || client.clientType === "매입매출";
return true;
});
@@ -165,41 +217,44 @@ export default function CustomerAccountManagementPage() {
setMobileDisplayCount(20);
}, [searchTerm, filterType]);
// 통계 (API에서 가져온 전체 데이터 기반)
const totalCustomers = pagination?.total || clients.length;
const activeCustomers = clients.filter((c) => c.status === "활성").length;
const inactiveCustomers = clients.filter((c) => c.status === "비활성").length;
// 통계 카드 (전체 통계 기준 - 검색과 무관하게 고정)
const stats = [
{
label: "전체 거래처",
value: totalCustomers,
value: totalStats.total,
icon: Users,
iconColor: "text-blue-600",
},
{
label: "매입 거래처",
value: totalStats.purchase,
icon: Building2,
iconColor: "text-orange-600",
},
{
label: "매출 거래처",
value: totalStats.sales,
icon: Building2,
iconColor: "text-rose-600",
},
{
label: "활성 거래처",
value: activeCustomers,
value: totalStats.active,
icon: CheckCircle,
iconColor: "text-green-600",
},
{
label: "비활성 거래처",
value: inactiveCustomers,
icon: XCircle,
iconColor: "text-gray-600",
},
];
// 데이터 새로고침 함수
const refreshData = useCallback(() => {
// 데이터 새로고침 함수 (삭제/등록 후 통계도 다시 로드)
const refreshData = useCallback(async () => {
await loadTotalStats(); // 통계 다시 로드
fetchClients({
page: currentPage,
size: itemsPerPage,
q: searchTerm || undefined,
onlyActive: filterType === "active" ? true : filterType === "inactive" ? false : undefined,
});
}, [currentPage, itemsPerPage, searchTerm, filterType, fetchClients]);
}, [currentPage, itemsPerPage, searchTerm, filterType, fetchClients, loadTotalStats]);
// 핸들러 - 페이지 기반 네비게이션
const handleAddNew = () => {
@@ -297,39 +352,74 @@ export default function CustomerAccountManagementPage() {
);
};
// 탭 구성
// 탭 구성 (전체 | 매입 | 매출 | 활성 | 비활성) - 전체 통계 기준으로 고정
const tabs: TabOption[] = [
{
value: "all",
label: "전체",
count: totalCustomers,
count: totalStats.total,
color: "blue",
},
{
value: "purchase",
label: "매입",
count: totalStats.purchase,
color: "orange",
},
{
value: "sales",
label: "매출",
count: totalStats.sales,
color: "rose",
},
{
value: "active",
label: "활성",
count: activeCustomers,
count: totalStats.active,
color: "green",
},
{
value: "inactive",
label: "비활성",
count: inactiveCustomers,
count: totalStats.inactive,
color: "gray",
},
];
// 거래처 유형 배지
const getClientTypeBadge = (clientType: Client["clientType"]) => {
switch (clientType) {
case "매출":
return (
<Badge className="bg-red-500 text-white hover:bg-red-600">
</Badge>
);
case "매입매출":
return (
<Badge className="bg-yellow-500 text-white hover:bg-yellow-600">
</Badge>
);
case "매입":
default:
return (
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
</Badge>
);
}
};
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: "rowNumber", label: "번호", className: "px-4" },
{ key: "code", label: "코드", className: "px-4" },
{ key: "clientType", label: "구분", className: "px-4" },
{ key: "name", label: "거래처명", className: "px-4" },
{ key: "businessNo", label: "사업자번호", className: "px-4" },
{ key: "representative", label: "대표자", className: "px-4" },
{ key: "manager", label: "담당자", className: "px-4" },
{ key: "phone", label: "전화번호", className: "px-4" },
{ key: "businessType", label: "업태", className: "px-4" },
{ key: "businessItem", label: "업종", className: "px-4" },
{ key: "status", label: "상태", className: "px-4" },
{ key: "actions", label: "작업", className: "px-4" },
];
@@ -360,16 +450,21 @@ export default function CustomerAccountManagementPage() {
{customer.code}
</code>
</TableCell>
<TableCell>{getClientTypeBadge(customer.clientType)}</TableCell>
<TableCell className="font-medium">{customer.name}</TableCell>
<TableCell>{customer.businessNo}</TableCell>
<TableCell>{customer.representative}</TableCell>
<TableCell>{customer.managerName || "-"}</TableCell>
<TableCell>{customer.phone}</TableCell>
<TableCell>{customer.businessType}</TableCell>
<TableCell>{customer.businessItem}</TableCell>
<TableCell>{getStatusBadge(customer.status)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(customer)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -417,19 +512,17 @@ export default function CustomerAccountManagementPage() {
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono whitespace-nowrap">
{customer.code}
</code>
{getClientTypeBadge(customer.clientType)}
</>
}
title={customer.name}
statusBadge={getStatusBadge(customer.status)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="사업자번호" value={customer.businessNo} />
<InfoField label="대표자" value={customer.representative} />
<InfoField label="담당자" value={customer.managerName || "-"} />
<InfoField label="전화번호" value={customer.phone} />
<InfoField label="이메일" value={customer.email || "-"} />
<InfoField label="업태" value={customer.businessType || "-"} />
<InfoField label="업종" value={customer.businessItem || "-"} />
<InfoField label="등록일" value={customer.registeredDate} />
<InfoField label="사업자번호" value={customer.businessNo} />
</div>
}
actions={
@@ -466,6 +559,18 @@ export default function CustomerAccountManagementPage() {
);
};
// 초기 로딩 중일 때 전체 화면 스피너 표시
if (!isInitialLoaded) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<IntegratedListTemplateV2
@@ -502,7 +607,6 @@ export default function CustomerAccountManagementPage() {
getItemId={(customer) => customer.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
isLoading={isLoading}
pagination={{
currentPage,
totalPages,

View File

@@ -2,9 +2,11 @@
* 단가 수정 페이지
*
* 경로: /sales/pricing-management/[id]/edit
* API: GET /api/v1/pricing/{id}, PUT /api/v1/pricing/{id}
*/
import { PricingFormClient } from '@/components/pricing';
import { getPricingById, updatePricing, finalizePricing } from '@/components/pricing/actions';
import type { PricingData } from '@/components/pricing';
interface EditPricingPageProps {
@@ -13,197 +15,10 @@ interface EditPricingPageProps {
}>;
}
// TODO: API 연동 시 실제 단가 조회로 교체
async function getPricingById(id: string): Promise<PricingData | null> {
// 임시 목(Mock) 데이터
const mockPricings: Record<string, PricingData> = {
'pricing-4': {
id: 'pricing-4',
itemId: 'item-4',
itemCode: 'GR-001',
itemName: '가이드레일 130×80',
itemType: 'PT',
specification: '130×80×2438',
unit: 'EA',
effectiveDate: '2025-11-24',
purchasePrice: 45000,
processingCost: 5000,
loss: 0,
roundingRule: 'round',
roundingUnit: 1,
marginRate: 20,
salesPrice: 60000,
supplier: '가이드레일 공급사',
note: '스크린용 가이드레일',
currentRevision: 1,
isFinal: false,
revisions: [
{
revisionNumber: 1,
revisionDate: '2025-11-20T10:00:00Z',
revisionBy: '관리자',
revisionReason: '초기 가격 조정',
previousData: {
id: 'pricing-4',
itemId: 'item-4',
itemCode: 'GR-001',
itemName: '가이드레일 130×80',
itemType: 'PT',
specification: '130×80×2438',
unit: 'EA',
effectiveDate: '2025-11-15',
purchasePrice: 40000,
processingCost: 5000,
marginRate: 15,
salesPrice: 51750,
currentRevision: 0,
isFinal: false,
status: 'draft',
createdAt: '2025-11-15T09:00:00Z',
createdBy: '관리자',
},
},
],
status: 'active',
createdAt: '2025-11-15T09:00:00Z',
createdBy: '관리자',
updatedAt: '2025-11-24T14:30:00Z',
updatedBy: '관리자',
},
'pricing-5': {
id: 'pricing-5',
itemId: 'item-5',
itemCode: 'CASE-001',
itemName: '케이스 철재',
itemType: 'PT',
specification: '표준형',
unit: 'EA',
effectiveDate: '2025-11-20',
purchasePrice: 35000,
processingCost: 10000,
loss: 0,
roundingRule: 'round',
roundingUnit: 10,
marginRate: 25,
salesPrice: 56250,
currentRevision: 0,
isFinal: false,
revisions: [],
status: 'active',
createdAt: '2025-11-20T10:00:00Z',
createdBy: '관리자',
},
'pricing-6': {
id: 'pricing-6',
itemId: 'item-6',
itemCode: 'MOTOR-001',
itemName: '모터 0.4KW',
itemType: 'PT',
specification: '0.4KW',
unit: 'EA',
effectiveDate: '2025-11-15',
purchasePrice: 120000,
processingCost: 10000,
loss: 0,
roundingRule: 'round',
roundingUnit: 100,
marginRate: 15,
salesPrice: 149500,
currentRevision: 2,
isFinal: false,
revisions: [
{
revisionNumber: 2,
revisionDate: '2025-11-12T10:00:00Z',
revisionBy: '관리자',
revisionReason: '공급가 변동',
previousData: {
id: 'pricing-6',
itemId: 'item-6',
itemCode: 'MOTOR-001',
itemName: '모터 0.4KW',
itemType: 'PT',
specification: '0.4KW',
unit: 'EA',
effectiveDate: '2025-11-10',
purchasePrice: 115000,
processingCost: 10000,
marginRate: 15,
salesPrice: 143750,
currentRevision: 1,
isFinal: false,
status: 'active',
createdAt: '2025-11-05T09:00:00Z',
createdBy: '관리자',
},
},
{
revisionNumber: 1,
revisionDate: '2025-11-10T10:00:00Z',
revisionBy: '관리자',
revisionReason: '초기 등록',
previousData: {
id: 'pricing-6',
itemId: 'item-6',
itemCode: 'MOTOR-001',
itemName: '모터 0.4KW',
itemType: 'PT',
specification: '0.4KW',
unit: 'EA',
effectiveDate: '2025-11-05',
purchasePrice: 110000,
processingCost: 10000,
marginRate: 15,
salesPrice: 138000,
currentRevision: 0,
isFinal: false,
status: 'draft',
createdAt: '2025-11-05T09:00:00Z',
createdBy: '관리자',
},
},
],
status: 'active',
createdAt: '2025-11-05T09:00:00Z',
createdBy: '관리자',
updatedAt: '2025-11-15T11:00:00Z',
updatedBy: '관리자',
},
'pricing-7': {
id: 'pricing-7',
itemId: 'item-7',
itemCode: 'CTL-001',
itemName: '제어기 기본형',
itemType: 'PT',
specification: '기본형',
unit: 'EA',
effectiveDate: '2025-11-10',
purchasePrice: 80000,
processingCost: 5000,
loss: 0,
roundingRule: 'round',
roundingUnit: 1000,
marginRate: 20,
salesPrice: 102000,
currentRevision: 3,
isFinal: true,
finalizedDate: '2025-11-25T10:00:00Z',
finalizedBy: '관리자',
revisions: [],
status: 'finalized',
createdAt: '2025-11-01T09:00:00Z',
createdBy: '관리자',
updatedAt: '2025-11-25T10:00:00Z',
updatedBy: '관리자',
},
};
return mockPricings[id] || null;
}
export default async function EditPricingPage({ params }: EditPricingPageProps) {
const { id } = await params;
// 기존 단가 데이터 조회
const pricingData = await getPricingById(id);
if (!pricingData) {
@@ -219,15 +34,38 @@ export default async function EditPricingPage({ params }: EditPricingPageProps)
);
}
// 서버 액션: 단가 수정
async function handleSave(data: PricingData, isRevision?: boolean, revisionReason?: string) {
'use server';
const result = await updatePricing(id, data, revisionReason);
if (!result.success) {
throw new Error(result.error || '단가 수정에 실패했습니다.');
}
console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
}
// 서버 액션: 단가 확정
async function handleFinalize(priceId: string) {
'use server';
const result = await finalizePricing(priceId);
if (!result.success) {
throw new Error(result.error || '단가 확정에 실패했습니다.');
}
console.log('[EditPricingPage] 단가 확정 성공:', result.data);
}
return (
<PricingFormClient
mode="edit"
initialData={pricingData}
onSave={async (data, isRevision, revisionReason) => {
'use server';
// TODO: API 연동 시 실제 수정 로직으로 교체
console.log('단가 수정:', data, isRevision, revisionReason);
}}
onSave={handleSave}
onFinalize={handleFinalize}
/>
);
}

View File

@@ -1,60 +1,31 @@
/**
* 단가 등록 페이지
*
* 경로: /sales/pricing-management/create?itemId=xxx&itemCode=xxx
* 경로: /sales/pricing-management/create?itemId=xxx&itemTypeCode=MATERIAL|PRODUCT
* API: POST /api/v1/pricing
*/
import { PricingFormClient } from '@/components/pricing';
import type { ItemInfo } from '@/components/pricing';
import { getItemInfo, createPricing } from '@/components/pricing/actions';
import type { PricingData } from '@/components/pricing';
interface CreatePricingPageProps {
searchParams: Promise<{
itemId?: string;
itemCode?: string;
itemTypeCode?: 'PRODUCT' | 'MATERIAL'; // PRODUCT 또는 MATERIAL (API 등록 시 필요)
}>;
}
// TODO: API 연동 시 실제 품목 조회로 교체
async function getItemInfo(itemId: string, itemCode: string): Promise<ItemInfo | null> {
// 임시 목(Mock) 데이터
const mockItems: Record<string, ItemInfo> = {
'item-1': {
id: 'item-1',
itemCode: 'SCREEN-001',
itemName: '스크린 셔터 기본형',
itemType: 'FG',
specification: '표준형',
unit: 'SET',
},
'item-2': {
id: 'item-2',
itemCode: 'SCREEN-002',
itemName: '스크린 셔터 오픈형',
itemType: 'FG',
specification: '오픈형',
unit: 'SET',
},
'item-3': {
id: 'item-3',
itemCode: 'STEEL-001',
itemName: '철재 셔터 기본형',
itemType: 'FG',
specification: '표준형',
unit: 'SET',
},
};
return mockItems[itemId] || null;
}
export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) {
const params = await searchParams;
const itemId = params.itemId || '';
const itemCode = params.itemCode || '';
const itemTypeCode = params.itemTypeCode || 'MATERIAL';
const itemInfo = await getItemInfo(itemId, itemCode);
// 품목 정보 조회
const itemInfo = itemId ? await getItemInfo(itemId) : null;
if (!itemInfo) {
if (!itemInfo && itemId) {
return (
<div className="container mx-auto py-6 px-4">
<div className="text-center py-12">
@@ -67,15 +38,38 @@ export default async function CreatePricingPage({ searchParams }: CreatePricingP
);
}
// 품목 정보 없이 접근한 경우 (목록에서 바로 등록)
if (!itemInfo) {
return (
<div className="container mx-auto py-6 px-4">
<div className="text-center py-12">
<h2 className="text-xl font-semibold mb-2"> </h2>
<p className="text-muted-foreground">
.
</p>
</div>
</div>
);
}
// 서버 액션: 단가 등록
async function handleSave(data: PricingData) {
'use server';
const result = await createPricing(data, itemTypeCode);
if (!result.success) {
throw new Error(result.error || '단가 등록에 실패했습니다.');
}
console.log('[CreatePricingPage] 단가 등록 성공:', result.data);
}
return (
<PricingFormClient
mode="create"
itemInfo={itemInfo}
onSave={async (data) => {
'use server';
// TODO: API 연동 시 실제 저장 로직으로 교체
console.log('단가 등록:', data);
}}
onSave={handleSave}
/>
);
}

View File

@@ -2,177 +2,312 @@
* 단가 목록 페이지
*
* 경로: /sales/pricing-management
* API:
* - GET /api/v1/items - 품목 목록 (품목기준관리에서 등록한 전체 품목)
* - GET /api/v1/pricing - 단가 목록 (등록된 단가 정보)
*
* 데이터 흐름:
* 품목 목록 + 단가 목록 → 병합 → 품목별 단가 현황 표시
*/
import { PricingListClient } from '@/components/pricing';
import type { PricingListItem } from '@/components/pricing';
import type { PricingListItem, PricingStatus } from '@/components/pricing';
import { cookies } from 'next/headers';
// TODO: API 연동 시 실제 데이터 fetching으로 교체
async function getPricingList(): Promise<PricingListItem[]> {
// 임시 목(Mock) 데이터
return [
{
id: 'pricing-1',
itemId: 'item-1',
itemCode: 'SCREEN-001',
itemName: '스크린 셔터 기본형',
itemType: 'FG',
specification: '표준형',
unit: 'SET',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered',
currentRevision: 0,
isFinal: false,
},
{
id: 'pricing-2',
itemId: 'item-2',
itemCode: 'SCREEN-002',
itemName: '스크린 셔터 오픈형',
itemType: 'FG',
specification: '오픈형',
unit: 'SET',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered',
currentRevision: 0,
isFinal: false,
},
{
id: 'pricing-3',
itemId: 'item-3',
itemCode: 'STEEL-001',
itemName: '철재 셔터 기본형',
itemType: 'FG',
specification: '표준형',
unit: 'SET',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered',
currentRevision: 0,
isFinal: false,
},
{
id: 'pricing-4',
itemId: 'item-4',
itemCode: 'GR-001',
itemName: '가이드레일 130×80',
itemType: 'PT',
specification: '130×80×2438',
unit: 'EA',
purchasePrice: 45000,
processingCost: 5000,
salesPrice: 60000,
marginRate: 20,
effectiveDate: '2025-11-24',
status: 'active',
currentRevision: 1,
isFinal: false,
},
{
id: 'pricing-5',
itemId: 'item-5',
itemCode: 'CASE-001',
itemName: '케이스 철재',
itemType: 'PT',
specification: '표준형',
unit: 'EA',
purchasePrice: 35000,
processingCost: 10000,
salesPrice: 56250,
marginRate: 25,
effectiveDate: '2025-11-20',
status: 'active',
currentRevision: 0,
isFinal: false,
},
{
id: 'pricing-6',
itemId: 'item-6',
itemCode: 'MOTOR-001',
itemName: '모터 0.4KW',
itemType: 'PT',
specification: '0.4KW',
unit: 'EA',
purchasePrice: 120000,
processingCost: 10000,
salesPrice: 149500,
marginRate: 15,
effectiveDate: '2025-11-15',
status: 'active',
currentRevision: 2,
isFinal: false,
},
{
id: 'pricing-7',
itemId: 'item-7',
itemCode: 'CTL-001',
itemName: '제어기 기본형',
itemType: 'PT',
specification: '기본형',
unit: 'EA',
purchasePrice: 80000,
processingCost: 5000,
salesPrice: 102000,
marginRate: 20,
effectiveDate: '2025-11-10',
status: 'finalized',
currentRevision: 3,
isFinal: true,
},
{
id: 'pricing-8',
itemId: 'item-8',
itemCode: '가이드레일wall12*30*12',
itemName: '가이드레일',
itemType: 'PT',
specification: '가이드레일',
unit: 'M',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered',
currentRevision: 0,
isFinal: false,
},
{
id: 'pricing-9',
itemId: 'item-9',
itemCode: '소모품 테스트-소모품 규격 테스트',
itemName: '소모품 테스트',
itemType: 'CS',
specification: '소모품 규격 테스트',
unit: 'M',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered',
currentRevision: 0,
isFinal: false,
},
];
// ============================================
// API 응답 타입 정의
// ============================================
// 품목 API 응답 타입 (GET /api/v1/items)
interface ItemApiData {
id: number;
item_type: 'PRODUCT' | 'MATERIAL';
code: string;
name: string;
unit: string;
category_id: number | null;
type_code: string; // FG, PT, SM, RM, CS
created_at: string;
deleted_at: string | null;
}
interface ItemsApiResponse {
success: boolean;
data: {
current_page: number;
data: ItemApiData[];
total: number;
per_page: number;
last_page: number;
};
message: string;
}
// 단가 API 응답 타입 (GET /api/v1/pricing)
interface PriceApiItem {
id: number;
tenant_id: number;
item_type_code: 'PRODUCT' | 'MATERIAL';
item_id: number;
client_group_id: number | null;
purchase_price: string | null;
processing_cost: string | null;
loss_rate: string | null;
margin_rate: string | null;
sales_price: string | null;
rounding_rule: 'round' | 'ceil' | 'floor';
rounding_unit: number;
supplier: string | null;
effective_from: string;
effective_to: string | null;
status: 'draft' | 'active' | 'finalized';
is_final: boolean;
finalized_at: string | null;
finalized_by: number | null;
note: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
client_group?: {
id: number;
name: string;
};
product?: {
id: number;
product_code: string;
product_name: string;
specification: string | null;
unit: string;
product_type: string;
};
material?: {
id: number;
item_code: string;
item_name: string;
specification: string | null;
unit: string;
product_type: string;
};
}
interface PricingApiResponse {
success: boolean;
data: {
current_page: number;
data: PriceApiItem[];
total: number;
per_page: number;
last_page: number;
};
message: string;
}
// ============================================
// 헬퍼 함수
// ============================================
// API 헤더 생성
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
};
}
// 품목 유형 매핑 (type_code → 프론트엔드 ItemType)
function mapItemType(typeCode?: string): string {
switch (typeCode) {
case 'FG': return 'FG'; // 제품
case 'PT': return 'PT'; // 부품
case 'SM': return 'SM'; // 부자재
case 'RM': return 'RM'; // 원자재
case 'CS': return 'CS'; // 소모품
default: return 'PT';
}
}
// API 상태 → 프론트엔드 상태 매핑
function mapStatus(apiStatus: string, isFinal: boolean): PricingStatus | 'not_registered' {
if (isFinal) return 'finalized';
switch (apiStatus) {
case 'draft': return 'draft';
case 'active': return 'active';
case 'finalized': return 'finalized';
default: return 'draft';
}
}
// ============================================
// API 호출 함수
// ============================================
// 품목 목록 조회
async function getItemsList(): Promise<ItemApiData[]> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?size=100`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
console.error('[PricingPage] Items API Error:', response.status, response.statusText);
return [];
}
const result: ItemsApiResponse = await response.json();
console.log('[PricingPage] Items API Response count:', result.data?.data?.length || 0);
if (!result.success || !result.data?.data) {
console.warn('[PricingPage] No items data in response');
return [];
}
return result.data.data;
} catch (error) {
console.error('[PricingPage] Items fetch error:', error);
return [];
}
}
// 단가 목록 조회
async function getPricingList(): Promise<PriceApiItem[]> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
console.error('[PricingPage] Pricing API Error:', response.status, response.statusText);
return [];
}
const result: PricingApiResponse = await response.json();
console.log('[PricingPage] Pricing API Response count:', result.data?.data?.length || 0);
if (!result.success || !result.data?.data) {
console.warn('[PricingPage] No pricing data in response');
return [];
}
return result.data.data;
} catch (error) {
console.error('[PricingPage] Pricing fetch error:', error);
return [];
}
}
// ============================================
// 데이터 병합 함수
// ============================================
/**
* 품목 목록 + 단가 목록 병합
*
* - 품목 목록을 기준으로 순회
* - 각 품목에 해당하는 단가 정보를 매핑
* - 단가 미등록 품목은 'not_registered' 상태로 표시
*/
function mergeItemsWithPricing(
items: ItemApiData[],
pricings: PriceApiItem[]
): PricingListItem[] {
// 단가 정보를 빠르게 찾기 위한 Map 생성
// key: "PRODUCT_123" 또는 "MATERIAL_456"
const pricingMap = new Map<string, PriceApiItem>();
for (const pricing of pricings) {
const key = `${pricing.item_type_code}_${pricing.item_id}`;
// 같은 품목에 여러 단가가 있을 수 있으므로 최신 것만 사용
if (!pricingMap.has(key)) {
pricingMap.set(key, pricing);
}
}
// 품목 목록을 기준으로 병합
return items.map((item) => {
const key = `${item.item_type}_${item.id}`;
const pricing = pricingMap.get(key);
if (pricing) {
// 단가 등록된 품목
return {
id: String(pricing.id),
itemId: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: mapItemType(item.type_code),
specification: undefined, // items API에서는 specification 미제공
unit: item.unit || 'EA',
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
effectiveDate: pricing.effective_from,
status: mapStatus(pricing.status, pricing.is_final),
currentRevision: 0,
isFinal: pricing.is_final,
itemTypeCode: item.item_type, // PRODUCT 또는 MATERIAL (등록 시 필요)
};
} else {
// 단가 미등록 품목
return {
id: `item_${item.id}`, // 임시 ID (단가 ID가 없으므로)
itemId: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: mapItemType(item.type_code),
specification: undefined,
unit: item.unit || 'EA',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered' as const,
currentRevision: 0,
isFinal: false,
itemTypeCode: item.item_type, // PRODUCT 또는 MATERIAL (등록 시 필요)
};
}
});
}
// ============================================
// 페이지 컴포넌트
// ============================================
export default async function PricingManagementPage() {
const pricingList = await getPricingList();
// 품목 목록과 단가 목록을 병렬로 조회
const [items, pricings] = await Promise.all([
getItemsList(),
getPricingList(),
]);
console.log('[PricingPage] Items count:', items.length);
console.log('[PricingPage] Pricings count:', pricings.length);
// 데이터 병합
const mergedData = mergeItemsWithPricing(items, pricings);
console.log('[PricingPage] Merged data count:', mergedData.length);
return (
<div className="container mx-auto py-6 px-4">
<PricingListClient initialData={pricingList} />
</div>
<PricingListClient initialData={mergedData} />
);
}
}

View File

@@ -0,0 +1,5 @@
import { LeavePolicyManagement } from '@/components/settings/LeavePolicyManagement';
export default function LeavePolicyPage() {
return <LeavePolicyManagement />;
}

View File

@@ -0,0 +1,10 @@
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PermissionDetailPage({ params }: PageProps) {
const { id } = await params;
return <PermissionDetailClient permissionId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
export default function NewPermissionPage() {
return <PermissionDetailClient permissionId="new" isNew />;
}

View File

@@ -0,0 +1,5 @@
import { PermissionManagement } from '@/components/settings/PermissionManagement';
export default function PermissionsPage() {
return <PermissionManagement />;
}

View File

@@ -0,0 +1,5 @@
import { RankManagement } from '@/components/settings/RankManagement';
export default function RanksPage() {
return <RankManagement />;
}

View File

@@ -0,0 +1,5 @@
import { TitleManagement } from '@/components/settings/TitleManagement';
export default function TitlesPage() {
return <TitleManagement />;
}

View File

@@ -0,0 +1,5 @@
import { WorkScheduleManagement } from '@/components/settings/WorkScheduleManagement';
export default function WorkSchedulePage() {
return <WorkScheduleManagement />;
}

View File

@@ -400,4 +400,57 @@
html {
font-size: var(--font-size);
}
/* ==========================================
Sheet/Dialog Slide Animations
========================================== */
/* Keyframes */
@keyframes slideInFromRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slideOutToRight {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@keyframes slideInFromLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes slideOutToLeft {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* Sheet Content - Right side slide animation */
[data-slot="sheet-content"][data-state="open"] {
animation: slideInFromRight 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
[data-slot="sheet-content"][data-state="closed"] {
animation: slideOutToRight 200ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Sheet Overlay - Fade animation */
[data-slot="sheet-overlay"][data-state="open"] {
animation: fadeIn 300ms ease-out forwards;
}
[data-slot="sheet-overlay"][data-state="closed"] {
animation: fadeOut 200ms ease-out forwards;
}