feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용

Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금

Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입

신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들

총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-19 17:31:28 +09:00
parent 1a6cde2d36
commit 1d7b028693
109 changed files with 6811 additions and 2562 deletions

View File

@@ -135,7 +135,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/bad-debt-collection/${recordId}/edit`);
router.push(`/ko/accounting/bad-debt-collection/${recordId}?mode=edit`);
}, [router, recordId]);
const handleCancel = useCallback(() => {

View File

@@ -0,0 +1,135 @@
'use client';
/**
* 대손추심 상세 클라이언트 컴포넌트 V2
*
* 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new
* 기존 BadDebtDetail 컴포넌트 활용
*/
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { BadDebtDetail } from './BadDebtDetail';
import { getBadDebtById } from './actions';
import type { BadDebtRecord } from './types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
import { toast } from 'sonner';
type DetailMode = 'view' | 'edit' | 'new';
interface BadDebtDetailClientV2Props {
recordId?: string;
initialMode?: DetailMode;
}
const BASE_PATH = '/ko/accounting/bad-debt-collection';
export function BadDebtDetailClientV2({ recordId, initialMode }: BadDebtDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !recordId || recordId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'new';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [recordData, setRecordData] = useState<BadDebtRecord | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [error, setError] = useState<string | null>(null);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const result = await getBadDebtById(recordId!);
if (result) {
setRecordData(result);
} else {
setError('악성채권 정보를 찾을 수 없습니다.');
toast.error('악성채권을 불러오는데 실패했습니다.');
}
} catch (err) {
console.error('악성채권 조회 실패:', err);
setError('악성채권 정보를 불러오는 중 오류가 발생했습니다.');
toast.error('악성채권을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [recordId, isNewMode]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 로딩 중
if (isLoading) {
return <ContentLoadingSpinner text="악성채권 정보를 불러오는 중..." />;
}
// 에러 발생 (view/edit 모드에서)
if (error && !isNewMode) {
return (
<ErrorCard
type="network"
title="악성채권 정보를 불러올 수 없습니다"
description={error}
tips={[
'해당 악성채권이 존재하는지 확인해주세요',
'인터넷 연결 상태를 확인해주세요',
'잠시 후 다시 시도해주세요',
]}
homeButtonLabel="목록으로 이동"
homeButtonHref={BASE_PATH}
/>
);
}
// 등록 모드
if (mode === 'new') {
return <BadDebtDetail mode="new" />;
}
// 수정 모드
if (mode === 'edit' && recordData) {
return <BadDebtDetail mode="edit" recordId={recordId} initialData={recordData} />;
}
// 상세 보기 모드
if (mode === 'view' && recordData) {
return <BadDebtDetail mode="view" recordId={recordId} initialData={recordData} />;
}
// 데이터 없음 (should not reach here)
return (
<ErrorCard
type="not-found"
title="악성채권을 찾을 수 없습니다"
description="요청하신 악성채권 정보가 존재하지 않습니다."
homeButtonLabel="목록으로 이동"
homeButtonHref={BASE_PATH}
/>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
/**
* 악성채권 추심관리 - UniversalListPage 마이그레이션
*
@@ -103,7 +105,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
const handleEdit = useCallback(
(item: BadDebtRecord) => {
router.push(`/ko/accounting/bad-debt-collection/${item.id}/edit`);
router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`);
},
[router]
);

View File

