- 공사현장관리: 프로젝트 상세, 공정관리, 칸반보드 구현 - 이슈관리: 현장 이슈 등록/조회 기능 추가 - 근로자현황: 일별 근로자 출역 현황 페이지 추가 - 유틸리티관리: 현장 유틸리티 관리 페이지 추가 - 기성청구: 기성청구 관리 페이지 추가 - CEO 대시보드: 현황판(StatusBoardSection) 추가, 설정 다이얼로그 개선 - 발주관리: 모바일 필터 적용, 리스트 UI 개선 - 공용 컴포넌트: MobileFilter, IntegratedListTemplateV2 개선, CalendarHeader 반응형 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
591 lines
20 KiB
TypeScript
591 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Package, Plus, X, List } from 'lucide-react';
|
|
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 {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-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 { ItemDetail, ItemFormData, ItemType, Specification, OrderType, ItemStatus, OrderItem } from './types';
|
|
import {
|
|
ITEM_TYPE_OPTIONS,
|
|
SPECIFICATION_OPTIONS,
|
|
ORDER_TYPE_OPTIONS,
|
|
STATUS_OPTIONS,
|
|
UNIT_OPTIONS,
|
|
} from './constants';
|
|
import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions';
|
|
|
|
interface ItemDetailClientProps {
|
|
itemId?: string;
|
|
isEditMode?: boolean;
|
|
isNewMode?: boolean;
|
|
}
|
|
|
|
const initialFormData: ItemFormData = {
|
|
itemNumber: '',
|
|
itemType: '제품',
|
|
categoryId: '',
|
|
itemName: '',
|
|
specification: '인정',
|
|
unit: 'SET',
|
|
orderType: '경품발주',
|
|
status: '사용',
|
|
note: '',
|
|
orderItems: [],
|
|
};
|
|
|
|
export default function ItemDetailClient({
|
|
itemId,
|
|
isEditMode = false,
|
|
isNewMode = false,
|
|
}: ItemDetailClientProps) {
|
|
const router = useRouter();
|
|
|
|
// 모드 상태
|
|
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
|
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
|
|
);
|
|
|
|
// 폼 데이터
|
|
const [formData, setFormData] = useState<ItemFormData>(initialFormData);
|
|
const [originalData, setOriginalData] = useState<ItemDetail | null>(null);
|
|
|
|
// 카테고리 옵션
|
|
const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]);
|
|
|
|
// 상태
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
|
// 카테고리 목록 로드
|
|
useEffect(() => {
|
|
const loadCategories = async () => {
|
|
const result = await getCategoryOptions();
|
|
if (result.success && result.data) {
|
|
setCategoryOptions(result.data);
|
|
}
|
|
};
|
|
loadCategories();
|
|
}, []);
|
|
|
|
// 품목 데이터 로드
|
|
useEffect(() => {
|
|
if (itemId && !isNewMode) {
|
|
const loadItem = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await getItem(itemId);
|
|
if (result.success && result.data) {
|
|
setOriginalData(result.data);
|
|
setFormData({
|
|
itemNumber: result.data.itemNumber,
|
|
itemType: result.data.itemType,
|
|
categoryId: result.data.categoryId,
|
|
itemName: result.data.itemName,
|
|
specification: result.data.specification,
|
|
unit: result.data.unit,
|
|
orderType: result.data.orderType,
|
|
status: result.data.status,
|
|
note: result.data.note || '',
|
|
orderItems: result.data.orderItems || [],
|
|
});
|
|
} else {
|
|
toast.error(result.error || '품목 정보를 불러오는데 실패했습니다.');
|
|
router.push('/ko/construction/order/base-info/items');
|
|
}
|
|
} catch {
|
|
toast.error('품목 정보를 불러오는데 실패했습니다.');
|
|
router.push('/ko/construction/order/base-info/items');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
loadItem();
|
|
}
|
|
}, [itemId, isNewMode, router]);
|
|
|
|
// 폼 필드 변경
|
|
const handleFieldChange = useCallback(
|
|
(field: keyof ItemFormData, value: string | OrderItem[]) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 발주 항목 추가
|
|
const handleAddOrderItem = useCallback(() => {
|
|
const newItem: OrderItem = {
|
|
id: `new-${Date.now()}`,
|
|
label: '',
|
|
value: '',
|
|
};
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
orderItems: [...prev.orderItems, newItem],
|
|
}));
|
|
}, []);
|
|
|
|
// 발주 항목 삭제
|
|
const handleRemoveOrderItem = useCallback((id: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
orderItems: prev.orderItems.filter((item) => item.id !== id),
|
|
}));
|
|
}, []);
|
|
|
|
// 발주 항목 변경
|
|
const handleOrderItemChange = useCallback(
|
|
(id: string, field: 'label' | 'value', value: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
orderItems: prev.orderItems.map((item) =>
|
|
item.id === id ? { ...item, [field]: value } : item
|
|
),
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 저장
|
|
const handleSave = useCallback(async () => {
|
|
// 유효성 검사
|
|
if (!formData.itemNumber.trim()) {
|
|
toast.error('품목번호를 입력해주세요.');
|
|
return;
|
|
}
|
|
if (!formData.itemName.trim()) {
|
|
toast.error('품목명을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
if (mode === 'new') {
|
|
const result = await createItem(formData);
|
|
if (result.success && result.data) {
|
|
toast.success('품목이 등록되었습니다.');
|
|
router.push(`/ko/construction/order/base-info/items/${result.data.id}`);
|
|
} else {
|
|
toast.error(result.error || '품목 등록에 실패했습니다.');
|
|
}
|
|
} else if (mode === 'edit' && itemId) {
|
|
const result = await updateItem(itemId, formData);
|
|
if (result.success) {
|
|
toast.success('품목이 수정되었습니다.');
|
|
setMode('view');
|
|
// 데이터 다시 로드
|
|
const reloadResult = await getItem(itemId);
|
|
if (reloadResult.success && reloadResult.data) {
|
|
setOriginalData(reloadResult.data);
|
|
}
|
|
} else {
|
|
toast.error(result.error || '품목 수정에 실패했습니다.');
|
|
}
|
|
}
|
|
} catch {
|
|
toast.error('저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [mode, formData, itemId, router]);
|
|
|
|
// 삭제
|
|
const handleDelete = useCallback(async () => {
|
|
if (!itemId) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await deleteItem(itemId);
|
|
if (result.success) {
|
|
toast.success('품목이 삭제되었습니다.');
|
|
router.push('/ko/construction/order/base-info/items');
|
|
} else {
|
|
toast.error(result.error || '품목 삭제에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
toast.error('삭제 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setDeleteDialogOpen(false);
|
|
}
|
|
}, [itemId, router]);
|
|
|
|
// 수정 모드 전환
|
|
const handleEditMode = useCallback(() => {
|
|
setMode('edit');
|
|
router.replace(`/ko/construction/order/base-info/items/${itemId}?mode=edit`);
|
|
}, [itemId, router]);
|
|
|
|
// 목록으로 이동
|
|
const handleBack = useCallback(() => {
|
|
router.push('/ko/construction/order/base-info/items');
|
|
}, [router]);
|
|
|
|
// 취소
|
|
const handleCancel = useCallback(() => {
|
|
if (mode === 'new') {
|
|
router.push('/ko/construction/order/base-info/items');
|
|
} else {
|
|
setMode('view');
|
|
// 원본 데이터로 복원
|
|
if (originalData) {
|
|
setFormData({
|
|
itemNumber: originalData.itemNumber,
|
|
itemType: originalData.itemType,
|
|
categoryId: originalData.categoryId,
|
|
itemName: originalData.itemName,
|
|
specification: originalData.specification,
|
|
unit: originalData.unit,
|
|
orderType: originalData.orderType,
|
|
status: originalData.status,
|
|
note: originalData.note || '',
|
|
orderItems: originalData.orderItems || [],
|
|
});
|
|
}
|
|
router.replace(`/ko/construction/order/base-info/items/${itemId}`);
|
|
}
|
|
}, [mode, itemId, originalData, router]);
|
|
|
|
// 읽기 전용 여부
|
|
const isReadOnly = mode === 'view';
|
|
|
|
// 페이지 타이틀
|
|
const pageTitle = mode === 'new' ? '품목 등록' : '품목 상세';
|
|
|
|
// 액션 버튼
|
|
const actionButtons = (
|
|
<div className="flex items-center gap-2">
|
|
{mode === 'view' && (
|
|
<>
|
|
<Button variant="outline" onClick={handleBack}>
|
|
<List className="h-4 w-4 mr-2" />
|
|
목록
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteDialogOpen(true)}
|
|
>
|
|
삭제
|
|
</Button>
|
|
<Button onClick={handleEditMode}>수정</Button>
|
|
</>
|
|
)}
|
|
{mode === 'edit' && (
|
|
<>
|
|
<Button variant="outline" onClick={handleCancel}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? '저장 중...' : '저장'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{mode === 'new' && (
|
|
<>
|
|
<Button variant="outline" onClick={handleCancel}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? '등록 중...' : '등록'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (isLoading && !isNewMode) {
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title={pageTitle}
|
|
description="품목 정보를 등록하고 관리합니다."
|
|
icon={Package}
|
|
/>
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="text-muted-foreground">로딩 중...</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageLayout>
|
|
<PageHeader
|
|
title={pageTitle}
|
|
description="품목 정보를 등록하고 관리합니다."
|
|
icon={Package}
|
|
actions={actionButtons}
|
|
/>
|
|
<div className="space-y-6">
|
|
{/* 기본 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">기본 정보</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="itemNumber">
|
|
품목번호 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="itemNumber"
|
|
value={formData.itemNumber}
|
|
onChange={(e) => handleFieldChange('itemNumber', e.target.value)}
|
|
placeholder="품목번호를 입력하세요"
|
|
disabled={isReadOnly}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="itemType">품목유형</Label>
|
|
<Select
|
|
value={formData.itemType}
|
|
onValueChange={(v) => handleFieldChange('itemType', v as ItemType)}
|
|
disabled={isReadOnly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="품목유형 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ITEM_TYPE_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: 카테고리명 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="categoryId">카테고리명</Label>
|
|
<Select
|
|
value={formData.categoryId}
|
|
onValueChange={(v) => handleFieldChange('categoryId', v)}
|
|
disabled={isReadOnly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="카테고리 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categoryOptions.map((category) => (
|
|
<SelectItem key={category.id} value={category.id}>
|
|
{category.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Row 3: 품목명, 규격 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="itemName">
|
|
품목명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="itemName"
|
|
value={formData.itemName}
|
|
onChange={(e) => handleFieldChange('itemName', e.target.value)}
|
|
placeholder="품목명을 입력하세요"
|
|
disabled={isReadOnly}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="specification">규격</Label>
|
|
<Select
|
|
value={formData.specification}
|
|
onValueChange={(v) => handleFieldChange('specification', v as Specification)}
|
|
disabled={isReadOnly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="규격 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SPECIFICATION_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 4: 단위, 구분 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="unit">단위</Label>
|
|
<Select
|
|
value={formData.unit}
|
|
onValueChange={(v) => handleFieldChange('unit', v)}
|
|
disabled={isReadOnly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="단위 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{UNIT_OPTIONS.map((unit) => (
|
|
<SelectItem key={unit} value={unit}>
|
|
{unit}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="orderType">구분</Label>
|
|
<Select
|
|
value={formData.orderType}
|
|
onValueChange={(v) => handleFieldChange('orderType', v as OrderType)}
|
|
disabled={isReadOnly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="구분 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ORDER_TYPE_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 5: 상태, 비고 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">상태</Label>
|
|
<Select
|
|
value={formData.status}
|
|
onValueChange={(v) => handleFieldChange('status', v as ItemStatus)}
|
|
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 className="space-y-2">
|
|
<Label htmlFor="note">비고</Label>
|
|
<Input
|
|
id="note"
|
|
value={formData.note || ''}
|
|
onChange={(e) => handleFieldChange('note', e.target.value)}
|
|
placeholder="비고를 입력하세요"
|
|
disabled={isReadOnly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 발주 항목 구분정보 */}
|
|
{/* 수정 모드: 항상 표시 (추가/삭제 가능) */}
|
|
{/* 상세 모드: 데이터가 있을 때만 표시 (읽기 전용) */}
|
|
{(!isReadOnly || formData.orderItems.length > 0) && (
|
|
<div className="pt-4">
|
|
{/* 헤더 */}
|
|
<div className="grid grid-cols-[1fr_1fr_auto] gap-4 items-center mb-4">
|
|
<div className="text-base font-semibold">발주 항목</div>
|
|
<div className="text-base font-semibold">구분 정보</div>
|
|
{!isReadOnly && (
|
|
<Button size="sm" onClick={handleAddOrderItem}>
|
|
추가
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 항목 리스트 */}
|
|
{formData.orderItems.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground border rounded-lg">
|
|
발주 항목이 없습니다. 추가 버튼을 클릭하여 항목을 추가하세요.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{formData.orderItems.map((item) => (
|
|
<div key={item.id} className={`grid ${isReadOnly ? 'grid-cols-2' : 'grid-cols-[1fr_1fr_auto]'} gap-4 items-center`}>
|
|
<Input
|
|
value={item.label}
|
|
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
|
|
placeholder="예: 무게"
|
|
disabled={isReadOnly}
|
|
/>
|
|
<Input
|
|
value={item.value}
|
|
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
|
|
placeholder="예: 400KG"
|
|
disabled={isReadOnly}
|
|
/>
|
|
{!isReadOnly && (
|
|
<Button
|
|
variant="default"
|
|
size="icon"
|
|
className="h-10 w-10 bg-black hover:bg-black/80"
|
|
onClick={() => handleRemoveOrderItem(item.id)}
|
|
>
|
|
<X className="h-4 w-4 text-white" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</PageLayout>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>품목 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
이 품목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
} |