Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
73
src/components/business/DashboardSwitcher.tsx
Normal file
73
src/components/business/DashboardSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { ContractDocumentModal } from './ContractDocumentModal';
|
||||
export { ContractDocumentModalV2 } from './ContractDocumentModalV2';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
209
src/components/layout/HeaderFavoritesBar.tsx
Normal file
209
src/components/layout/HeaderFavoritesBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { LocationItem } from "./QuoteRegistration";
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
|
||||
@@ -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는 동적 로드 (번들 크기 최적화)
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistration';
|
||||
import type { BomCalculationResultItem } from './types';
|
||||
|
||||
// 양식 타입
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
// =============================================================================
|
||||
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistration';
|
||||
|
||||
interface QuoteTransactionModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
/** 현재 모드 */
|
||||
|
||||
Reference in New Issue
Block a user