kintex/mobile/app/inspection.tsx
zio 6c9891c7e2 feat: mobile harness + design v2.1 (84 screens) + PLANNING v3.1 + deliverables + Stitch screens
- Harness: kintex-mobile-dev agent + kintex-mobile-orchestrator skill (WISE mobile ref, Stitch-first design rule, dual app targets B2B/B2C)
- design.md v2.1: full 84-screen inventory (web 51 / admin 10 / public 8 / mobile 15) with Stitch prompts incl. ticketing (SCR-P7/P8, M14/M15)
- PLANNING v3.1: unified account + split signup tracks (2FA required for staff, light signup/guest for visitors), one codebase / two app targets
- Deliverables: dev plan (21s), user/operator/developer guides (17/14/15s), program spec (44s, 65 programs, 8 flowcharts), DA (DB design 14s + table spec xlsx 35 tables/299 cols)
- Benchmark: ticketing-app-benchmark.md (7 apps) -> IMPLEMENTATION_BACKLOG Phase F (14 items)
- Stitch: 23 generated screens saved (mobile 10, admin 6, web core 5, ticket 2)
- mobile/: Expo scaffold (SDK 51, expo-router, secure store JWT)
- frontend: SCR-13~17 QA fixes, icons.tsx, kintexEvents, V10 seed migration
- ci/: KINTEX CI logo assets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:31:45 +09:00

270 lines
9.2 KiB
TypeScript

