diff --git a/src/components/business/construction/site-briefings/SiteBriefingForm.tsx b/src/components/business/construction/site-briefings/SiteBriefingForm.tsx index 3deaa542..5dd45edd 100644 --- a/src/components/business/construction/site-briefings/SiteBriefingForm.tsx +++ b/src/components/business/construction/site-briefings/SiteBriefingForm.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List } from 'lucide-react'; +import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List, Check, ChevronsUpDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -29,7 +29,7 @@ import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { TimePicker } from '@/components/ui/time-picker'; import { toast } from 'sonner'; -import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument } from './types'; +import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument, AttendeeItem } from './types'; import { BRIEFING_TYPE_OPTIONS, ATTENDANCE_STATUS_OPTIONS, @@ -37,20 +37,28 @@ import { getEmptySiteBriefingFormData, siteBriefingToFormData, } from './types'; - -// 목업 거래처 목록 -const MOCK_PARTNERS = [ - { value: '1', label: '회사명' }, - { value: '2', label: '대한건설' }, - { value: '3', label: '삼성시공' }, -]; - -// 목업 참석자 목록 -const MOCK_ATTENDEES = [ - { value: 'hong', label: '홍길동' }, - { value: 'kim', label: '김철수' }, - { value: 'lee', label: '이영희' }, -]; +import { Badge } from '@/components/ui/badge'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { cn } from '@/lib/utils'; +import { createSiteBriefing, updateSiteBriefing, deleteSiteBriefing } from './actions'; +import { getPartnerList } from '../partners/actions'; +import { getSiteList, createSite } from '../site-management/actions'; +import { getEmployees } from '@/components/hr/EmployeeManagement/actions'; +import type { Partner } from '../partners/types'; +import type { Site } from '../site-management/types'; +import type { Employee } from '@/components/hr/EmployeeManagement/types'; // 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용) const MOCK_DOCUMENTS: BriefingDocument[] = [ @@ -89,10 +97,16 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site const isNewMode = mode === 'new'; const isEditMode = mode === 'edit'; + // DEBUG: 초기 데이터 확인 + console.log('[SiteBriefingForm] initialData:', initialData); + console.log('[SiteBriefingForm] initialData.attendee:', initialData?.attendee); + // 폼 데이터 - const [formData, setFormData] = useState( - initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData() - ); + const [formData, setFormData] = useState(() => { + const data = initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData(); + console.log('[SiteBriefingForm] formData.attendeeItems:', data.attendeeItems); + return data; + }); // 로딩 상태 const [isLoading, setIsLoading] = useState(false); @@ -107,6 +121,63 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site // 드래그 상태 const [isDragging, setIsDragging] = useState(false); + // 거래처 목록 + const [partners, setPartners] = useState([]); + const [isLoadingPartners, setIsLoadingPartners] = useState(false); + + // 현장 목록 (선택된 거래처 기준) + const [sites, setSites] = useState([]); + const [isLoadingSites, setIsLoadingSites] = useState(false); + + // 현장 입력 및 선택 + const [siteInputValue, setSiteInputValue] = useState(formData.projectName); + const [showSiteDropdown, setShowSiteDropdown] = useState(false); + const siteInputRef = useRef(null); + + // 현장 신규 등록 다이얼로그 + const [showNewSiteDialog, setShowNewSiteDialog] = useState(false); + const [newSiteName, setNewSiteName] = useState(''); + const [isCreatingSite, setIsCreatingSite] = useState(false); + + // 직원 목록 (참석자용) + const [employees, setEmployees] = useState([]); + + // 참석자 Multi-Select Combobox 상태 + const [attendeePopoverOpen, setAttendeePopoverOpen] = useState(false); + const [attendeeSearchValue, setAttendeeSearchValue] = useState(''); + + // 필드 변경 핸들러 (참석자 핸들러에서 사용하므로 먼저 선언) + const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }, []); + + // 참석자 선택 핸들러 + const handleAttendeeSelect = useCallback((employee: Employee) => { + const newItem: AttendeeItem = { id: employee.id, name: employee.name }; + const exists = formData.attendeeItems.some((item) => item.id === employee.id); + if (!exists) { + handleChange('attendeeItems', [...formData.attendeeItems, newItem]); + } + setAttendeeSearchValue(''); + }, [formData.attendeeItems, handleChange]); + + // 참석자 제거 핸들러 + const handleAttendeeRemove = useCallback((attendeeId: string) => { + handleChange('attendeeItems', formData.attendeeItems.filter((item) => item.id !== attendeeId && item.name !== attendeeId)); + }, [formData.attendeeItems, handleChange]); + + // 참석자 직접 입력 추가 핸들러 + const handleAttendeeAdd = useCallback(() => { + const trimmed = attendeeSearchValue.trim(); + if (!trimmed) return; + const exists = formData.attendeeItems.some((item) => item.name === trimmed); + if (!exists) { + const newItem: AttendeeItem = { id: '', name: trimmed }; + handleChange('attendeeItems', [...formData.attendeeItems, newItem]); + } + setAttendeeSearchValue(''); + }, [attendeeSearchValue, formData.attendeeItems, handleChange]); + // 상세/수정 모드에서 목데이터 초기화 useEffect(() => { if (initialData && formData.documents.length === 0) { @@ -117,9 +188,66 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site } }, [initialData]); - // 필드 변경 핸들러 - const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => { - setFormData((prev) => ({ ...prev, [field]: value })); + // 거래처 목록 로드 + useEffect(() => { + const loadPartners = async () => { + setIsLoadingPartners(true); + try { + const result = await getPartnerList({ size: 100 }); + if (result.success && result.data) { + setPartners(result.data.items); + } + } catch (error) { + console.error('거래처 목록 로드 실패:', error); + } finally { + setIsLoadingPartners(false); + } + }; + loadPartners(); + }, []); + + // 거래처 선택 시 현장 목록 로드 + useEffect(() => { + const loadSites = async () => { + if (!formData.partnerId) { + setSites([]); + return; + } + setIsLoadingSites(true); + try { + const result = await getSiteList({ clientId: formData.partnerId, size: 100 }); + if (result.success && result.data) { + setSites(result.data.items); + } + } catch (error) { + console.error('현장 목록 로드 실패:', error); + } finally { + setIsLoadingSites(false); + } + }; + loadSites(); + }, [formData.partnerId]); + + // 초기 데이터가 있을 때 siteInputValue 동기화 + useEffect(() => { + if (initialData?.title) { + setSiteInputValue(initialData.title); + } + }, [initialData]); + + // 직원 목록 로드 (참석자용) + useEffect(() => { + const loadEmployees = async () => { + try { + const result = await getEmployees({ status: 'active', per_page: 100 }); + if (result.data && result.data.length > 0) { + setEmployees(result.data); + } + } catch (error) { + console.error('직원 목록 로드 실패:', error); + } + }; + loadEmployees(); }, []); // 네비게이션 핸들러 @@ -151,8 +279,19 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site const handleConfirmSave = useCallback(async () => { setIsLoading(true); try { - // TODO: 실제 API 연동 - await new Promise((resolve) => setTimeout(resolve, 1000)); + let result; + if (isNewMode) { + result = await createSiteBriefing(formData); + } else if (briefingId) { + result = await updateSiteBriefing(briefingId, formData); + } else { + throw new Error('현장설명회 ID가 없습니다.'); + } + + if (!result.success) { + throw new Error(result.error || '저장에 실패했습니다.'); + } + toast.success(isNewMode ? '현장설명회가 등록되었습니다.' : '수정이 완료되었습니다.'); setShowSaveDialog(false); router.push('/ko/construction/project/bidding/site-briefings'); @@ -162,7 +301,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site } finally { setIsLoading(false); } - }, [isNewMode, router]); + }, [isNewMode, briefingId, formData, router]); // 삭제 핸들러 const handleDelete = useCallback(() => { @@ -170,10 +309,19 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site }, []); const handleConfirmDelete = useCallback(async () => { + if (!briefingId) { + toast.error('현장설명회 ID가 없습니다.'); + return; + } + setIsLoading(true); try { - // TODO: 실제 API 연동 - await new Promise((resolve) => setTimeout(resolve, 1000)); + const result = await deleteSiteBriefing(briefingId); + + if (!result.success) { + throw new Error(result.error || '삭제에 실패했습니다.'); + } + toast.success('현장설명회가 삭제되었습니다.'); setShowDeleteDialog(false); router.push('/ko/construction/project/bidding/site-briefings'); @@ -183,7 +331,51 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site } finally { setIsLoading(false); } - }, [router]); + }, [briefingId, router]); + + // 현장 신규 등록 핸들러 + const handleCreateSite = useCallback(async () => { + if (!newSiteName.trim()) { + toast.error('현장명을 입력해주세요.'); + return; + } + if (!formData.partnerId) { + toast.error('거래처를 먼저 선택해주세요.'); + return; + } + + setIsCreatingSite(true); + try { + const result = await createSite({ + siteName: newSiteName.trim(), + partnerId: formData.partnerId, + }); + + if (!result.success) { + throw new Error(result.error || '현장 등록에 실패했습니다.'); + } + + toast.success('현장이 등록되었습니다.'); + + // 현장 목록 새로고침 + const sitesResult = await getSiteList({ clientId: formData.partnerId, size: 100 }); + if (sitesResult.success && sitesResult.data) { + setSites(sitesResult.data.items); + } + + // 새로 등록된 현장을 선택 + setSiteInputValue(newSiteName.trim()); + handleChange('projectName', newSiteName.trim()); + + // 다이얼로그 닫기 및 상태 초기화 + setShowNewSiteDialog(false); + setNewSiteName(''); + } catch (error) { + toast.error(error instanceof Error ? error.message : '현장 등록에 실패했습니다.'); + } finally { + setIsCreatingSite(false); + } + }, [newSiteName, formData.partnerId, handleChange]); // 참여업체 추가 핸들러 const handleAddCompany = useCallback(() => { @@ -451,7 +643,38 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site {renderField('현설번호', 'briefingCode', formData.briefingCode, { placeholder: '123123', })} - {renderSelectField('거래처명', 'partnerId', formData.partnerId, MOCK_PARTNERS, true)} + {/* 거래처명 - 실제 API 데이터 */} +
+ + +
{renderField('현장설명회 일자', 'briefingDate', formData.briefingDate, { type: 'date', required: true, @@ -471,7 +694,112 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site {renderField('현장설명회 장소', 'location', formData.location, { placeholder: '장소명', })} - {renderSelectField('참석자', 'attendee', formData.attendee, MOCK_ATTENDEES)} + {/* 참석자 - Multi-Select Combobox */} +
+ + {isViewMode ? ( +
+ {formData.attendeeItems.length > 0 ? ( + formData.attendeeItems.map((item) => ( + + {item.name} + + )) + ) : ( + 참석자 없음 + )} +
+ ) : ( + + + + + + + { + if (e.key === 'Enter' && attendeeSearchValue.trim()) { + e.preventDefault(); + handleAttendeeAdd(); + } + }} + /> + + + {attendeeSearchValue.trim() ? ( + + ) : ( + '검색 결과가 없습니다.' + )} + + + {employees + .filter((emp) => + emp.name.toLowerCase().includes(attendeeSearchValue.toLowerCase()) + ) + .map((employee) => { + const isSelected = formData.attendeeItems.some( + (item) => item.id === employee.id + ); + return ( + handleAttendeeSelect(employee)} + > + + {employee.name} + + ); + })} + + + + + + )} +
{renderSelectField('상태', 'attendanceStatus', formData.attendanceStatus, ATTENDANCE_STATUS_OPTIONS)} @@ -483,10 +811,87 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
- {renderField('현장명', 'projectName', formData.projectName, { - required: true, - placeholder: '현장명', - })} + {/* 현장명 - 거래처 연동 Combobox */} +
+ +
+ { + setSiteInputValue(e.target.value); + handleChange('projectName', e.target.value); + setShowSiteDropdown(true); + }} + onFocus={() => formData.partnerId && setShowSiteDropdown(true)} + onBlur={() => setTimeout(() => setShowSiteDropdown(false), 200)} + placeholder={ + !formData.partnerId + ? '거래처를 먼저 선택해주세요' + : isLoadingSites + ? '현장 목록 로딩 중...' + : '현장명 입력 또는 선택' + } + disabled={isViewMode || !formData.partnerId} + className="bg-white pr-10" + /> + {!isViewMode && formData.partnerId && ( + + )} + {/* 현장 드롭다운 */} + {showSiteDropdown && sites.length > 0 && ( +
+ {sites + .filter((site) => + site.siteName.toLowerCase().includes(siteInputValue.toLowerCase()) + ) + .map((site) => ( + + ))} + {sites.filter((site) => + site.siteName.toLowerCase().includes(siteInputValue.toLowerCase()) + ).length === 0 && ( +
+ 일치하는 현장이 없습니다. 신규 등록하려면 + 버튼을 클릭하세요. +
+ )} +
+ )} +
+ {!formData.partnerId && !isViewMode && ( +

거래처를 먼저 선택하면 해당 거래처의 현장 목록이 표시됩니다.

+ )} +
{renderField('입찰일자', 'bidDate', formData.bidDate, { type: 'date', })} @@ -736,6 +1141,53 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site + + {/* 현장 신규 등록 다이얼로그 */} + + + + 현장 신규 등록 + + 선택한 거래처에 새로운 현장을 등록합니다. +
+ 등록된 현장은 현장관리 목록에도 추가됩니다. +
+
+
+
+ + p.id === formData.partnerId)?.partnerName || ''} + disabled + className="bg-gray-50" + /> +
+
+ + setNewSiteName(e.target.value)} + placeholder="현장명을 입력하세요" + disabled={isCreatingSite} + autoFocus + /> +
+
+ + 취소 + + {isCreatingSite && } + 등록 + + +
+
); } \ No newline at end of file diff --git a/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx b/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx index 99efcf9e..76722a68 100644 --- a/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx +++ b/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx @@ -137,12 +137,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin if (listResult.success && listResult.data) { setBriefings(listResult.data.items); - // 목업 통계 계산 (참석 상태 기준) + // 통계 계산 (참석 상태 기준) const items = listResult.data.items; const total = items.length; - // 목업: scheduled 상태는 참석예정, 나머지는 참석완료로 처리 - const scheduled = items.filter((b) => b.status === 'scheduled').length; - const attended = items.filter((b) => b.status !== 'scheduled').length; + const scheduled = items.filter((b) => b.attendanceStatus === 'scheduled').length; + const attended = items.filter((b) => b.attendanceStatus === 'attended').length; setStatsData({ total, scheduled, attended }); } } catch { @@ -162,9 +161,9 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin // 필터링된 데이터 const filteredBriefings = useMemo(() => { return briefings.filter((briefing) => { - // Stats 탭 필터 - if (activeStatTab === 'scheduled' && briefing.status !== 'scheduled') return false; - if (activeStatTab === 'attended' && briefing.status === 'scheduled') return false; + // Stats 탭 필터 (참석 상태 기준) + if (activeStatTab === 'scheduled' && briefing.attendanceStatus !== 'scheduled') return false; + if (activeStatTab === 'attended' && briefing.attendanceStatus !== 'attended') return false; // 거래처 필터 (다중선택 - 빈 배열 = 전체) if (partnerFilters.length > 0) { @@ -181,8 +180,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin if (!attendeeFilters.includes(attendeeId)) return false; } - // 상태 필터 - if (statusFilter !== 'all' && briefing.status !== statusFilter) return false; + // 상태 필터 (참석 상태 기준) + if (statusFilter !== 'all' && briefing.attendanceStatus !== statusFilter) return false; // 검색 필터 if (searchValue) { @@ -348,8 +347,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin const renderTableRow = useCallback( (briefing: SiteBriefing, index: number, globalIndex: number) => { const isSelected = selectedItems.has(briefing.id); - // 목업 데이터에서 상태 매핑 - const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended'; + // 참석 상태 표시 + const displayStatus = briefing.attendanceStatus || 'scheduled'; return ( void) => { - const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended'; + const displayStatus = briefing.attendanceStatus || 'scheduled'; return ( | null; // 백엔드는 attendees (복수형), array 타입 + attendance_status: string | null; + project_name: string | null; + site_count: number | null; + construction_start_date: string | null; + construction_end_date: string | null; + vat_type: string | null; + work_report: string | null; + attendee_count: number | null; + created_at: string; + updated_at: string; + created_by: string | null; +} + +interface ApiSiteBriefingStats { + total: number; + scheduled: number; + ongoing: number; + completed: number; + cancelled: number; +} + +// ======================================== +// 타입 변환 함수 +// ======================================== + +/** + * API 응답 → SiteBriefing 타입 변환 + */ +function transformSiteBriefing(apiData: ApiSiteBriefing): SiteBriefing { + // attendees를 JSON 문자열로 변환 (types.ts의 parseAttendeeItems에서 파싱) + const attendeeJson = apiData.attendees ? JSON.stringify(apiData.attendees) : ''; + + return { + id: String(apiData.id), + briefingCode: apiData.briefing_code || '', + title: apiData.title || apiData.project_name || '', + description: apiData.description || '', + partnerId: apiData.partner_id ? String(apiData.partner_id) : '', + partnerName: apiData.partner_name || '', + briefingDate: apiData.briefing_date || '', + briefingTime: apiData.briefing_time || '', + location: apiData.location || '', + address: apiData.address || '', + status: (apiData.status as SiteBriefing['status']) || 'scheduled', + bidStatus: (apiData.bid_status as SiteBriefing['bidStatus']) || 'pending', + bidDate: apiData.bid_date, + attendee: attendeeJson, // JSON 문자열로 저장 (parseAttendeeItems에서 파싱) attendees: [], - attendeeCount: 5, - createdAt: '2025-01-01', - updatedAt: '2025-01-01', - createdBy: '홍길동', - }, - { - id: '2', - briefingCode: 'SB-002', - title: '서초 아파트 리모델링', - description: '서초구 반포동 아파트 리모델링 현장설명회', - partnerId: '2', - partnerName: '삼성시공', - briefingDate: '2025-05-12', - briefingTime: '10:00', - location: '서초구청 소회의실', - address: '서울특별시 서초구 남부순환로 2584', - status: 'ongoing', - bidStatus: 'bidding', - bidDate: '2025-05-18', - attendees: [], - attendeeCount: 8, - createdAt: '2025-01-02', - updatedAt: '2025-01-02', - createdBy: '김철수', - }, - { - id: '3', - briefingCode: 'SB-003', - title: '여의도 상업시설 신축', - description: '영등포구 여의도동 상업시설 신축 현장설명회', - partnerId: '3', - partnerName: 'LG건설', - briefingDate: '2025-05-13', - briefingTime: '15:00', - location: 'LG트윈타워 회의실', - address: '서울특별시 영등포구 여의대로 128', - status: 'completed', - bidStatus: 'awarded', - bidDate: '2025-05-20', - attendees: [], - attendeeCount: 12, - createdAt: '2025-01-03', - updatedAt: '2025-01-03', - createdBy: '박영수', - }, - { - id: '4', - briefingCode: 'SB-004', - title: '송파 주상복합 공사', - description: '송파구 잠실동 주상복합 건축 현장설명회', - partnerId: '1', - partnerName: '대한건설', - briefingDate: '2025-05-14', - briefingTime: '11:00', - location: '롯데월드타워 회의실', - address: '서울특별시 송파구 올림픽로 300', - status: 'cancelled', - bidStatus: 'failed', - bidDate: null, - attendees: [], - attendeeCount: 0, - createdAt: '2025-01-04', - updatedAt: '2025-01-04', - createdBy: '최민수', - }, - { - id: '5', - briefingCode: 'SB-005', - title: '마포 물류센터 증축', - description: '마포구 상암동 물류센터 증축 현장설명회', - partnerId: '2', - partnerName: '삼성시공', - briefingDate: '2025-05-15', - briefingTime: '09:00', - location: '상암 DMC 회의실', - address: '서울특별시 마포구 상암산로 76', - status: 'postponed', - bidStatus: 'pending', - bidDate: null, - attendees: [], - attendeeCount: 3, - createdAt: '2025-01-05', - updatedAt: '2025-01-05', - createdBy: '이영희', - }, -]; + attendeeCount: apiData.attendee_count || 0, + attendanceStatus: (apiData.attendance_status as SiteBriefing['attendanceStatus']) || 'scheduled', + createdAt: apiData.created_at || '', + updatedAt: apiData.updated_at || '', + createdBy: apiData.created_by || '', + }; +} + +/** + * SiteBriefingFormData → API 요청 데이터 변환 + */ +function transformFormDataToApi(data: SiteBriefingFormData): Record { + // attendeeItems 배열을 백엔드 형식으로 변환 + // - id가 있으면 internal (직원), 없으면 external (외부인/직접입력) + const attendees = data.attendeeItems && data.attendeeItems.length > 0 + ? data.attendeeItems.map(item => ({ + ...item, + type: item.id ? 'internal' : 'external', + })) + : null; + + return { + briefing_code: data.briefingCode, + title: data.projectName, + description: data.workReport, + partner_id: data.partnerId ? Number(data.partnerId) : null, + partner_name: data.partnerName, + briefing_date: data.briefingDate, + briefing_time: data.briefingTime, + briefing_type: data.briefingType, + location: data.location, + attendees: attendees, // 백엔드 필드명: attendees (복수형, array) + attendance_status: data.attendanceStatus, + project_name: data.projectName, + bid_date: data.bidDate, + site_count: data.siteCount, + construction_start_date: data.constructionStartDate, + construction_end_date: data.constructionEndDate, + vat_type: data.vatType, + work_report: data.workReport, + }; +} // 현장설명회 목록 조회 export async function getSiteBriefingList( filter?: SiteBriefingFilter ): Promise<{ success: boolean; data?: SiteBriefingListResponse; error?: string }> { try { - let filtered = [...mockSiteBriefings]; + // API 쿼리 파라미터 구성 (모든 값을 문자열로 변환) + const params: Record = { + per_page: String(filter?.size ?? 20), + page: String(filter?.page ?? 1), + }; - // 검색 필터 if (filter?.search) { - const search = filter.search.toLowerCase(); - filtered = filtered.filter( - (b) => - b.title.toLowerCase().includes(search) || - b.briefingCode.toLowerCase().includes(search) || - b.partnerName.toLowerCase().includes(search) - ); + params.search = filter.search; } - - // 상태 필터 if (filter?.status && filter.status !== 'all') { - filtered = filtered.filter((b) => b.status === filter.status); + params.status = filter.status; } - - // 입찰 상태 필터 if (filter?.bidStatus && filter.bidStatus !== 'all') { - filtered = filtered.filter((b) => b.bidStatus === filter.bidStatus); + params.bid_status = filter.bidStatus; } - - // 거래처 필터 if (filter?.partnerId) { - filtered = filtered.filter((b) => b.partnerId === filter.partnerId); + params.partner_id = filter.partnerId; } - - // 날짜 필터 if (filter?.startDate) { - filtered = filtered.filter((b) => b.briefingDate >= filter.startDate!); + params.start_date = filter.startDate; } if (filter?.endDate) { - filtered = filtered.filter((b) => b.briefingDate <= filter.endDate!); + params.end_date = 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 'dateAsc': - filtered.sort((a, b) => new Date(a.briefingDate).getTime() - new Date(b.briefingDate).getTime()); - break; - case 'dateDesc': - filtered.sort((a, b) => new Date(b.briefingDate).getTime() - new Date(a.briefingDate).getTime()); - break; + const sortMapping: Record = { + latest: { sort_by: 'created_at', sort_dir: 'desc' }, + oldest: { sort_by: 'created_at', sort_dir: 'asc' }, + dateAsc: { sort_by: 'briefing_date', sort_dir: 'asc' }, + dateDesc: { sort_by: 'briefing_date', sort_dir: 'desc' }, + }; + const sort = sortMapping[filter.sortBy]; + if (sort) { + params.sort_by = sort.sort_by; + params.sort_dir = sort.sort_dir; } } - const page = filter?.page ?? 1; - const size = filter?.size ?? 20; - const start = (page - 1) * size; - const paginatedItems = filtered.slice(start, start + size); + const response = await apiClient.get<{ + success: boolean; + data: { + data: ApiSiteBriefing[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; + }>('/site-briefings', { params }); + + const apiData = response.data; + const items = apiData.data.map(transformSiteBriefing); return { success: true, data: { - items: paginatedItems, - total: filtered.length, - page, - size, - totalPages: Math.ceil(filtered.length / size), + items, + total: apiData.total, + page: apiData.current_page, + size: apiData.per_page, + totalPages: apiData.last_page, }, }; } catch (error) { @@ -196,13 +197,12 @@ export async function getSiteBriefing( id: string ): Promise<{ success: boolean; data?: SiteBriefing; error?: string }> { try { - const briefing = mockSiteBriefings.find((b) => b.id === id); + const response = await apiClient.get<{ + success: boolean; + data: ApiSiteBriefing; + }>(`/site-briefings/${id}`); - if (!briefing) { - return { success: false, error: '현장설명회를 찾을 수 없습니다.' }; - } - - return { success: true, data: briefing }; + return { success: true, data: transformSiteBriefing(response.data) }; } catch (error) { console.error('getSiteBriefing error:', error); return { success: false, error: '현장설명회 조회에 실패했습니다.' }; @@ -212,46 +212,86 @@ export async function getSiteBriefing( // 현장설명회 통계 조회 export async function getSiteBriefingStats(): Promise<{ success: boolean; data?: SiteBriefingStats; error?: string }> { try { - const total = mockSiteBriefings.length; - const scheduled = mockSiteBriefings.filter((b) => b.status === 'scheduled').length; - const ongoing = mockSiteBriefings.filter((b) => b.status === 'ongoing').length; - const completed = mockSiteBriefings.filter((b) => b.status === 'completed').length; - const cancelled = mockSiteBriefings.filter((b) => b.status === 'cancelled' || b.status === 'postponed').length; + const response = await apiClient.get<{ + success: boolean; + data: ApiSiteBriefingStats; + }>('/site-briefings/stats'); - return { - success: true, - data: { - total, - scheduled, - ongoing, - completed, - cancelled, - }, - }; + return { success: true, data: response.data }; } catch (error) { console.error('getSiteBriefingStats error:', error); return { success: false, error: '통계 조회에 실패했습니다.' }; } } -// 현장설명회 삭제 +// ======================================== +// API 함수 (CRUD) +// ======================================== + +/** + * 현장설명회 등록 + * POST /api/v1/site-briefings + */ +export async function createSiteBriefing(data: SiteBriefingFormData): Promise<{ + success: boolean; + data?: SiteBriefing; + error?: string; +}> { + try { + const apiData = transformFormDataToApi(data); + const response = await apiClient.post<{ success: boolean; data: ApiSiteBriefing }>('/site-briefings', apiData); + return { success: true, data: transformSiteBriefing(response.data) }; + } catch (error) { + console.error('현장설명회 등록 오류:', error); + return { success: false, error: '현장설명회 등록에 실패했습니다.' }; + } +} + +/** + * 현장설명회 수정 + * PUT /api/v1/site-briefings/{id} + */ +export async function updateSiteBriefing(id: string, data: SiteBriefingFormData): Promise<{ + success: boolean; + data?: SiteBriefing; + error?: string; +}> { + try { + const apiData = transformFormDataToApi(data); + const response = await apiClient.put<{ success: boolean; data: ApiSiteBriefing }>(`/site-briefings/${id}`, apiData); + return { success: true, data: transformSiteBriefing(response.data) }; + } catch (error) { + console.error('현장설명회 수정 오류:', error); + return { success: false, error: '현장설명회 수정에 실패했습니다.' }; + } +} + +/** + * 현장설명회 삭제 + * DELETE /api/v1/site-briefings/{id} + */ export async function deleteSiteBriefing(id: string): Promise<{ success: boolean; error?: string }> { try { - console.log('Delete site briefing:', id); + await apiClient.delete(`/site-briefings/${id}`); return { success: true }; } catch (error) { - console.error('deleteSiteBriefing error:', error); + console.error('현장설명회 삭제 오류:', error); return { success: false, error: '현장설명회 삭제에 실패했습니다.' }; } } -// 현장설명회 일괄 삭제 +/** + * 현장설명회 일괄 삭제 + * DELETE /api/v1/site-briefings/bulk + */ export async function deleteSiteBriefings(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> { try { - console.log('Delete site briefings:', ids); + await apiClient.delete('/site-briefings/bulk', { + data: { ids: ids.map((id) => Number(id)) }, + }); return { success: true, deletedCount: ids.length }; } catch (error) { - console.error('deleteSiteBriefings error:', error); + console.error('현장설명회 일괄 삭제 오류:', error); return { success: false, error: '일괄 삭제에 실패했습니다.' }; } } diff --git a/src/components/business/construction/site-briefings/types.ts b/src/components/business/construction/site-briefings/types.ts index af9beebd..ed7dbee3 100644 --- a/src/components/business/construction/site-briefings/types.ts +++ b/src/components/business/construction/site-briefings/types.ts @@ -8,7 +8,7 @@ export type SiteBriefingStatus = 'scheduled' | 'ongoing' | 'completed' | 'cancel // 입찰 상태 export type BidStatus = 'pending' | 'bidding' | 'closed' | 'failed' | 'awarded'; -// 참석자 타입 +// 참석자 타입 (외부 참석자 - 상세 정보) export interface Attendee { id: string; name: string; @@ -18,6 +18,12 @@ export interface Attendee { isAttended: boolean; } +// 참석자 항목 타입 (내부 직원 또는 직접 입력) +export interface AttendeeItem { + id: string; // 직원 ID (직접 입력 시 빈 문자열) + name: string; // 이름 +} + // 현장설명회 타입 export interface SiteBriefing { id: string; @@ -41,8 +47,10 @@ export interface SiteBriefing { bidDate: string | null; // 입찰 날짜 // 참석자 정보 + attendee: string; // 참석자 attendees: Attendee[]; attendeeCount: number; // 참석자 수 + attendanceStatus: AttendanceStatus; // 참석 상태 // 메타 정보 createdAt: string; @@ -173,7 +181,7 @@ export interface SiteBriefingFormData { briefingTime: string; // 현장설명회 시간 briefingType: BriefingType; // 구분 (온라인/오프라인) location: string; // 현장설명회 장소 - attendee: string; // 참석자 + attendeeItems: AttendeeItem[]; // 참석자 목록 (JSON으로 저장) attendanceStatus: AttendanceStatus; // 상태 // 입찰 정보 @@ -219,7 +227,7 @@ export function getEmptySiteBriefingFormData(): SiteBriefingFormData { briefingTime: '', briefingType: 'offline', location: '', - attendee: '', + attendeeItems: [], attendanceStatus: 'scheduled', projectName: '', bidDate: '', @@ -233,6 +241,26 @@ export function getEmptySiteBriefingFormData(): SiteBriefingFormData { }; } +// attendee JSON 문자열을 AttendeeItem[]로 파싱 +export function parseAttendeeItems(attendeeJson: string | null): AttendeeItem[] { + if (!attendeeJson) return []; + try { + const parsed = JSON.parse(attendeeJson); + if (Array.isArray(parsed)) { + return parsed.filter((item): item is AttendeeItem => + typeof item === 'object' && item !== null && typeof item.name === 'string' + ); + } + return []; + } catch { + // JSON 파싱 실패 시 기존 단일 문자열을 AttendeeItem으로 변환 + if (attendeeJson.trim()) { + return [{ id: '', name: attendeeJson.trim() }]; + } + return []; + } +} + // SiteBriefing을 FormData로 변환 export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingFormData { return { @@ -243,8 +271,8 @@ export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingForm briefingTime: briefing.briefingTime, briefingType: 'offline', // 기본값 location: briefing.location, - attendee: '', // 기본값 - attendanceStatus: 'scheduled', // 기본값 + attendeeItems: parseAttendeeItems(briefing.attendee), + attendanceStatus: briefing.attendanceStatus || 'scheduled', projectName: briefing.title, bidDate: briefing.bidDate || '', siteCount: 0, // 기본값