@@ -333,7 +333,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<Select value={billType} onValueChange={(v) => setBillType(v as BillType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{BILL_TYPE_OPTIONS.map((option) => (
@@ -352,7 +352,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
@@ -410,7 +410,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<Select value={status} onValueChange={(v) => setStatus(v as BillStatus)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (

View File

@@ -288,7 +288,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
@@ -307,7 +307,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
</Label>
<Select value={depositType} onValueChange={(v) => setDepositType(v as DepositType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DEPOSIT_TYPE_SELECTOR_OPTIONS.map((option) => (

View File

@@ -0,0 +1,120 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import { depositDetailConfig } from './depositDetailConfig';
import type { DepositRecord } from './types';
import {
getDepositById,
createDeposit,
updateDeposit,
deleteDeposit,
} from './actions';
// ===== Props =====
interface DepositDetailClientV2Props {
depositId?: string;
initialMode?: DetailMode;
}
export default function DepositDetailClientV2({
depositId,
initialMode = 'view',
}: DepositDetailClientV2Props) {
const router = useRouter();
const [mode, setMode] = useState<DetailMode>(initialMode);
const [deposit, setDeposit] = useState<DepositRecord | null>(null);
const [isLoading, setIsLoading] = useState(initialMode !== 'create');
// ===== 데이터 로드 =====
useEffect(() => {
const loadDeposit = async () => {
if (depositId && initialMode !== 'create') {
setIsLoading(true);
const result = await getDepositById(depositId);
if (result.success && result.data) {
setDeposit(result.data);
} else {
toast.error(result.error || '입금 내역을 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
};
loadDeposit();
}, [depositId, initialMode]);
// ===== 저장/등록 핸들러 =====
const handleSubmit = useCallback(
async (formData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
const submitData = depositDetailConfig.transformSubmitData?.(formData) || formData;
if (!submitData.vendorId) {
toast.error('거래처를 선택해주세요.');
return { success: false, error: '거래처를 선택해주세요.' };
}
if (submitData.depositType === 'unset') {
toast.error('입금 유형을 선택해주세요.');
return { success: false, error: '입금 유형을 선택해주세요.' };
}
const result =
mode === 'create'
? await createDeposit(submitData as Partial<DepositRecord>)
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
if (result.success) {
toast.success(mode === 'create' ? '입금 내역이 등록되었습니다.' : '입금 내역이 수정되었습니다.');
router.push('/ko/accounting/deposits');
return { success: true };
} else {
toast.error(result.error || '저장에 실패했습니다.');
return { success: false, error: result.error };
}
},
[mode, depositId, router]
);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
const result = await deleteDeposit(depositId);
if (result.success) {
toast.success('입금 내역이 삭제되었습니다.');
router.push('/ko/accounting/deposits');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error };
}
}, [depositId, router]);
// ===== 모드 변경 핸들러 =====
const handleModeChange = useCallback(
(newMode: DetailMode) => {
if (newMode === 'edit' && depositId) {
router.push(`/ko/accounting/deposits/${depositId}/edit`);
} else {
setMode(newMode);
}
},
[depositId, router]
);
return (
<IntegratedDetailTemplate
config={depositDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={deposit as unknown as Record<string, unknown> | undefined}
itemId={depositId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}

View File

@@ -0,0 +1,123 @@
import { Banknote } from 'lucide-react';
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { DepositRecord } from './types';
import { DEPOSIT_TYPE_SELECTOR_OPTIONS } from './types';
import { getVendors } from './actions';
// ===== 필드 정의 =====
const fields: FieldDefinition[] = [
// 입금일 (readonly)
{
key: 'depositDate',
label: '입금일',
type: 'text',
readonly: true,
placeholder: '-',
},
// 입금계좌 (readonly)
{
key: 'accountName',
label: '입금계좌',
type: 'text',
readonly: true,
placeholder: '-',
},
// 입금자명 (readonly)
{
key: 'depositorName',
label: '입금자명',
type: 'text',
readonly: true,
placeholder: '-',
},
// 입금금액 (readonly)
{
key: 'depositAmount',
label: '입금금액',
type: 'text',
readonly: true,
placeholder: '-',
},
// 적요 (editable)
{
key: 'note',
label: '적요',
type: 'text',
placeholder: '적요를 입력해주세요',
gridSpan: 2,
disabled: (mode) => mode === 'view',
},
// 거래처 (editable, required)
{
key: 'vendorId',
label: '거래처',
type: 'select',
required: true,
placeholder: '선택',
fetchOptions: async () => {
const result = await getVendors();
if (result.success) {
return result.data.map((v) => ({
value: v.id,
label: v.name,
}));
}
return [];
},
disabled: (mode) => mode === 'view',
},
// 입금 유형 (editable, required)
{
key: 'depositType',
label: '입금 유형',
type: 'select',
required: true,
placeholder: '선택',
options: DEPOSIT_TYPE_SELECTOR_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label,
})),
disabled: (mode) => mode === 'view',
},
];
// ===== Config 정의 =====
export const depositDetailConfig: DetailConfig = {
title: '입금',
description: '입금 상세 내역을 등록합니다',
icon: Banknote,
basePath: '/accounting/deposits',
fields,
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
deleteConfirmMessage: {
title: '입금 삭제',
description: '이 입금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
const record = data as unknown as DepositRecord;
return {
depositDate: record.depositDate || '',
accountName: record.accountName || '',
depositorName: record.depositorName || '',
depositAmount: record.depositAmount ? record.depositAmount.toLocaleString() : '0',
note: record.note || '',
vendorId: record.vendorId || '',
depositType: record.depositType || 'unset',
};
},
transformSubmitData: (formData: Record<string, unknown>): Partial<DepositRecord> => {
return {
note: formData.note as string,
vendorId: formData.vendorId as string,
depositType: formData.depositType as DepositRecord['depositType'],
};
},
};

View File

@@ -400,7 +400,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
<Label htmlFor="salesType"> </Label>
<Select value={salesType} onValueChange={(v) => setSalesType(v as SalesType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => (

View File

@@ -288,7 +288,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
@@ -307,7 +307,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
</Label>
<Select value={withdrawalType} onValueChange={(v) => setWithdrawalType(v as WithdrawalType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((option) => (

View File

@@ -0,0 +1,120 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import { withdrawalDetailConfig } from './withdrawalDetailConfig';
import type { WithdrawalRecord } from './types';
import {
getWithdrawalById,
createWithdrawal,
updateWithdrawal,
deleteWithdrawal,
} from './actions';
// ===== Props =====
interface WithdrawalDetailClientV2Props {
withdrawalId?: string;
initialMode?: DetailMode;
}
export default function WithdrawalDetailClientV2({
withdrawalId,
initialMode = 'view',
}: WithdrawalDetailClientV2Props) {
const router = useRouter();
const [mode, setMode] = useState<DetailMode>(initialMode);
const [withdrawal, setWithdrawal] = useState<WithdrawalRecord | null>(null);
const [isLoading, setIsLoading] = useState(initialMode !== 'create');
// ===== 데이터 로드 =====
useEffect(() => {
const loadWithdrawal = async () => {
if (withdrawalId && initialMode !== 'create') {
setIsLoading(true);
const result = await getWithdrawalById(withdrawalId);
if (result.success && result.data) {
setWithdrawal(result.data);
} else {
toast.error(result.error || '출금 내역을 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
};
loadWithdrawal();
}, [withdrawalId, initialMode]);
// ===== 저장/등록 핸들러 =====
const handleSubmit = useCallback(
async (formData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
const submitData = withdrawalDetailConfig.transformSubmitData?.(formData) || formData;
if (!submitData.vendorId) {
toast.error('거래처를 선택해주세요.');
return { success: false, error: '거래처를 선택해주세요.' };
}
if (submitData.withdrawalType === 'unset') {
toast.error('출금 유형을 선택해주세요.');
return { success: false, error: '출금 유형을 선택해주세요.' };
}
const result =
mode === 'create'
? await createWithdrawal(submitData as Partial<WithdrawalRecord>)
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
if (result.success) {
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };
} else {
toast.error(result.error || '저장에 실패했습니다.');
return { success: false, error: result.error };
}
},
[mode, withdrawalId, router]
);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!withdrawalId) return { success: false, error: 'ID가 없습니다.' };
const result = await deleteWithdrawal(withdrawalId);
if (result.success) {
toast.success('출금 내역이 삭제되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error };
}
}, [withdrawalId, router]);
// ===== 모드 변경 핸들러 =====
const handleModeChange = useCallback(
(newMode: DetailMode) => {
if (newMode === 'edit' && withdrawalId) {
router.push(`/ko/accounting/withdrawals/${withdrawalId}/edit`);
} else {
setMode(newMode);
}
},
[withdrawalId, router]
);
return (
<IntegratedDetailTemplate
config={withdrawalDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={withdrawal as unknown as Record<string, unknown> | undefined}
itemId={withdrawalId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}

View File

@@ -0,0 +1,123 @@
import { Banknote } from 'lucide-react';
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { WithdrawalRecord } from './types';
import { WITHDRAWAL_TYPE_SELECTOR_OPTIONS } from './types';
import { getVendors } from './actions';
// ===== 필드 정의 =====
const fields: FieldDefinition[] = [
// 출금일 (readonly)
{
key: 'withdrawalDate',
label: '출금일',
type: 'text',
readonly: true,
placeholder: '-',
},
// 출금계좌 (readonly)
{
key: 'accountName',
label: '출금계좌',
type: 'text',
readonly: true,
placeholder: '-',
},
// 수취인명 (readonly)
{
key: 'recipientName',
label: '수취인명',
type: 'text',
readonly: true,
placeholder: '-',
},
// 출금금액 (readonly)
{
key: 'withdrawalAmount',
label: '출금금액',
type: 'text',
readonly: true,
placeholder: '-',
},
// 적요 (editable)
{
key: 'note',
label: '적요',
type: 'text',
placeholder: '적요를 입력해주세요',
gridSpan: 2,
disabled: (mode) => mode === 'view',
},
// 거래처 (editable, required)
{
key: 'vendorId',
label: '거래처',
type: 'select',
required: true,
placeholder: '선택',
fetchOptions: async () => {
const result = await getVendors();
if (result.success) {
return result.data.map((v) => ({
value: v.id,
label: v.name,
}));
}
return [];
},
disabled: (mode) => mode === 'view',
},
// 출금 유형 (editable, required)
{
key: 'withdrawalType',
label: '출금 유형',
type: 'select',
required: true,
placeholder: '선택',
options: WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label,
})),
disabled: (mode) => mode === 'view',
},
];
// ===== Config 정의 =====
export const withdrawalDetailConfig: DetailConfig = {
title: '출금',
description: '출금 상세 내역을 등록합니다',
icon: Banknote,
basePath: '/accounting/withdrawals',
fields,
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
deleteConfirmMessage: {
title: '출금 삭제',
description: '이 출금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
const record = data as unknown as WithdrawalRecord;
return {
withdrawalDate: record.withdrawalDate || '',
accountName: record.accountName || '',
recipientName: record.recipientName || '',
withdrawalAmount: record.withdrawalAmount ? record.withdrawalAmount.toLocaleString() : '0',
note: record.note || '',
vendorId: record.vendorId || '',
withdrawalType: record.withdrawalType || 'unset',
};
},
transformSubmitData: (formData: Record<string, unknown>): Partial<WithdrawalRecord> => {
return {
note: formData.note as string,
vendorId: formData.vendorId as string,
withdrawalType: formData.withdrawalType as WithdrawalRecord['withdrawalType'],
};
},
};

View File

@@ -0,0 +1,329 @@
'use client';
/**
* 게시판관리 상세 클라이언트 컴포넌트 V2
*
* 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new
* 기존 BoardDetail, BoardForm 컴포넌트 활용
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { BoardDetail } from './BoardDetail';
import { BoardForm } from './BoardForm';
import { getBoardById, createBoard, updateBoard, deleteBoard } from './actions';
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
import type { Board, BoardFormData } from './types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
type DetailMode = 'view' | 'edit' | 'create';
interface BoardDetailClientV2Props {
boardId?: string;
initialMode?: DetailMode;
}
const BASE_PATH = '/ko/board/board-management';
// 게시판 코드 생성 (타임스탬프 기반)
const generateBoardCode = (): string => {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 6);
return `board_${timestamp}_${random}`;
};
export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !boardId || boardId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [boardData, setBoardData] = useState<Board | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const result = await getBoardById(boardId!);
if (result.success && result.data) {
setBoardData(result.data);
} else {
setError(result.error || '게시판 정보를 찾을 수 없습니다.');
toast.error('게시판을 불러오는데 실패했습니다.');
}
} catch (err) {
console.error('게시판 조회 실패:', err);
setError('게시판 정보를 불러오는 중 오류가 발생했습니다.');
toast.error('게시판을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [boardId, isNewMode]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 등록 핸들러
const handleCreate = async (data: BoardFormData) => {
setIsSubmitting(true);
setError(null);
try {
const result = await createBoard({
...data,
boardCode: generateBoardCode(),
});
if (result.success && result.data) {
await forceRefreshMenus();
toast.success('게시판이 등록되었습니다.');
router.push(BASE_PATH);
} else {
setError(result.error || '게시판 등록에 실패했습니다.');
toast.error(result.error || '게시판 등록에 실패했습니다.');
}
} catch (err) {
console.error('게시판 등록 실패:', err);
setError('게시판 등록 중 오류가 발생했습니다.');
toast.error('게시판 등록 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 수정 핸들러
const handleUpdate = async (data: BoardFormData) => {
if (!boardData) return;
setIsSubmitting(true);
setError(null);
try {
const result = await updateBoard(boardData.id, {
...data,
boardCode: boardData.boardCode,
description: boardData.description,
});
if (result.success) {
await forceRefreshMenus();
toast.success('게시판이 수정되었습니다.');
router.push(`${BASE_PATH}/${boardData.id}`);
} else {
setError(result.error || '게시판 수정에 실패했습니다.');
toast.error(result.error || '게시판 수정에 실패했습니다.');
}
} catch (err) {
console.error('게시판 수정 실패:', err);
setError('게시판 수정 중 오류가 발생했습니다.');
toast.error('게시판 수정 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 삭제 핸들러
const handleDelete = () => {
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!boardData) return;
setIsDeleting(true);
try {
const result = await deleteBoard(boardData.id);
if (result.success) {
await forceRefreshMenus();
toast.success('게시판이 삭제되었습니다.');
router.push(BASE_PATH);
} else {
setError(result.error || '삭제에 실패했습니다.');
toast.error(result.error || '삭제에 실패했습니다.');
setDeleteDialogOpen(false);
}
} catch (err) {
console.error('게시판 삭제 실패:', err);
setError('게시판 삭제 중 오류가 발생했습니다.');
toast.error('게시판 삭제 중 오류가 발생했습니다.');
setDeleteDialogOpen(false);
} finally {
setIsDeleting(false);
}
};
// 수정 모드 전환
const handleEdit = () => {
router.push(`${BASE_PATH}/${boardId}?mode=edit`);
};
// 로딩 중
if (isLoading) {
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
}
// 에러 발생 (view/edit 모드에서)
if (error && !isNewMode) {
return (
<ErrorCard
type="network"
title="게시판 정보를 불러올 수 없습니다"
description={error}
tips={[
'해당 게시판이 존재하는지 확인해주세요',
'인터넷 연결 상태를 확인해주세요',
'잠시 후 다시 시도해주세요',
]}
homeButtonLabel="목록으로 이동"
homeButtonHref={BASE_PATH}
/>
);
}
// 등록 모드
if (mode === 'create') {
return (
<>
{error && (
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<BoardForm mode="create" onSubmit={handleCreate} />
{isSubmitting && (
<div className="fixed inset-0 bg-background/50 flex items-center justify-center z-50">
<div className="flex items-center gap-2 bg-background p-4 rounded-md shadow-lg">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
</div>
)}
</>
);
}
// 수정 모드
if (mode === 'edit' && boardData) {
return (
<>
{error && (
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<BoardForm mode="edit" board={boardData} onSubmit={handleUpdate} />
{isSubmitting && (
<div className="fixed inset-0 bg-background/50 flex items-center justify-center z-50">
<div className="flex items-center gap-2 bg-background p-4 rounded-md shadow-lg">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
</div>
)}
</>
);
}
// 상세 보기 모드
if (mode === 'view' && boardData) {
return (
<>
<BoardDetail
board={boardData}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{boardData.boardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// 데이터 없음 (should not reach here)
return (
<ErrorCard
type="not-found"
title="게시판을 찾을 수 없습니다"
description="요청하신 게시판 정보가 존재하지 않습니다."
homeButtonLabel="목록으로 이동"
homeButtonHref={BASE_PATH}
/>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
export { BoardDetailClientV2 } from './BoardDetailClientV2';
import { useRouter } from 'next/navigation';
import { ClipboardList, Edit, Trash2, Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';

View File

@@ -0,0 +1,122 @@
/**
* LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정
*
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - buttonPosition="top" 사용 (상단 버튼)
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
IntegratedDetailTemplate,
type DetailMode,
} from '@/components/templates/IntegratedDetailTemplate';
import { laborDetailConfig } from './laborDetailConfig';
import type { Labor, LaborFormData } from './types';
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
interface LaborDetailClientV2Props {
laborId?: string;
initialMode?: DetailMode;
}
export default function LaborDetailClientV2({
laborId,
initialMode = 'view',
}: LaborDetailClientV2Props) {
const router = useRouter();
const [labor, setLabor] = useState<Labor | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [mode, setMode] = useState<DetailMode>(initialMode);
// 데이터 로드
useEffect(() => {
if (laborId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getLabor(laborId);
if (result.success && result.data) {
setLabor(result.data);
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
}
} catch {
toast.error('노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
} finally {
setIsLoading(false);
}
};
loadData();
}
}, [laborId, initialMode, router]);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
if (mode === 'create') {
const result = await createLabor(submitData);
if (result.success && result.data) {
return { success: true };
}
return { success: false, error: result.error || '노임 등록에 실패했습니다.' };
} else if (mode === 'edit' && laborId) {
const result = await updateLabor(laborId, submitData);
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '노임 수정에 실패했습니다.' };
}
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
} catch {
return { success: false, error: '저장 중 오류가 발생했습니다.' };
}
},
[mode, laborId]
);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deleteLabor(String(id));
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
} catch {
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, []);
// 모드 변경 핸들러
const handleModeChange = useCallback((newMode: DetailMode) => {
setMode(newMode);
}, []);
return (
<IntegratedDetailTemplate
config={laborDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={labor as Record<string, unknown> | undefined}
itemId={laborId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}
// Named export for backwards compatibility
export { LaborDetailClientV2 };

View File

@@ -1,5 +1,7 @@
export { default as LaborManagementClient } from './LaborManagementClient';
export { default as LaborDetailClient } from './LaborDetailClient';
export { default as LaborDetailClientV2 } from './LaborDetailClientV2';
export { laborDetailConfig } from './laborDetailConfig';
export * from './types';
export * from './constants';
export * from './actions';

View File

@@ -0,0 +1,113 @@
/**
* 노임관리 상세 페이지 설정
* IntegratedDetailTemplate용 config
*/
import { Hammer } from 'lucide-react';
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate';
import type { LaborFormData, LaborCategory, LaborStatus } from './types';
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
// Select 옵션 변환 ('all' 제외)
const categoryFieldOptions = CATEGORY_OPTIONS
.filter((o) => o.value !== 'all')
.map((o) => ({ value: o.value, label: o.label }));
const statusFieldOptions = STATUS_OPTIONS
.filter((o) => o.value !== 'all')
.map((o) => ({ value: o.value, label: o.label }));
// 필드 정의
const fields: FieldDefinition[] = [
{
key: 'laborNumber',
label: '노임번호',
type: 'text',
required: true,
placeholder: '노임번호를 입력하세요',
},
{
key: 'category',
label: '구분',
type: 'select',
required: true,
options: categoryFieldOptions,
placeholder: '구분 선택',
},
{
key: 'minM',
label: '최소 M',
type: 'number',
placeholder: '0.00',
helpText: '소수점 둘째자리까지 입력 가능',
},
{
key: 'maxM',
label: '최대 M',
type: 'number',
placeholder: '0.00',
helpText: '소수점 둘째자리까지 입력 가능',
},
{
key: 'laborPrice',
label: '노임단가',
type: 'number',
placeholder: '0',
formatValue: (value) => {
if (value === null || value === undefined || value === '') return '-';
return Number(value).toLocaleString('ko-KR');
},
},
{
key: 'status',
label: '상태',
type: 'select',
required: true,
options: statusFieldOptions,
placeholder: '상태 선택',
},
];
// DetailConfig (Record<string, unknown> 제약 때문에 타입 캐스팅 필요)
export const laborDetailConfig: DetailConfig = {
title: '노임',
description: '노임 정보를 등록하고 관리합니다.',
icon: Hammer,
basePath: '/construction/order/base-info/labor',
fields,
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
submitLabel: undefined, // 모드에 따라 자동 결정 (등록/저장)
cancelLabel: '취소',
deleteConfirmMessage: {
title: '노임 삭제',
description: '이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
},
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => ({
laborNumber: data.laborNumber,
category: data.category,
minM: data.minM,
maxM: data.maxM,
laborPrice: data.laborPrice,
status: data.status,
}),
transformSubmitData: (formData: Record<string, unknown>): Partial<LaborFormData> => ({
laborNumber: formData.laborNumber as string,
category: formData.category as LaborCategory,
minM: Number(formData.minM) || 0,
maxM: Number(formData.maxM) || 0,
laborPrice: formData.laborPrice === '' || formData.laborPrice === null
? null
: Number(formData.laborPrice),
status: formData.status as LaborStatus,
}),
};
export default laborDetailConfig;

View File

@@ -0,0 +1,136 @@
/**
* PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정
*
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - buttonPosition="top" 사용 (상단 버튼)
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
IntegratedDetailTemplate,
type DetailMode,
} from '@/components/templates/IntegratedDetailTemplate';
import { pricingDetailConfig } from './pricingDetailConfig';
import type { Pricing, PricingFormData } from './types';
import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions';
interface PricingDetailClientV2Props {
pricingId?: string;
initialMode?: DetailMode;
}
export default function PricingDetailClientV2({
pricingId,
initialMode = 'view',
}: PricingDetailClientV2Props) {
const router = useRouter();
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [mode, setMode] = useState<DetailMode>(initialMode);
// 데이터 로드
useEffect(() => {
if (pricingId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getPricingDetail(pricingId);
if (result.success && result.data) {
setPricing(result.data);
} else {
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
}
} catch {
toast.error('단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
} finally {
setIsLoading(false);
}
};
loadData();
}
}, [pricingId, initialMode, router]);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
if (mode === 'create') {
const result = await createPricing(submitData);
if (result.success && result.data) {
return { success: true };
}
return { success: false, error: result.error || '단가 등록에 실패했습니다.' };
} else if (mode === 'edit' && pricingId) {
// edit 모드에서는 수정 가능한 필드만 전송
const result = await updatePricing(pricingId, {
vendor: submitData.vendor,
sellingPrice: submitData.sellingPrice,
status: submitData.status,
});
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '단가 수정에 실패했습니다.' };
}
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
} catch {
return { success: false, error: '저장 중 오류가 발생했습니다.' };
}
},
[mode, pricingId]
);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deletePricing(String(id));
if (result.success) {
return { success: true };
}
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
} catch {
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, []);
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
if (newMode === 'edit' && pricingId) {
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
router.push(`/ko/construction/order/base-info/pricing/${pricingId}/edit`);
} else {
setMode(newMode);
}
},
[pricingId, router]
);
return (
<IntegratedDetailTemplate
config={pricingDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
mode={mode}
initialData={pricing as Record<string, unknown> | undefined}
itemId={pricingId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onModeChange={handleModeChange}
buttonPosition="top"
/>
);
}
// Named export for backwards compatibility
export { PricingDetailClientV2 };

View File

@@ -1,3 +1,6 @@
export { default as PricingListClient } from './PricingListClient';
export { default as PricingDetailClient } from './PricingDetailClient';
export { default as PricingDetailClientV2 } from './PricingDetailClientV2';
export { pricingDetailConfig } from './pricingDetailConfig';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,174 @@
/**
* 단가관리 상세 페이지 설정
* IntegratedDetailTemplate용 config
*/
import { DollarSign } from 'lucide-react';
import type { DetailConfig, FieldDefinition, DetailMode } from '@/components/templates/IntegratedDetailTemplate';
import type { PricingFormData, PricingStatus } from './types';
import { STATUS_OPTIONS, PRICING_STATUS_LABELS } from './types';
import { getVendorList } from './actions';
// 상태 옵션 (all 제외)
const statusFieldOptions = STATUS_OPTIONS
.filter((o) => o.value !== 'all')
.map((o) => ({ value: o.value, label: o.label }));
// 필드 정의
const fields: FieldDefinition[] = [
{
key: 'pricingNumber',
label: '단가번호',
type: 'text',
disabled: true, // 항상 비활성화 (자동생성)
placeholder: '자동생성',
},
{
key: 'itemType',
label: '품목유형',
type: 'text',
disabled: true, // 항상 비활성화
},
{
key: 'category',
label: '카테고리명',
type: 'text',
disabled: true,
},
{
key: 'itemName',
label: '품목명',
type: 'text',
disabled: true,
},
{
key: 'spec',
label: '규격',
type: 'text',
disabled: true,
},
{
key: 'orderItemValue',
label: '무게',
type: 'text',
disabled: true,
helpText: '발주항목 (동적 컬럼)',
},
{
key: 'unit',
label: '단위',
type: 'text',
disabled: true,
},
{
key: 'division',
label: '구분',
type: 'text',
disabled: true,
},
{
key: 'vendor',
label: '거래처',
type: 'select',
disabled: (mode: DetailMode) => mode === 'view',
fetchOptions: async () => {
const result = await getVendorList();
if (result.success && result.data) {
return result.data.map((v) => ({ value: v.name, label: v.name }));
}
return [];
},
placeholder: '거래처 선택',
},
{
key: 'sellingPrice',
label: '판매단가',
type: 'number',
disabled: (mode: DetailMode) => mode === 'view',
placeholder: '판매단가 입력',
formatValue: (value) => {
if (value === null || value === undefined || value === '') return '-';
return Number(value).toLocaleString('ko-KR');
},
},
{
key: 'status',
label: '상태',
type: 'select',
disabled: (mode: DetailMode) => mode === 'view',
options: statusFieldOptions,
placeholder: '상태 선택',
formatValue: (value) => {
if (!value) return '-';
return PRICING_STATUS_LABELS[value as PricingStatus] || String(value);
},
},
{
key: 'note',
label: '비고',
type: 'textarea',
disabled: (mode: DetailMode) => mode === 'view',
placeholder: '비고 입력',
gridSpan: 2,
},
];
// DetailConfig
export const pricingDetailConfig: DetailConfig = {
title: '단가',
description: '단가 정보를 등록하고 관리합니다.',
icon: DollarSign,
basePath: '/construction/order/base-info/pricing',
fields,
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
submitLabel: undefined, // 모드에 따라 자동 결정
cancelLabel: '취소',
deleteConfirmMessage: {
title: '단가 삭제',
description: '이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
},
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
// orderItems에서 첫 번째 항목 추출
const orderItems = data.orderItems as Array<{ name?: string; value?: string }> | undefined;
const firstOrderItem = orderItems?.[0];
return {
pricingNumber: data.pricingNumber || '자동생성',
itemType: data.itemType,
category: data.category,
itemName: data.itemName,
spec: data.spec,
orderItemValue: firstOrderItem?.value || '-',
unit: data.unit,
division: data.division,
vendor: data.vendor,
sellingPrice: data.sellingPrice,
status: data.status || 'in_use',
note: '',
};
},
transformSubmitData: (formData: Record<string, unknown>): Partial<PricingFormData> => ({
itemType: formData.itemType as string,
category: formData.category as string,
itemName: formData.itemName as string,
spec: formData.spec as string,
orderItems: [], // 현재는 빈 배열 (기존 값 유지)
unit: formData.unit as string,
division: formData.division as string,
vendor: formData.vendor as string,
purchasePrice: 0, // 기존 값 유지 필요
marginRate: 0, // 기존 값 유지 필요
sellingPrice: Number(formData.sellingPrice) || 0,
status: formData.status as PricingStatus,
}),
};
export default pricingDetailConfig;

View File

@@ -0,0 +1,140 @@
'use client';
/**
* 현장관리 V2 클라이언트 컴포넌트
*
* V2 라우팅 패턴:
* - /site-management/[id] → 조회 모드 (기본)
* - /site-management/[id]?mode=edit → 수정 모드
*
* 기존 /site-management/[id]/edit → /site-management/[id]?mode=edit 으로 리다이렉트
*/
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import SiteDetailForm from './SiteDetailForm';
import type { Site } from './types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
type DetailMode = 'view' | 'edit';
interface SiteDetailClientV2Props {
siteId: string;
initialMode?: DetailMode;
}
// 목업 데이터 (추후 API 연동 시 제거)
const MOCK_SITE: Site = {
id: '1',
siteCode: '123-12-12345',
partnerId: '1',
partnerName: '거래처명',
siteName: '현장명',
address: '',
status: 'active',
createdAt: '2025-09-01T00:00:00Z',
updatedAt: '2025-09-01T00:00:00Z',
};
const BASE_PATH = '/ko/construction/order/site-management';
export function SiteDetailClientV2({ siteId, initialMode }: SiteDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL의 mode 쿼리 파라미터 확인
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
// 모드 결정: initialMode > query param > 기본값 'view'
const [mode, setMode] = useState<DetailMode>(() => {
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
// 데이터 상태
const [site, setSite] = useState<Site | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 데이터 로드
useEffect(() => {
async function fetchData() {
setIsLoading(true);
setError(null);
try {
// TODO: API 연동
// const result = await getSiteById(siteId);
// if (result.success && result.data) {
// setSite(result.data);
// } else {
// setError(result.error || '현장을 찾을 수 없습니다.');
// }
// 임시: 목업 데이터 사용
await new Promise((resolve) => setTimeout(resolve, 300));
// ID가 숫자가 아니거나 너무 큰 경우 에러
const numericId = parseInt(siteId, 10);
if (isNaN(numericId) || numericId > 100) {
setError('현장을 찾을 수 없습니다.');
} else {
setSite({ ...MOCK_SITE, id: siteId });
}
} catch {
setError('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}
fetchData();
}, [siteId]);
// URL 쿼리 파라미터 변경 감지
useEffect(() => {
if (modeFromQuery === 'edit') {
setMode('edit');
} else if (!modeFromQuery && mode !== 'view') {
setMode('view');
}
}, [modeFromQuery, mode]);
// ===== 로딩 상태 =====
if (isLoading) {
return <ContentLoadingSpinner />;
}
// ===== 에러 상태 =====
if (error) {
return (
<ErrorCard
type="not-found"
title="현장을 찾을 수 없습니다"
message={error}
actionLabel="목록으로"
onAction={() => router.push(BASE_PATH)}
/>
);
}
// ===== 데이터 없음 =====
if (!site) {
return (
<ErrorCard
type="error"
title="오류가 발생했습니다"
message="잠시 후 다시 시도해주세요."
actionLabel="목록으로"
onAction={() => router.push(BASE_PATH)}
/>
);
}
// ===== 정상 렌더링 =====
return <SiteDetailForm site={site} mode={mode} />;
}
export default SiteDetailClientV2;

View File

@@ -116,7 +116,7 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (site?.id) {
router.push(`/ko/construction/order/site-management/${site.id}/edit`);
router.push(`/ko/construction/order/site-management/${site.id}?mode=edit`);
}
}, [router, site?.id]);

View File

@@ -0,0 +1,148 @@
'use client';
/**
* 구조검토 V2 클라이언트 컴포넌트
*
* V2 라우팅 패턴:
* - /structure-review/[id] → 조회 모드 (기본)
* - /structure-review/[id]?mode=edit → 수정 모드
*
* 기존 /structure-review/[id]/edit → /structure-review/[id]?mode=edit 으로 리다이렉트
*/
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import StructureReviewDetailForm from './StructureReviewDetailForm';
import type { StructureReview } from './types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
type DetailMode = 'view' | 'edit';
interface StructureReviewDetailClientV2Props {
reviewId: string;
initialMode?: DetailMode;
}
// 목업 데이터 (추후 API 연동 시 제거)
const MOCK_REVIEW: StructureReview = {
id: '1',
reviewNumber: '123123',
partnerId: '1',
partnerName: '거래처명A',
siteId: '1',
siteName: '현장A',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: null,
status: 'pending',
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
};
const BASE_PATH = '/ko/construction/order/structure-review';
export function StructureReviewDetailClientV2({
reviewId,
initialMode,
}: StructureReviewDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL의 mode 쿼리 파라미터 확인
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
// 모드 결정: initialMode > query param > 기본값 'view'
const [mode, setMode] = useState<DetailMode>(() => {
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
// 데이터 상태
const [review, setReview] = useState<StructureReview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 데이터 로드
useEffect(() => {
async function fetchData() {
setIsLoading(true);
setError(null);
try {
// TODO: API 연동
// const result = await getStructureReviewById(reviewId);
// if (result.success && result.data) {
// setReview(result.data);
// } else {
// setError(result.error || '구조검토를 찾을 수 없습니다.');
// }
// 임시: 목업 데이터 사용
await new Promise((resolve) => setTimeout(resolve, 300));
// ID가 숫자가 아니거나 너무 큰 경우 에러
const numericId = parseInt(reviewId, 10);
if (isNaN(numericId) || numericId > 100) {
setError('구조검토를 찾을 수 없습니다.');
} else {
setReview({ ...MOCK_REVIEW, id: reviewId });
}
} catch {
setError('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}
fetchData();
}, [reviewId]);
// URL 쿼리 파라미터 변경 감지
useEffect(() => {
if (modeFromQuery === 'edit') {
setMode('edit');
} else if (!modeFromQuery && mode !== 'view') {
setMode('view');
}
}, [modeFromQuery, mode]);
// ===== 로딩 상태 =====
if (isLoading) {
return <ContentLoadingSpinner />;
}
// ===== 에러 상태 =====
if (error) {
return (
<ErrorCard
type="not-found"
title="구조검토를 찾을 수 없습니다"
message={error}
actionLabel="목록으로"
onAction={() => router.push(BASE_PATH)}
/>
);
}
// ===== 데이터 없음 =====
if (!review) {
return (
<ErrorCard
type="error"
title="오류가 발생했습니다"
message="잠시 후 다시 시도해주세요."
actionLabel="목록으로"
onAction={() => router.push(BASE_PATH)}
/>
);
}
// ===== 정상 렌더링 =====
return <StructureReviewDetailForm review={review} mode={mode} />;
}
export default StructureReviewDetailClientV2;

View File

@@ -125,7 +125,7 @@ export default function StructureReviewDetailForm({
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (review?.id) {
router.push(`/ko/construction/order/structure-review/${review.id}/edit`);
router.push(`/ko/construction/order/structure-review/${review.id}?mode=edit`);
}
}, [router, review?.id]);

View File

@@ -0,0 +1,229 @@
'use client';
/**
* 거래처(영업) 상세 클라이언트 컴포넌트 V2
* IntegratedDetailTemplate 기반 마이그레이션
*
* 클라이언트 사이드 데이터 페칭 (useClientList 훅 활용)
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Client, ClientFormData } from '@/hooks/useClientList';
import { useClientList, transformClientToApiCreate, transformClientToApiUpdate } from '@/hooks/useClientList';
import { clientDetailConfig } from './clientDetailConfig';
import { toast } from 'sonner';
interface ClientDetailClientV2Props {
clientId?: string;
initialMode?: DetailMode;
}
// 8자리 영문+숫자 코드 생성
function generateCode(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
const { fetchClient, createClient, updateClient, deleteClient } = useClientList();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !clientId || clientId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [clientData, setClientData] = useState<Client | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [generatedCode, setGeneratedCode] = useState<string>('');
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
// 신규 등록 시 코드 생성
const code = generateCode();
setGeneratedCode(code);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const data = await fetchClient(clientId!);
if (data) {
setClientData(data);
} else {
toast.error('거래처를 불러오는데 실패했습니다.');
router.push(clientDetailConfig.basePath);
}
} catch (error) {
console.error('거래처 조회 실패:', error);
toast.error('거래처를 불러오는데 실패했습니다.');
router.push(clientDetailConfig.basePath);
} finally {
setIsLoading(false);
}
};
loadData();
}, [clientId, isNewMode, router, fetchClient]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
setMode(newMode);
if (newMode === 'edit' && clientId) {
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=edit`);
} else if (newMode === 'view' && clientId) {
router.push(`${clientDetailConfig.basePath}/${clientId}`);
}
},
[router, clientId]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
// formData를 ClientFormData로 변환
const clientFormData: ClientFormData = {
clientCode: (formData.clientCode as string) || generatedCode,
name: formData.name as string,
businessNo: formData.businessNo as string,
representative: formData.representative as string,
phone: formData.phone as string,
address: formData.address as string,
email: formData.email as string,
businessType: formData.businessType as string,
businessItem: formData.businessItem as string,
isActive: formData.isActive === 'true',
clientType: (formData.clientType as ClientFormData['clientType']) || '매입',
mobile: formData.mobile as string,
fax: formData.fax as string,
managerName: formData.managerName as string,
managerTel: formData.managerTel as string,
systemManager: formData.systemManager as string,
accountId: formData.accountId as string || '',
accountPassword: formData.accountPassword as string || '',
purchasePaymentDay: '말일',
salesPaymentDay: '말일',
taxAgreement: false,
taxAmount: '',
taxStartDate: '',
taxEndDate: '',
badDebt: false,
badDebtAmount: '',
badDebtReceiveDate: '',
badDebtEndDate: '',
badDebtProgress: '',
memo: formData.memo as string || '',
};
if (isNewMode) {
const result = await createClient(clientFormData);
if (result) {
toast.success('거래처가 등록되었습니다.');
router.push(clientDetailConfig.basePath);
return { success: true };
}
return { success: false, error: '거래처 등록에 실패했습니다.' };
} else {
const result = await updateClient(clientId!, clientFormData);
if (result) {
toast.success('거래처가 수정되었습니다.');
router.push(`${clientDetailConfig.basePath}/${clientId}`);
return { success: true };
}
return { success: false, error: '거래처 수정에 실패했습니다.' };
}
} catch (error) {
console.error('저장 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
}
},
[isNewMode, clientId, generatedCode, router, createClient, updateClient]
);
// 삭제 핸들러
const handleDelete = useCallback(
async (id: string | number) => {
try {
const result = await deleteClient(String(id));
if (result) {
toast.success('거래처가 삭제되었습니다.');
router.push(clientDetailConfig.basePath);
return { success: true };
}
return { success: false, error: '거래처 삭제에 실패했습니다.' };
} catch (error) {
console.error('삭제 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
}
},
[router, deleteClient]
);
// 취소 핸들러
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push(clientDetailConfig.basePath);
} else {
setMode('view');
router.push(`${clientDetailConfig.basePath}/${clientId}`);
}
}, [router, clientId, isNewMode]);
// 초기 데이터 (신규 등록 시 코드 포함)
const initialData = isNewMode
? ({ code: generatedCode } as Client)
: clientData || undefined;
// 타이틀 동적 설정
const dynamicConfig = {
...clientDetailConfig,
title:
mode === 'create'
? '거래처'
: mode === 'edit'
? clientData?.name || '거래처'
: clientData?.name || '거래처 상세',
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={mode}
initialData={initialData}
itemId={clientId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onCancel={handleCancel}
onModeChange={handleModeChange}
/>
);
}

View File

@@ -0,0 +1,257 @@
'use server';
/**
* 거래처(영업) 서버 액션
*
* API Endpoints:
* - GET /api/v1/clients/{id} - 상세 조회
* - POST /api/v1/clients - 등록
* - PUT /api/v1/clients/{id} - 수정
* - DELETE /api/v1/clients/{id} - 삭제
*
* 🚨 401 에러 시 __authError: true 반환 → 클라이언트에서 로그인 페이지로 리다이렉트
*/
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { Client, ClientFormData, ClientApiResponse } from '@/hooks/useClientList';
import {
transformClientFromApi,
transformClientToApiCreate,
transformClientToApiUpdate,
} from '@/hooks/useClientList';
// ===== 응답 타입 =====
interface ActionResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
__authError?: boolean;
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ===== 거래처 단건 조회 =====
export async function getClientById(id: string): Promise<ActionResponse<Client>> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
console.log('[ClientActions] GET client URL:', url);
const { response, error } = await serverFetch(url, { method: 'GET' });
// 🚨 401 인증 에러
if (error?.__authError) {
console.error('[ClientActions] Auth error:', error);
return { success: false, __authError: true };
}
if (!response) {
console.error('[ClientActions] No response, error:', error);
return {
success: false,
error: error?.message || '서버 응답이 없습니다.',
};
}
// 응답 텍스트 먼저 읽기
const responseText = await response.text();
console.log('[ClientActions] Response status:', response.status);
console.log('[ClientActions] Response text:', responseText);
if (!response.ok) {
console.error('[ClientActions] GET client error:', response.status);
return {
success: false,
error: `API 오류: ${response.status}`,
};
}
// JSON 파싱
let result: ApiResponse<ClientApiResponse>;
try {
result = JSON.parse(responseText);
} catch {
console.error('[ClientActions] JSON parse error');
return {
success: false,
error: 'JSON 파싱 오류',
};
}
if (!result.success || !result.data) {
console.error('[ClientActions] API returned error:', result);
return {
success: false,
error: result.message || '거래처를 찾을 수 없습니다.',
};
}
return {
success: true,
data: transformClientFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] getClientById error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 조회 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 생성 =====
export async function createClient(
formData: Partial<ClientFormData>
): Promise<ActionResponse<Client>> {
try {
const apiData = transformClientToApiCreate(formData as ClientFormData);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`;
console.log('[ClientActions] POST client request:', apiData);
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify(apiData),
});
// 🚨 401 인증 에러
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!response) {
return {
success: false,
error: error?.message || '서버 오류가 발생했습니다.',
};
}
const result: ApiResponse<ClientApiResponse> = await response.json();
console.log('[ClientActions] POST client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 생성에 실패했습니다.',
};
}
return {
success: true,
data: transformClientFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] createClient error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 생성 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 수정 =====
export async function updateClient(
id: string,
formData: Partial<ClientFormData>
): Promise<ActionResponse<Client>> {
try {
const apiData = transformClientToApiUpdate(formData as ClientFormData);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
console.log('[ClientActions] PUT client request:', apiData);
const { response, error } = await serverFetch(url, {
method: 'PUT',
body: JSON.stringify(apiData),
});
// 🚨 401 인증 에러
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!response) {
return {
success: false,
error: error?.message || '서버 오류가 발생했습니다.',
};
}
const result: ApiResponse<ClientApiResponse> = await response.json();
console.log('[ClientActions] PUT client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 수정에 실패했습니다.',
};
}
return {
success: true,
data: transformClientFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] updateClient error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 수정 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 삭제 =====
export async function deleteClient(id: string): Promise<ActionResponse> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
const { response, error } = await serverFetch(url, { method: 'DELETE' });
// 🚨 401 인증 에러
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!response) {
return {
success: false,
error: error?.message || '서버 오류가 발생했습니다.',
};
}
const result = await response.json();
console.log('[ClientActions] DELETE client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 삭제에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] deleteClient error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 삭제 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 코드 생성 (8자리 영문+숫자) =====
export async function generateClientCode(): Promise<string> {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

View File

@@ -0,0 +1,276 @@
/**
* 거래처(영업) 상세 페이지 설정
* IntegratedDetailTemplate V2 마이그레이션
*/
import { Building2 } from 'lucide-react';
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { ClientFormData, Client } from '@/hooks/useClientList';
// ===== 거래처 유형 옵션 =====
const CLIENT_TYPE_OPTIONS = [
{ value: '매입', label: '매입' },
{ value: '매출', label: '매출' },
{ value: '매입매출', label: '매입매출' },
];
// ===== 상태 옵션 =====
const STATUS_OPTIONS = [
{ value: 'true', label: '활성' },
{ value: 'false', label: '비활성' },
];
// ===== 필드 정의 =====
export const clientFields: FieldDefinition[] = [
// 기본 정보
{
key: 'businessNo',
label: '사업자등록번호',
type: 'text',
required: true,
placeholder: '10자리 숫자 (예: 123-45-67890)',
validation: [
{ type: 'required', message: '사업자등록번호를 입력해주세요.' },
{
type: 'custom',
message: '사업자등록번호는 10자리 숫자여야 합니다.',
validate: (value) => {
const digits = String(value || '').replace(/-/g, '').trim();
return /^\d{10}$/.test(digits);
},
},
],
},
{
key: 'clientCode',
label: '거래처 코드',
type: 'text',
disabled: true,
helpText: '자동 생성됩니다',
},
{
key: 'name',
label: '거래처명',
type: 'text',
required: true,
placeholder: '거래처명 입력',
validation: [
{ type: 'required', message: '거래처명을 입력해주세요.' },
{ type: 'minLength', value: 2, message: '거래처명은 2자 이상 입력해주세요.' },
],
},
{
key: 'representative',
label: '대표자명',
type: 'text',
required: true,
placeholder: '대표자명 입력',
validation: [
{ type: 'required', message: '대표자명을 입력해주세요.' },
{ type: 'minLength', value: 2, message: '대표자명은 2자 이상 입력해주세요.' },
],
},
{
key: 'clientType',
label: '거래처 유형',
type: 'radio',
required: true,
options: CLIENT_TYPE_OPTIONS,
},
{
key: 'businessType',
label: '업태',
type: 'text',
placeholder: '제조업, 도소매업 등',
},
{
key: 'businessItem',
label: '종목',
type: 'text',
placeholder: '철강, 건설 등',
},
// 연락처 정보
{
key: 'address',
label: '주소',
type: 'text',
placeholder: '주소 입력',
gridSpan: 2,
},
{
key: 'phone',
label: '전화번호',
type: 'tel',
placeholder: '02-1234-5678',
},
{
key: 'mobile',
label: '모바일',
type: 'tel',
placeholder: '010-1234-5678',
},
{
key: 'fax',
label: '팩스',
type: 'tel',
placeholder: '02-1234-5678',
},
{
key: 'email',
label: '이메일',
type: 'email',
placeholder: 'example@company.com',
validation: [
{
type: 'custom',
message: '올바른 이메일 형식이 아닙니다.',
validate: (value) => {
if (!value) return true; // 선택 필드
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value));
},
},
],
},
// 담당자 정보
{
key: 'managerName',
label: '담당자명',
type: 'text',
placeholder: '담당자명 입력',
},
{
key: 'managerTel',
label: '담당자 전화',
type: 'tel',
placeholder: '010-1234-5678',
},
{
key: 'systemManager',
label: '시스템 관리자',
type: 'text',
placeholder: '시스템 관리자명',
},
// 기타 정보
{
key: 'memo',
label: '메모',
type: 'textarea',
placeholder: '메모 입력',
gridSpan: 2,
},
{
key: 'isActive',
label: '상태',
type: 'radio',
options: STATUS_OPTIONS,
defaultValue: 'true',
},
];
// ===== 섹션 정의 =====
export const clientSections: SectionDefinition[] = [
{
id: 'basicInfo',
title: '기본 정보',
description: '거래처의 기본 정보를 입력하세요',
fields: ['businessNo', 'clientCode', 'name', 'representative', 'clientType', 'businessType', 'businessItem'],
},
{
id: 'contactInfo',
title: '연락처 정보',
description: '거래처의 연락처 정보를 입력하세요',
fields: ['address', 'phone', 'mobile', 'fax', 'email'],
},
{
id: 'managerInfo',
title: '담당자 정보',
description: '거래처 담당자 정보를 입력하세요',
fields: ['managerName', 'managerTel', 'systemManager'],
},
{
id: 'otherInfo',
title: '기타 정보',
description: '추가 정보를 입력하세요',
fields: ['memo', 'isActive'],
},
];
// ===== 설정 =====
export const clientDetailConfig: DetailConfig<Client> = {
title: '거래처',
description: '거래처 정보를 관리합니다',
icon: Building2,
basePath: '/ko/sales/client-management-sales-admin',
fields: clientFields,
sections: clientSections,
gridColumns: 2,
actions: {
submitLabel: '저장',
cancelLabel: '취소',
showDelete: true,
deleteLabel: '삭제',
showEdit: true,
editLabel: '수정',
showBack: true,
backLabel: '목록',
deleteConfirmMessage: {
title: '거래처 삭제',
description: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Client) => ({
businessNo: data.businessNo || '',
clientCode: data.code || '',
name: data.name || '',
representative: data.representative || '',
clientType: data.clientType || '매입',
businessType: data.businessType || '',
businessItem: data.businessItem || '',
address: data.address || '',
phone: data.phone || '',
mobile: data.mobile || '',
fax: data.fax || '',
email: data.email || '',
managerName: data.managerName || '',
managerTel: data.managerTel || '',
systemManager: data.systemManager || '',
memo: data.memo || '',
isActive: data.status === '활성' ? 'true' : 'false',
}),
transformSubmitData: (formData): Partial<ClientFormData> => ({
clientCode: formData.clientCode as string,
name: formData.name as string,
businessNo: formData.businessNo as string,
representative: formData.representative as string,
clientType: formData.clientType as ClientFormData['clientType'],
businessType: formData.businessType as string,
businessItem: formData.businessItem as string,
address: formData.address as string,
phone: formData.phone as string,
mobile: formData.mobile as string,
fax: formData.fax as string,
email: formData.email as string,
managerName: formData.managerName as string,
managerTel: formData.managerTel as string,
systemManager: formData.systemManager as string,
memo: formData.memo as string,
isActive: formData.isActive === 'true',
// 기본값 설정
purchasePaymentDay: '말일',
salesPaymentDay: '말일',
taxAgreement: false,
taxAmount: '',
taxStartDate: '',
taxEndDate: '',
badDebt: false,
badDebtAmount: '',
badDebtReceiveDate: '',
badDebtEndDate: '',
badDebtProgress: '',
accountId: '',
accountPassword: '',
}),
};

View File

@@ -74,7 +74,7 @@ export function InquiryDetail({
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/customer-center/qna/${inquiry.id}/edit`);
router.push(`/ko/customer-center/qna/${inquiry.id}?mode=edit`);
}, [router, inquiry.id]);
const handleConfirmDelete = useCallback(async () => {

View File

@@ -0,0 +1,221 @@
'use client';
/**
* 1:1 문의 V2 클라이언트 컴포넌트
*
* V2 라우팅 패턴:
* - /qna/[id] → 조회 모드 (기본)
* - /qna/[id]?mode=edit → 수정 모드
* - /qna/create → 등록 모드
*
* 기존 /qna/[id]/edit → /qna/[id]?mode=edit 으로 리다이렉트
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { InquiryDetail } from './InquiryDetail';
import { InquiryForm } from './InquiryForm';
import { transformPostToInquiry, type Inquiry, type Comment } from './types';
import {
getPost,
getComments,
createComment,
updateComment,
deleteComment,
deletePost,
} from '../shared/actions';
import { transformApiToComment } from '../shared/types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
type DetailMode = 'view' | 'edit' | 'create';
interface InquiryDetailClientV2Props {
inquiryId?: string;
initialMode?: DetailMode;
}
const BASE_PATH = '/ko/customer-center/qna';
export function InquiryDetailClientV2({ inquiryId, initialMode }: InquiryDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL의 mode 쿼리 파라미터 확인
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isCreateMode = !inquiryId || inquiryId === 'create';
// 모드 결정: create > initialMode > query param > 기본값 'view'
const [mode, setMode] = useState<DetailMode>(() => {
if (isCreateMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
// 데이터 상태
const [inquiry, setInquiry] = useState<Inquiry | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [isLoading, setIsLoading] = useState(!isCreateMode);
const [error, setError] = useState<string | null>(null);
const [currentUserId, setCurrentUserId] = useState<string>('');
// 현재 사용자 ID 가져오기 (localStorage에서)
useEffect(() => {
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
setCurrentUserId(String(user.id || ''));
} catch {
setCurrentUserId('');
}
}
}, []);
// 데이터 로드
useEffect(() => {
if (isCreateMode) return;
async function fetchData() {
setIsLoading(true);
setError(null);
// 게시글과 댓글 동시 로드
const [postResult, commentsResult] = await Promise.all([
getPost('qna', inquiryId!),
getComments('qna', inquiryId!),
]);
if (postResult.success && postResult.data) {
setInquiry(transformPostToInquiry(postResult.data));
} else {
setError(postResult.error || '문의를 찾을 수 없습니다.');
}
if (commentsResult.success && commentsResult.data) {
setComments(commentsResult.data.map(transformApiToComment));
}
setIsLoading(false);
}
fetchData();
}, [inquiryId, isCreateMode]);
// URL 쿼리 파라미터 변경 감지
useEffect(() => {
if (isCreateMode) return;
if (modeFromQuery === 'edit') {
setMode('edit');
} else if (!modeFromQuery && mode !== 'view') {
setMode('view');
}
}, [modeFromQuery, isCreateMode, mode]);
// ===== 댓글 핸들러 =====
const handleAddComment = useCallback(
async (content: string) => {
if (!inquiryId) return;
const result = await createComment('qna', inquiryId, content);
if (result.success && result.data) {
setComments((prev) => [...prev, transformApiToComment(result.data!)]);
} else {
console.error('댓글 등록 실패:', result.error);
}
},
[inquiryId]
);
const handleUpdateComment = useCallback(
async (commentId: string, content: string) => {
if (!inquiryId) return;
const result = await updateComment('qna', inquiryId, commentId, content);
if (result.success && result.data) {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c))
);
} else {
console.error('댓글 수정 실패:', result.error);
}
},
[inquiryId]
);
const handleDeleteComment = useCallback(
async (commentId: string) => {
if (!inquiryId) return;
const result = await deleteComment('qna', inquiryId, commentId);
if (result.success) {
setComments((prev) => prev.filter((c) => c.id !== commentId));
} else {
console.error('댓글 삭제 실패:', result.error);
}
},
[inquiryId]
);
// 문의 삭제
const handleDeleteInquiry = useCallback(async () => {
if (!inquiryId) return false;
const result = await deletePost('qna', inquiryId);
return result.success;
}, [inquiryId]);
// ===== 로딩 상태 =====
if (isLoading) {
return <ContentLoadingSpinner />;
}
// ===== 에러 상태 =====
if (error) {
return (
<ErrorCard
type="not-found"
title="문의를 찾을 수 없습니다"
message={error}
actionLabel="목록으로"
onAction={() => router.push(BASE_PATH)}
/>
);
}
// ===== 등록 모드 =====
if (mode === 'create') {
return <InquiryForm mode="create" />;
}
// ===== 수정 모드 =====
if (mode === 'edit' && inquiry) {
return <InquiryForm mode="edit" initialData={inquiry} />;
}
// ===== 조회 모드 =====
if (inquiry) {
return (
<InquiryDetail
inquiry={inquiry}
comments={comments}
currentUserId={currentUserId}
onAddComment={handleAddComment}
onUpdateComment={handleUpdateComment}
onDeleteComment={handleDeleteComment}
onDeleteInquiry={handleDeleteInquiry}
/>
);
}
// 데이터 없음 (비정상 상태)
return (
<ErrorCard
type="error"
title="오류가 발생했습니다"
message="잠시 후 다시 시도해주세요."
actionLabel="목록으로"
onAction={() => router.push(BASE_PATH)}
/>
);
}
export default InquiryDetailClientV2;

View File

@@ -1,4 +1,5 @@
export { InquiryList } from './InquiryList';
export { InquiryDetail } from './InquiryDetail';
export { InquiryForm } from './InquiryForm';
export { InquiryDetailClientV2 } from './InquiryDetailClientV2';
export * from './types';

View File

@@ -127,7 +127,7 @@ export function AttendanceInfoDialog({
onValueChange={(value) => handleChange('employeeId', value)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (

View File

@@ -92,7 +92,7 @@ export function ReasonInfoDialog({
onValueChange={(value) => handleChange('employeeId', value)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
@@ -140,7 +140,7 @@ export function ReasonInfoDialog({
onValueChange={(value) => handleChange('reasonType', value)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(REASON_TYPE_LABELS).map(([value, label]) => (

View File

@@ -0,0 +1,136 @@
'use client';
/**
* 공정관리 상세 클라이언트 컴포넌트 V2
*
* 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new
* 기존 ProcessDetail, ProcessForm 컴포넌트 활용
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ProcessDetail } from './ProcessDetail';
import { ProcessForm } from './ProcessForm';
import { getProcessById } from './actions';
import type { Process } from '@/types/process';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
import { toast } from 'sonner';
type DetailMode = 'view' | 'edit' | 'create';
interface ProcessDetailClientV2Props {
processId?: string;
initialMode?: DetailMode;
}
const BASE_PATH = '/ko/master-data/process-management';
export function ProcessDetailClientV2({ processId, initialMode }: ProcessDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !processId || processId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [processData, setProcessData] = useState<Process | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [error, setError] = useState<string | null>(null);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const result = await getProcessById(processId!);
if (result.success && result.data) {
setProcessData(result.data);
} else {
setError(result.error || '공정 정보를 찾을 수 없습니다.');
toast.error('공정을 불러오는데 실패했습니다.');
}
} catch (err) {
console.error('공정 조회 실패:', err);
setError('공정 정보를 불러오는 중 오류가 발생했습니다.');
toast.error('공정을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [processId, isNewMode]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 로딩 중
if (isLoading) {
return <ContentLoadingSpinner text="공정 정보를 불러오는 중..." />;
}
// 에러 발생 (view/edit 모드에서)
if (error && !isNewMode) {
return (
<ErrorCard
type="network"
title="공정 정보를 불러올 수 없습니다"
description={error}
tips={[
'해당 공정이 존재하는지 확인해주세요',
'인터넷 연결 상태를 확인해주세요',
'잠시 후 다시 시도해주세요',
]}
homeButtonLabel="목록으로 이동"
homeButtonHref={BASE_PATH}
/>
);
}
// 등록 모드
if (mode === 'create') {
return <ProcessForm mode="create" />;
}
// 수정 모드
if (mode === 'edit' && processData) {
return <ProcessForm mode="edit" initialData={processData} />;
}
// 상세 보기 모드
if (mode === 'view' && processData) {
return <ProcessDetail process={processData} />;
}
// 데이터 없음 (should not reach here)
return (
<ErrorCard
type="not-found"
title="공정을 찾을 수 없습니다"
description="요청하신 공정 정보가 존재하지 않습니다."
homeButtonLabel="목록으로 이동"
homeButtonHref={BASE_PATH}
/>
);
}

View File

@@ -1,5 +1,6 @@
export { default as ProcessListClient } from './ProcessListClient';
export { ProcessForm } from './ProcessForm';
export { ProcessDetail } from './ProcessDetail';
export { ProcessDetailClientV2 } from './ProcessDetailClientV2';
export { RuleModal } from './RuleModal';
export { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';

View File

@@ -0,0 +1,201 @@
'use client';
/**
* 팝업관리 상세 클라이언트 컴포넌트 V2
* IntegratedDetailTemplate 기반 마이그레이션
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { format } from 'date-fns';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData } from './types';
import { getPopupById, createPopup, updatePopup, deletePopup } from './actions';
import { popupDetailConfig } from './popupDetailConfig';
import { toast } from 'sonner';
interface PopupDetailClientV2Props {
popupId?: string;
initialMode?: DetailMode;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !popupId || popupId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [popupData, setPopupData] = useState<Popup | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const data = await getPopupById(popupId!);
if (data) {
setPopupData(data);
} else {
toast.error('팝업을 불러오는데 실패했습니다.');
router.push(popupDetailConfig.basePath);
}
} catch (error) {
console.error('팝업 조회 실패:', error);
toast.error('팝업을 불러오는데 실패했습니다.');
router.push(popupDetailConfig.basePath);
} finally {
setIsLoading(false);
}
};
loadData();
}, [popupId, isNewMode, router]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
setMode(newMode);
if (newMode === 'edit' && popupId) {
router.push(`${popupDetailConfig.basePath}/${popupId}?mode=edit`);
} else if (newMode === 'view' && popupId) {
router.push(`${popupDetailConfig.basePath}/${popupId}`);
}
},
[router, popupId]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const popupFormData: PopupFormData = {
target: (formData.target as PopupFormData['target']) || 'all',
title: formData.title as string,
content: formData.content as string,
status: (formData.status as PopupFormData['status']) || 'inactive',
startDate: formData.startDate as string,
endDate: formData.endDate as string,
};
if (isNewMode) {
const result = await createPopup(popupFormData);
if (result.success) {
toast.success('팝업이 등록되었습니다.');
router.push(popupDetailConfig.basePath);
return { success: true };
}
return { success: false, error: result.error || '팝업 등록에 실패했습니다.' };
} else {
const result = await updatePopup(popupId!, popupFormData);
if (result.success) {
toast.success('팝업이 수정되었습니다.');
router.push(`${popupDetailConfig.basePath}/${popupId}`);
return { success: true };
}
return { success: false, error: result.error || '팝업 수정에 실패했습니다.' };
}
} catch (error) {
console.error('저장 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
}
},
[isNewMode, popupId, router]
);
// 삭제 핸들러
const handleDelete = useCallback(
async (id: string | number) => {
try {
const result = await deletePopup(String(id));
if (result.success) {
toast.success('팝업이 삭제되었습니다.');
router.push(popupDetailConfig.basePath);
return { success: true };
}
return { success: false, error: result.error || '팝업 삭제에 실패했습니다.' };
} catch (error) {
console.error('삭제 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
}
},
[router]
);
// 취소 핸들러
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push(popupDetailConfig.basePath);
} else {
setMode('view');
router.push(`${popupDetailConfig.basePath}/${popupId}`);
}
}, [router, popupId, isNewMode]);
// 초기 데이터 (신규 등록 시 기본값 포함)
const initialData = isNewMode
? ({
target: 'all',
status: 'inactive',
author: CURRENT_USER.name,
createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'),
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
} as unknown as Popup)
: popupData || undefined;
// 타이틀 동적 설정
const dynamicConfig = {
...popupDetailConfig,
title:
mode === 'create'
? '팝업관리'
: mode === 'edit'
? popupData?.title || '팝업관리'
: popupData?.title || '팝업관리 상세',
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={mode}
initialData={initialData}
itemId={popupId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onCancel={handleCancel}
onModeChange={handleModeChange}
/>
);
}

View File

@@ -1,4 +1,5 @@
export { PopupList } from './PopupList';
export { PopupForm } from './PopupForm';
export { PopupDetail } from './PopupDetail';
export { PopupDetailClientV2 } from './PopupDetailClientV2';
export * from './types';

View File

@@ -0,0 +1,191 @@
/**
* 팝업관리 상세 페이지 설정
* IntegratedDetailTemplate V2 마이그레이션
*/
import { Megaphone } from 'lucide-react';
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
import { RichTextEditor } from '@/components/board/RichTextEditor';
import { createElement } from 'react';
// ===== 대상 옵션 =====
const TARGET_OPTIONS = [
{ value: 'all', label: '전사' },
{ value: 'department', label: '부서별' },
];
// ===== 상태 옵션 =====
const STATUS_OPTIONS = [
{ value: 'inactive', label: '사용안함' },
{ value: 'active', label: '사용함' },
];
// ===== 필드 정의 =====
export const popupFields: FieldDefinition[] = [
{
key: 'target',
label: '대상',
type: 'select',
required: true,
options: TARGET_OPTIONS,
placeholder: '대상을 선택해주세요',
validation: [
{ type: 'required', message: '대상을 선택해주세요.' },
],
},
{
key: 'startDate',
label: '시작일',
type: 'date',
required: true,
validation: [
{ type: 'required', message: '시작일을 선택해주세요.' },
],
},
{
key: 'endDate',
label: '종료일',
type: 'date',
required: true,
validation: [
{ type: 'required', message: '종료일을 선택해주세요.' },
{
type: 'custom',
message: '종료일은 시작일 이후여야 합니다.',
validate: (value, formData) => {
const startDate = formData.startDate as string;
const endDate = value as string;
if (!startDate || !endDate) return true;
return endDate >= startDate;
},
},
],
},
{
key: 'title',
label: '제목',
type: 'text',
required: true,
placeholder: '제목을 입력해주세요',
gridSpan: 2,
validation: [
{ type: 'required', message: '제목을 입력해주세요.' },
],
},
{
key: 'content',
label: '내용',
type: 'custom',
required: true,
gridSpan: 2,
validation: [
{
type: 'custom',
message: '내용을 입력해주세요.',
validate: (value) => {
const content = value as string;
return !!content && content.trim() !== '' && content !== '<p></p>';
},
},
],
renderField: ({ value, onChange, mode, disabled }) => {
if (mode === 'view') {
// View 모드: HTML 렌더링
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: (value as string) || '' },
});
}
// Edit/Create 모드: RichTextEditor
return createElement(RichTextEditor, {
value: (value as string) || '',
onChange: onChange,
placeholder: '내용을 입력해주세요',
minHeight: '200px',
disabled: disabled,
});
},
formatValue: (value) => {
if (!value) return '-';
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: value as string },
});
},
},
{
key: 'status',
label: '상태',
type: 'radio',
options: STATUS_OPTIONS,
defaultValue: 'inactive',
},
{
key: 'author',
label: '작성자',
type: 'text',
disabled: true,
hideInForm: false,
},
{
key: 'createdAt',
label: '등록일시',
type: 'text',
disabled: true,
hideInForm: false,
},
];
// ===== 섹션 정의 =====
export const popupSections: SectionDefinition[] = [
{
id: 'basicInfo',
title: '팝업 정보',
description: '팝업의 기본 정보를 입력해주세요',
fields: ['target', 'startDate', 'endDate', 'title', 'content', 'status', 'author', 'createdAt'],
},
];
// ===== 설정 =====
export const popupDetailConfig: DetailConfig<Popup> = {
title: '팝업관리',
description: '팝업 목록을 관리합니다',
icon: Megaphone,
basePath: '/ko/settings/popup-management',
fields: popupFields,
sections: popupSections,
gridColumns: 2,
actions: {
submitLabel: '저장',
cancelLabel: '취소',
showDelete: true,
deleteLabel: '삭제',
showEdit: true,
editLabel: '수정',
showBack: true,
backLabel: '목록',
deleteConfirmMessage: {
title: '팝업 삭제',
description: '이 팝업을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Popup) => ({
target: data.target || 'all',
startDate: data.startDate || '',
endDate: data.endDate || '',
title: data.title || '',
content: data.content || '',
status: data.status || 'inactive',
author: data.author || '',
createdAt: data.createdAt || '',
}),
transformSubmitData: (formData): Partial<PopupFormData> => ({
target: formData.target as PopupTarget,
title: formData.title as string,
content: formData.content as string,
status: formData.status as PopupStatus,
startDate: formData.startDate as string,
endDate: formData.endDate as string,
}),
};

View File

@@ -0,0 +1,283 @@
'use client';
/**
* FieldInput - 순수 입력 컴포넌트 렌더러
*
* 라벨, 에러 메시지 없이 순수 입력 컴포넌트만 반환
* 레이아웃(라벨, 에러, description)은 DetailField가 담당
*/
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { FieldDefinition, DetailMode, FieldOption } from './types';
import type { ReactNode } from 'react';
export interface FieldInputProps {
field: FieldDefinition;
value: unknown;
onChange: (value: unknown) => void;
mode: DetailMode;
error?: string;
dynamicOptions?: FieldOption[];
}
export function FieldInput({
field,
value,
onChange,
mode,
error,
dynamicOptions,
}: FieldInputProps) {
const isViewMode = mode === 'view';
const isDisabled =
field.readonly ||
(typeof field.disabled === 'function'
? field.disabled(mode)
: field.disabled);
// 옵션 (동적 로드된 옵션 우선)
const options = dynamicOptions || field.options || [];
// View 모드: 값만 표시 (라벨 없이)
if (isViewMode) {
return (
<div className="text-sm">
{renderViewValue(field, value, options)}
</div>
);
}
// Form 모드: 입력 필드만 반환 (라벨, 에러 없이)
return renderFormField(field, value, onChange, isDisabled, options, error);
}
// View 모드 값 렌더링
function renderViewValue(
field: FieldDefinition,
value: unknown,
options: FieldOption[]
): ReactNode {
// 커스텀 포맷터가 있으면 사용
if (field.formatValue) {
return field.formatValue(value);
}
// 값이 없으면 '-' 표시
if (value === null || value === undefined || value === '') {
return '-';
}
switch (field.type) {
case 'password':
return '****';
case 'select':
case 'radio': {
const option = options.find((opt) => opt.value === value);
return option?.label || String(value);
}
case 'checkbox':
return value ? '예' : '아니오';
case 'date':
if (typeof value === 'string') {
try {
return new Date(value).toLocaleDateString('ko-KR');
} catch {
return value;
}
}
return String(value);
case 'textarea':
return (
<div className="whitespace-pre-wrap">{String(value)}</div>
);
default:
return String(value);
}
}
// Form 모드 필드 렌더링 (입력 컴포넌트만)
function renderFormField(
field: FieldDefinition,
value: unknown,
onChange: (value: unknown) => void,
disabled: boolean,
options: FieldOption[],
error?: string
): ReactNode {
const stringValue = value !== null && value !== undefined ? String(value) : '';
const hasError = !!error;
switch (field.type) {
case 'text':
case 'email':
case 'tel':
return (
<Input
id={field.key}
type={field.type}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
className={cn(hasError && 'border-destructive')}
/>
);
case 'number':
return (
<Input
id={field.key}
type="number"
value={stringValue}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
placeholder={field.placeholder}
disabled={disabled}
className={cn(hasError && 'border-destructive')}
/>
);
case 'password':
return (
<Input
id={field.key}
type="password"
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || '****'}
disabled={disabled}
className={cn(hasError && 'border-destructive')}
/>
);
case 'textarea':
return (
<Textarea
id={field.key}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
className={cn(hasError && 'border-destructive')}
rows={4}
/>
);
case 'select':
return (
<Select
key={`${field.key}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={cn(hasError && 'border-destructive')}>
<SelectValue placeholder={field.placeholder || '선택하세요'} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case 'radio':
return (
<RadioGroup
value={stringValue}
onValueChange={onChange}
disabled={disabled}
className="flex flex-wrap gap-4"
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${field.key}-${option.value}`} />
<Label
htmlFor={`${field.key}-${option.value}`}
className="font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
id={field.key}
checked={!!value}
onCheckedChange={onChange}
disabled={disabled}
/>
<Label
htmlFor={field.key}
className="font-normal cursor-pointer"
>
{field.placeholder || '동의'}
</Label>
</div>
);
case 'date':
return (
<Input
id={field.key}
type="date"
value={stringValue}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(hasError && 'border-destructive')}
/>
);
case 'custom':
if (field.renderField) {
return field.renderField({
value,
onChange,
mode: 'edit',
disabled,
});
}
return <div className="text-muted-foreground"> </div>;
default:
return (
<Input
id={field.key}
value={stringValue}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
/>
);
}
}
export default FieldInput;

View File

@@ -0,0 +1,145 @@
/**
* DetailActions - 상세 페이지 버튼 영역 컴포넌트
*
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
* Form 모드: 취소 | 저장/등록
*/
'use client';
import type { ReactNode } from 'react';
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface DetailActionsProps {
/** 현재 모드 */
mode: 'view' | 'edit' | 'create';
/** 제출 중 여부 */
isSubmitting?: boolean;
/** 권한 */
permissions?: {
canEdit?: boolean;
canDelete?: boolean;
};
/** 버튼 표시 설정 */
showButtons?: {
back?: boolean;
delete?: boolean;
edit?: boolean;
};
/** 버튼 라벨 */
labels?: {
back?: string;
cancel?: string;
delete?: string;
edit?: string;
submit?: string;
};
/** 핸들러 */
onBack?: () => void;
onCancel?: () => void;
onDelete?: () => void;
onEdit?: () => void;
onSubmit?: () => void;
/** 추가 액션 (view 모드에서 삭제 버튼 앞에 표시) */
extraActions?: ReactNode;
/** 추가 클래스 */
className?: string;
}
export function DetailActions({
mode,
isSubmitting = false,
permissions = {},
showButtons = {},
labels = {},
onBack,
onCancel,
onDelete,
onEdit,
onSubmit,
extraActions,
className,
}: DetailActionsProps) {
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const {
canEdit = true,
canDelete = true,
} = permissions;
const {
back: showBack = true,
delete: showDelete = true,
edit: showEdit = true,
} = showButtons;
const {
back: backLabel = '목록으로',
cancel: cancelLabel = '취소',
delete: deleteLabel = '삭제',
edit: editLabel = '수정',
submit: submitLabel,
} = labels;
// 실제 submit 라벨 (create 모드면 '등록', 아니면 '저장')
const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장');
if (isViewMode) {
return (
<div className={cn('flex items-center justify-between', className)}>
{/* 왼쪽: 목록으로 */}
{showBack && onBack ? (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
{backLabel}
</Button>
) : (
<div />
)}
{/* 오른쪽: 추가액션 + 삭제 + 수정 */}
<div className="flex items-center gap-2">
{extraActions}
{canDelete && showDelete && onDelete && (
<Button
variant="outline"
onClick={onDelete}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
{deleteLabel}
</Button>
)}
{canEdit && showEdit && onEdit && (
<Button onClick={onEdit}>
<Edit className="w-4 h-4 mr-2" />
{editLabel}
</Button>
)}
</div>
</div>
);
}
// Form 모드 (edit/create)
return (
<div className={cn('flex items-center justify-between', className)}>
{/* 왼쪽: 취소 */}
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
{cancelLabel}
</Button>
{/* 오른쪽: 저장/등록 */}
<Button onClick={onSubmit} disabled={isSubmitting}>
<Save className="w-4 h-4 mr-2" />
{actualSubmitLabel}
</Button>
</div>
);
}
export default DetailActions;

View File

@@ -0,0 +1,90 @@
/**
* DetailField - 상세 페이지 필드 레이아웃 컴포넌트
*
* 라벨, 필수 마크, 에러 메시지, 설명을 포함한 필드 래퍼
*/
'use client';
import type { ReactNode } from 'react';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
export interface DetailFieldProps {
/** 필드 라벨 */
label: string;
/** 필수 여부 (default: false) */
required?: boolean;
/** 에러 메시지 */
error?: string;
/** 설명 텍스트 */
description?: string;
/** 그리드 span (1~4) */
colSpan?: 1 | 2 | 3 | 4;
/** 필드 내용 (Input, Select 등) */
children: ReactNode;
/** 추가 클래스 */
className?: string;
/** 라벨 숨김 (시각적으로만) */
hideLabel?: boolean;
/** HTML for 속성 연결용 ID */
htmlFor?: string;
/** 모드 - view 모드에서는 필수마크/에러/description 숨김 */
mode?: 'view' | 'edit' | 'create';
}
// colSpan에 따른 그리드 클래스
const colSpanClasses = {
1: '',
2: 'md:col-span-2',
3: 'md:col-span-2 lg:col-span-3',
4: 'md:col-span-2 lg:col-span-4',
};
export function DetailField({
label,
required = false,
error,
description,
colSpan = 1,
children,
className,
hideLabel = false,
htmlFor,
mode,
}: DetailFieldProps) {
const isViewMode = mode === 'view';
return (
<div className={cn('space-y-2', colSpanClasses[colSpan], className)}>
{/* 라벨 영역 */}
<Label
htmlFor={htmlFor}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
isViewMode && 'text-muted-foreground',
hideLabel && 'sr-only'
)}
>
{label}
{/* View 모드에서는 필수마크 숨김 */}
{required && !isViewMode && <span className="text-destructive ml-1">*</span>}
</Label>
{/* 필드 내용 */}
{children}
{/* 에러 메시지 - View 모드에서는 숨김 */}
{error && !isViewMode && (
<p className="text-sm text-destructive">{error}</p>
)}
{/* 설명 텍스트 - View 모드에서는 숨김 */}
{description && !error && !isViewMode && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
export default DetailField;

View File

@@ -0,0 +1,59 @@
/**
* DetailGrid - 상세 페이지 반응형 그리드 컴포넌트
*
* 1~4열 반응형 그리드 레이아웃 제공
* 모바일에서는 자동으로 1열로 변환
*/
'use client';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface DetailGridProps {
/** 그리드 열 수 (default: 2) */
cols?: 1 | 2 | 3 | 4;
/** 그리드 간격 (default: 'md') */
gap?: 'sm' | 'md' | 'lg';
/** 그리드 내용 */
children: ReactNode;
/** 추가 클래스 */
className?: string;
}
// 열 수에 따른 그리드 클래스
const colsClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
// 간격에 따른 gap 클래스
const gapClasses = {
sm: 'gap-4',
md: 'gap-6',
lg: 'gap-8',
};
export function DetailGrid({
cols = 2,
gap = 'md',
children,
className,
}: DetailGridProps) {
return (
<div
className={cn(
'grid',
colsClasses[cols],
gapClasses[gap],
className
)}
>
{children}
</div>
);
}
export default DetailGrid;

View File

@@ -0,0 +1,96 @@
/**
* DetailSection - 상세 페이지 섹션 래퍼 컴포넌트
*
* Card 기반의 섹션 컨테이너로 제목, 설명, 접기/펼치기 기능 제공
*/
'use client';
import { useState, type ReactNode } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface DetailSectionProps {
/** 섹션 제목 */
title: string;
/** 섹션 설명 (선택) */
description?: string;
/** 섹션 내용 */
children: ReactNode;
/** 접기/펼치기 가능 여부 (default: false) */
collapsible?: boolean;
/** 기본 펼침 상태 (default: true) */
defaultOpen?: boolean;
/** 추가 클래스 */
className?: string;
/** 헤더 우측 액션 영역 */
headerActions?: ReactNode;
}
export function DetailSection({
title,
description,
children,
collapsible = false,
defaultOpen = true,
className,
headerActions,
}: DetailSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const handleToggle = () => {
if (collapsible) {
setIsOpen(!isOpen);
}
};
return (
<Card className={cn('', className)}>
<CardHeader
className={cn(
'pb-4',
collapsible && 'cursor-pointer select-none'
)}
onClick={collapsible ? handleToggle : undefined}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="text-base flex items-center gap-2">
{title}
{collapsible && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
>
{isOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
)}
</CardTitle>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{headerActions && (
<div onClick={(e) => e.stopPropagation()}>{headerActions}</div>
)}
</div>
</CardHeader>
{(!collapsible || isOpen) && (
<CardContent className="pt-0">{children}</CardContent>
)}
</Card>
);
}
export default DetailSection;

View File

@@ -0,0 +1,21 @@
/**
* IntegratedDetailTemplate Components Index
*
* 상세 페이지 내부 컴포넌트 통합 export
*/
// 메인 컴포넌트
export { DetailSection, type DetailSectionProps } from './DetailSection';
export { DetailGrid, type DetailGridProps } from './DetailGrid';
export { DetailField, type DetailFieldProps } from './DetailField';
export { DetailActions, type DetailActionsProps } from './DetailActions';
// 스켈레톤 컴포넌트
export {
DetailFieldSkeleton,
DetailGridSkeleton,
DetailSectionSkeleton,
type DetailFieldSkeletonProps,
type DetailGridSkeletonProps,
type DetailSectionSkeletonProps,
} from './skeletons';

View File

@@ -0,0 +1,47 @@
/**
* DetailFieldSkeleton - 필드 로딩 스켈레톤 컴포넌트
*
* 개별 필드의 로딩 상태를 표시
*/
'use client';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
export interface DetailFieldSkeletonProps {
/** 그리드 span (1~4) */
colSpan?: 1 | 2 | 3 | 4;
/** 라벨 너비 (default: 'w-20') */
labelWidth?: string;
/** 입력 높이 (default: 'h-10') */
inputHeight?: string;
/** 추가 클래스 */
className?: string;
}
// colSpan에 따른 그리드 클래스
const colSpanClasses = {
1: '',
2: 'md:col-span-2',
3: 'md:col-span-2 lg:col-span-3',
4: 'md:col-span-2 lg:col-span-4',
};
export function DetailFieldSkeleton({
colSpan = 1,
labelWidth = 'w-20',
inputHeight = 'h-10',
className,
}: DetailFieldSkeletonProps) {
return (
<div className={cn('space-y-2', colSpanClasses[colSpan], className)}>
{/* 라벨 스켈레톤 */}
<Skeleton className={cn('h-4', labelWidth)} />
{/* 입력 필드 스켈레톤 */}
<Skeleton className={cn('w-full', inputHeight)} />
</div>
);
}
export default DetailFieldSkeleton;

View File

@@ -0,0 +1,60 @@
/**
* DetailGridSkeleton - 그리드 로딩 스켈레톤 컴포넌트
*
* 여러 필드의 그리드 로딩 상태를 표시
*/
'use client';
import { cn } from '@/lib/utils';
import { DetailFieldSkeleton } from './DetailFieldSkeleton';
export interface DetailGridSkeletonProps {
/** 그리드 열 수 (default: 2) */
cols?: 1 | 2 | 3 | 4;
/** 필드 개수 (default: 6) */
fieldCount?: number;
/** 그리드 간격 (default: 'md') */
gap?: 'sm' | 'md' | 'lg';
/** 추가 클래스 */
className?: string;
}
// 열 수에 따른 그리드 클래스
const colsClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
// 간격에 따른 gap 클래스
const gapClasses = {
sm: 'gap-4',
md: 'gap-6',
lg: 'gap-8',
};
export function DetailGridSkeleton({
cols = 2,
fieldCount = 6,
gap = 'md',
className,
}: DetailGridSkeletonProps) {
return (
<div
className={cn(
'grid',
colsClasses[cols],
gapClasses[gap],
className
)}
>
{Array.from({ length: fieldCount }).map((_, index) => (
<DetailFieldSkeleton key={index} />
))}
</div>
);
}
export default DetailGridSkeleton;

View File

@@ -0,0 +1,52 @@
/**
* DetailSectionSkeleton - 섹션 로딩 스켈레톤 컴포넌트
*
* Card 기반의 섹션 로딩 상태를 표시
*/
'use client';
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { DetailGridSkeleton } from './DetailGridSkeleton';
export interface DetailSectionSkeletonProps {
/** 제목 영역 표시 여부 (default: true) */
hasTitle?: boolean;
/** 커스텀 내용 (없으면 DetailGridSkeleton 사용) */
children?: ReactNode;
/** 그리드 열 수 (children이 없을 때 사용) */
cols?: 1 | 2 | 3 | 4;
/** 필드 개수 (children이 없을 때 사용) */
fieldCount?: number;
/** 추가 클래스 */
className?: string;
}
export function DetailSectionSkeleton({
hasTitle = true,
children,
cols = 2,
fieldCount = 6,
className,
}: DetailSectionSkeletonProps) {
return (
<Card className={cn('', className)}>
{hasTitle && (
<CardHeader className="pb-4">
{/* 제목 스켈레톤 */}
<Skeleton className="h-5 w-1/4" />
</CardHeader>
)}
<CardContent className={hasTitle ? 'pt-0' : ''}>
{children || (
<DetailGridSkeleton cols={cols} fieldCount={fieldCount} />
)}
</CardContent>
</Card>
);
}
export default DetailSectionSkeleton;

View File

@@ -0,0 +1,9 @@
/**
* Skeleton Components Index
*
* 상세 페이지 로딩 상태용 스켈레톤 컴포넌트
*/
export { DetailFieldSkeleton, type DetailFieldSkeletonProps } from './DetailFieldSkeleton';
export { DetailGridSkeleton, type DetailGridSkeletonProps } from './DetailGridSkeleton';
export { DetailSectionSkeleton, type DetailSectionSkeletonProps } from './DetailSectionSkeleton';

View File

@@ -11,9 +11,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
@@ -26,10 +23,9 @@ import {
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { FieldRenderer } from './FieldRenderer';
import { FieldInput } from './FieldInput';
import { DetailSection, DetailGrid, DetailField, DetailActions, DetailSectionSkeleton } from './components';
import type {
IntegratedDetailTemplateProps,
DetailMode,
@@ -54,6 +50,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
headerActions,
beforeContent,
afterContent,
buttonPosition = 'bottom',
}: IntegratedDetailTemplateProps<T>) {
const router = useRouter();
const params = useParams();
@@ -266,6 +263,67 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
const actions = config.actions || {};
const deleteConfirm = actions.deleteConfirmMessage || {};
// ===== 버튼 위치 =====
const isTopButtons = buttonPosition === 'top';
// ===== 액션 버튼 렌더링 헬퍼 =====
const renderActionButtons = useCallback((additionalClass?: string) => {
if (isViewMode) {
return (
<DetailActions
mode="view"
permissions={permissions}
showButtons={{
back: actions.showBack !== false,
delete: actions.showDelete !== false && !!onDelete,
edit: actions.showEdit !== false,
}}
labels={{
back: actions.backLabel,
delete: actions.deleteLabel,
edit: actions.editLabel,
}}
onBack={navigateToList}
onDelete={handleDelete}
onEdit={handleEdit}
extraActions={headerActions}
className={additionalClass}
/>
);
}
// Form 모드 (edit/create)
return (
<DetailActions
mode={mode}
isSubmitting={isSubmitting}
permissions={permissions}
showButtons={{
back: actions.showBack !== false,
delete: actions.showDelete !== false && !!onDelete,
edit: actions.showEdit !== false,
}}
labels={{
back: actions.backLabel,
cancel: actions.cancelLabel,
delete: actions.deleteLabel,
edit: actions.editLabel,
submit: actions.submitLabel,
}}
onBack={navigateToList}
onCancel={handleCancel}
onDelete={handleDelete}
onEdit={handleEdit}
onSubmit={handleSubmit}
extraActions={headerActions}
className={additionalClass}
/>
);
}, [
isViewMode, mode, isSubmitting, permissions, actions, headerActions,
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
]);
// ===== 필터링된 필드 =====
const visibleFields = useMemo(() => {
return config.fields.filter((field) => {
@@ -275,13 +333,8 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
});
}, [config.fields, isViewMode]);
// ===== 그리드 클래스 =====
// ===== 그리드 컬럼 수 =====
const gridCols = config.gridColumns || 2;
const gridClass = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}[gridCols];
// ===== 로딩 상태 =====
if (isLoading) {
@@ -291,22 +344,10 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
title={config.title}
description={config.description}
icon={config.icon}
actions={isTopButtons ? renderActionButtons() : undefined}
/>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
<Skeleton className="h-8 w-1/3" />
<div className={cn('grid gap-6', gridClass)}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
<DetailSectionSkeleton cols={gridCols} fieldCount={6} />
{!isTopButtons && renderActionButtons('mt-6')}
</PageLayout>
);
}
@@ -319,36 +360,13 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
title={config.title}
description={config.description}
icon={config.icon}
actions={isTopButtons ? renderActionButtons() : undefined}
/>
{beforeContent}
{renderView(initialData)}
{afterContent}
{/* 버튼 영역 */}
<div className="flex items-center justify-between mt-6">
<Button variant="outline" onClick={navigateToList}>
<ArrowLeft className="w-4 h-4 mr-2" />
{actions.backLabel || '목록으로'}
</Button>
<div className="flex items-center gap-2">
{headerActions}
{permissions.canDelete && onDelete && (actions.showDelete !== false) && (
<Button
variant="outline"
onClick={handleDelete}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
{actions.deleteLabel || '삭제'}
</Button>
)}
{permissions.canEdit && (actions.showEdit !== false) && (
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
{actions.editLabel || '수정'}
</Button>
)}
</div>
</div>
{/* 버튼 영역 - 하단 배치 시만 */}
{!isTopButtons && renderActionButtons('mt-6')}
<DeleteDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
@@ -368,6 +386,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
title={isCreateMode ? `${config.title} 등록` : `${config.title} 수정`}
description={config.description}
icon={config.icon}
actions={isTopButtons ? renderActionButtons() : undefined}
/>
{beforeContent}
{renderForm({
@@ -377,17 +396,8 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
errors,
})}
{afterContent}
{/* 버튼 영역 */}
<div className="flex items-center justify-between mt-6">
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
{actions.cancelLabel || '취소'}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? (actions.submitLabel || '등록') : (actions.submitLabel || '저장')}
</Button>
</div>
{/* 버튼 영역 - 하단 배치 시만 */}
{!isTopButtons && renderActionButtons('mt-6')}
</PageLayout>
);
}
@@ -405,6 +415,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
}
description={config.description}
icon={config.icon}
actions={isTopButtons ? renderActionButtons() : undefined}
/>
{beforeContent}
@@ -413,80 +424,34 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
{/* 섹션이 있으면 섹션별로, 없으면 단일 카드 */}
{config.sections && config.sections.length > 0 ? (
config.sections.map((section) => (
<Card key={section.id}>
<CardHeader>
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
</CardHeader>
<CardContent>
<div className={cn('grid gap-6', gridClass)}>
{section.fields.map((fieldKey) => {
const field = visibleFields.find((f) => f.key === fieldKey);
if (!field) return null;
return renderFieldItem(field);
})}
</div>
</CardContent>
</Card>
<DetailSection
key={section.id}
title={section.title}
description={section.description}
collapsible={section.collapsible}
defaultOpen={!section.defaultCollapsed}
>
<DetailGrid cols={gridCols}>
{section.fields.map((fieldKey) => {
const field = visibleFields.find((f) => f.key === fieldKey);
if (!field) return null;
return renderFieldItem(field);
})}
</DetailGrid>
</DetailSection>
))
) : (
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className={cn('grid gap-6', gridClass)}>
{visibleFields.map((field) => renderFieldItem(field))}
</div>
</CardContent>
</Card>
<DetailSection title="기본 정보">
<DetailGrid cols={gridCols}>
{visibleFields.map((field) => renderFieldItem(field))}
</DetailGrid>
</DetailSection>
)}
{afterContent}
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
{isViewMode ? (
<>
<Button variant="outline" onClick={navigateToList}>
<ArrowLeft className="w-4 h-4 mr-2" />
{actions.backLabel || '목록으로'}
</Button>
<div className="flex items-center gap-2">
{headerActions}
{permissions.canDelete && onDelete && (actions.showDelete !== false) && (
<Button
variant="outline"
onClick={handleDelete}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
{actions.deleteLabel || '삭제'}
</Button>
)}
{permissions.canEdit && (actions.showEdit !== false) && (
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
{actions.editLabel || '수정'}
</Button>
)}
</div>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
{actions.cancelLabel || '취소'}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<Save className="w-4 h-4 mr-2" />
{isCreateMode ? (actions.submitLabel || '등록') : (actions.submitLabel || '저장')}
</Button>
</>
)}
</div>
{/* 버튼 영역 - 하단 배치 시만 */}
{!isTopButtons && renderActionButtons()}
</div>
{/* 삭제 확인 다이얼로그 */}
@@ -502,17 +467,14 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
// ===== 필드 아이템 렌더링 헬퍼 =====
function renderFieldItem(field: FieldDefinition) {
const spanClass = {
1: '',
2: 'md:col-span-2',
3: 'md:col-span-2 lg:col-span-3',
}[field.gridSpan || 1];
// gridSpan을 colSpan으로 매핑 (1, 2, 3, 4만 허용)
const colSpan = (field.gridSpan || 1) as 1 | 2 | 3 | 4;
// 커스텀 필드 렌더러 체크
if (renderField) {
const customRender = renderField(field, {
value: formData[field.key],
onChange: (value) => handleChange(field.key, value),
onChange: (value: unknown) => handleChange(field.key, value),
mode,
disabled:
field.readonly ||
@@ -521,16 +483,34 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
});
if (customRender !== null) {
return (
<div key={field.key} className={spanClass}>
<DetailField
key={field.key}
label={field.label}
required={field.required}
error={errors[field.key]}
description={field.helpText}
colSpan={colSpan}
htmlFor={field.key}
mode={mode}
>
{customRender}
</div>
</DetailField>
);
}
}
return (
<div key={field.key} className={spanClass}>
<FieldRenderer
<DetailField
key={field.key}
label={field.label}
required={field.required}
error={errors[field.key]}
description={field.helpText}
colSpan={colSpan}
htmlFor={field.key}
mode={mode}
>
<FieldInput
field={field}
value={formData[field.key]}
onChange={(value) => handleChange(field.key, value)}
@@ -538,7 +518,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
error={errors[field.key]}
dynamicOptions={dynamicOptions[field.key]}
/>
</div>
</DetailField>
);
}
}
@@ -611,3 +591,24 @@ function validateRule(
// Re-export types
export * from './types';
// Re-export FieldInput
export { FieldInput, type FieldInputProps } from './FieldInput';
// Re-export internal components
export {
DetailSection,
DetailGrid,
DetailField,
DetailActions,
DetailFieldSkeleton,
DetailGridSkeleton,
DetailSectionSkeleton,
type DetailSectionProps,
type DetailGridProps,
type DetailFieldProps,
type DetailActionsProps,
type DetailFieldSkeletonProps,
type DetailGridSkeletonProps,
type DetailSectionSkeletonProps,
} from './components';

View File

@@ -64,8 +64,8 @@ export interface FieldDefinition {
placeholder?: string;
/** 유효성 검사 규칙 */
validation?: ValidationRule[];
/** 그리드 span (1, 2, 3) - 기본값 1 */
gridSpan?: 1 | 2 | 3;
/** 그리드 span (1, 2, 3, 4) - 기본값 1, DetailField의 colSpan으로 매핑됨 */
gridSpan?: 1 | 2 | 3 | 4;
/** view 모드에서 숨김 */
hideInView?: boolean;
/** form 모드에서 숨김 */
@@ -208,6 +208,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
beforeContent?: ReactNode;
/** 폼 뒤에 추가 콘텐츠 */
afterContent?: ReactNode;
/** 버튼 위치 (기본값: 'bottom') */
buttonPosition?: 'top' | 'bottom';
}
// ===== API 응답 타입 =====

View File

@@ -0,0 +1,174 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { SearchX, Home, ArrowLeft, Map, AlertCircle, ServerCrash } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
type ErrorType = 'not-found' | 'network' | 'error';
interface ErrorCardProps {
type?: ErrorType;
title?: string;
description?: string;
tips?: string[];
showBackButton?: boolean;
showHomeButton?: boolean;
backButtonLabel?: string;
homeButtonLabel?: string;
homeButtonHref?: string;
onBack?: () => void;
}
const ERROR_CONFIG: Record<ErrorType, {
icon: typeof SearchX;
iconColor: string;
bgColor: string;
emoji: string;
defaultTitle: string;
defaultDescription: string;
defaultTips: string[];
}> = {
'not-found': {
icon: SearchX,
iconColor: 'text-yellow-600 dark:text-yellow-500',
bgColor: 'from-yellow-500/20 to-orange-500/20',
emoji: '?',
defaultTitle: '페이지를 찾을 수 없습니다',
defaultDescription: '요청하신 페이지가 존재하지 않거나 접근 권한이 없습니다.',
defaultTips: [
'메뉴에서 올바른 페이지를 선택했는지 확인',
'해당 페이지에 접근 권한이 있는지 확인',
'페이지가 아직 개발 중일 수 있습니다',
],
},
'network': {
icon: ServerCrash,
iconColor: 'text-orange-600 dark:text-orange-500',
bgColor: 'from-orange-500/20 to-red-500/20',
emoji: '!',
defaultTitle: '데이터를 불러올 수 없습니다',
defaultDescription: '서버와의 연결에 문제가 발생했습니다.',
defaultTips: [
'인터넷 연결 상태를 확인해주세요',
'잠시 후 다시 시도해주세요',
'문제가 지속되면 관리자에게 문의해주세요',
],
},
'error': {
icon: AlertCircle,
iconColor: 'text-red-600 dark:text-red-500',
bgColor: 'from-red-500/20 to-pink-500/20',
emoji: '!',
defaultTitle: '오류가 발생했습니다',
defaultDescription: '요청을 처리하는 중 문제가 발생했습니다.',
defaultTips: [
'페이지를 새로고침 해보세요',
'잠시 후 다시 시도해주세요',
'문제가 지속되면 관리자에게 문의해주세요',
],
},
};
export function ErrorCard({
type = 'not-found',
title,
description,
tips,
showBackButton = true,
showHomeButton = true,
backButtonLabel = '이전 페이지',
homeButtonLabel = '목록으로 이동',
homeButtonHref,
onBack,
}: ErrorCardProps) {
const router = useRouter();
const config = ERROR_CONFIG[type];
const Icon = config.icon;
const handleBack = () => {
if (onBack) {
onBack();
} else {
router.back();
}
};
return (
<div className="flex items-center justify-center min-h-[calc(100vh-200px)] p-4">
<Card className="w-full max-w-2xl border border-border/20 bg-card/50 backdrop-blur">
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-6">
<div className="relative">
<div className={`w-24 h-24 bg-gradient-to-br ${config.bgColor} rounded-2xl flex items-center justify-center`}>
<Icon className={`w-12 h-12 ${config.iconColor}`} strokeWidth={1.5} />
</div>
<div className="absolute -top-1 -right-1 w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center shadow-lg">
<span className="text-xl font-bold text-white">{config.emoji}</span>
</div>
</div>
</div>
<CardTitle className="text-2xl md:text-3xl font-bold text-foreground mb-2">
{title || config.defaultTitle}
</CardTitle>
<p className="text-muted-foreground">
{description || config.defaultDescription}
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* 안내 메시지 */}
<div className="bg-muted/50 rounded-xl p-6 space-y-3">
<div className="flex items-start gap-3">
<Map className="w-5 h-5 text-primary mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-sm text-foreground font-medium">
:
</p>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
{(tips || config.defaultTips).map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex flex-col sm:flex-row gap-3 pt-4">
{showBackButton && (
<Button
variant="outline"
className="flex-1 rounded-xl"
onClick={handleBack}
>
<ArrowLeft className="w-4 h-4 mr-2" />
{backButtonLabel}
</Button>
)}
{showHomeButton && homeButtonHref && (
<Button
asChild
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
>
<Link href={homeButtonHref}>
<Home className="w-4 h-4 mr-2" />
{homeButtonLabel}
</Link>
</Button>
)}
</div>
{/* 도움말 */}
<div className="pt-6 border-t border-border/20 text-center">
<p className="text-xs text-muted-foreground">
.
</p>
</div>
</CardContent>
</Card>
</div>
);
}