428 lines
27 KiB
PHP
428 lines
27 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '프로젝트관리/기성청구')
|
|
|
|
@section('content')
|
|
<div id="root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
<script type="text/babel">
|
|
const { useState, useMemo } = React;
|
|
|
|
// --- 데이터 ---
|
|
const initialProjects = [
|
|
{
|
|
id: 'PJ-001',
|
|
name: '힐스테이트 선화더와이즈',
|
|
client: '현대건설',
|
|
siteCount: 31,
|
|
contractAmount: 4331000000,
|
|
billedAmount: 289000000,
|
|
laborPaid: 4580000,
|
|
progress: 32,
|
|
sites: [
|
|
{ id: 'S-001', name: 'A동 지하1층', type: '방화셔터 2.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 18500000 },
|
|
{ id: 'S-002', name: 'A동 1층 로비', type: '방화셔터 3.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '지급완료', amount: 22000000 },
|
|
{ id: 'S-003', name: 'A동 2층', type: '방화셔터 2.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
|
{ id: 'S-004', name: 'A동 3층', type: '방화셔터 2.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
|
{ id: 'S-005', name: 'B동 지하1층', type: '방화셔터 2.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 18500000 },
|
|
{ id: 'S-006', name: 'B동 1층', type: '방화셔터 3.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 22000000 },
|
|
{ id: 'S-007', name: 'B동 2층', type: '방화셔터 2.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
|
{ id: 'S-008', name: 'B동 3층', type: '방화셔터 2.0m', status: '미착수', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
|
{ id: 'S-009', name: 'C동 지하1층', type: '방화셔터 2.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '미지급', amount: 18500000 },
|
|
{ id: 'S-010', name: 'C동 1층', type: '방화셔터 3.5m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 28000000 },
|
|
{ id: 'S-011', name: 'C동 2층', type: '방화셔터 2.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 15000000 },
|
|
{ id: 'S-012', name: 'D동 지하1층', type: '방화셔터 2.5m', status: '미착수', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 18500000 },
|
|
],
|
|
pendingOrders: 8,
|
|
pendingBilling: 6,
|
|
pendingLabor: 7,
|
|
},
|
|
{
|
|
id: 'PJ-002',
|
|
name: '충주 현대모비스 공장동',
|
|
client: '현대모비스',
|
|
siteCount: 28,
|
|
contractAmount: 3897000000,
|
|
billedAmount: 684000000,
|
|
laborPaid: 9130000,
|
|
progress: 50,
|
|
sites: [
|
|
{ id: 'S-101', name: '1공장 A라인', type: '방화셔터 4.0m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 35000000 },
|
|
{ id: 'S-102', name: '1공장 B라인', type: '방화셔터 4.0m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 35000000 },
|
|
{ id: 'S-103', name: '1공장 C라인', type: '방화셔터 3.5m', status: '시공완료', orderStatus: '발주완료', billingStatus: '청구완료', laborStatus: '지급완료', amount: 28000000 },
|
|
{ id: 'S-104', name: '2공장 A라인', type: '방화셔터 4.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 35000000 },
|
|
{ id: 'S-105', name: '2공장 B라인', type: '방화셔터 4.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 35000000 },
|
|
{ id: 'S-106', name: '2공장 C라인', type: '방화셔터 3.5m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 28000000 },
|
|
{ id: 'S-107', name: '3공장 메인홀', type: '방화셔터 5.0m', status: '자재대기', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 42000000 },
|
|
{ id: 'S-108', name: '3공장 A라인', type: '방화셔터 3.5m', status: '미착수', orderStatus: '발주대기', billingStatus: '청구대기', laborStatus: '미지급', amount: 28000000 },
|
|
{ id: 'S-109', name: '물류동 입구', type: '방화셔터 5.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 42000000 },
|
|
{ id: 'S-110', name: '물류동 적재구역', type: '방화셔터 4.0m', status: '시공중', orderStatus: '발주완료', billingStatus: '청구대기', laborStatus: '미지급', amount: 35000000 },
|
|
],
|
|
pendingOrders: 4,
|
|
pendingBilling: 7,
|
|
pendingLabor: 10,
|
|
},
|
|
];
|
|
|
|
function fmt(n) { return n == null ? '-' : n.toLocaleString('ko-KR'); }
|
|
function fmtBillion(n) { return n >= 100000000 ? (n / 100000000).toFixed(2) + '억' : fmt(Math.round(n / 10000)) + '만원'; }
|
|
|
|
const statusColors = {
|
|
'시공완료': 'bg-green-100 text-green-700',
|
|
'시공중': 'bg-blue-100 text-blue-700',
|
|
'자재대기': 'bg-yellow-100 text-yellow-700',
|
|
'미착수': 'bg-gray-100 text-gray-600',
|
|
'발주완료': 'bg-green-100 text-green-700',
|
|
'발주대기': 'bg-red-100 text-red-700',
|
|
'청구완료': 'bg-green-100 text-green-700',
|
|
'청구대기': 'bg-orange-100 text-orange-700',
|
|
'지급완료': 'bg-green-100 text-green-700',
|
|
'미지급': 'bg-purple-100 text-purple-700',
|
|
};
|
|
|
|
function Badge({ text }) {
|
|
const cls = statusColors[text] || 'bg-gray-100 text-gray-600';
|
|
return <span className={`px-2 py-0.5 rounded text-xs font-medium ${cls}`}>{text}</span>;
|
|
}
|
|
|
|
function Toast({ message, onClose }) {
|
|
React.useEffect(() => { const t = setTimeout(onClose, 2500); return () => clearTimeout(t); }, []);
|
|
return (
|
|
<div className="fixed top-6 right-6 z-50 bg-slate-800 text-white px-5 py-3 rounded-lg shadow-lg text-sm">
|
|
{message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 사이트 상세 모달 ---
|
|
function SiteDetailModal({ site, onClose, onUpdate }) {
|
|
if (!site) return null;
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg m-4" onClick={e => e.stopPropagation()}>
|
|
<div className="px-6 py-4 border-b flex justify-between items-center">
|
|
<h3 className="text-lg font-bold">{site.name}</h3>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
|
</div>
|
|
<div className="px-6 py-4 space-y-4">
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div><span className="text-gray-500">설비:</span> <span className="font-medium">{site.type}</span></div>
|
|
<div><span className="text-gray-500">금액:</span> <span className="font-bold text-blue-600">{fmt(site.amount)}원</span></div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div><p className="text-xs text-gray-500">시공 상태</p><Badge text={site.status} /></div>
|
|
{site.status !== '시공완료' && (
|
|
<button onClick={() => {
|
|
const next = { '미착수': '자재대기', '자재대기': '시공중', '시공중': '시공완료' };
|
|
if (next[site.status]) onUpdate(site.id, 'status', next[site.status]);
|
|
}} className="px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600">
|
|
다음 단계
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div><p className="text-xs text-gray-500">발주 상태</p><Badge text={site.orderStatus} /></div>
|
|
{site.orderStatus === '발주대기' && (
|
|
<button onClick={() => onUpdate(site.id, 'orderStatus', '발주완료')}
|
|
className="px-3 py-1 bg-emerald-500 text-white rounded text-xs hover:bg-emerald-600">발주처리</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div><p className="text-xs text-gray-500">기성청구</p><Badge text={site.billingStatus} /></div>
|
|
{site.billingStatus === '청구대기' && site.status === '시공완료' && (
|
|
<button onClick={() => onUpdate(site.id, 'billingStatus', '청구완료')}
|
|
className="px-3 py-1 bg-orange-500 text-white rounded text-xs hover:bg-orange-600">청구처리</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div><p className="text-xs text-gray-500">노임지급</p><Badge text={site.laborStatus} /></div>
|
|
{site.laborStatus === '미지급' && (
|
|
<button onClick={() => onUpdate(site.id, 'laborStatus', '지급완료')}
|
|
className="px-3 py-1 bg-purple-500 text-white rounded text-xs hover:bg-purple-600">지급처리</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-3 border-t flex justify-end">
|
|
<button onClick={onClose} className="px-4 py-2 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 프로젝트 상세 뷰 ---
|
|
function ProjectDetail({ project, onBack, onUpdateSite, toast: showToast }) {
|
|
const [filter, setFilter] = useState('all');
|
|
const [selectedSite, setSelectedSite] = useState(null);
|
|
|
|
const sites = project.sites;
|
|
const filtered = filter === 'all' ? sites
|
|
: filter === 'pending_order' ? sites.filter(s => s.orderStatus === '발주대기')
|
|
: filter === 'pending_billing' ? sites.filter(s => s.billingStatus === '청구대기')
|
|
: filter === 'pending_labor' ? sites.filter(s => s.laborStatus === '미지급')
|
|
: filter === 'in_progress' ? sites.filter(s => s.status === '시공중')
|
|
: filter === 'completed' ? sites.filter(s => s.status === '시공완료')
|
|
: sites;
|
|
|
|
const stats = useMemo(() => ({
|
|
completed: sites.filter(s => s.status === '시공완료').length,
|
|
inProgress: sites.filter(s => s.status === '시공중').length,
|
|
waiting: sites.filter(s => s.status === '자재대기').length,
|
|
notStarted: sites.filter(s => s.status === '미착수').length,
|
|
pendingOrder: sites.filter(s => s.orderStatus === '발주대기').length,
|
|
pendingBilling: sites.filter(s => s.billingStatus === '청구대기').length,
|
|
pendingLabor: sites.filter(s => s.laborStatus === '미지급').length,
|
|
}), [sites]);
|
|
|
|
const handleUpdate = (siteId, field, value) => {
|
|
onUpdateSite(project.id, siteId, field, value);
|
|
setSelectedSite(prev => prev ? { ...prev, [field]: value } : null);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={onBack} className="px-3 py-2 bg-white border rounded-lg text-sm hover:bg-gray-50 flex items-center gap-1">
|
|
← 목록으로
|
|
</button>
|
|
<div>
|
|
<h2 className="text-2xl font-bold">{project.name}</h2>
|
|
<p className="text-sm text-gray-500">{project.client} | {project.siteCount}개소</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 프로젝트 요약 */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">계약금액</p>
|
|
<p className="text-xl font-bold text-blue-600">{fmtBillion(project.contractAmount)}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">기성청구</p>
|
|
<p className="text-xl font-bold text-green-600">{fmtBillion(project.billedAmount)}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">노임지급</p>
|
|
<p className="text-xl font-bold text-purple-600">{fmtBillion(project.laborPaid)}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">공정 진행률</p>
|
|
<p className="text-xl font-bold text-orange-600">{project.progress}%</p>
|
|
<div className="mt-2 h-2 bg-gray-200 rounded-full"><div className="h-full bg-orange-500 rounded-full" style={{width:`${project.progress}%`}}></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 시공 현황 바 */}
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">시공 현황</h3>
|
|
<div className="flex gap-3 flex-wrap">
|
|
<button onClick={() => setFilter('completed')} className="flex items-center gap-1 px-3 py-1.5 bg-green-50 border border-green-200 rounded-lg text-sm hover:bg-green-100">
|
|
<span className="w-2 h-2 bg-green-500 rounded-full"></span> 시공완료 <span className="font-bold">{stats.completed}</span>
|
|
</button>
|
|
<button onClick={() => setFilter('in_progress')} className="flex items-center gap-1 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-lg text-sm hover:bg-blue-100">
|
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span> 시공중 <span className="font-bold">{stats.inProgress}</span>
|
|
</button>
|
|
<span className="flex items-center gap-1 px-3 py-1.5 bg-yellow-50 border border-yellow-200 rounded-lg text-sm">
|
|
<span className="w-2 h-2 bg-yellow-500 rounded-full"></span> 자재대기 <span className="font-bold">{stats.waiting}</span>
|
|
</span>
|
|
<span className="flex items-center gap-1 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm">
|
|
<span className="w-2 h-2 bg-gray-400 rounded-full"></span> 미착수 <span className="font-bold">{stats.notStarted}</span>
|
|
</span>
|
|
<div className="border-l mx-1"></div>
|
|
<button onClick={() => setFilter('pending_order')} className="flex items-center gap-1 px-3 py-1.5 bg-red-50 border border-red-200 rounded-lg text-sm hover:bg-red-100">
|
|
발주대기 <span className="font-bold text-red-600">{stats.pendingOrder}</span>
|
|
</button>
|
|
<button onClick={() => setFilter('pending_billing')} className="flex items-center gap-1 px-3 py-1.5 bg-orange-50 border border-orange-200 rounded-lg text-sm hover:bg-orange-100">
|
|
기성청구대기 <span className="font-bold text-orange-600">{stats.pendingBilling}</span>
|
|
</button>
|
|
<button onClick={() => setFilter('pending_labor')} className="flex items-center gap-1 px-3 py-1.5 bg-purple-50 border border-purple-200 rounded-lg text-sm hover:bg-purple-100">
|
|
노임미지급 <span className="font-bold text-purple-600">{stats.pendingLabor}</span>
|
|
</button>
|
|
{filter !== 'all' && (
|
|
<button onClick={() => setFilter('all')} className="px-3 py-1.5 bg-slate-700 text-white rounded-lg text-sm hover:bg-slate-800">
|
|
전체보기
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 개소별 테이블 */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="px-5 py-4 border-b flex justify-between items-center">
|
|
<h3 className="font-semibold">개소별 현황 ({filtered.length}건)</h3>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">개소</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600">설비</th>
|
|
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">시공상태</th>
|
|
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">발주</th>
|
|
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">기성청구</th>
|
|
<th className="px-4 py-3 text-center text-xs font-semibold text-gray-600">노임</th>
|
|
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600">금액</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{filtered.map(site => (
|
|
<tr key={site.id} className="hover:bg-blue-50 cursor-pointer" onClick={() => setSelectedSite(site)}>
|
|
<td className="px-4 py-3 text-sm font-medium text-blue-600">{site.name}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{site.type}</td>
|
|
<td className="px-4 py-3 text-center"><Badge text={site.status} /></td>
|
|
<td className="px-4 py-3 text-center"><Badge text={site.orderStatus} /></td>
|
|
<td className="px-4 py-3 text-center"><Badge text={site.billingStatus} /></td>
|
|
<td className="px-4 py-3 text-center"><Badge text={site.laborStatus} /></td>
|
|
<td className="px-4 py-3 text-sm text-right font-medium">{fmt(site.amount)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedSite && <SiteDetailModal site={selectedSite} onClose={() => setSelectedSite(null)} onUpdate={handleUpdate} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 대시보드 (메인) ---
|
|
function Dashboard({ projects, onSelectProject }) {
|
|
const totals = useMemo(() => ({
|
|
contract: projects.reduce((s, p) => s + p.contractAmount, 0),
|
|
billed: projects.reduce((s, p) => s + p.billedAmount, 0),
|
|
labor: projects.reduce((s, p) => s + p.laborPaid, 0),
|
|
pending: projects.reduce((s, p) => s + p.pendingOrders + p.pendingBilling + p.pendingLabor, 0),
|
|
}), [projects]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h2 className="text-2xl font-bold">전체 프로젝트 현황</h2>
|
|
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">총 계약금액</p>
|
|
<p className="text-2xl font-bold text-blue-600">{fmtBillion(totals.contract)}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">기성청구 누계</p>
|
|
<p className="text-2xl font-bold text-green-600">{fmtBillion(totals.billed)}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">노임지급 누계</p>
|
|
<p className="text-2xl font-bold text-purple-600">{fmtBillion(totals.labor)}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<p className="text-sm text-gray-500">처리 필요</p>
|
|
<p className="text-2xl font-bold text-red-600">{totals.pending}건</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{projects.map(pj => (
|
|
<div key={pj.id} onClick={() => onSelectProject(pj.id)}
|
|
className="bg-white rounded-lg shadow hover:shadow-lg cursor-pointer transition-shadow p-5">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-bold">{pj.name}</h3>
|
|
<p className="text-sm text-gray-500">{pj.client}</p>
|
|
</div>
|
|
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">{pj.siteCount}개소</span>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
<div><p className="text-xs text-gray-500">계약금액</p><p className="font-bold text-blue-600">{fmtBillion(pj.contractAmount)}</p></div>
|
|
<div><p className="text-xs text-gray-500">기성청구</p><p className="font-bold text-green-600">{fmtBillion(pj.billedAmount)}</p></div>
|
|
<div><p className="text-xs text-gray-500">노임지급</p><p className="font-bold text-purple-600">{fmtBillion(pj.laborPaid)}</p></div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div className="h-full bg-orange-500" style={{width: `${pj.progress}%`}}></div>
|
|
</div>
|
|
<span className="text-xs font-medium w-10 text-right">{pj.progress}%</span>
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t flex gap-2 flex-wrap">
|
|
{pj.pendingOrders > 0 && <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">발주대기 {pj.pendingOrders}</span>}
|
|
{pj.pendingBilling > 0 && <span className="px-2 py-1 bg-orange-100 text-orange-700 rounded text-xs">기성청구대기 {pj.pendingBilling}</span>}
|
|
{pj.pendingLabor > 0 && <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">노임미지급 {pj.pendingLabor}</span>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 앱 ---
|
|
function App() {
|
|
const [projects, setProjects] = useState(initialProjects);
|
|
const [selectedProjectId, setSelectedProjectId] = useState(null);
|
|
const [toast, setToast] = useState(null);
|
|
|
|
const showToast = (msg) => setToast(msg);
|
|
|
|
const handleUpdateSite = (projectId, siteId, field, value) => {
|
|
setProjects(prev => prev.map(pj => {
|
|
if (pj.id !== projectId) return pj;
|
|
const newSites = pj.sites.map(s => s.id === siteId ? { ...s, [field]: value } : s);
|
|
const pendingOrders = newSites.filter(s => s.orderStatus === '발주대기').length;
|
|
const pendingBilling = newSites.filter(s => s.billingStatus === '청구대기').length;
|
|
const pendingLabor = newSites.filter(s => s.laborStatus === '미지급').length;
|
|
const completed = newSites.filter(s => s.status === '시공완료').length;
|
|
const progress = Math.round((completed / newSites.length) * 100);
|
|
const billedAmount = newSites.filter(s => s.billingStatus === '청구완료').reduce((sum, s) => sum + s.amount, 0);
|
|
const laborPaid = newSites.filter(s => s.laborStatus === '지급완료').length * 650000;
|
|
|
|
return { ...pj, sites: newSites, pendingOrders, pendingBilling, pendingLabor, progress, billedAmount, laborPaid };
|
|
}));
|
|
|
|
const labels = { status: '시공상태', orderStatus: '발주', billingStatus: '기성청구', laborStatus: '노임' };
|
|
showToast(`${labels[field] || field}: ${value} 처리 완료`);
|
|
};
|
|
|
|
const selectedProject = projects.find(p => p.id === selectedProjectId);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100">
|
|
<header className="bg-gradient-to-r from-slate-800 to-slate-900 text-white shadow-lg">
|
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-xl font-bold cursor-pointer" onClick={() => setSelectedProjectId(null)}>주일기업</h1>
|
|
<span className="text-slate-400">|</span>
|
|
<span className="text-slate-300">자동방화셔터 시공 관리 시스템</span>
|
|
</div>
|
|
{selectedProject && (
|
|
<span className="text-sm text-slate-300">{selectedProject.name}</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 py-6">
|
|
{selectedProject ? (
|
|
<ProjectDetail
|
|
project={selectedProject}
|
|
onBack={() => setSelectedProjectId(null)}
|
|
onUpdateSite={handleUpdateSite}
|
|
toast={showToast}
|
|
/>
|
|
) : (
|
|
<Dashboard projects={projects} onSelectProject={setSelectedProjectId} />
|
|
)}
|
|
</main>
|
|
|
|
{toast && <Toast message={toast} onClose={() => setToast(null)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
</script>
|
|
@endpush
|