diff --git a/app/Http/Controllers/Juil/PmisDailyWorkReportController.php b/app/Http/Controllers/Juil/PmisDailyWorkReportController.php new file mode 100644 index 00000000..7b6b8887 --- /dev/null +++ b/app/Http/Controllers/Juil/PmisDailyWorkReportController.php @@ -0,0 +1,314 @@ +input('date', now()->toDateString()); + $company = $request->input('company', ''); + + $report = PmisDailyWorkReport::tenant($this->tenantId()) + ->where('date', $date) + ->when($company, fn ($q) => $q->where('company_name', $company)) + ->first(); + + if (! $report) { + $report = PmisDailyWorkReport::create([ + 'tenant_id' => $this->tenantId(), + 'date' => $date, + 'company_name' => $company, + 'weather' => '맑음', + 'status' => 'draft', + ]); + } + + $report->load(['workers', 'equipments', 'materials', 'volumes', 'photos']); + + return response()->json($report); + } + + public function monthStatus(Request $request): JsonResponse + { + $year = $request->integer('year', now()->year); + $month = $request->integer('month', now()->month); + $company = $request->input('company', ''); + + $reports = PmisDailyWorkReport::tenant($this->tenantId()) + ->whereYear('date', $year) + ->whereMonth('date', $month) + ->when($company, fn ($q) => $q->where('company_name', $company)) + ->withCount(['workers', 'equipments', 'materials', 'volumes', 'photos']) + ->get(); + + $result = []; + foreach ($reports as $r) { + $day = (int) $r->date->format('d'); + $hasData = $r->workers_count > 0 || $r->equipments_count > 0 + || $r->materials_count > 0 || $r->volumes_count > 0 + || $r->photos_count > 0 + || $r->work_content_today; + if ($hasData) { + $result[$day] = $r->status; + } + } + + return response()->json($result); + } + + public function update(Request $request, int $id): JsonResponse + { + $report = PmisDailyWorkReport::tenant($this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'weather' => 'sometimes|string|max:50', + 'temp_low' => 'sometimes|nullable|numeric', + 'temp_high' => 'sometimes|nullable|numeric', + 'precipitation' => 'sometimes|nullable|numeric', + 'snowfall' => 'sometimes|nullable|numeric', + 'fine_dust' => 'sometimes|nullable|string|max:50', + 'ultra_fine_dust' => 'sometimes|nullable|string|max:50', + 'work_content_today' => 'sometimes|nullable|string', + 'work_content_tomorrow' => 'sometimes|nullable|string', + 'notes' => 'sometimes|nullable|string', + 'status' => 'sometimes|in:draft,review,approved', + 'options' => 'sometimes|nullable|array', + ]); + + $report->update($validated); + + return response()->json($report); + } + + public function destroy(int $id): JsonResponse + { + $report = PmisDailyWorkReport::tenant($this->tenantId())->findOrFail($id); + $report->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + public function saveReviewers(Request $request, int $id): JsonResponse + { + $report = PmisDailyWorkReport::tenant($this->tenantId())->findOrFail($id); + $reviewers = $request->input('reviewers', []); + $options = $report->options ?? []; + $options['reviewers'] = $reviewers; + $report->update(['options' => $options]); + + return response()->json(['message' => '검토자가 저장되었습니다.']); + } + + // ─── Worker CRUD ─── + + public function workerStore(Request $request): JsonResponse + { + $v = $request->validate([ + 'report_id' => 'required|integer|exists:pmis_daily_work_reports,id', + 'work_type' => 'required|string|max:200', + 'job_type' => 'required|string|max:200', + 'prev_cumulative' => 'nullable|integer|min:0', + 'today_count' => 'nullable|integer|min:0', + ]); + $v['tenant_id'] = $this->tenantId(); + $v['prev_cumulative'] = $v['prev_cumulative'] ?? 0; + $v['today_count'] = $v['today_count'] ?? 0; + $v['sort_order'] = (PmisWorkReportWorker::where('report_id', $v['report_id'])->max('sort_order') ?? 0) + 1; + + return response()->json(PmisWorkReportWorker::create($v), 201); + } + + public function workerUpdate(Request $request, int $id): JsonResponse + { + $w = PmisWorkReportWorker::where('tenant_id', $this->tenantId())->findOrFail($id); + $w->update($request->validate([ + 'work_type' => 'sometimes|string|max:200', + 'job_type' => 'sometimes|string|max:200', + 'prev_cumulative' => 'nullable|integer|min:0', + 'today_count' => 'nullable|integer|min:0', + ])); + + return response()->json($w); + } + + public function workerDestroy(int $id): JsonResponse + { + PmisWorkReportWorker::where('tenant_id', $this->tenantId())->findOrFail($id)->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ─── Equipment CRUD ─── + + public function equipmentStore(Request $request): JsonResponse + { + $v = $request->validate([ + 'report_id' => 'required|integer|exists:pmis_daily_work_reports,id', + 'equipment_name' => 'required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'prev_cumulative' => 'nullable|integer|min:0', + 'today_count' => 'nullable|integer|min:0', + ]); + $v['tenant_id'] = $this->tenantId(); + $v['specification'] = $v['specification'] ?? ''; + $v['prev_cumulative'] = $v['prev_cumulative'] ?? 0; + $v['today_count'] = $v['today_count'] ?? 0; + $v['sort_order'] = (PmisWorkReportEquipment::where('report_id', $v['report_id'])->max('sort_order') ?? 0) + 1; + + return response()->json(PmisWorkReportEquipment::create($v), 201); + } + + public function equipmentUpdate(Request $request, int $id): JsonResponse + { + $e = PmisWorkReportEquipment::where('tenant_id', $this->tenantId())->findOrFail($id); + $e->update($request->validate([ + 'equipment_name' => 'sometimes|string|max:200', + 'specification' => 'nullable|string|max:300', + 'prev_cumulative' => 'nullable|integer|min:0', + 'today_count' => 'nullable|integer|min:0', + ])); + + return response()->json($e); + } + + public function equipmentDestroy(int $id): JsonResponse + { + PmisWorkReportEquipment::where('tenant_id', $this->tenantId())->findOrFail($id)->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ─── Material CRUD ─── + + public function materialStore(Request $request): JsonResponse + { + $v = $request->validate([ + 'report_id' => 'required|integer|exists:pmis_daily_work_reports,id', + 'material_name' => 'required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'unit' => 'nullable|string|max:50', + 'design_qty' => 'nullable|numeric|min:0', + 'prev_cumulative' => 'nullable|numeric|min:0', + 'today_count' => 'nullable|numeric|min:0', + ]); + $v['tenant_id'] = $this->tenantId(); + $v['sort_order'] = (PmisWorkReportMaterial::where('report_id', $v['report_id'])->max('sort_order') ?? 0) + 1; + + return response()->json(PmisWorkReportMaterial::create($v), 201); + } + + public function materialUpdate(Request $request, int $id): JsonResponse + { + $m = PmisWorkReportMaterial::where('tenant_id', $this->tenantId())->findOrFail($id); + $m->update($request->validate([ + 'material_name' => 'sometimes|string|max:200', + 'specification' => 'nullable|string|max:300', + 'unit' => 'nullable|string|max:50', + 'design_qty' => 'nullable|numeric|min:0', + 'prev_cumulative' => 'nullable|numeric|min:0', + 'today_count' => 'nullable|numeric|min:0', + ])); + + return response()->json($m); + } + + public function materialDestroy(int $id): JsonResponse + { + PmisWorkReportMaterial::where('tenant_id', $this->tenantId())->findOrFail($id)->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ─── Volume CRUD ─── + + public function volumeStore(Request $request): JsonResponse + { + $v = $request->validate([ + 'report_id' => 'required|integer|exists:pmis_daily_work_reports,id', + 'work_type' => 'required|string|max:200', + 'sub_work_type' => 'nullable|string|max:200', + 'unit' => 'nullable|string|max:50', + 'design_qty' => 'nullable|numeric|min:0', + 'prev_cumulative' => 'nullable|numeric|min:0', + 'today_count' => 'nullable|numeric|min:0', + ]); + $v['tenant_id'] = $this->tenantId(); + $v['sort_order'] = (PmisWorkReportVolume::where('report_id', $v['report_id'])->max('sort_order') ?? 0) + 1; + + return response()->json(PmisWorkReportVolume::create($v), 201); + } + + public function volumeUpdate(Request $request, int $id): JsonResponse + { + $vol = PmisWorkReportVolume::where('tenant_id', $this->tenantId())->findOrFail($id); + $vol->update($request->validate([ + 'work_type' => 'sometimes|string|max:200', + 'sub_work_type' => 'nullable|string|max:200', + 'unit' => 'nullable|string|max:50', + 'design_qty' => 'nullable|numeric|min:0', + 'prev_cumulative' => 'nullable|numeric|min:0', + 'today_count' => 'nullable|numeric|min:0', + ])); + + return response()->json($vol); + } + + public function volumeDestroy(int $id): JsonResponse + { + PmisWorkReportVolume::where('tenant_id', $this->tenantId())->findOrFail($id)->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ─── Photo CRUD ─── + + public function photoStore(Request $request): JsonResponse + { + $v = $request->validate([ + 'report_id' => 'required|integer|exists:pmis_daily_work_reports,id', + 'location' => 'nullable|string|max:200', + 'content' => 'nullable|string|max:500', + 'photo' => 'nullable|image|max:10240', + ]); + + $path = ''; + if ($request->hasFile('photo')) { + $path = $request->file('photo')->store('pmis/work-report-photos', 'public'); + } + + $photo = PmisWorkReportPhoto::create([ + 'tenant_id' => $this->tenantId(), + 'report_id' => $v['report_id'], + 'photo_path' => $path, + 'location' => $v['location'] ?? '', + 'content' => $v['content'] ?? '', + 'photo_date' => now()->toDateString(), + 'sort_order' => (PmisWorkReportPhoto::where('report_id', $v['report_id'])->max('sort_order') ?? 0) + 1, + ]); + + return response()->json($photo, 201); + } + + public function photoDestroy(int $id): JsonResponse + { + PmisWorkReportPhoto::where('tenant_id', $this->tenantId())->findOrFail($id)->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } +} diff --git a/app/Models/Juil/PmisDailyWorkReport.php b/app/Models/Juil/PmisDailyWorkReport.php new file mode 100644 index 00000000..1ae32be0 --- /dev/null +++ b/app/Models/Juil/PmisDailyWorkReport.php @@ -0,0 +1,71 @@ + 'date', + 'temp_low' => 'decimal:1', + 'temp_high' => 'decimal:1', + 'precipitation' => 'decimal:1', + 'snowfall' => 'decimal:1', + 'options' => 'array', + ]; + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + public function workers(): HasMany + { + return $this->hasMany(PmisWorkReportWorker::class, 'report_id')->orderBy('sort_order'); + } + + public function equipments(): HasMany + { + return $this->hasMany(PmisWorkReportEquipment::class, 'report_id')->orderBy('sort_order'); + } + + public function materials(): HasMany + { + return $this->hasMany(PmisWorkReportMaterial::class, 'report_id')->orderBy('sort_order'); + } + + public function volumes(): HasMany + { + return $this->hasMany(PmisWorkReportVolume::class, 'report_id')->orderBy('sort_order'); + } + + public function photos(): HasMany + { + return $this->hasMany(PmisWorkReportPhoto::class, 'report_id')->orderBy('sort_order'); + } +} diff --git a/app/Models/Juil/PmisWorkReportEquipment.php b/app/Models/Juil/PmisWorkReportEquipment.php new file mode 100644 index 00000000..cd8354e2 --- /dev/null +++ b/app/Models/Juil/PmisWorkReportEquipment.php @@ -0,0 +1,36 @@ + 'integer', + 'today_count' => 'integer', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(PmisDailyWorkReport::class, 'report_id'); + } +} diff --git a/app/Models/Juil/PmisWorkReportMaterial.php b/app/Models/Juil/PmisWorkReportMaterial.php new file mode 100644 index 00000000..a3715a31 --- /dev/null +++ b/app/Models/Juil/PmisWorkReportMaterial.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'prev_cumulative' => 'decimal:2', + 'today_count' => 'decimal:2', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(PmisDailyWorkReport::class, 'report_id'); + } +} diff --git a/app/Models/Juil/PmisWorkReportPhoto.php b/app/Models/Juil/PmisWorkReportPhoto.php new file mode 100644 index 00000000..59a027b8 --- /dev/null +++ b/app/Models/Juil/PmisWorkReportPhoto.php @@ -0,0 +1,35 @@ + 'date', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(PmisDailyWorkReport::class, 'report_id'); + } +} diff --git a/app/Models/Juil/PmisWorkReportVolume.php b/app/Models/Juil/PmisWorkReportVolume.php new file mode 100644 index 00000000..233234d7 --- /dev/null +++ b/app/Models/Juil/PmisWorkReportVolume.php @@ -0,0 +1,39 @@ + 'decimal:2', + 'prev_cumulative' => 'decimal:2', + 'today_count' => 'decimal:2', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(PmisDailyWorkReport::class, 'report_id'); + } +} diff --git a/app/Models/Juil/PmisWorkReportWorker.php b/app/Models/Juil/PmisWorkReportWorker.php new file mode 100644 index 00000000..0040fa13 --- /dev/null +++ b/app/Models/Juil/PmisWorkReportWorker.php @@ -0,0 +1,36 @@ + 'integer', + 'today_count' => 'integer', + 'options' => 'array', + ]; + + public function report(): BelongsTo + { + return $this->belongsTo(PmisDailyWorkReport::class, 'report_id'); + } +} diff --git a/database/migrations/2026_03_12_190000_create_pmis_daily_work_reports_table.php b/database/migrations/2026_03_12_190000_create_pmis_daily_work_reports_table.php new file mode 100644 index 00000000..078b034f --- /dev/null +++ b/database/migrations/2026_03_12_190000_create_pmis_daily_work_reports_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('date'); + $table->string('company_name', 200)->default(''); + $table->string('weather', 50)->default('맑음'); + $table->decimal('temp_low', 5, 1)->nullable(); + $table->decimal('temp_high', 5, 1)->nullable(); + $table->decimal('precipitation', 8, 1)->default(0); + $table->decimal('snowfall', 8, 1)->default(0); + $table->string('fine_dust', 50)->default(''); + $table->string('ultra_fine_dust', 50)->default(''); + $table->text('work_content_today')->nullable(); + $table->text('work_content_tomorrow')->nullable(); + $table->text('notes')->nullable(); + $table->enum('status', ['draft', 'review', 'approved'])->default('draft'); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'date', 'company_name'], 'pmis_dwr_unique'); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_daily_work_reports'); + } +}; diff --git a/database/migrations/2026_03_12_190001_create_pmis_work_report_details_tables.php b/database/migrations/2026_03_12_190001_create_pmis_work_report_details_tables.php new file mode 100644 index 00000000..1b0e952f --- /dev/null +++ b/database/migrations/2026_03_12_190001_create_pmis_work_report_details_tables.php @@ -0,0 +1,104 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('report_id')->constrained('pmis_daily_work_reports')->cascadeOnDelete(); + $table->string('work_type', 200); + $table->string('job_type', 200); + $table->integer('prev_cumulative')->default(0); + $table->integer('today_count')->default(0); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index('tenant_id'); + }); + + // 장비 + Schema::create('pmis_work_report_equipments', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('report_id')->constrained('pmis_daily_work_reports')->cascadeOnDelete(); + $table->string('equipment_name', 200); + $table->string('specification', 300)->default(''); + $table->integer('prev_cumulative')->default(0); + $table->integer('today_count')->default(0); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index('tenant_id'); + }); + + // 자재 + Schema::create('pmis_work_report_materials', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('report_id')->constrained('pmis_daily_work_reports')->cascadeOnDelete(); + $table->string('material_name', 200); + $table->string('specification', 300)->default(''); + $table->string('unit', 50)->default(''); + $table->decimal('design_qty', 14, 2)->default(0); + $table->decimal('prev_cumulative', 14, 2)->default(0); + $table->decimal('today_count', 14, 2)->default(0); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index('tenant_id'); + }); + + // 공사량 + Schema::create('pmis_work_report_volumes', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('report_id')->constrained('pmis_daily_work_reports')->cascadeOnDelete(); + $table->string('work_type', 200); + $table->string('sub_work_type', 200)->default(''); + $table->string('unit', 50)->default(''); + $table->decimal('design_qty', 14, 2)->default(0); + $table->decimal('prev_cumulative', 14, 2)->default(0); + $table->decimal('today_count', 14, 2)->default(0); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index('tenant_id'); + }); + + // 작업사진 + Schema::create('pmis_work_report_photos', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->foreignId('report_id')->constrained('pmis_daily_work_reports')->cascadeOnDelete(); + $table->string('photo_path', 500)->default(''); + $table->string('location', 200)->default(''); + $table->string('content', 500)->default(''); + $table->date('photo_date')->nullable(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_work_report_photos'); + Schema::dropIfExists('pmis_work_report_volumes'); + Schema::dropIfExists('pmis_work_report_materials'); + Schema::dropIfExists('pmis_work_report_equipments'); + Schema::dropIfExists('pmis_work_report_workers'); + } +}; diff --git a/resources/views/juil/pmis-daily-report.blade.php b/resources/views/juil/pmis-daily-report.blade.php index 8ace793e..75eac0c2 100644 --- a/resources/views/juil/pmis-daily-report.blade.php +++ b/resources/views/juil/pmis-daily-report.blade.php @@ -14,104 +14,596 @@ @verbatim const { useState, useEffect, useRef, useCallback, useMemo } = React; -/* ════════════════════════════════════════════════ - PMIS 사이드바 - ════════════════════════════════════════════════ */ -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' }, - { icon: 'ri-shield-check-line', label: '안전관리', id: 'safety' }, - { icon: 'ri-folder-line', label: '자료실', id: 'archive' }, +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'}, +{icon:'ri-shield-check-line',label:'안전관리',id:'safety'}, +{icon:'ri-folder-line',label:'자료실',id:'archive'}, +]; +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( +
+ PMIS 대시보드 +
+
+ {profile?.profile_photo_path?:} +
+
{profile?.name||'...'}
+
{profile?.department||''}
+
+
+ {PMIS_MENUS.map(m=>( +
+
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'}`}> + {m.label} +
+ {expanded===m.id&&m.children?.map(c=>({c.label}))} +
))} +
+
); +} + +/* ═══════ 캘린더 스트립 ═══════ */ +function CalendarStrip({year,month,selectedDay,onSelectDay,dayStatus}){ + const days=getDaysInMonth(year,month); + const SC={draft:'#22c55e',review:'#ef4444',approved:'#3b82f6'}; + return( +
+ + {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(
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'}}> +
{dc&&
}
+
{d}
+
); + })} + +
); +} + +/* ═══════ 랜덤 데이터 ═══════ */ +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 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 ( -
- - PMIS 대시보드 - -
-
- {profile?.profile_photo_path - ? - : } +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( +
+
e.stopPropagation()}> +

검토자 지정

+
+
+
조직도
+ {ORG_TREE.map((n,i)=>(
{n.name}
+ {n.children?.map((c,j)=>(
{c.name}
+ {c.children?.map((g,k)=>(
{g.name}
))}
))} +
))} +
+
+
검토 라인
+ + {list.map((r,i)=>( + ))}
직위성명직급
{r.position}{r.name}{r.title}
-
{profile?.name || '...'}
-
{profile?.department || ''}
-
- {PMIS_MENUS.map(m => ( -
-
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'}`}> - {m.label} - +
+ + +
+
+
); +} + +/* ═══════ 양식보기 (전체화면 뷰어) ═══════ */ +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( +
+
+ + + {page} / {totalPages} + + | + + {zoom}% + +
+ +
+
+
+ + {page===1&&( +
+

작 업 일 보

+
+
■ 공사명 : 안성 당목리 물류센터
+ + {reviewers.slice(0,4).map((r,i)=>())} + + {reviewers.slice(0,4).map((r,i)=>())} +
{r.position}
{r.name}
+
+ + + + + + + +
날씨{report?.weather}최저{report?.temp_low}℃최고{report?.temp_high}℃강수량{report?.precipitation}미세먼지{report?.fine_dust}/{report?.ultra_fine_dust}
+
1. 작업내용
+
{dateStr}
+ + + + + + +
금 일 작 업 사 항명 일 작 업 사 항
{report?.work_content_today||''}{report?.work_content_tomorrow||''}
+ + +
특이사항{report?.notes||''}
+
업 체 명 : (주)주일기업
+
)} + + {page===2&&( +
+
2. 인원투입현황
+ + + + {workers.map((w,i)=>())} + + +
구 분단위전일금일누계비고
{w.work_type} - {w.job_type}{num(w.prev_cumulative)}{num(w.today_count)}{num(w.prev_cumulative)+num(w.today_count)}
{wSum.prev}{wSum.today}{wSum.prev+wSum.today}
+
3. 장비투입현황
+ + + + {equipments.map((e,i)=>())} + + +
구 분단위전일금일누계비고
{e.equipment_name} {e.specification}{num(e.prev_cumulative)}{num(e.today_count)}{num(e.prev_cumulative)+num(e.today_count)}
{eSum.prev}{eSum.today}{eSum.prev+eSum.today}
+
)} + + {page===3&&( +
+
4. 자재 투입현황
+ + + + {materials.map((m,i)=>())} + {materials.length===0&&} + +
품 명규 격단위설계량전일금일누계비고
{m.material_name}{m.specification}{m.unit}{num(m.design_qty)}{num(m.prev_cumulative)}{num(m.today_count)}{num(m.prev_cumulative)+num(m.today_count)}
(자재 데이터 없음)
+
)} + +
+
+
); +} + +/* ═══════ 탭 공통 테이블 스타일 ═══════ */ +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()}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( +
+ +
+ {/* 헤더 */} +
+

{year}년 {String(month).padStart(2,'0')}월 {String(day).padStart(2,'0')}일 ({dayName}) {report?.weather||''}

+
+ + {/* 필터 */} +
+ {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"/> + + + +
+ 작성중 + 검토중 + 승인 + 미작성 +
+
+ + {/* 캘린더 */} +
+ +
+ + {/* 버튼 바 */} +
+ +
+ + + {!isReadOnly&&} + {canDelete&&!isReadOnly&&} +
+ + {/* 탭 */} +
+
+ {TABS.map(t=>())} +
+
+ + {/* 탭 콘텐츠 */} +
+ {loading?
로딩중...
: +
+ + {/* ─── 작업내용 탭 ─── */} + {tab==='content'&&(
+
+ + + + 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"/> + / + 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"/> + + 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"/> + / + 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"/> + + setReport({...report,fine_dust:e.target.value})} className="border border-gray-300 rounded px-2 py-1 text-sm w-16 text-center" placeholder=""/> + / + 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=""/> +
+
+
+
금일내용
+