zzangtest commited on
Commit
b289b19
·
0 Parent(s):

feat: initial release without tracked binary images

Browse files
Files changed (6) hide show
  1. .github/workflows/deploy.yml +31 -0
  2. Dockerfile +12 -0
  3. README.md +39 -0
  4. app.js +1546 -0
  5. index.html +550 -0
  6. styles.css +2139 -0
.github/workflows/deploy.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Spaces
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ # GitHub Actions 탭에서 수동 실행 허용
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ deploy:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Checkout Code
15
+ uses: actions/checkout@v3
16
+ with:
17
+ fetch-depth: 0
18
+
19
+ - name: Setup Git Credentials & Push to HF
20
+ env:
21
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
22
+ # 본인의 Hugging Face Username 및 Space Name에 맞춰 변경하십시오.
23
+ HF_USERNAME: "HwangJinwook"
24
+ HF_SPACE_NAME: "caretable"
25
+ run: |
26
+ git config --global user.email "actions@github.com"
27
+ git config --global user.name "GitHub Actions"
28
+
29
+ # Hugging Face Spaces Git Remote 추가 및 강제 푸시 (Docker 배포 진행)
30
+ git remote add hf https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/$HF_USERNAME/$HF_SPACE_NAME
31
+ git push --force hf main
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nginx:alpine
2
+
3
+ # 프로젝트 전체 정적 파일을 Nginx 기본 서빙 경로로 복사
4
+ COPY . /usr/share/nginx/html
5
+
6
+ # Hugging Face Spaces는 기본 포트로 7860을 사용하므로 default.conf의 80 포트를 7860으로 리라이팅
7
+ RUN sed -i 's/listen[[:space:]]*80;/listen 7860;/g' /etc/nginx/conf.d/default.conf
8
+
9
+ # 7860 포트 노출
10
+ EXPOSE 7860
11
+
12
+ CMD ["nginx", "-g", "daemon off;"]
README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CareTable
3
+ emoji: 🍱
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # CareTable - 취약계층 및 가정 맞춤형 급식 안전 스케줄러 & 증빙 자동화 플랫폼
11
+
12
+ 2026 사회보장정보와 공공·민간 빅데이터를 활용한 국민행복 서비스 발굴‧창업경진대회 출품작입니다.
13
+
14
+ 본 저장소는 **Hugging Face Spaces**의 Docker SDK 환경을 통해 호스팅되는 CareTable 데모 웹 사이트입니다.
15
+
16
+ ## 🚀 주요 기능
17
+ 1. **일반 가정 / 사회복지시설 범용 토글**: 시설용 모드와 일반 소비자(가정) 모드를 스위칭하여 맞춤형 데이터베이스 및 ESG 일지를 관리합니다.
18
+ 2. **OpenRouter 무료 AI 모델 실시간 연동**: Llama-3.1 Nemotron, Llama-3, Gemma-2 등 오픈라우터 무료 LLM 모델 폴백(Fallback) 체인 및 Llama-3.2 Vision VLM 무료 모델 실시간 호출을 지원합니다. (API Key가 없어도 시뮬레이션 모드로 정상 작동합니다.)
19
+ 3. **주간/월간 대량 식단 스케줄 분석**: 월/주 단위 식단을 통째로 입력하면 일목요연한 캘린더 그리드 및 아코디언 카드 뷰로 시각화합니다.
20
+ 4. **VLM(Vision-Language Model) 오배식 방지 검증**: 식판 이미지 촬영 및 업로드 시 비전 AI가 유해 식자재 혼입 여부를 2차 자동 검증합니다.
21
+ 5. **디지털 자동 일지 및 ESG 성과 인쇄**: 시설 평가 증빙 및 ESG 가치 수치 통계를 대시보드로 요약하고 공식 PDF로 깔끔하게 인쇄합니다.
22
+
23
+ ---
24
+
25
+ ## 🛠️ GitHub Actions를 활용한 Hugging Face Spaces CI/CD 배포 구성법
26
+
27
+ 본 프로젝트의 코드를 GitHub에 올린 뒤, 코드 변경 시마다 허깅페이스 스페이스로 배포 자동화를 하려면 아래 단계를 완료하십시오.
28
+
29
+ ### 1단계: Hugging Face Access Token 발급
30
+ 1. [Hugging Face Settings](https://huggingface.co/settings/tokens)로 이동합니다.
31
+ 2. **New token** 버튼을 클릭하고 Role을 **Write**로 지정하여 토큰을 생성 및 복사합니다.
32
+
33
+ ### 2단계: GitHub Secrets에 토큰 추가
34
+ 1. 배포할 GitHub Repository의 **Settings ➔ Secrets and variables ➔ Actions**로 이동합니다.
35
+ 2. **New repository secret** 버튼을 클릭합니다.
36
+ 3. Name에 `HF_TOKEN`을 입력하고, Value에 복사한 Hugging Face Access Token을 붙여넣습니다.
37
+
38
+ ### 3단계: GitHub push 진행
39
+ - 코드가 GitHub 저장소의 `main` 브랜치에 push될 때마다 GitHub Actions 워크플로우가 자동으로 트리거되어 Hugging Face Spaces로 코드를 빌드/복사합니다.
app.js ADDED
@@ -0,0 +1,1546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CareTable - Premium Demo Logic Engine (Version 2.2)
2
+
3
+ // Global State Default Data Sets
4
+ const defaultMembers = {
5
+ child: [
6
+ { id: 1, name: '김민수', age: '5세 / 남', type: '알레르기', detail: '달걀, 우유', instruction: '달걀/우유 성분 완전 차단 및 대체 간식 제공', status: 'care' },
7
+ { id: 2, name: '박예린', age: '6세 / 여', type: '알레르기', detail: '땅콩, 견과류', instruction: '견과류 함유 소스/조미료 전면 금지 및 대체 견과 무첨가 제품 사용', status: 'safe' },
8
+ { id: 3, name: '최지환', age: '4세 / 남', type: '알레르기', detail: '대두, 콩류', instruction: '간장, 된장 대체 소스 사용 및 콩 단백질 대체 반찬 제공', status: 'safe' }
9
+ ],
10
+ senior: [
11
+ { id: 1, name: '최옥분', age: '82세 / 여', type: '질환식', detail: '당뇨, 연하곤란 2단계', instruction: '저염/저당질 식사, 1.5cm 이하 다진식(Soft Food) 및 연하보조제 믹스 제공', status: 'care' },
12
+ { id: 2, name: '정순길', age: '78세 / 남', type: '질환식', detail: '신부전증 (칼륨 제한)', instruction: '나트륨 최소화, 생채소 섭취 금지(데친 채소 제공), 잡곡밥 대신 흰쌀밥 제공', status: 'care' },
13
+ { id: 3, name: '이명자', age: '85세 / 여', type: '질환식', detail: '유당불내증', instruction: '일반 우유/요구르트 급식 금지, 락토프리 우유 또는 대체 두유 제공', status: 'care' }
14
+ ],
15
+ family: [
16
+ { id: 1, name: '우리남편', age: '42세 / 남', type: '질환식', detail: '고혈압, 통풍', instruction: '나트륨 제한식, 퓨린이 많은 맥주/조개류 반찬 금지, 식이섬유 증량', status: 'care' },
17
+ { id: 2, name: '첫째딸', age: '8세 / 여', type: '알레르기', detail: '복숭아, 키위', instruction: '해당 과일 디저트 일절 배제 및 과일 칼 분리 사용 철저', status: 'safe' },
18
+ { id: 3, name: '막내아들', age: '5세 / 남', type: '알레르기', detail: '우유, 밀가루', instruction: '락토프리 유제품 급지 및 쌀식빵/글루텐프리 대체 베이킹 제공', status: 'care' }
19
+ ]
20
+ };
21
+
22
+ const state = {
23
+ currentTab: 'dashboard',
24
+ currentMode: 'agency', // 'agency' (기관) or 'family' (가정)
25
+ currentFacility: 'child', // 'child' (보육) or 'senior' (노인)
26
+ members: JSON.parse(JSON.stringify(defaultMembers)), // Deep Copy
27
+
28
+ // Simulation / Analyzer variables
29
+ activeDietInput: '',
30
+ analysisResult: null, // Schedule cards list
31
+ selectedVlmImage: null, // 'normal' or 'alternative' or 'custom'
32
+ customVlmImageSrc: null,
33
+ vlmApproved: false,
34
+
35
+ // Database of logs to generate reports
36
+ logs: [
37
+ {
38
+ date: '2026-05-30',
39
+ menu: '보리밥, 계란국, 제육볶음, 두부조림, 오렌지주스',
40
+ facility: 'child',
41
+ target: '김민수 아동 (달걀/우유)',
42
+ alternative: '계란국 ➔ 맑은 무국 대체 제공',
43
+ guideline: '계란 조리 시 교차오염 방지용 독립 조리기구 사용',
44
+ vlmStatus: 'PASS',
45
+ timestamp: '2026-05-30 11:45'
46
+ },
47
+ {
48
+ date: '2026-05-30',
49
+ menu: '잡곡밥, 버섯국, 불고기, 고구마순나물, 요플레',
50
+ facility: 'senior',
51
+ target: '이명자 어르신 (유당불내증)',
52
+ alternative: '요플레 ➔ 락토프리 요거트 대체 제공',
53
+ guideline: '유제품 보관 구역 구별 및 라벨링 철저',
54
+ vlmStatus: 'PASS',
55
+ timestamp: '2026-05-30 11:52'
56
+ }
57
+ ],
58
+ charts: {
59
+ dashboard: null,
60
+ esg: null
61
+ }
62
+ };
63
+
64
+ // API settings configuration
65
+ const apiConfig = {
66
+ key: localStorage.getItem('caremeal_api_key') || '',
67
+ model: localStorage.getItem('caremeal_api_model') || 'nvidia/llama-3.1-nemotron-70b-instruct:free'
68
+ };
69
+
70
+ // OpenRouter Free Models for Fallback Strategy
71
+ const freeLLMModels = [
72
+ "nvidia/llama-3.1-nemotron-70b-instruct:free",
73
+ "meta-llama/llama-3-8b-instruct:free",
74
+ "google/gemma-2-9b-it:free",
75
+ "mistralai/mistral-7b-instruct:free"
76
+ ];
77
+
78
+ const freeVLMModels = [
79
+ "meta-llama/llama-3.2-11b-vision-instruct:free",
80
+ "qwen/qwen-2-vl-7b-instruct:free"
81
+ ];
82
+
83
+ // DOM Elements
84
+ const elements = {
85
+ navButtons: document.querySelectorAll('.nav-btn'),
86
+ tabContents: document.querySelectorAll('.tab-content'),
87
+ facilitySelect: document.getElementById('facility-select'),
88
+ facilitySelectorWrapper: document.getElementById('facility-selector-wrapper'),
89
+ pageMainTitle: document.getElementById('page-main-title'),
90
+ pageSubTitle: document.getElementById('page-sub-title'),
91
+ mainBadge: document.getElementById('main-badge'),
92
+
93
+ // Theme Toggle
94
+ themeToggleBtn: document.getElementById('theme-toggle-btn'),
95
+
96
+ // Mode Switch Toggle
97
+ modeToggleCheckbox: document.getElementById('mode-toggle-checkbox'),
98
+ modeTextLeft: document.getElementById('mode-text-left'),
99
+ modeTextRight: document.getElementById('mode-text-right'),
100
+ userAvatarInitial: document.getElementById('user-avatar-initial'),
101
+ userDisplayName: document.getElementById('user-display-name'),
102
+ userDisplayRole: document.getElementById('user-display-role'),
103
+
104
+ // Stats
105
+ statTotalMembers: document.getElementById('stat-total-members'),
106
+ statRiskMembers: document.getElementById('stat-risk-members'),
107
+ statSavedTime: document.getElementById('stat-saved-time'),
108
+ statSafetyScore: document.getElementById('stat-safety-score'),
109
+
110
+ lblTotalMembers: document.getElementById('lbl-total-members'),
111
+ lblTotalDesc: document.getElementById('lbl-total-desc'),
112
+ lblSavedTime: document.getElementById('lbl-saved-time'),
113
+ lblSavedDesc: document.getElementById('lbl-saved-desc'),
114
+ esgBottomDesc: document.getElementById('esg-bottom-desc'),
115
+
116
+ // GNB & API Connection Status
117
+ openApiConfigBtn: document.getElementById('open-api-config-btn'),
118
+ apiStatusBadge: document.getElementById('api-status-badge'),
119
+ apiStatusText: document.getElementById('api-status-text'),
120
+
121
+ // Tables
122
+ memberTableBody: document.getElementById('member-table-body'),
123
+ panelMemberTitle: document.getElementById('panel-member-title'),
124
+
125
+ // Bulk Diet tab
126
+ dietBulkInput: document.getElementById('diet-bulk-input'),
127
+ analyzeDietBtn: document.getElementById('analyze-diet-btn'),
128
+ analyzerLoading: document.getElementById('analyzer-loading'),
129
+ analyzerModelIndicator: document.getElementById('analyzer-model-indicator'),
130
+ loadingText: document.getElementById('loading-text'),
131
+ scheduleResultView: document.getElementById('schedule-result-view'),
132
+ scheduleCardsContainer: document.getElementById('schedule-cards-container'),
133
+ scheduleStatusBanner: document.getElementById('schedule-status-banner'),
134
+ scheduleSummaryTitle: document.getElementById('schedule-summary-title'),
135
+ scheduleSummaryDesc: document.getElementById('schedule-summary-desc'),
136
+ scheduleEmptyView: document.getElementById('schedule-empty-view'),
137
+ confirmAllDietBtn: document.getElementById('confirm-all-diet-btn'),
138
+ exampleTags: document.querySelectorAll('.example-tag'),
139
+
140
+ // VLM tab
141
+ vlmOptNormal: document.getElementById('sim-opt-normal'),
142
+ vlmOptAlternative: document.getElementById('sim-opt-alternative'),
143
+ vlmTargetImage: document.getElementById('vlm-target-image'),
144
+ vlmViewportPlaceholder: document.querySelector('.viewport-placeholder'),
145
+ runVlmBtn: document.getElementById('run-vlm-btn'),
146
+ scannerLaser: document.getElementById('scanner-laser'),
147
+ vlmEmptyResult: document.getElementById('vlm-empty-result'),
148
+ vlmResultPanel: document.getElementById('vlm-result-panel'),
149
+ vlmVerdictBox: document.getElementById('vlm-verdict-box'),
150
+ verdictIconContainer: document.getElementById('verdict-icon-container'),
151
+ verdictTitle: document.getElementById('verdict-title'),
152
+ vlmMatchRate: document.getElementById('vlm-match-rate'),
153
+ vlmMatchValue: document.getElementById('vlm-match-value'),
154
+ vlmAllergenStatus: document.getElementById('vlm-allergen-status'),
155
+ vlmPortionStatus: document.getElementById('vlm-portion-status'),
156
+ vlmAnalysisReason: document.getElementById('vlm-analysis-reason'),
157
+ vlmActionFooter: document.getElementById('vlm-action-footer'),
158
+ triggerFileBtn: document.getElementById('trigger-file-btn'),
159
+ vlmFileInput: document.getElementById('vlm-file-input'),
160
+
161
+ // Report tab
162
+ reportSubTabBtns: document.querySelectorAll('.sub-tab-btn'),
163
+ reportDocTitle: document.getElementById('report-doc-title'),
164
+ rFacilityName: document.getElementById('r-facility-name'),
165
+ rDate: document.getElementById('r-date'),
166
+ rWriter: document.getElementById('r-writer'),
167
+ reportDocContent: document.getElementById('report-doc-content'),
168
+ printReportBtn: document.getElementById('print-report-btn'),
169
+ reportConfirmStatement: document.getElementById('report-confirm-statement'),
170
+ reportSignerName: document.getElementById('report-signer-name'),
171
+ reportFooterDesc: document.getElementById('report-footer-desc'),
172
+ navReportBtn: document.getElementById('nav-report-btn'),
173
+ esgCo2: document.getElementById('esg-co2'),
174
+ esgTime: document.getElementById('esg-time'),
175
+ esgLocal: document.getElementById('esg-local'),
176
+
177
+ // Modals & Forms
178
+ addMemberBtn: document.getElementById('add-member-btn'),
179
+ addMemberModal: document.getElementById('add-member-modal'),
180
+ closeMemberModalBtn: document.getElementById('close-member-modal-btn'),
181
+ cancelMemberBtn: document.getElementById('cancel-member-btn'),
182
+ memberForm: document.getElementById('member-form'),
183
+ modalMemberTitle: document.getElementById('modal-member-title'),
184
+
185
+ apiConfigModal: document.getElementById('api-config-modal'),
186
+ closeApiModalBtn: document.getElementById('close-api-modal-btn'),
187
+ apiConfigForm: document.getElementById('api-config-form'),
188
+ apiKeyInput: document.getElementById('api-key-input'),
189
+ apiModelSelect: document.getElementById('api-model-select'),
190
+ toggleKeyVisibilityBtn: document.getElementById('toggle-key-visibility-btn'),
191
+ resetApiKeyBtn: document.getElementById('reset-api-key-btn')
192
+ };
193
+
194
+ // Templates
195
+ const templates = {
196
+ weekly: `[월요일] 쌀밥, 계란말이, 어묵볶음, 요구르트\n[화요일] 잡곡밥, 소불고기, 고구마순나물, 두부국, 우유\n[수요일] 현미밥, 맑은 무국, 생선구이, 콩자반, 식혜\n[목요일] 쌀밥, 닭볶음탕, 오징어채무침, 땅콩조림, 사과주스\n[금요일] 잡곡밥, 버섯찌개, 계란찜, 시금치나물, 핫초코`,
197
+ monthly: `[1주 월요일] 쌀밥, 계란말이, 어묵볶음, 요구르트\n[1주 화요일] 잡곡밥, 소불고기, 시금치, 두부국, 우유\n[1주 수요일] 현미밥, 맑은 무국, 생선구이, 콩자반, 요구르트\n[1주 목요일] 쌀밥, 닭볶음탕, 오징어채무침, 땅콩조림, 주스\n[1주 금요일] 잡곡밥, 버섯찌개, 만두국, 고구마순나물, 요구르트\n[2주 월요일] 귀리밥, 돈까스, 샐러드, 계란국, 우유\n[2주 화요일] 쌀밥, 된장찌개, 갈치구이, 멸치볶음, 식혜`
198
+ };
199
+
200
+ // 1. Initial System Setup
201
+ document.addEventListener('DOMContentLoaded', () => {
202
+ loadDatabase();
203
+ initThemeManager();
204
+ initModeToggler();
205
+ initTabs();
206
+ initFacilitySelector();
207
+ initCharts();
208
+ initDietAnalyzer();
209
+ initVlmScanner();
210
+ initReportCenter();
211
+ initModal();
212
+ initApiConfig();
213
+
214
+ // Draw initial dashboard & status
215
+ switchTab('dashboard');
216
+ updateApiStatusUI();
217
+ });
218
+
219
+ // Load DB from localStorage or restore defaults if empty/not-present
220
+ function loadDatabase() {
221
+ ['child', 'senior', 'family'].forEach(key => {
222
+ const stored = localStorage.getItem(`caremeal_members_${key}`);
223
+ if (stored && JSON.parse(stored).length > 0) {
224
+ state.members[key] = JSON.parse(stored);
225
+ } else {
226
+ state.members[key] = JSON.parse(JSON.stringify(defaultMembers[key]));
227
+ saveDatabase(key);
228
+ }
229
+ });
230
+
231
+ const storedLogs = localStorage.getItem('caremeal_logs');
232
+ if (storedLogs) {
233
+ state.logs = JSON.parse(storedLogs);
234
+ }
235
+ }
236
+
237
+ function saveDatabase(key) {
238
+ localStorage.setItem(`caremeal_members_${key}`, JSON.stringify(state.members[key]));
239
+ }
240
+
241
+ function saveLogs() {
242
+ localStorage.setItem('caremeal_logs', JSON.stringify(state.logs));
243
+ }
244
+
245
+ // 2. Light / Dark Theme Management
246
+ function initThemeManager() {
247
+ const storedTheme = localStorage.getItem('caremeal_theme') || 'light';
248
+ applyTheme(storedTheme);
249
+
250
+ elements.themeToggleBtn.addEventListener('click', () => {
251
+ const isDark = document.body.classList.contains('dark-mode');
252
+ const nextTheme = isDark ? 'light' : 'dark';
253
+ applyTheme(nextTheme);
254
+ localStorage.setItem('caremeal_theme', nextTheme);
255
+ });
256
+ }
257
+
258
+ function applyTheme(theme) {
259
+ const icon = elements.themeToggleBtn.querySelector('i');
260
+ const text = document.getElementById('theme-toggle-text');
261
+
262
+ if (theme === 'dark') {
263
+ document.body.classList.add('dark-mode');
264
+ icon.className = 'fa-solid fa-sun';
265
+ text.textContent = '라이트 모드';
266
+ } else {
267
+ document.body.classList.remove('dark-mode');
268
+ icon.className = 'fa-solid fa-moon';
269
+ text.textContent = '다크 모드';
270
+ }
271
+ }
272
+
273
+ // 3. Mode Switching (Agency vs Family)
274
+ function initModeToggler() {
275
+ const storedMode = localStorage.getItem('caremeal_mode');
276
+ if (storedMode) {
277
+ state.currentMode = storedMode;
278
+ elements.modeToggleCheckbox.checked = (storedMode === 'family');
279
+ }
280
+
281
+ applyModeTheme();
282
+
283
+ elements.modeToggleCheckbox.addEventListener('change', (e) => {
284
+ state.currentMode = e.target.checked ? 'family' : 'agency';
285
+ localStorage.setItem('caremeal_mode', state.currentMode);
286
+
287
+ applyModeTheme();
288
+
289
+ resetDietUI();
290
+ resetVlmUI();
291
+
292
+ switchTab(state.currentTab);
293
+ showNotification(`${state.currentMode === 'family' ? '가정(가족) 건강 케어' : '보건복지시설 관리'} 모드로 전환되었습니다.`);
294
+ });
295
+ }
296
+
297
+ function applyModeTheme() {
298
+ if (state.currentMode === 'family') {
299
+ elements.modeTextLeft.classList.remove('active-mode');
300
+ elements.modeTextRight.classList.add('active-mode');
301
+ elements.facilitySelectorWrapper.classList.add('hidden');
302
+ elements.mainBadge.textContent = '가족 건강 관리 모드';
303
+ elements.mainBadge.style.background = 'rgba(124, 58, 237, 0.08)';
304
+ elements.mainBadge.style.borderColor = 'rgba(124, 58, 237, 0.2)';
305
+ elements.mainBadge.style.color = '#7c3aed';
306
+
307
+ elements.userAvatarInitial.textContent = '가';
308
+ elements.userDisplayName.textContent = '엄마/아빠';
309
+ elements.userDisplayRole.textContent = '우리 집 영양 안심 케어';
310
+
311
+ elements.navReportBtn.innerHTML = '<i class="fa-solid fa-file-invoice"></i> <span>안전 증빙 일지</span>';
312
+ } else {
313
+ elements.modeTextLeft.classList.add('active-mode');
314
+ elements.modeTextRight.classList.remove('active-mode');
315
+ elements.facilitySelectorWrapper.classList.remove('hidden');
316
+ elements.mainBadge.textContent = '사보원 공공데이터 연계';
317
+ elements.mainBadge.style.background = 'rgba(37, 99, 235, 0.08)';
318
+ elements.mainBadge.style.borderColor = 'rgba(37, 99, 235, 0.2)';
319
+ elements.mainBadge.style.color = 'var(--color-blue)';
320
+
321
+ elements.userAvatarInitial.textContent = '복';
322
+ elements.userDisplayName.textContent = '박아름 사회복지사';
323
+ elements.userDisplayRole.textContent = '마포구 시설 운영대표';
324
+ elements.navReportBtn.innerHTML = '<i class="fa-solid fa-file-shield"></i> <span>평가 증빙 & ESG</span>';
325
+ }
326
+ }
327
+
328
+ // 4. Navigation System
329
+ function initTabs() {
330
+ elements.navButtons.forEach(btn => {
331
+ btn.addEventListener('click', () => {
332
+ const tabId = btn.getAttribute('data-tab');
333
+ switchTab(tabId);
334
+ });
335
+ });
336
+ }
337
+
338
+ function switchTab(tabId) {
339
+ state.currentTab = tabId;
340
+
341
+ elements.navButtons.forEach(btn => {
342
+ if (btn.getAttribute('data-tab') === tabId) {
343
+ btn.classList.add('active');
344
+ } else {
345
+ btn.classList.remove('active');
346
+ }
347
+ });
348
+
349
+ elements.tabContents.forEach(content => {
350
+ if (content.id === tabId) {
351
+ content.classList.add('active-tab');
352
+ } else {
353
+ content.classList.remove('active-tab');
354
+ }
355
+ });
356
+
357
+ updateHeaderTitles(tabId);
358
+
359
+ if (tabId === 'dashboard') {
360
+ renderDashboard();
361
+ setTimeout(() => updateCharts(), 100);
362
+ } else if (tabId === 'report-generator') {
363
+ renderReport();
364
+ setTimeout(() => updateCharts(), 100);
365
+ }
366
+ }
367
+
368
+ function updateHeaderTitles(tabId) {
369
+ let modeTitle = '';
370
+ if (state.currentMode === 'family') {
371
+ modeTitle = '가족 건강 스케줄러';
372
+ } else {
373
+ modeTitle = state.currentFacility === 'child' ? '튼튼어린이집' : '행복실버요양원';
374
+ }
375
+
376
+ switch (tabId) {
377
+ case 'dashboard':
378
+ elements.pageMainTitle.textContent = `${modeTitle} 종합 대시보드`;
379
+ elements.pageSubTitle.textContent = state.currentMode === 'family'
380
+ ? '가정 내 가족별 알레르기 및 유해인자 스크리닝 요약'
381
+ : '시설 맞춤형 프로파일 및 취약계층 급식 위험 관리 요약';
382
+ break;
383
+ case 'diet-analyzer':
384
+ elements.pageMainTitle.textContent = '주간/월간 식단 AI 대량 분석';
385
+ elements.pageSubTitle.textContent = '식단표를 붙여넣으면 OpenRouter AI 모델이 캘린더 대체 식단과 레시피를 일목요연하게 자동 분석합니다.';
386
+ break;
387
+ case 'vlm-scanner':
388
+ elements.pageMainTitle.textContent = 'VLM 배식 오배 예방 모니터';
389
+ elements.pageSubTitle.textContent = '배식 직전 촬영된 식판 이미지를 Vision AI가 분석하여 제한 식재료 교차 감지를 실시간 2차 예방합니다.';
390
+ break;
391
+ case 'report-generator':
392
+ elements.pageMainTitle.textContent = state.currentMode === 'family' ? '가정용 식단 위생 안전 일지' : '사회보장/가족 건강 데이터 증빙 센터';
393
+ elements.pageSubTitle.textContent = state.currentMode === 'family'
394
+ ? '가정 내 배식 일지 보관 및 페이퍼리스 디지털 케어 실천 점수'
395
+ : '한국사회보장정보원 시스템 제출용 증빙 서류 자동 생성 및 ESG 혁신 통계';
396
+ break;
397
+ }
398
+ }
399
+
400
+ // Render Dashboard Panel
401
+ function renderDashboard() {
402
+ const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
403
+ const list = state.members[listKey];
404
+ const totalMembers = list.length;
405
+ const riskMembers = list.filter(m => m.type !== '일반').length;
406
+ const totalSavedTime = (state.logs.filter(l => state.currentMode === 'family' ? l.facility === 'family' : l.facility === state.currentFacility).length * 4.2).toFixed(1);
407
+
408
+ if (state.currentMode === 'family') {
409
+ elements.lblTotalMembers.textContent = '가족 등록 인원';
410
+ elements.lblTotalDesc.innerHTML = '<i class="fa-solid fa-house"></i> 가정용 프로필 기준';
411
+ elements.lblSavedTime.textContent = '가사 노동 및 행정 절감 시간';
412
+ elements.lblSavedDesc.innerHTML = '<i class="fa-solid fa-clock"></i> 식단 작성 및 증빙 자동화 효과';
413
+ elements.panelMemberTitle.innerHTML = '<i class="fa-solid fa-people-roof"></i> 가족 구성원 건강/알레르기 DB';
414
+ elements.esgBottomDesc.textContent = '친환경 로컬 식단 구성 및 식료품 소비 데이터 연동을 통해 에코 탄소 마일리지 120kg CO2e 감축 기여';
415
+ elements.modalMemberTitle.innerHTML = '<i class="fa-solid fa-house-chimney-medical"></i> 가족 건강 프로필 신규 등록';
416
+ } else {
417
+ elements.lblTotalMembers.textContent = '관리 대상자 수';
418
+ elements.lblTotalDesc.innerHTML = '<i class="fa-solid fa-circle-info"></i> 사보원 데이터 기준';
419
+ elements.lblSavedTime.textContent = '누적 행정 절감 시간';
420
+ elements.lblSavedDesc.innerHTML = '<i class="fa-solid fa-arrow-trend-up"></i> 돌봄 시간으로 환원됨';
421
+ elements.panelMemberTitle.innerHTML = '<i class="fa-solid fa-id-card"></i> 대상자 집중 관리 명단';
422
+ elements.esgBottomDesc.textContent = '친환경 저탄소 식단 추천 및 페이퍼리스 행정 자동화를 통한 탄소 발자국 240kg CO2e 감축 달성';
423
+ elements.modalMemberTitle.innerHTML = '<i class="fa-solid fa-user-plus"></i> 신규 관리 대상자 등록';
424
+ }
425
+
426
+ elements.statTotalMembers.textContent = `${totalMembers}명`;
427
+ elements.statRiskMembers.textContent = `${riskMembers}명`;
428
+ elements.statSavedTime.textContent = `${totalSavedTime}시간`;
429
+ elements.statSafetyScore.textContent = '100%';
430
+
431
+ elements.memberTableBody.innerHTML = '';
432
+
433
+ list.forEach(member => {
434
+ const tr = document.createElement('tr');
435
+
436
+ const riskTag = member.type === '일반'
437
+ ? `<span style="color:var(--text-muted)">일반 급식군</span>`
438
+ : `<span class="risk-tag ${member.type === '알레르기' ? 'allergy' : 'disease'}">${member.type}: ${member.detail}</span>`;
439
+
440
+ tr.innerHTML = `
441
+ <td><strong>${member.name}</strong></td>
442
+ <td>${member.age}</td>
443
+ <td>${riskTag}</td>
444
+ <td style="color: var(--text-secondary); max-width: 250px; line-height: 1.4;">${member.instruction}</td>
445
+ <td>
446
+ <button class="delete-member-btn" onclick="deleteMember(${member.id})">
447
+ <i class="fa-solid fa-trash-can"></i>
448
+ </button>
449
+ </td>
450
+ `;
451
+ elements.memberTableBody.appendChild(tr);
452
+ });
453
+ }
454
+
455
+ // Facility switch logic for agency mode
456
+ function initFacilitySelector() {
457
+ elements.facilitySelect.addEventListener('change', (e) => {
458
+ state.currentFacility = e.target.value;
459
+
460
+ resetDietUI();
461
+ resetVlmUI();
462
+
463
+ switchTab(state.currentTab);
464
+ showNotification(`${state.currentFacility === 'child' ? '보육시설 (어린이집)' : '노인요양시설 (실버케어)'} 데이터로 갱신되었습니다.`);
465
+ });
466
+ }
467
+
468
+ // 5. Diet Bulk Scheduler and OpenRouter API Engine
469
+ function initDietAnalyzer() {
470
+ elements.dietBulkInput.value = state.currentMode === 'family' ? `[월요일] 쌀밥, 계란말이, 요구르트\n[화요일] 쌀식빵, 사과잼, 우유\n[수요일] 잡곡밥, 돈까스, 샐러드` : templates.weekly;
471
+
472
+ elements.exampleTags.forEach(tag => {
473
+ tag.addEventListener('click', () => {
474
+ elements.exampleTags.forEach(t => t.classList.remove('active'));
475
+ tag.classList.add('active');
476
+ const period = tag.getAttribute('data-period');
477
+ elements.dietBulkInput.value = templates[period];
478
+ });
479
+ });
480
+
481
+ elements.analyzeDietBtn.addEventListener('click', async () => {
482
+ const textInput = elements.dietBulkInput.value.trim();
483
+ if (!textInput) return;
484
+
485
+ state.activeDietInput = textInput;
486
+
487
+ elements.scheduleEmptyView.classList.add('hidden');
488
+ elements.scheduleResultView.classList.add('hidden');
489
+ elements.analyzerLoading.classList.remove('hidden');
490
+ elements.analyzeDietBtn.disabled = true;
491
+
492
+ elements.loadingText.textContent = apiConfig.key
493
+ ? "오픈라우터 무료 AI 모델과 실시간 통신 중입니다 (API 폴백 활성화)..."
494
+ : "인터넷 지연 및 API 키 미검출에 따라 로컬 고성능 시뮬레이션 알고리즘으로 폴백하여 식단을 분석하는 중...";
495
+
496
+ try {
497
+ if (apiConfig.key) {
498
+ // Call real OpenRouter API with Fallback Chain
499
+ state.analysisResult = await callOpenRouterLLMFallback(textInput);
500
+ } else {
501
+ await new Promise(resolve => setTimeout(resolve, 1200));
502
+ state.analysisResult = runLocalSimParser(textInput);
503
+ }
504
+
505
+ renderScheduleCards();
506
+ } catch (error) {
507
+ console.error(error);
508
+ showNotification("AI 모델 호출 실패로 안전 시뮬레이션 데이터로 대체 처리합니다.");
509
+ state.analysisResult = runLocalSimParser(textInput);
510
+ renderScheduleCards();
511
+ } finally {
512
+ elements.analyzerLoading.classList.add('hidden');
513
+ elements.analyzeDietBtn.disabled = false;
514
+ }
515
+ });
516
+
517
+ elements.confirmAllDietBtn.addEventListener('click', () => {
518
+ showNotification("대체 가이드라인을 반영하여 전체 주간/월간 식단 검토가 확정되었습니다. 배식 검증 단계를 진행하십시오.");
519
+ switchTab('vlm-scanner');
520
+
521
+ elements.runVlmBtn.disabled = false;
522
+ elements.vlmOptNormal.click();
523
+ });
524
+ }
525
+
526
+ function resetDietUI() {
527
+ elements.dietBulkInput.value = state.currentMode === 'family' ? `[월요일] 쌀밥, 계란말이, 요구르트\n[화요일] 쌀식빵, 사과잼, 우유\n[수요일] 잡곡밥, 돈까스, 샐러드` : templates.weekly;
528
+ elements.scheduleEmptyView.classList.remove('hidden');
529
+ elements.scheduleResultView.classList.add('hidden');
530
+ elements.analyzerLoading.classList.add('hidden');
531
+ }
532
+
533
+ // LLM Fallback Sequential API Calling
534
+ async function callOpenRouterLLMFallback(dietText) {
535
+ const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
536
+ const list = state.members[listKey];
537
+ const memberProfileStr = list.map(m => `- 이름: ${m.name}, 유형: ${m.type}, 원인물질/질환: ${m.detail}, 처방: ${m.instruction}`).join('\n');
538
+
539
+ const prompt = `
540
+ 당신은 취약계층 급식 또는 가정을 관리하는 영양 안전 케어 AI 시스템인 CareTable입니다.
541
+ 다음 입력된 식단표를 바탕으로 현재 관리 대상자들의 알레르기 및 만성질환 유해 성분 정보와 비교하여 위험 요소를 분석하고 대체 식단과 위생/조리지침 레시피 가이드를 수립하십시오.
542
+
543
+ [수혜 대상자 알레르기/질환 명단]
544
+ ${memberProfileStr}
545
+
546
+ [식단표]
547
+ ${dietText}
548
+
549
+ [출력 요구사항]
550
+ 반드시 다른 잡다한 말(인사말 등)이나 마크업(```json 등)을 작성하지 말고 오직 아래 양식에 맞춘 유효한 JSON 배열만 출력하십시오.
551
+
552
+ JSON 양식:
553
+ [
554
+ {
555
+ "day": "요일/일자 구분 (예: 월요일 또는 1주 화요일)",
556
+ "date": "임의의 날짜 (예: 06/01)",
557
+ "hasRisk": true/false (유해인자가 식단에 감지되면 true, 없으면 false),
558
+ "risks": ["감지된 위험 요소 상세 요약 (예: 김민수(달걀 알레르기) - 계란말이 유래 성분 충돌)"],
559
+ "menuOrig": "입력된 원래 식단",
560
+ "menuAlt": "대체 처방이 적용된 신규 식단 (원래 식단에서 위험 성분 제거/대체식 교체)",
561
+ "recipe": "조리 지침 및 주의 가이드 (교차오염 방지 및 대체 조리법 서술)"
562
+ }
563
+ ]
564
+ `;
565
+
566
+ // Try selected model first
567
+ const modelsToTry = [apiConfig.model, ...freeLLMModels.filter(m => m !== apiConfig.model)];
568
+
569
+ for (let model of modelsToTry) {
570
+ try {
571
+ console.log(`Trying OpenRouter LLM model: ${model}`);
572
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
573
+ method: "POST",
574
+ headers: {
575
+ "Authorization": `Bearer ${apiConfig.key}`,
576
+ "Content-Type": "application/json",
577
+ "HTTP-Referer": "https://huggingface.co/spaces/HwangJinwook/caretable",
578
+ "X-Title": "CareTable Client"
579
+ },
580
+ body: JSON.stringify({
581
+ model: model,
582
+ messages: [
583
+ { role: "system", content: "You are an expert clinical dietitian AI. Output only JSON array. Do not wrap in markdown codeblocks." },
584
+ { role: "user", content: prompt }
585
+ ]
586
+ })
587
+ });
588
+
589
+ if (!res.ok) continue; // Try next model on failure
590
+
591
+ const data = await res.json();
592
+ let text = data.choices[0].message.content.trim();
593
+
594
+ if (text.includes("```json")) {
595
+ text = text.split("```json")[1].split("```")[0].trim();
596
+ } else if (text.includes("```")) {
597
+ text = text.split("```")[1].split("```")[0].trim();
598
+ }
599
+
600
+ return JSON.parse(text);
601
+ } catch (e) {
602
+ console.warn(`Model ${model} failed: `, e);
603
+ }
604
+ }
605
+
606
+ throw new Error("All free OpenRouter LLM models failed.");
607
+ }
608
+
609
+ // Local simulation fallback parser
610
+ function runLocalSimParser(dietText) {
611
+ const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
612
+ const list = state.members[listKey];
613
+ const lines = dietText.split('\n').filter(l => l.trim().length > 0);
614
+
615
+ const results = [];
616
+
617
+ lines.forEach((line, index) => {
618
+ const dayMatch = line.match(/^\[(.*?)\]/);
619
+ const dayName = dayMatch ? dayMatch[1] : `일차 ${index+1}`;
620
+ const menuString = line.replace(/^\[.*?\]/, '').trim();
621
+ const foods = menuString.split(',').map(f => f.trim());
622
+
623
+ const risks = [];
624
+ const alternatives = [];
625
+ const guidelines = [];
626
+
627
+ foods.forEach(food => {
628
+ list.forEach(m => {
629
+ if (m.type === '알레르기') {
630
+ const allergens = m.detail.split(',').map(a => a.trim());
631
+ allergens.forEach(allergen => {
632
+ if (isAllergenContained(food, allergen)) {
633
+ risks.push(`${m.name} (${allergen} 알레르기) - ${food} 성분 유의`);
634
+ const repl = getAlternativeFood(food, allergen);
635
+ alternatives.push({ from: food, to: repl.food });
636
+ guidelines.push(`${m.name}의 알레르기 교차오염을 차단하기 위해 ${food} 대체식 조리 시 도구 분리 사용.`);
637
+ }
638
+ });
639
+ } else if (m.type === '질환식') {
640
+ if (m.detail.includes('당뇨') && (food.includes('밥') || food.includes('요플레') || food.includes('요구르트') || food.includes('설탕'))) {
641
+ if (!food.includes('잡곡') && !food.includes('현미')) {
642
+ risks.push(`${m.name} (당뇨 케어) - ${food} 단순당/혈당상승 위험`);
643
+ const repl = getAlternativeFood(food, '당뇨');
644
+ alternatives.push({ from: food, to: repl.food });
645
+ guidelines.push(`${m.name} 어르신의 급격한 인슐린 반응 억제를 위한 저당 잡곡밥 믹싱 및 천연 에리스리톨 가미.`);
646
+ }
647
+ }
648
+ if (m.detail.includes('유당불내증') && (food.includes('요구르트') || food.includes('우유') || food.includes('요플레'))) {
649
+ risks.push(`${m.name} (유당불내) - ${food} 유당 함유 위험`);
650
+ const repl = getAlternativeFood(food, '유당불내증');
651
+ alternatives.push({ from: food, to: repl.food });
652
+ guidelines.push(`${m.name}님에게 일반 요구르트/유제품 대신 대체 두유 팩 제공.`);
653
+ }
654
+ if (m.detail.includes('통풍') && (food.includes('조개') || food.includes('새우') || food.includes('맥주'))) {
655
+ risks.push(`${m.name} (통풍 케어) - ${food} 고퓨린 성분 위험`);
656
+ alternatives.push({ from: food, to: '버섯구이 또는 야채찜' });
657
+ guidelines.push(`통풍 발작 예방을 위해 퓨린 함량이 높은 해산물 요리 대신 식이섬유가 많은 뿌리채소 구이로 교체.`);
658
+ }
659
+ }
660
+ });
661
+ });
662
+
663
+ const hasRisk = risks.length > 0;
664
+
665
+ let menuAlt = menuString;
666
+ alternatives.forEach(alt => {
667
+ menuAlt = menuAlt.replace(alt.from, alt.to);
668
+ });
669
+
670
+ let recipe = '';
671
+ if (hasRisk) {
672
+ recipe = `[대체 조리 수칙]\n` + guidelines.map((g, i) => `${i+1}. ${g}`).join('\n');
673
+ } else {
674
+ recipe = "일반 조리 시설 보건 위생 규정 및 교차 오염 기본 예방 수칙을 준수하여 정성껏 위생 조리하십시오.";
675
+ }
676
+
677
+ const dateObj = new Date();
678
+ dateObj.setDate(dateObj.getDate() + index);
679
+ const dateStr = `${String(dateObj.getMonth() + 1).padStart(2, '0')}/${String(dateObj.getDate()).padStart(2, '0')}`;
680
+
681
+ results.push({
682
+ day: dayName,
683
+ date: dateStr,
684
+ hasRisk: hasRisk,
685
+ risks: risks,
686
+ menuOrig: menuString,
687
+ menuAlt: menuAlt,
688
+ recipe: recipe
689
+ });
690
+ });
691
+
692
+ return results;
693
+ }
694
+
695
+ function isAllergenContained(food, allergen) {
696
+ if (allergen === '달걀' && (food.includes('계란') || food.includes('달걀') || food.includes('난황') || food.includes('알'))) return true;
697
+ if (allergen === '우유' && (food.includes('우유') || food.includes('밀크') || food.includes('요구르트') || food.includes('야쿠르트') || food.includes('요플레') || food.includes('치즈'))) return true;
698
+ if (allergen === '땅콩' && (food.includes('땅콩') || food.includes('피넛') || food.includes('견과'))) return true;
699
+ if (allergen === '대두' && (food.includes('대두') || food.includes('콩') || food.includes('간장') || food.includes('된장') || food.includes('두부'))) return true;
700
+ return food.includes(allergen);
701
+ }
702
+
703
+ function getAlternativeFood(food, allergen) {
704
+ if (allergen === '달걀') {
705
+ return { food: '두부구이 (연화처리)', reason: '대체 단백질 및 가루 응집 식감 구현 가능, 알레르기 제로' };
706
+ }
707
+ if (allergen === '우유') {
708
+ return { food: '오렌지주스 (무칼슘/천연)', reason: '우유 지질 성분 제로 및 비타민 보존 대체' };
709
+ }
710
+ if (allergen === '당뇨') {
711
+ if (food.includes('밥')) return { food: '귀리잡곡밥', reason: '식이섬유가 풍부하여 식후 혈당 급상승을 예방하는 복합탄수화물 대체' };
712
+ if (food.includes('요플레')) return { food: '무설탕 플레인 요거트', reason: '과당/정제 설탕 배제로 인슐린 급증 억제' };
713
+ return { food: '저당 식이 반찬', reason: '천연 대체 감미료 조리법 적용' };
714
+ }
715
+ if (allergen === '유당불내증') {
716
+ return { food: '약콩 두유 (락토프리)', reason: '유당 불포함 천연 유기농 식물성 단유 대체' };
717
+ }
718
+ return { food: '대체 자연식품', reason: '식약처 식자재 DB 대체 지표에 따른 추천' };
719
+ }
720
+
721
+ // Render schedule cards in DOM
722
+ function renderScheduleCards() {
723
+ elements.scheduleCardsContainer.innerHTML = '';
724
+
725
+ const results = state.analysisResult;
726
+ const hasAnyRisk = results.some(r => r.hasRisk);
727
+ const riskCount = results.filter(r => r.hasRisk).length;
728
+
729
+ if (hasAnyRisk) {
730
+ elements.scheduleStatusBanner.className = 'summary-status-badge alert-red';
731
+ elements.scheduleSummaryTitle.textContent = '대체식 처방 식단 스케줄 생성 완료';
732
+ elements.scheduleSummaryDesc.textContent = `전체 분석 식단 중 ${riskCount}일의 식단에서 유해 요인이 확인되어 AI 대체 처방 및 안전 조리법을 발행했습니다.`;
733
+ elements.scheduleStatusBanner.querySelector('i').className = 'fa-solid fa-circle-exclamation text-red animate-bounce';
734
+ } else {
735
+ elements.scheduleStatusBanner.className = 'summary-status-badge safe-green';
736
+ elements.scheduleSummaryTitle.textContent = '식단 스크리닝 통과 (전원 안전)';
737
+ elements.scheduleSummaryDesc.textContent = '입력된 식단 전체에서 현재 등록된 대상자들의 알레르기 및 만성질환 위험군 제한 물질이 검출되지 않았습니다.';
738
+ elements.scheduleStatusBanner.querySelector('i').className = 'fa-solid fa-circle-check text-green';
739
+ }
740
+
741
+ results.forEach((item, idx) => {
742
+ const card = document.createElement('div');
743
+ card.className = `schedule-card ${item.hasRisk ? 'has-risk' : 'is-safe'}`;
744
+
745
+ const statusPill = item.hasRisk
746
+ ? `<span class="status-pill danger">위험 처방 완료</span>`
747
+ : `<span class="status-pill success">검증 통과</span>`;
748
+
749
+ card.innerHTML = `
750
+ <div class="card-header-toggle" onclick="toggleCardAccordion(${idx})">
751
+ <div class="header-day-info">
752
+ <span class="day-title">[${item.day}]</span>
753
+ <span class="day-date">예정일자: ${item.date}</span>
754
+ </div>
755
+ <div class="status-indicator-box">
756
+ ${statusPill}
757
+ <i class="fa-solid fa-chevron-down toggle-arrow-icon" id="arrow-${idx}"></i>
758
+ </div>
759
+ </div>
760
+
761
+ <div class="accordion-content" id="accordion-${idx}">
762
+ <div class="card-body-details">
763
+ <div class="menu-orig-box">
764
+ <h5>기존 원본 식단</h5>
765
+ <p class="menu-content-text" style="${item.hasRisk ? 'color: var(--text-secondary); text-decoration: line-through;' : ''}">
766
+ ${item.menuOrig}
767
+ </p>
768
+ </div>
769
+
770
+ ${item.hasRisk ? `
771
+ <div class="menu-alt-box">
772
+ <h5>AI 처방 대체식 식단</h5>
773
+ <p class="menu-content-text text-green font-bold">
774
+ ${item.menuAlt}
775
+ </p>
776
+ </div>
777
+
778
+ <div class="detected-risk-sublist" style="background: rgba(220,38,38,0.03); border-left: 2px solid var(--color-red); padding: 8px 12px; border-radius: 4px; font-size:11px;">
779
+ <strong class="text-red">충돌 정보:</strong><br>
780
+ ${item.risks.map(r => `• ${r}`).join('<br>')}
781
+ </div>
782
+ ` : ''}
783
+
784
+ <div class="recipe-guide-box">
785
+ <h5>${item.hasRisk ? 'AI 맞춤 조리 가이드라인' : '표준 조리 지침'}</h5>
786
+ <p class="recipe-content-text">
787
+ ${item.recipe.replace(/\n/g, '<br>')}
788
+ </p>
789
+ </div>
790
+ </div>
791
+ </div>
792
+ `;
793
+
794
+ elements.scheduleCardsContainer.appendChild(card);
795
+ });
796
+
797
+ elements.scheduleResultView.classList.remove('hidden');
798
+ }
799
+
800
+ window.toggleCardAccordion = function(index) {
801
+ const content = document.getElementById(`accordion-${index}`);
802
+ const arrow = document.getElementById(`arrow-${index}`);
803
+
804
+ if (content.classList.contains('expanded')) {
805
+ content.classList.remove('expanded');
806
+ arrow.classList.remove('rotate-icon');
807
+ } else {
808
+ content.classList.add('expanded');
809
+ arrow.classList.add('rotate-icon');
810
+ }
811
+ };
812
+
813
+ // 6. VLM Scanner Integration
814
+ function initVlmScanner() {
815
+ elements.vlmOptNormal.addEventListener('click', () => {
816
+ selectVlmImage('normal');
817
+ });
818
+
819
+ elements.vlmOptAlternative.addEventListener('click', () => {
820
+ selectVlmImage('alternative');
821
+ });
822
+
823
+ elements.triggerFileBtn.addEventListener('click', () => {
824
+ elements.vlmFileInput.click();
825
+ });
826
+
827
+ elements.vlmFileInput.addEventListener('change', (e) => {
828
+ const file = e.target.files[0];
829
+ if (!file) return;
830
+
831
+ const reader = new FileReader();
832
+ reader.onload = (event) => {
833
+ state.customVlmImageSrc = event.target.result;
834
+ selectVlmImage('custom');
835
+ };
836
+ reader.readAsDataURL(file);
837
+ });
838
+
839
+ elements.runVlmBtn.addEventListener('click', async () => {
840
+ if (!state.selectedVlmImage) return;
841
+
842
+ elements.vlmEmptyResult.classList.add('hidden');
843
+ elements.vlmResultPanel.classList.add('hidden');
844
+ elements.scannerLaser.style.display = 'block';
845
+ elements.scannerLaser.style.animation = 'scan 2.5s infinite linear';
846
+ elements.runVlmBtn.disabled = true;
847
+
848
+ try {
849
+ if (apiConfig.key && (state.selectedVlmImage === 'custom' || state.customVlmImageSrc)) {
850
+ // Call Real OpenRouter VLM (Vision) models
851
+ const visionResult = await callOpenRouterVLMFallback();
852
+ // Fill result with vision result
853
+ setTimeout(() => {
854
+ elements.scannerLaser.style.display = 'none';
855
+ elements.scannerLaser.style.animation = 'none';
856
+ elements.runVlmBtn.disabled = false;
857
+ renderVlmResult(visionResult);
858
+ }, 2500);
859
+ } else {
860
+ // Default Sim Fallback
861
+ setTimeout(() => {
862
+ elements.scannerLaser.style.display = 'none';
863
+ elements.scannerLaser.style.animation = 'none';
864
+ elements.runVlmBtn.disabled = false;
865
+ showVlmScanResult();
866
+ }, 2500);
867
+ }
868
+ } catch (error) {
869
+ console.error(error);
870
+ showNotification("VLM 모델 인식 지연으로 시뮬레이션 결과로 연동 대체합니다.");
871
+ setTimeout(() => {
872
+ elements.scannerLaser.style.display = 'none';
873
+ elements.scannerLaser.style.animation = 'none';
874
+ elements.runVlmBtn.disabled = false;
875
+ showVlmScanResult();
876
+ }, 1000);
877
+ }
878
+ });
879
+ }
880
+
881
+ // OpenRouter VLM Vision Fallback Call
882
+ async function callOpenRouterVLMFallback() {
883
+ const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
884
+ const list = state.members[listKey];
885
+ const memberProfileStr = list.map(m => `- 이름: ${m.name}, 유형: ${m.type}, 원인물질/질환: ${m.detail}`).join('\n');
886
+
887
+ const prompt = `
888
+ 이 이미지는 배식 조리 완료된 식판 사진입니다.
889
+ 아래에 제공된 관리 대상자들의 알레르기/질환 과민 반응 제한 성분 목록을 분석하여, 이 식판에 해당 대상자에게 유해한 식품이나 알레르기 유발 요소가 오배식 되었는지 검증하십시오.
890
+
891
+ [제한 대상 명단]
892
+ ${memberProfileStr}
893
+
894
+ 반드시 다른 텍스트 설명 없이 오직 아래 형태의 JSON 객체 하나만 반환하십시오:
895
+ {
896
+ "verdict": "PASS" 또는 "REJECT" (위해물질이 없으면 PASS, 있으면 REJECT),
897
+ "allergen": "검출된 유해 물질 요약 (예: 달걀 성분 또는 없음)",
898
+ "portion": "정량 및 형태 분석 (예: 적정 배식 98% 또는 적정)",
899
+ "reason": "AI Vision 분석 상세 근거 (식판 내 물체의 위치 및 매칭 사유)"
900
+ }
901
+ `;
902
+
903
+ const base64Image = state.customVlmImageSrc.split(',')[1];
904
+
905
+ for (let model of freeVLMModels) {
906
+ try {
907
+ console.log(`Trying OpenRouter VLM model: ${model}`);
908
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
909
+ method: "POST",
910
+ headers: {
911
+ "Authorization": `Bearer ${apiConfig.key}`,
912
+ "Content-Type": "application/json",
913
+ "HTTP-Referer": "https://huggingface.co/spaces/HwangJinwook/caretable",
914
+ "X-Title": "CareTable Client"
915
+ },
916
+ body: JSON.stringify({
917
+ model: model,
918
+ messages: [
919
+ {
920
+ role: "user",
921
+ content: [
922
+ { type: "text", text: prompt },
923
+ {
924
+ type: "image_url",
925
+ image_url: {
926
+ url: `data:image/jpeg;base64,${base64Image}`
927
+ }
928
+ }
929
+ ]
930
+ }
931
+ ]
932
+ })
933
+ });
934
+
935
+ if (!res.ok) continue;
936
+
937
+ const data = await res.json();
938
+ let text = data.choices[0].message.content.trim();
939
+
940
+ if (text.includes("```json")) {
941
+ text = text.split("```json")[1].split("```")[0].trim();
942
+ } else if (text.includes("```")) {
943
+ text = text.split("```")[1].split("```")[0].trim();
944
+ }
945
+
946
+ return JSON.parse(text);
947
+ } catch (e) {
948
+ console.warn(`VLM Model ${model} failed: `, e);
949
+ }
950
+ }
951
+
952
+ throw new Error("All free OpenRouter VLM models failed.");
953
+ }
954
+
955
+ function renderVlmResult(apiResult) {
956
+ elements.vlmResultPanel.classList.remove('hidden');
957
+
958
+ if (apiResult.verdict === 'REJECT') {
959
+ elements.vlmVerdictBox.className = 'vlm-verdict-box rejected';
960
+ elements.verdictIconContainer.innerHTML = '<i class="fa-solid fa-circle-xmark"></i>';
961
+ elements.verdictTitle.textContent = '배식 보류 (REJECTED)';
962
+
963
+ elements.vlmMatchRate.style.width = '72%';
964
+ elements.vlmMatchRate.style.backgroundColor = 'var(--color-red)';
965
+ elements.vlmMatchValue.textContent = '72%';
966
+
967
+ elements.vlmAllergenStatus.textContent = apiResult.allergen;
968
+ elements.vlmAllergenStatus.className = 'detail-value text-red';
969
+ elements.vlmPortionStatus.textContent = apiResult.portion;
970
+ elements.vlmAnalysisReason.textContent = apiResult.reason;
971
+
972
+ elements.vlmActionFooter.innerHTML = `
973
+ <button class="secondary-btn" onclick="switchTab('diet-analyzer')"><i class="fa-solid fa-calendar-days"></i> 식단 분석 재진행</button>
974
+ <button class="primary-btn" style="background:var(--gradient-danger);" disabled><i class="fa-solid fa-ban"></i> 배식 불가 상태</button>
975
+ `;
976
+ } else {
977
+ elements.vlmVerdictBox.className = 'vlm-verdict-box passed';
978
+ elements.verdictIconContainer.innerHTML = '<i class="fa-solid fa-circle-check"></i>';
979
+ elements.verdictTitle.textContent = '배식 승인 (PASSED)';
980
+
981
+ elements.vlmMatchRate.style.width = '99%';
982
+ elements.vlmMatchRate.style.backgroundColor = 'var(--color-green)';
983
+ elements.vlmMatchValue.textContent = '99%';
984
+
985
+ elements.vlmAllergenStatus.textContent = '유해 성분 없음 (검증 완료)';
986
+ elements.vlmAllergenStatus.className = 'detail-value text-green';
987
+ elements.vlmPortionStatus.textContent = apiResult.portion;
988
+ elements.vlmAnalysisReason.textContent = apiResult.reason;
989
+
990
+ elements.vlmActionFooter.innerHTML = `
991
+ <button id="approve-final-btn" class="primary-btn" style="background:var(--gradient-success); width:100%;"><i class="fa-solid fa-circle-check"></i> 최종 배식 확정 및 디지털 이력 저장</button>
992
+ `;
993
+
994
+ document.getElementById('approve-final-btn').addEventListener('click', saveFinalVerificationLog);
995
+ }
996
+ }
997
+
998
+ function selectVlmImage(type) {
999
+ state.selectedVlmImage = type;
1000
+ elements.vlmViewportPlaceholder.classList.add('hidden');
1001
+ elements.vlmTargetImage.classList.remove('hidden');
1002
+
1003
+ elements.vlmOptNormal.classList.remove('selected');
1004
+ elements.vlmOptAlternative.classList.remove('selected');
1005
+
1006
+ if (type === 'normal') {
1007
+ elements.vlmTargetImage.src = 'assets/meal_normal.png';
1008
+ elements.vlmOptNormal.classList.add('selected');
1009
+ } else if (type === 'alternative') {
1010
+ elements.vlmTargetImage.src = 'assets/meal_alternative.png';
1011
+ elements.vlmOptAlternative.classList.add('selected');
1012
+ } else if (type === 'custom' && state.customVlmImageSrc) {
1013
+ elements.vlmTargetImage.src = state.customVlmImageSrc;
1014
+ }
1015
+
1016
+ elements.runVlmBtn.disabled = false;
1017
+ }
1018
+
1019
+ function resetVlmUI() {
1020
+ state.selectedVlmImage = null;
1021
+ state.customVlmImageSrc = null;
1022
+ state.vlmApproved = false;
1023
+ elements.vlmViewportPlaceholder.classList.remove('hidden');
1024
+ elements.vlmTargetImage.classList.add('hidden');
1025
+ elements.vlmTargetImage.src = '';
1026
+ elements.vlmOptNormal.classList.remove('selected');
1027
+ elements.vlmOptAlternative.classList.remove('selected');
1028
+ elements.vlmEmptyResult.classList.remove('hidden');
1029
+ elements.vlmResultPanel.classList.add('hidden');
1030
+ elements.runVlmBtn.disabled = true;
1031
+ elements.vlmFileInput.value = '';
1032
+ }
1033
+
1034
+ function showVlmScanResult() {
1035
+ elements.vlmResultPanel.classList.remove('hidden');
1036
+
1037
+ if (state.selectedVlmImage === 'normal') {
1038
+ elements.vlmVerdictBox.className = 'vlm-verdict-box rejected';
1039
+ elements.verdictIconContainer.innerHTML = '<i class="fa-solid fa-circle-xmark"></i>';
1040
+ elements.verdictTitle.textContent = '배식 보류 (REJECTED)';
1041
+
1042
+ elements.vlmMatchRate.style.width = '70%';
1043
+ elements.vlmMatchRate.style.backgroundColor = 'var(--color-red)';
1044
+ elements.vlmMatchValue.textContent = '70%';
1045
+
1046
+ if (state.currentMode === 'family') {
1047
+ elements.vlmAllergenStatus.textContent = '계란 및 요구르트 포함 (막내아들 알러지 충돌)';
1048
+ elements.vlmAllergenStatus.className = 'detail-value text-red';
1049
+ elements.vlmAnalysisReason.textContent = 'Vision 분석 결과, 준비된 식판에 계란말이와 일반 우유 성분 요구르트가 감지되었습니다. 막내아들(우유/밀가루 알레르기)에게는 유해하므로 대체 배식으로 교체하십시오.';
1050
+ } else if (state.currentFacility === 'child') {
1051
+ elements.vlmAllergenStatus.textContent = '달걀, 우유 성분 검출 (김민수 아동 위험)';
1052
+ elements.vlmAllergenStatus.className = 'detail-value text-red';
1053
+ elements.vlmAnalysisReason.textContent = 'Vision 분석 결과, 튼튼어린이집 김민수 아동의 유해 인자인 달걀 성분의 계란말이 및 우유 성분의 요구르트가 그대로 배식되어 오배식 경고를 발동합니다.';
1054
+ } else {
1055
+ elements.vlmAllergenStatus.textContent = '백미 쌀밥 및 가당 유제품 검출 (노인 질환 위험)';
1056
+ elements.vlmAllergenStatus.className = 'detail-value text-red';
1057
+ elements.vlmAnalysisReason.textContent = 'Vision 분석 결과, 요양시설 당뇨 최옥분 어르신과 유당불내 이명자 어르신에게 유해한 백미 쌀밥 및 요플레가 확인되었습니다. 배식을 차단하고 저당 잡곡밥 및 락토프리 두유로 재배식하십시오.';
1058
+ }
1059
+
1060
+ elements.vlmPortionStatus.textContent = '배식량 적정 (94%)';
1061
+
1062
+ elements.vlmActionFooter.innerHTML = `
1063
+ <button class="secondary-btn" onclick="switchTab('diet-analyzer')"><i class="fa-solid fa-calendar-days"></i> 식단 분석 재진행</button>
1064
+ <button class="primary-btn" style="background:var(--gradient-danger);" disabled><i class="fa-solid fa-ban"></i> 배식 불가 상태</button>
1065
+ `;
1066
+ } else {
1067
+ elements.vlmVerdictBox.className = 'vlm-verdict-box passed';
1068
+ elements.verdictIconContainer.innerHTML = '<i class="fa-solid fa-circle-check"></i>';
1069
+ elements.verdictTitle.textContent = '배식 승인 (PASSED)';
1070
+
1071
+ elements.vlmMatchRate.style.width = '99%';
1072
+ elements.vlmMatchRate.style.backgroundColor = 'var(--color-green)';
1073
+ elements.vlmMatchValue.textContent = '99%';
1074
+ elements.vlmAllergenStatus.textContent = '위험 성분 없음 (대체식 안전 적용 완료)';
1075
+ elements.vlmAllergenStatus.className = 'detail-value text-green';
1076
+
1077
+ if (state.selectedVlmImage === 'custom') {
1078
+ elements.vlmAnalysisReason.textContent = '직접 업로드한 사진 분석 결과, 조리지침에 따른 대체 구성 성분 매칭이 완벽하여 위험 물질이 전혀 감지되지 않았습니다. 안전 배식이 허가되었습니다.';
1079
+ } else if (state.currentMode === 'family') {
1080
+ elements.vlmAnalysisReason.textContent = 'Vision 분석 결과, 계란말이 대신 두부구이와 일반 요구르트 대신 유당 성분이 없는 오렌지주스가 완벽히 배식되어 막내아들 및 가족 알레르기 수칙 통과를 보증합니다.';
1081
+ } else if (state.currentFacility === 'child') {
1082
+ elements.vlmAnalysisReason.textContent = 'Vision 분석 결과, 김민수 아동의 대체 처방 식자재인 두부구이와 오렌지주스가 오배식 없이 완벽 매핑되었습니다.';
1083
+ } else {
1084
+ elements.vlmAnalysisReason.textContent = 'Vision 분석 결과, 당뇨 및 유당불내 어르신들에게 적합한 잡곡밥 및 락토프리 두유 대체 급식이 검증 완료되었습니다.';
1085
+ }
1086
+
1087
+ elements.vlmPortionStatus.textContent = '정량 배식 검증 (98%)';
1088
+
1089
+ elements.vlmActionFooter.innerHTML = `
1090
+ <button id="approve-final-btn" class="primary-btn" style="background:var(--gradient-success); width:100%;"><i class="fa-solid fa-circle-check"></i> 최종 배식 확정 및 디지털 이력 저장</button>
1091
+ `;
1092
+
1093
+ document.getElementById('approve-final-btn').addEventListener('click', saveFinalVerificationLog);
1094
+ }
1095
+ }
1096
+
1097
+ function saveFinalVerificationLog() {
1098
+ const today = new Date();
1099
+ const dateStr = today.toISOString().split('T')[0];
1100
+ const timeStr = today.toTimeString().split(' ')[0].substring(0, 5);
1101
+
1102
+ let logItem = {};
1103
+ if (state.currentMode === 'family') {
1104
+ logItem = {
1105
+ date: dateStr,
1106
+ menu: '쌀밥, 두부구이, 어묵볶음, 오렌지주스',
1107
+ facility: 'family',
1108
+ target: '막내아들 (우유, 밀가루)',
1109
+ alternative: '계란말이 ➔ 두부구이, 요구르트 ➔ 오렌지주스 대체',
1110
+ guideline: '가정 내 유제품 독립 보관 및 배식 도구 철저 분리',
1111
+ vlmStatus: 'PASS',
1112
+ timestamp: `${dateStr} ${timeStr}`
1113
+ };
1114
+ } else if (state.currentFacility === 'child') {
1115
+ logItem = {
1116
+ date: dateStr,
1117
+ menu: '쌀밥, 두부구이, 어묵볶음, 맑은 무국, 오렌지주스',
1118
+ facility: 'child',
1119
+ target: '김민수 아동 (달걀/우유)',
1120
+ alternative: '달걀말이 ➔ 두부구이, 요구르트 ➔ 오렌지주스 대체',
1121
+ guideline: '계란 조리기구 분리 사용 및 대체 급식 라벨 체크',
1122
+ vlmStatus: 'PASS',
1123
+ timestamp: `${dateStr} ${timeStr}`
1124
+ };
1125
+ } else {
1126
+ logItem = {
1127
+ date: dateStr,
1128
+ menu: '귀리잡곡밥, 버섯국, 불고기, 고구마순나물, 약콩 두유',
1129
+ facility: 'senior',
1130
+ target: '최옥분(당뇨), 이명자(유당불내)',
1131
+ alternative: '쌀밥 ➔ 귀리잡곡밥, 요플레 ➔ 약콩 두유 대체',
1132
+ guideline: '당뇨 혈당 모니터링 식단 및 비유제품 간식 교체 검증',
1133
+ vlmStatus: 'PASS',
1134
+ timestamp: `${dateStr} ${timeStr}`
1135
+ };
1136
+ }
1137
+
1138
+ state.logs.push(logItem);
1139
+ saveLogs();
1140
+ state.vlmApproved = true;
1141
+
1142
+ showNotification("배식 이력이 안전하게 저장되었습니다. 자동 생성된 디지털 일지를 보관합니다.");
1143
+ switchTab('report-generator');
1144
+ }
1145
+
1146
+ // 7. Report Center and ESG Integration
1147
+ function initReportCenter() {
1148
+ elements.reportSubTabBtns.forEach(btn => {
1149
+ btn.addEventListener('click', () => {
1150
+ elements.reportSubTabBtns.forEach(b => b.classList.remove('active'));
1151
+ btn.classList.add('active');
1152
+ renderReport();
1153
+ });
1154
+ });
1155
+
1156
+ elements.printReportBtn.addEventListener('click', () => {
1157
+ window.print();
1158
+ });
1159
+ }
1160
+
1161
+ function renderReport() {
1162
+ const activeSubTab = document.querySelector('.sub-tab-btn.active').getAttribute('data-report');
1163
+
1164
+ let facilityName = '';
1165
+ let reportWriter = '';
1166
+ let reportSigner = '';
1167
+
1168
+ if (state.currentMode === 'family') {
1169
+ facilityName = '마포구 서교동 삼총사 가정';
1170
+ reportWriter = '엄마/아빠 (가정 안심 영양사)';
1171
+ reportSigner = '가정대표 보호자 (서명)';
1172
+ elements.reportConfirmStatement.textContent = '위와 같이 가족 구성원의 특이 체질 및 지침에 부합하는 안전 식단과 대체 배식이 올바르게 실행되었음을 확인하며 VLM 스캔 검증 이력을 디지털 기록합니다.';
1173
+ elements.reportSignerName.textContent = '가족 건강 확인자: 엄마/아빠 (인)';
1174
+ elements.reportFooterDesc.textContent = 'CareTable 가정용 식단 위생 자율 점검 대장 (F-2026)';
1175
+ } else {
1176
+ facilityName = state.currentFacility === 'child' ? '튼튼어린이집' : '행복실버요양원';
1177
+ reportWriter = '박아름 사회복지사';
1178
+ reportSigner = '시설대표 박아름 (서명)';
1179
+ elements.reportConfirmStatement.textContent = '위와 같이 취약계층 급식 관리 및 대체 배식이 올바르게 수행되었으며, 배식 전 VLM(Vision-Language Model) 검증 절차를 완료하였음을 확인합니다.';
1180
+ elements.reportSignerName.textContent = '확인자: 시설대표 박아름 (서명/인)';
1181
+ elements.reportFooterDesc.textContent = '한국사회보장정보원 사회복지시설평가 증빙 표준 서식 (SSIS 14-2)';
1182
+ }
1183
+
1184
+ elements.rFacilityName.textContent = facilityName;
1185
+ elements.rWriter.textContent = reportWriter;
1186
+
1187
+ const today = new Date();
1188
+ elements.rDate.textContent = `${today.getFullYear()}년 ${String(today.getMonth() + 1).padStart(2, '0')}월 ${String(today.getDate()).padStart(2, '0')}일`;
1189
+
1190
+ const currentFilterKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
1191
+ const filteredLogs = state.logs.filter(l => l.facility === currentFilterKey);
1192
+
1193
+ let contentHtml = '';
1194
+
1195
+ if (activeSubTab === '급식일지') {
1196
+ elements.reportDocTitle.textContent = state.currentMode === 'family' ? '가 정 식 단 위 생 일 지' : '영 양 급 식 일 지';
1197
+
1198
+ contentHtml = `
1199
+ <div class="report-paper-body">
1200
+ <p>${state.currentMode === 'family' ? '가정 내 자율 보건 위생 관리와 오배식 방지 수칙에 따른 안심 식단 급지 기록입니다.' : '보건복지부 급식안전 기준 및 한국사회보장정보원 시설 평가 가이드라인에 근거한 당일 배식 현황 보고서입니다.'}</p>
1201
+ <table class="report-table">
1202
+ <thead>
1203
+ <tr>
1204
+ <th>작성일자</th>
1205
+ <th>배식 식단 내용 (대체식 포함)</th>
1206
+ <th>집중 케어 대상자</th>
1207
+ <th>VLM AI 분석 적합성</th>
1208
+ <th>인증 확인</th>
1209
+ </tr>
1210
+ </thead>
1211
+ <tbody>
1212
+ ${filteredLogs.length > 0 ? filteredLogs.map(log => `
1213
+ <tr>
1214
+ <td>${log.date}</td>
1215
+ <td class="align-left">${log.menu}</td>
1216
+ <td>${log.target}</td>
1217
+ <td><span style="color:var(--color-green); font-weight:bold;">${log.vlmStatus} (99%)</span></td>
1218
+ <td>확인완료</td>
1219
+ </tr>
1220
+ `).join('') : `
1221
+ <tr>
1222
+ <td colspan="5" style="color: var(--text-muted); padding:20px 0;">배식 이력이 존재하지 않습니다. VLM 검증 탭에서 배식을 최종 확정해 주십시오.</td>
1223
+ </tr>
1224
+ `}
1225
+ </tbody>
1226
+ </table>
1227
+ <p><strong>주요 안전 점검 준수 사항:</strong></p>
1228
+ <ul>
1229
+ <li>식자재 보관 냉장/냉동고 온도 적격 확인</li>
1230
+ <li>VLM 비전 분석 스캔을 통한 알레르기 유발 유래 성분 최종 격리 점검 양호</li>
1231
+ <li>지정 조리사의 교차 오염 방지용 조리기구 분리 사용 실행 완료</li>
1232
+ </ul>
1233
+ </div>
1234
+ `;
1235
+ } else if (activeSubTab === '알레르기대장') {
1236
+ elements.reportDocTitle.textContent = state.currentMode === 'family' ? '가족 알레르기 및 만성질환 DB' : '집중 케어 대상자 대장';
1237
+
1238
+ contentHtml = `
1239
+ <div class="report-paper-body">
1240
+ <p>${state.currentMode === 'family' ? '가족들의 건강 및 식품 과민반응 정보를 지속 보관하여 오인 사고를 예방하는 집중 예방 DB입니다.' : '사회복지시설 평가지표 24번 취약계층 식품 유해인자 맞춤 안전 관리대장 양식입니다.'}</p>
1241
+ <table class="report-table">
1242
+ <thead>
1243
+ <tr>
1244
+ <th>순번</th>
1245
+ <th>수혜자명</th>
1246
+ <th>제한 식품 및 요인</th>
1247
+ <th>추천 대체 식자재</th>
1248
+ <th>핵심 대체 조리 지침</th>
1249
+ <th>검증 방식</th>
1250
+ </tr>
1251
+ </thead>
1252
+ <tbody>
1253
+ ${state.members[currentFilterKey].filter(m => m.type !== '일반').map((m, idx) => {
1254
+ const pair = getAlternativeFood(m.detail.split(',')[0].trim(), m.detail.includes('당뇨') ? '당뇨' : m.detail.includes('고혈압') ? '당뇨' : m.detail.split(',')[0].trim());
1255
+ return `
1256
+ <tr>
1257
+ <td>${idx + 1}</td>
1258
+ <td><strong>${m.name}</strong></td>
1259
+ <td><span style="color:#e11d48; font-weight:600;">${m.detail}</span></td>
1260
+ <td><span style="color:#0d9488; font-weight:600;">${pair.food}</span></td>
1261
+ <td class="align-left">${m.instruction}</td>
1262
+ <td>VLM 비전 매칭</td>
1263
+ </tr>
1264
+ `;
1265
+ }).join('')}
1266
+ </tbody>
1267
+ </table>
1268
+ </div>
1269
+ `;
1270
+ } else if (activeSubTab === '대체식대장') {
1271
+ elements.reportDocTitle.textContent = '대 체 급 식 제 공 대 장';
1272
+
1273
+ contentHtml = `
1274
+ <div class="report-paper-body">
1275
+ <p>특이 체질이나 의학적 위험 인자로 인해 일반 식사를 섭취하지 못하는 대상자에게 안전 대체식을 처방하고 지급을 검증한 이력서입니다.</p>
1276
+ <table class="report-table">
1277
+ <thead>
1278
+ <tr>
1279
+ <th>제공일시</th>
1280
+ <th>대상자</th>
1281
+ <th>제한 원본 메뉴</th>
1282
+ <th>실제 대체 급식 내용</th>
1283
+ <th>대체 사유</th>
1284
+ <th>VLM 스캔 완료시각</th>
1285
+ </tr>
1286
+ </thead>
1287
+ <tbody>
1288
+ ${filteredLogs.length > 0 ? filteredLogs.map(log => `
1289
+ <tr>
1290
+ <td>${log.date}</td>
1291
+ <td><strong>${log.target.split(' ')[0]}</strong></td>
1292
+ <td><span style="color:#e11d48; text-decoration:line-through;">${log.target.includes('아동') || log.target.includes('아들') ? '계란말이, 요구르트' : '쌀밥, 요플레'}</span></td>
1293
+ <td><span style="color:#0d9488; font-weight:bold;">${log.alternative.split('➔')[1] || log.alternative}</span></td>
1294
+ <td>${log.target.includes('아동') || log.target.includes('아들') ? '식품 알레르기 격리' : '혈당/유당 만성질환 케어'}</td>
1295
+ <td>${log.timestamp}</td>
1296
+ </tr>
1297
+ `).join('') : `
1298
+ <tr>
1299
+ <td colspan="6" style="color: var(--text-muted); padding:20px 0;">배식 이력이 존재하지 않습니다. VLM 검증 탭에서 배식을 최종 확정해 주십시오.</td>
1300
+ </tr>
1301
+ `}
1302
+ </tbody>
1303
+ </table>
1304
+ </div>
1305
+ `;
1306
+ }
1307
+
1308
+ elements.reportDocContent.innerHTML = contentHtml;
1309
+
1310
+ const savedHours = (filteredLogs.length * 4.2).toFixed(1);
1311
+ const co2Saved = (filteredLogs.length * 12.0).toFixed(1);
1312
+
1313
+ elements.esgCo2.textContent = `${co2Saved}kg`;
1314
+ elements.esgTime.textContent = `${savedHours}시간`;
1315
+ elements.esgLocal.textContent = state.currentMode === 'family' ? '75%' : (state.currentFacility === 'child' ? '45%' : '60%');
1316
+
1317
+ if (state.currentMode === 'family') {
1318
+ elements.esgTime.parentElement.querySelector('.esg-lbl').textContent = '가사 노동 및 행정 절감 시간';
1319
+ elements.esgTime.parentElement.querySelector('.esg-sub').textContent = '레시피 고민 및 자율 기록 자동화 효과';
1320
+ } else {
1321
+ elements.esgTime.parentElement.querySelector('.esg-lbl').textContent = '행정 시간 ➔ 직접 돌봄 시간 환원';
1322
+ elements.esgTime.parentElement.querySelector('.esg-sub').textContent = '시설 평가 문서 자동화에 따른 절감';
1323
+ }
1324
+ }
1325
+
1326
+ // 8. API configuration modal functions
1327
+ function initApiConfig() {
1328
+ elements.openApiConfigBtn.addEventListener('click', () => {
1329
+ elements.apiKeyInput.value = apiConfig.key;
1330
+ elements.apiModelSelect.value = apiConfig.model;
1331
+ elements.apiConfigModal.classList.remove('hidden');
1332
+ });
1333
+
1334
+ elements.closeApiModalBtn.addEventListener('click', closeApiModal);
1335
+
1336
+ elements.apiConfigModal.addEventListener('click', (e) => {
1337
+ if (e.target === elements.apiConfigModal) closeApiModal();
1338
+ });
1339
+
1340
+ elements.toggleKeyVisibilityBtn.addEventListener('click', () => {
1341
+ const type = elements.apiKeyInput.type === 'password' ? 'text' : 'password';
1342
+ elements.apiKeyInput.type = type;
1343
+ elements.toggleKeyVisibilityBtn.innerHTML = type === 'password' ? '<i class="fa-solid fa-eye"></i>' : '<i class="fa-solid fa-eye-slash"></i>';
1344
+ });
1345
+
1346
+ elements.resetApiKeyBtn.addEventListener('click', () => {
1347
+ elements.apiKeyInput.value = '';
1348
+ apiConfig.key = '';
1349
+ localStorage.removeItem('caremeal_api_key');
1350
+ updateApiStatusUI();
1351
+ closeApiModal();
1352
+ showNotification("오픈라우터 API Key가 삭제되었습니다. 데모 시뮬레이션 모드로 작동합니다.");
1353
+ });
1354
+
1355
+ elements.apiConfigForm.addEventListener('submit', (e) => {
1356
+ e.preventDefault();
1357
+
1358
+ const key = elements.apiKeyInput.value.trim();
1359
+ const model = elements.apiModelSelect.value;
1360
+
1361
+ apiConfig.key = key;
1362
+ apiConfig.model = model;
1363
+
1364
+ if (key) {
1365
+ localStorage.setItem('caremeal_api_key', key);
1366
+ localStorage.setItem('caremeal_api_model', model);
1367
+ showNotification("오픈라우터 무료 AI 연동 및 순차 폴백 체인 활성화!");
1368
+ } else {
1369
+ localStorage.removeItem('caremeal_api_key');
1370
+ showNotification("API Key가 비어있어 로컬 시뮬레이션 모드로 전환되었습니다.");
1371
+ }
1372
+
1373
+ updateApiStatusUI();
1374
+ closeApiModal();
1375
+ });
1376
+ }
1377
+
1378
+ function closeApiModal() {
1379
+ elements.apiConfigModal.classList.add('hidden');
1380
+ }
1381
+
1382
+ function updateApiStatusUI() {
1383
+ if (apiConfig.key) {
1384
+ elements.apiStatusBadge.className = 'api-status live-connected';
1385
+ elements.apiStatusText.textContent = `AI 연동: 폴백 체인`;
1386
+ elements.analyzerModelIndicator.textContent = `${apiConfig.model.split('/')[1] || apiConfig.model} (실시간 무료 AI 폴백 활성화)`;
1387
+ } else {
1388
+ elements.apiStatusBadge.className = 'api-status online';
1389
+ elements.apiStatusText.textContent = 'AI: 시뮬레이션 모드';
1390
+ elements.analyzerModelIndicator.textContent = 'Nemotron-70B / Llama-3 (시뮬레이션 폴백 모드)';
1391
+ }
1392
+ }
1393
+
1394
+ // 9. Interactive Person management Modal
1395
+ function initModal() {
1396
+ elements.addMemberBtn.addEventListener('click', () => {
1397
+ elements.addMemberModal.classList.remove('hidden');
1398
+ });
1399
+
1400
+ elements.closeMemberModalBtn.addEventListener('click', closeMemberModal);
1401
+ elements.cancelMemberBtn.addEventListener('click', closeMemberModal);
1402
+
1403
+ elements.addMemberModal.addEventListener('click', (e) => {
1404
+ if (e.target === elements.addMemberModal) closeMemberModal();
1405
+ });
1406
+
1407
+ elements.memberForm.addEventListener('submit', (e) => {
1408
+ e.preventDefault();
1409
+
1410
+ const name = document.getElementById('m-name').value.trim();
1411
+ const ageVal = document.getElementById('m-age').value.trim();
1412
+ const gender = document.getElementById('m-gender').value;
1413
+ const type = document.getElementById('m-risk-type').value;
1414
+ const detail = document.getElementById('m-detail').value.trim();
1415
+ const instruction = document.getElementById('m-instruction').value.trim();
1416
+
1417
+ if (!name || !ageVal || !detail || !instruction) return;
1418
+
1419
+ const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
1420
+ const newId = state.members[listKey].length > 0 ? Math.max(...state.members[listKey].map(m => m.id)) + 1 : 1;
1421
+
1422
+ const memberObj = {
1423
+ id: newId,
1424
+ name: name,
1425
+ age: `${ageVal}세 / ${gender}`,
1426
+ type: type,
1427
+ detail: detail,
1428
+ instruction: instruction,
1429
+ status: type === '일반' ? 'safe' : 'care'
1430
+ };
1431
+
1432
+ state.members[listKey].push(memberObj);
1433
+ saveDatabase(listKey);
1434
+
1435
+ closeMemberModal();
1436
+ elements.memberForm.reset();
1437
+
1438
+ renderDashboard();
1439
+ updateCharts();
1440
+
1441
+ showNotification(`신규 수혜자 ${name}님이 성공적으로 데이터베이스에 추가되었습니다.`);
1442
+ });
1443
+ }
1444
+
1445
+ function closeMemberModal() {
1446
+ elements.addMemberModal.classList.add('hidden');
1447
+ }
1448
+
1449
+ // 10. Chart.js Dashboard and ESG rendering
1450
+ function initCharts() {
1451
+ // Dynamic refresh
1452
+ }
1453
+
1454
+ function updateCharts() {
1455
+ const dashCanvas = document.getElementById('dashboardChart');
1456
+ if (dashCanvas) {
1457
+ if (state.charts.dashboard) state.charts.dashboard.destroy();
1458
+
1459
+ const listKey = state.currentMode === 'family' ? 'family' : state.currentFacility;
1460
+ const list = state.members[listKey];
1461
+ const normalCount = list.filter(m => m.type === '일반').length;
1462
+ const allergyCount = list.filter(m => m.type === '알레르기').length;
1463
+ const diseaseCount = list.filter(m => m.type === '질환식').length;
1464
+
1465
+ Chart.defaults.color = '#94a3b8';
1466
+ Chart.defaults.font.family = 'Outfit';
1467
+
1468
+ state.charts.dashboard = new Chart(dashCanvas, {
1469
+ type: 'doughnut',
1470
+ data: {
1471
+ labels: ['일반 급식군', '식품 알러지군', '만성 질환 식이군'],
1472
+ datasets: [{
1473
+ data: [normalCount, allergyCount, diseaseCount],
1474
+ backgroundColor: ['#10b981', '#f43f5e', '#8b5cf6'],
1475
+ borderWidth: 1,
1476
+ borderColor: 'rgba(255, 255, 255, 0.1)'
1477
+ }]
1478
+ },
1479
+ options: {
1480
+ responsive: true,
1481
+ maintainAspectRatio: false,
1482
+ plugins: {
1483
+ legend: {
1484
+ position: 'bottom',
1485
+ labels: {
1486
+ boxWidth: 12,
1487
+ padding: 10,
1488
+ font: { size: 11 }
1489
+ }
1490
+ }
1491
+ },
1492
+ cutout: '65%'
1493
+ }
1494
+ });
1495
+ }
1496
+
1497
+ const esgCanvas = document.getElementById('esgChart');
1498
+ if (esgCanvas) {
1499
+ if (state.charts.esg) state.charts.esg.destroy();
1500
+
1501
+ const lineLabel1 = state.currentMode === 'family' ? '자율 관리 환원 시간(H)' : '돌봄 환원 시간(H)';
1502
+ const lineLabel2 = state.currentMode === 'family' ? '가정 탄소 감축량(kg)' : '누적 탄소 절감(kg)';
1503
+
1504
+ state.charts.esg = new Chart(esgCanvas, {
1505
+ type: 'line',
1506
+ data: {
1507
+ labels: ['1월', '2월', '3월', '4월', '5월', '6월(예정)'],
1508
+ datasets: [
1509
+ {
1510
+ label: lineLabel1,
1511
+ data: [20, 35, 52, 68, 84, 105],
1512
+ borderColor: '#3b82f6',
1513
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
1514
+ tension: 0.4,
1515
+ fill: true
1516
+ },
1517
+ {
1518
+ label: lineLabel2,
1519
+ data: [50, 95, 140, 190, 240, 310],
1520
+ borderColor: '#10b981',
1521
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
1522
+ tension: 0.4,
1523
+ fill: true
1524
+ }
1525
+ ]
1526
+ },
1527
+ options: {
1528
+ responsive: true,
1529
+ maintainAspectRatio: false,
1530
+ plugins: {
1531
+ legend: {
1532
+ position: 'top',
1533
+ labels: {
1534
+ boxWidth: 10,
1535
+ font: { size: 10 }
1536
+ }
1537
+ }
1538
+ },
1539
+ scales: {
1540
+ x: { grid: { display: false } },
1541
+ y: { grid: { color: 'rgba(255, 255, 255, 0.05)' } }
1542
+ }
1543
+ }
1544
+ });
1545
+ }
1546
+ }
index.html ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CareTable - 취약계층 급식 위험 관리 및 평가 증빙 자동화 플랫폼</title>
7
+ <!-- Google Fonts -->
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
11
+ <!-- FontAwesome Icons -->
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
13
+ <!-- Chart.js CDN -->
14
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
15
+ <link rel="stylesheet" href="styles.css">
16
+ </head>
17
+ <body>
18
+ <div class="app-container">
19
+ <!-- Sidebar Navigation -->
20
+ <aside class="sidebar">
21
+ <div class="sidebar-header">
22
+ <div class="logo-area">
23
+ <i class="fa-solid fa-wand-magic-sparkles logo-icon"></i>
24
+ <span class="logo-text">CareTable <span class="highlight">AI</span></span>
25
+ </div>
26
+ <div class="badge-tag" id="main-badge">사보원 공공데이터 연계</div>
27
+ </div>
28
+
29
+ <nav class="sidebar-nav">
30
+ <button class="nav-btn active" data-tab="dashboard">
31
+ <i class="fa-solid fa-chart-pie"></i>
32
+ <span id="nav-dash-text">종합 대시보드</span>
33
+ </button>
34
+ <button class="nav-btn" data-tab="diet-analyzer">
35
+ <i class="fa-solid fa-calendar-days"></i>
36
+ <span>주간/월간 식단 AI 분석</span>
37
+ </button>
38
+ <button class="nav-btn" data-tab="vlm-scanner">
39
+ <i class="fa-solid fa-camera-retro"></i>
40
+ <span>VLM 배식 검증</span>
41
+ </button>
42
+ <button class="nav-btn" data-tab="report-generator" id="nav-report-btn">
43
+ <i class="fa-solid fa-file-shield"></i>
44
+ <span>평가 증빙 & ESG</span>
45
+ </button>
46
+ </nav>
47
+
48
+ <div class="sidebar-footer">
49
+ <!-- Mode Toggle -->
50
+ <div class="mode-switch-container">
51
+ <span class="mode-label"><i class="fa-solid fa-toggle-on"></i> 사용 모드 전환</span>
52
+ <div class="toggle-switch-wrapper">
53
+ <span class="toggle-mode-text" id="mode-text-left">기관</span>
54
+ <label class="switch">
55
+ <input type="checkbox" id="mode-toggle-checkbox">
56
+ <span class="slider round"></span>
57
+ </label>
58
+ <span class="toggle-mode-text" id="mode-text-right">가정</span>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="facility-selector" id="facility-selector-wrapper">
63
+ <label for="facility-select"><i class="fa-solid fa-house-medical"></i> 대상 시설 관리</label>
64
+ <select id="facility-select">
65
+ <option value="child" selected>튼튼어린이집 (보육시설)</option>
66
+ <option value="senior">행복실버요양원 (노인시설)</option>
67
+ </select>
68
+ </div>
69
+
70
+ <div class="user-profile">
71
+ <div class="user-avatar" id="user-avatar-initial">복</div>
72
+ <div class="user-info">
73
+ <span class="user-name" id="user-display-name">박아름 사회복지사</span>
74
+ <span class="user-role" id="user-display-role">마포구 시설 운영대표</span>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </aside>
79
+
80
+ <!-- Main Content Area -->
81
+ <main class="main-content">
82
+ <!-- Top GNB -->
83
+ <header class="top-gnb">
84
+ <div class="page-title-area">
85
+ <h1 id="page-main-title">종합 대시보드</h1>
86
+ <p id="page-sub-title">시설 맞춤형 프로파일 및 취약계층 급식 위험 관리 요약</p>
87
+ </div>
88
+ <div class="status-panel">
89
+ <button class="theme-toggle-btn" id="theme-toggle-btn">
90
+ <i class="fa-solid fa-moon"></i> <span id="theme-toggle-text">다크 모드</span>
91
+ </button>
92
+ <button class="config-btn" id="open-api-config-btn">
93
+ <i class="fa-solid fa-gears"></i> <span>오픈라우터 AI 연동</span>
94
+ </button>
95
+ <div class="api-status online" id="api-status-badge">
96
+ <span class="status-dot"></span>
97
+ <span id="api-status-text">AI: 시뮬레이션 모드</span>
98
+ </div>
99
+ <div class="notification-bell">
100
+ <i class="fa-regular fa-bell"></i>
101
+ <span class="noti-count" id="noti-count-badge">2</span>
102
+ </div>
103
+ </div>
104
+ </header>
105
+
106
+ <!-- Dashboard Tab -->
107
+ <section id="dashboard" class="tab-content active-tab">
108
+ <!-- Info Cards Grid -->
109
+ <div class="stats-grid">
110
+ <div class="stat-card glass-card animate-fade-in">
111
+ <div class="card-icon-wrapper blue">
112
+ <i class="fa-solid fa-users"></i>
113
+ </div>
114
+ <div class="stat-data">
115
+ <span class="stat-label" id="lbl-total-members">관리 대상자 수</span>
116
+ <h3 id="stat-total-members" class="stat-value">42명</h3>
117
+ <span class="stat-trend positive" id="lbl-total-desc"><i class="fa-solid fa-circle-info"></i> 사보원 데이터 기준</span>
118
+ </div>
119
+ </div>
120
+ <div class="stat-card glass-card animate-fade-in" style="animation-delay: 0.1s;">
121
+ <div class="card-icon-wrapper red">
122
+ <i class="fa-solid fa-triangle-exclamation"></i>
123
+ </div>
124
+ <div class="stat-data">
125
+ <span class="stat-label">위험 관리군 (알레르기/질환)</span>
126
+ <h3 id="stat-risk-members" class="stat-value">5명</h3>
127
+ <span class="stat-trend alert-color"><i class="fa-solid fa-circle-exclamation"></i> 개인별 집중 관리 요망</span>
128
+ </div>
129
+ </div>
130
+ <div class="stat-card glass-card animate-fade-in" style="animation-delay: 0.2s;">
131
+ <div class="card-icon-wrapper green">
132
+ <i class="fa-solid fa-clock-rotate-left"></i>
133
+ </div>
134
+ <div class="stat-data">
135
+ <span class="stat-label" id="lbl-saved-time">누적 행정 절감 시간</span>
136
+ <h3 id="stat-saved-time" class="stat-value">84시간</h3>
137
+ <span class="stat-trend positive" id="lbl-saved-desc"><i class="fa-solid fa-arrow-trend-up"></i> 돌봄 시간으로 환원됨</span>
138
+ </div>
139
+ </div>
140
+ <div class="stat-card glass-card animate-fade-in" style="animation-delay: 0.3s;">
141
+ <div class="card-icon-wrapper yellow">
142
+ <i class="fa-solid fa-shield-halved"></i>
143
+ </div>
144
+ <div class="stat-data">
145
+ <span class="stat-label">급식 안전 무결성 점수</span>
146
+ <h3 id="stat-safety-score" class="stat-value">100%</h3>
147
+ <span class="stat-trend positive"><i class="fa-solid fa-shield-check"></i> 사고 0건 달성</span>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Middle Content Section -->
153
+ <div class="content-row">
154
+ <!-- Left: Facility Profile & Person management -->
155
+ <div class="panel-col glass-card">
156
+ <div class="panel-header">
157
+ <h2 class="panel-title" id="panel-member-title"><i class="fa-solid fa-id-card"></i> 대상자 집중 관리 명단</h2>
158
+ <button class="add-member-btn" id="add-member-btn"><i class="fa-solid fa-plus"></i> 대상 추가</button>
159
+ </div>
160
+ <div class="table-container">
161
+ <table class="data-table" id="member-table">
162
+ <thead>
163
+ <tr>
164
+ <th>이름</th>
165
+ <th>연령/성별</th>
166
+ <th>위험 요인 (알레르기/질환)</th>
167
+ <th>맞춤 식이 지침</th>
168
+ <th>조작</th>
169
+ </tr>
170
+ </thead>
171
+ <tbody id="member-table-body">
172
+ <!-- Dynamic member data -->
173
+ </tbody>
174
+ </table>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- Right: Quick Analytics -->
179
+ <div class="panel-col glass-card flex-col-35">
180
+ <div class="panel-header">
181
+ <h2 class="panel-title"><i class="fa-solid fa-chart-line"></i> 실시간 통계 분석</h2>
182
+ </div>
183
+ <div class="chart-container">
184
+ <canvas id="dashboardChart"></canvas>
185
+ </div>
186
+ <div class="esg-score-box">
187
+ <div class="esg-title-row">
188
+ <span class="esg-title"><i class="fa-solid fa-leaf text-green"></i> CareTable ESG 혁신 지표</span>
189
+ <span class="esg-badge">A+ 등급</span>
190
+ </div>
191
+ <div class="esg-progress-bar">
192
+ <div class="esg-progress-fill" style="width: 92%;"></div>
193
+ </div>
194
+ <p class="esg-desc" id="esg-bottom-desc">친환경 저탄소 식단 추천 및 페이퍼리스 행정 자동화를 통한 탄소 발자국 240kg CO2e 감축 달성</p>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </section>
199
+
200
+ <!-- Diet Analyzer Tab -->
201
+ <section id="diet-analyzer" class="tab-content">
202
+ <div class="vertical-layout-wrapper">
203
+ <!-- Input Section -->
204
+ <div class="glass-card full-width-panel margin-bottom-24">
205
+ <div class="panel-header">
206
+ <h2 class="panel-title"><i class="fa-solid fa-calendar-plus"></i> 주간/월간 식단표 입력 (대량 식단 스캔)</h2>
207
+ <div class="quick-examples">
208
+ <span class="example-tag active" data-period="weekly">1주일 식단 템플릿</span>
209
+ <span class="example-tag" data-period="monthly">1개월 식단 템플릿</span>
210
+ </div>
211
+ </div>
212
+ <div class="diet-input-area">
213
+ <label class="form-label">날짜(또는 요일)와 식단 내용을 입력하세요. 식재료는 쉼표로, 일자는 줄바꿈으로 구분합니다:</label>
214
+ <textarea id="diet-bulk-input" class="diet-textarea large-textarea" placeholder="[월] 쌀밥, 계란말이, 어묵볶음, 요구르트&#10;[화] 잡곡밥, 된장국, 불고기, 고구마순나물, 생선구이&#10;[수] 잡곡밥, 닭볶음탕, 오징어채무침, 땅콩조림, 우유"></textarea>
215
+ <div class="btn-row justify-between">
216
+ <div class="model-status-info">
217
+ <i class="fa-solid fa-robot"></i> <span id="analyzer-model-indicator">DeepSeek V4 Flash (무료) 모델 우선 작동</span>
218
+ </div>
219
+ <button id="analyze-diet-btn" class="primary-btn"><i class="fa-solid fa-wand-magic-sparkles"></i> AI 전체 식단 안전성 정밀 검사</button>
220
+ </div>
221
+ </div>
222
+
223
+ <!-- Analysis Loading State (hidden by default) -->
224
+ <div id="analyzer-loading" class="analyzer-loading hidden">
225
+ <div class="spinner"></div>
226
+ <p id="loading-text">OpenRouter AI 모델에 통신 중입니다. 실시간 식자재 영양 분석 및 위험 요인 매칭 중...</p>
227
+ </div>
228
+ </div>
229
+
230
+ <!-- Output Schedule View (hidden by default, shown after analysis) -->
231
+ <div id="schedule-result-view" class="hidden animate-fade-in">
232
+ <div class="result-header-summary glass-card margin-bottom-24">
233
+ <div class="summary-status-badge alert-red" id="schedule-status-banner">
234
+ <i class="fa-solid fa-circle-exclamation text-red animate-bounce"></i>
235
+ <div>
236
+ <h4 id="schedule-summary-title">위험 식단 감지됨</h4>
237
+ <p id="schedule-summary-desc">총 5일의 식단 중 2일의 식단에서 등록 수혜자 알레르기/질환 유발 성분이 발견되어 자동 대체식을 처방했습니다.</p>
238
+ </div>
239
+ </div>
240
+ <div class="btn-row">
241
+ <button id="confirm-all-diet-btn" class="primary-btn"><i class="fa-solid fa-circle-check"></i> 전체 식단 확정 및 VLM 배식 검증 대기</button>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Calendar / Timelime Cards Grid -->
246
+ <div class="schedule-grid" id="schedule-cards-container">
247
+ <!-- Dynamic Cards generated via JS -->
248
+ </div>
249
+ </div>
250
+
251
+ <!-- Empty State for results -->
252
+ <div id="schedule-empty-view" class="glass-card empty-state-view">
253
+ <i class="fa-solid fa-magnifying-glass-chart"></i>
254
+ <h3>식단 분석 대기 중</h3>
255
+ <p>위 텍스트 영역에 주간/월간 식단표를 넣고 분석 버튼을 누르면, 일자별 대체 식단표와 AI 안전 조리 지침이 타임라인 카드 뷰로 보기 좋게 자동 생성됩니다.</p>
256
+ </div>
257
+ </div>
258
+ </section>
259
+
260
+ <!-- VLM Verification Tab -->
261
+ <section id="vlm-scanner" class="tab-content">
262
+ <div class="content-row">
263
+ <!-- Scanner camera panel -->
264
+ <div class="panel-col glass-card">
265
+ <div class="panel-header">
266
+ <h2 class="panel-title"><i class="fa-solid fa-camera"></i> VLM(Vision-Language Model) 실시간 배식 모니터</h2>
267
+ <button class="add-member-btn" id="trigger-file-btn"><i class="fa-solid fa-upload"></i> 식판 사진 직접 업로드</button>
268
+ <input type="file" id="vlm-file-input" accept="image/*" class="hidden">
269
+ </div>
270
+
271
+ <div class="vlm-monitor-layout">
272
+ <!-- Left: Camera Box -->
273
+ <div class="camera-simulator-box">
274
+ <div class="camera-lens-overlay">
275
+ <div class="lens-crosshair"></div>
276
+ <div class="scanner-laser" id="scanner-laser"></div>
277
+ <div class="camera-badge">CAMERA LIVE</div>
278
+ </div>
279
+ <div class="image-viewport" id="image-viewport">
280
+ <div class="viewport-placeholder">
281
+ <i class="fa-solid fa-image"></i>
282
+ <p>아래에서 시뮬레이션 이미지를 선택하거나 직접 식판 사진을 업로드하세요</p>
283
+ </div>
284
+ <img id="vlm-target-image" src="" alt="식판 사진" class="hidden">
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Right: Operations -->
289
+ <div class="vlm-ops-panel">
290
+ <h3>배식 모니터링 식판 이미지 매칭</h3>
291
+ <p class="vlm-desc">검증할 식판 사진을 준비해 주십시오. (성공/실패 데모를 즉시 실행할 수 있습니다.)</p>
292
+ <div class="sim-selector-grid">
293
+ <div class="sim-option-card" id="sim-opt-normal" data-type="normal">
294
+ <div class="sim-img-thumb-container">
295
+ <img src="assets/meal_normal.png" alt="일반 식단 (대체식 미적용)">
296
+ </div>
297
+ <div class="sim-info">
298
+ <span class="sim-title">일반 배식 식판</span>
299
+ <span class="sim-badge danger-badge">달걀말이/요구르트 포함</span>
300
+ </div>
301
+ </div>
302
+ <div class="sim-option-card" id="sim-opt-alternative" data-type="alternative">
303
+ <div class="sim-img-thumb-container">
304
+ <img src="assets/meal_alternative.png" alt="대체 식단 (두부, 주스 적용)">
305
+ </div>
306
+ <div class="sim-info">
307
+ <span class="sim-title">대체 배식 식판</span>
308
+ <span class="sim-badge success-badge">두부구이/주스 변경완료</span>
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ <div class="scan-action-row">
314
+ <button id="run-vlm-btn" class="primary-btn large-btn" disabled>
315
+ <i class="fa-solid fa-cube"></i> VLM 배식 사진 분석 & 검증 시작
316
+ </button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- VLM Verification Results -->
323
+ <div class="panel-col glass-card flex-col-40">
324
+ <div class="panel-header">
325
+ <h2 class="panel-title"><i class="fa-solid fa-circle-info"></i> AI Vision 매칭 및 실시간 분석 결과</h2>
326
+ </div>
327
+
328
+ <div id="vlm-empty-result" class="empty-state-view">
329
+ <i class="fa-solid fa-magnifying-glass-chart"></i>
330
+ <p>식판 사진을 선택하고 VLM 분석을 시작하면 배식 식단 매칭성, 유해 알레르기 물질 탐지, 배식 가부 판정 결과가 표시됩니다.</p>
331
+ </div>
332
+
333
+ <!-- Real VLM result panel (hidden by default) -->
334
+ <div id="vlm-result-panel" class="vlm-result-panel hidden animate-fade-in">
335
+ <div class="vlm-verdict-box" id="vlm-verdict-box">
336
+ <div class="verdict-icon" id="verdict-icon-container">
337
+ <!-- Dynamic verdict icon -->
338
+ </div>
339
+ <div class="verdict-text-area">
340
+ <span class="verdict-lbl">VLM 배식 검증 결과</span>
341
+ <h3 id="verdict-title">배식 보류 (REJECTED)</h3>
342
+ </div>
343
+ </div>
344
+
345
+ <div class="matching-details-list">
346
+ <div class="detail-item">
347
+ <span class="detail-label">식단 일치율 (Menu Matching)</span>
348
+ <div class="bar-progress-container">
349
+ <div class="bar-fill" id="vlm-match-rate" style="width: 80%;"></div>
350
+ </div>
351
+ <span class="detail-value" id="vlm-match-value">80%</span>
352
+ </div>
353
+ <div class="detail-item">
354
+ <span class="detail-label">유해/금지 성분 유무 (Allergens)</span>
355
+ <span class="detail-value text-red" id="vlm-allergen-status">달걀, 우유 검출</span>
356
+ </div>
357
+ <div class="detail-item">
358
+ <span class="detail-label">배식 정량 및 형태</span>
359
+ <span class="detail-value" id="vlm-portion-status">정량 배식 (96%)</span>
360
+ </div>
361
+ </div>
362
+
363
+ <div class="vlm-reason-box">
364
+ <h4><i class="fa-solid fa-comment-dots"></i> AI Vision 분석 사유</h4>
365
+ <p id="vlm-analysis-reason">카메라 사진 분석 결과, 10시 방향에 계란말이 및 우측 하단에 우유가 포함된 요구르트병이 그대로 배치되어 있어 등록된 아동 '김민수'의 식판으로 적합하지 않습니다. 대체식(두부구이, 주스)으로의 교체를 요구하십시오.</p>
366
+ </div>
367
+
368
+ <div class="btn-row" id="vlm-action-footer">
369
+ <!-- dynamic pass / reject buttons -->
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </section>
375
+
376
+ <!-- Report and ESG Tab -->
377
+ <section id="report-generator" class="tab-content">
378
+ <div class="content-row">
379
+ <!-- Left: Report preview list -->
380
+ <div class="panel-col glass-card flex-col-65">
381
+ <div class="panel-header">
382
+ <h2 class="panel-title"><i class="fa-solid fa-file-invoice"></i> 사회보장/가족 건강 데이터 증빙 센터</h2>
383
+ <div class="report-tab-selectors">
384
+ <button class="sub-tab-btn active" data-report="급식일지">급식 일지</button>
385
+ <button class="sub-tab-btn" data-report="알레르기대장">집중 케어 대장</button>
386
+ <button class="sub-tab-btn" data-report="대체식대장">대체 급식 관리대장</button>
387
+ </div>
388
+ </div>
389
+
390
+ <!-- Report Render Area -->
391
+ <div class="report-document-container" id="report-print-area">
392
+ <div class="report-paper">
393
+ <!-- Dynamic Document Header -->
394
+ <div class="report-paper-header">
395
+ <h1 id="report-doc-title">급 식 일 지</h1>
396
+ <div class="report-meta-grid">
397
+ <div class="meta-cell"><strong>구분</strong>: <span id="r-facility-name">튼튼어린이집</span></div>
398
+ <div class="meta-cell"><strong>일자</strong>: <span id="r-date">2026년 06월 01일 (월)</span></div>
399
+ <div class="meta-cell"><strong>작성자</strong>: <span id="r-writer">박아름 사회복지사</span></div>
400
+ <div class="meta-cell"><strong>인증코드</strong>: <span>SSIS-2026-N45</span></div>
401
+ </div>
402
+ </div>
403
+
404
+ <!-- Dynamic Document Body -->
405
+ <div id="report-doc-content">
406
+ <!-- Inject via JS -->
407
+ </div>
408
+
409
+ <div class="report-paper-footer">
410
+ <div class="sign-area">
411
+ <p id="report-confirm-statement">위와 같이 취약계층 급식 관리 및 대체 배식이 올바르게 수행되었으며, 배식 전 VLM(Vision-Language Model) 검증 절차를 완료하였음을 확인합니다.</p>
412
+ <div class="sign-seal">
413
+ <span id="report-signer-name">확인자: 시설대표 박아름 (서명/인)</span>
414
+ <div class="seal-stamp">CareTable 검증</div>
415
+ </div>
416
+ </div>
417
+ <div class="report-footer-tag" id="report-footer-desc">한국사회보장정보원 사회복지시설평가 증빙 표준 서식 (SSIS 14-2)</div>
418
+ </div>
419
+ </div>
420
+ </div>
421
+
422
+ <div class="btn-row justify-end padding-top-15">
423
+ <button id="print-report-btn" class="primary-btn"><i class="fa-solid fa-print"></i> 공식 인쇄 (PDF/문서 저장)</button>
424
+ </div>
425
+ </div>
426
+
427
+ <!-- Right: ESG Innovation statistics -->
428
+ <div class="panel-col glass-card">
429
+ <div class="panel-header">
430
+ <h2 class="panel-title"><i class="fa-solid fa-leaf"></i> CareTable ESG 혁신 대시보드</h2>
431
+ </div>
432
+
433
+ <div class="esg-metric-grid">
434
+ <div class="esg-metric-card">
435
+ <div class="esg-val text-green" id="esg-co2">240.5kg</div>
436
+ <div class="esg-lbl">누적 탄소(CO2) 절감량</div>
437
+ <div class="esg-sub">종이 인쇄 감축 및 저탄소 식단 추천 효과</div>
438
+ </div>
439
+ <div class="esg-metric-card">
440
+ <div class="esg-val text-blue" id="esg-time">84.2시간</div>
441
+ <div class="esg-lbl" id="esg-saved-label">행정 시간 ➔ 직접 돌봄 시간 환원</div>
442
+ <div class="esg-sub" id="esg-saved-sub">시설 평가 문서 자동화에 따른 절감</div>
443
+ </div>
444
+ <div class="esg-metric-card">
445
+ <div class="esg-val text-yellow" id="esg-local">45%</div>
446
+ <div class="esg-lbl">지역 농가 상생 재료 비중</div>
447
+ <div class="esg-sub">지자체 및 인근 로컬 푸드 매칭율</div>
448
+ </div>
449
+ </div>
450
+
451
+ <div class="esg-chart-box">
452
+ <canvas id="esgChart"></canvas>
453
+ </div>
454
+ </div>
455
+ </div>
456
+ </section>
457
+ </main>
458
+ </div>
459
+
460
+ <!-- Modals -->
461
+ <!-- OpenRouter API Config Modal -->
462
+ <div id="api-config-modal" class="modal hidden">
463
+ <div class="modal-content glass-card">
464
+ <div class="modal-header">
465
+ <h3><i class="fa-solid fa-robot"></i> OpenRouter AI 모델 설정</h3>
466
+ <span class="close-modal-btn" id="close-api-modal-btn">&times;</span>
467
+ </div>
468
+ <div class="modal-body">
469
+ <form id="api-config-form">
470
+ <p class="modal-info-text">OpenRouter 무료 API Key를 등록하여 실시간 AI 위험 감지 및 맞춤 대체 레시피 분석을 구동할 수 있습니다. 키가 없어도 시뮬레이션 모드로 작동합니다.</p>
471
+ <div class="form-group">
472
+ <label for="api-key-input">OpenRouter API Key (OPENROUTER_API)</label>
473
+ <div class="password-input-wrapper">
474
+ <input type="password" id="api-key-input" placeholder="sk-or-v1-...">
475
+ <button type="button" id="toggle-key-visibility-btn"><i class="fa-solid fa-eye"></i></button>
476
+ </div>
477
+ </div>
478
+ <div class="form-group">
479
+ <label for="api-model-select">사용할 AI 모델 (무료 모델)</label>
480
+ <select id="api-model-select">
481
+ <option value="deepseek/deepseek-v4-flash:free" selected>DeepSeek V4 Flash (무료)</option>
482
+ <option value="qwen/qwen3-next-80b-a3b-instruct:free">Qwen 3 Next 80B (무료)</option>
483
+ <option value="meta-llama/llama-3-8b-instruct:free">Llama 3 8B Instruct (무료)</option>
484
+ <option value="meta-llama/llama-3.3-70b-instruct:free">Llama 3.3 70B Instruct (무료)</option>
485
+ <option value="google/gemma-4-26b-a4b:free">Gemma 4 26B A4B (무료)</option>
486
+ <option value="nvidia/llama-3.1-nemotron-70b-instruct:free">Llama 3.1 Nemotron 70B (무료)</option>
487
+ </select>
488
+ </div>
489
+ <div class="btn-row justify-end">
490
+ <button type="button" class="secondary-btn" id="reset-api-key-btn">키 삭제</button>
491
+ <button type="submit" class="primary-btn">연동 완료</button>
492
+ </div>
493
+ </form>
494
+ </div>
495
+ </div>
496
+ </div>
497
+
498
+ <!-- Add Member Modal -->
499
+ <div id="add-member-modal" class="modal hidden">
500
+ <div class="modal-content glass-card">
501
+ <div class="modal-header">
502
+ <h3 id="modal-member-title"><i class="fa-solid fa-user-plus"></i> 신규 관리 대상자 등록</h3>
503
+ <span class="close-modal-btn" id="close-member-modal-btn">&times;</span>
504
+ </div>
505
+ <div class="modal-body">
506
+ <form id="member-form">
507
+ <div class="form-group">
508
+ <label for="m-name">이름 (또는 별칭)</label>
509
+ <input type="text" id="m-name" required placeholder="예: 김동현">
510
+ </div>
511
+ <div class="form-group">
512
+ <label for="m-age">나이 (세)</label>
513
+ <input type="number" id="m-age" required placeholder="예: 6">
514
+ </div>
515
+ <div class="form-group">
516
+ <label for="m-gender">성별</label>
517
+ <select id="m-gender">
518
+ <option value="남">남성</option>
519
+ <option value="여">여성</option>
520
+ </select>
521
+ </div>
522
+ <div class="form-group">
523
+ <label for="m-risk-type">위험 분류</label>
524
+ <select id="m-risk-type">
525
+ <option value="알레르기">알레르기 (식품성)</option>
526
+ <option value="질환식">질환군 식이 (당뇨, 연하장애, 고혈압 등)</option>
527
+ <option value="일반">해당 없음 (일반 급식군)</option>
528
+ </select>
529
+ </div>
530
+ <div class="form-group">
531
+ <label for="m-detail">상세 요인 (식품명 또는 질환명)</label>
532
+ <input type="text" id="m-detail" required placeholder="예: 대두, 땅콩, 당뇨병 2단계, 유당불내증">
533
+ </div>
534
+ <div class="form-group">
535
+ <label for="m-instruction">맞춤 식사 조치 지침</label>
536
+ <textarea id="m-instruction" required placeholder="예: 콩기름/간장 사용 전면 제한, 알레르기 발생 시 대체 급식 및 구급약 준비"></textarea>
537
+ </div>
538
+ <div class="btn-row justify-end">
539
+ <button type="button" class="secondary-btn" id="cancel-member-btn">취소</button>
540
+ <button type="submit" class="primary-btn">등록 완료</button>
541
+ </div>
542
+ </form>
543
+ </div>
544
+ </div>
545
+ </div>
546
+
547
+ <!-- Script Source -->
548
+ <script src="app.js"></script>
549
+ </body>
550
+ </html>
styles.css ADDED
@@ -0,0 +1,2139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CareMeal AI Premium Design Stylesheet */
2
+
3
+ /* Light Theme Variables (Default) */
4
+ :root {
5
+ --bg-main: #f3f4f6;
6
+ --bg-side: #ffffff;
7
+ --bg-card: rgba(255, 255, 255, 0.8);
8
+ --border-color: rgba(0, 0, 0, 0.08);
9
+ --border-color-active: rgba(59, 130, 246, 0.3);
10
+
11
+ --text-primary: #111827;
12
+ --text-secondary: #4b5563;
13
+ --text-muted: #9ca3af;
14
+
15
+ --color-blue: #2563eb;
16
+ --color-cyan: #0891b2;
17
+ --color-green: #059669;
18
+ --color-red: #dc2626;
19
+ --color-yellow: #d97706;
20
+ --color-purple: #7c3aed;
21
+
22
+ --gradient-primary: linear-gradient(135deg, #2563eb 0%, #0891b2 100%);
23
+ --gradient-success: linear-gradient(135deg, #059669 0%, #047857 100%);
24
+ --gradient-danger: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
25
+ --gradient-warning: linear-gradient(135deg, #d97706 0%, #b45309 100%);
26
+
27
+ --font-outfit: 'Outfit', 'Inter', 'Noto Sans KR', sans-serif;
28
+ --card-shadow: 0 10px 30px 0 rgba(31, 41, 55, 0.08);
29
+ --card-blur: blur(12px);
30
+ --transition-speed: 0.3s;
31
+ }
32
+
33
+ /* Dark Theme Variables (Activated via body.dark-mode) */
34
+ body.dark-mode {
35
+ --bg-main: #0a0d16;
36
+ --bg-side: #0f1322;
37
+ --bg-card: rgba(22, 28, 48, 0.6);
38
+ --border-color: rgba(255, 255, 255, 0.08);
39
+ --border-color-active: rgba(59, 130, 246, 0.4);
40
+
41
+ --text-primary: #f8fafc;
42
+ --text-secondary: #94a3b8;
43
+ --text-muted: #64748b;
44
+
45
+ --color-blue: #3b82f6;
46
+ --color-cyan: #06b6d4;
47
+ --color-green: #10b981;
48
+ --color-red: #f43f5e;
49
+ --color-yellow: #fbbf24;
50
+ --color-purple: #8b5cf6;
51
+
52
+ --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);
53
+ --gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
54
+ --gradient-danger: linear-gradient(135deg, #f43f5e 0%, #be123c 100%);
55
+ --gradient-warning: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
56
+
57
+ --card-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
58
+ }
59
+
60
+ /* Reset & Global styles */
61
+ * {
62
+ margin: 0;
63
+ padding: 0;
64
+ box-sizing: border-box;
65
+ }
66
+
67
+ body {
68
+ background-color: var(--bg-main);
69
+ color: var(--text-primary);
70
+ font-family: var(--font-outfit);
71
+ overflow-x: hidden;
72
+ height: 100vh;
73
+ transition: background-color var(--transition-speed), color var(--transition-speed);
74
+ }
75
+
76
+ /* App Container Layout */
77
+ .app-container {
78
+ display: flex;
79
+ width: 100vw;
80
+ height: 100vh;
81
+ }
82
+
83
+ /* Sidebar Styling */
84
+ .sidebar {
85
+ width: 280px;
86
+ background-color: var(--bg-side);
87
+ border-right: 1px solid var(--border-color);
88
+ display: flex;
89
+ flex-direction: column;
90
+ padding: 24px;
91
+ z-index: 10;
92
+ transition: background-color var(--transition-speed), border-color var(--transition-speed);
93
+ }
94
+
95
+ .sidebar-header {
96
+ margin-bottom: 30px;
97
+ }
98
+
99
+ .logo-area {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 12px;
103
+ }
104
+
105
+ .logo-icon {
106
+ font-size: 26px;
107
+ background: var(--gradient-primary);
108
+ -webkit-background-clip: text;
109
+ -webkit-text-fill-color: transparent;
110
+ }
111
+
112
+ .logo-text {
113
+ font-size: 22px;
114
+ font-weight: 800;
115
+ letter-spacing: -0.5px;
116
+ }
117
+
118
+ .logo-text .highlight {
119
+ color: var(--color-cyan);
120
+ }
121
+
122
+ .badge-tag {
123
+ display: inline-block;
124
+ margin-top: 8px;
125
+ font-size: 11px;
126
+ font-weight: 600;
127
+ background: rgba(37, 99, 235, 0.08);
128
+ color: var(--color-blue);
129
+ padding: 3px 8px;
130
+ border-radius: 20px;
131
+ border: 1px solid rgba(37, 99, 235, 0.2);
132
+ text-align: center;
133
+ }
134
+
135
+ .sidebar-nav {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 8px;
139
+ flex-grow: 1;
140
+ }
141
+
142
+ .nav-btn {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 16px;
146
+ background: none;
147
+ border: 1px solid transparent;
148
+ color: var(--text-secondary);
149
+ padding: 14px 18px;
150
+ border-radius: 12px;
151
+ cursor: pointer;
152
+ font-family: var(--font-outfit);
153
+ font-size: 15px;
154
+ font-weight: 500;
155
+ text-align: left;
156
+ transition: all var(--transition-speed) ease;
157
+ }
158
+
159
+ .nav-btn i {
160
+ font-size: 18px;
161
+ transition: transform 0.2s ease;
162
+ }
163
+
164
+ .nav-btn:hover {
165
+ background-color: rgba(0, 0, 0, 0.03);
166
+ color: var(--text-primary);
167
+ }
168
+
169
+ body.dark-mode .nav-btn:hover {
170
+ background-color: rgba(255, 255, 255, 0.03);
171
+ }
172
+
173
+ .nav-btn.active {
174
+ background-color: rgba(37, 99, 235, 0.08);
175
+ color: var(--color-blue);
176
+ border-color: rgba(37, 99, 235, 0.15);
177
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
178
+ }
179
+
180
+ body.dark-mode .nav-btn.active {
181
+ background-color: rgba(59, 130, 246, 0.1);
182
+ color: var(--text-primary);
183
+ border-color: rgba(59, 130, 246, 0.25);
184
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
185
+ }
186
+
187
+ .nav-btn.active i {
188
+ color: var(--color-cyan);
189
+ transform: scale(1.1);
190
+ }
191
+
192
+ .sidebar-footer {
193
+ border-top: 1px solid var(--border-color);
194
+ padding-top: 15px;
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: 15px;
198
+ }
199
+
200
+ /* Toggle Switch Styles */
201
+ .mode-switch-container {
202
+ background-color: rgba(0, 0, 0, 0.02);
203
+ border: 1px solid var(--border-color);
204
+ border-radius: 10px;
205
+ padding: 12px;
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 8px;
209
+ }
210
+
211
+ body.dark-mode .mode-switch-container {
212
+ background-color: rgba(255, 255, 255, 0.02);
213
+ }
214
+
215
+ .mode-label {
216
+ font-size: 11px;
217
+ color: var(--text-secondary);
218
+ font-weight: 600;
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 6px;
222
+ }
223
+
224
+ .toggle-switch-wrapper {
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: space-between;
228
+ gap: 10px;
229
+ }
230
+
231
+ .toggle-mode-text {
232
+ font-size: 12px;
233
+ font-weight: 600;
234
+ color: var(--text-muted);
235
+ transition: color 0.2s;
236
+ }
237
+
238
+ .toggle-mode-text.active-mode {
239
+ color: var(--color-cyan);
240
+ }
241
+
242
+ /* Switch slider */
243
+ .switch {
244
+ position: relative;
245
+ display: inline-block;
246
+ width: 48px;
247
+ height: 24px;
248
+ }
249
+
250
+ .switch input {
251
+ opacity: 0;
252
+ width: 0;
253
+ height: 0;
254
+ }
255
+
256
+ .slider {
257
+ position: absolute;
258
+ cursor: pointer;
259
+ top: 0;
260
+ left: 0;
261
+ right: 0;
262
+ bottom: 0;
263
+ background-color: rgba(0, 0, 0, 0.08);
264
+ transition: .4s;
265
+ border: 1px solid var(--border-color);
266
+ }
267
+
268
+ body.dark-mode .slider {
269
+ background-color: rgba(255, 255, 255, 0.1);
270
+ }
271
+
272
+ .slider:before {
273
+ position: absolute;
274
+ content: "";
275
+ height: 16px;
276
+ width: 16px;
277
+ left: 3px;
278
+ bottom: 3px;
279
+ background-color: var(--text-secondary);
280
+ transition: .4s;
281
+ }
282
+
283
+ input:checked + .slider {
284
+ background-color: rgba(8, 145, 178, 0.15);
285
+ border-color: var(--color-cyan);
286
+ }
287
+
288
+ body.dark-mode input:checked + .slider {
289
+ background-color: rgba(6, 182, 212, 0.2);
290
+ }
291
+
292
+ input:checked + .slider:before {
293
+ transform: translateX(24px);
294
+ background-color: var(--color-cyan);
295
+ box-shadow: 0 0 6px var(--color-cyan);
296
+ }
297
+
298
+ .slider.round {
299
+ border-radius: 34px;
300
+ }
301
+
302
+ .slider.round:before {
303
+ border-radius: 50%;
304
+ }
305
+
306
+ .facility-selector {
307
+ display: flex;
308
+ flex-direction: column;
309
+ gap: 6px;
310
+ }
311
+
312
+ .facility-selector label {
313
+ font-size: 11px;
314
+ color: var(--text-secondary);
315
+ font-weight: 500;
316
+ }
317
+
318
+ .facility-selector select {
319
+ background-color: rgba(0, 0, 0, 0.03);
320
+ border: 1px solid var(--border-color);
321
+ color: var(--text-primary);
322
+ padding: 8px 12px;
323
+ border-radius: 8px;
324
+ font-family: var(--font-outfit);
325
+ font-size: 13px;
326
+ cursor: pointer;
327
+ outline: none;
328
+ transition: border-color 0.2s;
329
+ }
330
+
331
+ body.dark-mode .facility-selector select {
332
+ background-color: rgba(255, 255, 255, 0.05);
333
+ }
334
+
335
+ .facility-selector select:focus {
336
+ border-color: var(--color-blue);
337
+ }
338
+
339
+ .user-profile {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 12px;
343
+ }
344
+
345
+ .user-avatar {
346
+ width: 40px;
347
+ height: 40px;
348
+ border-radius: 50%;
349
+ background: var(--gradient-primary);
350
+ display: flex;
351
+ align-items: center;
352
+ justify-content: center;
353
+ font-weight: 700;
354
+ color: white;
355
+ }
356
+
357
+ .user-info {
358
+ display: flex;
359
+ flex-direction: column;
360
+ }
361
+
362
+ .user-name {
363
+ font-size: 14px;
364
+ font-weight: 600;
365
+ }
366
+
367
+ .user-role {
368
+ font-size: 11px;
369
+ color: var(--text-muted);
370
+ }
371
+
372
+ /* Main Content Layout */
373
+ .main-content {
374
+ flex-grow: 1;
375
+ display: flex;
376
+ flex-direction: column;
377
+ height: 100vh;
378
+ overflow-y: auto;
379
+ background-image: radial-gradient(circle at top right, rgba(37, 99, 235, 0.05) 0%, rgba(243, 244, 246, 0) 60%);
380
+ }
381
+
382
+ body.dark-mode .main-content {
383
+ background-image: radial-gradient(circle at top right, rgba(30, 41, 59, 0.3) 0%, rgba(10, 13, 22, 0) 60%);
384
+ }
385
+
386
+ /* Top GNB */
387
+ .top-gnb {
388
+ height: 80px;
389
+ min-height: 80px;
390
+ border-bottom: 1px solid var(--border-color);
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: space-between;
394
+ padding: 0 40px;
395
+ backdrop-filter: blur(10px);
396
+ background-color: rgba(255, 255, 255, 0.8);
397
+ position: sticky;
398
+ top: 0;
399
+ z-index: 9;
400
+ transition: background-color var(--transition-speed), border-color var(--transition-speed);
401
+ }
402
+
403
+ body.dark-mode .top-gnb {
404
+ background-color: rgba(10, 13, 22, 0.8);
405
+ }
406
+
407
+ .page-title-area h1 {
408
+ font-size: 22px;
409
+ font-weight: 700;
410
+ letter-spacing: -0.5px;
411
+ }
412
+
413
+ .page-title-area p {
414
+ font-size: 13px;
415
+ color: var(--text-secondary);
416
+ margin-top: 2px;
417
+ }
418
+
419
+ .status-panel {
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 16px;
423
+ }
424
+
425
+ /* Theme Toggle GNB */
426
+ .theme-toggle-btn, .config-btn {
427
+ background-color: rgba(0, 0, 0, 0.03);
428
+ color: var(--text-primary);
429
+ border: 1px solid var(--border-color);
430
+ padding: 8px 14px;
431
+ border-radius: 8px;
432
+ font-family: var(--font-outfit);
433
+ font-size: 12px;
434
+ font-weight: 600;
435
+ cursor: pointer;
436
+ display: inline-flex;
437
+ align-items: center;
438
+ gap: 8px;
439
+ transition: all 0.2s ease;
440
+ }
441
+
442
+ body.dark-mode .theme-toggle-btn, body.dark-mode .config-btn {
443
+ background-color: rgba(255, 255, 255, 0.05);
444
+ }
445
+
446
+ .theme-toggle-btn:hover, .config-btn:hover {
447
+ background-color: rgba(0, 0, 0, 0.06);
448
+ border-color: var(--color-blue);
449
+ }
450
+
451
+ body.dark-mode .theme-toggle-btn:hover, body.dark-mode .config-btn:hover {
452
+ background-color: rgba(255, 255, 255, 0.08);
453
+ }
454
+
455
+ .api-status {
456
+ display: flex;
457
+ align-items: center;
458
+ gap: 8px;
459
+ font-size: 12px;
460
+ color: var(--text-secondary);
461
+ background-color: rgba(0, 0, 0, 0.03);
462
+ padding: 6px 12px;
463
+ border-radius: 20px;
464
+ border: 1px solid var(--border-color);
465
+ }
466
+
467
+ body.dark-mode .api-status {
468
+ background-color: rgba(255, 255, 255, 0.03);
469
+ }
470
+
471
+ .status-dot {
472
+ width: 8px;
473
+ height: 8px;
474
+ border-radius: 50%;
475
+ }
476
+
477
+ .api-status.online .status-dot {
478
+ background-color: var(--color-green);
479
+ box-shadow: 0 0 8px var(--color-green);
480
+ }
481
+
482
+ .api-status.live-connected .status-dot {
483
+ background-color: var(--color-cyan);
484
+ box-shadow: 0 0 8px var(--color-cyan);
485
+ }
486
+
487
+ .notification-bell {
488
+ position: relative;
489
+ font-size: 20px;
490
+ color: var(--text-secondary);
491
+ cursor: pointer;
492
+ width: 38px;
493
+ height: 38px;
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: center;
497
+ border-radius: 50%;
498
+ background-color: rgba(0, 0, 0, 0.03);
499
+ border: 1px solid var(--border-color);
500
+ transition: all 0.2s;
501
+ }
502
+
503
+ body.dark-mode .notification-bell {
504
+ background-color: rgba(255, 255, 255, 0.03);
505
+ }
506
+
507
+ .notification-bell:hover {
508
+ color: var(--text-primary);
509
+ background-color: rgba(0, 0, 0, 0.06);
510
+ }
511
+
512
+ body.dark-mode .notification-bell:hover {
513
+ background-color: rgba(255, 255, 255, 0.08);
514
+ }
515
+
516
+ .noti-count {
517
+ position: absolute;
518
+ top: -2px;
519
+ right: -2px;
520
+ background-color: var(--color-red);
521
+ color: white;
522
+ font-size: 10px;
523
+ font-weight: 700;
524
+ width: 16px;
525
+ height: 16px;
526
+ border-radius: 50%;
527
+ display: flex;
528
+ align-items: center;
529
+ justify-content: center;
530
+ border: 2px solid var(--bg-main);
531
+ }
532
+
533
+ /* Glass Card Styling */
534
+ .glass-card {
535
+ background: var(--bg-card);
536
+ border: 1px solid var(--border-color);
537
+ border-radius: 16px;
538
+ box-shadow: var(--card-shadow);
539
+ backdrop-filter: var(--card-blur);
540
+ -webkit-backdrop-filter: var(--card-blur);
541
+ padding: 24px;
542
+ transition: border-color 0.3s, box-shadow 0.3s, background-color var(--transition-speed);
543
+ }
544
+
545
+ .glass-card:hover {
546
+ border-color: rgba(0, 0, 0, 0.12);
547
+ }
548
+
549
+ body.dark-mode .glass-card:hover {
550
+ border-color: rgba(255, 255, 255, 0.12);
551
+ }
552
+
553
+ /* Tab Content Visibility */
554
+ .tab-content {
555
+ display: none;
556
+ padding: 30px 40px;
557
+ animation: fadeIn 0.4s ease-out forwards;
558
+ }
559
+
560
+ .tab-content.active-tab {
561
+ display: block;
562
+ }
563
+
564
+ /* 1. Dashboard Tab Styles */
565
+ .stats-grid {
566
+ display: grid;
567
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
568
+ gap: 20px;
569
+ margin-bottom: 24px;
570
+ }
571
+
572
+ .stat-card {
573
+ display: flex;
574
+ align-items: center;
575
+ gap: 20px;
576
+ }
577
+
578
+ .card-icon-wrapper {
579
+ width: 54px;
580
+ height: 54px;
581
+ border-radius: 12px;
582
+ display: flex;
583
+ align-items: center;
584
+ justify-content: center;
585
+ font-size: 22px;
586
+ }
587
+
588
+ .card-icon-wrapper.blue {
589
+ background: rgba(37, 99, 235, 0.08);
590
+ color: var(--color-blue);
591
+ border: 1px solid rgba(37, 99, 235, 0.15);
592
+ }
593
+
594
+ body.dark-mode .card-icon-wrapper.blue {
595
+ background: rgba(59, 130, 246, 0.15);
596
+ border-color: rgba(59, 130, 246, 0.3);
597
+ }
598
+
599
+ .card-icon-wrapper.red {
600
+ background: rgba(220, 38, 38, 0.08);
601
+ color: var(--color-red);
602
+ border: 1px solid rgba(220, 38, 38, 0.15);
603
+ }
604
+
605
+ body.dark-mode .card-icon-wrapper.red {
606
+ background: rgba(244, 63, 94, 0.15);
607
+ border-color: rgba(244, 63, 94, 0.3);
608
+ }
609
+
610
+ .card-icon-wrapper.green {
611
+ background: rgba(5, 150, 105, 0.08);
612
+ color: var(--color-green);
613
+ border: 1px solid rgba(5, 150, 105, 0.15);
614
+ }
615
+
616
+ body.dark-mode .card-icon-wrapper.green {
617
+ background: rgba(16, 185, 129, 0.15);
618
+ border-color: rgba(16, 185, 129, 0.3);
619
+ }
620
+
621
+ .card-icon-wrapper.yellow {
622
+ background: rgba(217, 119, 6, 0.08);
623
+ color: var(--color-yellow);
624
+ border: 1px solid rgba(217, 119, 6, 0.15);
625
+ }
626
+
627
+ body.dark-mode .card-icon-wrapper.yellow {
628
+ background: rgba(251, 191, 36, 0.15);
629
+ border-color: rgba(251, 191, 36, 0.3);
630
+ }
631
+
632
+ .stat-data {
633
+ display: flex;
634
+ flex-direction: column;
635
+ }
636
+
637
+ .stat-label {
638
+ font-size: 13px;
639
+ color: var(--text-secondary);
640
+ }
641
+
642
+ .stat-value {
643
+ font-size: 26px;
644
+ font-weight: 700;
645
+ margin: 2px 0;
646
+ }
647
+
648
+ .stat-trend {
649
+ font-size: 11px;
650
+ display: flex;
651
+ align-items: center;
652
+ gap: 4px;
653
+ }
654
+
655
+ .stat-trend.positive {
656
+ color: var(--color-green);
657
+ }
658
+
659
+ .stat-trend.alert-color {
660
+ color: var(--color-red);
661
+ }
662
+
663
+ /* Content Columns & Table */
664
+ .content-row {
665
+ display: flex;
666
+ gap: 24px;
667
+ margin-bottom: 24px;
668
+ }
669
+
670
+ .panel-col {
671
+ flex: 1;
672
+ display: flex;
673
+ flex-direction: column;
674
+ }
675
+
676
+ .flex-col-35 {
677
+ flex: 0 0 35%;
678
+ }
679
+
680
+ .flex-col-40 {
681
+ flex: 0 0 40%;
682
+ }
683
+
684
+ .flex-col-65 {
685
+ flex: 0 0 65%;
686
+ }
687
+
688
+ .panel-header {
689
+ display: flex;
690
+ align-items: center;
691
+ justify-content: space-between;
692
+ margin-bottom: 20px;
693
+ }
694
+
695
+ .panel-title {
696
+ font-size: 16px;
697
+ font-weight: 600;
698
+ display: flex;
699
+ align-items: center;
700
+ gap: 10px;
701
+ }
702
+
703
+ .panel-title i {
704
+ color: var(--color-cyan);
705
+ }
706
+
707
+ /* Member Table Design */
708
+ .table-container {
709
+ overflow-x: auto;
710
+ flex-grow: 1;
711
+ }
712
+
713
+ .data-table {
714
+ width: 100%;
715
+ border-collapse: collapse;
716
+ text-align: left;
717
+ }
718
+
719
+ .data-table th, .data-table td {
720
+ padding: 14px 16px;
721
+ font-size: 13px;
722
+ border-bottom: 1px solid var(--border-color);
723
+ }
724
+
725
+ .data-table th {
726
+ color: var(--text-secondary);
727
+ font-weight: 600;
728
+ background-color: rgba(0, 0, 0, 0.02);
729
+ }
730
+
731
+ body.dark-mode .data-table th {
732
+ background-color: rgba(255, 255, 255, 0.02);
733
+ }
734
+
735
+ .data-table tbody tr {
736
+ transition: background-color 0.2s;
737
+ }
738
+
739
+ .data-table tbody tr:hover {
740
+ background-color: rgba(0, 0, 0, 0.01);
741
+ }
742
+
743
+ body.dark-mode .data-table tbody tr:hover {
744
+ background-color: rgba(255, 255, 255, 0.02);
745
+ }
746
+
747
+ .risk-tag {
748
+ display: inline-block;
749
+ padding: 4px 10px;
750
+ border-radius: 20px;
751
+ font-size: 11px;
752
+ font-weight: 600;
753
+ }
754
+
755
+ .risk-tag.allergy {
756
+ background-color: rgba(220, 38, 38, 0.06);
757
+ color: #b91c1c;
758
+ border: 1px solid rgba(220, 38, 38, 0.15);
759
+ }
760
+
761
+ body.dark-mode .risk-tag.allergy {
762
+ background-color: rgba(244, 63, 94, 0.12);
763
+ color: #fca5a5;
764
+ border-color: rgba(244, 63, 94, 0.2);
765
+ }
766
+
767
+ .risk-tag.disease {
768
+ background-color: rgba(124, 58, 237, 0.06);
769
+ color: #6d28d9;
770
+ border: 1px solid rgba(124, 58, 237, 0.15);
771
+ }
772
+
773
+ body.dark-mode .risk-tag.disease {
774
+ background-color: rgba(139, 92, 246, 0.12);
775
+ color: #c084fc;
776
+ border-color: rgba(139, 92, 246, 0.2);
777
+ }
778
+
779
+ .status-badge {
780
+ display: inline-flex;
781
+ align-items: center;
782
+ gap: 5px;
783
+ font-size: 11px;
784
+ font-weight: 500;
785
+ }
786
+
787
+ .status-badge::before {
788
+ content: '';
789
+ width: 6px;
790
+ height: 6px;
791
+ border-radius: 50%;
792
+ }
793
+
794
+ .status-badge.safe {
795
+ color: var(--color-green);
796
+ }
797
+
798
+ .status-badge.safe::before {
799
+ background-color: var(--color-green);
800
+ }
801
+
802
+ .status-badge.care {
803
+ color: var(--color-yellow);
804
+ }
805
+
806
+ .status-badge.care::before {
807
+ background-color: var(--color-yellow);
808
+ }
809
+
810
+ /* Remove/Delete Row icon */
811
+ .delete-member-btn {
812
+ background: none;
813
+ border: none;
814
+ color: var(--text-muted);
815
+ cursor: pointer;
816
+ font-size: 14px;
817
+ transition: color 0.2s;
818
+ }
819
+
820
+ .delete-member-btn:hover {
821
+ color: var(--color-red);
822
+ }
823
+
824
+ /* Add buttons & Common Button Styles */
825
+ .add-member-btn, .primary-btn, .secondary-btn {
826
+ font-family: var(--font-outfit);
827
+ border-radius: 8px;
828
+ font-weight: 600;
829
+ cursor: pointer;
830
+ display: inline-flex;
831
+ align-items: center;
832
+ justify-content: center;
833
+ gap: 8px;
834
+ transition: all 0.2s ease;
835
+ }
836
+
837
+ .add-member-btn {
838
+ background: none;
839
+ border: 1px dashed var(--color-cyan);
840
+ color: var(--color-cyan);
841
+ padding: 6px 12px;
842
+ font-size: 12px;
843
+ }
844
+
845
+ .add-member-btn:hover {
846
+ background: rgba(8, 145, 178, 0.04);
847
+ transform: translateY(-1px);
848
+ }
849
+
850
+ body.dark-mode .add-member-btn:hover {
851
+ background: rgba(6, 182, 212, 0.08);
852
+ }
853
+
854
+ .primary-btn {
855
+ background: var(--gradient-primary);
856
+ color: white;
857
+ border: none;
858
+ padding: 12px 20px;
859
+ font-size: 14px;
860
+ box-shadow: 0 4px 14px rgba(37, 99, 235, 0.2);
861
+ }
862
+
863
+ body.dark-mode .primary-btn {
864
+ box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3);
865
+ }
866
+
867
+ .primary-btn:hover:not(:disabled) {
868
+ opacity: 0.95;
869
+ box-shadow: 0 6px 20px rgba(37, 99, 235, 0.3);
870
+ transform: translateY(-1px);
871
+ }
872
+
873
+ .primary-btn:disabled {
874
+ background: var(--text-muted);
875
+ opacity: 0.4;
876
+ cursor: not-allowed;
877
+ box-shadow: none;
878
+ }
879
+
880
+ .secondary-btn {
881
+ background: rgba(0, 0, 0, 0.03);
882
+ border: 1px solid var(--border-color);
883
+ color: var(--text-primary);
884
+ padding: 12px 20px;
885
+ font-size: 14px;
886
+ }
887
+
888
+ body.dark-mode .secondary-btn {
889
+ background: rgba(255, 255, 255, 0.05);
890
+ }
891
+
892
+ .secondary-btn:hover {
893
+ background: rgba(0, 0, 0, 0.06);
894
+ border-color: rgba(0, 0, 0, 0.15);
895
+ }
896
+
897
+ body.dark-mode .secondary-btn:hover {
898
+ background: rgba(255, 255, 255, 0.08);
899
+ border-color: rgba(255, 255, 255, 0.2);
900
+ }
901
+
902
+ /* Charts and ESG info on Dash */
903
+ .chart-container {
904
+ height: 180px;
905
+ position: relative;
906
+ margin-bottom: 20px;
907
+ }
908
+
909
+ .esg-score-box {
910
+ background-color: rgba(0, 0, 0, 0.01);
911
+ border: 1px solid var(--border-color);
912
+ border-radius: 12px;
913
+ padding: 16px;
914
+ display: flex;
915
+ flex-direction: column;
916
+ gap: 10px;
917
+ }
918
+
919
+ body.dark-mode .esg-score-box {
920
+ background-color: rgba(255, 255, 255, 0.02);
921
+ }
922
+
923
+ .esg-title-row {
924
+ display: flex;
925
+ justify-content: space-between;
926
+ font-size: 13px;
927
+ font-weight: 600;
928
+ }
929
+
930
+ .esg-title {
931
+ display: flex;
932
+ align-items: center;
933
+ gap: 6px;
934
+ }
935
+
936
+ .text-green {
937
+ color: var(--color-green);
938
+ }
939
+
940
+ .text-blue {
941
+ color: var(--color-blue);
942
+ }
943
+
944
+ .text-yellow {
945
+ color: var(--color-yellow);
946
+ }
947
+
948
+ .esg-badge {
949
+ background-color: rgba(5, 150, 105, 0.08);
950
+ color: #047857;
951
+ padding: 2px 8px;
952
+ border-radius: 20px;
953
+ font-size: 11px;
954
+ border: 1px solid rgba(5, 150, 105, 0.2);
955
+ }
956
+
957
+ body.dark-mode .esg-badge {
958
+ background-color: rgba(16, 185, 129, 0.15);
959
+ color: #6ee7b7;
960
+ border-color: rgba(16, 185, 129, 0.3);
961
+ }
962
+
963
+ .esg-progress-bar {
964
+ width: 100%;
965
+ height: 8px;
966
+ background-color: rgba(0, 0, 0, 0.05);
967
+ border-radius: 4px;
968
+ overflow: hidden;
969
+ }
970
+
971
+ body.dark-mode .esg-progress-bar {
972
+ background-color: rgba(255, 255, 255, 0.08);
973
+ }
974
+
975
+ .esg-progress-fill {
976
+ height: 100%;
977
+ background: var(--gradient-success);
978
+ border-radius: 4px;
979
+ }
980
+
981
+ .esg-desc {
982
+ font-size: 11px;
983
+ color: var(--text-secondary);
984
+ line-height: 1.5;
985
+ }
986
+
987
+ /* 2. Diet Analyzer Tab (Weekly/Monthly Schedule layout) */
988
+ .vertical-layout-wrapper {
989
+ display: flex;
990
+ flex-direction: column;
991
+ width: 100%;
992
+ }
993
+
994
+ .full-width-panel {
995
+ width: 100%;
996
+ }
997
+
998
+ .margin-bottom-24 {
999
+ margin-bottom: 24px;
1000
+ }
1001
+
1002
+ .diet-textarea {
1003
+ width: 100%;
1004
+ height: 110px;
1005
+ background-color: rgba(0, 0, 0, 0.02);
1006
+ border: 1px solid var(--border-color);
1007
+ border-radius: 12px;
1008
+ color: var(--text-primary);
1009
+ padding: 16px;
1010
+ font-family: var(--font-outfit);
1011
+ font-size: 14px;
1012
+ line-height: 1.6;
1013
+ outline: none;
1014
+ resize: none;
1015
+ transition: border-color 0.2s, box-shadow 0.2s;
1016
+ }
1017
+
1018
+ body.dark-mode .diet-textarea {
1019
+ background-color: rgba(0, 0, 0, 0.2);
1020
+ }
1021
+
1022
+ .diet-textarea.large-textarea {
1023
+ height: 180px;
1024
+ }
1025
+
1026
+ .diet-textarea:focus {
1027
+ border-color: var(--color-blue);
1028
+ box-shadow: 0 0 10px rgba(37, 99, 235, 0.1);
1029
+ }
1030
+
1031
+ body.dark-mode .diet-textarea:focus {
1032
+ box-shadow: 0 0 10px rgba(59, 130, 246, 0.15);
1033
+ }
1034
+
1035
+ .justify-between {
1036
+ justify-content: space-between;
1037
+ align-items: center;
1038
+ }
1039
+
1040
+ .model-status-info {
1041
+ font-size: 12px;
1042
+ color: var(--text-secondary);
1043
+ display: flex;
1044
+ align-items: center;
1045
+ gap: 8px;
1046
+ background: rgba(0, 0, 0, 0.02);
1047
+ border: 1px solid var(--border-color);
1048
+ padding: 6px 12px;
1049
+ border-radius: 6px;
1050
+ }
1051
+
1052
+ body.dark-mode .model-status-info {
1053
+ background: rgba(255, 255, 255, 0.02);
1054
+ }
1055
+
1056
+ .model-status-info i {
1057
+ color: var(--color-cyan);
1058
+ }
1059
+
1060
+ .example-tag.active {
1061
+ background-color: rgba(8, 145, 178, 0.08);
1062
+ color: var(--text-primary);
1063
+ border-color: rgba(8, 145, 178, 0.25);
1064
+ }
1065
+
1066
+ body.dark-mode .example-tag.active {
1067
+ background-color: rgba(6, 182, 212, 0.1);
1068
+ border-color: rgba(6, 182, 212, 0.3);
1069
+ }
1070
+
1071
+ /* Scheduler Loading View */
1072
+ .analyzer-loading {
1073
+ display: flex;
1074
+ flex-direction: column;
1075
+ align-items: center;
1076
+ gap: 16px;
1077
+ padding: 30px 0;
1078
+ color: var(--text-secondary);
1079
+ font-size: 13px;
1080
+ border-top: 1px solid var(--border-color);
1081
+ margin-top: 20px;
1082
+ }
1083
+
1084
+ .spinner {
1085
+ width: 32px;
1086
+ height: 32px;
1087
+ border: 3px solid rgba(37, 99, 235, 0.15);
1088
+ border-top: 3px solid var(--color-blue);
1089
+ border-radius: 50%;
1090
+ animation: spin 1s linear infinite;
1091
+ }
1092
+
1093
+ /* Results header banner */
1094
+ .result-header-summary {
1095
+ display: flex;
1096
+ justify-content: space-between;
1097
+ align-items: center;
1098
+ gap: 20px;
1099
+ flex-wrap: wrap;
1100
+ }
1101
+
1102
+ .summary-status-badge {
1103
+ display: flex;
1104
+ align-items: flex-start;
1105
+ gap: 16px;
1106
+ padding: 10px;
1107
+ flex: 1;
1108
+ }
1109
+
1110
+ .summary-status-badge.alert-red {
1111
+ background: rgba(220, 38, 38, 0.03);
1112
+ border-left: 4px solid var(--color-red);
1113
+ }
1114
+
1115
+ body.dark-mode .summary-status-badge.alert-red {
1116
+ background: rgba(244, 63, 94, 0.05);
1117
+ }
1118
+
1119
+ .summary-status-badge.safe-green {
1120
+ background: rgba(5, 150, 105, 0.03);
1121
+ border-left: 4px solid var(--color-green);
1122
+ }
1123
+
1124
+ body.dark-mode .summary-status-badge.safe-green {
1125
+ background: rgba(16, 185, 129, 0.05);
1126
+ }
1127
+
1128
+ .summary-status-badge i {
1129
+ font-size: 22px;
1130
+ margin-top: 2px;
1131
+ }
1132
+
1133
+ .summary-status-badge h4 {
1134
+ font-size: 14px;
1135
+ font-weight: 700;
1136
+ }
1137
+
1138
+ .summary-status-badge p {
1139
+ font-size: 12px;
1140
+ color: var(--text-secondary);
1141
+ margin-top: 4px;
1142
+ }
1143
+
1144
+ /* Schedule Card Grid (Weekly / Monthly cards) */
1145
+ .schedule-grid {
1146
+ display: grid;
1147
+ grid-template-columns: 1fr;
1148
+ gap: 16px;
1149
+ margin-bottom: 30px;
1150
+ }
1151
+
1152
+ @media(min-width: 1024px) {
1153
+ .schedule-grid {
1154
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1155
+ }
1156
+ }
1157
+
1158
+ .schedule-card {
1159
+ background: rgba(255, 255, 255, 0.6);
1160
+ border: 1px solid var(--border-color);
1161
+ border-radius: 12px;
1162
+ overflow: hidden;
1163
+ transition: all 0.3s ease;
1164
+ }
1165
+
1166
+ body.dark-mode .schedule-card {
1167
+ background: rgba(22, 28, 48, 0.4);
1168
+ }
1169
+
1170
+ .schedule-card.has-risk {
1171
+ border-top: 3px solid var(--color-red);
1172
+ }
1173
+
1174
+ .schedule-card.is-safe {
1175
+ border-top: 3px solid var(--color-green);
1176
+ }
1177
+
1178
+ .schedule-card:hover {
1179
+ transform: translateY(-2px);
1180
+ box-shadow: 0 8px 24px rgba(31, 41, 55, 0.08);
1181
+ border-color: rgba(0, 0, 0, 0.12);
1182
+ }
1183
+
1184
+ body.dark-mode .schedule-card:hover {
1185
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
1186
+ border-color: rgba(255, 255, 255, 0.15);
1187
+ }
1188
+
1189
+ .card-header-toggle {
1190
+ padding: 18px 20px;
1191
+ display: flex;
1192
+ justify-content: space-between;
1193
+ align-items: center;
1194
+ cursor: pointer;
1195
+ user-select: none;
1196
+ }
1197
+
1198
+ .header-day-info {
1199
+ display: flex;
1200
+ flex-direction: column;
1201
+ gap: 4px;
1202
+ }
1203
+
1204
+ .day-title {
1205
+ font-size: 16px;
1206
+ font-weight: 700;
1207
+ }
1208
+
1209
+ .day-date {
1210
+ font-size: 11px;
1211
+ color: var(--text-muted);
1212
+ }
1213
+
1214
+ .status-indicator-box {
1215
+ display: flex;
1216
+ align-items: center;
1217
+ gap: 12px;
1218
+ }
1219
+
1220
+ .status-pill {
1221
+ font-size: 10px;
1222
+ font-weight: 700;
1223
+ padding: 2px 8px;
1224
+ border-radius: 4px;
1225
+ }
1226
+
1227
+ .status-pill.danger {
1228
+ background-color: rgba(220, 38, 38, 0.08);
1229
+ color: #dc2626;
1230
+ border: 1px solid rgba(220, 38, 38, 0.2);
1231
+ }
1232
+
1233
+ body.dark-mode .status-pill.danger {
1234
+ background-color: rgba(244, 63, 94, 0.15);
1235
+ color: #fca5a5;
1236
+ border-color: rgba(244, 63, 94, 0.3);
1237
+ }
1238
+
1239
+ .status-pill.success {
1240
+ background-color: rgba(5, 150, 105, 0.08);
1241
+ color: #059669;
1242
+ border: 1px solid rgba(5, 150, 105, 0.2);
1243
+ }
1244
+
1245
+ body.dark-mode .status-pill.success {
1246
+ background-color: rgba(16, 185, 129, 0.15);
1247
+ color: #a7f3d0;
1248
+ border-color: rgba(16, 185, 129, 0.3);
1249
+ }
1250
+
1251
+ .toggle-arrow-icon {
1252
+ color: var(--text-muted);
1253
+ font-size: 14px;
1254
+ transition: transform var(--transition-speed);
1255
+ }
1256
+
1257
+ .toggle-arrow-icon.rotate-icon {
1258
+ transform: rotate(180deg);
1259
+ }
1260
+
1261
+ /* Accordion sliding effect */
1262
+ .accordion-content {
1263
+ max-height: 0;
1264
+ overflow: hidden;
1265
+ transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
1266
+ background-color: rgba(0, 0, 0, 0.02);
1267
+ }
1268
+
1269
+ body.dark-mode .accordion-content {
1270
+ background-color: rgba(0, 0, 0, 0.15);
1271
+ }
1272
+
1273
+ .accordion-content.expanded {
1274
+ max-height: 2000px;
1275
+ transition: max-height 0.4s ease-in-out;
1276
+ }
1277
+
1278
+ .card-body-details {
1279
+ padding: 20px;
1280
+ border-top: 1px solid var(--border-color);
1281
+ display: flex;
1282
+ flex-direction: column;
1283
+ gap: 16px;
1284
+ }
1285
+
1286
+ .menu-orig-box, .menu-alt-box, .recipe-guide-box {
1287
+ display: flex;
1288
+ flex-direction: column;
1289
+ gap: 6px;
1290
+ }
1291
+
1292
+ .menu-orig-box h5 {
1293
+ font-size: 12px;
1294
+ color: var(--color-red);
1295
+ text-transform: uppercase;
1296
+ letter-spacing: 0.5px;
1297
+ font-weight: 700;
1298
+ }
1299
+
1300
+ .menu-alt-box h5 {
1301
+ font-size: 12px;
1302
+ color: var(--color-green);
1303
+ text-transform: uppercase;
1304
+ letter-spacing: 0.5px;
1305
+ font-weight: 700;
1306
+ }
1307
+
1308
+ .recipe-guide-box h5 {
1309
+ font-size: 12px;
1310
+ color: var(--color-yellow);
1311
+ text-transform: uppercase;
1312
+ letter-spacing: 0.5px;
1313
+ font-weight: 700;
1314
+ }
1315
+
1316
+ .menu-content-text {
1317
+ font-size: 13px;
1318
+ line-height: 1.5;
1319
+ color: var(--text-primary);
1320
+ }
1321
+
1322
+ .recipe-content-text {
1323
+ font-size: 12px;
1324
+ line-height: 1.6;
1325
+ color: var(--text-secondary);
1326
+ background-color: rgba(0, 0, 0, 0.02);
1327
+ padding: 12px;
1328
+ border-radius: 8px;
1329
+ border: 1px solid var(--border-color);
1330
+ }
1331
+
1332
+ body.dark-mode .recipe-content-text {
1333
+ background-color: rgba(0, 0, 0, 0.2);
1334
+ }
1335
+
1336
+ /* 3. VLM Verification Tab Styles */
1337
+ .vlm-monitor-layout {
1338
+ display: flex;
1339
+ flex-direction: column;
1340
+ gap: 24px;
1341
+ }
1342
+
1343
+ .camera-simulator-box {
1344
+ width: 100%;
1345
+ height: 380px;
1346
+ background-color: #04060b;
1347
+ border-radius: 12px;
1348
+ border: 1px solid var(--border-color);
1349
+ position: relative;
1350
+ overflow: hidden;
1351
+ }
1352
+
1353
+ .camera-lens-overlay {
1354
+ position: absolute;
1355
+ top: 0;
1356
+ left: 0;
1357
+ width: 100%;
1358
+ height: 100%;
1359
+ pointer-events: none;
1360
+ z-index: 2;
1361
+ border: 2px solid rgba(59, 130, 246, 0.15);
1362
+ }
1363
+
1364
+ .lens-crosshair {
1365
+ position: absolute;
1366
+ top: 50%;
1367
+ left: 50%;
1368
+ width: 40px;
1369
+ height: 40px;
1370
+ transform: translate(-50%, -50%);
1371
+ border: 1px solid rgba(255, 255, 255, 0.15);
1372
+ border-radius: 50%;
1373
+ }
1374
+
1375
+ .lens-crosshair::before, .lens-crosshair::after {
1376
+ content: '';
1377
+ position: absolute;
1378
+ background-color: rgba(255, 255, 255, 0.2);
1379
+ }
1380
+
1381
+ .lens-crosshair::before {
1382
+ top: 50%;
1383
+ left: -10px;
1384
+ width: 60px;
1385
+ height: 1px;
1386
+ }
1387
+
1388
+ .lens-crosshair::after {
1389
+ left: 50%;
1390
+ top: -10px;
1391
+ width: 1px;
1392
+ height: 60px;
1393
+ }
1394
+
1395
+ .scanner-laser {
1396
+ position: absolute;
1397
+ top: 0;
1398
+ left: 0;
1399
+ width: 100%;
1400
+ height: 3px;
1401
+ background: linear-gradient(90deg, rgba(6, 182, 212, 0) 0%, rgba(6, 182, 212, 0.8) 50%, rgba(6, 182, 212, 0) 100%);
1402
+ box-shadow: 0 0 12px var(--color-cyan);
1403
+ display: none;
1404
+ }
1405
+
1406
+ @keyframes scan {
1407
+ 0% { top: 0%; }
1408
+ 50% { top: 100%; }
1409
+ 100% { top: 0%; }
1410
+ }
1411
+
1412
+ .camera-badge {
1413
+ position: absolute;
1414
+ top: 16px;
1415
+ left: 16px;
1416
+ background-color: var(--color-red);
1417
+ color: white;
1418
+ font-size: 10px;
1419
+ font-weight: 700;
1420
+ padding: 3px 8px;
1421
+ border-radius: 4px;
1422
+ letter-spacing: 0.5px;
1423
+ animation: flash 1.5s infinite;
1424
+ }
1425
+
1426
+ .image-viewport {
1427
+ width: 100%;
1428
+ height: 100%;
1429
+ display: flex;
1430
+ align-items: center;
1431
+ justify-content: center;
1432
+ background-color: #020306;
1433
+ }
1434
+
1435
+ .viewport-placeholder {
1436
+ display: flex;
1437
+ flex-direction: column;
1438
+ align-items: center;
1439
+ gap: 12px;
1440
+ color: var(--text-muted);
1441
+ }
1442
+
1443
+ .viewport-placeholder i {
1444
+ font-size: 40px;
1445
+ }
1446
+
1447
+ .viewport-placeholder p {
1448
+ font-size: 13px;
1449
+ }
1450
+
1451
+ #vlm-target-image {
1452
+ max-height: 100%;
1453
+ max-width: 100%;
1454
+ object-fit: contain;
1455
+ }
1456
+
1457
+ .vlm-ops-panel {
1458
+ display: flex;
1459
+ flex-direction: column;
1460
+ gap: 14px;
1461
+ }
1462
+
1463
+ .vlm-desc {
1464
+ font-size: 12px;
1465
+ color: var(--text-secondary);
1466
+ }
1467
+
1468
+ .sim-selector-grid {
1469
+ display: grid;
1470
+ grid-template-columns: 1fr 1fr;
1471
+ gap: 16px;
1472
+ }
1473
+
1474
+ .sim-option-card {
1475
+ border: 1px solid var(--border-color);
1476
+ background-color: rgba(0, 0, 0, 0.02);
1477
+ border-radius: 8px;
1478
+ cursor: pointer;
1479
+ overflow: hidden;
1480
+ transition: all 0.2s ease;
1481
+ }
1482
+
1483
+ body.dark-mode .sim-option-card {
1484
+ background-color: rgba(255, 255, 255, 0.02);
1485
+ }
1486
+
1487
+ .sim-option-card:hover {
1488
+ border-color: rgba(0, 0, 0, 0.15);
1489
+ background-color: rgba(0, 0, 0, 0.04);
1490
+ }
1491
+
1492
+ body.dark-mode .sim-option-card:hover {
1493
+ border-color: rgba(255, 255, 255, 0.15);
1494
+ background-color: rgba(255, 255, 255, 0.04);
1495
+ }
1496
+
1497
+ .sim-option-card.selected {
1498
+ border-color: var(--color-blue);
1499
+ background-color: rgba(37, 99, 235, 0.03);
1500
+ box-shadow: 0 0 10px rgba(37, 99, 235, 0.05);
1501
+ }
1502
+
1503
+ body.dark-mode .sim-option-card.selected {
1504
+ background-color: rgba(59, 130, 246, 0.05);
1505
+ box-shadow: 0 0 10px rgba(59, 130, 246, 0.1);
1506
+ }
1507
+
1508
+ .sim-img-thumb-container {
1509
+ height: 120px;
1510
+ background-color: black;
1511
+ display: flex;
1512
+ align-items: center;
1513
+ justify-content: center;
1514
+ }
1515
+
1516
+ .sim-img-thumb-container img {
1517
+ max-height: 100%;
1518
+ max-width: 100%;
1519
+ object-fit: cover;
1520
+ }
1521
+
1522
+ .sim-info {
1523
+ padding: 10px;
1524
+ display: flex;
1525
+ flex-direction: column;
1526
+ gap: 6px;
1527
+ }
1528
+
1529
+ .sim-title {
1530
+ font-size: 12px;
1531
+ font-weight: 600;
1532
+ }
1533
+
1534
+ .sim-badge {
1535
+ align-self: flex-start;
1536
+ font-size: 9px;
1537
+ font-weight: 700;
1538
+ padding: 2px 6px;
1539
+ border-radius: 4px;
1540
+ }
1541
+
1542
+ .danger-badge {
1543
+ background-color: rgba(220, 38, 38, 0.08);
1544
+ color: #dc2626;
1545
+ border: 1px solid rgba(220, 38, 38, 0.2);
1546
+ }
1547
+
1548
+ body.dark-mode .danger-badge {
1549
+ background-color: rgba(244, 63, 94, 0.15);
1550
+ color: #fecaca;
1551
+ border-color: rgba(244, 63, 94, 0.3);
1552
+ }
1553
+
1554
+ .success-badge {
1555
+ background-color: rgba(5, 150, 105, 0.08);
1556
+ color: #059669;
1557
+ border: 1px solid rgba(5, 150, 105, 0.2);
1558
+ }
1559
+
1560
+ body.dark-mode .success-badge {
1561
+ background-color: rgba(16, 185, 129, 0.15);
1562
+ color: #d1fae5;
1563
+ border-color: rgba(16, 185, 129, 0.3);
1564
+ }
1565
+
1566
+ .scan-action-row {
1567
+ margin-top: 10px;
1568
+ }
1569
+
1570
+ .large-btn {
1571
+ width: 100%;
1572
+ padding: 14px;
1573
+ font-size: 15px;
1574
+ }
1575
+
1576
+ /* VLM Verdict output panel */
1577
+ .vlm-result-panel {
1578
+ display: flex;
1579
+ flex-direction: column;
1580
+ gap: 20px;
1581
+ }
1582
+
1583
+ .vlm-verdict-box {
1584
+ display: flex;
1585
+ align-items: center;
1586
+ gap: 16px;
1587
+ padding: 18px;
1588
+ border-radius: 12px;
1589
+ color: white;
1590
+ }
1591
+
1592
+ .vlm-verdict-box.rejected {
1593
+ background: var(--gradient-danger);
1594
+ box-shadow: 0 4px 14px rgba(220, 38, 38, 0.2);
1595
+ }
1596
+
1597
+ body.dark-mode .vlm-verdict-box.rejected {
1598
+ box-shadow: 0 4px 14px rgba(244, 63, 94, 0.2);
1599
+ }
1600
+
1601
+ .vlm-verdict-box.passed {
1602
+ background: var(--gradient-success);
1603
+ box-shadow: 0 4px 14px rgba(5, 150, 105, 0.2);
1604
+ }
1605
+
1606
+ body.dark-mode .vlm-verdict-box.passed {
1607
+ box-shadow: 0 4px 14px rgba(16, 185, 129, 0.2);
1608
+ }
1609
+
1610
+ .verdict-icon {
1611
+ font-size: 28px;
1612
+ width: 48px;
1613
+ height: 48px;
1614
+ border-radius: 50%;
1615
+ background-color: rgba(255, 255, 255, 0.15);
1616
+ display: flex;
1617
+ align-items: center;
1618
+ justify-content: center;
1619
+ }
1620
+
1621
+ .verdict-text-area {
1622
+ display: flex;
1623
+ flex-direction: column;
1624
+ }
1625
+
1626
+ .verdict-lbl {
1627
+ font-size: 11px;
1628
+ opacity: 0.8;
1629
+ }
1630
+
1631
+ .verdict-text-area h3 {
1632
+ font-size: 18px;
1633
+ font-weight: 700;
1634
+ }
1635
+
1636
+ .matching-details-list {
1637
+ background-color: rgba(0, 0, 0, 0.02);
1638
+ border: 1px solid var(--border-color);
1639
+ border-radius: 12px;
1640
+ padding: 18px;
1641
+ display: flex;
1642
+ flex-direction: column;
1643
+ gap: 14px;
1644
+ }
1645
+
1646
+ body.dark-mode .matching-details-list {
1647
+ background-color: rgba(0, 0, 0, 0.15);
1648
+ }
1649
+
1650
+ .detail-item {
1651
+ display: flex;
1652
+ align-items: center;
1653
+ justify-content: space-between;
1654
+ font-size: 13px;
1655
+ }
1656
+
1657
+ .detail-label {
1658
+ color: var(--text-secondary);
1659
+ }
1660
+
1661
+ .bar-progress-container {
1662
+ width: 80px;
1663
+ height: 6px;
1664
+ background-color: rgba(0, 0, 0, 0.05);
1665
+ border-radius: 3px;
1666
+ overflow: hidden;
1667
+ margin: 0 10px;
1668
+ }
1669
+
1670
+ body.dark-mode .bar-progress-container {
1671
+ background-color: rgba(255, 255, 255, 0.08);
1672
+ }
1673
+
1674
+ .bar-fill {
1675
+ height: 100%;
1676
+ background-color: var(--color-blue);
1677
+ border-radius: 3px;
1678
+ }
1679
+
1680
+ .detail-value {
1681
+ font-weight: 600;
1682
+ }
1683
+
1684
+ .vlm-reason-box {
1685
+ background-color: rgba(0, 0, 0, 0.01);
1686
+ border: 1px solid var(--border-color);
1687
+ border-radius: 12px;
1688
+ padding: 16px;
1689
+ display: flex;
1690
+ flex-direction: column;
1691
+ gap: 8px;
1692
+ }
1693
+
1694
+ body.dark-mode .vlm-reason-box {
1695
+ background-color: rgba(255, 255, 255, 0.02);
1696
+ }
1697
+
1698
+ .vlm-reason-box h4 {
1699
+ font-size: 12px;
1700
+ font-weight: 700;
1701
+ color: var(--text-secondary);
1702
+ display: flex;
1703
+ align-items: center;
1704
+ gap: 6px;
1705
+ }
1706
+
1707
+ .vlm-reason-box p {
1708
+ font-size: 12px;
1709
+ line-height: 1.5;
1710
+ color: var(--text-secondary);
1711
+ }
1712
+
1713
+ /* 4. Report and ESG Tab Styles */
1714
+ .report-tab-selectors {
1715
+ display: flex;
1716
+ gap: 6px;
1717
+ }
1718
+
1719
+ .sub-tab-btn {
1720
+ background: rgba(0, 0, 0, 0.02);
1721
+ border: 1px solid var(--border-color);
1722
+ color: var(--text-secondary);
1723
+ padding: 6px 12px;
1724
+ font-size: 12px;
1725
+ border-radius: 6px;
1726
+ cursor: pointer;
1727
+ font-family: var(--font-outfit);
1728
+ }
1729
+
1730
+ body.dark-mode .sub-tab-btn {
1731
+ background: rgba(255, 255, 255, 0.03);
1732
+ }
1733
+
1734
+ .sub-tab-btn:hover {
1735
+ color: var(--text-primary);
1736
+ }
1737
+
1738
+ .sub-tab-btn.active {
1739
+ background: rgba(8, 145, 178, 0.08);
1740
+ color: var(--text-primary);
1741
+ border-color: rgba(8, 145, 178, 0.25);
1742
+ }
1743
+
1744
+ body.dark-mode .sub-tab-btn.active {
1745
+ background: rgba(6, 182, 212, 0.1);
1746
+ border-color: rgba(6, 182, 212, 0.3);
1747
+ }
1748
+
1749
+ .report-document-container {
1750
+ background-color: #f8fafc;
1751
+ border-radius: 12px;
1752
+ padding: 24px;
1753
+ color: #1e293b;
1754
+ box-shadow: 0 10px 25px rgba(31, 41, 55, 0.1);
1755
+ max-height: 520px;
1756
+ overflow-y: auto;
1757
+ }
1758
+
1759
+ body.dark-mode .report-document-container {
1760
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
1761
+ }
1762
+
1763
+ .report-paper {
1764
+ background-color: white;
1765
+ padding: 30px;
1766
+ border: 1px solid #e2e8f0;
1767
+ border-radius: 4px;
1768
+ font-family: 'Noto Sans KR', sans-serif;
1769
+ }
1770
+
1771
+ .report-paper-header {
1772
+ text-align: center;
1773
+ border-bottom: 2px double #475569;
1774
+ padding-bottom: 16px;
1775
+ margin-bottom: 20px;
1776
+ }
1777
+
1778
+ .report-paper-header h1 {
1779
+ font-size: 20px;
1780
+ font-weight: 700;
1781
+ letter-spacing: 4px;
1782
+ margin-bottom: 14px;
1783
+ color: #0f172a;
1784
+ }
1785
+
1786
+ .report-meta-grid {
1787
+ display: grid;
1788
+ grid-template-columns: 1fr 1fr;
1789
+ text-align: left;
1790
+ font-size: 11px;
1791
+ color: #475569;
1792
+ row-gap: 6px;
1793
+ }
1794
+
1795
+ .report-table {
1796
+ width: 100%;
1797
+ border-collapse: collapse;
1798
+ margin-top: 14px;
1799
+ margin-bottom: 20px;
1800
+ }
1801
+
1802
+ .report-table th, .report-table td {
1803
+ border: 1px solid #cbd5e1;
1804
+ padding: 10px;
1805
+ font-size: 11px;
1806
+ text-align: center;
1807
+ }
1808
+
1809
+ .report-table th {
1810
+ background-color: #f1f5f9;
1811
+ color: #1e293b;
1812
+ font-weight: 700;
1813
+ }
1814
+
1815
+ .report-table td.align-left {
1816
+ text-align: left;
1817
+ }
1818
+
1819
+ .report-paper-body p {
1820
+ font-size: 12px;
1821
+ line-height: 1.6;
1822
+ margin-bottom: 12px;
1823
+ }
1824
+
1825
+ .report-paper-footer {
1826
+ border-top: 1px solid #cbd5e1;
1827
+ padding-top: 16px;
1828
+ margin-top: 30px;
1829
+ font-size: 11px;
1830
+ color: #475569;
1831
+ }
1832
+
1833
+ .sign-area {
1834
+ display: flex;
1835
+ flex-direction: column;
1836
+ gap: 16px;
1837
+ }
1838
+
1839
+ .sign-seal {
1840
+ display: flex;
1841
+ justify-content: flex-end;
1842
+ align-items: center;
1843
+ gap: 12px;
1844
+ font-weight: 700;
1845
+ position: relative;
1846
+ }
1847
+
1848
+ .seal-stamp {
1849
+ width: 48px;
1850
+ height: 48px;
1851
+ border: 2px solid #ef4444;
1852
+ color: #ef4444;
1853
+ border-radius: 50%;
1854
+ font-size: 8px;
1855
+ display: flex;
1856
+ align-items: center;
1857
+ justify-content: center;
1858
+ transform: rotate(-15deg);
1859
+ font-weight: 800;
1860
+ letter-spacing: 0.5px;
1861
+ opacity: 0.8;
1862
+ }
1863
+
1864
+ .report-footer-tag {
1865
+ font-size: 9px;
1866
+ color: #94a3b8;
1867
+ text-align: center;
1868
+ margin-top: 20px;
1869
+ }
1870
+
1871
+ .padding-top-15 {
1872
+ padding-top: 15px;
1873
+ }
1874
+
1875
+ /* ESG metrics style page */
1876
+ .esg-metric-grid {
1877
+ display: grid;
1878
+ grid-template-columns: 1fr;
1879
+ gap: 16px;
1880
+ margin-bottom: 24px;
1881
+ }
1882
+
1883
+ .esg-metric-card {
1884
+ background-color: rgba(0, 0, 0, 0.01);
1885
+ border: 1px solid var(--border-color);
1886
+ border-radius: 12px;
1887
+ padding: 18px;
1888
+ display: flex;
1889
+ flex-direction: column;
1890
+ }
1891
+
1892
+ body.dark-mode .esg-metric-card {
1893
+ background-color: rgba(255, 255, 255, 0.02);
1894
+ }
1895
+
1896
+ .esg-val {
1897
+ font-size: 22px;
1898
+ font-weight: 800;
1899
+ }
1900
+
1901
+ .esg-lbl {
1902
+ font-size: 12px;
1903
+ font-weight: 600;
1904
+ margin: 4px 0;
1905
+ }
1906
+
1907
+ .esg-sub {
1908
+ font-size: 10px;
1909
+ color: var(--text-secondary);
1910
+ }
1911
+
1912
+ .esg-chart-box {
1913
+ background-color: rgba(0, 0, 0, 0.01);
1914
+ border: 1px solid var(--border-color);
1915
+ border-radius: 12px;
1916
+ padding: 16px;
1917
+ height: 180px;
1918
+ position: relative;
1919
+ }
1920
+
1921
+ body.dark-mode .esg-chart-box {
1922
+ background-color: rgba(255, 255, 255, 0.02);
1923
+ }
1924
+
1925
+ /* Password Input field for OpenRouter API */
1926
+ .password-input-wrapper {
1927
+ display: flex;
1928
+ position: relative;
1929
+ width: 100%;
1930
+ }
1931
+
1932
+ .password-input-wrapper input {
1933
+ width: 100%;
1934
+ padding-right: 46px !important;
1935
+ }
1936
+
1937
+ .password-input-wrapper button {
1938
+ position: absolute;
1939
+ right: 5px;
1940
+ top: 50%;
1941
+ transform: translateY(-50%);
1942
+ background: none;
1943
+ border: none;
1944
+ color: var(--text-secondary);
1945
+ cursor: pointer;
1946
+ font-size: 14px;
1947
+ padding: 8px;
1948
+ }
1949
+
1950
+ .password-input-wrapper button:hover {
1951
+ color: var(--text-primary);
1952
+ }
1953
+
1954
+ /* Modal styles */
1955
+ .modal {
1956
+ position: fixed;
1957
+ top: 0;
1958
+ left: 0;
1959
+ width: 100vw;
1960
+ height: 100vh;
1961
+ background-color: rgba(0, 0, 0, 0.4);
1962
+ backdrop-filter: blur(4px);
1963
+ display: flex;
1964
+ align-items: center;
1965
+ justify-content: center;
1966
+ z-index: 100;
1967
+ animation: fadeIn 0.2s ease-out;
1968
+ }
1969
+
1970
+ body.dark-mode .modal {
1971
+ background-color: rgba(0, 0, 0, 0.6);
1972
+ }
1973
+
1974
+ .modal-content {
1975
+ width: 480px;
1976
+ max-width: 90%;
1977
+ padding: 30px;
1978
+ background: #ffffff;
1979
+ color: #111827;
1980
+ }
1981
+
1982
+ body.dark-mode .modal-content {
1983
+ background: var(--bg-card);
1984
+ color: var(--text-primary);
1985
+ }
1986
+
1987
+ .modal-header {
1988
+ display: flex;
1989
+ align-items: center;
1990
+ justify-content: space-between;
1991
+ margin-bottom: 24px;
1992
+ }
1993
+
1994
+ .modal-header h3 {
1995
+ font-size: 16px;
1996
+ font-weight: 700;
1997
+ }
1998
+
1999
+ .close-modal-btn {
2000
+ font-size: 24px;
2001
+ cursor: pointer;
2002
+ color: var(--text-secondary);
2003
+ transition: color 0.2s;
2004
+ }
2005
+
2006
+ .close-modal-btn:hover {
2007
+ color: var(--text-primary);
2008
+ }
2009
+
2010
+ .member-form {
2011
+ display: flex;
2012
+ flex-direction: column;
2013
+ gap: 16px;
2014
+ }
2015
+
2016
+ .form-group {
2017
+ display: flex;
2018
+ flex-direction: column;
2019
+ gap: 6px;
2020
+ margin-bottom: 16px;
2021
+ }
2022
+
2023
+ .form-group label {
2024
+ font-size: 12px;
2025
+ color: var(--text-secondary);
2026
+ font-weight: 600;
2027
+ }
2028
+
2029
+ .form-group input, .form-group select, .form-group textarea {
2030
+ background-color: rgba(0, 0, 0, 0.02);
2031
+ border: 1px solid var(--border-color);
2032
+ border-radius: 8px;
2033
+ color: var(--text-primary);
2034
+ padding: 10px 14px;
2035
+ font-family: var(--font-outfit);
2036
+ font-size: 13px;
2037
+ outline: none;
2038
+ transition: border-color 0.2s;
2039
+ }
2040
+
2041
+ body.dark-mode .form-group input, body.dark-mode .form-group select, body.dark-mode .form-group textarea {
2042
+ background-color: rgba(0, 0, 0, 0.2);
2043
+ }
2044
+
2045
+ .form-group input:focus, .form-group select:focus, .form-group textarea:focus {
2046
+ border-color: var(--color-blue);
2047
+ }
2048
+
2049
+ .form-group textarea {
2050
+ height: 80px;
2051
+ resize: none;
2052
+ }
2053
+
2054
+ .modal-info-text {
2055
+ font-size: 12px;
2056
+ color: var(--text-secondary);
2057
+ line-height: 1.5;
2058
+ margin-bottom: 20px;
2059
+ }
2060
+
2061
+ /* Animations */
2062
+ @keyframes fadeIn {
2063
+ from { opacity: 0; transform: translateY(10px); }
2064
+ to { opacity: 1; transform: translateY(0); }
2065
+ }
2066
+
2067
+ @keyframes spin {
2068
+ 0% { transform: rotate(0deg); }
2069
+ 100% { transform: rotate(360deg); }
2070
+ }
2071
+
2072
+ @keyframes flash {
2073
+ 0%, 100% { opacity: 1; }
2074
+ 50% { opacity: 0.4; }
2075
+ }
2076
+
2077
+ .animate-bounce {
2078
+ animation: bounce 2s infinite;
2079
+ }
2080
+
2081
+ @keyframes bounce {
2082
+ 0%, 100% { transform: translateY(0); }
2083
+ 50% { transform: translateY(-5px); }
2084
+ }
2085
+
2086
+ /* Toast Notifications */
2087
+ .toast-notification {
2088
+ position: fixed;
2089
+ bottom: 30px;
2090
+ right: 30px;
2091
+ z-index: 1000;
2092
+ display: flex;
2093
+ align-items: center;
2094
+ gap: 12px;
2095
+ padding: 16px 24px;
2096
+ background: #ffffff !important;
2097
+ border: 1px solid rgba(37, 99, 235, 0.3) !important;
2098
+ color: #111827;
2099
+ transform: translateY(100px);
2100
+ opacity: 0;
2101
+ transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
2102
+ }
2103
+
2104
+ body.dark-mode .toast-notification {
2105
+ background: rgba(15, 23, 42, 0.95) !important;
2106
+ border: 1px solid rgba(59, 130, 246, 0.4) !important;
2107
+ color: white;
2108
+ }
2109
+
2110
+ .toast-notification.show {
2111
+ transform: translateY(0);
2112
+ opacity: 1;
2113
+ }
2114
+
2115
+ /* Print CSS Styles */
2116
+ @media print {
2117
+ body * {
2118
+ visibility: hidden;
2119
+ }
2120
+ #report-print-area, #report-print-area * {
2121
+ visibility: visible;
2122
+ }
2123
+ #report-print-area {
2124
+ position: absolute;
2125
+ left: 0;
2126
+ top: 0;
2127
+ width: 100%;
2128
+ background-color: white !important;
2129
+ box-shadow: none;
2130
+ padding: 0;
2131
+ color: black !important;
2132
+ max-height: none;
2133
+ overflow: visible;
2134
+ }
2135
+ .report-paper {
2136
+ border: none;
2137
+ padding: 0;
2138
+ }
2139
+ }