- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항 - 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제 - 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일 - 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치 - 품질관리: 시정조치 UI 페이지 - 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드 - 작업일보/출면일보 오류 수정 및 기능 개선 - 설비 사진 업로드, 근로계약서 종료일 수정
632 lines
51 KiB
PHP
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"><</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">></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
|