feat(WEB): 현장설명회 실제 API 연동

- mock 데이터 제거하고 실제 API 연동
- apiClient 표준화 적용
- SiteBriefingFormData 타입 추가
- CRUD 액션 API 호출로 변경
This commit is contained in:
2026-01-13 19:47:33 +09:00
parent c56c140e4b
commit e162ad5a12
4 changed files with 747 additions and 228 deletions

View File

@@ -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<SiteBriefingFormData>(
initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData()
);
const [formData, setFormData] = useState<SiteBriefingFormData>(() => {
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<Partner[]>([]);
const [isLoadingPartners, setIsLoadingPartners] = useState(false);
// 현장 목록 (선택된 거래처 기준)
const [sites, setSites] = useState<Site[]>([]);
const [isLoadingSites, setIsLoadingSites] = useState(false);
// 현장 입력 및 선택
const [siteInputValue, setSiteInputValue] = useState(formData.projectName);
const [showSiteDropdown, setShowSiteDropdown] = useState(false);
const siteInputRef = useRef<HTMLInputElement>(null);
// 현장 신규 등록 다이얼로그
const [showNewSiteDialog, setShowNewSiteDialog] = useState(false);
const [newSiteName, setNewSiteName] = useState('');
const [isCreatingSite, setIsCreatingSite] = useState(false);
// 직원 목록 (참석자용)
const [employees, setEmployees] = useState<Employee[]>([]);
// 참석자 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 데이터 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.partnerId}
onValueChange={(val) => {
handleChange('partnerId', val);
// 거래처 변경 시 현장명 초기화
handleChange('projectName', '');
setSiteInputValue('');
// partnerName도 업데이트
const selectedPartner = partners.find((p) => p.id === val);
if (selectedPartner) {
handleChange('partnerName', selectedPartner.partnerName);
}
}}
disabled={isViewMode || isLoadingPartners}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder={isLoadingPartners ? '로딩 중...' : '거래처 선택'} />
</SelectTrigger>
<SelectContent>
{partners.map((partner) => (
<SelectItem key={partner.id} value={partner.id}>
{partner.partnerName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{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 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
{isViewMode ? (
<div className="flex flex-wrap gap-2 min-h-[38px] p-2 border rounded-md bg-gray-50">
{formData.attendeeItems.length > 0 ? (
formData.attendeeItems.map((item) => (
<Badge key={item.id || item.name} variant="secondary">
{item.name}
</Badge>
))
) : (
<span className="text-gray-400 text-sm"> </span>
)}
</div>
) : (
<Popover open={attendeePopoverOpen} onOpenChange={setAttendeePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={attendeePopoverOpen}
className="w-full justify-between min-h-[38px] h-auto"
>
<div className="flex flex-wrap gap-1 flex-1 text-left">
{formData.attendeeItems.length > 0 ? (
formData.attendeeItems.map((item) => (
<Badge
key={item.id || item.name}
variant="secondary"
className="mr-1"
onClick={(e) => {
e.stopPropagation();
handleAttendeeRemove(item.id || item.name);
}}
>
{item.name}
<X className="ml-1 h-3 w-3 cursor-pointer" />
</Badge>
))
) : (
<span className="text-muted-foreground"> </span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="직원 검색 또는 직접 입력..."
value={attendeeSearchValue}
onValueChange={setAttendeeSearchValue}
onKeyDown={(e) => {
if (e.key === 'Enter' && attendeeSearchValue.trim()) {
e.preventDefault();
handleAttendeeAdd();
}
}}
/>
<CommandList>
<CommandEmpty>
{attendeeSearchValue.trim() ? (
<button
type="button"
className="w-full px-2 py-1.5 text-sm text-left hover:bg-accent rounded"
onClick={handleAttendeeAdd}
>
&quot;{attendeeSearchValue}&quot;
</button>
) : (
'검색 결과가 없습니다.'
)}
</CommandEmpty>
<CommandGroup heading="직원 목록">
{employees
.filter((emp) =>
emp.name.toLowerCase().includes(attendeeSearchValue.toLowerCase())
)
.map((employee) => {
const isSelected = formData.attendeeItems.some(
(item) => item.id === employee.id
);
return (
<CommandItem
key={employee.id}
value={employee.name}
onSelect={() => handleAttendeeSelect(employee)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
{employee.name}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{renderSelectField('상태', 'attendanceStatus', formData.attendanceStatus, ATTENDANCE_STATUS_OPTIONS)}
</CardContent>
</Card>
@@ -483,10 +811,87 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('현장명', 'projectName', formData.projectName, {
required: true,
placeholder: '현장명',
})}
{/* 현장명 - 거래처 연동 Combobox */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
ref={siteInputRef}
type="text"
value={siteInputValue}
onChange={(e) => {
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 && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={() => {
setNewSiteName(siteInputValue);
setShowNewSiteDialog(true);
}}
title="현장 신규 등록"
>
<Plus className="h-4 w-4" />
</Button>
)}
{/* 현장 드롭다운 */}
{showSiteDropdown && sites.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-auto">
{sites
.filter((site) =>
site.siteName.toLowerCase().includes(siteInputValue.toLowerCase())
)
.map((site) => (
<button
key={site.id}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
onMouseDown={(e) => {
e.preventDefault();
setSiteInputValue(site.siteName);
handleChange('projectName', site.siteName);
setShowSiteDropdown(false);
}}
>
<div className="font-medium">{site.siteName}</div>
{site.address && (
<div className="text-xs text-gray-500">{site.address}</div>
)}
</button>
))}
{sites.filter((site) =>
site.siteName.toLowerCase().includes(siteInputValue.toLowerCase())
).length === 0 && (
<div className="px-3 py-2 text-sm text-gray-500">
. + .
</div>
)}
</div>
)}
</div>
{!formData.partnerId && !isViewMode && (
<p className="text-xs text-amber-600"> .</p>
)}
</div>
{renderField('입찰일자', 'bidDate', formData.bidDate, {
type: 'date',
})}
@@ -736,6 +1141,53 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 현장 신규 등록 다이얼로그 */}
<AlertDialog open={showNewSiteDialog} onOpenChange={setShowNewSiteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
.
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<Input
value={partners.find((p) => p.id === formData.partnerId)?.partnerName || ''}
disabled
className="bg-gray-50"
/>
</div>
<div className="space-y-2 mt-4">
<Label className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<Input
value={newSiteName}
onChange={(e) => setNewSiteName(e.target.value)}
placeholder="현장명을 입력하세요"
disabled={isCreatingSite}
autoFocus
/>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isCreatingSite}></AlertDialogCancel>
<AlertDialogAction
onClick={handleCreateSite}
className="bg-blue-500 hover:bg-blue-600"
disabled={isCreatingSite || !newSiteName.trim()}
>
{isCreatingSite && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -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 (
<TableRow
@@ -407,7 +406,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(briefing: SiteBriefing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
const displayStatus = briefing.attendanceStatus || 'scheduled';
return (
<MobileCard

View File

@@ -1,188 +1,189 @@
'use server';
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse } from './types';
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse, SiteBriefingFormData } from './types';
import { apiClient } from '@/lib/api';
/**
* 주일 기업 - 현장설명회 관리 Server Actions
* TODO: 실제 API 연동 시 구현
* 표준화된 apiClient 사용 버전
*/
// 목업 데이터
const mockSiteBriefings: SiteBriefing[] = [
{
id: '1',
briefingCode: 'SB-001',
title: '강남 오피스텔 신축공사',
description: '강남구 삼성동 오피스텔 신축 현장설명회',
partnerId: '1',
partnerName: '대한건설',
briefingDate: '2025-05-12',
briefingTime: '14:00',
location: '강남구청 대회의실',
address: '서울특별시 강남구 학동로 426',
status: 'scheduled',
bidStatus: 'pending',
bidDate: '2025-05-15',
// ========================================
// API 응답 타입
// ========================================
interface ApiSiteBriefing {
id: number;
briefing_code: string | null;
title: string;
description: string | null;
partner_id: number | null;
partner_name: string | null;
briefing_date: string;
briefing_time: string | null;
briefing_type: string | null;
location: string | null;
address: string | null;
status: string | null;
bid_status: string | null;
bid_date: string | null;
attendees: Array<{ id: string; name: string }> | 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<string, unknown> {
// 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<string, string> = {
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<string, { sort_by: string; sort_dir: string }> = {
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: '일괄 삭제에 실패했습니다.' };
}
}

View File

@@ -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, // 기본값