Spaces:
Paused
Paused
NextPatent Bot
feat: customize allergen and chronic disease inputs using local dictionary mapping and reduce LLM dependencies
a6c34c9 | // 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 = '<i class="fa-solid fa-file-invoice"></i> <span>안전 증빙 일지</span>'; | |
| } 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 = '<i class="fa-solid fa-file-shield"></i> <span>평가 증빙 & ESG</span>'; | |
| } | |
| } | |
| 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 = '<i class="fa-solid fa-house"></i> 가정 프로필 기준'; | |
| } else { | |
| elements.lblTotalMembers.textContent = '총 관리 대상자'; | |
| if (elements.lblTotalDesc) elements.lblTotalDesc.innerHTML = '<i class="fa-solid fa-circle-info"></i> 사보원 연동 기준'; | |
| } | |
| } | |
| 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 = ` | |
| <div class="diet-time-badge ${timeBadgeClass}">${meal.time}</div> | |
| <div class="diet-menu-items">${meal.menu}</div> | |
| <div class="diet-status-tag ${meal.color}">${meal.tag}</div> | |
| `; | |
| 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 | |
| ? `<span class="status-pill danger">주의/위험</span>` | |
| : `<span class="status-pill success">검증 통과</span>`; | |
| const menuStr = item.menu.map(m => m.name).join(', '); | |
| card.innerHTML = ` | |
| <div class="card-header-toggle" onclick="toggleCardAccordion(${idx})"> | |
| <div class="header-day-info"> | |
| <span class="day-title">${item.date}</span> | |
| <span class="day-date">${menuStr}</span> | |
| </div> | |
| <div class="status-indicator-box"> | |
| ${statusPill} | |
| <i class="fa-solid fa-chevron-down toggle-arrow-icon" id="arrow-${idx}"></i> | |
| </div> | |
| </div> | |
| <div class="accordion-content" id="accordion-${idx}"> | |
| <div class="card-body-details"> | |
| <div class="menu-orig-box"> | |
| <h5>식단 구성 및 중량</h5> | |
| <p class="menu-content-text"> | |
| ${item.menu.map(m => `• ${m.name} (${m.weight})`).join('<br>')} | |
| </p> | |
| </div> | |
| ${item.alternative ? ` | |
| <div class="menu-alt-box"> | |
| <h5>AI 처방 대체식</h5> | |
| <p class="menu-content-text text-green font-bold"> | |
| ${item.alternative.from} ➔ ${item.alternative.to} | |
| </p> | |
| <p class="recipe-content-text" style="margin-top:5px; border-color: rgba(16,185,129,0.2);"> | |
| <strong>개선 효과</strong>: ${item.alternative.effect} | |
| </p> | |
| </div> | |
| ` : ''} | |
| <div class="recipe-guide-box"> | |
| <h5>스크리닝 위해 목록</h5> | |
| <p class="menu-content-text"> | |
| ${item.results.map(r => `• [${r.type}] ${r.desc}`).join('<br>')} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| 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 = '<i class="fa-solid fa-circle-xmark"></i>'; | |
| 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 = ` | |
| <button class="secondary-btn" onclick="switchTab('diet-analyzer')"><i class="fa-solid fa-rotate-left"></i> 식단 분석 재진행</button> | |
| <button class="primary-btn" style="background:var(--gradient-danger);" disabled><i class="fa-solid fa-ban"></i> 배식 불가</button> | |
| `; | |
| } | |
| } else { | |
| if (elements.vlmVerdictBox) elements.vlmVerdictBox.className = 'vlm-verdict-box passed'; | |
| if (elements.verdictIconContainer) elements.verdictIconContainer.innerHTML = '<i class="fa-solid fa-circle-check"></i>'; | |
| 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 = ` | |
| <button id="approve-final-btn" class="primary-btn" style="background:var(--gradient-success); width:100%;"><i class="fa-solid fa-circle-check"></i> 최종 배식 확정 및 이력 저장</button> | |
| `; | |
| 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 = '<i class="fa-solid fa-circle-xmark"></i>'; | |
| 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 = ` | |
| <button class="secondary-btn" onclick="switchTab('diet-analyzer')"><i class="fa-solid fa-rotate-left"></i> 식단 분석 재진행</button> | |
| <button class="primary-btn" style="background:var(--gradient-danger);" disabled><i class="fa-solid fa-ban"></i> 배식 불가</button> | |
| `; | |
| } | |
| } else { | |
| if (elements.vlmVerdictBox) elements.vlmVerdictBox.className = 'vlm-verdict-box passed'; | |
| if (elements.verdictIconContainer) elements.verdictIconContainer.innerHTML = '<i class="fa-solid fa-circle-check"></i>'; | |
| 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 = ` | |
| <button id="approve-final-btn" class="primary-btn" style="background:var(--gradient-success); width:100%;"><i class="fa-solid fa-circle-check"></i> 최종 배식 확정 및 이력 저장</button> | |
| `; | |
| 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 = ` | |
| <span class="food-name">${item.name}</span> | |
| <span class="food-weight">${item.weight}</span> | |
| `; | |
| 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 = ` | |
| <div class="res-num">${res.num}</div> | |
| <div class="res-body"> | |
| <h4>${res.title}</h4> | |
| <p>${res.desc}</p> | |
| </div> | |
| <div class="res-tag ${tagClass}">${res.status === 'danger' ? '위험' : res.status === 'warning' ? '주의' : '안내'}</div> | |
| `; | |
| 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 = ` | |
| <div class="menu-before">${diet.alternative.from}</div> | |
| <i class="fa-solid fa-arrow-right-long arrow-icon"></i> | |
| <div class="menu-after text-green">${diet.alternative.to}</div> | |
| `; | |
| } | |
| const eff = elements.vAlternativeBox.querySelector('.alt-effect-desc'); | |
| if (eff) { | |
| eff.innerHTML = ` | |
| <strong>개선 효과</strong>: ${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 = ` | |
| <td><strong>${item.brand}</strong></td> | |
| <td>${item.food}</td> | |
| <td><span class="risk-tag ${item.type === '회수' ? 'allergy' : 'disease'}">${item.type}</span></td> | |
| <td class="${item.type === '회수' ? 'text-red' : ''}">${item.reason}</td> | |
| <td>${item.date}</td> | |
| `; | |
| elements.recallTableBody.appendChild(tr); | |
| }); | |
| if (elements.recallTotalBadge) elements.recallTotalBadge.textContent = `${filtered.length}건 위해요소 발견`; | |
| } else { | |
| elements.recallTableBody.innerHTML = ` | |
| <tr> | |
| <td colspan="5" style="text-align: center; color: var(--text-muted); padding: 30px;"> | |
| <i class="fa-solid fa-circle-info" style="font-size:20px; margin-bottom:8px;"></i><br> | |
| 위해 및 회수 식자재 정보가 감지되지 않았습니다. (안전 등급 식재료군) | |
| </td> | |
| </tr> | |
| `; | |
| 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 => ` | |
| <label style="display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;"> | |
| <input type="checkbox" name="allergen-items" value="${item}" style="cursor:pointer;"> | |
| <span>${item}</span> | |
| </label> | |
| `).join(''); | |
| } | |
| if (elements.diseaseCheckboxGrid && elements.diseaseCheckboxGrid.children.length === 0) { | |
| elements.diseaseCheckboxGrid.innerHTML = DISEASE_CATEGORIES.map(item => ` | |
| <label style="display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;"> | |
| <input type="checkbox" name="disease-items" value="${item}" style="cursor:pointer;"> | |
| <span>${item}</span> | |
| </label> | |
| `).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 = '<i class="fa-solid fa-people-roof"></i> 가족 구성원'; | |
| if (elements.modalMemberTitle) elements.modalMemberTitle.innerHTML = '<i class="fa-solid fa-house-chimney-medical"></i> 가족 건강 프로필 신규 등록'; | |
| } else { | |
| elements.panelMemberTitle.innerHTML = '<i class="fa-solid fa-id-card"></i> 대상자 명단'; | |
| if (elements.modalMemberTitle) elements.modalMemberTitle.innerHTML = '<i class="fa-solid fa-user-plus"></i> 신규 관리 대상자 등록'; | |
| } | |
| } | |
| if (!elements.customUserPickerContainer) return; | |
| elements.customUserPickerContainer.innerHTML = ''; | |
| if (list.length === 0) { | |
| elements.customUserPickerContainer.innerHTML = '<p style="color:var(--text-muted); text-align:center; padding:20px;">등록된 케어 대상자가 없습니다.</p>'; | |
| 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 = ` | |
| <div class="picker-name-box"> | |
| <span class="picker-name">${member.name}</span> | |
| <span class="picker-desc">${member.age}</span> | |
| </div> | |
| <span class="picker-risk-badge ${badgeClass}">${member.type}</span> | |
| `; | |
| 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 += `<div class="guideline-badge low-sodium">알레르기 조치 <span class="sub text-muted">${user.detail} 격리 급식</span></div>`; | |
| } else if (isDisease) { | |
| if (user.detail.includes('당뇨')) { | |
| elements.userGuidelinesPills.innerHTML += `<div class="guideline-badge diabetes">당뇨식 <span class="sub text-muted">탄수화물 제한</span></div>`; | |
| } | |
| if (user.detail.includes('고혈압') || user.instruction.includes('저염')) { | |
| elements.userGuidelinesPills.innerHTML += `<div class="guideline-badge low-sodium">저염식 <span class="sub text-muted">나트륨 1,500mg 조절</span></div>`; | |
| } | |
| if (user.detail.includes('연하')) { | |
| elements.userGuidelinesPills.innerHTML += `<div class="guideline-badge swallow">연하식 <span class="sub text-muted">연하 2단계 연화 다짐</span></div>`; | |
| } | |
| } else { | |
| elements.userGuidelinesPills.innerHTML = '<div class="guideline-badge swallow" style="background-color:rgba(0,0,0,0.03); color:var(--text-secondary); border-color:var(--border-color);">일반 급식 기준 <span class="sub text-muted">특이사항 없음</span></div>'; | |
| } | |
| } | |
| 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 += ` | |
| <div class="avoid-food-card danger"> | |
| <div class="card-icon"><i class="fa-solid fa-circle-xmark"></i></div> | |
| <div class="card-info"> | |
| <h4>${clean} 포함 식품</h4> | |
| <p>${ex} 등 유래 물질 전면 제외 급식</p> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| } else if (isDisease) { | |
| if (user.detail.includes('당뇨')) { | |
| elements.userAvoidFoods.innerHTML += ` | |
| <div class="avoid-food-card danger"> | |
| <div class="card-icon"><i class="fa-solid fa-circle-xmark"></i></div> | |
| <div class="card-info"> | |
| <h4>단순당 높은 식품</h4> | |
| <p>케이크, 초콜릿, 탄산음료 등 과당 함유 가공품 배제</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| if (user.detail.includes('고혈압')) { | |
| elements.userAvoidFoods.innerHTML += ` | |
| <div class="avoid-food-card warning"> | |
| <div class="card-icon"><i class="fa-solid fa-triangle-exclamation"></i></div> | |
| <div class="card-info"> | |
| <h4>고나트륨 염장 가공품</h4> | |
| <p>젓갈, 장아찌, 고염도 찌개 양념 육수 섭취 제한</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| if (user.detail.includes('연하')) { | |
| elements.userAvoidFoods.innerHTML += ` | |
| <div class="avoid-food-card danger"> | |
| <div class="card-icon"><i class="fa-solid fa-circle-xmark"></i></div> | |
| <div class="card-info"> | |
| <h4>질기거나 딱딱한 식품</h4> | |
| <p>견과류, 말린 오징어, 대형 생채소류 등 목넘김 방해 물질 차단</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| } else { | |
| elements.userAvoidFoods.innerHTML = ` | |
| <div class="avoid-food-card warning" style="background-color:rgba(0,0,0,0.01); border-color:var(--border-color);"> | |
| <div class="card-icon" style="color:var(--text-muted);"><i class="fa-solid fa-circle-check"></i></div> | |
| <div class="card-info"> | |
| <h4 style="color:var(--text-primary);">제한 식품 없음</h4> | |
| <p>일반 위생 조리 지침 준수식 급식 제공 가능</p> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| } | |
| if (elements.infoDetailsList) { | |
| elements.infoDetailsList.innerHTML = ` | |
| <div class="detail-log-row"> | |
| <div class="log-lbl">고유식별키</div> | |
| <div class="log-val">CT-${user.id}-2026</div> | |
| </div> | |
| <div class="detail-log-row"> | |
| <div class="log-lbl">수혜자성명</div> | |
| <div class="log-val"><strong>${user.name}</strong></div> | |
| </div> | |
| <div class="detail-log-row"> | |
| <div class="log-lbl">연령 및 정보</div> | |
| <div class="log-val">${user.age}</div> | |
| </div> | |
| <div class="detail-log-row"> | |
| <div class="log-lbl">위험도 진단</div> | |
| <div class="log-val"><span class="risk-tag ${user.type === '알레르기' ? 'allergy' : user.type === '질환식' ? 'disease' : 'general'}">${user.type}</span></div> | |
| </div> | |
| <div class="detail-log-row"> | |
| <div class="log-lbl">조리제한지침</div> | |
| <div class="log-val">${user.instruction}</div> | |
| </div> | |
| `; | |
| } | |
| 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 = ` | |
| <div class="timeline-time">${log.timestamp}</div> | |
| <div class="timeline-title">${log.alternative}</div> | |
| <div class="timeline-desc">제공메뉴: ${log.menu}<br>조리감독: ${log.guideline} [VLM ${log.vlmStatus}]</div> | |
| `; | |
| elements.historyTimelineContainer.appendChild(timelineCard); | |
| }); | |
| } else { | |
| elements.historyTimelineContainer.innerHTML = ` | |
| <div class="timeline-item-card" style="border-style:dashed;"> | |
| <div class="timeline-time">기록 없음</div> | |
| <div class="timeline-title">대체 급식 제공 이력이 없습니다.</div> | |
| <div class="timeline-desc">VLM 배식 스캔을 완료하면 해당 이력이 타임라인에 누적 보관됩니다.</div> | |
| </div> | |
| `; | |
| } | |
| } | |
| } | |
| } | |
| 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 = ` | |
| <div class="report-paper-body"> | |
| <p>${state.currentMode === 'family' ? '가정 내 자율 보건 위생 관리와 오배식 방지 수칙에 따른 안심 식단 급지 기록입니다.' : '보건복지부 급식안전 기준 및 한국사회보장정보원 시설 평가 가이드라인에 근거한 당일 배식 현황 보고서입니다.'}</p> | |
| <table class="report-table"> | |
| <thead> | |
| <tr> | |
| <th>작성일자</th> | |
| <th>배식 식단 내용 (대체식 포함)</th> | |
| <th>집중 케어 대상자</th> | |
| <th>VLM AI 분석 적합성</th> | |
| <th>인증 확인</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${filteredLogs.length > 0 ? filteredLogs.map(log => ` | |
| <tr> | |
| <td>${log.date}</td> | |
| <td class="align-left">${log.menu}</td> | |
| <td>${log.target}</td> | |
| <td><span style="color:var(--color-green); font-weight:bold;">${log.vlmStatus} (99%)</span></td> | |
| <td>확인완료</td> | |
| </tr> | |
| `).join('') : ` | |
| <tr> | |
| <td colspan="5" style="color: var(--text-muted); padding:20px 0;">배식 이력이 존재하지 않습니다. 식단 검증 탭에서 실시간 VLM 검증을 최종 완료해 주십시오.</td> | |
| </tr> | |
| `} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } else if (activeSubTab === '알레르기대장') { | |
| if (elements.reportDocTitle) elements.reportDocTitle.textContent = state.currentMode === 'family' ? '가족 알레르기 및 만성질환 DB' : '집중 케어 대상자 대장'; | |
| contentHtml = ` | |
| <div class="report-paper-body"> | |
| <p>${state.currentMode === 'family' ? '가족들의 건강 및 식품 과민반응 정보를 지속 보관하여 오인 사고를 예방하는 집중 예방 DB입니다.' : '사회복지시설 평가지표 24번 취약계층 식품 유해인자 맞춤 안전 관리대장 양식입니다.'}</p> | |
| <table class="report-table"> | |
| <thead> | |
| <tr> | |
| <th>순번</th> | |
| <th>수혜자명</th> | |
| <th>제한 식품 및 요인</th> | |
| <th>핵심 대체 조리 지침</th> | |
| <th>검증 방식</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${state.members[currentFilterKey].filter(m => m.type !== '일반').map((m, idx) => { | |
| return ` | |
| <tr> | |
| <td>${idx + 1}</td> | |
| <td><strong>${m.name}</strong></td> | |
| <td><span style="color:#e11d48; font-weight:600;">${m.detail}</span></td> | |
| <td class="align-left">${m.instruction}</td> | |
| <td>VLM 비전 매칭</td> | |
| </tr> | |
| `; | |
| }).join('')} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } else if (activeSubTab === '대체식대장') { | |
| if (elements.reportDocTitle) elements.reportDocTitle.textContent = '대 체 급 식 제 공 대 장'; | |
| contentHtml = ` | |
| <div class="report-paper-body"> | |
| <p>특이 체질이나 의학적 위험 인자로 인해 일반 식사를 섭취하지 못하는 대상자에게 안전 대체식을 처방하고 지급을 검증한 이력서입니다.</p> | |
| <table class="report-table"> | |
| <thead> | |
| <tr> | |
| <th>제공일시</th> | |
| <th>대상자</th> | |
| <th>제한 원본 메뉴</th> | |
| <th>실제 대체 급식 내용</th> | |
| <th>대체 사유</th> | |
| <th>VLM 스캔 완료시각</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${filteredLogs.length > 0 ? filteredLogs.map(log => ` | |
| <tr> | |
| <td>${log.date}</td> | |
| <td><strong>${log.target.split(' ')[0]}</strong></td> | |
| <td><span style="color:#e11d48; text-decoration:line-through;">${log.target.includes('아동') || log.target.includes('아들') ? '계란말이, 요구르트' : '쌀밥, 요플레'}</span></td> | |
| <td><span style="color:#0d9488; font-weight:bold;">${log.alternative.split('➔')[1] || log.alternative}</span></td> | |
| <td>${log.target.includes('아동') || log.target.includes('아들') ? '식품 알레르기 격리' : '혈당/유당 만성질환 케어'}</td> | |
| <td>${log.timestamp}</td> | |
| </tr> | |
| `).join('') : ` | |
| <tr> | |
| <td colspan="6" style="color: var(--text-muted); padding:20px 0;">대체 급식 배식 이력이 존재하지 않습니다. 식단 검증 탭에서 배식을 최종 확정해 주십시오.</td> | |
| </tr> | |
| `} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| 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' ? '<i class="fa-solid fa-eye"></i>' : '<i class="fa-solid fa-eye-slash"></i>'; | |
| }); | |
| } | |
| 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 = ` | |
| <i class="fa-solid fa-bell-ring" style="color:var(--color-cyan);"></i> | |
| <span style="font-size:12px; font-weight:600;">${message}</span> | |
| `; | |
| document.body.appendChild(toast); | |
| setTimeout(() => toast.classList.add('show'), 100); | |
| setTimeout(() => { | |
| toast.classList.remove('show'); | |
| setTimeout(() => toast.remove(), 400); | |
| }, 3000); | |
| } | |