Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-11 15:10:15 +09:00
96 changed files with 4930 additions and 6550 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -34,6 +34,9 @@ import {
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
import { useDetailData, useCRUDHandlers } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
@@ -46,157 +49,199 @@ interface ClientOption {
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetail({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 로딩 상태 =====
const [isLoading, setIsLoading] = useState(false);
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 =====
const [billNumber, setBillNumber] = useState('');
const [billType, setBillType] = useState<BillType>('received');
const [vendorId, setVendorId] = useState('');
const [amount, setAmount] = useState(0);
const [issueDate, setIssueDate] = useState('');
const [maturityDate, setMaturityDate] = useState('');
const [status, setStatus] = useState<BillStatus>('stored');
const [note, setNote] = useState('');
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) =====
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// ===== 거래처 목록 로드 =====
useEffect(() => {
async function loadInitialData() {
const isEditMode = billId && billId !== 'new';
setIsLoading(!!isEditMode);
const [clientsResult, billResult] = await Promise.all([
getClients(),
isEditMode ? getBill(billId) : Promise.resolve(null),
]);
// 거래처 목록
if (clientsResult.success && clientsResult.data) {
setClients(clientsResult.data.map(c => ({ id: String(c.id), name: c.name })));
async function loadClients() {
const result = await getClients();
if (result.success && result.data) {
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
}
}
loadClients();
}, []);
// 어음 상세
if (billResult) {
if (billResult.success && billResult.data) {
const data = billResult.data;
setBillNumber(data.billNumber);
setBillType(data.billType);
setVendorId(data.vendorId);
setAmount(data.amount);
setIssueDate(data.issueDate);
setMaturityDate(data.maturityDate);
setStatus(data.status);
setNote(data.note);
setInstallments(data.installments);
} else {
toast.error(billResult.error || '어음 정보를 불러올 수 없습니다.');
router.push('/ko/accounting/bills');
}
}
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
[]
);
setIsLoading(false);
}
const {
data: billData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
loadInitialData();
}, [billId, router]);
// ===== 데이터 로드 시 폼에 반영 =====
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
}
}, [billData]);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
if (!billNumber.trim()) {
toast.error('어음번호를 입력해주세요.');
return { success: false, error: '어음번호를 입력해주세요.' };
// ===== 로드 에러 처리 =====
useEffect(() => {
if (loadError) {
toast.error(loadError);
router.push('/ko/accounting/bills');
}
if (!vendorId) {
toast.error('거래처를 선택해주세요.');
return { success: false, error: '거래처를 선택해주세요.' };
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (amount <= 0) {
toast.error('금액을 입력해주세요.');
return { success: false, error: '금액을 입력해주세요.' };
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (!issueDate) {
toast.error('발행일을 입력해주세요.');
return { success: false, error: '발행일을 입력해주세요.' };
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!maturityDate) {
toast.error('만기일을 입력해주세요.');
return { success: false, error: '만기일을 입력해주세요.' };
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < installments.length; i++) {
const inst = installments[i];
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
toast.error(errorMsg);
return { success: false, error: errorMsg };
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
toast.error(errorMsg);
return { success: false, error: errorMsg };
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
return { valid: true };
}, [formData]);
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
const updateBillWrapper = useCallback(
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
[]
);
const deleteBillWrapper = useCallback(
(id: string | number) => deleteBill(String(id)),
[]
);
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
const {
handleCreate,
handleUpdate,
handleDelete: crudDelete,
isSubmitting,
isDeleting,
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
onCreate: createBill,
onUpdate: updateBillWrapper,
onDelete: deleteBillWrapper,
successRedirect: '/ko/accounting/bills',
successMessages: {
create: '어음이 등록되었습니다.',
update: '어음이 수정되었습니다.',
delete: '어음이 삭제되었습니다.',
},
// 수정 성공 시 view 모드로 이동
disableRedirect: !isNewMode,
onSuccess: (action) => {
if (action === 'update') {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
},
});
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
const validation = validateForm();
if (!validation.valid) {
toast.error(validation.error!);
return { success: false, error: validation.error };
}
const billData: Partial<BillRecord> = {
billNumber,
billType,
vendorId,
vendorName: clients.find(c => c.id === vendorId)?.name || '',
amount,
issueDate,
maturityDate,
status,
note,
installments,
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
let result;
if (isNewMode) {
result = await createBill(billData);
return handleCreate(billData);
} else {
result = await updateBill(billId, billData);
return handleUpdate(billId, billData);
}
if (result.success) {
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
if (isNewMode) {
router.push('/ko/accounting/bills');
} else {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
return { success: true };
} else {
toast.error(result.error || '저장에 실패했습니다.');
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const result = await deleteBill(billId);
return crudDelete(billId);
}, [billId, crudDelete]);
if (result.success) {
toast.success('어음이 삭제되었습니다.');
router.push('/ko/accounting/bills');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
}, [billId, router]);
// ===== 차수 추가 =====
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
@@ -204,23 +249,37 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
amount: 0,
note: '',
};
setInstallments(prev => [...prev, newInstallment]);
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
// ===== 차수 삭제 =====
const handleRemoveInstallment = useCallback((id: string) => {
setInstallments(prev => prev.filter(inst => inst.id !== id));
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
// ===== 차수 업데이트 =====
const handleUpdateInstallment = useCallback((id: string, field: keyof InstallmentRecord, value: string | number) => {
setInstallments(prev => prev.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
));
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = getBillStatusOptions(billType);
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = () => (
@@ -239,8 +298,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<Input
id="billNumber"
value={billNumber}
onChange={(e) => setBillNumber(e.target.value)}
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
@@ -251,7 +310,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select value={billType} onValueChange={(v) => setBillType(v as BillType)} disabled={isViewMode}>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -270,7 +333,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -291,8 +358,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Label>
<CurrencyInput
id="amount"
value={amount}
onChange={(value) => setAmount(value ?? 0)}
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
@@ -304,8 +371,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={issueDate}
onChange={setIssueDate}
value={formData.issueDate}
onChange={(date) => updateField('issueDate', date)}
disabled={isViewMode}
/>
</div>
@@ -316,8 +383,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={maturityDate}
onChange={setMaturityDate}
value={formData.maturityDate}
onChange={(date) => updateField('maturityDate', date)}
disabled={isViewMode}
/>
</div>
@@ -327,7 +394,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select value={status} onValueChange={(v) => setStatus(v as BillStatus)} disabled={isViewMode}>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
@@ -346,8 +417,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Label htmlFor="note"></Label>
<Input
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
@@ -386,14 +457,14 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</TableRow>
</TableHeader>
<TableBody>
{installments.length === 0 ? (
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
installments.map((inst, index) => (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
@@ -442,8 +513,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
);
// ===== 템플릿 모드 및 동적 설정 =====
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
// view 모드에서 "어음 상세"로 표시하려면 직접 설정 필요
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
@@ -460,7 +529,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
mode={templateMode}
initialData={{}}
itemId={billId}
isLoading={isLoading}
isLoading={isLoading || isSubmitting || isDeleting}
onSubmit={handleSubmit}
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
renderView={() => renderFormContent()}

View File

@@ -1,539 +0,0 @@
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { billConfig } from './billConfig';
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
import {
BILL_TYPE_OPTIONS,
getBillStatusOptions,
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
import { useDetailData, useCRUDHandlers } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
mode: 'view' | 'edit' | 'new';
}
// ===== 거래처 타입 =====
interface ClientOption {
id: string;
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetailV2({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// ===== 거래처 목록 로드 =====
useEffect(() => {
async function loadClients() {
const result = await getClients();
if (result.success && result.data) {
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
}
}
loadClients();
}, []);
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
[]
);
const {
data: billData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
// ===== 데이터 로드 시 폼에 반영 =====
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
}
}, [billData]);
// ===== 로드 에러 처리 =====
useEffect(() => {
if (loadError) {
toast.error(loadError);
router.push('/ko/accounting/bills');
}
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
return { valid: true };
}, [formData]);
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
const updateBillWrapper = useCallback(
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
[]
);
const deleteBillWrapper = useCallback(
(id: string | number) => deleteBill(String(id)),
[]
);
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
const {
handleCreate,
handleUpdate,
handleDelete: crudDelete,
isSubmitting,
isDeleting,
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
onCreate: createBill,
onUpdate: updateBillWrapper,
onDelete: deleteBillWrapper,
successRedirect: '/ko/accounting/bills',
successMessages: {
create: '어음이 등록되었습니다.',
update: '어음이 수정되었습니다.',
delete: '어음이 삭제되었습니다.',
},
// 수정 성공 시 view 모드로 이동
disableRedirect: !isNewMode,
onSuccess: (action) => {
if (action === 'update') {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
},
});
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
const validation = validateForm();
if (!validation.valid) {
toast.error(validation.error!);
return { success: false, error: validation.error };
}
const billData: Partial<BillRecord> = {
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
if (isNewMode) {
return handleCreate(billData);
} else {
return handleUpdate(billId, billData);
}
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
return crudDelete(billId);
}, [billId, crudDelete]);
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
date: '',
amount: 0,
note: '',
};
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
const handleRemoveInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label htmlFor="billNumber">
<span className="text-red-500">*</span>
</Label>
<Input
id="billNumber"
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{BILL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 금액 */}
<div className="space-y-2">
<Label htmlFor="amount">
<span className="text-red-500">*</span>
</Label>
<CurrencyInput
id="amount"
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.issueDate}
onChange={(date) => updateField('issueDate', date)}
disabled={isViewMode}
/>
</div>
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.maturityDate}
onChange={(date) => updateField('maturityDate', date)}
disabled={isViewMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 차수 관리 섹션 */}
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-red-500">*</span>
</CardTitle>
{!isViewMode && (
<Button
variant="outline"
size="sm"
onClick={handleAddInstallment}
className="text-orange-500 border-orange-300 hover:bg-orange-50"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<DatePicker
value={inst.date}
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={inst.amount}
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
value={inst.note}
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
{!isViewMode && (
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveInstallment(inst.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</>
);
// ===== 템플릿 모드 및 동적 설정 =====
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
title: isViewMode ? '어음 상세' : '어음',
actions: {
...billConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={billId}
isLoading={isLoading || isSubmitting || isDeleting}
onSubmit={handleSubmit}
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -34,7 +34,7 @@ import { CommentSection } from '../CommentSection';
import { deletePost } from '../actions';
import type { Post, Comment } from '../types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { sanitizeHTML } from '@/lib/sanitize';
interface BoardDetailProps {

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { CEODashboardSkeleton } from './skeletons';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DashboardSwitcher } from '@/components/business/DashboardSwitcher';
import {
TodayIssueSection,
EnhancedStatusBoardSection,
@@ -281,15 +282,18 @@ export function CEODashboard() {
description="전체 현황을 조회합니다."
icon={LayoutDashboard}
actions={
<Button
variant="outline"
size="sm"
onClick={handleSettingClick}
className="gap-2"
>
<Settings className="h-4 w-4" />
</Button>
<>
<Button
variant="outline"
size="sm"
onClick={handleSettingClick}
className="gap-2"
>
<Settings className="h-4 w-4" />
</Button>
<DashboardSwitcher />
</>
}
/>

View File

@@ -0,0 +1,73 @@
'use client';
import { useRouter, usePathname, useParams } from 'next/navigation';
import { ChevronDown, LayoutDashboard, LayoutGrid, Target, Activity, Columns3 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
const dashboards = [
{ path: '/dashboard', label: '보고서형', icon: LayoutDashboard },
{ path: '/dashboard_type2', label: '탭형', icon: Columns3 },
{ path: '/dashboard_type3', label: '위젯형', icon: LayoutGrid },
{ path: '/dashboard_type4', label: '드릴다운형', icon: Target },
{ path: '/dashboard_type5', label: '피드형', icon: Activity },
] as const;
export function DashboardSwitcher() {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const locale = (params.locale as string) || 'ko';
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 현재 활성 대시보드 찾기
const current = dashboards.find((d) => pathname.endsWith(d.path)) ?? dashboards[0];
// 외부 클릭 시 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
if (open) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-background text-sm font-medium hover:bg-muted transition-colors"
>
<current.icon className="w-4 h-4 text-primary" />
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg border bg-background shadow-lg z-50 py-1">
{dashboards.map((d) => {
const Icon = d.icon;
const isActive = d.path === current.path;
return (
<button
key={d.path}
onClick={() => {
router.push(`/${locale}${d.path}`);
setOpen(false);
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground hover:bg-muted'
}`}
>
<Icon className={`w-4 h-4 ${isActive ? 'text-primary' : 'text-muted-foreground'}`} />
{d.label}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -34,7 +34,7 @@ import {
import { updateContract, deleteContract, createContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
// import { ContractDocumentModal } from './modals/ContractDocumentModal';
import { ContractDocumentModalV2 as ContractDocumentModal } from './modals/ContractDocumentModalV2';
import { ContractDocumentModal } from './modals/ContractDocumentModal';
import {
ElectronicApprovalModal,
type ElectronicApproval,

View File

@@ -1,20 +1,7 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Edit,
X as XIcon,
Printer,
Send,
} from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ContractDetail } from '../types';
interface ContractDocumentModalProps {
@@ -23,6 +10,13 @@ interface ContractDocumentModalProps {
contract: ContractDetail;
}
/**
* 계약서 문서 모달 V2
*
* DocumentViewer를 사용하여 통합 UI 제공
* - 줌/드래그 기능 추가
* - PDF iframe 지원
*/
export function ContractDocumentModal({
open,
onOpenChange,
@@ -38,67 +32,32 @@ export function ContractDocumentModal({
toast.info('전자결재 상신 기능은 준비 중입니다.');
};
// 인쇄
const handlePrint = () => {
printArea({ title: '계약서 인쇄' });
};
// PDF URL 확인
const pdfUrl = contract.contractFile?.fileUrl;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
)}
<DocumentViewer
title="계약서"
subtitle={`${contract.partnerName} - ${contract.projectName}`}
preset="construction"
open={open}
onOpenChange={onOpenChange}
onEdit={handleEdit}
onSubmit={handleSubmit}
>
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
</DocumentViewer>
);
}
}

View File

@@ -1,63 +0,0 @@
'use client';
import { DocumentViewer } from '@/components/document-system';
import { toast } from 'sonner';
import type { ContractDetail } from '../types';
interface ContractDocumentModalV2Props {
open: boolean;
onOpenChange: (open: boolean) => void;
contract: ContractDetail;
}
/**
* 계약서 문서 모달 V2
*
* DocumentViewer를 사용하여 통합 UI 제공
* - 줌/드래그 기능 추가
* - PDF iframe 지원
*/
export function ContractDocumentModalV2({
open,
onOpenChange,
contract,
}: ContractDocumentModalV2Props) {
// 수정
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
// 상신 (전자결재)
const handleSubmit = () => {
toast.info('전자결재 상신 기능은 준비 중입니다.');
};
// PDF URL 확인
const pdfUrl = contract.contractFile?.fileUrl;
return (
<DocumentViewer
title="계약서"
subtitle={`${contract.partnerName} - ${contract.projectName}`}
preset="construction"
open={open}
onOpenChange={onOpenChange}
onEdit={handleEdit}
onSubmit={handleSubmit}
>
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
)}
</div>
</DocumentViewer>
);
}

View File

@@ -1,2 +1 @@
export { ContractDocumentModal } from './ContractDocumentModal';
export { ContractDocumentModalV2 } from './ContractDocumentModalV2';

View File

@@ -1,91 +1,46 @@
/**
* LaborDetailClient - IntegratedDetailTemplate 기반 노임 상세/등록/수정
*
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { Labor, LaborFormData, LaborCategory, LaborStatus } from './types';
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
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 LaborDetailClientProps {
laborId?: string;
isEditMode?: boolean;
isNewMode?: boolean;
initialMode?: DetailMode;
}
const initialFormData: LaborFormData = {
laborNumber: '',
category: '가로',
minM: 0,
maxM: 0,
laborPrice: null,
status: '사용',
};
export default function LaborDetailClient({
laborId,
isEditMode = false,
isNewMode = false,
initialMode = 'view',
}: LaborDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate, canDelete } = usePermission();
// 모드 상태
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
);
// 폼 데이터
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
const [originalData, setOriginalData] = useState<Labor | null>(null);
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
const [minMInput, setMinMInput] = useState<string>('');
const [maxMInput, setMaxMInput] = useState<string>('');
// 상태
const [labor, setLabor] = useState<Labor | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [mode, setMode] = useState<DetailMode>(initialMode);
// 노임 데이터 로드
// 데이터 로드
useEffect(() => {
if (laborId && !isNewMode) {
const loadLabor = async () => {
if (laborId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getLabor(laborId);
if (result.success && result.data) {
setOriginalData(result.data);
setFormData({
laborNumber: result.data.laborNumber,
category: result.data.category,
minM: result.data.minM,
maxM: result.data.maxM,
laborPrice: result.data.laborPrice,
status: result.data.status,
});
// 소수점 입력용 문자열 상태 초기화
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
setLabor(result.data);
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/labor');
@@ -97,376 +52,69 @@ export default function LaborDetailClient({
setIsLoading(false);
}
};
loadLabor();
loadData();
}
}, [laborId, isNewMode, router]);
}, [laborId, initialMode, router]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof LaborFormData, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
const handleMinMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
setMinMInput('');
handleFieldChange('minM', 0);
return;
}
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMinMInput(value);
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange('minM', numValue);
}
}
},
[handleFieldChange]
);
const handleMaxMChange = useCallback(
(value: string) => {
// 빈 값 허용
if (value === '') {
setMaxMInput('');
handleFieldChange('maxM', 0);
return;
}
// 소수점 둘째자리까지 허용하는 정규식
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
setMaxMInput(value);
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange('maxM', numValue);
}
}
},
[handleFieldChange]
);
// 노임단가 입력 핸들러 (정수만)
const handleLaborPriceChange = useCallback(
(value: string) => {
if (value === '') {
handleFieldChange('laborPrice', null);
return;
}
// 정수만 허용
const regex = /^\d*$/;
if (regex.test(value)) {
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
handleFieldChange('laborPrice', numValue);
}
}
},
[handleFieldChange]
);
// 저장
const handleSave = useCallback(async () => {
// 유효성 검사
if (!formData.laborNumber.trim()) {
toast.error('노임번호를 입력해주세요.');
return;
}
setIsSaving(true);
try {
if (mode === 'new') {
const result = await createLabor(formData);
if (result.success && result.data) {
toast.success('노임이 등록되었습니다.');
router.push(`/ko/construction/order/base-info/labor/${result.data.id}?mode=view`);
} else {
toast.error(result.error || '노임 등록에 실패했습니다.');
}
} else if (mode === 'edit' && laborId) {
const result = await updateLabor(laborId, formData);
if (result.success) {
toast.success('노임이 수정되었습니다.');
setMode('view');
// 데이터 다시 로드
const reloadResult = await getLabor(laborId);
if (reloadResult.success && reloadResult.data) {
setOriginalData(reloadResult.data);
if (mode === 'create') {
const result = await createLabor(submitData);
if (result.success && result.data) {
return { success: true };
}
} else {
toast.error(result.error || '노임 수정에 실패했습니다.');
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: '저장 중 오류가 발생했습니다.' };
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [mode, formData, laborId, router]);
},
[mode, laborId]
);
// 삭제
const handleDelete = useCallback(async () => {
if (!laborId) return;
setIsLoading(true);
// 삭제 핸들러
const handleDelete = useCallback(async (id: string | number) => {
try {
const result = await deleteLabor(laborId);
const result = await deleteLabor(String(id));
if (result.success) {
toast.success('노임이 삭제되었습니다.');
router.push('/ko/construction/order/base-info/labor');
} else {
toast.error(result.error || '노임 삭제에 실패했습니다.');
return { success: true };
}
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [laborId, router]);
}, []);
// 수정 모드 전환
const handleEditMode = useCallback(() => {
setMode('edit');
router.replace(`/ko/construction/order/base-info/labor/${laborId}?mode=edit`);
}, [laborId, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/construction/order/base-info/labor');
}, [router]);
// 취소
const handleCancel = useCallback(() => {
if (mode === 'new') {
router.push('/ko/construction/order/base-info/labor');
} else {
setMode('view');
// 원본 데이터로 복원
if (originalData) {
setFormData({
laborNumber: originalData.laborNumber,
category: originalData.category,
minM: originalData.minM,
maxM: originalData.maxM,
laborPrice: originalData.laborPrice,
status: originalData.status,
});
// 소수점 입력용 문자열 상태도 복원
setMinMInput(originalData.minM === 0 ? '' : originalData.minM.toString());
setMaxMInput(originalData.maxM === 0 ? '' : originalData.maxM.toString());
}
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
}
}, [mode, laborId, originalData, router]);
// 읽기 전용 여부
const isReadOnly = mode === 'view';
// 페이지 타이틀
const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세';
if (isLoading && !isNewMode) {
return (
<PageLayout>
<PageHeader
title={pageTitle}
description="노임 정보를 등록하고 관리합니다."
icon={Hammer}
/>
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground"> ...</div>
</div>
</PageLayout>
);
}
// 모드 변경 핸들러
const handleModeChange = useCallback((newMode: DetailMode) => {
setMode(newMode);
}, []);
return (
<>
<PageLayout>
<PageHeader
title={pageTitle}
description="노임 정보를 등록하고 관리합니다."
icon={Hammer}
/>
<div className="space-y-6 pb-24">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base">
<span className="text-destructive">*</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Row 1: 노임번호, 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="laborNumber"></Label>
<Input
id="laborNumber"
value={formData.laborNumber}
onChange={(e) => handleFieldChange('laborNumber', e.target.value)}
placeholder="노임번호를 입력하세요"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Select
value={formData.category}
onValueChange={(v) => handleFieldChange('category', v as LaborCategory)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="구분 선택" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 최소 M, 최대 M */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="minM"> M</Label>
<Input
id="minM"
type="text"
inputMode="decimal"
value={minMInput}
onChange={(e) => handleMinMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxM"> M</Label>
<Input
id="maxM"
type="text"
inputMode="decimal"
value={maxMInput}
onChange={(e) => handleMaxMChange(e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
</div>
</div>
{/* Row 3: 노임단가, 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="laborPrice"></Label>
<Input
id="laborPrice"
type="text"
inputMode="numeric"
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
onChange={(e) => handleLaborPriceChange(e.target.value)}
placeholder="0"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(v) => handleFieldChange('status', v as LaborStatus)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{mode === 'view' && (
<>
{canDelete && (
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button onClick={handleEditMode} size="sm" className="md:size-default">
<Edit className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
</>
)}
{mode === 'edit' && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '저장 중...' : '저장'}</span>
</Button>
</>
)}
{mode === 'new' && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Plus className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '등록 중...' : '등록'}</span>
</Button>
</>
)}
</div>
</div>
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
onConfirm={handleDelete}
/>
</>
<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}
/>
);
}
}
// Named export for backwards compatibility
export { LaborDetailClient };

View File

@@ -1,120 +0,0 @@
/**
* LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정
*
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 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}
/>
);
}
// Named export for backwards compatibility
export { LaborDetailClientV2 };

View File

@@ -1,6 +1,5 @@
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';

View File

@@ -1,464 +1,134 @@
/**
* PricingDetailClient - IntegratedDetailTemplate 기반 단가 상세/등록/수정
*
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Pricing, PricingStatus } from './types';
import { PRICING_STATUS_LABELS } from './types';
import {
getPricingDetail,
createPricing,
updatePricing,
deletePricing,
getVendorList,
} from './actions';
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 PricingDetailClientProps {
id?: string;
mode: 'view' | 'create' | 'edit';
pricingId?: string;
initialMode?: DetailMode;
}
interface FormData {
itemType: string;
category: string;
itemName: string;
spec: string;
unit: string;
division: string;
vendor: string;
purchasePrice: number;
marginRate: number;
sellingPrice: number;
status: PricingStatus;
note: string;
}
const initialFormData: FormData = {
itemType: '',
category: '',
itemName: '',
spec: '',
unit: '',
division: '',
vendor: '',
purchasePrice: 0,
marginRate: 0,
sellingPrice: 0,
status: 'in_use',
note: '',
};
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
export default function PricingDetailClient({
pricingId,
initialMode = 'view',
}: PricingDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate, canDelete } = usePermission();
const [pricing, setPricing] = useState<Pricing | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const isEditMode = mode === 'edit';
const [mode, setMode] = useState<DetailMode>(initialMode);
// 데이터 로드
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
try {
// 거래처 목록 로드
const vendorResult = await getVendorList();
if (vendorResult.success && vendorResult.data) {
setVendors(vendorResult.data);
}
// 상세 데이터 로드 (수정/보기 모드)
if (id && (isViewMode || isEditMode)) {
const result = await getPricingDetail(id);
if (pricingId && initialMode !== 'create') {
const loadData = async () => {
setIsLoading(true);
try {
const result = await getPricingDetail(pricingId);
if (result.success && result.data) {
setPricing(result.data);
setFormData({
itemType: result.data.itemType,
category: result.data.category,
itemName: result.data.itemName,
spec: result.data.spec,
unit: result.data.unit,
division: result.data.division,
vendor: result.data.vendor,
purchasePrice: result.data.purchasePrice,
marginRate: result.data.marginRate,
sellingPrice: result.data.sellingPrice,
status: result.data.status,
note: '',
});
} else {
toast.error(result.error || '데이터를 불러올 수 없습니다.');
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
}
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [id, mode, isViewMode, isEditMode, router]);
// 판매단가 변경
const handleSellingPriceChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
sellingPrice: numValue,
}));
}, []);
// 거래처 변경
const handleVendorChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, vendor: value }));
}, []);
// 상태 변경
const handleStatusChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, status: value as PricingStatus }));
}, []);
// 비고 변경
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, note: e.target.value }));
}, []);
// 저장
const handleSave = useCallback(async () => {
setIsLoading(true);
try {
if (isCreateMode) {
const result = await createPricing({
itemType: formData.itemType,
category: formData.category,
itemName: formData.itemName,
spec: formData.spec,
orderItems: [],
unit: formData.unit,
division: formData.division,
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
if (result.success) {
toast.success('단가가 등록되었습니다.');
} catch {
toast.error('단가 정보를 불러오는데 실패했습니다.');
router.push('/ko/construction/order/base-info/pricing');
} else {
toast.error(result.error || '등록에 실패했습니다.');
} finally {
setIsLoading(false);
}
} else if (isEditMode && id) {
const result = await updatePricing(id, {
vendor: formData.vendor,
sellingPrice: formData.sellingPrice,
status: formData.status,
});
if (result.success) {
toast.success('단가가 수정되었습니다.');
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=view`);
} else {
toast.error(result.error || '수정에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
};
loadData();
}
}, [isCreateMode, isEditMode, id, formData, router]);
}, [pricingId, initialMode, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!id) return;
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
setIsLoading(true);
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(id);
const result = await deletePricing(String(id));
if (result.success) {
toast.success('단가가 삭제되었습니다.');
router.push('/ko/construction/order/base-info/pricing');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: true };
}
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [id, router]);
}, []);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
if (id) {
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=edit`);
}
}, [id, router]);
// 취소
const handleCancel = useCallback(() => {
if (isCreateMode) {
router.push('/ko/construction/order/base-info/pricing');
} else if (isEditMode && id) {
router.push(`/ko/construction/order/base-info/pricing/${id}?mode=view`);
}
}, [isCreateMode, isEditMode, id, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/construction/order/base-info/pricing');
}, [router]);
// 숫자 포맷
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
// 페이지 제목
const pageTitle = isCreateMode ? '단가 등록' : isEditMode ? '단가 수정' : '단가 상세';
const pageDescription = '단가 정보를 등록하고 관리합니다';
// 동적 항목 (무게, 두께 등) 가져오기
const dynamicOrderItems = pricing?.orderItems || [];
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
if (newMode === 'edit' && pricingId) {
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
router.push(`/ko/construction/order/base-info/pricing/${pricingId}?mode=edit`);
} else {
setMode(newMode);
}
},
[pricingId, router]
);
return (
<PageLayout>
<PageHeader
title={pageTitle}
description={pageDescription}
icon={DollarSign}
/>
<div className="space-y-6 pb-24">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 단가번호 / 품목유형 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isCreateMode ? (
<Input value="자동생성" disabled />
) : (
<Input value={pricing?.pricingNumber || ''} disabled />
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.itemType} disabled />
</div>
</div>
{/* 카테고리명 / 품목명 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.category} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.itemName} disabled />
</div>
</div>
{/* 규격 / 동적항목 (무게 등) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.spec} disabled />
</div>
{dynamicOrderItems.length > 0 ? (
<div className="space-y-2">
<Label>{dynamicOrderItems[0]?.name || '무게'}</Label>
<Input value={dynamicOrderItems[0]?.value || '-'} disabled />
</div>
) : (
<div className="space-y-2">
<Label></Label>
<Input value="-" disabled />
</div>
)}
</div>
{/* 단위 / 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.unit} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.division} disabled />
</div>
</div>
{/* 거래처 / 판매단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formData.vendor} disabled />
) : (
<Select value={formData.vendor} onValueChange={handleVendorChange}>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.name}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formatNumber(formData.sellingPrice)} disabled />
) : (
<CurrencyInput
value={formData.sellingPrice}
onChange={(value) => handleSellingPriceChange(String(value ?? 0))}
placeholder="판매단가 입력"
/>
)}
</div>
</div>
{/* 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={PRICING_STATUS_LABELS[formData.status]} disabled />
) : (
<Select value={formData.status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="in_use"></SelectItem>
<SelectItem value="stopped"></SelectItem>
</SelectContent>
</Select>
)}
</div>
<div />
</div>
{/* 비고 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Textarea value={formData.note || '-'} disabled rows={3} />
) : (
<Textarea
value={formData.note}
onChange={handleNoteChange}
placeholder="비고 입력"
rows={3}
/>
)}
</div>
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{isViewMode && (
<>
{canDelete && (
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button onClick={handleEdit} size="sm" className="md:size-default">
<Edit className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
</>
)}
{isEditMode && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isLoading} size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isLoading ? '저장 중...' : '저장'}</span>
</Button>
</>
)}
{isCreateMode && (
<>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSave} disabled={isLoading} size="sm" className="md:size-default">
<Plus className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isLoading ? '등록 중...' : '등록'}</span>
</Button>
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isLoading}
onConfirm={handleDelete}
/>
</PageLayout>
<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}
/>
);
}
// Named export for backwards compatibility
export { PricingDetailClient };

View File

@@ -1,134 +0,0 @@
/**
* PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정
*
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
* - 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}?mode=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}
/>
);
}
// Named export for backwards compatibility
export { PricingDetailClientV2 };

View File

@@ -1,6 +1,5 @@
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

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import {

View File

@@ -27,7 +27,7 @@ import {
import { Client } from "../../hooks/useClientList";
import { PageLayout } from "../organisms/PageLayout";
import { PageHeader } from "../organisms/PageHeader";
import { useMenuStore } from "@/store/menuStore";
import { useMenuStore } from "@/stores/menuStore";
interface ClientDetailProps {
client: Client;

View File

@@ -15,7 +15,7 @@ import {
randomRemark,
tempId,
} from './index';
import type { QuoteFormData, QuoteItem } from '@/components/quotes/QuoteRegistration';
import type { QuoteFormData, QuoteFormItem } from '@/components/quotes/types';
import type { Vendor } from '@/components/accounting/VendorManagement/types';
import type { FinishedGoods } from '@/components/quotes/actions';
@@ -40,11 +40,11 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서
* @param products 제품 목록 (code, name, category 속성 필요)
* @param category 제품 카테고리 (지정하지 않으면 랜덤 선택)
*/
export function generateQuoteItem(
export function generateQuoteFormItem(
index: number,
products?: Array<{ code: string; name: string; category?: string }>,
category?: string
): QuoteItem {
): QuoteFormItem {
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
// 카테고리에 맞는 제품 필터링
@@ -106,9 +106,9 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote
// 품목 생성 (동일 카테고리 사용)
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
const items: QuoteItem[] = [];
const items: QuoteFormItem[] = [];
for (let i = 0; i < count; i++) {
items.push(generateQuoteItem(i, products, selectedCategory));
items.push(generateQuoteFormItem(i, products, selectedCategory));
}
return {

View File

@@ -10,7 +10,7 @@ export { useDevFill } from './useDevFill';
export { DevToolbar } from './DevToolbar';
// Generators
export { generateQuoteData, generateQuoteItem } from './generators/quoteData';
export { generateQuoteData, generateQuoteFormItem } from './generators/quoteData';
export { generateOrderData, generateOrderDataFull } from './generators/orderData';
export { generateWorkOrderData } from './generators/workOrderData';
export { generateShipmentData } from './generators/shipmentData';

View File

@@ -9,7 +9,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';

View File

@@ -30,7 +30,7 @@ import {
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
interface ItemDetailClientProps {

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
import { useRouter } from 'next/navigation';
import { useMenuStore, type MenuItem } from '@/store/menuStore';
import { useMenuStore, type MenuItem } from '@/stores/menuStore';
import {
CommandDialog,
CommandInput,

View File

@@ -0,0 +1,209 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFavoritesStore } from '@/stores/favoritesStore';
import { iconMap } from '@/lib/utils/menuTransform';
import type { FavoriteItem } from '@/stores/favoritesStore';
type DisplayMode = 'full' | 'icon-only' | 'overflow';
interface HeaderFavoritesBarProps {
isMobile: boolean;
}
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
const router = useRouter();
const { favorites } = useFavoritesStore();
const containerRef = useRef<HTMLDivElement>(null);
const [displayMode, setDisplayMode] = useState<DisplayMode>('full');
// 반응형: ResizeObserver로 컨테이너 너비 감지
useEffect(() => {
if (isMobile) {
setDisplayMode('overflow');
return;
}
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
if (width < 300 || favorites.length > 4) {
setDisplayMode('overflow');
} else if (width < 600) {
setDisplayMode('icon-only');
} else {
setDisplayMode('full');
}
}
});
observer.observe(container);
return () => observer.disconnect();
}, [isMobile, favorites.length]);
const handleClick = useCallback(
(item: FavoriteItem) => {
router.push(item.path);
},
[router]
);
if (favorites.length === 0) return null;
const getIcon = (iconName: string) => {
const Icon = iconMap[iconName];
return Icon || null;
};
// 모바일: 최대 2개 아이콘 + 나머지 드롭다운
if (isMobile) {
const visible = favorites.slice(0, 2);
const overflow = favorites.slice(2);
return (
<div className="flex items-center space-x-0.5 sm:space-x-1">
{visible.map((item) => {
const Icon = getIcon(item.iconName);
if (!Icon) return null;
return (
<Button
key={item.id}
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
title={item.label}
>
<Icon className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
</Button>
);
})}
{overflow.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-slate-600 hover:bg-slate-700 text-white flex items-center justify-center"
>
<MoreHorizontal className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{overflow.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
// 데스크톱
const visibleCount = displayMode === 'overflow' ? 3 : favorites.length;
const visibleItems = favorites.slice(0, visibleCount);
const overflowItems = favorites.slice(visibleCount);
return (
<TooltipProvider delayDuration={300}>
<div ref={containerRef} className="flex items-center space-x-2">
{visibleItems.map((item) => {
const Icon = getIcon(item.iconName);
if (!Icon) return null;
if (displayMode === 'full') {
return (
<Button
key={item.id}
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
>
<Icon className="h-4 w-4" />
<span className="hidden xl:inline">{item.label}</span>
</Button>
);
}
// icon-only 또는 overflow의 visible 부분
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white p-2 flex items-center justify-center transition-all duration-200"
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
{overflowItems.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="rounded-xl p-2 flex items-center justify-center"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{overflowItems.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -1,6 +1,9 @@
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
import type { MenuItem } from '@/store/menuStore';
import { useEffect, useRef } from 'react';
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
import type { MenuItem } from '@/stores/menuStore';
import { useEffect, useRef, useCallback } from 'react';
import { useFavoritesStore } from '@/stores/favoritesStore';
import { getIconName } from '@/lib/utils/menuTransform';
import type { FavoriteItem } from '@/stores/favoritesStore';
interface SidebarProps {
menuItems: MenuItem[];
@@ -45,6 +48,24 @@ function MenuItemComponent({
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMenus.includes(item.id);
const isActive = activeMenu === item.id;
const isLeaf = !hasChildren;
// 즐겨찾기 상태
const { toggleFavorite, isFavorite } = useFavoritesStore();
const isFav = isLeaf ? isFavorite(item.id) : false;
const handleStarClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const favItem: FavoriteItem = {
id: item.id,
label: item.label,
iconName: getIconName(item.icon),
path: item.path,
addedAt: Date.now(),
};
toggleFavorite(favItem);
}, [item, toggleFavorite]);
const handleClick = () => {
if (hasChildren) {
@@ -72,48 +93,63 @@ function MenuItemComponent({
className="relative"
ref={isActive ? activeMenuRef : null}
>
{/* 메인 메뉴 버튼 */}
<button
onClick={handleClick}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
{/* 메인 메뉴 버튼 + 별표 래퍼 */}
<div className="flex items-center group/row">
<button
onClick={handleClick}
className={`flex-1 min-w-0 flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-2.5 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
{isLeaf && !sidebarCollapsed && (
<button
onClick={handleStarClick}
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
</div>
{/* 자식 메뉴 (재귀) */}
{hasChildren && isExpanded && !sidebarCollapsed && (
@@ -143,24 +179,39 @@ function MenuItemComponent({
if (is2Depth) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3.5 w-3.5" />
</div>
<div className="flex items-center group/row">
<button
onClick={handleClick}
className={`flex-1 min-w-0 flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3.5 w-3.5" />
</div>
)}
</button>
{isLeaf && (
<button
onClick={handleStarClick}
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</button>
</div>
{/* 자식 메뉴 (3depth) */}
{hasChildren && isExpanded && (
@@ -190,26 +241,41 @@ function MenuItemComponent({
if (is3DepthOrMore) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
}`} />
<span className="flex-1 text-xs text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3 w-3" />
</div>
<div className="flex items-center group/row">
<button
onClick={handleClick}
className={`flex-1 min-w-0 flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
}`} />
<span className="flex-1 text-xs text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3 w-3" />
</div>
)}
</button>
{isLeaf && (
<button
onClick={handleStarClick}
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</button>
</div>
{/* 자식 메뉴 (4depth 이상 - 재귀) */}
{hasChildren && isExpanded && (

View File

@@ -19,7 +19,7 @@ import { useRouter } from 'next/navigation';
import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
import { InspectionModal } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModal';
import { ImportInspectionInputModal } from './ImportInspectionInputModal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -874,7 +874,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
/>
{/* 수입검사 성적서 모달 (읽기 전용) */}
<InspectionModalV2
<InspectionModal
isOpen={isInspectionModalOpen}
onClose={() => setIsInspectionModalOpen(false)}
document={{

View File

@@ -12,7 +12,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, FileText, CheckCircle2, Edit3, Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';

View File

@@ -25,7 +25,7 @@ import {
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { createPricingTable, updatePricingTable, deletePricingTable } from './actions';

View File

@@ -4,16 +4,11 @@
* 중간검사 미리보기 모달
*
* 설정된 검사 항목들로 실제 성적서가 어떻게 보일지 미리보기
* DocumentViewer preset="readonly" 사용 → 줌/드래그/인쇄/PDF 자동 제공
*/
import { Fragment } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { DocumentViewer } from '@/components/document-system';
import { Badge } from '@/components/ui/badge';
import type { InspectionSetting } from '@/types/process';
@@ -30,21 +25,16 @@ export function InspectionPreviewModal({
}: InspectionPreviewModalProps) {
if (!inspectionSetting) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="py-12 text-center text-muted-foreground">
. .
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
<DocumentViewer
title="중간검사 미리보기"
preset="readonly"
open={open}
onOpenChange={onOpenChange}
>
<div className="py-12 text-center text-muted-foreground">
. .
</div>
</DocumentViewer>
);
}
@@ -69,214 +59,206 @@ export function InspectionPreviewModal({
const sampleRows = [1, 2, 3, 4, 5];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[1400px] sm:max-w-[1400px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* 헤더 정보 */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">:</span>
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> :</span>
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}</Badge>
</div>
<DocumentViewer
title="중간검사 미리보기"
preset="readonly"
open={open}
onOpenChange={onOpenChange}
>
<div className="space-y-6 p-6">
{/* 헤더 정보 */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">:</span>
<Badge variant="outline">{inspectionSetting.standardName || '미설정'}</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> :</span>
<Badge>{activeAppearanceItems.length + activeDimensionItems.length}</Badge>
</div>
</div>
{/* 중간검사 기준서 */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
{/* 중간검사 기준서 */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
</div>
<div className="p-4 grid grid-cols-2 gap-4">
{/* 도해 이미지 영역 */}
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.schematicImage ? (
<img
src={inspectionSetting.schematicImage}
alt="도해 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<span className="text-muted-foreground text-sm"> </span>
)}
</div>
<div className="p-4 grid grid-cols-2 gap-4">
{/* 도해 이미지 영역 */}
<div className="border rounded-lg p-4 min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.schematicImage ? (
<img
src={inspectionSetting.schematicImage}
alt="도해 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<span className="text-muted-foreground text-sm"> </span>
)}
</div>
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.inspectionStandardImage ? (
<img
src={inspectionSetting.inspectionStandardImage}
alt="검사기준 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<div className="w-full">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
{/* 검사기준 이미지 또는 검사 항목 테이블 */}
<div className="border rounded-lg overflow-hidden min-h-[200px] flex items-center justify-center bg-muted/30">
{inspectionSetting.inspectionStandardImage ? (
<img
src={inspectionSetting.inspectionStandardImage}
alt="검사기준 이미지"
className="max-w-full max-h-[180px] object-contain"
/>
) : (
<div className="w-full">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
<th className="border-b px-3 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{activeAppearanceItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2"></td>
<td className="px-3 py-2">-</td>
</tr>
</thead>
<tbody>
{activeAppearanceItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2"></td>
<td className="px-3 py-2">-</td>
</tr>
))}
{activeDimensionItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">{item.point}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
{activeDimensionItems.map((item) => (
<tr key={item.key} className="border-b last:border-b-0">
<td className="px-3 py-2">{item.label}</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">{item.point}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* 중간검사 DATA */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
DATA
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
{/* 겉모양 항목들 */}
{/* 중간검사 DATA */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-b">
DATA
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="border-b border-r px-3 py-2 text-center w-12">No.</th>
{/* 겉모양 항목들 */}
{activeAppearanceItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
>
{item.label}
</th>
))}
{/* 치수 항목들 */}
{activeDimensionItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center"
colSpan={2}
>
{item.label} (mm)
</th>
))}
{/* 판정 */}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-2 text-center w-20">
<br />
<span className="text-xs">(/)</span>
</th>
)}
</tr>
{/* 치수 서브헤더 */}
{activeDimensionItems.length > 0 && (
<tr className="bg-muted/30">
<th className="border-b border-r px-3 py-1"></th>
{activeAppearanceItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center min-w-[80px]"
>
{item.label}
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
/
</th>
))}
{/* 치수 항목들 */}
{activeDimensionItems.map((item) => (
<th
key={item.key}
className="border-b border-r px-3 py-2 text-center"
colSpan={2}
>
{item.label} (mm)
</th>
<Fragment key={`${item.key}-header`}>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
</Fragment>
))}
{/* 판정 */}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-2 text-center w-20">
<br />
<span className="text-xs">(/)</span>
</th>
<th className="border-b px-3 py-1"></th>
)}
</tr>
{/* 치수 서브헤더 */}
{activeDimensionItems.length > 0 && (
<tr className="bg-muted/30">
<th className="border-b border-r px-3 py-1"></th>
{activeAppearanceItems.map((item) => (
<th key={item.key} className="border-b border-r px-3 py-1 text-xs">
/
</th>
))}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-header`}>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
<th className="border-b border-r px-3 py-1 text-xs">
</th>
</Fragment>
))}
{inspectionSetting.judgment && (
<th className="border-b px-3 py-1"></th>
)}
</tr>
)}
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
<td className="border-r px-3 py-2 text-center">{row}</td>
{/* 겉모양 샘플 데이터 */}
{activeAppearanceItems.map((item) => (
<td key={item.key} className="border-r px-3 py-2 text-center">
<span className="text-muted-foreground"> </span>
<br />
<span className="text-muted-foreground"> </span>
)}
</thead>
<tbody>
{sampleRows.map((row) => (
<tr key={row} className="border-b last:border-b-0 hover:bg-muted/20">
<td className="border-r px-3 py-2 text-center">{row}</td>
{/* 겉모양 샘플 데이터 */}
{activeAppearanceItems.map((item) => (
<td key={item.key} className="border-r px-3 py-2 text-center">
<span className="text-muted-foreground"> </span>
<br />
<span className="text-muted-foreground"> </span>
</td>
))}
{/* 치수 샘플 데이터 */}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-data-${row}`}>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
))}
{/* 치수 샘플 데이터 */}
{activeDimensionItems.map((item) => (
<Fragment key={`${item.key}-data-${row}`}>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
</Fragment>
))}
{/* 판정 샘플 */}
{inspectionSetting.judgment && (
<td className="px-3 py-2 text-center">
<span className="text-muted-foreground">-</span>
<td className="border-r px-3 py-2 text-center text-muted-foreground">
-
</td>
)}
</tr>
))}
</tbody>
</table>
</Fragment>
))}
{/* 판정 샘플 */}
{inspectionSetting.judgment && (
<td className="px-3 py-2 text-center">
<span className="text-muted-foreground">-</span>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 부적합 내용 */}
{inspectionSetting.nonConformingContent && (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
</div>
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
</div>
</div>
<div className="grid grid-cols-2">
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
( )
</div>
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
/
</div>
</div>
</div>
{/* 부적합 내용 */}
{inspectionSetting.nonConformingContent && (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-2">
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm border-r">
</div>
<div className="bg-muted/50 px-4 py-2 font-semibold text-sm">
</div>
</div>
<div className="grid grid-cols-2">
<div className="px-4 py-3 border-r min-h-[60px] text-muted-foreground text-sm">
( )
</div>
<div className="px-4 py-3 text-center text-muted-foreground text-sm">
/
</div>
</div>
</div>
)}
</div>
{/* 버튼 영역 */}
<div className="flex justify-end mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</DocumentViewer>
);
}

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';

View File

@@ -17,7 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { deleteProcessStep } from './actions';
import { toast } from 'sonner';

View File

@@ -41,10 +41,6 @@ export interface ScreenInspectionContentProps {
workItems?: WorkItemData[];
inspectionDataMap?: InspectionDataMap;
inspectionSetting?: InspectionSetting;
/** @deprecated inspectionSetting.schematicImage 사용 */
schematicImage?: string;
/** @deprecated inspectionSetting.inspectionStandardImage 사용 */
inspectionStandardImage?: string;
}
interface InspectionRow {
@@ -87,9 +83,8 @@ export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenIn
workItems,
inspectionDataMap,
inspectionSetting,
schematicImage: schematicImageProp,
}, ref) {
const schematicImage = inspectionSetting?.schematicImage || schematicImageProp;
const schematicImage = inspectionSetting?.schematicImage;
const fullDate = getFullDate();
const today = getToday();
const { documentNo, primaryAssignee } = getOrderInfo(order);

View File

@@ -14,7 +14,7 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';

View File

@@ -35,7 +35,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ItemSearchModal } from "./ItemSearchModal";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
import type { FinishedGoods } from "./actions";
import type { BomCalculationResultItem } from "./types";

View File

@@ -27,7 +27,7 @@ import {
SelectValue,
} from "../ui/select";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
// =============================================================================
// 상수

View File

@@ -33,7 +33,7 @@ import {
} from "../ui/table";
import { DeleteConfirmDialog } from "../ui/confirm-dialog";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
import type { FinishedGoods } from "./actions";
// xlsx는 동적 로드 (번들 크기 최적화)

View File

@@ -5,7 +5,7 @@
* - DocumentHeader: quote 레이아웃 + LotApprovalTable
*/
import { QuoteFormData } from "./QuoteRegistration";
import { QuoteFormData } from "./types";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { DocumentHeader, LotApprovalTable } from "@/components/document-system";

View File

@@ -4,7 +4,7 @@
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
*/
import { QuoteFormData } from "./QuoteRegistration";
import { QuoteFormData } from "./types";
import type { BomMaterial } from "./types";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";

View File

@@ -6,7 +6,7 @@
* - SignatureSection: 서명/도장 영역
*/
import { QuoteFormData } from "./QuoteRegistration";
import { QuoteFormData } from "./types";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { DocumentHeader, SignatureSection } from "@/components/document-system";

View File

@@ -9,7 +9,7 @@
*/
import React from 'react';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import type { QuoteFormDataV2 } from './QuoteRegistration';
import type { BomCalculationResultItem } from './types';
// 양식 타입

View File

@@ -18,7 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DocumentViewer } from '@/components/document-system';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import type { QuoteFormDataV2 } from './QuoteRegistration';
import { QuotePreviewContent } from './QuotePreviewContent';
// 양식 타입: 업체발송용 / 산출내역서

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ import { Coins } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { LocationItem } from "./QuoteRegistration";
// =============================================================================
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)

View File

@@ -12,7 +12,7 @@
*/
import { DocumentViewer } from '@/components/document-system';
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
import type { QuoteFormDataV2 } from './QuoteRegistration';
interface QuoteTransactionModalProps {
open: boolean;

View File

@@ -549,22 +549,6 @@ export async function getQuoteReferenceData(): Promise<{
};
}
/** @deprecated getQuoteReferenceData 사용 */
export async function getSiteNames(): Promise<{
success: boolean;
data: string[];
error?: string;
__authError?: boolean;
}> {
const result = await getQuoteReferenceData();
return {
success: result.success,
data: result.data.siteNames,
error: result.error,
__authError: result.__authError,
};
}
// ===== 품목 카테고리 트리 조회 =====
export interface ItemCategoryNode {
id: number;

View File

@@ -5,9 +5,9 @@
// 클라이언트 컴포넌트
export { QuoteManagementClient } from './QuoteManagementClient';
// 기존 컴포넌트
// 컴포넌트
export { QuoteDocument } from './QuoteDocument';
export { QuoteRegistration, INITIAL_QUOTE_FORM } from './QuoteRegistration';
export { QuoteRegistration } from './QuoteRegistration';
export { QuoteCalculationReport } from './QuoteCalculationReport';
export { PurchaseOrderDocument } from './PurchaseOrderDocument';

View File

@@ -15,7 +15,7 @@ import {
Loader2,
Plus,
} from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
import { DetailPageSkeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@@ -16,7 +16,7 @@ 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';
import { useMenuStore } from '@/store/menuStore';
import { useMenuStore } from '@/stores/menuStore';
export interface DetailActionsProps {
/** 현재 모드 */