kintex/mobile/app/checklist.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

345 lines
12 KiB
TypeScript

/*
* SCR-M1 [모바일] 시공업체 현장 체크리스트 — 48px 터치·오프라인 배지·사진 첨부.
* 아코디언 그룹(반입 전 / 시공 중 / 구조 확인 / 완료) + 금지작업 경고 + 검수 요청 CTA.
* 위반(높이 5m 초과) 즉시 빨간 안내. 오프라인 시 로컬 저장 안내(배지).
*/
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Alert, Image, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { Banner } from '../components/Banner';
import { Button } from '../components/Button';
import { useAuth } from '../context/AuthContext';
import { colors, radius, spacing, touch, type } from '../theme';
interface Item {
id: string;
label: string;
hint?: string;
required: boolean;
photo: boolean; // 사진 첨부 가능
}
interface Group {
id: string;
title: string;
items: Item[];
}
const GROUPS: Group[] = [
{
id: 'before',
title: '반입 전',
items: [
{ id: 'flame', label: '방염 확인서 지참', required: true, photo: true },
{ id: 'helmet', label: '안전모·보호장구 착용', required: true, photo: false },
],
},
{
id: 'during',
title: '시공 중',
items: [
{ id: 'floor', label: '바닥 마감·단차 확인', hint: '테이핑 및 단차', required: true, photo: true },
{ id: 'wire', label: '전기 배선 안전 확인', hint: '노출 전선 정리', required: false, photo: true },
],
},
{
id: 'done',
title: '완료',
items: [{ id: 'final', label: '완료 사진 업로드', required: true, photo: true }],
},
];
export default function ChecklistScreen() {
const { activeWorkspace } = useAuth();
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [photos, setPhotos] = useState<Record<string, string>>({});
const [open, setOpen] = useState<Record<string, boolean>>({ before: true, during: true, done: true });
const [height, setHeight] = useState('');
const heightValue = parseFloat(height);
const heightViolation = !Number.isNaN(heightValue) && heightValue > 5;
const requiredIds = useMemo(
() => GROUPS.flatMap((g) => g.items.filter((i) => i.required).map((i) => i.id)),
[],
);
const remaining = requiredIds.filter((id) => !checked[id]).length;
const heightOk = !Number.isNaN(heightValue) && heightValue > 0 && heightValue <= 5;
const canSubmit = remaining === 0 && heightOk;
async function attachPhoto(itemId: string) {
try {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('권한 필요', '사진 첨부를 위해 갤러리 접근 권한이 필요합니다.');
return;
}
const res = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.6,
});
if (!res.canceled && res.assets[0]) {
setPhotos((p) => ({ ...p, [itemId]: res.assets[0].uri }));
}
} catch {
Alert.alert('오류', '사진을 첨부하지 못했습니다.');
}
}
function submit() {
// 스캐폴드: 실제 검수 요청 API 연동은 백엔드 M6/C-4 확장 시 배선.
Alert.alert('검수 요청', '검수 요청이 대기열에 저장되었습니다. (오프라인 시 연결되면 동기화)');
router.back();
}
return (
<View style={styles.flex}>
{/* 상단 컨텍스트 */}
<View style={styles.header}>
<View style={styles.headerTop}>
<View>
<Text style={styles.boothNo}> A-102</Text>
<Text style={styles.boothMeta}>
{activeWorkspace?.hallLabel ?? '홀7'} · 1
</Text>
</View>
<View style={styles.offlineBadge}>
<Ionicons name="cloud-offline-outline" size={14} color={colors.neutral500} />
<Text style={styles.offlineText}> </Text>
</View>
</View>
<View style={styles.toggleRow}>
<View style={[styles.toggleBtn, styles.toggleActive]}>
<Ionicons name="git-network-outline" size={16} color={colors.primary600} />
<Text style={styles.toggleActiveText}></Text>
</View>
<View style={styles.toggleBtn}>
<Ionicons name="image-outline" size={16} color={colors.neutral500} />
<Text style={styles.toggleText}> ()</Text>
</View>
</View>
</View>
<ScrollView contentContainerStyle={styles.scroll}>
<Banner tone="info"> {remaining} </Banner>
{/* 금지작업 경고 (상시) */}
<View style={styles.warnCard}>
<View style={styles.warnHead}>
<Ionicons name="warning" size={18} color={colors.error} />
<Text style={styles.warnTitle}> </Text>
</View>
<Text style={styles.warnBody}> 금지: 전기톱 · · </Text>
<Text style={styles.warnBody}> 한도: 70~75dB </Text>
</View>
{GROUPS.map((g) => (
<View key={g.id} style={styles.group}>
<Pressable style={styles.groupHead} onPress={() => setOpen((o) => ({ ...o, [g.id]: !o[g.id] }))}>
<Text style={styles.groupTitle}>{g.title}</Text>
<Ionicons name={open[g.id] ? 'chevron-up' : 'chevron-down'} size={18} color={colors.neutral500} />
</Pressable>
{open[g.id]
? g.items.map((it) => (
<View key={it.id} style={styles.item}>
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: !!checked[it.id] }}
onPress={() => setChecked((c) => ({ ...c, [it.id]: !c[it.id] }))}
style={[styles.checkbox, checked[it.id] && styles.checkboxOn]}
>
{checked[it.id] ? <Ionicons name="checkmark" size={22} color={colors.white} /> : null}
</Pressable>
<View style={styles.itemBody}>
<Text style={[styles.itemLabel, checked[it.id] && styles.itemLabelDone]}>
{it.label}
{it.required ? <Text style={styles.req}> *</Text> : null}
</Text>
{it.hint ? <Text style={styles.itemHint}>{it.hint}</Text> : null}
{photos[it.id] ? (
<Image source={{ uri: photos[it.id] }} style={styles.thumb} />
) : null}
</View>
{it.photo ? (
<Pressable style={styles.camera} onPress={() => attachPhoto(it.id)}>
<Ionicons name="camera-outline" size={22} color={colors.neutral700} />
</Pressable>
) : null}
</View>
))
: null}
</View>
))}
{/* 구조 확인 — 높이 실측 */}
<View style={styles.group}>
<View style={styles.groupHead}>
<Text style={styles.groupTitle}> </Text>
</View>
<View style={styles.heightRow}>
<Text style={styles.itemLabel}> </Text>
<View style={styles.heightInputWrap}>
<TextInput
value={height}
onChangeText={setHeight}
placeholder="4.2"
placeholderTextColor={colors.neutral500}
keyboardType="decimal-pad"
style={styles.heightInput}
/>
<Text style={styles.heightUnit}>m</Text>
</View>
</View>
<Text style={styles.itemHint}>규정: 5m </Text>
{heightViolation ? (
<Banner tone="error"> 5m .</Banner>
) : null}
</View>
</ScrollView>
{/* 하단 고정 CTA */}
<View style={styles.footer}>
<Text style={styles.syncNote}> </Text>
<Button label="검수 요청 보내기" onPress={submit} disabled={!canSubmit} />
</View>
</View>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.neutral050 },
header: {
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.neutral200,
padding: spacing.md,
gap: 12,
},
headerTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
boothNo: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
boothMeta: { fontSize: type.caption.fontSize, color: colors.neutral500, marginTop: 2 },
offlineBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: colors.neutral050,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.pill,
paddingHorizontal: 8,
paddingVertical: 4,
},
offlineText: { fontSize: 11, color: colors.neutral500 },
toggleRow: { flexDirection: 'row', gap: 8 },
toggleBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
},
toggleActive: { backgroundColor: colors.primary050, borderColor: colors.primary600 },
toggleActiveText: { color: colors.primary600, fontWeight: '600', fontSize: type.caption.fontSize },
toggleText: { color: colors.neutral500, fontSize: type.caption.fontSize },
scroll: { padding: spacing.md, gap: spacing.md, paddingBottom: 120 },
warnCard: {
backgroundColor: '#FEF3F2',
borderWidth: 1,
borderColor: colors.error,
borderRadius: radius.md,
padding: spacing.md,
gap: 4,
},
warnHead: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 },
warnTitle: { color: colors.error, fontWeight: '700', fontSize: type.body.fontSize },
warnBody: { color: colors.neutral700, fontSize: type.caption.fontSize },
group: {
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
overflow: 'hidden',
},
groupHead: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.neutral200,
},
groupTitle: { fontSize: type.h3.fontSize, fontWeight: '600', color: colors.neutral900 },
item: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.neutral050,
},
checkbox: {
width: 32,
height: 32,
borderRadius: radius.sm,
borderWidth: 2,
borderColor: colors.neutral200,
alignItems: 'center',
justifyContent: 'center',
},
checkboxOn: { backgroundColor: colors.primary600, borderColor: colors.primary600 },
itemBody: { flex: 1, gap: 4 },
itemLabel: { fontSize: type.body.fontSize, color: colors.neutral900 },
itemLabelDone: { textDecorationLine: 'line-through', color: colors.neutral500 },
itemHint: { fontSize: type.caption.fontSize, color: colors.neutral500 },
req: { color: colors.error },
thumb: { width: 72, height: 72, borderRadius: radius.sm, marginTop: 4 },
camera: {
width: touch.min,
height: touch.min,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.neutral200,
alignItems: 'center',
justifyContent: 'center',
},
heightRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingTop: spacing.md,
gap: 12,
},
heightInputWrap: { flexDirection: 'row', alignItems: 'center', gap: 6 },
heightInput: {
width: 90,
minHeight: touch.min,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.sm,
paddingHorizontal: 12,
fontSize: type.h3.fontSize,
color: colors.neutral900,
textAlign: 'right',
},
heightUnit: { fontSize: type.h3.fontSize, color: colors.neutral500 },
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.neutral200,
padding: spacing.md,
paddingBottom: spacing.lg,
gap: 6,
},
syncNote: { textAlign: 'center', fontSize: 11, color: colors.neutral500 },
});