// CareTable - Bulletproof Logic Engine (Version 3.1) // 식약처 고시 식품 알레르기 유발물질 22종 const ALLERGEN_CATEGORIES = [ "난류(가금류)", "우유", "메밀", "땅콩", "대두", "밀", "고등어", "게", "새우", "돼지고기", "복숭아", "토마토", "아황산류", "호두", "닭고기", "쇠고기", "오징어", "조개류(굴,전복,홍합 포함)", "잣", "겨자", "깨", "루핀" ]; // 만성 기저질환 및 섭식 장애 7종 const DISEASE_CATEGORIES = [ "당뇨병", "고혈압", "신장질환", "유당불내증", "연하장애 1단계(다짐식)", "연하장애 2단계(연화식)", "연하장애 3단계(무스식)" ]; // 단체급식 메뉴-알레르기 유발 유래 성분 매핑 로컬 사전 (Rule-based 매칭용) const MENU_ALLERGEN_MAP = { "현미밥": [], "쌀밥": [], "잡곡밥": [], "보리밥": [], "귀리잡곡밥": [], "귀리죽": [], "배추김치": ["밀", "대두"], "깍두기": ["밀", "대두"], "미역국": ["쇠고기", "조개류(굴,전복,홍합 포함)"], "근대된장국": ["대두", "밀"], "맑은 무국": ["쇠고기"], "된장찌개": ["대두", "밀", "조개류(굴,전복,홍합 포함)"], "저나트륨 된장국": ["대두", "밀"], "닭살야채볶음": ["닭고기", "대두", "밀"], "닭가슴살야채볶음": ["닭고기", "대두", "밀"], "불고기": ["쇠고기", "대두", "밀"], "제육볶음": ["돼지고기", "대두", "밀"], "오징어볶음": ["오징어", "대두", "밀"], "오징어채무침": ["오징어", "대두", "밀"], "생선구이": ["고등어", "대두", "밀"], "계란찜": ["난류(가금류)", "우유"], "순두부계란찜": ["난류(가금류)", "우유", "대두"], "달걀찜": ["난류(가금류)", "우유"], "계란말이": ["난류(가금류)"], "두부구이": ["대두"], "연두부구이": ["대두"], "연두부구이 + 약콩 두유": ["대두"], "두부조림": ["대두", "밀"], "시금치나물": [], "고구마순나물": [], "버섯찌개": ["대두", "밀"], "저나트륨 버섯국": ["대두", "밀"], "요구르트": ["우유"], "요플레": ["우유"], "약콩 두유": ["대두"], "오렌지주스": ["복숭아"], "돈까스": ["돼지고기", "밀", "난류(가금류)", "우유", "대두"], "양배추 샐러드": ["난류(가금류)", "우유"], "조기구이": ["고등어"], "명란젓갈": ["조개류(굴,전복,홍합 포함)"], "칼국수": ["밀", "조개류(굴,전복,홍합 포함)", "대두"], "야채만두": ["밀", "돼지고기", "대두"], "식식빵": ["밀", "우유"], "사과잼": [], "우유": ["우유"] }; // Database of Default Mock Data const defaultMembers = { child: [ { id: 101, name: "김민수", age: "5세 / 남", type: "알레르기", detail: "달걀, 우유", instruction: "달걀말이 대신 두부구이, 요구르트 대신 오렌지주스로 전면 대체 배식 필요", status: "care" }, { id: 102, name: "이영희", age: "6세 / 여", type: "일반", detail: "없음", instruction: "표준 건강 식단 및 적정 정량 배식", status: "safe" }, { id: 103, name: "박예준", age: "4세 / 남", type: "알레르기", detail: "대두, 밀", instruction: "된장 및 간장 양념 최소화, 쌀가루 글루텐프리 튀김옷 사용 교차오염 방지", status: "care" } ], senior: [ { id: 201, name: "최옥분", age: "78세 / 여", type: "질환식", detail: "당뇨병, 고혈압", instruction: "나트륨 1,500mg 이하 저염식, 정제 설탕 배제, 백미 대신 귀리잡곡밥 믹싱 지급", status: "care" }, { id: 202, name: "이명자", age: "82세 / 여", type: "질환식", detail: "유당불내증", instruction: "간식 제공 시 일반 요플레/요구르트 배제, 락토프리 약콩 두유 대체 지급", status: "care" }, { id: 203, name: "김성진", age: "80세 / 남", type: "질환식", detail: "연하 2단계", instruction: "씹기 장애에 따른 미세 연화 조리(부드러운 입자), 딱딱한 견과류 전면 배제", status: "care" } ], family: [ { id: 301, name: "막내아들", age: "8세 / 남", type: "알레르기", detail: "우유, 밀가루", instruction: "밀크 및 빵류 간식 절대 급지, 오렌지주스 및 감자 떡볶이 대체", status: "care" }, { id: 302, name: "할머니", age: "85세 / 여", type: "질환식", detail: "연하 2단계", instruction: "삼킴 장애 대응 연화 조리, 식사 시 다진 야채 및 수분기 많은 양념 조리", status: "care" } ] }; const defaultDiets = [ { date: "2026.06.01 (월) 중식", menu: [ { name: "현미밥", weight: "210g" }, { name: "닭살야채볶음", weight: "150g" }, { name: "미역국", weight: "150g" }, { name: "배추김치", weight: "40g" } ], results: [ { num: 1, type: "알레르기", title: "알레르기 주의", desc: "대두, 밀, 닭고기 성분 주의 (김민수 외 1명)", status: "warning" }, { num: 2, type: "질환식", title: "질환별 적합성", desc: "당뇨 환자 단순당 및 혈당 상승 유의 (최옥분 어르신)", status: "warning" }, { num: 3, type: "연하식", title: "연하식 적합성", desc: "연하 2단계 아동/어르신 연화처리 식사 제공 권장", status: "info" }, { num: 4, type: "영양", title: "영양 적정성", desc: "당일 중식 식단 나트륨 과다 (권장량 대비 35% 초과)", status: "danger" }, { num: 5, type: "식중독", title: "식중독 위험", desc: "현재 기상 정보 및 계절적 요인에 따른 식중독 발생 위험도: 보통", status: "info" } ], alternative: { from: "닭살야채볶음", to: "닭가슴살야채볶음", effect: "나트륨 32% 감소, 포화지방 20% 감소, 대두/밀 알레르기 교차오염 차단 대체재" } }, { date: "2026.06.02 (화) 중식", menu: [ { name: "쌀밥", weight: "210g" }, { name: "불고기", weight: "120g" }, { name: "된장찌개", weight: "150g" }, { name: "시금치나물", weight: "50g" } ], results: [ { num: 1, type: "알레르기", title: "알레르기 주의", desc: "대두 성분 주의 (박예준 아동 양념)", status: "warning" }, { num: 2, type: "질환식", title: "질환별 적합성", desc: "백미 쌀밥 및 고나트륨 된장찌개 혈당/혈압 상승 유의 (최옥분 어르신)", status: "warning" }, { num: 3, type: "영양", title: "영양 적정성", desc: "당일 중식 정제 탄수화물 비율 높음 (현미밥 대체 권장)", status: "warning" }, { num: 4, type: "식중독", title: "식중독 위험", desc: "현재 기상 정보 및 계절적 요인에 따른 식중독 발생 위험도: 보통", status: "info" } ], alternative: { from: "쌀밥 + 된장찌개", to: "귀리잡곡밥 + 저나트륨 된장국", effect: "단당류 탄수화물 흡수율 40% 저하 및 염도 0.6% 저감 효과" } }, { date: "2026.06.03 (수) 중식", menu: [ { name: "잡곡밥", weight: "210g" }, { name: "생선구이", weight: "100g" }, { name: "계란찜", weight: "80g" }, { name: "요구르트", weight: "80ml" } ], results: [ { num: 1, type: "알레르기", title: "알레르기 위험", desc: "달걀, 우유 알레르기 유발 성분 대량 포함 (김민수 아동 비상)", status: "danger" }, { num: 2, type: "질환식", title: "질환별 적합성", desc: "유당불내증 간식(요구르트) 제한 지침 대조 (이명자 어르신)", status: "warning" }, { num: 3, type: "연하식", title: "연하식 적합성", desc: "생선 가시 제거 및 부드러운 순두부계란찜 대체 권유", status: "info" }, { num: 4, type: "식중독", title: "식중독 위험", desc: "고온 환경에 따른 어패류 유통 위생 주의보: 식중독 주의 요망", status: "warning" } ], alternative: { from: "계란찜 + 요구르트", to: "연두부구이 + 약콩 두유", effect: "달걀/우유 알러지원 원천 배제 및 락토프리 식단 전환 성공" } } ]; const defaultRecalls = [ { brand: "OO식품 제조", food: "해물믹스", type: "회수", reason: "금속성 이물질 검출 및 세균 기준치 초과 가능성", date: "2026.05.28" }, { brand: "△△푸드", food: "국산 콩나물", type: "주의", reason: "보존제(이산화황) 허용 기준치 미량 초과 검출", date: "2026.05.27" }, { brand: "□□유통", food: "냉동새우살", type: "주의", reason: "냉동 보관 위생 부적합 판정 및 대장균 수 초과", date: "2026.05.25" }, { brand: "성실유통", food: "가래떡", type: "회수", reason: "허가되지 않은 보존 첨가물 검출", date: "2026.05.20" }, { brand: "신선푸드", food: "어묵 슬라이스", type: "주의", reason: "위생 전처리 기준 위반 건", date: "2026.05.15" }, { brand: "대자연푸드", food: "도토리묵", type: "회수", reason: "잔류 농약 기준 미달 원재료 사용 회수 조치", date: "2026.05.10" } ]; const defaultLogs = [ { date: "2026.05.29", menu: "귀리잡곡밥, 저나트륨 버섯국, 연두부구이, 깍두기, 약콩 두유", facility: "senior", target: "최옥분(당뇨), 이명자(유당)", alternative: "쌀밥 ➔ 귀리잡곡밥, 요플레 ➔ 두유 대체", guideline: "당뇨/유당불내증 전용 대체 식단 조립", vlmStatus: "PASS", timestamp: "2026.05.29 11:55" }, { date: "2026.05.28", menu: "쌀밥, 두부구이, 오징어채무침, 맑은 무국, 오렌지주스", facility: "child", target: "김민수(달걀/우유)", alternative: "달걀말이 ➔ 두부구이, 요구르트 ➔ 주스 대체", guideline: "식재료 라벨 점검 및 조리기구 세척", vlmStatus: "PASS", timestamp: "2026.05.28 12:02" } ]; // Global State const state = { currentTab: 'dashboard', currentMode: 'agency', currentFacility: 'child', members: JSON.parse(JSON.stringify(defaultMembers)), logs: JSON.parse(JSON.stringify(defaultLogs)), selectedUserId: 101, selectedMealIndex: 0, selectedVlmImage: null, customVlmImageSrc: null, vlmApproved: false, recallSearchQuery: '', charts: { dashboard: null, statsDoughnut: null, statsPie: null, esg: null } }; const apiConfig = { key: localStorage.getItem('OPENROUTER_API') || '', model: localStorage.getItem('caremeal_api_model') || 'nvidia/llama-3.1-nemotron-70b-instruct:free' }; const freeLLMModels = [ "deepseek/deepseek-v4-flash:free", "qwen/qwen3-next-80b-a3b-instruct:free", "meta-llama/llama-3.3-70b-instruct:free", "google/gemma-4-26b-a4b:free", "nvidia/llama-3.1-nemotron-70b-instruct:free" ]; const freeVLMModels = [ "google/gemma-4-26b-a4b:free", "nvidia/nemotron-nano-12b-2-vl:free", "meta-llama/llama-3.2-11b-vision-instruct:free" ]; // Cache DOM Elements let elements = {}; document.addEventListener('DOMContentLoaded', () => { cacheDomElements(); loadDatabase(); initThemeManager(); initModeToggler(); initTabs(); initFacilitySelector(); initDietAnalyzer(); initDietVerification(); initIngredientSafety(); initUserCustomMgmt(); initStatsReport(); initReportCenter(); initApiConfig(); // Launch app switchTab('dashboard'); updateApiStatusUI(); }); function cacheDomElements() { elements = { navButtons: document.querySelectorAll('.nav-btn'), tabContents: document.querySelectorAll('.tab-content'), facilitySelect: document.getElementById('facility-select'), facilitySelectorWrapper: document.getElementById('facility-selector-wrapper'), pageMainTitle: document.getElementById('page-main-title'), pageSubTitle: document.getElementById('page-sub-title'), mainBadge: document.getElementById('main-badge'), apiStatusBadge: document.getElementById('api-status-badge'), apiStatusText: document.getElementById('api-status-text'), themeToggleBtn: document.getElementById('theme-toggle-btn'), themeToggleText: document.getElementById('theme-toggle-text'), modeToggleCheckbox: document.getElementById('mode-toggle-checkbox'), modeTextLeft: document.getElementById('mode-text-left'), modeTextRight: document.getElementById('mode-text-right'), userAvatarInitial: document.getElementById('user-avatar-initial'), userDisplayName: document.getElementById('user-display-name'), userDisplayRole: document.getElementById('user-display-role'), statTotalMembers: document.getElementById('stat-total-members'), statDietCheckCount: document.getElementById('stat-diet-check-count'), statRiskAlerts: document.getElementById('stat-risk-alerts'), lblTotalMembers: document.getElementById('lbl-total-members'), lblTotalDesc: document.getElementById('lbl-total-desc'), dashboardApplyAltBtn: document.getElementById('dashboard-apply-alt-btn'), dietBulkInput: document.getElementById('diet-bulk-input'), analyzeDietBtn: document.getElementById('analyze-diet-btn'), analyzerLoading: document.getElementById('analyzer-loading'), analyzerModelIndicator: document.getElementById('analyzer-model-indicator'), loadingText: document.getElementById('loading-text'), scheduleResultView: document.getElementById('schedule-result-view'), scheduleCardsContainer: document.getElementById('schedule-cards-container'), scheduleStatusBanner: document.getElementById('schedule-status-banner'), scheduleSummaryTitle: document.getElementById('schedule-summary-title'), scheduleSummaryDesc: document.getElementById('schedule-summary-desc'), scheduleEmptyView: document.getElementById('schedule-empty-view'), confirmAllDietBtn: document.getElementById('confirm-all-diet-btn'), exampleTags: document.querySelectorAll('.example-tag'), btnPrevMeal: document.getElementById('btn-prev-meal'), btnNextMeal: document.getElementById('btn-next-meal'), selectedMealLabel: document.getElementById('selected-meal-label'), verificationMenuList: document.getElementById('verification-menu-list'), verificationResultsList: document.getElementById('verification-results-list'), applyAlternativeDietBtn: document.getElementById('apply-alternative-diet-btn'), vAlternativeBox: document.getElementById('v-alternative-box'), vlmOptNormal: document.getElementById('sim-opt-normal'), vlmOptAlternative: document.getElementById('sim-opt-alternative'), vlmTargetImage: document.getElementById('vlm-target-image'), vlmPlaceholder: document.getElementById('vlm-placeholder'), runVlmBtn: document.getElementById('run-vlm-btn'), scannerLaser: document.getElementById('scanner-laser'), vlmEmptyResult: document.getElementById('vlm-empty-result'), vlmResultPanel: document.getElementById('vlm-result-panel'), vlmVerdictBox: document.getElementById('vlm-verdict-box'), verdictIconContainer: document.getElementById('verdict-icon-container'), verdictTitle: document.getElementById('verdict-title'), vlmMatchRate: document.getElementById('vlm-match-rate'), vlmMatchValue: document.getElementById('vlm-match-value'), vlmAllergenStatus: document.getElementById('vlm-allergen-status'), vlmPortionStatus: document.getElementById('vlm-portion-status'), vlmAnalysisReason: document.getElementById('vlm-analysis-reason'), vlmActionFooter: document.getElementById('vlm-action-footer'), triggerFileBtn: document.getElementById('trigger-file-btn'), vlmFileInput: document.getElementById('vlm-file-input'), safetySearchInput: document.getElementById('safety-search-input'), btnSearchSafety: document.getElementById('btn-search-safety'), recallTableBody: document.getElementById('recall-table-body'), btnLoadMoreRecalls: document.getElementById('btn-load-more-recalls'), recallTotalBadge: document.getElementById('recall-total-badge'), customUserPickerContainer: document.getElementById('custom-user-picker-container'), customTabBtns: document.querySelectorAll('.custom-tab-btn'), subtabWarnings: document.getElementById('subtab-warnings'), subtabInfo: document.getElementById('subtab-info'), subtabHistory: document.getElementById('subtab-history'), selectedUserAvatar: document.getElementById('selected-user-avatar'), selectedUserName: document.getElementById('selected-user-name'), selectedUserType: document.getElementById('selected-user-type'), selectedUserDiseases: document.getElementById('selected-user-diseases'), selectedUserDiets: document.getElementById('selected-user-diets'), selectedUserAllergies: document.getElementById('selected-user-allergies'), selectedUserSwallow: document.getElementById('selected-user-swallow'), userGuidelinesPills: document.getElementById('user-guidelines-pills'), userAvoidFoods: document.getElementById('user-avoid-foods'), infoDetailsList: document.getElementById('info-details-list'), historyTimelineContainer: document.getElementById('history-timeline-container'), panelMemberTitle: document.getElementById('panel-member-title'), statsDateStart: document.getElementById('stats-date-start'), statsDateEnd: document.getElementById('stats-date-end'), btnStatsSearch: document.getElementById('btn-stats-search'), reportSubTabBtns: document.querySelectorAll('.sub-tab-btn'), reportDocTitle: document.getElementById('report-doc-title'), rFacilityName: document.getElementById('r-facility-name'), rDate: document.getElementById('r-date'), rWriter: document.getElementById('r-writer'), reportDocContent: document.getElementById('report-doc-content'), printReportBtn: document.getElementById('print-report-btn'), reportConfirmStatement: document.getElementById('report-confirm-statement'), reportSignerName: document.getElementById('report-signer-name'), reportFooterDesc: document.getElementById('report-footer-desc'), navReportBtn: document.getElementById('nav-report-btn'), esgCo2: document.getElementById('esg-co2'), esgTime: document.getElementById('esg-time'), esgLocal: document.getElementById('esg-local'), addMemberBtn: document.getElementById('add-member-btn'), addMemberModal: document.getElementById('add-member-modal'), closeMemberModalBtn: document.getElementById('close-member-modal-btn'), cancelMemberBtn: document.getElementById('cancel-member-btn'), memberForm: document.getElementById('member-form'), modalMemberTitle: document.getElementById('modal-member-title'), allergenSelectorGroup: document.getElementById('allergen-selector-group'), diseaseSelectorGroup: document.getElementById('disease-selector-group'), allergenCheckboxGrid: document.getElementById('allergen-checkbox-grid'), diseaseCheckboxGrid: document.getElementById('disease-checkbox-grid'), mRiskType: document.getElementById('m-risk-type'), apiConfigForm: document.getElementById('api-config-form'), apiKeyInput: document.getElementById('api-key-input'), apiModelSelect: document.getElementById('api-model-select'), toggleKeyVisibilityBtn: document.getElementById('toggle-key-visibility-btn'), resetApiKeyBtn: document.getElementById('reset-api-key-btn') }; } function loadDatabase() { try { ['child', 'senior', 'family'].forEach(key => { const stored = localStorage.getItem(`caremeal_members_${key}`); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed) && parsed.length > 0) { state.members[key] = parsed; } else { saveDatabase(key); } } else { saveDatabase(key); } }); } catch (e) { console.error('Database parse error:', e); localStorage.clear(); state.members = JSON.parse(JSON.stringify(defaultMembers)); saveDatabase('child'); saveDatabase('senior'); saveDatabase('family'); } try { const storedLogs = localStorage.getItem('caremeal_logs'); if (storedLogs) { state.logs = JSON.parse(storedLogs); } else { localStorage.setItem('caremeal_logs', JSON.stringify(state.logs)); } } catch (e) { console.error('Logs parse error:', e); state.logs = JSON.parse(JSON.stringify(defaultLogs)); saveLogs(); } } function saveDatabase(key) { localStorage.setItem(`caremeal_members_${key}`, JSON.stringify(state.members[key])); } function saveLogs() { localStorage.setItem('caremeal_logs', JSON.stringify(state.logs)); } function initThemeManager() { if (!elements.themeToggleBtn) return; const storedTheme = localStorage.getItem('caremeal_theme') || 'light'; applyTheme(storedTheme); elements.themeToggleBtn.addEventListener('click', () => { const isDark = document.body.classList.contains('dark-mode'); const nextTheme = isDark ? 'light' : 'dark'; applyTheme(nextTheme); localStorage.setItem('caremeal_theme', nextTheme); }); } function applyTheme(theme) { const icon = elements.themeToggleBtn ? elements.themeToggleBtn.querySelector('i') : null; if (theme === 'dark') { document.body.classList.add('dark-mode'); if (icon) icon.className = 'fa-solid fa-sun'; if (elements.themeToggleText) elements.themeToggleText.textContent = '라이트 모드'; } else { document.body.classList.remove('dark-mode'); if (icon) icon.className = 'fa-solid fa-moon'; if (elements.themeToggleText) elements.themeToggleText.textContent = '다크 모드'; } setTimeout(() => { updateCharts(); }, 100); } function initModeToggler() { if (!elements.modeToggleCheckbox) return; const storedMode = localStorage.getItem('caremeal_mode'); if (storedMode) { state.currentMode = storedMode; elements.modeToggleCheckbox.checked = (storedMode === 'family'); } applyModeTheme(); elements.modeToggleCheckbox.addEventListener('change', (e) => { state.currentMode = e.target.checked ? 'family' : 'agency'; localStorage.setItem('caremeal_mode', state.currentMode); applyModeTheme(); resetVlmUI(); const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; if (state.members[listKey] && state.members[listKey].length > 0) { state.selectedUserId = state.members[listKey][0].id; } switchTab(state.currentTab); showNotification(`${state.currentMode === 'family' ? '가정 케어' : '시설 위생 관리'} 모드로 전환되었습니다.`); }); } function applyModeTheme() { const modeLeft = elements.modeTextLeft; const modeRight = elements.modeTextRight; if (state.currentMode === 'family') { if (modeLeft) modeLeft.classList.remove('active-mode'); if (modeRight) modeRight.classList.add('active-mode'); if (elements.facilitySelectorWrapper) elements.facilitySelectorWrapper.classList.add('hidden'); if (elements.mainBadge) { elements.mainBadge.textContent = '일반 소비자 모드'; elements.mainBadge.style.background = 'rgba(124, 58, 237, 0.08)'; elements.mainBadge.style.borderColor = 'rgba(124, 58, 237, 0.2)'; elements.mainBadge.style.color = '#7c3aed'; } if (elements.userAvatarInitial) elements.userAvatarInitial.textContent = '가'; if (elements.userDisplayName) elements.userDisplayName.textContent = '서교동 삼총사'; if (elements.userDisplayRole) elements.userDisplayRole.textContent = '가정 자율 영양 케어'; if (elements.navReportBtn) elements.navReportBtn.innerHTML = ' 안전 증빙 일지'; } else { if (modeLeft) modeLeft.classList.add('active-mode'); if (modeRight) modeRight.classList.remove('active-mode'); if (elements.facilitySelectorWrapper) elements.facilitySelectorWrapper.classList.remove('hidden'); if (elements.mainBadge) { elements.mainBadge.textContent = '게스트 기관 모드'; elements.mainBadge.style.background = 'rgba(37, 99, 235, 0.08)'; elements.mainBadge.style.borderColor = 'rgba(37, 99, 235, 0.2)'; elements.mainBadge.style.color = 'var(--color-blue)'; } if (elements.userAvatarInitial) elements.userAvatarInitial.textContent = '복'; if (elements.userDisplayName) elements.userDisplayName.textContent = '박아름 사회복지사'; if (elements.userDisplayRole) elements.userDisplayRole.textContent = '마포구 시설 운영대표'; if (elements.navReportBtn) elements.navReportBtn.innerHTML = ' 평가 증빙 & ESG'; } } function initTabs() { if (!elements.navButtons) return; elements.navButtons.forEach(btn => { btn.addEventListener('click', () => { const tabId = btn.getAttribute('data-tab'); switchTab(tabId); }); }); } function switchTab(tabId) { state.currentTab = tabId; if (elements.navButtons) { elements.navButtons.forEach(btn => { if (btn.getAttribute('data-tab') === tabId) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); } if (elements.tabContents) { elements.tabContents.forEach(content => { if (content.id === tabId) { content.classList.add('active-tab'); } else { content.classList.remove('active-tab'); } }); } updateHeaderTitles(tabId); if (tabId === 'dashboard') { renderDashboard(); } else if (tabId === 'diet-verification') { renderDietVerification(); } else if (tabId === 'user-custom-mgmt') { renderUserCustomMgmt(); } else if (tabId === 'stats-report') { renderStatsReport(); } else if (tabId === 'report-generator') { renderReport(); } } function updateHeaderTitles(tabId) { let modeTitle = ''; if (state.currentMode === 'family') { modeTitle = '가정 식단 케어'; } else { modeTitle = state.currentFacility === 'child' ? '튼튼어린이집' : '행복실버요양원'; } if (elements.pageMainTitle) { switch (tabId) { case 'dashboard': elements.pageMainTitle.textContent = `${modeTitle} 종합 대시보드`; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '급식소 실시간 급식 안전성 및 영양 관리 요약'; break; case 'diet-analyzer': elements.pageMainTitle.textContent = '식단 AI 분석'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '식단을 대량 입력하면 OpenRouter AI가 수혜자 DB와 실시간 스크리닝 분석을 시행합니다.'; break; case 'diet-verification': elements.pageMainTitle.textContent = '식단 검증'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '지정 일자별 급식 위험요소 2차 비전 검증 및 대체식 영양 개선 분석'; break; case 'ingredient-safety': elements.pageMainTitle.textContent = '식재료 안전 모니터링'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '식품의약품안전처 위해 및 회수 식품 표준 정보 실시간 동기화 검색'; break; case 'user-custom-mgmt': elements.pageMainTitle.textContent = '이용자 맞춤 관리'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '사회보장정보 기반 집중 식이 케어 대상자 맞춤 지침 및 주의 정보'; break; case 'stats-report': elements.pageMainTitle.textContent = '통계 리포트'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '검출된 급식 위해 정보 통계 및 기간별 위험 알림 유형 분석'; break; case 'report-generator': elements.pageMainTitle.textContent = state.currentMode === 'family' ? '가정 식단 위생 일지' : '평가 증빙 및 ESG 센터'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '한국사회보장정보원 시스템 증빙 양식 출력 및 디지털 친환경 행정 지표 요약'; break; case 'service-flow': elements.pageMainTitle.textContent = '서비스 개요'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = 'CareTable의 실시간 AI 급식 영양 안전 서비스 작동 구조도 및 민간 협업 인프라'; break; case 'settings': elements.pageMainTitle.textContent = '설정 및 관리'; if (elements.pageSubTitle) elements.pageSubTitle.textContent = '시스템 매개변수 조정, 테마 스위칭, API 연동 Key 관리'; break; } } } function initFacilitySelector() { if (!elements.facilitySelect) return; elements.facilitySelect.addEventListener('change', (e) => { state.currentFacility = e.target.value; resetVlmUI(); const listKey = state.currentFacility; if (state.members[listKey] && state.members[listKey].length > 0) { state.selectedUserId = state.members[listKey][0].id; } switchTab(state.currentTab); showNotification(`${state.currentFacility === 'child' ? '튼튼어린이집' : '행복실버요양원'} 데이터로 전환되었습니다.`); }); } function renderDashboard() { const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const list = state.members[listKey]; if (!list) return; const totalMembersCount = list.length; const riskCount = list.filter(m => m.type !== '일반').length; if (elements.statTotalMembers) elements.statTotalMembers.textContent = `${totalMembersCount}명`; if (elements.statRiskAlerts) elements.statRiskAlerts.textContent = `${riskCount * 2}건`; if (elements.lblTotalMembers) { if (state.currentMode === 'family') { elements.lblTotalMembers.textContent = '가족 인원'; if (elements.lblTotalDesc) elements.lblTotalDesc.innerHTML = ' 가정 프로필 기준'; } else { elements.lblTotalMembers.textContent = '총 관리 대상자'; if (elements.lblTotalDesc) elements.lblTotalDesc.innerHTML = ' 사보원 연동 기준'; } } const todayDietContainer = document.querySelector('.today-diet-list'); if (todayDietContainer) { todayDietContainer.innerHTML = ''; let activeMealSet = []; if (state.currentMode === 'family') { activeMealSet = [ { time: "조식", menu: "귀리밥, 계란말이, 된장국, 깍두기", tag: "주의 1건", color: "tag-yellow" }, { time: "중식", menu: "잡곡밥, 돈까스, 양배추 샐러드, 우유", tag: "주의 1건", color: "tag-yellow" }, { time: "석식", menu: "쌀밥, 버섯찌개, 계란찜, 시금치나물", tag: "정상", color: "tag-green" } ]; } else if (state.currentFacility === 'child') { activeMealSet = [ { time: "조식", menu: "잡곡밥, 근대된장국, 달걀찜, 깍두기", tag: "정상", color: "tag-green" }, { time: "중식", menu: "현미밥, 닭살야채볶음, 미역국, 배추김치", tag: "주의 1건", color: "tag-yellow" }, { time: "석식", menu: "보리밥, 두부조림, 시금치나물, 깍두기", tag: "정상", color: "tag-green" } ]; } else { activeMealSet = [ { time: "조식", menu: "귀리밥, 두부국, 시금치, 조기구이", tag: "정상", color: "tag-green" }, { time: "중식", menu: "귀리잡곡밥, 버섯국, 불고기, 고구마순나물, 약콩 두유", tag: "주의 2건", color: "tag-yellow" }, { time: "석식", menu: "귀리죽, 계란찜, 명란젓갈, 배추김치", tag: "주의 1건", color: "tag-yellow" } ]; } activeMealSet.forEach(meal => { const timeBadgeClass = meal.time === '조식' ? 'header-blue' : meal.time === '중식' ? 'header-orange' : 'header-purple'; const card = document.createElement('div'); card.className = 'today-diet-card glass-card'; card.innerHTML = `
${meal.time}
${meal.menu}
${meal.tag}
`; todayDietContainer.appendChild(card); }); } if (elements.dashboardApplyAltBtn) { elements.dashboardApplyAltBtn.onclick = () => { showNotification("대체 식단 처방이 오늘의 식단에 즉시 적용되었습니다."); switchTab('diet-verification'); }; } setTimeout(() => { updateCharts(); }, 100); } // 식재료 텍스트 기반 100% 로컬 오프라인 위험 대조 분석 알고리즘 function localAnalyzeDiet(dietText) { const lines = dietText.split('\n'); const results = []; const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const currentMembers = state.members[listKey] || []; lines.forEach((line, index) => { const cleanLine = line.trim(); if (!cleanLine) return; let datePart = `식단 ${index + 1}`; let menuPart = cleanLine; const dateMatch = cleanLine.match(/^\[(.*?)\](.*)/) || cleanLine.match(/^(.*?):(.*)/) || cleanLine.match(/^(.*?)-(.*)/); if (dateMatch) { datePart = dateMatch[1].trim(); menuPart = dateMatch[2].trim(); } const menuItems = menuPart.split(',').map(m => m.trim()).filter(m => m); const menuObjects = menuItems.map(name => { let w = "150g"; if (name.includes("밥") || name.includes("죽")) w = "210g"; if (name.includes("국") || name.includes("찌개")) w = "150g"; if (name.includes("김치") || name.includes("나물")) w = "40g"; if (name.includes("주스") || name.includes("음료") || name.includes("우유") || name.includes("두유")) w = "125ml"; return { name: name, weight: w }; }); const risks = []; let altFrom = ""; let altTo = ""; let altEffect = ""; const activeAllergensInMenu = {}; menuItems.forEach(menuName => { let foundMenu = ""; let mappedAllergens = []; for (let key in MENU_ALLERGEN_MAP) { if (menuName.includes(key) || key.includes(menuName)) { foundMenu = key; mappedAllergens = MENU_ALLERGEN_MAP[key]; break; } } mappedAllergens.forEach(allergen => { activeAllergensInMenu[allergen] = menuName; }); }); let riskCount = 1; currentMembers.forEach(member => { if (member.type === '알레르기') { const memberAllergens = member.detail.split(',').map(s => s.trim()); memberAllergens.forEach(alg => { if (activeAllergensInMenu[alg]) { const targetMenu = activeAllergensInMenu[alg]; risks.push({ num: riskCount++, type: "알레르기", title: "알레르기 위험", desc: `[${targetMenu}] 내 ${alg} 성분 포함 ➔ ${member.name} (${member.detail}) 위험`, status: "danger" }); if (targetMenu.includes("계란찜") || targetMenu.includes("달걀찜") || targetMenu.includes("계란말이")) { altFrom = targetMenu; altTo = "연두부구이"; altEffect = "난류 알러지원 차단 및 식물성 대두 단백질 대체 공급"; } else if (targetMenu.includes("요구르트") || targetMenu.includes("요플레") || targetMenu.includes("우유")) { altFrom = targetMenu; altTo = "약콩 두유"; altEffect = "유제품 락토프리 식물성 단백질 전환"; } else if (targetMenu.includes("된장찌개")) { altFrom = targetMenu; altTo = "저나트륨 버섯국"; altEffect = "대두 및 나트륨 함량 40% 저감"; } else if (!altFrom) { altFrom = targetMenu; altTo = "두부조림"; altEffect = `${alg} 항원 물질 배제 완료`; } } }); } else if (member.type === '질환식') { const diseaseList = member.detail.split(',').map(s => s.trim()); diseaseList.forEach(dis => { if (dis.includes("당뇨")) { const hasRice = menuItems.some(m => m === "쌀밥" || m === "백미밥"); const hasSweet = menuItems.some(m => m.includes("요구르트") || m.includes("요플레") || m.includes("우유")); if (hasRice) { risks.push({ num: riskCount++, type: "질환식", title: "당뇨 적합성 주의", desc: `정제 백미 쌀밥 포함 ➔ 당뇨군 ${member.name} 혈당 상승 주의`, status: "warning" }); altFrom = "쌀밥"; altTo = "귀리잡곡밥"; altEffect = "정제 탄수화물 제한 및 잡곡 혼합으로 혈당 상승 속도 저하"; } } if (dis.includes("연하")) { const needsSoft = menuItems.some(m => m.includes("떡") || m.includes("질긴") || m.includes("생선") || m.includes("어묵")); if (needsSoft) { risks.push({ num: riskCount++, type: "연하식", title: "연하 섭식 위험", desc: `고형 반찬 포함 ➔ 연하장애군 ${member.name} 삼킴 위험 감지`, status: "info" }); const filterMenu = menuItems.find(m => m.includes("생선") || m.includes("떡") || m.includes("질긴")) || menuItems[1]; altFrom = filterMenu; altTo = `${filterMenu} (연화 다짐식)`; altEffect = "삼킴 장애 완화 및 잇몸 저작 대응 다짐식 조리"; } } }); } }); // 식중독지수 가상 추가 risks.push({ num: riskCount++, type: "식중독", title: "식중독 예측 보통", desc: "당일 보건기상지수에 따른 수산물 위생가공 주의 및 익힘 조리 권장", status: "info" }); results.push({ date: datePart.includes("식단") ? `${datePart}` : `${datePart} 식단`, menu: menuObjects, results: risks, alternative: altFrom ? { from: altFrom, to: altTo, effect: altEffect } : null }); }); return results; } function initDietAnalyzer() { if (!elements.dietBulkInput) return; elements.dietBulkInput.value = state.currentMode === 'family' ? `[월요일] 쌀밥, 계란말이, 요구르트\n[화요일] 쌀식빵, 사과잼, 우유\n[수요일] 잡곡밥, 돈까스, 샐러드` : `[월요일] 현미밥, 닭살야채볶음, 미역국, 배추김치\n[화요일] 쌀밥, 불고기, 된장찌개, 시금치나물\n[수요일] 잡곡밥, 생선구이, 계란찜, 요구르트`; if (elements.exampleTags) { elements.exampleTags.forEach(tag => { tag.addEventListener('click', () => { elements.exampleTags.forEach(t => t.classList.remove('active')); tag.classList.add('active'); const period = tag.getAttribute('data-period'); if (period === 'weekly') { elements.dietBulkInput.value = `[월요일] 현미밥, 닭살야채볶음, 미역국, 배추김치\n[화요일] 쌀밥, 불고기, 된장찌개, 시금치나물\n[수요일] 잡곡밥, 생선구이, 계란찜, 요구르트`; } else { elements.dietBulkInput.value = `[1주 월요일] 현미밥, 닭살야채볶음, 미역국, 배추김치\n[1주 화요일] 쌀밥, 불고기, 된장찌개, 시금치나물\n[1주 수요일] 잡곡밥, 생선구이, 계란찜, 요구르트\n[1주 목요일] 보리밥, 오징어볶음, 콩나물국, 무피클\n[1주 금요일] 칼국수, 야채만두, 배추겉절이, 요구르트`; } }); }); } if (elements.analyzeDietBtn) { elements.analyzeDietBtn.addEventListener('click', async () => { const textInput = elements.dietBulkInput.value.trim(); if (!textInput) return; if (elements.scheduleEmptyView) elements.scheduleEmptyView.classList.add('hidden'); if (elements.scheduleResultView) elements.scheduleResultView.classList.add('hidden'); if (elements.analyzerLoading) elements.analyzerLoading.classList.remove('hidden'); elements.analyzeDietBtn.disabled = true; if (elements.loadingText) { elements.loadingText.textContent = apiConfig.key ? "OpenRouter 임상 영양 분석 LLM 모델과 통신하여 알레르기 및 식습관 위험요소를 실시간 정밀 스크리닝 중입니다..." : "인터넷 및 API Key 비검출로 인하여 로컬 영양 성분 교차 대조 알고리즘으로 폴백하여 식단을 분석하고 처방하는 중..."; } try { if (apiConfig.key) { const parsedResult = await callOpenRouterLLM(textInput); renderAnalyzedSchedule(parsedResult); } else { await new Promise(resolve => setTimeout(resolve, 800)); // 100% 로컬 대조 알고리즘 실행 const localResults = localAnalyzeDiet(textInput); renderAnalyzedSchedule(localResults); } showNotification("AI 식단 분석 및 대체식 처방 수립이 완료되었습니다."); } catch (error) { console.error(error); showNotification("AI 모델 호출 실패로 로컬 안전 가이드 데이터를 로드합니다."); const localResults = localAnalyzeDiet(textInput); renderAnalyzedSchedule(localResults); } finally { if (elements.analyzerLoading) elements.analyzerLoading.classList.add('hidden'); elements.analyzeDietBtn.disabled = false; } }); } if (elements.confirmAllDietBtn) { elements.confirmAllDietBtn.addEventListener('click', () => { showNotification("대체 식단 처방 수칙이 식단 검증 시스템에 최종 동기화되었습니다. 배식 검증 단계를 수행하십시오."); switchTab('diet-verification'); }); } } async function callOpenRouterLLM(dietText) { const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const list = state.members[listKey]; const memberProfileStr = list.map(m => `- 이름: ${m.name}, 유형: ${m.type}, 원인물질/질환: ${m.detail}, 처방: ${m.instruction}`).join('\n'); const prompt = ` 당신은 취약계층 급식 또는 가정을 관리하는 영양 안전 케어 AI 시스템인 CareTable입니다. 다음 입력된 식단표를 바탕으로 현재 관리 대상자들의 알레르기 및 만성질환 유해 성분 정보와 비교하여 위험 요소를 분석하고 대체 식단과 위생/조리지침 레시피 가이드를 수립하십시오. [수혜 대상자 알레르기/질환 명단] ${memberProfileStr} [식단표] ${dietText} [출력 요구사항] 반드시 다른 잡다한 설명 없이 오직 아래 형태의 유효한 JSON 배열만 출력하십시오. Markdown 기호(\`\`\`) 등을 붙이지 마세요. JSON Array format: [ { "date": "요일/일자 구분 (예: 2026.06.01 (월) 중식)", "menu": [ { "name": "식품명", "weight": "권장 중량" } ], "results": [ { "num": 1, "type": "알레르기", "title": "위험 분류 요약", "desc": "위험 대상 및 상세 설명", "status": "danger" } ], "alternative": { "from": "제한할 식단명", "to": "대체 처방 식단명", "effect": "개선 효과 서술" } } ] `; const modelsToTry = [apiConfig.model, ...freeLLMModels.filter(m => m !== apiConfig.model)]; for (let model of modelsToTry) { try { console.log(`Connecting OpenRouter: ${model}`); const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiConfig.key}`, "Content-Type": "application/json", "HTTP-Referer": "https://huggingface.co/spaces/HwangJinwook/caretable", "X-Title": "CareTable Client" }, body: JSON.stringify({ model: model, messages: [ { role: "system", content: "You are clinical dietitian AI. Output only JSON array. Do not wrap in markdown." }, { role: "user", content: prompt } ] }) }); if (!res.ok) continue; const data = await res.json(); let text = data.choices[0].message.content.trim(); if (text.includes("```json")) { text = text.split("```json")[1].split("```")[0].trim(); } else if (text.includes("```")) { text = text.split("```")[1].split("```")[0].trim(); } return JSON.parse(text); } catch (e) { console.warn(`Model ${model} connection error: `, e); } } throw new Error("API failures"); } function renderAnalyzedSchedule(results) { if (!elements.scheduleCardsContainer) return; elements.scheduleCardsContainer.innerHTML = ''; results.forEach((item, idx) => { const card = document.createElement('div'); const hasRisk = item.results.some(r => r.status === 'danger' || r.status === 'warning'); card.className = `schedule-card ${hasRisk ? 'has-risk' : 'is-safe'}`; const statusPill = hasRisk ? `주의/위험` : `검증 통과`; const menuStr = item.menu.map(m => m.name).join(', '); card.innerHTML = `
${item.date} ${menuStr}
${statusPill}
${item.alternative ? ` ` : ''}
스크리닝 위해 목록
`; elements.scheduleCardsContainer.appendChild(card); }); window.toggleCardAccordion = function(index) { const content = document.getElementById(`accordion-${index}`); const arrow = document.getElementById(`arrow-${index}`); if (content && content.classList.contains('expanded')) { content.classList.remove('expanded'); if (arrow) arrow.classList.remove('rotate-icon'); } else if (content) { content.classList.add('expanded'); if (arrow) arrow.classList.add('rotate-icon'); } }; if (elements.scheduleResultView) elements.scheduleResultView.classList.remove('hidden'); } function initDietVerification() { if (elements.btnPrevMeal) { elements.btnPrevMeal.addEventListener('click', () => { if (state.selectedMealIndex > 0) { state.selectedMealIndex--; renderDietVerification(); } }); } if (elements.btnNextMeal) { elements.btnNextMeal.addEventListener('click', () => { if (state.selectedMealIndex < defaultDiets.length - 1) { state.selectedMealIndex++; renderDietVerification(); } }); } if (elements.applyAlternativeDietBtn) { elements.applyAlternativeDietBtn.addEventListener('click', () => { const diet = defaultDiets[state.selectedMealIndex]; showNotification(`[${diet.alternative.from}]이 [${diet.alternative.to}]으로 안전 대체 적용되었습니다.`); diet.menu = diet.menu.map(food => { if (food.name === diet.alternative.from) { return { name: diet.alternative.to, weight: food.weight }; } return food; }); diet.results = diet.results.filter(r => r.type !== '알레르기'); renderDietVerification(); renderDashboard(); }); } if (elements.vlmOptNormal) { elements.vlmOptNormal.addEventListener('click', () => { selectVlmImage('normal'); }); } if (elements.vlmOptAlternative) { elements.vlmOptAlternative.addEventListener('click', () => { selectVlmImage('alternative'); }); } if (elements.triggerFileBtn) { elements.triggerFileBtn.addEventListener('click', () => { if (elements.vlmFileInput) elements.vlmFileInput.click(); }); } if (elements.vlmFileInput) { elements.vlmFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { state.customVlmImageSrc = event.target.result; selectVlmImage('custom'); }; reader.readAsDataURL(file); }); } if (elements.runVlmBtn) { elements.runVlmBtn.addEventListener('click', async () => { if (!state.selectedVlmImage) return; if (elements.vlmEmptyResult) elements.vlmEmptyResult.classList.add('hidden'); if (elements.vlmResultPanel) elements.vlmResultPanel.classList.add('hidden'); if (elements.scannerLaser) { elements.scannerLaser.style.display = 'block'; elements.scannerLaser.style.animation = 'scan 2s infinite linear'; } elements.runVlmBtn.disabled = true; try { if (apiConfig.key && (state.selectedVlmImage === 'custom' || state.customVlmImageSrc)) { const visionResult = await callOpenRouterVLM(); setTimeout(() => { if (elements.scannerLaser) elements.scannerLaser.style.display = 'none'; elements.runVlmBtn.disabled = false; renderVlmResult(visionResult); }, 2000); } else { setTimeout(() => { if (elements.scannerLaser) elements.scannerLaser.style.display = 'none'; elements.runVlmBtn.disabled = false; renderVlmSimulationResult(); }, 2000); } } catch (error) { console.error(error); showNotification("VLM 모델 응답 지연으로 로컬 인공지능 검증 결과를 표시합니다."); setTimeout(() => { if (elements.scannerLaser) elements.scannerLaser.style.display = 'none'; elements.runVlmBtn.disabled = false; renderVlmSimulationResult(); }, 1000); } }); } } function selectVlmImage(type) { state.selectedVlmImage = type; if (elements.vlmPlaceholder) elements.vlmPlaceholder.classList.add('hidden'); if (elements.vlmTargetImage) elements.vlmTargetImage.classList.remove('hidden'); if (elements.vlmOptNormal) elements.vlmOptNormal.classList.remove('selected'); if (elements.vlmOptAlternative) elements.vlmOptAlternative.classList.remove('selected'); if (type === 'normal') { if (elements.vlmTargetImage) elements.vlmTargetImage.src = 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=400&q=80'; if (elements.vlmOptNormal) elements.vlmOptNormal.classList.add('selected'); } else if (type === 'alternative') { if (elements.vlmTargetImage) elements.vlmTargetImage.src = 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=400&q=80'; if (elements.vlmOptAlternative) elements.vlmOptAlternative.classList.add('selected'); } else if (type === 'custom' && state.customVlmImageSrc) { if (elements.vlmTargetImage) elements.vlmTargetImage.src = state.customVlmImageSrc; } if (elements.runVlmBtn) elements.runVlmBtn.disabled = false; } function resetVlmUI() { state.selectedVlmImage = null; state.customVlmImageSrc = null; state.vlmApproved = false; if (elements.vlmPlaceholder) elements.vlmPlaceholder.classList.remove('hidden'); if (elements.vlmTargetImage) { elements.vlmTargetImage.classList.add('hidden'); elements.vlmTargetImage.src = ''; } if (elements.vlmOptNormal) elements.vlmOptNormal.classList.remove('selected'); if (elements.vlmOptAlternative) elements.vlmOptAlternative.classList.remove('selected'); if (elements.vlmEmptyResult) elements.vlmEmptyResult.classList.remove('hidden'); if (elements.vlmResultPanel) elements.vlmResultPanel.classList.add('hidden'); if (elements.runVlmBtn) elements.runVlmBtn.disabled = true; if (elements.vlmFileInput) elements.vlmFileInput.value = ''; } async function callOpenRouterVLM() { const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const list = state.members[listKey]; const memberProfileStr = list.map(m => `- 이름: ${m.name}, 유형: ${m.type}, 원인물질/질환: ${m.detail}`).join('\n'); const prompt = ` 이 식판 사진에서 닭고기, 달걀, 우유 성분이 포함되어 오배식이 일어났는지 감지하고, 아래 JSON 객체 형태 하나만 출력하세요. [제한 인원 정보] ${memberProfileStr} JSON Format: { "verdict": "PASS" 또는 "REJECT", "allergen": "발견된 유발 요인 요약 또는 없음", "portion": "정량 비율 평가", "reason": "AI Vision 이미지 판별 결과 설명" } `; const base64Image = state.customVlmImageSrc.split(',')[1]; for (let model of freeVLMModels) { try { console.log(`Connecting OpenRouter VLM: ${model}`); const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiConfig.key}`, "Content-Type": "application/json", "HTTP-Referer": "https://huggingface.co/spaces/HwangJinwook/caretable", "X-Title": "CareTable Client" }, body: JSON.stringify({ model: model, messages: [ { role: "user", content: [ { type: "text", text: prompt }, { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64Image}` } } ] } ] }) }); if (!res.ok) continue; const data = await res.json(); let text = data.choices[0].message.content.trim(); if (text.includes("```json")) { text = text.split("```json")[1].split("```")[0].trim(); } else if (text.includes("```")) { text = text.split("```")[1].split("```")[0].trim(); } return JSON.parse(text); } catch (e) { console.warn(`Vision Model ${model} failure: `, e); } } throw new Error("VLM Failure"); } function renderVlmSimulationResult() { if (elements.vlmResultPanel) elements.vlmResultPanel.classList.remove('hidden'); if (elements.vlmEmptyResult) elements.vlmEmptyResult.classList.add('hidden'); if (state.selectedVlmImage === 'normal') { if (elements.vlmVerdictBox) elements.vlmVerdictBox.className = 'vlm-verdict-box rejected'; if (elements.verdictIconContainer) elements.verdictIconContainer.innerHTML = ''; if (elements.verdictTitle) elements.verdictTitle.textContent = '배식 보류 (REJECTED)'; if (elements.vlmMatchRate) { elements.vlmMatchRate.style.width = '70%'; elements.vlmMatchRate.style.backgroundColor = 'var(--color-red)'; } if (elements.vlmMatchValue) elements.vlmMatchValue.textContent = '70%'; if (state.currentMode === 'family') { if (elements.vlmAllergenStatus) elements.vlmAllergenStatus.textContent = '계란/우유 검출 (막내아들 알러지원 검출)'; if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = 'Vision 비전 AI 분석 결과, 식판에 계란말이 및 요구르트병이 그대로 포착되었습니다. 막내아들을 위해 두부구이와 주스로 교체를 진행하십시오.'; } else if (state.currentFacility === 'child') { if (elements.vlmAllergenStatus) elements.vlmAllergenStatus.textContent = '달걀, 우유 검출 (김민수 아동 비상)'; if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = 'Vision 비전 AI 판독 결과, 조리 식판 내에 달걀 성분 계란찜 및 요구르트병이 인식되었습니다. 오배식 사고 예방을 위해 배식을 차단하십시오.'; } else { if (elements.vlmAllergenStatus) elements.vlmAllergenStatus.textContent = '백미 및 가당 유제품 검출 (최옥분, 이명자 주의)'; if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = 'Vision 비전 AI 판독 결과, 당뇨 최옥분 어르신 제한군 요구르트 및 백미 쌀밥이 감지되었습니다. 저나트륨 영양식단으로 교체하십시오.'; } if (elements.vlmPortionStatus) elements.vlmPortionStatus.textContent = '적량 배식 (94%)'; if (elements.vlmActionFooter) { elements.vlmActionFooter.innerHTML = ` `; } } else { if (elements.vlmVerdictBox) elements.vlmVerdictBox.className = 'vlm-verdict-box passed'; if (elements.verdictIconContainer) elements.verdictIconContainer.innerHTML = ''; if (elements.verdictTitle) elements.verdictTitle.textContent = '배식 승인 (PASSED)'; if (elements.vlmMatchRate) { elements.vlmMatchRate.style.width = '99%'; elements.vlmMatchRate.style.backgroundColor = 'var(--color-green)'; } if (elements.vlmMatchValue) elements.vlmMatchValue.textContent = '99%'; if (elements.vlmAllergenStatus) elements.vlmAllergenStatus.textContent = '위험 성분 없음 (대체 식재료 완벽 적용)'; if (state.currentMode === 'family') { if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = 'Vision 판독 완료: 계란말이 대신 식물성 두부구이와 오렌지주스 팩 교체가 정상 확인되었습니다. 배식이 안전합니다.'; } else if (state.currentFacility === 'child') { if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = 'Vision 판독 완료: 김민수 아동의 대체식인 연두부구이 및 오렌지주스가 매핑 확인되었습니다. 배식을 즉시 승인합니다.'; } else { if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = 'Vision 판독 완료: 당뇨 및 유당불내증 수혜 전용 잡곡밥 및 락토프리 두유 대체 배식이 검증되었습니다.'; } if (elements.vlmPortionStatus) elements.vlmPortionStatus.textContent = '적량 배식 (98%)'; if (elements.vlmActionFooter) { elements.vlmActionFooter.innerHTML = ` `; const btn = document.getElementById('approve-final-btn'); if (btn) btn.addEventListener('click', saveFinalVerificationLog); } } } function renderVlmResult(apiResult) { if (elements.vlmResultPanel) elements.vlmResultPanel.classList.remove('hidden'); if (elements.vlmEmptyResult) elements.vlmEmptyResult.classList.add('hidden'); if (apiResult.verdict === 'REJECT') { if (elements.vlmVerdictBox) elements.vlmVerdictBox.className = 'vlm-verdict-box rejected'; if (elements.verdictIconContainer) elements.verdictIconContainer.innerHTML = ''; if (elements.verdictTitle) elements.verdictTitle.textContent = '배식 보류 (REJECTED)'; if (elements.vlmMatchRate) { elements.vlmMatchRate.style.width = '68%'; elements.vlmMatchRate.style.backgroundColor = 'var(--color-red)'; } if (elements.vlmMatchValue) elements.vlmMatchValue.textContent = '68%'; if (elements.vlmAllergenStatus) elements.vlmAllergenStatus.textContent = apiResult.allergen; if (elements.vlmPortionStatus) elements.vlmPortionStatus.textContent = apiResult.portion; if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = apiResult.reason; if (elements.vlmActionFooter) { elements.vlmActionFooter.innerHTML = ` `; } } else { if (elements.vlmVerdictBox) elements.vlmVerdictBox.className = 'vlm-verdict-box passed'; if (elements.verdictIconContainer) elements.verdictIconContainer.innerHTML = ''; if (elements.verdictTitle) elements.verdictTitle.textContent = '배식 승인 (PASSED)'; if (elements.vlmMatchRate) { elements.vlmMatchRate.style.width = '99%'; elements.vlmMatchRate.style.backgroundColor = 'var(--color-green)'; } if (elements.vlmMatchValue) elements.vlmMatchValue.textContent = '99%'; if (elements.vlmAllergenStatus) elements.vlmAllergenStatus.textContent = '유해/금지 성분 검출되지 않음'; if (elements.vlmPortionStatus) elements.vlmPortionStatus.textContent = apiResult.portion; if (elements.vlmAnalysisReason) elements.vlmAnalysisReason.textContent = apiResult.reason; if (elements.vlmActionFooter) { elements.vlmActionFooter.innerHTML = ` `; const btn = document.getElementById('approve-final-btn'); if (btn) btn.addEventListener('click', saveFinalVerificationLog); } } } function saveFinalVerificationLog() { const diet = defaultDiets[state.selectedMealIndex]; if (!diet) return; const today = new Date(); const dateStr = today.toISOString().split('T')[0]; const timeStr = today.toTimeString().split(' ')[0].substring(0, 5); let targetStr = ''; let altStr = ''; let facilityKey = state.currentMode === 'family' ? 'family' : state.currentFacility; if (state.currentMode === 'family') { targetStr = '막내아들 (우유/밀가루)'; altStr = '요구르트 ➔ 오렌지주스 대체 공급 완료'; } else if (state.currentFacility === 'child') { targetStr = '김민수 아동 (달걀/우유)'; altStr = '달걀말이 ➔ 두부구이 대체 공급 완료'; } else { targetStr = '최옥분/이명자 (당뇨/유당)'; altStr = '쌀밥 ➔ 귀리잡곡밥, 요구르트 ➔ 두유 대체 지급 완료'; } const logItem = { date: dateStr, menu: diet.menu.map(m => m.name).join(', '), facility: facilityKey, target: targetStr, alternative: altStr, guideline: 'AI 조리지침 준수 및 VLM 배식 사진 비전 대조 PASS 검증', vlmStatus: 'PASS', timestamp: `${dateStr} ${timeStr}` }; state.logs.unshift(logItem); saveLogs(); showNotification("배식 검증 디지털 로그가 안심 데이터베이스에 안전하게 저장되었습니다."); resetVlmUI(); switchTab('report-generator'); } function renderDietVerification() { const diet = defaultDiets[state.selectedMealIndex]; if (!diet) return; if (elements.selectedMealLabel) elements.selectedMealLabel.textContent = diet.date; if (elements.verificationMenuList) { elements.verificationMenuList.innerHTML = ''; diet.menu.forEach(item => { const row = document.createElement('div'); row.className = 'menu-ingredient-item'; row.innerHTML = ` ${item.name} ${item.weight} `; elements.verificationMenuList.appendChild(row); }); } if (elements.verificationResultsList) { elements.verificationResultsList.innerHTML = ''; diet.results.forEach(res => { const card = document.createElement('div'); const alertClass = res.status === 'danger' ? 'alert-danger' : res.status === 'warning' ? 'alert-warning' : 'alert-info'; const tagClass = res.status === 'danger' ? 'tag-danger' : res.status === 'warning' ? 'tag-warning' : 'tag-info'; card.className = `verif-result-card ${alertClass}`; card.innerHTML = `
${res.num}

${res.title}

${res.desc}

${res.status === 'danger' ? '위험' : res.status === 'warning' ? '주의' : '안내'}
`; elements.verificationResultsList.appendChild(card); }); } if (diet.alternative && elements.vAlternativeBox) { elements.vAlternativeBox.classList.remove('hidden'); const altFlow = elements.vAlternativeBox.querySelector('.alternative-flow'); if (altFlow) { altFlow.innerHTML = ` `; } const eff = elements.vAlternativeBox.querySelector('.alt-effect-desc'); if (eff) { eff.innerHTML = ` 개선 효과: ${diet.alternative.effect} `; } } else if (elements.vAlternativeBox) { elements.vAlternativeBox.classList.add('hidden'); } } function initIngredientSafety() { if (elements.btnSearchSafety) { elements.btnSearchSafety.addEventListener('click', runSafetyRecallFilter); } if (elements.safetySearchInput) { elements.safetySearchInput.addEventListener('keyup', (e) => { if (e.key === 'Enter') runSafetyRecallFilter(); }); } if (elements.btnLoadMoreRecalls) { elements.btnLoadMoreRecalls.addEventListener('click', () => { showNotification("추가적인 위해식재료 회수 공공데이터 내역을 로드합니다."); }); } } function runSafetyRecallFilter() { if (!elements.safetySearchInput || !elements.recallTableBody) return; const query = elements.safetySearchInput.value.trim().toLowerCase(); state.recallSearchQuery = query; const filtered = defaultRecalls.filter(item => item.brand.toLowerCase().includes(query) || item.food.toLowerCase().includes(query) || item.reason.toLowerCase().includes(query) ); elements.recallTableBody.innerHTML = ''; if (filtered.length > 0) { filtered.forEach(item => { const tr = document.createElement('tr'); tr.innerHTML = ` ${item.brand} ${item.food} ${item.type} ${item.reason} ${item.date} `; elements.recallTableBody.appendChild(tr); }); if (elements.recallTotalBadge) elements.recallTotalBadge.textContent = `${filtered.length}건 위해요소 발견`; } else { elements.recallTableBody.innerHTML = `
위해 및 회수 식자재 정보가 감지되지 않았습니다. (안전 등급 식재료군) `; if (elements.recallTotalBadge) elements.recallTotalBadge.textContent = '0건'; } } function initUserCustomMgmt() { // 1. 알레르기 및 질환 체크박스 그리드 동적 렌더링 (최초 1회 실행) if (elements.allergenCheckboxGrid && elements.allergenCheckboxGrid.children.length === 0) { elements.allergenCheckboxGrid.innerHTML = ALLERGEN_CATEGORIES.map(item => ` `).join(''); } if (elements.diseaseCheckboxGrid && elements.diseaseCheckboxGrid.children.length === 0) { elements.diseaseCheckboxGrid.innerHTML = DISEASE_CATEGORIES.map(item => ` `).join(''); } // 2. 위험 분류 변경 시 동적 selector 토글 if (elements.mRiskType) { elements.mRiskType.addEventListener('change', (e) => { const val = e.target.value; if (val === '알레르기') { if (elements.allergenSelectorGroup) elements.allergenSelectorGroup.classList.remove('hidden'); if (elements.diseaseSelectorGroup) elements.diseaseSelectorGroup.classList.add('hidden'); } else if (val === '질환식') { if (elements.allergenSelectorGroup) elements.allergenSelectorGroup.classList.add('hidden'); if (elements.diseaseSelectorGroup) elements.diseaseSelectorGroup.classList.remove('hidden'); } else { if (elements.allergenSelectorGroup) elements.allergenSelectorGroup.classList.add('hidden'); if (elements.diseaseSelectorGroup) elements.diseaseSelectorGroup.classList.add('hidden'); } }); } if (elements.customTabBtns) { elements.customTabBtns.forEach(btn => { btn.addEventListener('click', () => { elements.customTabBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); const subtab = btn.getAttribute('data-subtab'); document.querySelectorAll('.subtab-content').forEach(c => c.classList.remove('active-subtab')); const el = document.getElementById(`subtab-${subtab}`); if (el) el.classList.add('active-subtab'); }); }); } const resetModalForm = () => { if (elements.addMemberModal) elements.addMemberModal.classList.add('hidden'); if (elements.memberForm) elements.memberForm.reset(); // UI 기본 상태 복원 (알레르기 활성, 질환 숨김) if (elements.allergenSelectorGroup) elements.allergenSelectorGroup.classList.remove('hidden'); if (elements.diseaseSelectorGroup) elements.diseaseSelectorGroup.classList.add('hidden'); }; if (elements.addMemberBtn) { elements.addMemberBtn.addEventListener('click', () => { if (elements.addMemberModal) elements.addMemberModal.classList.remove('hidden'); }); } if (elements.closeMemberModalBtn) { elements.closeMemberModalBtn.addEventListener('click', resetModalForm); } if (elements.cancelMemberBtn) { elements.cancelMemberBtn.addEventListener('click', resetModalForm); } if (elements.memberForm) { elements.memberForm.addEventListener('submit', (e) => { e.preventDefault(); const name = document.getElementById('m-name').value.trim(); const ageVal = document.getElementById('m-age').value.trim(); const gender = document.getElementById('m-gender').value; const type = document.getElementById('m-risk-type').value; const instruction = document.getElementById('m-instruction').value.trim(); // 3. 다중 선택된 알레르기 및 만성질환 직렬화(Serialization) let detail = ''; if (type === '알레르기') { const checked = Array.from(document.querySelectorAll('input[name="allergen-items"]:checked')).map(el => el.value); detail = checked.length > 0 ? checked.join(', ') : '없음'; } else if (type === '질환식') { const checked = Array.from(document.querySelectorAll('input[name="disease-items"]:checked')).map(el => el.value); detail = checked.length > 0 ? checked.join(', ') : '없음'; } else { detail = '없음'; } if (!name || !ageVal || !detail || !instruction) { showNotification("필수 선택/입력 사항을 다시 확인해 주십시오."); return; } const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const newId = state.members[listKey].length > 0 ? Math.max(...state.members[listKey].map(m => m.id)) + 1 : 1; const memberObj = { id: newId, name: name, age: `${ageVal}세 / ${gender}`, type: type, detail: detail, instruction: instruction, status: type === '일반' ? 'safe' : 'care' }; state.members[listKey].push(memberObj); saveDatabase(listKey); resetModalForm(); renderUserCustomMgmt(); renderDashboard(); showNotification(`신규 케어 대상자 ${name}님이 등록 완료되었습니다.`); }); } } function renderUserCustomMgmt() { const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const list = state.members[listKey]; if (!list) return; if (elements.panelMemberTitle) { if (state.currentMode === 'family') { elements.panelMemberTitle.innerHTML = ' 가족 구성원'; if (elements.modalMemberTitle) elements.modalMemberTitle.innerHTML = ' 가족 건강 프로필 신규 등록'; } else { elements.panelMemberTitle.innerHTML = ' 대상자 명단'; if (elements.modalMemberTitle) elements.modalMemberTitle.innerHTML = ' 신규 관리 대상자 등록'; } } if (!elements.customUserPickerContainer) return; elements.customUserPickerContainer.innerHTML = ''; if (list.length === 0) { elements.customUserPickerContainer.innerHTML = '

등록된 케어 대상자가 없습니다.

'; return; } list.forEach(member => { const item = document.createElement('div'); const activeClass = state.selectedUserId === member.id ? 'active' : ''; const badgeClass = member.type === '알레르기' ? 'allergy' : member.type === '질환식' ? 'disease' : 'general'; item.className = `user-picker-item ${activeClass}`; item.innerHTML = `
${member.name} ${member.age}
${member.type} `; item.addEventListener('click', () => { state.selectedUserId = member.id; renderUserCustomMgmt(); }); elements.customUserPickerContainer.appendChild(item); }); const user = list.find(m => m.id === state.selectedUserId) || list[0]; if (user) { state.selectedUserId = user.id; if (elements.selectedUserAvatar) elements.selectedUserAvatar.textContent = user.name.charAt(0); if (elements.selectedUserName) elements.selectedUserName.textContent = user.name; let typeStr = ''; if (state.currentMode === 'family') { typeStr = `가족 / ${user.age}`; } else { typeStr = `${state.currentFacility === 'child' ? '원아' : '입소어르신'} / ${user.age}`; } if (elements.selectedUserType) elements.selectedUserType.textContent = typeStr; const isAllergy = user.type === '알레르기'; const isDisease = user.type === '질환식'; if (elements.selectedUserDiseases) elements.selectedUserDiseases.textContent = isDisease ? user.detail : '없음'; if (elements.selectedUserDiets) elements.selectedUserDiets.textContent = isDisease ? user.detail : '없음'; if (elements.selectedUserAllergies) elements.selectedUserAllergies.textContent = isAllergy ? user.detail : '없음'; let swallowLevel = '일반 삼킴'; if (user.detail.includes('연하') || user.instruction.includes('연하')) { swallowLevel = '연하 2단계 (연화처치식)'; } if (elements.selectedUserSwallow) elements.selectedUserSwallow.textContent = swallowLevel; if (elements.userGuidelinesPills) { elements.userGuidelinesPills.innerHTML = ''; if (isAllergy) { elements.userGuidelinesPills.innerHTML += `
알레르기 조치 ${user.detail} 격리 급식
`; } else if (isDisease) { if (user.detail.includes('당뇨')) { elements.userGuidelinesPills.innerHTML += `
당뇨식 탄수화물 제한
`; } if (user.detail.includes('고혈압') || user.instruction.includes('저염')) { elements.userGuidelinesPills.innerHTML += `
저염식 나트륨 1,500mg 조절
`; } if (user.detail.includes('연하')) { elements.userGuidelinesPills.innerHTML += `
연하식 연하 2단계 연화 다짐
`; } } else { elements.userGuidelinesPills.innerHTML = '
일반 급식 기준 특이사항 없음
'; } } if (elements.userAvoidFoods) { elements.userAvoidFoods.innerHTML = ''; if (isAllergy) { const allergenList = user.detail.split(','); allergenList.forEach(alg => { const clean = alg.trim(); let ex = '교차오염 차단 도구 분리 사용'; if (clean.includes('달걀') || clean.includes('계란')) ex = '계란말이, 계란찜, 마요네즈, 빵류'; if (clean.includes('우유')) ex = '유제품, 야쿠르트, 치즈, 아이스크림'; if (clean.includes('밀')) ex = '밀가루, 부침개, 면류, 과자류'; if (clean.includes('대두')) ex = '간장 조림, 된장국, 콩자반, 식포류'; elements.userAvoidFoods.innerHTML += `

${clean} 포함 식품

${ex} 등 유래 물질 전면 제외 급식

`; }); } else if (isDisease) { if (user.detail.includes('당뇨')) { elements.userAvoidFoods.innerHTML += `

단순당 높은 식품

케이크, 초콜릿, 탄산음료 등 과당 함유 가공품 배제

`; } if (user.detail.includes('고혈압')) { elements.userAvoidFoods.innerHTML += `

고나트륨 염장 가공품

젓갈, 장아찌, 고염도 찌개 양념 육수 섭취 제한

`; } if (user.detail.includes('연하')) { elements.userAvoidFoods.innerHTML += `

질기거나 딱딱한 식품

견과류, 말린 오징어, 대형 생채소류 등 목넘김 방해 물질 차단

`; } } else { elements.userAvoidFoods.innerHTML = `

제한 식품 없음

일반 위생 조리 지침 준수식 급식 제공 가능

`; } } if (elements.infoDetailsList) { elements.infoDetailsList.innerHTML = `
고유식별키
CT-${user.id}-2026
수혜자성명
${user.name}
연령 및 정보
${user.age}
위험도 진단
${user.type}
조리제한지침
${user.instruction}
`; } if (elements.historyTimelineContainer) { elements.historyTimelineContainer.innerHTML = ''; const userLogs = state.logs.filter(l => l.target.includes(user.name)); if (userLogs.length > 0) { userLogs.forEach(log => { const timelineCard = document.createElement('div'); timelineCard.className = 'timeline-item-card'; timelineCard.innerHTML = `
${log.timestamp}
${log.alternative}
제공메뉴: ${log.menu}
조리감독: ${log.guideline} [VLM ${log.vlmStatus}]
`; elements.historyTimelineContainer.appendChild(timelineCard); }); } else { elements.historyTimelineContainer.innerHTML = `
기록 없음
대체 급식 제공 이력이 없습니다.
VLM 배식 스캔을 완료하면 해당 이력이 타임라인에 누적 보관됩니다.
`; } } } } function initStatsReport() { if (!elements.btnStatsSearch) return; elements.btnStatsSearch.addEventListener('click', () => { const start = elements.statsDateStart ? elements.statsDateStart.value : '2026-05-01'; const end = elements.statsDateEnd ? elements.statsDateEnd.value : '2026-05-31'; showNotification(`${start} ~ ${end} 기간의 급식 예방 통계 데이터를 재조회합니다.`); setTimeout(() => { updateCharts(); }, 100); }); } function renderStatsReport() { setTimeout(() => { updateCharts(); }, 100); } function initReportCenter() { if (elements.reportSubTabBtns) { elements.reportSubTabBtns.forEach(btn => { btn.addEventListener('click', () => { elements.reportSubTabBtns.forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderReport(); }); }); } if (elements.printReportBtn) { elements.printReportBtn.addEventListener('click', () => { window.print(); }); } } function renderReport() { const activeBtn = document.querySelector('.sub-tab-btn.active'); const activeSubTab = activeBtn ? activeBtn.getAttribute('data-report') : '급식일지'; let facilityName = ''; let reportWriter = ''; let reportSigner = ''; if (state.currentMode === 'family') { facilityName = '마포구 서교동 삼총사 가정'; reportWriter = '가정 영양 리더'; reportSigner = '보호자 자필 서명'; if (elements.reportConfirmStatement) elements.reportConfirmStatement.textContent = '위와 같이 가족 구성원의 특이 체질 및 지침에 부합하는 안전 식단과 대체 배식이 올바르게 실행되었음을 확인하며 VLM 스캔 검증 이력을 디지털 기록합니다.'; if (elements.reportSignerName) elements.reportSignerName.textContent = '가족 건강 확인자: 보호자 (인)'; if (elements.reportFooterDesc) elements.reportFooterDesc.textContent = 'CareTable 가정용 식단 위생 자율 점검 대장 (F-2026)'; } else { facilityName = state.currentFacility === 'child' ? '튼튼어린이집' : '행복실버요양원'; reportWriter = '박아름 사회복지사'; reportSigner = '시설대표 박아름 (서명)'; if (elements.reportConfirmStatement) elements.reportConfirmStatement.textContent = '위와 같이 취약계층 급식 관리 및 대체 배식이 올바르게 수행되었으며, 배식 전 VLM(Vision-Language Model) 검증 절차를 완료하였음을 확인합니다.'; if (elements.reportSignerName) elements.reportSignerName.textContent = '확인자: 시설대표 박아름 (서명/인)'; if (elements.reportFooterDesc) elements.reportFooterDesc.textContent = '한국사회보장정보원 사회복지시설평가 증빙 표준 서식 (SSIS 14-2)'; } if (elements.rFacilityName) elements.rFacilityName.textContent = facilityName; if (elements.rWriter) elements.rWriter.textContent = reportWriter; const today = new Date(); if (elements.rDate) elements.rDate.textContent = `${today.getFullYear()}년 ${String(today.getMonth() + 1).padStart(2, '0')}월 ${String(today.getDate()).padStart(2, '0')}일`; const currentFilterKey = state.currentMode === 'family' ? 'family' : state.currentFacility; const filteredLogs = state.logs.filter(l => l.facility === currentFilterKey); let contentHtml = ''; if (activeSubTab === '급식일지') { if (elements.reportDocTitle) elements.reportDocTitle.textContent = state.currentMode === 'family' ? '가 정 식 단 위 생 일 지' : '영 양 급 식 일 지'; contentHtml = `

${state.currentMode === 'family' ? '가정 내 자율 보건 위생 관리와 오배식 방지 수칙에 따른 안심 식단 급지 기록입니다.' : '보건복지부 급식안전 기준 및 한국사회보장정보원 시설 평가 가이드라인에 근거한 당일 배식 현황 보고서입니다.'}

${filteredLogs.length > 0 ? filteredLogs.map(log => ` `).join('') : ` `}
작성일자 배식 식단 내용 (대체식 포함) 집중 케어 대상자 VLM AI 분석 적합성 인증 확인
${log.date} ${log.menu} ${log.target} ${log.vlmStatus} (99%) 확인완료
배식 이력이 존재하지 않습니다. 식단 검증 탭에서 실시간 VLM 검증을 최종 완료해 주십시오.
`; } else if (activeSubTab === '알레르기대장') { if (elements.reportDocTitle) elements.reportDocTitle.textContent = state.currentMode === 'family' ? '가족 알레르기 및 만성질환 DB' : '집중 케어 대상자 대장'; contentHtml = `

${state.currentMode === 'family' ? '가족들의 건강 및 식품 과민반응 정보를 지속 보관하여 오인 사고를 예방하는 집중 예방 DB입니다.' : '사회복지시설 평가지표 24번 취약계층 식품 유해인자 맞춤 안전 관리대장 양식입니다.'}

${state.members[currentFilterKey].filter(m => m.type !== '일반').map((m, idx) => { return ` `; }).join('')}
순번 수혜자명 제한 식품 및 요인 핵심 대체 조리 지침 검증 방식
${idx + 1} ${m.name} ${m.detail} ${m.instruction} VLM 비전 매칭
`; } else if (activeSubTab === '대체식대장') { if (elements.reportDocTitle) elements.reportDocTitle.textContent = '대 체 급 식 제 공 대 장'; contentHtml = `

특이 체질이나 의학적 위험 인자로 인해 일반 식사를 섭취하지 못하는 대상자에게 안전 대체식을 처방하고 지급을 검증한 이력서입니다.

${filteredLogs.length > 0 ? filteredLogs.map(log => ` `).join('') : ` `}
제공일시 대상자 제한 원본 메뉴 실제 대체 급식 내용 대체 사유 VLM 스캔 완료시각
${log.date} ${log.target.split(' ')[0]} ${log.target.includes('아동') || log.target.includes('아들') ? '계란말이, 요구르트' : '쌀밥, 요플레'} ${log.alternative.split('➔')[1] || log.alternative} ${log.target.includes('아동') || log.target.includes('아들') ? '식품 알레르기 격리' : '혈당/유당 만성질환 케어'} ${log.timestamp}
대체 급식 배식 이력이 존재하지 않습니다. 식단 검증 탭에서 배식을 최종 확정해 주십시오.
`; } if (elements.reportDocContent) elements.reportDocContent.innerHTML = contentHtml; const savedHours = (filteredLogs.length * 4.2).toFixed(1); const co2Saved = (filteredLogs.length * 12.0).toFixed(1); if (elements.esgCo2) elements.esgCo2.textContent = `${co2Saved}kg`; if (elements.esgTime) elements.esgTime.textContent = `${savedHours}시간`; if (elements.esgLocal) elements.esgLocal.textContent = state.currentMode === 'family' ? '75%' : (state.currentFacility === 'child' ? '45%' : '60%'); } function initApiConfig() { if (elements.apiKeyInput) elements.apiKeyInput.value = apiConfig.key; if (elements.apiModelSelect) elements.apiModelSelect.value = apiConfig.model; if (elements.toggleKeyVisibilityBtn) { elements.toggleKeyVisibilityBtn.addEventListener('click', () => { if (!elements.apiKeyInput) return; const type = elements.apiKeyInput.type === 'password' ? 'text' : 'password'; elements.apiKeyInput.type = type; elements.toggleKeyVisibilityBtn.innerHTML = type === 'password' ? '' : ''; }); } if (elements.resetApiKeyBtn) { elements.resetApiKeyBtn.addEventListener('click', () => { if (elements.apiKeyInput) elements.apiKeyInput.value = ''; apiConfig.key = ''; localStorage.removeItem('OPENROUTER_API'); updateApiStatusUI(); showNotification("오픈라우터 API Key가 삭제되었습니다. 데모 시뮬레이션 모드로 작동합니다."); }); } if (elements.apiConfigForm) { elements.apiConfigForm.addEventListener('submit', (e) => { e.preventDefault(); const key = elements.apiKeyInput ? elements.apiKeyInput.value.trim() : ''; const model = elements.apiModelSelect ? elements.apiModelSelect.value : 'nvidia/llama-3.1-nemotron-70b-instruct:free'; apiConfig.key = key; apiConfig.model = model; if (key) { localStorage.setItem('OPENROUTER_API', key); localStorage.setItem('caremeal_api_model', model); showNotification("오픈라우터 무료 AI 연동 및 자동 폴백 체인 구성완료!"); } else { localStorage.removeItem('OPENROUTER_API'); showNotification("API Key가 비어있어 로컬 시뮬레이션 모드로 전환되었습니다."); } updateApiStatusUI(); }); } } function updateApiStatusUI() { if (elements.apiStatusBadge && elements.apiStatusText) { if (apiConfig.key) { elements.apiStatusBadge.className = 'api-status live-connected'; elements.apiStatusText.textContent = `AI 연동: 폴백 체인`; if (elements.analyzerModelIndicator) elements.analyzerModelIndicator.textContent = `${apiConfig.model.split('/')[1] || apiConfig.model} (실시간 AI 우선 연동)`; } else { elements.apiStatusBadge.className = 'api-status online'; elements.apiStatusText.textContent = 'AI: 시뮬레이션 모드'; if (elements.analyzerModelIndicator) elements.analyzerModelIndicator.textContent = 'Nemotron-70B / Llama-3 (시뮬레이션 폴백 모드)'; } } } function updateCharts() { if (typeof Chart === 'undefined') { console.warn('Chart.js is not loaded yet.'); return; } try { const isDark = document.body.classList.contains('dark-mode'); const labelColor = isDark ? '#94a3b8' : '#4b5563'; const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)'; Chart.defaults.color = labelColor; Chart.defaults.font.family = 'Outfit'; // 1. Dashboard Doughnut const dashCanvas = document.getElementById('dashboardChart'); if (dashCanvas) { if (state.charts.dashboard) state.charts.dashboard.destroy(); state.charts.dashboard = new Chart(dashCanvas, { type: 'doughnut', data: { labels: ['정상', '주의', '위험'], datasets: [{ data: [10, 3, 2], backgroundColor: ['#10b981', '#f59e0b', '#ef4444'], borderWidth: isDark ? 2 : 1, borderColor: isDark ? '#0f1322' : '#ffffff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, cutout: '72%' } }); } // 2. Stats Doughnut const statsDoughnutCanvas = document.getElementById('statsDoughnutChart'); if (statsDoughnutCanvas) { if (state.charts.statsDoughnut) state.charts.statsDoughnut.destroy(); state.charts.statsDoughnut = new Chart(statsDoughnutCanvas, { type: 'doughnut', data: { labels: ['주의', '위험', '안내'], datasets: [{ data: [28, 12, 5], backgroundColor: ['#fbbf24', '#f43f5e', '#3b82f6'], borderWidth: isDark ? 2 : 1, borderColor: isDark ? '#0f1322' : '#ffffff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 8, font: { size: 10 } } } }, cutout: '65%' } }); } // 3. Stats Pie const statsPieCanvas = document.getElementById('statsPieChart'); if (statsPieCanvas) { if (state.charts.statsPie) state.charts.statsPie.destroy(); state.charts.statsPie = new Chart(statsPieCanvas, { type: 'pie', data: { labels: ['알레르기', '영양 불균형', '식중독 위험', '질환별 적합성'], datasets: [{ data: [35, 30, 20, 15], backgroundColor: ['#ef4444', '#f59e0b', '#10b981', '#8b5cf6'], borderWidth: isDark ? 2 : 1, borderColor: isDark ? '#0f1322' : '#ffffff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 8, font: { size: 10 } } } } } }); } // 4. ESG Line Chart const esgCanvas = document.getElementById('esgChart'); if (esgCanvas) { if (state.charts.esg) state.charts.esg.destroy(); const lineLabel1 = state.currentMode === 'family' ? '자율 관리 시간 (H)' : '행정 돌봄 환원 시간 (H)'; const lineLabel2 = state.currentMode === 'family' ? '가정 탄소 배출량 (kg)' : '누적 탄소 절감 (kg)'; state.charts.esg = new Chart(esgCanvas, { type: 'line', data: { labels: ['1월', '2월', '3월', '4월', '5월', '6월(예정)'], datasets: [ { label: lineLabel1, data: [20, 35, 52, 68, 84, 105], borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.05)', tension: 0.4, fill: true }, { label: lineLabel2, data: [50, 95, 140, 190, 240, 310], borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.05)', tension: 0.4, fill: true } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { boxWidth: 10, font: { size: 10 } } } }, scales: { x: { grid: { display: false } }, y: { grid: { color: gridColor } } } } }); } } catch (e) { console.error('Error drawing charts:', e); } } function showNotification(message) { const oldToast = document.querySelector('.toast-notification'); if (oldToast) oldToast.remove(); const toast = document.createElement('div'); toast.className = 'toast-notification glass-card'; toast.style.borderRadius = '10px'; toast.style.boxShadow = '0 10px 25px rgba(31, 41, 55, 0.15)'; toast.innerHTML = ` ${message} `; document.body.appendChild(toast); setTimeout(() => toast.classList.add('show'), 100); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 400); }, 3000); }