feat(WEB): 현장설명회 실제 API 연동
- mock 데이터 제거하고 실제 API 연동 - apiClient 표준화 적용 - SiteBriefingFormData 타입 추가 - CRUD 액션 API 호출로 변경
This commit is contained in:
@@ -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}
|
||||
>
|
||||
"{attendeeSearchValue}" 추가
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, // 기본값
|
||||
|
||||
Reference in New Issue
Block a user