/*
* SCR-M2 [모바일] 홀매니저 현장 검수 — 승인 도면 vs 현장 사진 비교 + AI 사전심사 플래그.
* 검수 항목 [적합]/[부적합] 세그먼트 · 부적합 시 사진+메모 · 승인/시정 요청.
* 모든 AI 플래그 "현장 확인함" 체크 전 승인 비활성(오조작 방지).
*/
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import React, { useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { AiImage } from '../components/AiImage';
import { Button } from '../components/Button';
import { Card } from '../components/Card';
import { useAuth } from '../context/AuthContext';
import { colors, radius, spacing, touch, type } from '../theme';
interface Flag {
id: string;
label: string;
severity: 'block' | 'warn';
}
const FLAGS: Flag[] = [
{ id: 'f1', label: '장치물 높이 5.4m 초과', severity: 'block' },
{ id: 'f2', label: '방염 확인서 누락', severity: 'warn' },
{ id: 'f3', label: '리깅 구조계산서 미제출', severity: 'warn' },
];
interface InspItem {
id: string;
label: string;
}
const ITEMS: InspItem[] = [
{ id: 'height', label: '장치물 높이 (5m 이하)' },
{ id: 'flame', label: '방염 라벨 부착' },
{ id: 'aisle', label: '통로 침범 여부' },
{ id: 'noise', label: '소음 (70~75dB)' },
];
type Verdict = 'ok' | 'fail' | null;
export default function InspectionScreen() {
const { activeWorkspace } = useAuth();
const [confirmed, setConfirmed] = useState<Record<string, boolean>>({});
const [verdict, setVerdict] = useState<Record<string, Verdict>>({});
const [memo, setMemo] = useState<Record<string, string>>({});
const [showFlags, setShowFlags] = useState(true);
const allConfirmed = FLAGS.every((f) => confirmed[f.id]);
const anyFail = ITEMS.some((it) => verdict[it.id] === 'fail');
const canApprove = allConfirmed && !anyFail;
function approve() {
Alert.alert('검수 승인', '검수가 승인되었습니다.');
router.back();
}
function requestFix() {
Alert.alert('시정 요청', '위반 항목이 첨부되어 시공업체에 전달되었습니다.');
router.back();
}
return (
<View style={styles.flex}>
<ScrollView contentContainerStyle={styles.scroll}>
<Text style={styles.title}>
· A-102 · {activeWorkspace?.hallLabel ?? '홀7'}
</Text>
{/* 비교 카드 */}
<Card>
<View style={styles.compareRow}>
<View style={styles.compareCol}>
<Text style={styles.compareLabel}> </Text>
<View style={styles.drawingThumb}>
<Ionicons name="git-network-outline" size={28} color={colors.primary100} />
<Text style={styles.drawingText}> v2</Text>
</View>
</View>
<View style={styles.compareCol}>
<Text style={styles.compareLabel}> (AI )</Text>
<AiImage uri={null} notice="참고용 — 심사 근거 아님" height={96} />
</View>
</View>
</Card>
{/* AI 사전심사 플래그 */}
<Card accent="ai">
<View style={styles.flagHead}>
<Text style={styles.flagTitle}>AI {FLAGS.length}</Text>
<Ionicons
name={showFlags ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.aiAccent}
onPress={() => setShowFlags((v) => !v)}
/>
</View>
{showFlags
? FLAGS.map((f) => (
<View key={f.id} style={styles.flagRow}>
<View
style={[
styles.sevDot,
{ backgroundColor: f.severity === 'block' ? colors.error : colors.warning },
]}
/>
<Text style={styles.flagLabel}>{f.label}</Text>
<View
style={[styles.confirmChip, confirmed[f.id] && styles.confirmChipOn]}
onTouchEnd={() => setConfirmed((c) => ({ ...c, [f.id]: !c[f.id] }))}
>
<Text style={[styles.confirmText, confirmed[f.id] && styles.confirmTextOn]}>
{confirmed[f.id] ? '확인함' : '확인'}
</Text>
</View>
</View>
))
: null}
</Card>
{/* 검수 항목 */}
{ITEMS.map((it) => (
<Card key={it.id}>
<Text style={styles.itemLabel}>{it.label}</Text>
<View style={styles.segment}>
<SegBtn
label="적합"
active={verdict[it.id] === 'ok'}
tone="ok"
onPress={() => setVerdict((v) => ({ ...v, [it.id]: 'ok' }))}
/>
<SegBtn
label="부적합"
active={verdict[it.id] === 'fail'}
tone="fail"
onPress={() => setVerdict((v) => ({ ...v, [it.id]: 'fail' }))}
/>
</View>
{verdict[it.id] === 'fail' ? (
<TextInput
value={memo[it.id] ?? ''}
onChangeText={(t) => setMemo((m) => ({ ...m, [it.id]: t }))}
placeholder="부적합 사유 메모 (사진 촬영 필수)"
placeholderTextColor={colors.neutral500}
style={styles.memo}
multiline
/>
) : null}
</Card>
))}
</ScrollView>
{/* 하단 고정 액션 */}
<View style={styles.footer}>
{!allConfirmed ? (
<Text style={styles.footerNote}> AI .</Text>
) : null}
<View style={styles.footerRow}>
<Button
label="검수 승인"
variant="success"
onPress={approve}
disabled={!canApprove}
style={styles.footerBtn}
/>
<Button label="시정 요청" variant="danger" onPress={requestFix} style={styles.footerBtn} />
</View>
</View>
</View>
);
}
function SegBtn({
label,
active,
tone,
onPress,
}: {
label: string;
active: boolean;
tone: 'ok' | 'fail';
onPress: () => void;
}) {
const activeColor = tone === 'ok' ? colors.success : colors.error;
return (
<Text
onPress={onPress}
style={[
styles.segBtn,
active && { backgroundColor: activeColor, borderColor: activeColor, color: colors.white },
]}
>
{label}
</Text>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.neutral050 },
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 140 },
title: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
compareRow: { flexDirection: 'row', gap: spacing.sm },
compareCol: { flex: 1, gap: 6 },
compareLabel: { fontSize: type.caption.fontSize, color: colors.neutral500, fontWeight: '600' },
drawingThumb: {
height: 96,
borderRadius: radius.md,
backgroundColor: colors.canvasBg,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
drawingText: { color: colors.primary100, fontSize: type.caption.fontSize },
flagHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 },
flagTitle: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.aiAccent },
flagRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 8 },
sevDot: { width: 10, height: 10, borderRadius: 5 },
flagLabel: { flex: 1, fontSize: type.body.fontSize, color: colors.neutral900 },
confirmChip: {
borderRadius: radius.pill,
borderWidth: 1,
borderColor: colors.neutral200,
paddingHorizontal: 12,
paddingVertical: 6,
minHeight: 36,
justifyContent: 'center',
},
confirmChipOn: { backgroundColor: colors.success, borderColor: colors.success },
confirmText: { fontSize: type.caption.fontSize, color: colors.neutral700, fontWeight: '600' },
confirmTextOn: { color: colors.white },
itemLabel: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900, marginBottom: 10 },
segment: { flexDirection: 'row', gap: 8 },
segBtn: {
flex: 1,
textAlign: 'center',
minHeight: touch.min,
lineHeight: touch.min,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
color: colors.neutral700,
fontSize: type.body.fontSize,
fontWeight: '600',
overflow: 'hidden',
},
memo: {
marginTop: 10,
minHeight: 64,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.sm,
padding: 12,
fontSize: type.body.fontSize,
color: colors.neutral900,
textAlignVertical: 'top',
},
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.neutral200,
padding: spacing.md,
paddingBottom: spacing.lg,
gap: 8,
},
footerNote: { textAlign: 'center', fontSize: type.caption.fontSize, color: colors.warning },
footerRow: { flexDirection: 'row', gap: spacing.sm },
footerBtn: { flex: 1 },
});