-
-
+ {/* 2행: 견적 회사 담당자, 견적 회사 담당자 연락처 */}
+
-
-
-
-
-
-
-
-
- {STATUS_LABELS[formData.status]}
-
+ {/* 3행: 견적금액, 상태 */}
+
+
+
+
+
+
+
+
+
+ {STATUS_LABELS[formData.status]}
+
+
diff --git a/src/components/business/construction/estimates/sections/PriceAdjustmentSection.tsx b/src/components/business/construction/estimates/sections/PriceAdjustmentSection.tsx
index 01e6458f..2cb00694 100644
--- a/src/components/business/construction/estimates/sections/PriceAdjustmentSection.tsx
+++ b/src/components/business/construction/estimates/sections/PriceAdjustmentSection.tsx
@@ -78,9 +78,11 @@ export function PriceAdjustmentSection({
>
전체 적용
+ {/* 초기화 버튼 주석처리
+ */}
)}
diff --git a/src/components/business/construction/estimates/types.ts b/src/components/business/construction/estimates/types.ts
index 80a30a90..bf859dfc 100644
--- a/src/components/business/construction/estimates/types.ts
+++ b/src/components/business/construction/estimates/types.ts
@@ -184,6 +184,8 @@ export interface EstimateDetailFormData {
estimateCode: string;
estimatorId: string;
estimatorName: string;
+ estimateCompanyManager: string; // 견적 회사 담당자
+ estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
estimateAmount: number;
status: EstimateStatus;
@@ -251,6 +253,8 @@ export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
estimateCode: '',
estimatorId: '',
estimatorName: '',
+ estimateCompanyManager: '',
+ estimateCompanyManagerContact: '',
estimateAmount: 0,
status: 'pending',
siteBriefing: {
@@ -290,6 +294,8 @@ export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetail
estimateCode: detail.estimateCode,
estimatorId: detail.estimatorId,
estimatorName: detail.estimatorName,
+ estimateCompanyManager: detail.estimateCompanyManager || '',
+ estimateCompanyManagerContact: detail.estimateCompanyManagerContact || '',
estimateAmount: detail.estimateAmount,
status: detail.status,
siteBriefing: detail.siteBriefing,
@@ -315,6 +321,8 @@ export interface Estimate {
projectName: string; // 현장명
estimatorId: string; // 견적자 ID
estimatorName: string; // 견적자명
+ estimateCompanyManager: string; // 견적 회사 담당자
+ estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
// 견적 정보
itemCount: number; // 총 개소 (품목 수)
diff --git a/src/components/business/construction/handover-report/HandoverReportListClient.tsx b/src/components/business/construction/handover-report/HandoverReportListClient.tsx
index 194314c4..c66d359d 100644
--- a/src/components/business/construction/handover-report/HandoverReportListClient.tsx
+++ b/src/components/business/construction/handover-report/HandoverReportListClient.tsx
@@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
-import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
+import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
+import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
@@ -272,6 +265,95 @@ export default function HandoverReportListClient({
[router]
);
+ // ===== filterConfig 기반 통합 필터 시스템 =====
+ const filterConfig: FilterFieldConfig[] = useMemo(() => [
+ {
+ key: 'partner',
+ label: '거래처',
+ type: 'multi',
+ options: MOCK_PARTNERS.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'contractManager',
+ label: '계약담당자',
+ type: 'multi',
+ options: MOCK_CONTRACT_MANAGERS.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'constructionPM',
+ label: '공사PM',
+ type: 'multi',
+ options: MOCK_CONSTRUCTION_PMS.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'status',
+ label: '상태',
+ type: 'single',
+ options: REPORT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ allOptionLabel: '전체',
+ },
+ {
+ key: 'sortBy',
+ label: '정렬',
+ type: 'single',
+ options: REPORT_SORT_OPTIONS.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ allOptionLabel: '최신순 (계약시작일)',
+ },
+ ], []);
+
+ const filterValues: FilterValues = useMemo(() => ({
+ partner: partnerFilters,
+ contractManager: contractManagerFilters,
+ constructionPM: constructionPMFilters,
+ status: statusFilter,
+ sortBy: sortBy,
+ }), [partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, sortBy]);
+
+ const handleFilterChange = useCallback((key: string, value: string | string[]) => {
+ switch (key) {
+ case 'partner':
+ setPartnerFilters(value as string[]);
+ break;
+ case 'contractManager':
+ setContractManagerFilters(value as string[]);
+ break;
+ case 'constructionPM':
+ setConstructionPMFilters(value as string[]);
+ break;
+ case 'status':
+ setStatusFilter(value as string);
+ break;
+ case 'sortBy':
+ setSortBy(value as string);
+ break;
+ }
+ setCurrentPage(1);
+ }, []);
+
+ const handleFilterReset = useCallback(() => {
+ setPartnerFilters([]);
+ setContractManagerFilters([]);
+ setConstructionPMFilters([]);
+ setStatusFilter('all');
+ setSortBy('contractDateDesc');
+ setCurrentPage(1);
+ }, []);
+
// 테이블 행 렌더링
const renderTableRow = useCallback(
(report: HandoverReport, index: number, globalIndex: number) => {
@@ -389,73 +471,6 @@ export default function HandoverReportListClient({
},
];
- // 테이블 헤더 액션
- const tableHeaderActions = (
-
-
- 총 {sortedReports.length}건
-
-
- {/* 거래처 필터 */}
-
-
- {/* 계약담당자 필터 */}
-
-
- {/* 공사PM 필터 */}
-
-
- {/* 상태 필터 */}
-
-
- {/* 정렬 */}
-
-
- );
-
return (
<>
(null);
+
+ // 철회 다이얼로그
+ const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
+
+ // 폼 상태
+ const [formData, setFormData] = useState({
+ issueNumber: issue?.issueNumber || '',
+ constructionNumber: issue?.constructionNumber || '',
+ partnerName: issue?.partnerName || '',
+ siteName: issue?.siteName || '',
+ constructionPM: issue?.constructionPM || '',
+ constructionManagers: issue?.constructionManagers || '',
+ reporter: issue?.reporter || '',
+ assignee: issue?.assignee || '',
+ reportDate: issue?.reportDate || new Date().toISOString().split('T')[0],
+ resolvedDate: issue?.resolvedDate || '',
+ status: issue?.status || 'received',
+ category: issue?.category || 'material',
+ priority: issue?.priority || 'normal',
+ title: issue?.title || '',
+ content: issue?.content || '',
+ images: issue?.images || [],
+ });
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // 시공번호 변경 시 관련 정보 자동 채움
+ useEffect(() => {
+ if (formData.constructionNumber) {
+ const construction = MOCK_CONSTRUCTION_NUMBERS.find(
+ (c) => c.value === formData.constructionNumber
+ );
+ if (construction) {
+ setFormData((prev) => ({
+ ...prev,
+ partnerName: construction.partnerName,
+ siteName: construction.siteName,
+ constructionPM: construction.pm,
+ constructionManagers: construction.managers,
+ }));
+ }
+ }
+ }, [formData.constructionNumber]);
+
+ // 담당자 지정 시 상태를 처리중으로 자동 변경
+ const handleAssigneeChange = useCallback((value: string) => {
+ setFormData((prev) => ({
+ ...prev,
+ assignee: value,
+ // 담당자가 지정되고 현재 상태가 '접수'이면 '처리중'으로 변경
+ status: value && prev.status === 'received' ? 'in_progress' : prev.status,
+ }));
+ if (value && formData.status === 'received') {
+ toast.info('담당자가 지정되어 상태가 "처리중"으로 변경되었습니다.');
+ }
+ }, [formData.status]);
+
+ // 중요도 변경 시 긴급이면 알림 표시
+ const handlePriorityChange = useCallback((value: string) => {
+ setFormData((prev) => ({ ...prev, priority: value as IssuePriority }));
+ if (value === 'urgent') {
+ toast.warning('긴급 이슈로 설정되었습니다. 공사PM과 대표에게 알림이 발송됩니다.');
+ }
+ }, []);
+
+ // 입력 핸들러
+ const handleInputChange = useCallback(
+ (field: keyof IssueFormData) => (e: React.ChangeEvent) => {
+ setFormData((prev) => ({ ...prev, [field]: e.target.value }));
+ },
+ []
+ );
+
+ const handleSelectChange = useCallback((field: keyof IssueFormData) => (value: string) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ }, []);
+
+ // 수정 버튼 클릭
+ const handleEditClick = useCallback(() => {
+ if (issue?.id) {
+ router.push(`/ko/construction/project/issue-management/${issue.id}/edit`);
+ }
+ }, [router, issue?.id]);
+
+ // 저장
+ const handleSubmit = useCallback(async () => {
+ if (!formData.title.trim()) {
+ toast.error('제목을 입력해주세요.');
+ return;
+ }
+ if (!formData.constructionNumber) {
+ toast.error('시공번호를 선택해주세요.');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ if (isCreateMode) {
+ const result = await createIssue({
+ issueNumber: `ISS-${Date.now()}`,
+ constructionNumber: formData.constructionNumber,
+ partnerName: formData.partnerName,
+ siteName: formData.siteName,
+ constructionPM: formData.constructionPM,
+ constructionManagers: formData.constructionManagers,
+ category: formData.category,
+ title: formData.title,
+ content: formData.content,
+ reporter: formData.reporter,
+ reportDate: formData.reportDate,
+ resolvedDate: formData.resolvedDate || null,
+ assignee: formData.assignee,
+ priority: formData.priority,
+ status: formData.status,
+ images: formData.images,
+ });
+ if (result.success) {
+ toast.success('이슈가 등록되었습니다.');
+ router.push('/ko/construction/project/issue-management');
+ } else {
+ toast.error(result.error || '이슈 등록에 실패했습니다.');
+ }
+ } else {
+ const result = await updateIssue(issue!.id, {
+ constructionNumber: formData.constructionNumber,
+ partnerName: formData.partnerName,
+ siteName: formData.siteName,
+ constructionPM: formData.constructionPM,
+ constructionManagers: formData.constructionManagers,
+ category: formData.category,
+ title: formData.title,
+ content: formData.content,
+ reporter: formData.reporter,
+ reportDate: formData.reportDate,
+ resolvedDate: formData.resolvedDate || null,
+ assignee: formData.assignee,
+ priority: formData.priority,
+ status: formData.status,
+ images: formData.images,
+ });
+ if (result.success) {
+ toast.success('이슈가 수정되었습니다.');
+ router.push('/ko/construction/project/issue-management');
+ } else {
+ toast.error(result.error || '이슈 수정에 실패했습니다.');
+ }
+ }
+ } catch {
+ toast.error('저장에 실패했습니다.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [formData, isCreateMode, issue, router]);
+
+ // 취소
+ const handleCancel = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ // 철회
+ const handleWithdraw = useCallback(async () => {
+ if (!issue?.id) return;
+ try {
+ const result = await withdrawIssue(issue.id);
+ if (result.success) {
+ toast.success('이슈가 철회되었습니다.');
+ router.push('/ko/construction/project/issue-management');
+ } else {
+ toast.error(result.error || '이슈 철회에 실패했습니다.');
+ }
+ } catch {
+ toast.error('이슈 철회에 실패했습니다.');
+ } finally {
+ setWithdrawDialogOpen(false);
+ }
+ }, [issue?.id, router]);
+
+ // 이미지 업로드 핸들러
+ const handleImageUpload = useCallback((e: React.ChangeEvent) => {
+ const files = e.target.files;
+ if (!files || files.length === 0) return;
+
+ const newImages: IssueImage[] = Array.from(files).map((file, index) => ({
+ id: `img-${Date.now()}-${index}`,
+ url: URL.createObjectURL(file),
+ fileName: file.name,
+ uploadedAt: new Date().toISOString(),
+ }));
+
+ setFormData((prev) => ({
+ ...prev,
+ images: [...prev.images, ...newImages],
+ }));
+ toast.success(`${files.length}개의 이미지가 추가되었습니다.`);
+
+ // 입력 초기화
+ if (imageInputRef.current) {
+ imageInputRef.current.value = '';
+ }
+ }, []);
+
+ // 이미지 삭제
+ const handleImageRemove = useCallback((imageId: string) => {
+ setFormData((prev) => ({
+ ...prev,
+ images: prev.images.filter((img) => img.id !== imageId),
+ }));
+ toast.success('이미지가 삭제되었습니다.');
+ }, []);
+
+ // 녹음 버튼 (UI만)
+ const handleRecordClick = useCallback(() => {
+ toast.info('녹음 기능은 준비 중입니다.');
+ }, []);
+
+ // 읽기 전용 여부
+ const isReadOnly = isViewMode;
+
+ return (
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )
+ }
+ />
+
+
+ {/* 이슈 정보 카드 */}
+
+
+ 이슈 정보
+
+
+
+ {/* 이슈번호 */}
+
+
+
+
+
+ {/* 시공번호 */}
+
+
+
+
+
+ {/* 거래처 */}
+
+
+
+
+
+ {/* 현장 */}
+
+
+
+
+
+ {/* 공사PM (자동) */}
+
+
+
+
+
+ {/* 공사담당자 (자동) */}
+
+
+
+
+
+ {/* 보고자 */}
+
+
+
+
+
+ {/* 담당자 */}
+
+
+
+
+
+ {/* 이슈보고일 */}
+
+
+
+
+
+ {/* 이슈해결일 */}
+
+
+
+
+
+ {/* 상태 */}
+
+
+
+
+
+
+
+
+ {/* 이슈 보고 카드 */}
+
+
+ 이슈 보고
+
+
+
+ {/* 구분 & 중요도 */}
+
+ {/* 구분 */}
+
+
+
+
+
+ {/* 중요도 */}
+
+
+
+
+
+
+ {/* 제목 */}
+
+
+
+
+
+ {/* 내용 */}
+
+
+
+ {!isReadOnly && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/* 사진 카드 */}
+
+
+ 사진
+
+
+ {/* 업로드 버튼 */}
+ {!isReadOnly && (
+
+
+
+ )}
+
+ {/* 업로드된 사진 목록 */}
+ {formData.images.length > 0 ? (
+
+ {formData.images.map((image) => (
+
+

+ {!isReadOnly && (
+
+ )}
+
+ {image.fileName}
+
+
+ ))}
+
+ ) : (
+
+ 업로드된 사진이 없습니다.
+
+ )}
+
+
+
+
+ {/* 철회 확인 다이얼로그 */}
+
+
+
+ 이슈 철회
+
+ 이 이슈를 철회하시겠습니까?
+
+ 철회된 이슈는 복구할 수 없습니다.
+
+
+
+ 취소
+
+ 철회
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/business/construction/issue-management/IssueManagementListClient.tsx b/src/components/business/construction/issue-management/IssueManagementListClient.tsx
new file mode 100644
index 00000000..7e1ef745
--- /dev/null
+++ b/src/components/business/construction/issue-management/IssueManagementListClient.tsx
@@ -0,0 +1,679 @@
+'use client';
+
+import { useState, useMemo, useCallback, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { AlertTriangle, Pencil, Plus, Inbox, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { TableCell, TableRow } from '@/components/ui/table';
+import { Checkbox } from '@/components/ui/checkbox';
+import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
+import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
+import { MobileCard } from '@/components/molecules/MobileCard';
+import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { toast } from 'sonner';
+import type {
+ Issue,
+ IssueStats,
+} from './types';
+import {
+ ISSUE_STATUS_OPTIONS,
+ ISSUE_PRIORITY_OPTIONS,
+ ISSUE_CATEGORY_OPTIONS,
+ ISSUE_SORT_OPTIONS,
+ ISSUE_STATUS_STYLES,
+ ISSUE_STATUS_LABELS,
+ ISSUE_PRIORITY_STYLES,
+ ISSUE_PRIORITY_LABELS,
+ ISSUE_CATEGORY_LABELS,
+ MOCK_ISSUE_PARTNERS,
+ MOCK_ISSUE_SITES,
+ MOCK_ISSUE_REPORTERS,
+ MOCK_ISSUE_ASSIGNEES,
+} from './types';
+import {
+ getIssueList,
+ getIssueStats,
+ withdrawIssues,
+} from './actions';
+
+// 테이블 컬럼 정의
+// 체크박스, 번호, 이슈번호, 시공번호, 거래처, 현장, 구분, 제목, 보고자, 이슈보고일, 이슈해결일, 담당자, 중요도, 상태, 작업
+const tableColumns: TableColumn[] = [
+ { key: 'no', label: '번호', className: 'w-[50px] text-center' },
+ { key: 'issueNumber', label: '이슈번호', className: 'w-[120px]' },
+ { key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
+ { key: 'partnerName', label: '거래처', className: 'w-[100px]' },
+ { key: 'siteName', label: '현장', className: 'min-w-[120px]' },
+ { key: 'category', label: '구분', className: 'w-[80px] text-center' },
+ { key: 'title', label: '제목', className: 'min-w-[150px]' },
+ { key: 'reporter', label: '보고자', className: 'w-[80px]' },
+ { key: 'reportDate', label: '이슈보고일', className: 'w-[100px]' },
+ { key: 'resolvedDate', label: '이슈해결일', className: 'w-[100px]' },
+ { key: 'assignee', label: '담당자', className: 'w-[80px]' },
+ { key: 'priority', label: '중요도', className: 'w-[80px] text-center' },
+ { key: 'status', label: '상태', className: 'w-[80px] text-center' },
+ { key: 'actions', label: '작업', className: 'w-[80px] text-center' },
+];
+
+interface IssueManagementListClientProps {
+ initialData?: Issue[];
+ initialStats?: IssueStats;
+}
+
+export default function IssueManagementListClient({
+ initialData = [],
+ initialStats,
+}: IssueManagementListClientProps) {
+ const router = useRouter();
+
+ // 상태
+ const [issues, setIssues] = useState
(initialData);
+ const [stats, setStats] = useState(initialStats || null);
+ const [searchValue, setSearchValue] = useState('');
+ // 다중선택 필터 (빈 배열 = 전체)
+ const [partnerFilters, setPartnerFilters] = useState([]);
+ const [siteFilters, setSiteFilters] = useState([]);
+ const [categoryFilters, setCategoryFilters] = useState([]);
+ const [reporterFilters, setReporterFilters] = useState([]);
+ const [assigneeFilters, setAssigneeFilters] = useState([]);
+ // 단일선택 필터
+ const [priorityFilter, setPriorityFilter] = useState('all');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('latest');
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [selectedItems, setSelectedItems] = useState>(new Set());
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isLoading, setIsLoading] = useState(false);
+ const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
+ const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
+ const itemsPerPage = 20;
+
+ // 데이터 로드
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const [listResult, statsResult] = await Promise.all([
+ getIssueList({
+ size: 1000,
+ startDate: startDate || undefined,
+ endDate: endDate || undefined,
+ }),
+ getIssueStats(),
+ ]);
+
+ if (listResult.success && listResult.data) {
+ setIssues(listResult.data.items);
+ }
+ if (statsResult.success && statsResult.data) {
+ setStats(statsResult.data);
+ }
+ } catch {
+ toast.error('데이터 로드에 실패했습니다.');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [startDate, endDate]);
+
+ // 초기 데이터가 없으면 로드
+ useEffect(() => {
+ if (initialData.length === 0) {
+ loadData();
+ }
+ }, [initialData.length, loadData]);
+
+ // 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
+ const partnerOptions: MultiSelectOption[] = useMemo(() =>
+ MOCK_ISSUE_PARTNERS.map(p => ({ value: p.value, label: p.label })),
+ []);
+ const siteOptions: MultiSelectOption[] = useMemo(() =>
+ MOCK_ISSUE_SITES.map(s => ({ value: s.value, label: s.label })),
+ []);
+ const categoryOptions: MultiSelectOption[] = useMemo(() =>
+ ISSUE_CATEGORY_OPTIONS.filter(c => c.value !== 'all').map(c => ({ value: c.value, label: c.label })),
+ []);
+ const reporterOptions: MultiSelectOption[] = useMemo(() =>
+ MOCK_ISSUE_REPORTERS.map(r => ({ value: r.value, label: r.label })),
+ []);
+ const assigneeOptions: MultiSelectOption[] = useMemo(() =>
+ MOCK_ISSUE_ASSIGNEES.map(a => ({ value: a.value, label: a.label })),
+ []);
+
+ // 필터링된 데이터
+ const filteredIssues = useMemo(() => {
+ return issues.filter((item) => {
+ // 상태 탭 필터
+ if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
+
+ // 상태 필터
+ if (statusFilter !== 'all' && item.status !== statusFilter) return false;
+
+ // 중요도 필터
+ if (priorityFilter !== 'all' && item.priority !== priorityFilter) return false;
+
+ // 거래처 필터 (다중선택)
+ if (partnerFilters.length > 0) {
+ const matchingPartner = MOCK_ISSUE_PARTNERS.find((p) => p.label === item.partnerName);
+ if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
+ return false;
+ }
+ }
+
+ // 현장 필터 (다중선택)
+ if (siteFilters.length > 0) {
+ const matchingSite = MOCK_ISSUE_SITES.find((s) => s.label === item.siteName);
+ if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
+ return false;
+ }
+ }
+
+ // 구분 필터 (다중선택)
+ if (categoryFilters.length > 0) {
+ if (!categoryFilters.includes(item.category)) {
+ return false;
+ }
+ }
+
+ // 보고자 필터 (다중선택)
+ if (reporterFilters.length > 0) {
+ const matchingReporter = MOCK_ISSUE_REPORTERS.find((r) => r.label === item.reporter);
+ if (!matchingReporter || !reporterFilters.includes(matchingReporter.value)) {
+ return false;
+ }
+ }
+
+ // 담당자 필터 (다중선택)
+ if (assigneeFilters.length > 0) {
+ const matchingAssignee = MOCK_ISSUE_ASSIGNEES.find((a) => a.label === item.assignee);
+ if (!matchingAssignee || !assigneeFilters.includes(matchingAssignee.value)) {
+ return false;
+ }
+ }
+
+ // 검색 필터
+ if (searchValue) {
+ const search = searchValue.toLowerCase();
+ return (
+ item.issueNumber.toLowerCase().includes(search) ||
+ item.constructionNumber.toLowerCase().includes(search) ||
+ item.partnerName.toLowerCase().includes(search) ||
+ item.siteName.toLowerCase().includes(search) ||
+ item.title.toLowerCase().includes(search) ||
+ item.reporter.toLowerCase().includes(search) ||
+ item.assignee.toLowerCase().includes(search)
+ );
+ }
+ return true;
+ });
+ }, [issues, activeStatTab, statusFilter, priorityFilter, partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, searchValue]);
+
+ // 정렬
+ const sortedIssues = useMemo(() => {
+ const sorted = [...filteredIssues];
+ switch (sortBy) {
+ case 'latest':
+ sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ break;
+ case 'oldest':
+ sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
+ break;
+ case 'reportDate':
+ sorted.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
+ break;
+ case 'priorityHigh':
+ const priorityOrder: Record = { urgent: 0, normal: 1 };
+ sorted.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
+ break;
+ case 'priorityLow':
+ const priorityOrderLow: Record = { urgent: 1, normal: 0 };
+ sorted.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
+ break;
+ }
+ return sorted;
+ }, [filteredIssues, sortBy]);
+
+ // 페이지네이션
+ const totalPages = Math.ceil(sortedIssues.length / itemsPerPage);
+ const paginatedData = useMemo(() => {
+ const start = (currentPage - 1) * itemsPerPage;
+ return sortedIssues.slice(start, start + itemsPerPage);
+ }, [sortedIssues, currentPage, itemsPerPage]);
+
+ // 핸들러
+ const handleSearchChange = useCallback((value: string) => {
+ setSearchValue(value);
+ setCurrentPage(1);
+ }, []);
+
+ const handleToggleSelection = useCallback((id: string) => {
+ setSelectedItems((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(id)) {
+ newSet.delete(id);
+ } else {
+ newSet.add(id);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const handleToggleSelectAll = useCallback(() => {
+ if (selectedItems.size === paginatedData.length) {
+ setSelectedItems(new Set());
+ } else {
+ setSelectedItems(new Set(paginatedData.map((c) => c.id)));
+ }
+ }, [selectedItems.size, paginatedData]);
+
+ const handleRowClick = useCallback(
+ (item: Issue) => {
+ router.push(`/ko/construction/project/issue-management/${item.id}`);
+ },
+ [router]
+ );
+
+ const handleEdit = useCallback(
+ (e: React.MouseEvent, itemId: string) => {
+ e.stopPropagation();
+ router.push(`/ko/construction/project/issue-management/${itemId}/edit`);
+ },
+ [router]
+ );
+
+ const handleCreateIssue = useCallback(() => {
+ router.push('/ko/construction/project/issue-management/new');
+ }, [router]);
+
+ // 철회 다이얼로그 열기
+ const handleWithdrawClick = useCallback(() => {
+ if (selectedItems.size === 0) {
+ toast.error('철회할 이슈를 선택해주세요.');
+ return;
+ }
+ setWithdrawDialogOpen(true);
+ }, [selectedItems.size]);
+
+ // 철회 실행
+ const handleWithdraw = useCallback(async () => {
+ try {
+ const ids = Array.from(selectedItems);
+ const result = await withdrawIssues(ids);
+ if (result.success) {
+ toast.success(`${ids.length}건의 이슈가 철회되었습니다.`);
+ setSelectedItems(new Set());
+ loadData();
+ } else {
+ toast.error(result.error || '이슈 철회에 실패했습니다.');
+ }
+ } catch {
+ toast.error('이슈 철회에 실패했습니다.');
+ } finally {
+ setWithdrawDialogOpen(false);
+ }
+ }, [selectedItems, loadData]);
+
+ // 날짜 포맷
+ const formatDate = (dateStr: string | null) => {
+ if (!dateStr) return '-';
+ return dateStr.split('T')[0];
+ };
+
+ // 테이블 행 렌더링
+ const renderTableRow = useCallback(
+ (item: Issue, index: number, globalIndex: number) => {
+ const isSelected = selectedItems.has(item.id);
+
+ return (
+ handleRowClick(item)}
+ >
+ e.stopPropagation()}>
+ handleToggleSelection(item.id)}
+ />
+
+ {globalIndex}
+ {item.issueNumber}
+ {item.constructionNumber}
+ {item.partnerName}
+ {item.siteName}
+
+
+ {ISSUE_CATEGORY_LABELS[item.category]}
+
+
+ {item.title}
+ {item.reporter}
+ {formatDate(item.reportDate)}
+ {formatDate(item.resolvedDate)}
+ {item.assignee}
+
+
+ {ISSUE_PRIORITY_LABELS[item.priority]}
+
+
+
+
+ {ISSUE_STATUS_LABELS[item.status]}
+
+
+
+ {isSelected && (
+
+
+
+ )}
+
+
+ );
+ },
+ [selectedItems, handleToggleSelection, handleRowClick, handleEdit]
+ );
+
+ // 모바일 카드 렌더링
+ const renderMobileCard = useCallback(
+ (item: Issue, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
+ return (
+ handleRowClick(item)}
+ details={[
+ { label: '거래처', value: item.partnerName },
+ { label: '현장', value: item.siteName },
+ { label: '보고일', value: formatDate(item.reportDate) },
+ { label: '중요도', value: ISSUE_PRIORITY_LABELS[item.priority] },
+ ]}
+ />
+ );
+ },
+ [handleRowClick]
+ );
+
+ // 헤더 액션 (DateRangeSelector + 이슈 등록 버튼)
+ const headerActions = (
+
+
+ 이슈 등록
+
+ }
+ />
+ );
+
+ // 통계 카드 클릭 핸들러
+ const handleStatClick = useCallback((tab: 'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved') => {
+ setActiveStatTab(tab);
+ setCurrentPage(1);
+ }, []);
+
+ // 통계 카드 데이터
+ const statsCardsData: StatCard[] = [
+ {
+ label: '접수',
+ value: stats?.received ?? 0,
+ icon: Inbox,
+ iconColor: 'text-blue-600',
+ onClick: () => handleStatClick('received'),
+ isActive: activeStatTab === 'received',
+ },
+ {
+ label: '처리중',
+ value: stats?.inProgress ?? 0,
+ icon: Clock,
+ iconColor: 'text-yellow-600',
+ onClick: () => handleStatClick('in_progress'),
+ isActive: activeStatTab === 'in_progress',
+ },
+ {
+ label: '해결완료',
+ value: stats?.resolved ?? 0,
+ icon: CheckCircle,
+ iconColor: 'text-green-600',
+ onClick: () => handleStatClick('resolved'),
+ isActive: activeStatTab === 'resolved',
+ },
+ {
+ label: '미해결',
+ value: stats?.unresolved ?? 0,
+ icon: XCircle,
+ iconColor: 'text-red-600',
+ onClick: () => handleStatClick('unresolved'),
+ isActive: activeStatTab === 'unresolved',
+ },
+ ];
+
+ // ===== filterConfig 기반 통합 필터 시스템 =====
+ const filterConfig: FilterFieldConfig[] = useMemo(() => [
+ {
+ key: 'partner',
+ label: '거래처',
+ type: 'multi',
+ options: partnerOptions.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'site',
+ label: '현장명',
+ type: 'multi',
+ options: siteOptions.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'category',
+ label: '구분',
+ type: 'multi',
+ options: categoryOptions.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'reporter',
+ label: '보고자',
+ type: 'multi',
+ options: reporterOptions.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'assignee',
+ label: '담당자',
+ type: 'multi',
+ options: assigneeOptions.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ },
+ {
+ key: 'priority',
+ label: '중요도',
+ type: 'single',
+ options: ISSUE_PRIORITY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ allOptionLabel: '전체',
+ },
+ {
+ key: 'status',
+ label: '상태',
+ type: 'single',
+ options: ISSUE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ allOptionLabel: '전체',
+ },
+ {
+ key: 'sortBy',
+ label: '정렬',
+ type: 'single',
+ options: ISSUE_SORT_OPTIONS.map(o => ({
+ value: o.value,
+ label: o.label,
+ })),
+ allOptionLabel: '최신순',
+ },
+ ], [partnerOptions, siteOptions, categoryOptions, reporterOptions, assigneeOptions]);
+
+ const filterValues: FilterValues = useMemo(() => ({
+ partner: partnerFilters,
+ site: siteFilters,
+ category: categoryFilters,
+ reporter: reporterFilters,
+ assignee: assigneeFilters,
+ priority: priorityFilter,
+ status: statusFilter,
+ sortBy: sortBy,
+ }), [partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, priorityFilter, statusFilter, sortBy]);
+
+ const handleFilterChange = useCallback((key: string, value: string | string[]) => {
+ switch (key) {
+ case 'partner':
+ setPartnerFilters(value as string[]);
+ break;
+ case 'site':
+ setSiteFilters(value as string[]);
+ break;
+ case 'category':
+ setCategoryFilters(value as string[]);
+ break;
+ case 'reporter':
+ setReporterFilters(value as string[]);
+ break;
+ case 'assignee':
+ setAssigneeFilters(value as string[]);
+ break;
+ case 'priority':
+ setPriorityFilter(value as string);
+ break;
+ case 'status':
+ setStatusFilter(value as string);
+ break;
+ case 'sortBy':
+ setSortBy(value as string);
+ break;
+ }
+ setCurrentPage(1);
+ }, []);
+
+ const handleFilterReset = useCallback(() => {
+ setPartnerFilters([]);
+ setSiteFilters([]);
+ setCategoryFilters([]);
+ setReporterFilters([]);
+ setAssigneeFilters([]);
+ setPriorityFilter('all');
+ setStatusFilter('all');
+ setSortBy('latest');
+ setCurrentPage(1);
+ }, []);
+
+ // 철회 버튼 (bulkActions용)
+ const bulkActions = selectedItems.size > 0 ? (
+
+ ) : null;
+
+ return (
+ <>
+ item.id}
+ renderTableRow={renderTableRow}
+ renderMobileCard={renderMobileCard}
+ selectedItems={selectedItems}
+ onToggleSelection={handleToggleSelection}
+ onToggleSelectAll={handleToggleSelectAll}
+ pagination={{
+ currentPage,
+ totalPages,
+ totalItems: sortedIssues.length,
+ itemsPerPage,
+ onPageChange: setCurrentPage,
+ }}
+ />
+
+ {/* 철회 확인 다이얼로그 */}
+
+
+
+ 이슈 철회
+
+ 선택한 {selectedItems.size}건의 이슈를 철회하시겠습니까?
+
+ 철회된 이슈는 복구할 수 없습니다.
+
+
+
+ 취소
+
+ 철회
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/business/construction/issue-management/actions.ts b/src/components/business/construction/issue-management/actions.ts
new file mode 100644
index 00000000..d3fc539e
--- /dev/null
+++ b/src/components/business/construction/issue-management/actions.ts
@@ -0,0 +1,417 @@
+'use server';
+
+import type {
+ Issue,
+ IssueStats,
+ IssueFilter,
+ IssueListResponse,
+} from './types';
+
+/**
+ * 이슈관리 Server Actions
+ */
+
+// Mock 이슈 데이터
+const mockIssues: Issue[] = [
+ {
+ id: '1',
+ issueNumber: 'ISS-2025-001',
+ constructionNumber: 'CON-001',
+ partnerName: '대한건설',
+ siteName: '서울 강남 현장',
+ constructionPM: '홍길동',
+ constructionManagers: '홍길동, 김철수, 이영희',
+ category: 'material',
+ title: '자재 품질 불량',
+ content: '납품된 철근 일부에 녹이 발생하여 품질 검수가 필요합니다.',
+ reporter: '홍길동',
+ reportDate: '2025-09-01',
+ resolvedDate: '2025-09-03',
+ assignee: '김과장',
+ priority: 'urgent',
+ status: 'resolved',
+ description: '납품된 철근 일부에 녹이 발생',
+ createdAt: '2025-09-01T09:00:00Z',
+ updatedAt: '2025-09-03T15:00:00Z',
+ },
+ {
+ id: '2',
+ issueNumber: 'ISS-2025-002',
+ constructionNumber: 'CON-002',
+ partnerName: '삼성시공',
+ siteName: '부산 해운대 현장',
+ constructionPM: '김철수',
+ constructionManagers: '김철수, 박민수',
+ category: 'safety',
+ title: '안전장비 미착용',
+ content: '현장 작업자 안전모 미착용 발견되어 시정 조치가 필요합니다.',
+ reporter: '김철수',
+ reportDate: '2025-09-02',
+ resolvedDate: null,
+ assignee: '이부장',
+ priority: 'urgent',
+ status: 'in_progress',
+ description: '현장 작업자 안전모 미착용 발견',
+ createdAt: '2025-09-02T10:00:00Z',
+ updatedAt: '2025-09-02T10:00:00Z',
+ },
+ {
+ id: '3',
+ issueNumber: 'ISS-2025-003',
+ constructionNumber: 'CON-001',
+ partnerName: '대한건설',
+ siteName: '서울 강남 현장',
+ constructionPM: '홍길동',
+ constructionManagers: '홍길동, 김철수, 이영희',
+ category: 'process',
+ title: '공정 지연',
+ content: '우천으로 인한 외부 공사가 지연되고 있습니다.',
+ reporter: '이영희',
+ reportDate: '2025-09-03',
+ resolvedDate: null,
+ assignee: '박대리',
+ priority: 'normal',
+ status: 'received',
+ description: '우천으로 인한 외부 공사 지연',
+ createdAt: '2025-09-03T08:00:00Z',
+ updatedAt: '2025-09-03T08:00:00Z',
+ },
+ {
+ id: '4',
+ issueNumber: 'ISS-2025-004',
+ constructionNumber: 'CON-003',
+ partnerName: 'LG건설',
+ siteName: '대전 유성 현장',
+ constructionPM: '이영희',
+ constructionManagers: '이영희, 최대리',
+ category: 'etc',
+ title: '예산 초과 우려',
+ content: '자재비 상승으로 인한 예산 초과가 예상됩니다.',
+ reporter: '박민수',
+ reportDate: '2025-09-01',
+ resolvedDate: null,
+ assignee: '정차장',
+ priority: 'normal',
+ status: 'unresolved',
+ description: '자재비 상승으로 인한 예산 초과 예상',
+ createdAt: '2025-09-01T11:00:00Z',
+ updatedAt: '2025-09-01T11:00:00Z',
+ },
+ {
+ id: '5',
+ issueNumber: 'ISS-2025-005',
+ constructionNumber: 'CON-004',
+ partnerName: '현대건설',
+ siteName: '인천 송도 현장',
+ constructionPM: '박민수',
+ constructionManagers: '박민수, 홍길동',
+ category: 'etc',
+ title: '민원 발생',
+ content: '인근 주민으로부터 소음 민원이 접수되었습니다.',
+ reporter: '최대리',
+ reportDate: '2025-09-02',
+ resolvedDate: '2025-09-02',
+ assignee: '송이사',
+ priority: 'normal',
+ status: 'resolved',
+ description: '소음 민원 접수',
+ createdAt: '2025-09-02T14:00:00Z',
+ updatedAt: '2025-09-02T18:00:00Z',
+ },
+ {
+ id: '6',
+ issueNumber: 'ISS-2025-006',
+ constructionNumber: 'CON-002',
+ partnerName: '삼성시공',
+ siteName: '부산 해운대 현장',
+ constructionPM: '김철수',
+ constructionManagers: '김철수, 박민수',
+ category: 'material',
+ title: '시공 품질 미달',
+ content: '콘크리트 타설 품질이 기준에 미달합니다.',
+ reporter: '홍길동',
+ reportDate: '2025-09-03',
+ resolvedDate: null,
+ assignee: '김과장',
+ priority: 'urgent',
+ status: 'received',
+ description: '콘크리트 타설 품질 기준 미달',
+ createdAt: '2025-09-03T09:30:00Z',
+ updatedAt: '2025-09-03T09:30:00Z',
+ },
+ {
+ id: '7',
+ issueNumber: 'ISS-2025-007',
+ constructionNumber: 'CON-005',
+ partnerName: 'SK건설',
+ siteName: '광주 북구 현장',
+ constructionPM: '최대리',
+ constructionManagers: '최대리, 김철수, 이영희',
+ category: 'safety',
+ title: '장비 점검 필요',
+ content: '크레인 정기 점검 시기가 도래하여 점검이 필요합니다.',
+ reporter: '김철수',
+ reportDate: '2025-09-01',
+ resolvedDate: null,
+ assignee: '이부장',
+ priority: 'normal',
+ status: 'in_progress',
+ description: '크레인 정기 점검 시기 도래',
+ createdAt: '2025-09-01T13:00:00Z',
+ updatedAt: '2025-09-02T10:00:00Z',
+ },
+ {
+ id: '8',
+ issueNumber: 'ISS-2025-008',
+ constructionNumber: 'CON-001',
+ partnerName: '대한건설',
+ siteName: '서울 강남 현장',
+ constructionPM: '홍길동',
+ constructionManagers: '홍길동, 김철수, 이영희',
+ category: 'process',
+ title: '인력 부족',
+ content: '숙련공 부족으로 공사 진행에 어려움이 있습니다.',
+ reporter: '이영희',
+ reportDate: '2025-09-02',
+ resolvedDate: null,
+ assignee: '박대리',
+ priority: 'urgent',
+ status: 'in_progress',
+ description: '숙련공 부족으로 공사 진행 어려움',
+ createdAt: '2025-09-02T08:30:00Z',
+ updatedAt: '2025-09-03T09:00:00Z',
+ },
+ // 추가 더미 데이터
+ ...Array.from({ length: 47 }, (_, i) => ({
+ id: `${i + 9}`,
+ issueNumber: `ISS-2025-${String(i + 9).padStart(3, '0')}`,
+ constructionNumber: `CON-${String((i % 5) + 1).padStart(3, '0')}`,
+ partnerName: ['대한건설', '삼성시공', 'LG건설', '현대건설', 'SK건설'][i % 5],
+ siteName: ['서울 강남 현장', '부산 해운대 현장', '대전 유성 현장', '인천 송도 현장', '광주 북구 현장'][i % 5],
+ constructionPM: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
+ constructionManagers: ['홍길동, 김철수', '김철수, 박민수', '이영희, 최대리', '박민수, 홍길동', '최대리, 김철수'][i % 5],
+ category: (['material', 'drawing', 'process', 'safety', 'etc'] as const)[i % 5],
+ title: `이슈 ${i + 9}`,
+ content: `이슈 ${i + 9}에 대한 상세 내용입니다.`,
+ reporter: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
+ reportDate: `2025-09-${String((i % 28) + 1).padStart(2, '0')}`,
+ resolvedDate: i % 3 === 0 ? `2025-09-${String(Math.min((i % 28) + 3, 30)).padStart(2, '0')}` : null,
+ assignee: ['김과장', '이부장', '박대리', '정차장', '송이사'][i % 5],
+ priority: (['urgent', 'normal'] as const)[i % 2],
+ status: (['received', 'in_progress', 'resolved', 'unresolved'] as const)[i % 4],
+ description: `이슈 설명 ${i + 9}`,
+ createdAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
+ updatedAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
+ })),
+];
+
+// 이슈 목록 조회
+export async function getIssueList(
+ filter?: IssueFilter
+): Promise<{ success: boolean; data?: IssueListResponse; error?: string }> {
+ try {
+ let filtered = [...mockIssues];
+
+ // 거래처 필터 (다중선택)
+ if (filter?.partners && filter.partners.length > 0) {
+ filtered = filtered.filter((issue) =>
+ filter.partners!.some((p) => issue.partnerName.includes(p) || p.includes(issue.partnerName))
+ );
+ }
+
+ // 현장 필터 (다중선택)
+ if (filter?.sites && filter.sites.length > 0) {
+ filtered = filtered.filter((issue) =>
+ filter.sites!.some((s) => issue.siteName.includes(s) || s.includes(issue.siteName))
+ );
+ }
+
+ // 구분 필터 (다중선택)
+ if (filter?.categories && filter.categories.length > 0) {
+ filtered = filtered.filter((issue) =>
+ filter.categories!.includes(issue.category)
+ );
+ }
+
+ // 보고자 필터 (다중선택)
+ if (filter?.reporters && filter.reporters.length > 0) {
+ filtered = filtered.filter((issue) =>
+ filter.reporters!.some((r) => issue.reporter.includes(r) || r.includes(issue.reporter))
+ );
+ }
+
+ // 담당자 필터 (다중선택)
+ if (filter?.assignees && filter.assignees.length > 0) {
+ filtered = filtered.filter((issue) =>
+ filter.assignees!.some((a) => issue.assignee.includes(a) || a.includes(issue.assignee))
+ );
+ }
+
+ // 중요도 필터 (단일선택)
+ if (filter?.priority && filter.priority !== 'all') {
+ filtered = filtered.filter((issue) => issue.priority === filter.priority);
+ }
+
+ // 상태 필터 (단일선택)
+ if (filter?.status && filter.status !== 'all') {
+ filtered = filtered.filter((issue) => issue.status === filter.status);
+ }
+
+ // 날짜 필터
+ if (filter?.startDate) {
+ filtered = filtered.filter((issue) => issue.reportDate >= filter.startDate!);
+ }
+ if (filter?.endDate) {
+ filtered = filtered.filter((issue) => issue.reportDate <= filter.endDate!);
+ }
+
+ // 정렬
+ if (filter?.sortBy) {
+ switch (filter.sortBy) {
+ case 'latest':
+ filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ break;
+ case 'oldest':
+ filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
+ break;
+ case 'reportDate':
+ filtered.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
+ break;
+ case 'priorityHigh':
+ const priorityOrder: Record = { urgent: 0, normal: 1 };
+ filtered.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
+ break;
+ case 'priorityLow':
+ const priorityOrderLow: Record = { urgent: 1, normal: 0 };
+ filtered.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
+ break;
+ }
+ }
+
+ const page = filter?.page ?? 1;
+ const size = filter?.size ?? 20;
+ const start = (page - 1) * size;
+ const paginatedItems = filtered.slice(start, start + size);
+
+ return {
+ success: true,
+ data: {
+ items: paginatedItems,
+ total: filtered.length,
+ page,
+ size,
+ totalPages: Math.ceil(filtered.length / size),
+ },
+ };
+ } catch (error) {
+ console.error('getIssueList error:', error);
+ return { success: false, error: '이슈 목록 조회에 실패했습니다.' };
+ }
+}
+
+// 이슈 통계 조회
+export async function getIssueStats(): Promise<{
+ success: boolean;
+ data?: IssueStats;
+ error?: string;
+}> {
+ try {
+ const received = mockIssues.filter((i) => i.status === 'received').length;
+ const inProgress = mockIssues.filter((i) => i.status === 'in_progress').length;
+ const resolved = mockIssues.filter((i) => i.status === 'resolved').length;
+ const unresolved = mockIssues.filter((i) => i.status === 'unresolved').length;
+
+ return {
+ success: true,
+ data: {
+ received,
+ inProgress,
+ resolved,
+ unresolved,
+ },
+ };
+ } catch (error) {
+ console.error('getIssueStats error:', error);
+ return { success: false, error: '이슈 통계 조회에 실패했습니다.' };
+ }
+}
+
+// 이슈 상세 조회
+export async function getIssue(
+ id: string
+): Promise<{ success: boolean; data?: Issue; error?: string }> {
+ try {
+ const issue = mockIssues.find((i) => i.id === id);
+
+ if (!issue) {
+ return { success: false, error: '이슈를 찾을 수 없습니다.' };
+ }
+
+ return { success: true, data: issue };
+ } catch (error) {
+ console.error('getIssue error:', error);
+ return { success: false, error: '이슈 조회에 실패했습니다.' };
+ }
+}
+
+// 이슈 수정
+export async function updateIssue(
+ id: string,
+ data: Partial
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ console.log('Update issue:', id, data);
+ // 실제 구현에서는 DB 업데이트
+ return { success: true };
+ } catch (error) {
+ console.error('updateIssue error:', error);
+ return { success: false, error: '이슈 수정에 실패했습니다.' };
+ }
+}
+
+// 이슈 생성
+export async function createIssue(
+ data: Omit
+): Promise<{ success: boolean; data?: Issue; error?: string }> {
+ try {
+ console.log('Create issue:', data);
+ const newIssue: Issue = {
+ ...data,
+ id: `new-${Date.now()}`,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ return { success: true, data: newIssue };
+ } catch (error) {
+ console.error('createIssue error:', error);
+ return { success: false, error: '이슈 등록에 실패했습니다.' };
+ }
+}
+
+// 이슈 철회 (단일)
+export async function withdrawIssue(
+ id: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ console.log('Withdraw issue:', id);
+ // 실제 구현에서는 DB 상태 업데이트 (삭제가 아닌 철회 상태로 변경)
+ return { success: true };
+ } catch (error) {
+ console.error('withdrawIssue error:', error);
+ return { success: false, error: '이슈 철회에 실패했습니다.' };
+ }
+}
+
+// 이슈 철회 (다중)
+export async function withdrawIssues(
+ ids: string[]
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ console.log('Withdraw issues:', ids);
+ // 실제 구현에서는 DB 상태 일괄 업데이트
+ return { success: true };
+ } catch (error) {
+ console.error('withdrawIssues error:', error);
+ return { success: false, error: '이슈 일괄 철회에 실패했습니다.' };
+ }
+}
\ No newline at end of file
diff --git a/src/components/business/construction/issue-management/index.ts b/src/components/business/construction/issue-management/index.ts
new file mode 100644
index 00000000..3d6cf6d8
--- /dev/null
+++ b/src/components/business/construction/issue-management/index.ts
@@ -0,0 +1,4 @@
+export { default as IssueManagementListClient } from './IssueManagementListClient';
+export { default as IssueDetailForm } from './IssueDetailForm';
+export * from './types';
+export * from './actions';
diff --git a/src/components/business/construction/issue-management/types.ts b/src/components/business/construction/issue-management/types.ts
new file mode 100644
index 00000000..c18ca798
--- /dev/null
+++ b/src/components/business/construction/issue-management/types.ts
@@ -0,0 +1,237 @@
+/**
+ * 이슈관리 타입 정의
+ */
+
+// 이슈 상태
+export type IssueStatus = 'received' | 'in_progress' | 'resolved' | 'unresolved';
+
+// 이슈 중요도 (긴급, 일반)
+export type IssuePriority = 'urgent' | 'normal';
+
+// 이슈 구분 (자재, 도면, 공정, 안전, 기타)
+export type IssueCategory = 'material' | 'drawing' | 'process' | 'safety' | 'etc';
+
+// 이슈 이미지
+export interface IssueImage {
+ id: string;
+ url: string;
+ fileName: string;
+ uploadedAt: string;
+}
+
+// 이슈 데이터
+export interface Issue {
+ id: string;
+ issueNumber: string; // 이슈번호
+ constructionNumber: string; // 시공번호
+ partnerName: string; // 거래처
+ siteName: string; // 현장
+ constructionPM?: string; // 공사PM (자동)
+ constructionManagers?: string; // 공사담당자 (자동, 다중)
+ category: IssueCategory; // 구분
+ title: string; // 제목
+ content?: string; // 내용
+ reporter: string; // 보고자
+ reportDate: string; // 이슈보고일
+ resolvedDate: string | null; // 이슈해결일
+ assignee: string; // 담당자
+ priority: IssuePriority; // 중요도
+ status: IssueStatus; // 상태
+ images?: IssueImage[]; // 사진
+ description?: string; // 설명 (레거시)
+ createdAt: string;
+ updatedAt: string;
+}
+
+// 이슈 폼 데이터
+export interface IssueFormData {
+ issueNumber: string;
+ constructionNumber: string;
+ partnerName: string;
+ siteName: string;
+ constructionPM: string;
+ constructionManagers: string;
+ reporter: string;
+ assignee: string;
+ reportDate: string;
+ resolvedDate: string;
+ status: IssueStatus;
+ category: IssueCategory;
+ priority: IssuePriority;
+ title: string;
+ content: string;
+ images: IssueImage[];
+}
+
+// 이슈 통계
+export interface IssueStats {
+ received: number; // 접수
+ inProgress: number; // 처리중
+ resolved: number; // 해결완료
+ unresolved: number; // 미해결
+}
+
+// 이슈 필터
+export interface IssueFilter {
+ partners?: string[]; // 거래처 (다중선택)
+ sites?: string[]; // 현장 (다중선택)
+ categories?: string[]; // 구분 (다중선택)
+ reporters?: string[]; // 보고자 (다중선택)
+ assignees?: string[]; // 담당자 (다중선택)
+ priority?: string; // 중요도 (단일선택)
+ status?: string; // 상태 (단일선택)
+ sortBy?: string; // 정렬
+ startDate?: string;
+ endDate?: string;
+ page?: number;
+ size?: number;
+}
+
+// API 응답
+export interface IssueListResponse {
+ items: Issue[];
+ total: number;
+ page: number;
+ size: number;
+ totalPages: number;
+}
+
+// 상태 옵션
+export const ISSUE_STATUS_OPTIONS = [
+ { value: 'all', label: '전체' },
+ { value: 'received', label: '접수' },
+ { value: 'in_progress', label: '처리중' },
+ { value: 'resolved', label: '해결완료' },
+ { value: 'unresolved', label: '미해결' },
+];
+
+// 상태 라벨
+export const ISSUE_STATUS_LABELS: Record = {
+ received: '접수',
+ in_progress: '처리중',
+ resolved: '해결완료',
+ unresolved: '미해결',
+};
+
+// 상태 스타일
+export const ISSUE_STATUS_STYLES: Record = {
+ received: 'bg-blue-100 text-blue-700',
+ in_progress: 'bg-yellow-100 text-yellow-700',
+ resolved: 'bg-green-100 text-green-700',
+ unresolved: 'bg-red-100 text-red-700',
+};
+
+// 중요도 옵션 (긴급, 일반)
+export const ISSUE_PRIORITY_OPTIONS = [
+ { value: 'all', label: '전체' },
+ { value: 'urgent', label: '긴급' },
+ { value: 'normal', label: '일반' },
+];
+
+// 중요도 라벨
+export const ISSUE_PRIORITY_LABELS: Record = {
+ urgent: '긴급',
+ normal: '일반',
+};
+
+// 중요도 스타일
+export const ISSUE_PRIORITY_STYLES: Record = {
+ urgent: 'bg-red-100 text-red-700',
+ normal: 'bg-gray-100 text-gray-700',
+};
+
+// 구분 옵션 (자재, 도면, 공정, 안전, 기타)
+export const ISSUE_CATEGORY_OPTIONS = [
+ { value: 'all', label: '전체' },
+ { value: 'material', label: '자재' },
+ { value: 'drawing', label: '도면' },
+ { value: 'process', label: '공정' },
+ { value: 'safety', label: '안전' },
+ { value: 'etc', label: '기타' },
+];
+
+// 구분 라벨
+export const ISSUE_CATEGORY_LABELS: Record = {
+ material: '자재',
+ drawing: '도면',
+ process: '공정',
+ safety: '안전',
+ etc: '기타',
+};
+
+// 정렬 옵션
+export const ISSUE_SORT_OPTIONS = [
+ { value: 'latest', label: '최신순' },
+ { value: 'oldest', label: '오래된순' },
+ { value: 'reportDate', label: '보고일순' },
+ { value: 'priorityHigh', label: '중요도 높은순' },
+ { value: 'priorityLow', label: '중요도 낮은순' },
+];
+
+// Mock 거래처 데이터
+export const MOCK_ISSUE_PARTNERS = [
+ { value: 'partner1', label: '대한건설' },
+ { value: 'partner2', label: '삼성시공' },
+ { value: 'partner3', label: 'LG건설' },
+ { value: 'partner4', label: '현대건설' },
+ { value: 'partner5', label: 'SK건설' },
+];
+
+// Mock 현장 데이터
+export const MOCK_ISSUE_SITES = [
+ { value: 'site1', label: '서울 강남 현장' },
+ { value: 'site2', label: '부산 해운대 현장' },
+ { value: 'site3', label: '대전 유성 현장' },
+ { value: 'site4', label: '인천 송도 현장' },
+ { value: 'site5', label: '광주 북구 현장' },
+];
+
+// Mock 보고자 데이터
+export const MOCK_ISSUE_REPORTERS = [
+ { value: 'reporter1', label: '홍길동' },
+ { value: 'reporter2', label: '김철수' },
+ { value: 'reporter3', label: '이영희' },
+ { value: 'reporter4', label: '박민수' },
+ { value: 'reporter5', label: '최대리' },
+];
+
+// Mock 담당자 데이터
+export const MOCK_ISSUE_ASSIGNEES = [
+ { value: 'assignee1', label: '김과장' },
+ { value: 'assignee2', label: '이부장' },
+ { value: 'assignee3', label: '박대리' },
+ { value: 'assignee4', label: '정차장' },
+ { value: 'assignee5', label: '송이사' },
+];
+
+// Mock 시공번호 데이터 (상세 폼용)
+export const MOCK_CONSTRUCTION_NUMBERS = [
+ { value: 'CON-001', label: 'CON-001', partnerName: '대한건설', siteName: '서울 강남 현장', pm: '홍길동', managers: '홍길동, 김철수, 이영희' },
+ { value: 'CON-002', label: 'CON-002', partnerName: '삼성시공', siteName: '부산 해운대 현장', pm: '김철수', managers: '김철수, 박민수' },
+ { value: 'CON-003', label: 'CON-003', partnerName: 'LG건설', siteName: '대전 유성 현장', pm: '이영희', managers: '이영희, 최대리' },
+ { value: 'CON-004', label: 'CON-004', partnerName: '현대건설', siteName: '인천 송도 현장', pm: '박민수', managers: '박민수, 홍길동' },
+ { value: 'CON-005', label: 'CON-005', partnerName: 'SK건설', siteName: '광주 북구 현장', pm: '최대리', managers: '최대리, 김철수, 이영희' },
+];
+
+// 폼용 상태 옵션 (전체 제외)
+export const ISSUE_STATUS_FORM_OPTIONS = [
+ { value: 'received', label: '접수' },
+ { value: 'in_progress', label: '처리중' },
+ { value: 'resolved', label: '해결완료' },
+ { value: 'unresolved', label: '미해결' },
+];
+
+// 폼용 중요도 옵션 (전체 제외)
+export const ISSUE_PRIORITY_FORM_OPTIONS = [
+ { value: 'urgent', label: '긴급' },
+ { value: 'normal', label: '일반' },
+];
+
+// 폼용 구분 옵션 (전체 제외)
+export const ISSUE_CATEGORY_FORM_OPTIONS = [
+ { value: 'material', label: '자재' },
+ { value: 'drawing', label: '도면' },
+ { value: 'process', label: '공정' },
+ { value: 'safety', label: '안전' },
+ { value: 'etc', label: '기타' },
+];
\ No newline at end of file
diff --git a/src/components/business/construction/item-management/ItemDetailClient.tsx b/src/components/business/construction/item-management/ItemDetailClient.tsx
index 072970a1..d4717566 100644
--- a/src/components/business/construction/item-management/ItemDetailClient.tsx
+++ b/src/components/business/construction/item-management/ItemDetailClient.tsx
@@ -512,59 +512,58 @@ export default function ItemDetailClient({
/>