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:
38
src/app/[locale]/(protected)/hr/salary-management/page.tsx
Normal file
38
src/app/[locale]/(protected)/hr/salary-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/app/[locale]/(protected)/hr/vacation-management/page.tsx
Normal file
38
src/app/[locale]/(protected)/hr/vacation-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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('품목을 찾을 수 없습니다.');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LeavePolicyManagement } from '@/components/settings/LeavePolicyManagement';
|
||||
|
||||
export default function LeavePolicyPage() {
|
||||
return <LeavePolicyManagement />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
|
||||
|
||||
export default function NewPermissionPage() {
|
||||
return <PermissionDetailClient permissionId="new" isNew />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PermissionManagement } from '@/components/settings/PermissionManagement';
|
||||
|
||||
export default function PermissionsPage() {
|
||||
return <PermissionManagement />;
|
||||
}
|
||||
5
src/app/[locale]/(protected)/settings/ranks/page.tsx
Normal file
5
src/app/[locale]/(protected)/settings/ranks/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RankManagement } from '@/components/settings/RankManagement';
|
||||
|
||||
export default function RanksPage() {
|
||||
return <RankManagement />;
|
||||
}
|
||||
5
src/app/[locale]/(protected)/settings/titles/page.tsx
Normal file
5
src/app/[locale]/(protected)/settings/titles/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TitleManagement } from '@/components/settings/TitleManagement';
|
||||
|
||||
export default function TitlesPage() {
|
||||
return <TitleManagement />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WorkScheduleManagement } from '@/components/settings/WorkScheduleManagement';
|
||||
|
||||
export default function WorkSchedulePage() {
|
||||
return <WorkScheduleManagement />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user