fix: 1:1 문의 라우트 수정 및 빌드 오류 수정
- customer-center/inquiries → customer-center/qna 폴더명 변경 - InquiryManagement 컴포넌트 경로 참조 수정 - BadDebtDetail.tsx 중복 선언 오류 수정 - EditableTable 컴포넌트 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -110,17 +110,6 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Daum 우편번호 서비스
|
|
||||||
const { openPostcode } = useDaumPostcode({
|
|
||||||
onComplete: (result) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
zipCode: result.zonecode,
|
|
||||||
address1: result.address,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 다이얼로그 상태
|
// 다이얼로그 상태
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
|||||||
332
src/components/common/EditableTable/EditableTable.tsx
Normal file
332
src/components/common/EditableTable/EditableTable.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface EditableColumn<T> {
|
||||||
|
key: keyof T;
|
||||||
|
header: string;
|
||||||
|
width?: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
type?: 'text' | 'number' | 'select';
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[]; // for select type
|
||||||
|
render?: (value: T[keyof T], row: T, index: number, onChange: (value: T[keyof T]) => void) => React.ReactNode;
|
||||||
|
editable?: boolean; // default true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditableTableProps<T extends { id: string | number }> {
|
||||||
|
columns: EditableColumn<T>[];
|
||||||
|
data: T[];
|
||||||
|
onChange: (data: T[]) => void;
|
||||||
|
|
||||||
|
// 새 행 생성 시 기본값
|
||||||
|
createNewRow: () => T;
|
||||||
|
|
||||||
|
// 옵션
|
||||||
|
title?: string;
|
||||||
|
addButtonLabel?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
showRowNumber?: boolean;
|
||||||
|
maxRows?: number;
|
||||||
|
minRows?: number;
|
||||||
|
|
||||||
|
// 대량 추가 옵션
|
||||||
|
bulkAdd?: boolean; // 여러 행 한번에 추가 기능
|
||||||
|
bulkAddLabel?: string; // 대량 추가 버튼 라벨
|
||||||
|
|
||||||
|
// 스타일
|
||||||
|
striped?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableTable<T extends { id: string | number }>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
createNewRow,
|
||||||
|
title,
|
||||||
|
addButtonLabel = '행 추가',
|
||||||
|
emptyMessage = '데이터가 없습니다. 행을 추가해주세요.',
|
||||||
|
showRowNumber = true,
|
||||||
|
maxRows,
|
||||||
|
minRows = 0,
|
||||||
|
bulkAdd = false,
|
||||||
|
bulkAddLabel = '추가',
|
||||||
|
striped = false,
|
||||||
|
compact = false,
|
||||||
|
}: EditableTableProps<T>) {
|
||||||
|
// 대량 추가 개수 상태
|
||||||
|
const [bulkCount, setBulkCount] = useState(1);
|
||||||
|
|
||||||
|
// 행 추가 (단일)
|
||||||
|
const handleAddRow = useCallback(() => {
|
||||||
|
if (maxRows && data.length >= maxRows) return;
|
||||||
|
const newRow = createNewRow();
|
||||||
|
onChange([...data, newRow]);
|
||||||
|
}, [data, onChange, createNewRow, maxRows]);
|
||||||
|
|
||||||
|
// 행 추가 (대량)
|
||||||
|
const handleBulkAddRows = useCallback(() => {
|
||||||
|
if (!bulkCount || bulkCount < 1) return;
|
||||||
|
|
||||||
|
// 최대 개수 제한 계산
|
||||||
|
let countToAdd = bulkCount;
|
||||||
|
if (maxRows) {
|
||||||
|
const remaining = maxRows - data.length;
|
||||||
|
countToAdd = Math.min(countToAdd, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countToAdd <= 0) return;
|
||||||
|
|
||||||
|
const newRows = Array.from({ length: countToAdd }, () => createNewRow());
|
||||||
|
onChange([...data, ...newRows]);
|
||||||
|
setBulkCount(1); // 입력값 초기화
|
||||||
|
}, [bulkCount, data, onChange, createNewRow, maxRows]);
|
||||||
|
|
||||||
|
// 행 삭제
|
||||||
|
const handleDeleteRow = useCallback((index: number) => {
|
||||||
|
if (data.length <= minRows) return;
|
||||||
|
const newData = data.filter((_, i) => i !== index);
|
||||||
|
onChange(newData);
|
||||||
|
}, [data, onChange, minRows]);
|
||||||
|
|
||||||
|
// 셀 값 변경
|
||||||
|
const handleCellChange = useCallback((index: number, key: keyof T, value: T[keyof T]) => {
|
||||||
|
const newData = [...data];
|
||||||
|
newData[index] = { ...newData[index], [key]: value };
|
||||||
|
onChange(newData);
|
||||||
|
}, [data, onChange]);
|
||||||
|
|
||||||
|
// 기본 셀 렌더링
|
||||||
|
const renderCell = (
|
||||||
|
column: EditableColumn<T>,
|
||||||
|
row: T,
|
||||||
|
rowIndex: number
|
||||||
|
) => {
|
||||||
|
const value = row[column.key];
|
||||||
|
const isEditable = column.editable !== false;
|
||||||
|
|
||||||
|
// 커스텀 렌더러가 있으면 사용
|
||||||
|
if (column.render) {
|
||||||
|
return column.render(value, row, rowIndex, (newValue) => {
|
||||||
|
handleCellChange(rowIndex, column.key, newValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 불가능한 경우 텍스트로 표시
|
||||||
|
if (!isEditable) {
|
||||||
|
return <span className="text-muted-foreground">{String(value ?? '-')}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입별 기본 렌더링
|
||||||
|
switch (column.type) {
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value as number ?? ''}
|
||||||
|
onChange={(e) => handleCellChange(rowIndex, column.key, Number(e.target.value) as T[keyof T])}
|
||||||
|
placeholder={column.placeholder}
|
||||||
|
className={cn('h-8', compact && 'h-7 text-sm')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(value ?? '')}
|
||||||
|
onChange={(e) => handleCellChange(rowIndex, column.key, e.target.value as T[keyof T])}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-8 px-2 rounded-md border border-input bg-background text-sm',
|
||||||
|
compact && 'h-7'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">{column.placeholder || '선택'}</option>
|
||||||
|
{column.options?.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={String(value ?? '')}
|
||||||
|
onChange={(e) => handleCellChange(rowIndex, column.key, e.target.value as T[keyof T])}
|
||||||
|
placeholder={column.placeholder}
|
||||||
|
className={cn('h-8', compact && 'h-7 text-sm')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAddRow = !maxRows || data.length < maxRows;
|
||||||
|
const canDeleteRow = data.length > minRows;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{title && (
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data.length}개 항목
|
||||||
|
{maxRows && ` (최대 ${maxRows}개)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={cn(!title && 'pt-6')}>
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{showRowNumber && (
|
||||||
|
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||||
|
)}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead
|
||||||
|
key={String(column.key)}
|
||||||
|
className={cn(
|
||||||
|
column.width && `w-[${column.width}]`,
|
||||||
|
column.align === 'center' && 'text-center',
|
||||||
|
column.align === 'right' && 'text-right'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="w-[60px] text-center">삭제</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length + (showRowNumber ? 1 : 0) + 1}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{emptyMessage}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.map((row, rowIndex) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
striped && rowIndex % 2 === 1 && 'bg-muted/30',
|
||||||
|
compact && 'h-10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showRowNumber && (
|
||||||
|
<TableCell className="text-center text-muted-foreground">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell
|
||||||
|
key={String(column.key)}
|
||||||
|
className={cn(
|
||||||
|
column.align === 'center' && 'text-center',
|
||||||
|
column.align === 'right' && 'text-right',
|
||||||
|
compact && 'py-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderCell(column, row, rowIndex)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteRow(rowIndex)}
|
||||||
|
disabled={!canDeleteRow}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 추가 버튼 */}
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{bulkAdd ? (
|
||||||
|
<>
|
||||||
|
{/* 대량 추가: 숫자 입력 + 버튼 */}
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={maxRows ? maxRows - data.length : 100}
|
||||||
|
value={bulkCount}
|
||||||
|
onChange={(e) => setBulkCount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="w-20 h-9"
|
||||||
|
placeholder="개수"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">개</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkAddRows}
|
||||||
|
disabled={!canAddRow}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{bulkAddLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* 단일 추가 버튼도 제공 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddRow}
|
||||||
|
disabled={!canAddRow}
|
||||||
|
className="h-9 border border-dashed"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
1개 추가
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 단일 추가만 */
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddRow}
|
||||||
|
disabled={!canAddRow}
|
||||||
|
className="w-full border-dashed"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{addButtonLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditableTable;
|
||||||
2
src/components/common/EditableTable/index.ts
Normal file
2
src/components/common/EditableTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { EditableTable } from './EditableTable';
|
||||||
|
export type { EditableColumn, EditableTableProps } from './EditableTable';
|
||||||
@@ -70,11 +70,11 @@ export function InquiryDetail({
|
|||||||
|
|
||||||
// ===== 액션 핸들러 =====
|
// ===== 액션 핸들러 =====
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
router.push('/ko/customer-center/inquiries');
|
router.push('/ko/customer-center/qna');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
const handleEdit = useCallback(() => {
|
||||||
router.push(`/ko/customer-center/inquiries/${inquiry.id}/edit`);
|
router.push(`/ko/customer-center/qna/${inquiry.id}/edit`);
|
||||||
}, [router, inquiry.id]);
|
}, [router, inquiry.id]);
|
||||||
|
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
@@ -84,7 +84,7 @@ export function InquiryDetail({
|
|||||||
const success = await onDeleteInquiry();
|
const success = await onDeleteInquiry();
|
||||||
if (success) {
|
if (success) {
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
router.push('/ko/customer-center/inquiries');
|
router.push('/ko/customer-center/qna');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function InquiryForm({ mode, initialData }: InquiryFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
router.push('/ko/customer-center/inquiries');
|
router.push('/ko/customer-center/qna');
|
||||||
} else {
|
} else {
|
||||||
// 에러 처리
|
// 에러 처리
|
||||||
console.error('Submit error:', result?.error);
|
console.error('Submit error:', result?.error);
|
||||||
|
|||||||
@@ -165,13 +165,13 @@ export function InquiryList() {
|
|||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(item: Inquiry) => {
|
(item: Inquiry) => {
|
||||||
router.push(`/ko/customer-center/inquiries/${item.id}`);
|
router.push(`/ko/customer-center/qna/${item.id}`);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
router.push('/ko/customer-center/inquiries/create');
|
router.push('/ko/customer-center/qna/create');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
// ===== 상태 Badge 색상 =====
|
// ===== 상태 Badge 색상 =====
|
||||||
|
|||||||
Reference in New Issue
Block a user