- 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>
270 lines
9.2 KiB
TypeScript
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 },
|
|
});
|