Files
sam-manage/resources/views/juil/pmis-daily-report.blade.php
김보곤 2102f4a398 feat: [pmis] PMIS 자료실/안전관리/품질관리 기능 추가 및 개선
- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항
- 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제
- 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일
- 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치
- 품질관리: 시정조치 UI 페이지
- 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드
- 작업일보/출면일보 오류 수정 및 기능 개선
- 설비 사진 업로드, 근로계약서 종료일 수정
2026-03-12 21:11:21 +09:00

632 lines
51 KiB
PHP

@extends('layouts.app')
@section('title', '작업일보 - 건설PMIS')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet" />
@include('partials.react-cdn')
<script type="text/babel">
@verbatim
const { useState, useEffect, useRef, useCallback, useMemo } = React;
const API = '/juil/construction-pmis/api';
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
async function api(path, opts = {}) {
const res = await fetch(`${API}${path}`, {
headers: { 'Accept':'application/json','Content-Type':'application/json','X-CSRF-TOKEN':CSRF, ...opts.headers },
...opts,
});
if (!res.ok) { const e = await res.json().catch(()=>({})); throw new Error(e.message||`HTTP ${res.status}`); }
return res.json();
}
async function apiForm(path, formData) {
const res = await fetch(`${API}${path}`, {
method:'POST', headers:{'Accept':'application/json','X-CSRF-TOKEN':CSRF}, body:formData,
});
if (!res.ok) { const e = await res.json().catch(()=>({})); throw new Error(e.message||`HTTP ${res.status}`); }
return res.json();
}
const DAY_NAMES=['일','월','화','수','목','금','토'];
const WEATHERS=['맑음','흐림','비','눈','안개','구름많음'];
function getDaysInMonth(y,m){return new Date(y,m,0).getDate()}
function getDayOfWeek(y,m,d){return new Date(y,m-1,d).getDay()}
function fmt(y,m,d){return `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`}
function num(v){return Number(v)||0}
/* ═══════ 사이드바 ═══════ */
const PMIS_MENUS=[
{icon:'ri-building-2-line',label:'BIM 관리',id:'bim',children:[{label:'BIM 뷰어',id:'bim-viewer',url:'/juil/construction-pmis/bim-viewer'}]},
{icon:'ri-line-chart-line',label:'시공관리',id:'construction',children:[
{label:'인원관리',id:'workforce',url:'/juil/construction-pmis/workforce'},
{label:'장비관리',id:'equipment',url:'/juil/construction-pmis/equipment'},
{label:'자재관리',id:'materials',url:'/juil/construction-pmis/materials'},
{label:'공사량관리',id:'work-volume',url:'/juil/construction-pmis/work-volume'},
{label:'출면일보',id:'daily-attendance',url:'/juil/construction-pmis/daily-attendance'},
{label:'작업일보',id:'daily-report',url:'/juil/construction-pmis/daily-report'},
]},
{icon:'ri-file-list-3-line',label:'품질관리',id:'quality',children:[
{label:'시정조치',id:'corrective-action',url:'/juil/construction-pmis/corrective-action'},
]},
{icon:'ri-shield-check-line',label:'안전관리',id:'safety',children:[
{label:'안전보건교육',id:'safety-education',url:'/juil/construction-pmis/safety-education'},
{label:'TBM현장',id:'tbm',url:'/juil/construction-pmis/tbm'},
{label:'위험성 평가',id:'risk-assessment',url:'/juil/construction-pmis/risk-assessment'},
{label:'재해예방조치',id:'disaster-prevention',url:'/juil/construction-pmis/disaster-prevention'},
]},
{icon:'ri-folder-line',label:'자료실',id:'archive',children:[
{label:'자료보관함',id:'archive-files',url:'/juil/construction-pmis/archive-files'},
{label:'매뉴얼',id:'archive-manual',url:'/juil/construction-pmis/archive-manual'},
{label:'공지사항',id:'archive-notice',url:'/juil/construction-pmis/archive-notice'},
]},
];
function PmisSidebar({activePage}){
const[profile,setProfile]=useState(null);
const[expanded,setExpanded]=useState(()=>{for(const m of PMIS_MENUS){if(m.children?.some(c=>c.id===activePage))return m.id}return null});
useEffect(()=>{fetch('/juil/construction-pmis/profile',{headers:{Accept:'application/json'}}).then(r=>r.json()).then(d=>setProfile(d.worker)).catch(()=>{})},[]);
return(
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0" style={{width:200}}>
<a href="/juil/construction-pmis" className="flex items-center gap-2 px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 border-b border-gray-100 transition"><i className="ri-arrow-left-s-line text-lg"></i> PMIS 대시보드</a>
<div className="p-3 border-b border-gray-100 text-center">
<div className="w-12 h-12 mx-auto mb-1 rounded-full bg-gray-100 border-2 border-gray-200 flex items-center justify-center">
{profile?.profile_photo_path?<img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover"/>:<i className="ri-user-3-line text-xl text-gray-300"></i>}
</div>
<div className="text-sm font-bold text-gray-800">{profile?.name||'...'}</div>
<div className="text-xs text-gray-500 mt-0.5">{profile?.department||''}</div>
</div>
<div className="flex-1 overflow-auto py-1">
{PMIS_MENUS.map(m=>(
<div key={m.id}>
<div onClick={()=>setExpanded(expanded===m.id?null:m.id)} className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${expanded===m.id?'bg-blue-50 text-blue-700 font-semibold':'text-gray-600 hover:bg-gray-50'}`}>
<i className={`${m.icon} text-base`}></i>{m.label}<i className={`ml-auto ${expanded===m.id?'ri-arrow-down-s-line':'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>
</div>
{expanded===m.id&&m.children?.map(c=>(<a key={c.id} href={c.url} className={`block pl-10 pr-4 py-2 text-sm transition ${c.id===activePage?'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600':'text-gray-500 hover:text-blue-600 hover:bg-gray-50'}`}>{c.label}</a>))}
</div>))}
</div>
</div>);
}
/* ═══════ 캘린더 스트립 ═══════ */
function CalendarStrip({year,month,selectedDay,onSelectDay,dayStatus}){
const days=getDaysInMonth(year,month);
const SC={draft:'#22c55e',review:'#ef4444',approved:'#3b82f6'};
return(
<div className="flex items-center gap-0.5 py-2 overflow-x-auto">
<button onClick={()=>onSelectDay(Math.max(1,selectedDay-1))} className="px-2 py-1 text-gray-500 hover:text-gray-800 text-lg shrink-0">&lt;</button>
{Array.from({length:days},(_,i)=>{
const d=i+1,dow=getDayOfWeek(year,month,d),isW=dow===0||dow===6,isSel=d===selectedDay,dc=SC[dayStatus[d]];
return(<div key={d} onClick={()=>onSelectDay(d)} className={`flex flex-col items-center cursor-pointer shrink-0 transition rounded ${isSel?'bg-gray-600 text-white':'hover:bg-gray-100'}`} style={{width:32,padding:'2px 0'}}>
<div className="h-2 flex items-center justify-center">{dc&&<div className="rounded-full" style={{width:6,height:6,backgroundColor:dc}}></div>}</div>
<div className={`text-sm font-medium ${isSel?'text-white':isW?'text-red-500':'text-gray-700'}`}>{d}</div>
</div>);
})}
<button onClick={()=>onSelectDay(Math.min(days,selectedDay+1))} className="px-2 py-1 text-gray-500 hover:text-gray-800 text-lg shrink-0">&gt;</button>
</div>);
}
/* ═══════ 랜덤 데이터 ═══════ */
const R_WORK_TYPES=['방화셔터공사','철근콘크리트공사','전기공사','설비공사','도장공사','방수공사','미장공사','창호공사','타일공사','조적공사'];
const R_JOB_TYPES=['방화셔터','현장소장','화기감시자','철근공','형틀목공','전기기사','배관공','도장공','방수공','미장공','타일공','용접공'];
const R_EQUIP_NAMES=['타워크레인','굴삭기','덤프트럭','레미콘','펌프카','지게차','렌탈','스카이차','항타기','발전기'];
const R_EQUIP_SPECS=['50ton','0.7m³','15ton','6m³','36M','2.5ton','2.5(M)*1.17(M)*2.36(M)','45M','유압식','100KW'];
const R_MAT_NAMES=['레미탈','시멘트','철근(HD13)','합판','모래','자갈','PVC파이프','전선(HIV)','페인트','방수시트'];
const R_MAT_UNITS=['m³','ton','ton','매','m³','m³','m','m','L','m²'];
const R_VOL_TYPES=['철근콘크리트공사','방화셔터공사','전기공사','설비공사','도장공사'];
const R_VOL_SUB=['거푸집','콘크리트타설','철근가공','셔터설치','배선공사','배관공사','내부도장','외부도장'];
function pick(arr){return arr[Math.floor(Math.random()*arr.length)]}
function rand(a,b){return Math.floor(Math.random()*(b-a+1))+a}
function randWorker(){return{work_type:pick(R_WORK_TYPES),job_type:pick(R_JOB_TYPES),prev_cumulative:rand(50,500),today_count:rand(1,15)}}
function randEquip(){return{equipment_name:pick(R_EQUIP_NAMES),specification:pick(R_EQUIP_SPECS),prev_cumulative:rand(100,600),today_count:rand(0,5)}}
function randMat(){return{material_name:pick(R_MAT_NAMES),specification:pick(R_EQUIP_SPECS),unit:pick(R_MAT_UNITS),design_qty:rand(100,5000),prev_cumulative:rand(50,2000),today_count:rand(0,100)}}
function randVol(){return{work_type:pick(R_VOL_TYPES),sub_work_type:pick(R_VOL_SUB),unit:pick(['m²','m³','ton','EA','m']),design_qty:rand(100,10000),prev_cumulative:rand(50,5000),today_count:rand(0,200)}}
/* ═══════ 검토자 모달 ═══════ */
const ORG_TREE=[{name:'안성 당목리 물류센터',children:[{name:'협력업체',children:[{name:'(주)주일기업 -방화셔터공사'}]},{name:'KCC건설'}]}];
const DEFAULT_REVIEWERS=[
{position:'소장',name:'신승표',title:'부장'},
{position:'공사과장',name:'이성태',title:'대리'},
{position:'관리부장',name:'안성현',title:'과장'},
{position:'현장부장',name:'정영진',title:'과장'},
{position:'안전관리자',name:'심준수',title:'부장'},
{position:'품질관리자',name:'김정훈',title:'부장'},
];
function ReviewerModal({open,onClose,reviewers,onSave}){
const[list,setList]=useState([]);
useEffect(()=>{if(open)setList(reviewers?.length?[...reviewers]:DEFAULT_REVIEWERS.map(r=>({...r})))},[open]);
if(!open)return null;
return(
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl" style={{width:900,maxHeight:'80vh'}} onClick={e=>e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b"><h3 className="font-bold text-gray-800">검토자 지정</h3><button onClick={onClose} className="text-gray-400 hover:text-gray-700 text-xl"><i className="ri-close-line"></i></button></div>
<div className="flex" style={{height:400}}>
<div className="border-r p-3" style={{width:220}}>
<div className="text-xs font-bold text-gray-500 mb-2">조직도</div>
{ORG_TREE.map((n,i)=>(<div key={i} className="text-sm"><div className="font-semibold text-gray-700 py-1"><i className="ri-building-line mr-1"></i>{n.name}</div>
{n.children?.map((c,j)=>(<div key={j} className="pl-4"><div className="text-gray-600 py-0.5"><i className="ri-team-line mr-1"></i>{c.name}</div>
{c.children?.map((g,k)=>(<div key={k} className="pl-4 text-gray-500 py-0.5 text-xs">{g.name}</div>))}</div>))}
</div>))}
</div>
<div className="flex-1 p-3 overflow-auto">
<div className="text-xs font-bold text-gray-500 mb-2">검토 라인</div>
<table className="w-full text-sm border"><thead><tr className="bg-gray-100"><th className="border p-1.5">직위</th><th className="border p-1.5">성명</th><th className="border p-1.5">직급</th><th className="border p-1.5 w-12"></th></tr></thead>
<tbody>{list.map((r,i)=>(<tr key={i}><td className="border p-1.5 text-center">{r.position}</td><td className="border p-1.5 text-center">{r.name}</td><td className="border p-1.5 text-center">{r.title}</td>
<td className="border p-1.5 text-center"><button onClick={()=>setList(list.filter((_,j)=>j!==i))} className="text-red-400 hover:text-red-600"><i className="ri-delete-bin-line"></i></button></td></tr>))}</tbody></table>
</div>
</div>
<div className="flex justify-end gap-2 px-5 py-3 border-t">
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded hover:bg-gray-50">취소</button>
<button onClick={()=>{onSave(list);onClose()}} className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">저장</button>
</div>
</div>
</div>);
}
/* ═══════ 양식보기 (전체화면 뷰어) ═══════ */
function PrintViewer({open,onClose,report,workers,equipments,materials,volumes}){
const[page,setPage]=useState(1);
const[zoom,setZoom]=useState(100);
const totalPages=3;
if(!open)return null;
const dateStr=report?.date?new Date(report.date).toLocaleDateString('ko-KR',{year:'numeric',month:'2-digit',day:'2-digit',weekday:'short'}):'';
const reviewers=report?.options?.reviewers||DEFAULT_REVIEWERS;
const wSum={prev:0,today:0};workers.forEach(w=>{wSum.prev+=num(w.prev_cumulative);wSum.today+=num(w.today_count)});
const eSum={prev:0,today:0};equipments.forEach(e=>{eSum.prev+=num(e.prev_cumulative);eSum.today+=num(e.today_count)});
const pageStyle={width:210*3.78,minHeight:297*3.78,background:'#fff',padding:'40px 50px',fontSize:11,fontFamily:'serif',margin:'0 auto',boxShadow:'0 2px 10px rgba(0,0,0,.2)'};
const th={background:'#d9d9d9',border:'1px solid #333',padding:'4px 6px',fontWeight:'bold',textAlign:'center',fontSize:10};
const td={border:'1px solid #333',padding:'4px 6px',textAlign:'center',fontSize:10};
const tdL={...td,textAlign:'left'};
return(
<div className="fixed inset-0 bg-gray-800 z-50 flex flex-col">
<div className="bg-gray-900 text-white flex items-center gap-3 px-4 py-2 text-sm shrink-0">
<button onClick={()=>window.print()} className="hover:bg-gray-700 p-1.5 rounded" title="인쇄"><i className="ri-printer-line text-lg"></i></button>
<button onClick={()=>setPage(Math.max(1,page-1))} disabled={page<=1} className="hover:bg-gray-700 p-1.5 rounded disabled:opacity-30"><i className="ri-arrow-left-s-line"></i></button>
<span>{page} / {totalPages}</span>
<button onClick={()=>setPage(Math.min(totalPages,page+1))} disabled={page>=totalPages} className="hover:bg-gray-700 p-1.5 rounded disabled:opacity-30"><i className="ri-arrow-right-s-line"></i></button>
<span className="mx-2">|</span>
<button onClick={()=>setZoom(Math.max(50,zoom-10))} className="hover:bg-gray-700 p-1 rounded"><i className="ri-zoom-out-line"></i></button>
<span>{zoom}%</span>
<button onClick={()=>setZoom(Math.min(200,zoom+10))} className="hover:bg-gray-700 p-1 rounded"><i className="ri-zoom-in-line"></i></button>
<div className="flex-1"></div>
<button onClick={onClose} className="hover:bg-gray-700 p-1.5 rounded"><i className="ri-close-line text-lg"></i></button>
</div>
<div className="flex-1 overflow-auto p-6 bg-gray-600" style={{display:'flex',justifyContent:'center',alignItems:'flex-start'}}>
<div style={{transform:`scale(${zoom/100})`,transformOrigin:'top center'}}>
{page===1&&(
<div style={pageStyle}>
<h2 style={{textAlign:'center',fontSize:18,fontWeight:'bold',marginBottom:16}}> </h2>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:8}}>
<div style={{fontSize:11}}> 공사명 : 안성 당목리 물류센터</div>
<table style={{borderCollapse:'collapse'}}><tbody><tr>
{reviewers.slice(0,4).map((r,i)=>(<td key={i} style={{...th,minWidth:55}}>{r.position}</td>))}
</tr><tr>
{reviewers.slice(0,4).map((r,i)=>(<td key={i} style={{...td,height:35}}>{r.name}</td>))}
</tr></tbody></table>
</div>
<table style={{width:'100%',borderCollapse:'collapse',marginBottom:6}}>
<tbody><tr><td style={{...th,width:80}}>날씨</td><td style={td}>{report?.weather}</td>
<td style={{...th,width:60}}>최저</td><td style={td}>{report?.temp_low}</td>
<td style={{...th,width:60}}>최고</td><td style={td}>{report?.temp_high}</td>
<td style={{...th,width:60}}>강수량</td><td style={td}>{report?.precipitation}</td>
<td style={{...th,width:70}}>미세먼지</td><td style={td}>{report?.fine_dust}/{report?.ultra_fine_dust}</td>
</tr></tbody>
</table>
<div style={{fontSize:11,fontWeight:'bold',marginBottom:4}}>1. 작업내용</div>
<div style={{textAlign:'right',fontSize:10,marginBottom:2}}>{dateStr}</div>
<table style={{width:'100%',borderCollapse:'collapse',marginBottom:10}}>
<thead><tr><th style={{...th,width:'50%'}}> </th><th style={th}> </th></tr></thead>
<tbody><tr>
<td style={{...tdL,verticalAlign:'top',height:300,whiteSpace:'pre-wrap'}}>{report?.work_content_today||''}</td>
<td style={{...tdL,verticalAlign:'top',whiteSpace:'pre-wrap'}}>{report?.work_content_tomorrow||''}</td>
</tr></tbody>
</table>
<table style={{width:'100%',borderCollapse:'collapse',marginBottom:6}}>
<tbody><tr><td style={{...th,width:60}}>특이사항</td><td style={{...tdL,height:50,whiteSpace:'pre-wrap'}}>{report?.notes||''}</td></tr></tbody>
</table>
<div style={{textAlign:'right',fontSize:10,marginTop:8}}> : ()주일기업</div>
</div>)}
{page===2&&(
<div style={pageStyle}>
<div style={{fontSize:12,fontWeight:'bold',marginBottom:8}}>2. 인원투입현황</div>
<table style={{width:'100%',borderCollapse:'collapse',marginBottom:20}}>
<thead><tr><th style={th}> </th><th style={{...th,width:40}}>단위</th><th style={{...th,width:60}}>전일</th><th style={{...th,width:60}}>금일</th><th style={{...th,width:60}}>누계</th><th style={{...th,width:60}}>비고</th></tr></thead>
<tbody>
{workers.map((w,i)=>(<tr key={i}><td style={td}>{w.work_type} - {w.job_type}</td><td style={td}></td><td style={td}>{num(w.prev_cumulative)}</td><td style={td}>{num(w.today_count)}</td><td style={td}>{num(w.prev_cumulative)+num(w.today_count)}</td><td style={td}></td></tr>))}
<tr style={{fontWeight:'bold'}}><td style={td}></td><td style={td}></td><td style={td}>{wSum.prev}</td><td style={td}>{wSum.today}</td><td style={td}>{wSum.prev+wSum.today}</td><td style={td}></td></tr>
</tbody>
</table>
<div style={{fontSize:12,fontWeight:'bold',marginBottom:8}}>3. 장비투입현황</div>
<table style={{width:'100%',borderCollapse:'collapse'}}>
<thead><tr><th style={th}> </th><th style={{...th,width:40}}>단위</th><th style={{...th,width:60}}>전일</th><th style={{...th,width:60}}>금일</th><th style={{...th,width:60}}>누계</th><th style={{...th,width:60}}>비고</th></tr></thead>
<tbody>
{equipments.map((e,i)=>(<tr key={i}><td style={td}>{e.equipment_name} {e.specification}</td><td style={td}></td><td style={td}>{num(e.prev_cumulative)}</td><td style={td}>{num(e.today_count)}</td><td style={td}>{num(e.prev_cumulative)+num(e.today_count)}</td><td style={td}></td></tr>))}
<tr style={{fontWeight:'bold'}}><td style={td}></td><td style={td}></td><td style={td}>{eSum.prev}</td><td style={td}>{eSum.today}</td><td style={td}>{eSum.prev+eSum.today}</td><td style={td}></td></tr>
</tbody>
</table>
</div>)}
{page===3&&(
<div style={pageStyle}>
<div style={{fontSize:12,fontWeight:'bold',marginBottom:8}}>4. 자재 투입현황</div>
<table style={{width:'100%',borderCollapse:'collapse'}}>
<thead><tr><th style={th}> </th><th style={th}> </th><th style={{...th,width:40}}>단위</th><th style={{...th,width:60}}>설계량</th><th style={{...th,width:60}}>전일</th><th style={{...th,width:60}}>금일</th><th style={{...th,width:60}}>누계</th><th style={{...th,width:50}}>비고</th></tr></thead>
<tbody>
{materials.map((m,i)=>(<tr key={i}><td style={td}>{m.material_name}</td><td style={td}>{m.specification}</td><td style={td}>{m.unit}</td><td style={td}>{num(m.design_qty)}</td><td style={td}>{num(m.prev_cumulative)}</td><td style={td}>{num(m.today_count)}</td><td style={td}>{num(m.prev_cumulative)+num(m.today_count)}</td><td style={td}></td></tr>))}
{materials.length===0&&<tr><td style={td} colSpan={8}>(자재 데이터 없음)</td></tr>}
</tbody>
</table>
</div>)}
</div>
</div>
</div>);
}
/* ═══════ 탭 공통 테이블 스타일 ═══════ */
const TH='border border-gray-300 bg-gray-100 px-3 py-2 text-xs font-semibold text-gray-600 text-center whitespace-nowrap';
const TD='border border-gray-200 px-3 py-2 text-sm text-center';
const TDR='border border-gray-200 px-3 py-2 text-sm text-right tabular-nums';
/* ═══════ 메인 App ═══════ */
function App(){
const now=new Date();
const[year,setYear]=useState(now.getFullYear());
const[month,setMonth]=useState(now.getMonth()+1);
const[day,setDay]=useState(now.getDate());
const[company,setCompany]=useState('');
const[tab,setTab]=useState('content');
const[dayStatus,setDayStatus]=useState({});
const[report,setReport]=useState(null);
const[workers,setWorkers]=useState([]);
const[equipments,setEquipments]=useState([]);
const[materials,setMaterials]=useState([]);
const[volumes,setVolumes]=useState([]);
const[photos,setPhotos]=useState([]);
const[loading,setLoading]=useState(false);
const[showReviewer,setShowReviewer]=useState(false);
const[showPrint,setShowPrint]=useState(false);
const[checkedW,setCheckedW]=useState({});
const[checkedE,setCheckedE]=useState({});
const[checkedM,setCheckedM]=useState({});
const[checkedV,setCheckedV]=useState({});
const[checkedP,setCheckedP]=useState({});
const dateStr=fmt(year,month,day);
const dateObj=new Date(year,month-1,day);
const dayName=DAY_NAMES[dateObj.getDay()];
const isReadOnly=report?.status==='review'||report?.status==='approved';
const canDelete=report?.status!=='approved';
const loadReport=useCallback(async()=>{
setLoading(true);
try{
const d=await api(`/daily-work-reports?date=${dateStr}&company=${encodeURIComponent(company)}`);
setReport(d);setWorkers(d.workers||[]);setEquipments(d.equipments||[]);setMaterials(d.materials||[]);setVolumes(d.volumes||[]);setPhotos(d.photos||[]);
}catch(e){console.error(e)}
setLoading(false);
},[dateStr,company]);
const loadMonth=useCallback(async()=>{
try{const d=await api(`/daily-work-reports/month-status?year=${year}&month=${month}&company=${encodeURIComponent(company)}`);setDayStatus(d)}catch(e){}
},[year,month,company]);
useEffect(()=>{loadReport()},[loadReport]);
useEffect(()=>{loadMonth()},[loadMonth]);
const saveReport=async(fields)=>{
if(!report?.id||isReadOnly)return;
try{const d=await api(`/daily-work-reports/${report.id}`,{method:'PUT',body:JSON.stringify(fields)});setReport(d);loadMonth();alert('저장되었습니다.')}catch(e){alert(e.message)}
};
const deleteReport=async()=>{
if(!report?.id||!canDelete||!confirm('이 날짜의 작업일보를 삭제하시겠습니까?'))return;
try{await api(`/daily-work-reports/${report.id}`,{method:'DELETE'});loadReport();loadMonth()}catch(e){alert(e.message)}
};
// ─── CRUD helpers ───
const addItem=async(endpoint,data)=>{
if(!report?.id)return;
try{const r=await api(endpoint,{method:'POST',body:JSON.stringify({report_id:report.id,...data})});return r}catch(e){alert(e.message)}
};
const deleteItems=async(endpoint,ids)=>{
for(const id of ids){try{await api(`${endpoint}/${id}`,{method:'DELETE'})}catch(e){}}
loadReport();
};
// ─── 번개 (랜덤 데이터 추가) ───
const lightning=async()=>{
if(!report?.id||isReadOnly)return;
const promises=[];
if(tab==='workers'){for(let i=0;i<3;i++)promises.push(addItem('/work-report-workers',randWorker()))}
else if(tab==='equipments'){for(let i=0;i<2;i++)promises.push(addItem('/work-report-equipments',randEquip()))}
else if(tab==='materials'){for(let i=0;i<3;i++)promises.push(addItem('/work-report-materials',randMat()))}
else if(tab==='volumes'){for(let i=0;i<3;i++)promises.push(addItem('/work-report-volumes',randVol()))}
await Promise.all(promises);
loadReport();
};
const deleteChecked=async()=>{
if(isReadOnly)return;
if(tab==='workers'){const ids=Object.keys(checkedW).filter(k=>checkedW[k]);if(ids.length)await deleteItems('/work-report-workers',ids);setCheckedW({})}
else if(tab==='equipments'){const ids=Object.keys(checkedE).filter(k=>checkedE[k]);if(ids.length)await deleteItems('/work-report-equipments',ids);setCheckedE({})}
else if(tab==='materials'){const ids=Object.keys(checkedM).filter(k=>checkedM[k]);if(ids.length)await deleteItems('/work-report-materials',ids);setCheckedM({})}
else if(tab==='volumes'){const ids=Object.keys(checkedV).filter(k=>checkedV[k]);if(ids.length)await deleteItems('/work-report-volumes',ids);setCheckedV({})}
else if(tab==='photos'){const ids=Object.keys(checkedP).filter(k=>checkedP[k]);if(ids.length)await deleteItems('/work-report-photos',ids);setCheckedP({})}
};
// 인원/장비 합계
const wSum=useMemo(()=>{let p=0,t=0;workers.forEach(w=>{p+=num(w.prev_cumulative);t+=num(w.today_count)});return{prev:p,today:t}},[workers]);
const eSum=useMemo(()=>{let p=0,t=0;equipments.forEach(e=>{p+=num(e.prev_cumulative);t+=num(e.today_count)});return{prev:p,today:t}},[equipments]);
const TABS=[
{id:'content',label:'작업내용'},
{id:'workers',label:'인원'},
{id:'equipments',label:'장비'},
{id:'materials',label:'자재'},
{id:'volumes',label:'공사량'},
{id:'photos',label:'작업사진'},
];
const onSelectDay=(d)=>{setDay(d);setCheckedW({});setCheckedE({});setCheckedM({});setCheckedV({});setCheckedP({})};
return(
<div className="flex bg-gray-100" style={{height:'calc(100vh - 56px)'}}>
<PmisSidebar activePage="daily-report"/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* 헤더 */}
<div className="bg-white px-6 py-3 text-center border-b">
<h1 className="text-lg font-bold text-gray-800">{year} {String(month).padStart(2,'0')} {String(day).padStart(2,'0')} ({dayName}) {report?.weather||''}</h1>
</div>
{/* 필터 */}
<div className="bg-white px-6 py-2 border-b flex items-center gap-3 flex-wrap">
<input type="date" value={dateStr} onChange={e=>{const p=e.target.value.split('-');if(p.length===3){setYear(+p[0]);setMonth(+p[1]);setDay(+p[2])}}} className="border border-gray-300 rounded px-2 py-1 text-sm"/>
<select value={company} onChange={e=>setCompany(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" style={{minWidth:200}}>
<option value="">()주일기업 -방화셔터공사</option>
</select>
<button onClick={()=>{loadReport();loadMonth()}} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm hover:bg-blue-700">검색</button>
<button onClick={()=>{setCompany('');setYear(now.getFullYear());setMonth(now.getMonth()+1);setDay(now.getDate())}} className="border border-gray-300 px-4 py-1.5 rounded text-sm hover:bg-gray-50">검색초기화</button>
<div className="ml-auto flex items-center gap-3 text-xs">
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500"></span>작성중</span>
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500"></span>검토중</span>
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-500"></span>승인</span>
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-full bg-gray-300"></span>미작성</span>
</div>
</div>
{/* 캘린더 */}
<div className="bg-white px-6 border-b">
<CalendarStrip year={year} month={month} selectedDay={day} onSelectDay={onSelectDay} dayStatus={dayStatus}/>
</div>
{/* 버튼 바 */}
<div className="bg-white px-6 py-2 border-b flex items-center gap-2">
<button className="border border-gray-300 px-3 py-1.5 rounded text-sm hover:bg-gray-50">일괄출력</button>
<div className="flex-1"></div>
<button onClick={()=>setShowReviewer(true)} className="border border-gray-300 px-3 py-1.5 rounded text-sm hover:bg-gray-50">검토자 지정</button>
<button onClick={()=>setShowPrint(true)} className="border border-gray-300 px-3 py-1.5 rounded text-sm hover:bg-gray-50">양식보기</button>
{!isReadOnly&&<button onClick={()=>saveReport({work_content_today:report?.work_content_today,work_content_tomorrow:report?.work_content_tomorrow,notes:report?.notes,weather:report?.weather,temp_low:report?.temp_low,temp_high:report?.temp_high,precipitation:report?.precipitation,snowfall:report?.snowfall,fine_dust:report?.fine_dust,ultra_fine_dust:report?.ultra_fine_dust,options:report?.options})} className="bg-blue-600 text-white px-5 py-1.5 rounded text-sm hover:bg-blue-700">저장</button>}
{canDelete&&!isReadOnly&&<button onClick={deleteReport} className="bg-red-500 text-white px-5 py-1.5 rounded text-sm hover:bg-red-600">삭제</button>}
</div>
{/* 탭 */}
<div className="bg-white px-6 border-b">
<div className="flex">
{TABS.map(t=>(<button key={t.id} onClick={()=>setTab(t.id)} className={`px-5 py-2.5 text-sm font-medium border-b-2 transition ${tab===t.id?'border-blue-600 text-blue-700':'border-transparent text-gray-500 hover:text-gray-700'}`}>{t.label}</button>))}
</div>
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-auto bg-white">
{loading?<div className="flex items-center justify-center h-40 text-gray-400"><i className="ri-loader-4-line animate-spin text-2xl mr-2"></i>로딩중...</div>:
<div className="px-6 py-4">
{/* ─── 작업내용 탭 ─── */}
{tab==='content'&&(<div>
<div className="flex items-center gap-4 mb-4 flex-wrap">
<label className="text-sm font-semibold text-gray-600 shrink-0">날씨</label>
<select value={report?.weather||'맑음'} disabled={isReadOnly} onChange={e=>setReport({...report,weather:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-24">{WEATHERS.map(w=>(<option key={w}>{w}</option>))}</select>
<label className="text-sm font-semibold text-gray-600 shrink-0">최저/최고기온</label>
<input type="number" value={report?.temp_low??''} disabled={isReadOnly} onChange={e=>setReport({...report,temp_low:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" step="0.1"/>
<span>/</span>
<input type="number" value={report?.temp_high??''} disabled={isReadOnly} onChange={e=>setReport({...report,temp_high:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" step="0.1"/>
<label className="text-sm font-semibold text-gray-600 shrink-0">강수/강설량</label>
<input type="number" value={report?.precipitation??0} disabled={isReadOnly} onChange={e=>setReport({...report,precipitation:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" step="0.1"/>
<span>/</span>
<input type="number" value={report?.snowfall??0} disabled={isReadOnly} onChange={e=>setReport({...report,snowfall:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" step="0.1"/>
<label className="text-sm font-semibold text-gray-600 shrink-0">미세/초미세먼지</label>
<input type="text" value={report?.fine_dust??''} disabled={isReadOnly} onChange={e=>setReport({...report,fine_dust:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" placeholder=""/>
<span>/</span>
<input type="text" value={report?.ultra_fine_dust??''} disabled={isReadOnly} onChange={e=>setReport({...report,ultra_fine_dust:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" placeholder=""/>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<div className="bg-gray-100 text-center py-2 text-sm font-semibold text-gray-700 border border-gray-300 rounded-t">금일내용</div>
<textarea value={report?.work_content_today||''} disabled={isReadOnly} onChange={e=>setReport({...report,work_content_today:e.target.value})} className="w-full border border-gray-300 border-t-0 rounded-b p-3 text-sm resize-none" rows={12} placeholder="금일 작업내용을 입력하세요..."/>
</div>
<div>
<div className="bg-gray-100 text-center py-2 text-sm font-semibold text-gray-700 border border-gray-300 rounded-t">명일내용</div>
<textarea value={report?.work_content_tomorrow||''} disabled={isReadOnly} onChange={e=>setReport({...report,work_content_tomorrow:e.target.value})} className="w-full border border-gray-300 border-t-0 rounded-b p-3 text-sm resize-none" rows={12} placeholder="명일 작업내용을 입력하세요..."/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-600 shrink-0">직원 / 장비 / 인력</span>
<input type="number" value={report?.options?.staff_today??0} disabled={isReadOnly} onChange={e=>{const o={...(report?.options||{}),staff_today:+e.target.value};setReport({...report,options:o})}} className="border border-gray-300 rounded px-2 py-1 w-16 text-center text-sm"/>
<span>/</span>
<input type="number" value={report?.options?.equip_today??0} disabled={isReadOnly} onChange={e=>{const o={...(report?.options||{}),equip_today:+e.target.value};setReport({...report,options:o})}} className="border border-gray-300 rounded px-2 py-1 w-16 text-center text-sm"/>
<span>/</span>
<input type="number" value={report?.options?.labor_today??0} disabled={isReadOnly} onChange={e=>{const o={...(report?.options||{}),labor_today:+e.target.value};setReport({...report,options:o})}} className="border border-gray-300 rounded px-2 py-1 w-16 text-center text-sm"/>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-600 shrink-0">직원 / 장비 / 인력</span>
<input type="number" value={report?.options?.staff_tomorrow??0} disabled={isReadOnly} onChange={e=>{const o={...(report?.options||{}),staff_tomorrow:+e.target.value};setReport({...report,options:o})}} className="border border-gray-300 rounded px-2 py-1 w-16 text-center text-sm"/>
<span>/</span>
<input type="number" value={report?.options?.equip_tomorrow??0} disabled={isReadOnly} onChange={e=>{const o={...(report?.options||{}),equip_tomorrow:+e.target.value};setReport({...report,options:o})}} className="border border-gray-300 rounded px-2 py-1 w-16 text-center text-sm"/>
<span>/</span>
<input type="number" value={report?.options?.labor_tomorrow??0} disabled={isReadOnly} onChange={e=>{const o={...(report?.options||{}),labor_tomorrow:+e.target.value};setReport({...report,options:o})}} className="border border-gray-300 rounded px-2 py-1 w-16 text-center text-sm"/>
</div>
</div>
<div className="bg-gray-100 text-center py-2 text-sm font-semibold text-gray-700 border border-gray-300 rounded-t">특이사항</div>
<textarea value={report?.notes||''} disabled={isReadOnly} onChange={e=>setReport({...report,notes:e.target.value})} className="w-full border border-gray-300 border-t-0 rounded-b p-3 text-sm resize-none" rows={3} placeholder="특이사항을 입력하세요..."/>
</div>)}
{/* ─── 인원 탭 ─── */}
{tab==='workers'&&(<div>
{!isReadOnly&&<div className="flex justify-end gap-2 mb-2">
<button onClick={lightning} className="text-yellow-500 hover:text-yellow-600 text-xl" title="랜덤 데이터"><i className="ri-flashlight-line"></i></button>
<button onClick={deleteChecked} className="text-red-400 hover:text-red-600 text-sm border border-red-300 rounded px-2 py-1">선택삭제</button>
</div>}
<table className="w-full border-collapse border border-gray-300">
<thead><tr><th className={TH} style={{width:30}}><input type="checkbox" onChange={e=>{const c={};workers.forEach(w=>c[w.id]=e.target.checked);setCheckedW(c)}}/></th><th className={TH}>공종</th><th className={TH}>직종</th><th className={TH} style={{width:100}}>전일누계</th><th className={TH} style={{width:80}}>금일</th><th className={TH} style={{width:100}}>총계</th></tr></thead>
<tbody>
<tr className="bg-gray-50 font-semibold"><td className={TD}></td><td className={TD} colSpan={2}>누계</td><td className={TDR}>{wSum.prev.toLocaleString()}</td><td className={TDR}>{wSum.today.toLocaleString()}</td><td className={TDR}>{(wSum.prev+wSum.today).toLocaleString()}</td></tr>
{workers.map(w=>(<tr key={w.id}>
<td className={TD}><input type="checkbox" checked={!!checkedW[w.id]} onChange={e=>setCheckedW({...checkedW,[w.id]:e.target.checked})}/></td>
<td className={TD}>{w.work_type}</td><td className={TD}>{w.job_type}</td>
<td className={TDR}>{num(w.prev_cumulative).toLocaleString()}</td><td className={TDR}>{num(w.today_count).toLocaleString()}</td><td className={TDR}>{(num(w.prev_cumulative)+num(w.today_count)).toLocaleString()}</td>
</tr>))}
{workers.length===0&&<tr><td className={TD} colSpan={6}><span className="text-gray-400">데이터가 없습니다</span></td></tr>}
</tbody>
</table>
</div>)}
{/* ─── 장비 탭 ─── */}
{tab==='equipments'&&(<div>
{!isReadOnly&&<div className="flex justify-end gap-2 mb-2">
<button onClick={lightning} className="text-yellow-500 hover:text-yellow-600 text-xl" title="랜덤 데이터"><i className="ri-flashlight-line"></i></button>
<button onClick={deleteChecked} className="text-red-400 hover:text-red-600 text-sm border border-red-300 rounded px-2 py-1">선택삭제</button>
</div>}
<table className="w-full border-collapse border border-gray-300">
<thead><tr><th className={TH} style={{width:30}}><input type="checkbox" onChange={e=>{const c={};equipments.forEach(eq=>c[eq.id]=e.target.checked);setCheckedE(c)}}/></th><th className={TH}>장비명</th><th className={TH}>규격</th><th className={TH} style={{width:100}}>전일누계</th><th className={TH} style={{width:80}}>금일</th><th className={TH} style={{width:100}}>총계</th></tr></thead>
<tbody>
<tr className="bg-gray-50 font-semibold"><td className={TD}></td><td className={TD} colSpan={2}>누계</td><td className={TDR}>{eSum.prev.toLocaleString()}</td><td className={TDR}>{eSum.today.toLocaleString()}</td><td className={TDR}>{(eSum.prev+eSum.today).toLocaleString()}</td></tr>
{equipments.map(e=>(<tr key={e.id}>
<td className={TD}><input type="checkbox" checked={!!checkedE[e.id]} onChange={ev=>setCheckedE({...checkedE,[e.id]:ev.target.checked})}/></td>
<td className={TD}>{e.equipment_name}</td><td className={TD}>{e.specification}</td>
<td className={TDR}>{num(e.prev_cumulative).toLocaleString()}</td><td className={TDR}>{num(e.today_count).toLocaleString()}</td><td className={TDR}>{(num(e.prev_cumulative)+num(e.today_count)).toLocaleString()}</td>
</tr>))}
{equipments.length===0&&<tr><td className={TD} colSpan={6}><span className="text-gray-400">데이터가 없습니다</span></td></tr>}
</tbody>
</table>
</div>)}
{/* ─── 자재 탭 ─── */}
{tab==='materials'&&(<div>
{!isReadOnly&&<div className="flex justify-end gap-2 mb-2">
<button onClick={lightning} className="text-yellow-500 hover:text-yellow-600 text-xl" title="랜덤 데이터"><i className="ri-flashlight-line"></i></button>
<button onClick={deleteChecked} className="text-red-400 hover:text-red-600 text-sm border border-red-300 rounded px-2 py-1">선택삭제</button>
</div>}
<table className="w-full border-collapse border border-gray-300">
<thead><tr><th className={TH} style={{width:30}}><input type="checkbox" onChange={e=>{const c={};materials.forEach(m=>c[m.id]=e.target.checked);setCheckedM(c)}}/></th><th className={TH}>자재명</th><th className={TH}>규격</th><th className={TH} style={{width:60}}>단위</th><th className={TH} style={{width:80}}>설계량</th><th className={TH} style={{width:90}}>전일누계</th><th className={TH} style={{width:70}}>금일</th><th className={TH} style={{width:90}}>총계</th></tr></thead>
<tbody>
{materials.map(m=>(<tr key={m.id}>
<td className={TD}><input type="checkbox" checked={!!checkedM[m.id]} onChange={e=>setCheckedM({...checkedM,[m.id]:e.target.checked})}/></td>
<td className={TD}>{m.material_name}</td><td className={TD}>{m.specification}</td><td className={TD}>{m.unit}</td>
<td className={TDR}>{num(m.design_qty).toLocaleString()}</td><td className={TDR}>{num(m.prev_cumulative).toLocaleString()}</td><td className={TDR}>{num(m.today_count).toLocaleString()}</td><td className={TDR}>{(num(m.prev_cumulative)+num(m.today_count)).toLocaleString()}</td>
</tr>))}
{materials.length===0&&<tr><td className={TD} colSpan={8}><span className="text-gray-400">데이터가 없습니다</span></td></tr>}
</tbody>
</table>
</div>)}
{/* ─── 공사량 탭 ─── */}
{tab==='volumes'&&(<div>
{!isReadOnly&&<div className="flex justify-end gap-2 mb-2">
<button onClick={lightning} className="text-yellow-500 hover:text-yellow-600 text-xl" title="랜덤 데이터"><i className="ri-flashlight-line"></i></button>
<button onClick={deleteChecked} className="text-red-400 hover:text-red-600 text-sm border border-red-300 rounded px-2 py-1">선택삭제</button>
</div>}
<table className="w-full border-collapse border border-gray-300">
<thead><tr><th className={TH} style={{width:30}}><input type="checkbox" onChange={e=>{const c={};volumes.forEach(v=>c[v.id]=e.target.checked);setCheckedV(c)}}/></th><th className={TH}>공종</th><th className={TH}>세부공종</th><th className={TH} style={{width:60}}>단위</th><th className={TH} style={{width:80}}>설계량</th><th className={TH} style={{width:90}}>전일누계</th><th className={TH} style={{width:70}}>금일</th><th className={TH} style={{width:90}}>총계</th></tr></thead>
<tbody>
{volumes.map(v=>(<tr key={v.id}>
<td className={TD}><input type="checkbox" checked={!!checkedV[v.id]} onChange={e=>setCheckedV({...checkedV,[v.id]:e.target.checked})}/></td>
<td className={TD}>{v.work_type}</td><td className={TD}>{v.sub_work_type}</td><td className={TD}>{v.unit}</td>
<td className={TDR}>{num(v.design_qty).toLocaleString()}</td><td className={TDR}>{num(v.prev_cumulative).toLocaleString()}</td><td className={TDR}>{num(v.today_count).toLocaleString()}</td><td className={TDR}>{(num(v.prev_cumulative)+num(v.today_count)).toLocaleString()}</td>
</tr>))}
{volumes.length===0&&<tr><td className={TD} colSpan={8}><span className="text-gray-400">데이터가 없습니다</span></td></tr>}
</tbody>
</table>
</div>)}
{/* ─── 작업사진 탭 ─── */}
{tab==='photos'&&(<div className="flex gap-4" style={{minHeight:400}}>
<div className="flex-1">
<div className="text-sm font-bold text-gray-700 mb-2">사진목록</div>
{!isReadOnly&&<div className="flex justify-end gap-2 mb-2">
<button onClick={deleteChecked} className="text-red-400 hover:text-red-600 text-sm border border-red-300 rounded px-2 py-1">선택삭제</button>
</div>}
<table className="w-full border-collapse border border-gray-300">
<thead><tr><th className={TH} style={{width:30}}><input type="checkbox" onChange={e=>{const c={};photos.forEach(p=>c[p.id]=e.target.checked);setCheckedP(c)}}/></th><th className={TH}>사진</th><th className={TH}>위치</th><th className={TH}>내용</th><th className={TH} style={{width:90}}>날짜</th></tr></thead>
<tbody>
{photos.map(p=>(<tr key={p.id}>
<td className={TD}><input type="checkbox" checked={!!checkedP[p.id]} onChange={e=>setCheckedP({...checkedP,[p.id]:e.target.checked})}/></td>
<td className={TD}>{p.photo_path?<img src={`/storage/${p.photo_path}`} className="w-16 h-16 object-cover rounded mx-auto"/>:<span className="text-gray-300"><i className="ri-image-line text-xl"></i></span>}</td>
<td className={TD}>{p.location}</td><td className={TD}>{p.content}</td><td className={TD}>{p.photo_date?.slice(0,10)}</td>
</tr>))}
{photos.length===0&&<tr><td className={TD} colSpan={5}><span className="text-gray-400">사진이 없습니다</span></td></tr>}
</tbody>
</table>
</div>
<div className="shrink-0 border-l pl-4" style={{width:320}}>
<div className="text-sm font-bold text-gray-700 mb-3">사진정보</div>
{!isReadOnly&&<PhotoUploadForm reportId={report?.id} onUploaded={loadReport}/>}
</div>
</div>)}
</div>}
</div>
</div>
<ReviewerModal open={showReviewer} onClose={()=>setShowReviewer(false)} reviewers={report?.options?.reviewers||[]}
onSave={async(list)=>{if(report?.id){try{await api(`/daily-work-reports/${report.id}/reviewers`,{method:'PUT',body:JSON.stringify({reviewers:list})});loadReport()}catch(e){alert(e.message)}}}}/>
<PrintViewer open={showPrint} onClose={()=>setShowPrint(false)} report={report} workers={workers} equipments={equipments} materials={materials} volumes={volumes}/>
</div>);
}
/* ═══════ 사진 업로드 폼 ═══════ */
function PhotoUploadForm({reportId,onUploaded}){
const[loc,setLoc]=useState('');
const[cont,setCont]=useState('');
const fileRef=useRef(null);
const[uploading,setUploading]=useState(false);
const submit=async()=>{
if(!reportId)return;
setUploading(true);
try{
const fd=new FormData();
fd.append('report_id',reportId);
fd.append('location',loc);
fd.append('content',cont);
if(fileRef.current?.files[0])fd.append('photo',fileRef.current.files[0]);
await apiForm('/work-report-photos',fd);
setLoc('');setCont('');if(fileRef.current)fileRef.current.value='';
onUploaded();
}catch(e){alert(e.message)}
setUploading(false);
};
return(
<div className="space-y-3">
<div><label className="text-xs text-gray-500 block mb-1">* 위치</label><input value={loc} onChange={e=>setLoc(e.target.value)} className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"/></div>
<div><label className="text-xs text-gray-500 block mb-1">* 내용</label><input value={cont} onChange={e=>setCont(e.target.value)} className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"/></div>
<div><label className="text-xs text-gray-500 block mb-1">* 첨부파일</label><input ref={fileRef} type="file" accept="image/*" className="w-full text-sm"/></div>
<button onClick={submit} disabled={uploading} className="w-full bg-blue-600 text-white py-2 rounded text-sm hover:bg-blue-700 disabled:opacity-50">{uploading?'업로드 중...':'사진 추가'}</button>
</div>);
}
ReactDOM.render(<App/>, document.getElementById('root'));
@endverbatim
</script>
@endpush