From 581c845f543afa02ef6595dbd1cb9507d156b6d9 Mon Sep 17 00:00:00 2001 From: Johngreen Date: Tue, 10 Feb 2026 13:46:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20inspection=20se?= =?UTF-8?q?ssions=20(create,=20execute,=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - InspectionSession + InspectionRecord models with alembic migration - 6 API endpoints: create, list, get detail, save records, complete, delete - Auto pass/fail judgment for numeric (spec range) and boolean items - Completed inspections are immutable, required items enforced on complete - 14 new tests (total 53/53 passed) Frontend: - Inspection list page with in_progress/completed tabs - Template select modal for starting new inspections - Inspection execution page with data-type-specific inputs - Auto-save with 1.5s debounce, manual save button - Completion modal with notes and required item validation - Read-only view for completed inspections - Pass/fail badges and color-coded item cards Co-Authored-By: Claude Opus 4 --- .../b4c5d6e7f8a9_add_inspection_sessions.py | 111 ++++ .../app/[tenant]/inspections/[id]/page.tsx | 373 +++++++++++++ dashboard/app/[tenant]/inspections/page.tsx | 215 ++++++++ dashboard/app/globals.css | 402 ++++++++++++++ dashboard/lib/hooks.ts | 38 +- dashboard/lib/types.ts | 51 ++ main.py | 2 + src/api/inspections.py | 518 ++++++++++++++++++ src/database/models.py | 72 +++ tests/test_inspections.py | 411 ++++++++++++++ 10 files changed, 2192 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/b4c5d6e7f8a9_add_inspection_sessions.py create mode 100644 dashboard/app/[tenant]/inspections/[id]/page.tsx create mode 100644 dashboard/app/[tenant]/inspections/page.tsx create mode 100644 src/api/inspections.py create mode 100644 tests/test_inspections.py diff --git a/alembic/versions/b4c5d6e7f8a9_add_inspection_sessions.py b/alembic/versions/b4c5d6e7f8a9_add_inspection_sessions.py new file mode 100644 index 0000000..01cfb41 --- /dev/null +++ b/alembic/versions/b4c5d6e7f8a9_add_inspection_sessions.py @@ -0,0 +1,111 @@ +"""add_inspection_sessions + +Revision ID: b4c5d6e7f8a9 +Revises: 9566bf2a256b +Create Date: 2026-02-10 13:20:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "b4c5d6e7f8a9" +down_revision: Union[str, None] = "9566bf2a256b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "inspection_sessions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", sa.String(50), sa.ForeignKey("tenants.id"), nullable=False + ), + sa.Column( + "template_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("inspection_templates.id"), + nullable=False, + ), + sa.Column( + "inspector_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column("status", sa.String(20), nullable=False, server_default="draft"), + sa.Column("started_at", postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.Column("completed_at", postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column( + "created_at", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + ) + op.create_index( + "ix_sessions_tenant_status", "inspection_sessions", ["tenant_id", "status"] + ) + op.create_index( + "ix_sessions_tenant_template", + "inspection_sessions", + ["tenant_id", "template_id"], + ) + op.create_index( + "ix_sessions_tenant_inspector", + "inspection_sessions", + ["tenant_id", "inspector_id"], + ) + + op.create_table( + "inspection_records", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "session_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("inspection_sessions.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "template_item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("inspection_template_items.id"), + nullable=False, + ), + sa.Column("value_numeric", sa.Float, nullable=True), + sa.Column("value_boolean", sa.Boolean, nullable=True), + sa.Column("value_text", sa.Text, nullable=True), + sa.Column("value_select", sa.String(200), nullable=True), + sa.Column("is_pass", sa.Boolean, nullable=True), + sa.Column("recorded_at", postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "created_at", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + ), + ) + op.create_index("ix_records_session", "inspection_records", ["session_id"]) + op.create_unique_constraint( + "uq_record_session_item", + "inspection_records", + ["session_id", "template_item_id"], + ) + + +def downgrade() -> None: + op.drop_table("inspection_records") + op.drop_table("inspection_sessions") diff --git a/dashboard/app/[tenant]/inspections/[id]/page.tsx b/dashboard/app/[tenant]/inspections/[id]/page.tsx new file mode 100644 index 0000000..868d676 --- /dev/null +++ b/dashboard/app/[tenant]/inspections/[id]/page.tsx @@ -0,0 +1,373 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useInspection } from '@/lib/hooks'; +import { useToast } from '@/lib/toast-context'; +import { api } from '@/lib/api'; +import type { InspectionItemDetail } from '@/lib/types'; + +interface LocalValues { + [templateItemId: string]: { + value_numeric?: number | null; + value_boolean?: boolean | null; + value_text?: string | null; + value_select?: string | null; + }; +} + +export default function InspectionDetailPage() { + const params = useParams(); + const router = useRouter(); + const tenantId = params?.tenant as string; + const inspectionId = params?.id as string; + const { addToast } = useToast(); + + const { inspection, isLoading, error, mutate } = useInspection(tenantId, inspectionId); + const [localValues, setLocalValues] = useState({}); + const [saving, setSaving] = useState(false); + const [completing, setCompleting] = useState(false); + const saveTimerRef = useRef | null>(null); + const [completionNotes, setCompletionNotes] = useState(''); + const [showCompleteModal, setShowCompleteModal] = useState(false); + + const isCompleted = inspection?.status === 'completed'; + const isEditable = !isCompleted; + + useEffect(() => { + if (inspection?.items_detail) { + const initial: LocalValues = {}; + for (const item of inspection.items_detail) { + const r = item.record; + initial[r.template_item_id] = { + value_numeric: r.value_numeric, + value_boolean: r.value_boolean, + value_text: r.value_text, + value_select: r.value_select, + }; + } + setLocalValues(initial); + } + }, [inspection?.id]); + + const saveRecords = useCallback(async (values: LocalValues) => { + if (!inspection || isCompleted) return; + const records = Object.entries(values) + .filter(([, v]) => v.value_numeric !== null || v.value_boolean !== null || v.value_text !== null || v.value_select !== null) + .map(([templateItemId, v]) => ({ + template_item_id: templateItemId, + ...(v.value_numeric !== undefined && v.value_numeric !== null ? { value_numeric: v.value_numeric } : {}), + ...(v.value_boolean !== undefined && v.value_boolean !== null ? { value_boolean: v.value_boolean } : {}), + ...(v.value_text !== undefined && v.value_text !== null ? { value_text: v.value_text } : {}), + ...(v.value_select !== undefined && v.value_select !== null ? { value_select: v.value_select } : {}), + })); + + if (records.length === 0) return; + + setSaving(true); + try { + await api.put(`/api/${tenantId}/inspections/${inspectionId}/records`, { records }); + mutate(); + } catch { + addToast('저장에 실패했습니다.', 'error'); + } finally { + setSaving(false); + } + }, [inspection, isCompleted, tenantId, inspectionId, mutate, addToast]); + + const debouncedSave = useCallback((values: LocalValues) => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => saveRecords(values), 1500); + }, [saveRecords]); + + const handleNumericChange = useCallback((templateItemId: string, value: string) => { + const numVal = value === '' ? null : parseFloat(value); + setLocalValues((prev) => { + const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_numeric: numVal } }; + debouncedSave(next); + return next; + }); + }, [debouncedSave]); + + const handleBooleanChange = useCallback((templateItemId: string, value: boolean) => { + setLocalValues((prev) => { + const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_boolean: value } }; + debouncedSave(next); + return next; + }); + }, [debouncedSave]); + + const handleTextChange = useCallback((templateItemId: string, value: string) => { + setLocalValues((prev) => { + const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_text: value || null } }; + debouncedSave(next); + return next; + }); + }, [debouncedSave]); + + const handleSelectChange = useCallback((templateItemId: string, value: string) => { + setLocalValues((prev) => { + const next = { ...prev, [templateItemId]: { ...prev[templateItemId], value_select: value || null } }; + debouncedSave(next); + return next; + }); + }, [debouncedSave]); + + const handleSaveNow = useCallback(async () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + await saveRecords(localValues); + addToast('저장되었습니다.', 'success'); + }, [saveRecords, localValues, addToast]); + + const handleComplete = useCallback(async () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + await saveRecords(localValues); + + setCompleting(true); + try { + await api.post(`/api/${tenantId}/inspections/${inspectionId}/complete`, { + notes: completionNotes || null, + }); + addToast('검사가 완료되었습니다.', 'success'); + setShowCompleteModal(false); + mutate(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '검사 완료에 실패했습니다.'; + addToast(message, 'error'); + } finally { + setCompleting(false); + } + }, [localValues, saveRecords, tenantId, inspectionId, completionNotes, mutate, addToast]); + + const getPassClass = (item: InspectionItemDetail) => { + const r = item.record; + if (r.is_pass === true) return 'pass'; + if (r.is_pass === false) return 'fail'; + return ''; + }; + + const getPassLabel = (item: InspectionItemDetail) => { + const r = item.record; + if (r.is_pass === true) return '합격'; + if (r.is_pass === false) return '불합격'; + return '-'; + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + const d = new Date(dateStr); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + }; + + if (error) { + return ( +
+ error +

검사를 불러오는 데 실패했습니다.

+
+ ); + } + + if (isLoading || !inspection) { + return ( +
+ progress_activity +
+ ); + } + + const filledCount = inspection.items_detail + ? inspection.items_detail.filter((d) => d.record.recorded_at !== null).length + : 0; + const totalCount = inspection.items_detail?.length || 0; + + return ( +
+
+
+ +
+

{inspection.template_name || '검사'}

+ + {inspection.inspector_name} · {formatDate(inspection.started_at)} + {isCompleted && ` · 완료: ${formatDate(inspection.completed_at)}`} + +
+
+
+ {isEditable && ( + <> + {saving ? '저장 중...' : `${filledCount}/${totalCount} 입력`} + + + + )} + {isCompleted && ( + + check_circle + 완료 + + )} +
+
+ + {inspection.notes && ( +
+ note + {inspection.notes} +
+ )} + +
+ {(inspection.items_detail || []).map((detail, idx) => { + const item = detail.item; + const record = detail.record; + const local = localValues[record.template_item_id] || {}; + const passClass = getPassClass(detail); + + return ( +
+
+ {idx + 1} + {item.name} + {item.is_required && 필수} + {item.category && {item.category}} + {record.is_pass !== null && ( + + {getPassLabel(detail)} + + )} +
+ +
+ {item.data_type === 'numeric' && ( +
+
+ handleNumericChange(record.template_item_id, e.target.value)} + disabled={!isEditable} + placeholder="측정값 입력" + /> + {item.unit && {item.unit}} +
+ {(item.spec_min !== null || item.spec_max !== null) && ( + + 규격: {item.spec_min !== null ? item.spec_min : '-'} ~ {item.spec_max !== null ? item.spec_max : '-'} + {item.unit && ` ${item.unit}`} + + )} + {(item.warning_min !== null || item.warning_max !== null) && ( + + 경고: {item.warning_min !== null ? item.warning_min : '-'} ~ {item.warning_max !== null ? item.warning_max : '-'} + + )} +
+ )} + + {item.data_type === 'boolean' && ( +
+
+ + +
+
+ )} + + {item.data_type === 'text' && ( +
+