feat: [equipment] 설비 QR 코드 점검 시스템 추가

- 설비 상세 basic-info 탭에 QR 코드 표시 (qrcode.js CDN)
- QR PNG 다운로드/인쇄 기능
- 모바일 전용 레이아웃 (layouts/mobile.blade.php)
- 모바일 점검 페이지 (/m/inspect/{id})
- setResult API (PATCH /inspections/set-result)
- 4버튼 직접 결과 설정 (양호/이상/수리/취소)
- 전체 양호 일괄 처리
- 주기 탭 전환 (활성 주기만 표시)
This commit is contained in:
김보곤
2026-02-28 15:17:40 +09:00
parent ee1a2d6633
commit b6b16fcbd1
10 changed files with 506 additions and 0 deletions

View File

@@ -79,6 +79,39 @@ public function toggleDetail(Request $request): JsonResponse
}
}
public function setResult(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'template_item_id' => 'required|integer',
'check_date' => 'required|date',
'cycle' => 'nullable|string',
'result' => 'nullable|in:good,bad,repaired',
]);
try {
$result = $this->inspectionService->setResult(
$request->input('equipment_id'),
$request->input('template_item_id'),
$request->input('check_date'),
$request->input('cycle', InspectionCycle::DAILY),
$request->input('result')
);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], $status);
}
}
public function updateNotes(Request $request): JsonResponse
{
$request->validate([

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Enums\InspectionCycle;
use App\Http\Controllers\Controller;
use App\Models\Equipment\Equipment;
use App\Models\Equipment\EquipmentInspection;
use App\Models\Equipment\EquipmentInspectionDetail;
use App\Models\Equipment\EquipmentInspectionTemplate;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MobileInspectionController extends Controller
{
public function show(Request $request, int $id): View
{
$equipment = Equipment::with(['manager', 'subManager'])->findOrFail($id);
$cycle = $request->input('cycle', InspectionCycle::DAILY);
$today = now()->format('Y-m-d');
$period = InspectionCycle::resolvePeriod($cycle, $today);
$activeCycles = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
->where('is_active', true)
->distinct()
->pluck('inspection_cycle')
->toArray();
$templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
->where('inspection_cycle', $cycle)
->where('is_active', true)
->orderBy('sort_order')
->get();
$inspection = EquipmentInspection::where('equipment_id', $equipment->id)
->where('inspection_cycle', $cycle)
->where('year_month', $period)
->first();
$details = collect();
if ($inspection) {
$details = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->where('check_date', $today)
->get()
->keyBy('template_item_id');
}
$canInspect = $equipment->canInspect();
return view('mobile.inspection.show', compact(
'equipment',
'cycle',
'today',
'period',
'activeCycles',
'templates',
'details',
'canInspect',
));
}
}

View File

@@ -137,6 +137,62 @@ public function toggleDetail(int $equipmentId, int $templateItemId, string $chec
];
}
/**
* 점검 결과 직접 설정 (모바일용)
*/
public function setResult(int $equipmentId, int $templateItemId, string $checkDate, string $cycle, ?string $result): array
{
$equipment = Equipment::findOrFail($equipmentId);
if (! $equipment->canInspect()) {
throw new \Exception('점검 권한이 없습니다.');
}
$tenantId = session('selected_tenant_id', 1);
$period = InspectionCycle::resolvePeriod($cycle, $checkDate);
$inspection = EquipmentInspection::firstOrCreate(
[
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'inspection_cycle' => $cycle,
'year_month' => $period,
],
[
'created_by' => auth()->id(),
]
);
$detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->where('template_item_id', $templateItemId)
->where('check_date', $checkDate)
->first();
if ($result === null) {
if ($detail) {
$detail->delete();
}
return ['result' => null, 'symbol' => '', 'color' => 'text-gray-400'];
}
if ($detail) {
$detail->update(['result' => $result]);
} else {
$detail = EquipmentInspectionDetail::create([
'inspection_id' => $inspection->id,
'template_item_id' => $templateItemId,
'check_date' => $checkDate,
'result' => $result,
]);
}
return [
'result' => $result,
'symbol' => $detail->fresh()->result_symbol,
'color' => $detail->fresh()->result_color,
];
}
public function updateInspectionNotes(int $equipmentId, string $yearMonth, array $data, string $cycle = 'daily'): EquipmentInspection
{
$tenantId = session('selected_tenant_id', 1);

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services;
class QrCodeService
{
/**
* 모바일 점검 페이지 URL 생성
*/
public static function inspectionUrl(int $equipmentId): string
{
$baseUrl = rtrim(config('app.url'), '/');
return $baseUrl.'/m/inspect/'.$equipmentId;
}
}

View File

@@ -0,0 +1,75 @@
{{-- QR 코드 (모바일 점검용) --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">QR 코드 (모바일 점검)</h2>
<div class="flex items-start gap-6">
<div>
<div id="qr-code-container" class="bg-white p-2 border rounded-lg inline-block"></div>
</div>
<div class="flex-1">
<p class="text-sm font-mono text-gray-700 mb-1">{{ $equipment->equipment_code }}</p>
<p class="text-base font-semibold text-gray-900 mb-3">{{ $equipment->name }}</p>
<p class="text-xs text-gray-500 mb-4 break-all" id="qr-url-text"></p>
<div class="flex gap-2">
<button onclick="downloadQrCode()" class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
PNG 다운로드
</button>
<button onclick="printQrCode()" class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/></svg>
인쇄
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
(function() {
const qrUrl = @json(\App\Services\QrCodeService::inspectionUrl($equipment->id));
const equipCode = @json($equipment->equipment_code);
const equipName = @json($equipment->name);
document.getElementById('qr-url-text').textContent = qrUrl;
new QRCode(document.getElementById('qr-code-container'), {
text: qrUrl,
width: 150,
height: 150,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M,
});
window.downloadQrCode = function() {
const canvas = document.querySelector('#qr-code-container canvas');
if (!canvas) return;
canvas.toBlob(function(blob) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'QR_' + equipCode + '.png';
a.click();
URL.revokeObjectURL(a.href);
}, 'image/png');
};
window.printQrCode = function() {
const canvas = document.querySelector('#qr-code-container canvas');
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
const win = window.open('', '_blank', 'width=400,height=500');
win.document.write(`<!DOCTYPE html><html><head><title>QR 코드 인쇄</title>
<style>body{text-align:center;font-family:sans-serif;padding:20px}
img{margin:20px auto}p{margin:8px 0}</style></head><body>
<img src="${dataUrl}" width="200" height="200">
<p style="font-weight:bold;font-size:16px">${equipCode}</p>
<p style="font-size:14px">${equipName}</p>
<p style="font-size:10px;color:#888">${qrUrl}</p>
<script>window.onload=function(){window.print();}<\/script>
</body></html>`);
win.document.close();
};
})();
</script>

View File

@@ -145,6 +145,8 @@ function closePhotoModal(e) {
</script>
@endif
@include('equipment.partials.qr-code', ['equipment' => $equipment])
@if($equipment->processes->isNotEmpty())
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">연결된 공정</h2>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', '점검') - SAM</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
body { -webkit-tap-highlight-color: transparent; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
{{-- 최소 헤더 --}}
<header class="bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between sticky top-0 z-10">
<span class="text-lg font-bold text-blue-600">SAM</span>
<span class="text-sm text-gray-600">{{ auth()->user()->name }} </span>
</header>
<main class="pb-6">
@yield('content')
</main>
<script>
window.CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
</script>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,223 @@
@extends('layouts.mobile')
@section('title', $equipment->name . ' 점검')
@section('content')
<div class="px-4 pt-4">
{{-- 설비 정보 --}}
<div class="bg-white rounded-xl shadow-sm p-4 mb-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 font-mono">{{ $equipment->equipment_code }}</p>
<h1 class="text-lg font-bold text-gray-900">{{ $equipment->name }}</h1>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $equipment->status_color }}">
{{ $equipment->status_label }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
@if($equipment->production_line)
<span>{{ $equipment->production_line }}</span>
@endif
@if($equipment->manager)
<span>담당: {{ $equipment->manager->name }}</span>
@endif
</div>
</div>
{{-- 날짜 주기 --}}
<div class="bg-white rounded-xl shadow-sm p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-medium text-gray-700">
{{ \Carbon\Carbon::parse($today)->format('Y-m-d') }}
<span class="text-gray-400 ml-1">{{ \App\Enums\InspectionCycle::label($cycle) }} 점검</span>
</p>
</div>
@if(count($activeCycles) > 1)
<div class="flex gap-2 overflow-x-auto pb-1">
@foreach($activeCycles as $c)
<a href="{{ route('mobile.inspect', ['id' => $equipment->id, 'cycle' => $c]) }}"
class="shrink-0 px-3 py-1.5 rounded-full text-sm font-medium transition
{{ $cycle === $c ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
{{ \App\Enums\InspectionCycle::label($c) }}
</a>
@endforeach
</div>
@endif
</div>
@if($templates->isEmpty())
<div class="bg-white rounded-xl shadow-sm p-8 text-center">
<p class="text-gray-400 text-sm"> 주기에 등록된 점검항목이 없습니다.</p>
</div>
@else
{{-- 점검 항목 리스트 --}}
<div class="space-y-3" id="inspection-list">
@foreach($templates as $idx => $template)
@php
$detail = $details->get($template->id);
$currentResult = $detail?->result;
@endphp
<div class="bg-white rounded-xl shadow-sm p-4" id="item-{{ $template->id }}">
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900">
{{ $idx + 1 }}. {{ $template->check_point }}
<span class="font-normal text-gray-600">- {{ $template->check_item }}</span>
</p>
@if($template->check_method)
<p class="text-xs text-gray-400 mt-0.5">{{ $template->check_method }}</p>
@endif
</div>
</div>
{{-- 결과 버튼 --}}
<div class="flex gap-2 mt-3">
<button onclick="setResult({{ $template->id }}, 'good', this)"
class="result-btn flex-1 py-2 rounded-lg text-sm font-medium border transition
{{ $currentResult === 'good' ? 'bg-green-600 text-white border-green-600' : 'bg-white text-green-700 border-green-300 hover:bg-green-50' }}"
data-template="{{ $template->id }}" data-result="good"
{{ !$canInspect ? 'disabled' : '' }}>
양호
</button>
<button onclick="setResult({{ $template->id }}, 'bad', this)"
class="result-btn flex-1 py-2 rounded-lg text-sm font-medium border transition
{{ $currentResult === 'bad' ? 'bg-red-600 text-white border-red-600' : 'bg-white text-red-700 border-red-300 hover:bg-red-50' }}"
data-template="{{ $template->id }}" data-result="bad"
{{ !$canInspect ? 'disabled' : '' }}>
X 이상
</button>
<button onclick="setResult({{ $template->id }}, 'repaired', this)"
class="result-btn flex-1 py-2 rounded-lg text-sm font-medium border transition
{{ $currentResult === 'repaired' ? 'bg-yellow-500 text-white border-yellow-500' : 'bg-white text-yellow-700 border-yellow-300 hover:bg-yellow-50' }}"
data-template="{{ $template->id }}" data-result="repaired"
{{ !$canInspect ? 'disabled' : '' }}>
수리
</button>
<button onclick="setResult({{ $template->id }}, null, this)"
class="result-btn py-2 px-3 rounded-lg text-sm font-medium border transition
{{ $currentResult === null && $detail === null ? 'bg-gray-200 text-gray-500 border-gray-300' : 'bg-white text-gray-500 border-gray-300 hover:bg-gray-50' }}"
data-template="{{ $template->id }}" data-result="null"
{{ !$canInspect ? 'disabled' : '' }}>
취소
</button>
</div>
</div>
@endforeach
</div>
{{-- 요약 + 전체 양호 --}}
<div class="bg-white rounded-xl shadow-sm p-4 mt-4 mb-6">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-600" id="summary-text">
전체 {{ $templates->count() }}항목 |
점검 <span id="checked-count">{{ $details->count() }}</span> |
미점검 <span id="unchecked-count">{{ $templates->count() - $details->count() }}</span>
</p>
@if($canInspect)
<button onclick="setAllGood()"
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition">
전체 양호 처리
</button>
@endif
</div>
</div>
@endif
</div>
@endsection
@push('scripts')
<script>
(function() {
const equipmentId = {{ $equipment->id }};
const checkDate = '{{ $today }}';
const cycle = '{{ $cycle }}';
const totalItems = {{ $templates->count() }};
const resultStyles = {
good: { active: 'bg-green-600 text-white border-green-600', inactive: 'bg-white text-green-700 border-green-300 hover:bg-green-50' },
bad: { active: 'bg-red-600 text-white border-red-600', inactive: 'bg-white text-red-700 border-red-300 hover:bg-red-50' },
repaired: { active: 'bg-yellow-500 text-white border-yellow-500', inactive: 'bg-white text-yellow-700 border-yellow-300 hover:bg-yellow-50' },
'null': { active: 'bg-gray-200 text-gray-500 border-gray-300', inactive: 'bg-white text-gray-500 border-gray-300 hover:bg-gray-50' },
};
function updateButtonStyles(templateId, result) {
const container = document.getElementById('item-' + templateId);
if (!container) return;
container.querySelectorAll('.result-btn').forEach(function(btn) {
const btnResult = btn.dataset.result;
const style = resultStyles[btnResult];
const isActive = (result === null && btnResult === 'null') || (btnResult === result);
// 모든 관련 클래스 제거
btn.className = 'result-btn py-2 rounded-lg text-sm font-medium border transition ' +
(btnResult === 'null' ? 'px-3' : 'flex-1') + ' ' +
(isActive ? style.active : style.inactive);
});
}
function updateSummary() {
let checked = 0;
document.querySelectorAll('[id^="item-"]').forEach(function(container) {
const hasActive = container.querySelector('.result-btn.bg-green-600, .result-btn.bg-red-600, .result-btn.bg-yellow-500');
if (hasActive) checked++;
});
const el = document.getElementById('checked-count');
const el2 = document.getElementById('unchecked-count');
if (el) el.textContent = checked;
if (el2) el2.textContent = totalItems - checked;
}
window.setResult = function(templateItemId, result, btn) {
if (btn && btn.disabled) return;
// 즉시 UI 반영
updateButtonStyles(templateItemId, result);
updateSummary();
fetch('/api/admin/equipment/inspections/set-result', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': window.CSRF_TOKEN,
'Accept': 'application/json',
},
body: JSON.stringify({
equipment_id: equipmentId,
template_item_id: templateItemId,
check_date: checkDate,
cycle: cycle,
result: result,
}),
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (!data.success) {
alert(data.message || '저장에 실패했습니다.');
location.reload();
}
})
.catch(function() {
alert('네트워크 오류가 발생했습니다.');
location.reload();
});
};
window.setAllGood = function() {
if (!confirm('모든 항목을 양호로 처리하시겠습니까?')) return;
const templateIds = [];
document.querySelectorAll('[id^="item-"]').forEach(function(container) {
const id = parseInt(container.id.replace('item-', ''));
if (id) templateIds.push(id);
});
templateIds.forEach(function(tid) {
setResult(tid, 'good', null);
});
};
})();
</script>
@endpush

View File

@@ -1029,6 +1029,7 @@
Route::get('/inspections', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'index'])->name('inspections.index');
Route::patch('/inspections/detail', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'toggleDetail'])->name('inspections.toggle');
Route::patch('/inspections/notes', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'updateNotes'])->name('inspections.notes');
Route::patch('/inspections/set-result', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'setResult'])->name('inspections.set-result');
// 수리이력
Route::get('/repairs', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'index'])->name('repairs.index');

View File

@@ -1667,6 +1667,15 @@
Route::delete('/history', [\App\Http\Controllers\Video\TutorialVideoController::class, 'destroy'])->name('destroy');
});
/*
|--------------------------------------------------------------------------
| 모바일 점검 (QR 코드 → 모바일 점검)
|--------------------------------------------------------------------------
*/
Route::middleware(['auth', 'hq.member'])->group(function () {
Route::get('/m/inspect/{id}', [\App\Http\Controllers\Mobile\MobileInspectionController::class, 'show'])->whereNumber('id')->name('mobile.inspect');
});
/*
|--------------------------------------------------------------------------
| 설비관리 (Equipment Management)