- 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>
345 lines
12 KiB
TypeScript
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 },
|
|
});
|