센터 프로그램  ↗
ABOUT HARUE+

HARUE+ 플랫폼 소개

AI와 데이터로
시니어 라이프케어의
새로운 운영 기준을 만듭니다

HARUE+ Care OS 2.6은 라이프케어센터의 회원, 상담, 프로그램, 일정, 직원, 급식, 차량과 운영 기록을 하나의 흐름으로 연결하는 통합 운영 플랫폼입니다.

통합 운영 관리센터의 주요 업무를 한곳에서 확인
현장 데이터 활용운영 기록을 서비스 개선에 연결
쉽고 친절한 사용복잡한 기능보다 현장 사용성을 우선
시니어와 케어 종사자가 함께하는 모습
CARE OS 2.6

센터 운영 화면 미리보기

실제 운영 화면의 전체 분위기를 확인할 수 있는 이미지입니다. GitHub의 이미지 파일만 교체하면 언제든 새 화면으로 변경할 수 있습니다.

로그인으로 이동 ↑
HARUE+ Care OS 2.6 프로그램 메인 화면 예시
시니어센터 운영·직원·회원·급식·일정을 하나로 연결하는 AI 운영 플랫폼
근무자를 선택하세요 ⌄

오늘의 센터

센터장과 직원이 출근 후 가장 먼저 확인하는 통합 운영 화면입니다.

2026.08.20 · 목요일
Care OS 2.6모바일 현장업무AI 업무보조기존 데이터 유지

AI 오늘 브리핑

출결·상담·일정·프로그램·급식 데이터를 한 번에 확인해 오늘 해야 할 일을 정리합니다.

오늘 확인할 일

주간 이용률

빠른 실행

회원관리

기본정보·가족·장기요양등급·프로그램 참여정보를 하나의 회원카드로 관리합니다.

회원연령이용상태장기요양등급주요관심보호자최근방문

상담 CRM

문의→상담→체험→등록→후속관리까지 전환 과정을 관리합니다.

상담 등록

AI 상담요약 예시

긴 상담기록을 직원이 검수 가능한 구조로 요약합니다.

• 관심: 걷기·스마트폰 교육
• 이용 가능: 화·목 오후
• 보호자 요청: 프로그램 참여상황 월 1회 공유
• 다음 행동: 체험수업 예약 안내

최근 상담 목록

상태를 바로 변경할 수 있습니다
이름연락처관심서비스상태담당자메모등록일

출결·송영

입실·귀가·차량·보호자 확인을 한 화면에서 관리합니다. (오늘 이용중 회원 기준)

📱 회원 빠른 입실 · 귀가

공용 PC가 없어도 직원 휴대폰으로 회원 QR/NFC 또는 등록된 회원 휴대폰을 확인해 입실·귀가를 처리합니다. Bluetooth는 회원 본인확정보다 센터·차량 근접 확인용으로 쓰는 것을 권장합니다.

입실/귀가 방식 선택 또는 회원번호를 입력하세요.

카드·QR 스캔 체크인

회원카드를 태그하거나 QR을 스캔하면 리더기가 입력값을 그대로 넣어줍니다. 카드가 없다면 회원번호(예: C0001)를 직접 입력해도 됩니다.

회원입실귀가예정송영상태처리

건강·관찰기록

의료진단이 아니라 센터 생활 중 필요한 관찰·생활기록을 시간대별로 안전하게 관리합니다.

관찰기록 작성

근무자를 선택하세요
원칙: AI는 기록 정리와 초안 작성을 돕고, 건강 관련 판단은 담당자와 적절한 전문가가 최종 확인합니다. AI 초안은 반드시 검수 후 저장하세요.

오늘의 시간대별 기록

이전 기록

프로그램 관리

계획·참여·출석체크를 표준 프로그램팩으로 축적합니다. 카드를 클릭하면 출석부를 열 수 있습니다.

프로그램 QR 출석

프로그램 선택 후 회원 QR/회원번호를 스캔하면 한 번에 출석 처리합니다.

오늘의 프로그램 일정

오늘 요일에 해당하는 프로그램만 표시됩니다. 출결에서 입실 확인된 등록 인원을 기준으로 참여기록을 자동 생성할 수 있습니다.

보호자 리포트

오늘의 활동과 월간 변화를 이해하기 쉬운 리포트로 공유합니다.

보호자 메시지 예약발송

오늘의 하루

왼쪽에서 회원을 선택하고 ‘리포트 생성’을 눌러주세요.

월간 리포트 항목

출석월 이용일수·시간
활동프로그램 참여·관심변화
직원 메모보호자에게 필요한 핵심내용만 검수 후 공유

회원 QR 카드

회원카드를 휴대폰으로 보여주거나 인쇄해 출결·프로그램 참여를 빠르게 처리합니다.

회원 선택

회원

HARUE+ Care OS회원카드
👤
회원
실제 서비스에서는 회원별 암호화 토큰을 사용하고 개인정보를 QR 안에 직접 넣지 않습니다.

WalkCare · 낙상위험 체크

보행과 균형 상태를 정기적으로 기록하고 변화 추이를 확인합니다. 의료진단이 아닌 생활 웰니스·안전관리용입니다.

낙상 위험 스크리닝

0
검사값을 입력하고 점수를 계산하세요.
이 점수는 Care OS 데모용 내부 스크리닝이며 의학적 진단 기준이 아닙니다.

최근 WalkCare 기록

AI 식판 촬영

휴대폰으로 식판을 촬영해 메뉴와 섭취·잔반 상태를 기록하는 시연 기능입니다.

식판 사진을 촬영하거나 선택하세요.

AI 분석 결과

메뉴사진 분석 대기
섭취량-
잔반-
주의-

최근 식사 사진 기록

직원 메신저

회원·업무 중심으로 소통하고, 중요한 메시지는 생활기록·보호자요청·인수인계로 바로 저장합니다.

0읽지 않은 메시지
0중요·긴급 메시지
0회원 태그
0오늘 메시지

대화방

전체 직원

센터 내부 업무소통

AI 소통 도우미

아직 요약이 없습니다.

회원 관심 · 전달사항

회원별 관심사, 주의사항, 오늘 전달할 내용, 최근 변화를 직원이 빠르게 확인합니다.

회원별 반복 스케줄

인수인계

교대 근무자가 꼭 알아야 할 회원·업무 사항만 분리해 확인합니다.

확인 필요

확인 완료

시간대별 알림

식사·복약 확인·송영·상담·프로그램·보호자 연락 등 반복되는 업무를 시간에 맞춰 알려줍니다.

빠른 알림 등록

오늘 알림

반복 알림

공지 · 긴급알림

전체 직원에게 꼭 공유해야 할 공지와 긴급사항을 분리합니다.

케어플랜

회원별 개별 서비스 계획(목표·제공 서비스·재검토일)을 관리합니다.

케어플랜 작성

회원별 케어플랜 목록

일정관리

월간 달력에서 프로그램·상담·행사·휴무일을 한눈에 보고, 날짜를 누르면 바로 일정을 등록할 수 있습니다.

● 대한민국 공휴일 자동 표시
공휴일·휴무 센터 일정 프로그램
일
월
화
수
목
금
토
공휴일은 2026년 공식 월력요항과 2026년 개정 공휴일 기준을 반영해 자동 표시합니다. 실제 상용 서비스에서는 공공데이터포털 특일정보 API 연동으로 매년 자동 갱신하는 구조를 권장합니다.

복지정보 허브

정부·서울시·구청·복지기관의 정책, 무료지원, 행사, 모집, 공모·요청사항을 지역과 대상별로 한곳에서 확인합니다.

우리 센터 지역 브리핑

서울특별시 · 도봉구 기준으로 센터 운영과 회원에게 도움이 될 가능성이 높은 정보를 우선 표시합니다.

연동 정보원

실서비스: API/RSS/공식 게시판 수집
복지로복지서비스·지원제도DEMO 연동
서울복지포털서울형 복지·돌봄·시설DEMO 연동
도봉구청지역 공고·행사·모집DEMO 연동
서울시50플러스일자리·교육·행사DEMO 연동
보건복지부정책·사업·지침DEMO 연동
공공데이터오픈API·특일·복지 데이터연동 예정

AI 복지 브리핑

새로 들어온 공고를 센터운영 / 회원혜택 / 직원교육 / 무료지원 / 행사로 자동 분류하는 구조입니다.

새로고침 후 AI가 중요도를 정리합니다.

급식관리

기존 테스트 식단을 그대로 유지하면서 주간·월간 식단표, AI 초안, 특이식이, 검식·보존식까지 관리합니다.

🤖 AI 식단 도우미

기존에 입력된 식단은 보존합니다. ‘빈 날짜만 채우기’를 사용하면 등록된 메뉴는 건드리지 않고 비어 있는 날만 생성합니다.

기존 mealPlan 데이터는 자동으로 날짜형 식단으로 변환해 표시합니다.
입력 후 반드시 ‘현재 식단 저장’을 눌러주세요.

오늘 특이식이 확인

회원의 메모·주의사항에서 식이 관련 키워드를 찾아 표시합니다. 실제 운영에서는 알레르기 항목을 별도 필드로 관리하는 것이 좋습니다.

급식 위생 체크

후원관리

후원자·후원금 현황을 관리하고 영수증 발급 대상을 확인합니다.

후원자 등록

후원자 목록

후원자구분방식누적후원금최근후원일

문서서식

운영에 필요한 표준 서식을 회원·후원자 데이터로 자동 채워 인쇄용으로 생성합니다.

회원 등록카드

기본정보·보호자·장기요양등급을 포함한 회원카드를 출력합니다.

장기요양 급여제공기록지

이번 달 이용일수·프로그램 참여내역을 기준으로 작성합니다.

상담일지

상담 CRM에 등록된 내용을 상담일지 형식으로 출력합니다.

후원금 영수증

선택한 후원자의 누적 후원 내역으로 영수증을 생성합니다.

프로그램 참가신청서

서명란이 포함된 공백 신청서 서식을 출력합니다.

차량 · 송영

차량별 탑승자 명단 → 회원 QR/NFC 승차 인증 → 차량 BLE 근접확인 → 센터 도착 일괄 입실 → 보호자 도착 알림까지 한 흐름으로 처리합니다.

1. 차량별 탑승자차량·기사·회원 배정
2. 승차 QR회원 QR/번호로 승차 확인
3. 센터 도착탑승 완료 회원 일괄 입실
4. 보호자 알림도착·귀가 알림 발송

오늘 차량 운행

차량을 선택하면 탑승자 명단을 확인합니다.

승차 QR / 회원번호

차량을 선택한 뒤 회원 QR이나 회원번호를 스캔하면 승차 상태가 바로 변경됩니다.

대기 중

일괄 처리

보호자 알림 이력

인사관리

직원 근태와 자격증·보수교육 이수현황을 관리합니다. 직원은 개인 휴대폰으로 QR·Bluetooth·직접 버튼을 이용해 출퇴근을 기록할 수 있습니다.

📱 모바일 출·퇴근

센터 공용 PC가 없어도 직원 본인 휴대폰에서 처리합니다. 권장 운영은 ‘센터 QR + 선택적 Bluetooth 근접확인’입니다.

상단에서 근무자를 선택한 뒤 출·퇴근 방식을 선택하세요.
센터 QR 값: HARUE-CAREOS-CENTER-001 · 실제 운영에서는 센터별 고유 QR과 GPS/비콘을 조합하는 것을 권장합니다.
직원역할자격증보수교육(시간)오늘 근태처리

기기 · 인증관리

직원 휴대폰, 회원 QR/NFC 카드, 센터·차량 BLE 비콘을 한곳에서 등록하고 인증방식을 관리합니다.

권장 인증 구조

1. 사람 등록직원·회원 정보
2. 기기/카드 연결휴대폰·QR·NFC
3. 장소 인증센터/차량 BLE
4. 업무 처리출근·입실·송영

BLE 비콘 등록은 이렇게 합니다

전용 기기를 직접 만들 필요 없이, 처음에는 기성 BLE Beacon을 센터 입구와 차량에 설치하는 방식이 가장 현실적입니다.

  1. 기성 BLE 비콘의 전원을 켭니다.
  2. 관리자 휴대폰에서 제조사 앱으로 UUID/Major/Minor 또는 기기 ID를 확인합니다.
  3. Care OS의 ‘비콘 등록’에서 센터 입구/차량을 선택하고 식별값을 저장합니다.
  4. 직원 휴대폰을 1회 등록해 본인 기기로 승인합니다.
  5. 출근 시 휴대폰이 센터 비콘 근처인지 확인한 뒤 ‘출근’ 버튼을 누릅니다.

직원 휴대폰 등록

첫 로그인 때 직원과 휴대폰을 1:1 또는 승인기기 목록으로 연결합니다.

회원 QR / NFC 등록

시니어 회원은 앱 설치를 강요하지 않고 QR 카드나 NFC 카드를 기본 인증수단으로 권장합니다.

센터 · 차량 BLE 비콘

비콘은 ‘누가’가 아니라 ‘어디에 있는지’를 확인하는 근접 인증용으로 사용합니다.

인증 적용표

업무권장 기본보조 인증
직원 출근/퇴근등록 휴대폰 + BLEGPS/Wi-Fi
회원 입실/귀가QR/NFC 카드회원 휴대폰
차량 승차/하차회원 QR/NFC차량 BLE
센터 도착 일괄입실차량 탑승명단센터 BLE
Bluetooth 신호만으로 출근을 자동 확정하지 않고, ‘등록된 직원 휴대폰 + 센터 BLE 감지 + 출근 버튼’ 조합을 기본으로 두는 것이 좋습니다.

추후 하드웨어 구매 가이드

BLE Beacon

센터 입구·차량 위치 확인용. 배터리형 또는 USB 전원형.

MVP부터
NFC 카드/태그

회원 지갑카드·목걸이형 카드에 적합. QR과 병행.

2단계
NFC 지원 태블릿/키오스크

센터 입구에서 회원이 직접 태그하는 공용 출결 단말기.

센터 확장 후
차량 GPS/통신 단말

노선·ETA·실시간 위치까지 제공할 때 도입.

네트워크 확장 후

안전관리

사고·민원을 접수하고 처리 현황을 추적합니다.

사고·민원 접수

최근 접수 목록

유형대상제목상태담당자접수일

품질관리

기관평가 대비 자가점검 체크리스트입니다. 항목을 체크하면 이행률이 자동 계산됩니다.

AI 업무비서

직원을 대체하는 AI가 아니라, 반복 업무를 줄이고 누락을 발견하는 보조도구입니다. 실제 등록된 데이터를 바탕으로 답합니다.

HARUE+ Care Assistant

예: “오늘 미응답 상담 알려줘”, “회원 최근 참여기록 요약해줘”, “이번 달 프로그램 참여율 정리해줘”

질문을 입력하면 실제 등록된 데이터를 기반으로 시연 결과가 표시됩니다.

낙상·결석 등 위험 신호 감지

건강 관찰기록·출석·월간 이용현황을 근거로 담당자 확인이 필요한 회원을 자동으로 찾아냅니다.

상담일지 초안

급여기록 비고란 초안

생성된 초안

왼쪽에서 대상을 선택하고 초안 생성을 눌러주세요. 초안은 직원 검수 후 실제 기록에 사용하세요.

보호자 안내문 초안

회원을 선택하면 오늘 활동을 바탕으로 문자 안내문 초안을 만들어 드립니다.

보호자 챗봇 미리보기

보호자가 직접 물어볼 법한 질문에 자동 응답하는 챗봇 시연입니다.

질문을 입력해보세요.

프로그램·직원 배치 운영 최적화

프로그램별 정원 대비 참여율과 직원 1인당 회원 비율을 기준으로 운영 개선점을 제안합니다.

회원의 관심분야와 현재 정원 여유를 기준으로 적합한 프로그램을 추천합니다. 신규 상담·상담중 회원에게 특히 유용합니다.

기록 요약

상담·일지·프로그램 기록 핵심요약

누락 체크

미응답·미작성·미확인 업무 탐지

리포트 초안

보호자·센터장용 보고서 초안 생성

청구·정산

장기요양등급 보유 회원의 이용일수를 기준으로 급여비용·본인부담금·공단청구액을 계산합니다.

회원등급이번달 이용일수일일 단가(예시)급여비용 합계본인부담(15%)공단청구액(85%)청구상태
실제 급여비용·본인부담률은 국민건강보험공단 장기요양보험 수가·등급 기준에 따라 달라지며, 이 화면의 단가와 계산식은 데모용 예시입니다. 실 서비스에서는 공단 청구시스템(요양보험 EDI) 연동이 필요합니다.

KPI·본사 리포트

센터 수가 늘어나도 같은 정의의 숫자로 운영품질과 수익성을 비교합니다.

본사 관리 기준

이용률 · 상담전환 · 유지율 · 안전 · 직원이직 · 프로그램품질 · 고객만족 · 점포수익성을 동일 기준으로 관리합니다. 이 화면의 이용률·상담전환·프로그램참여는 현재 입력된 데모 데이터로 실시간 계산됩니다.

본사 대시보드

직영센터와 파트너센터를 같은 기준으로 비교해 운영 품질과 이상 신호를 빠르게 확인합니다.

운영센터4직영 1 · 파트너 3
전체 회원326전월 +18
평균 이용률84%목표 85%
안전 알림2확인 필요

센터 비교

센터회원이용률상담전환프로그램 참여품질
HARUE+ 도봉12688%46%91%A
파트너 강북7482%41%87%A
파트너 노원6880%39%84%B
파트너 성북5885%44%89%A

설정

센터 기본정보, 사용자 권한, 알림, 데이터 관리 기준을 설정합니다.

센터 기본정보

사용자·권한

실제 서비스에서는 개인정보 최소수집, 역할별 접근권한, 접속로그, 데이터 보관주기, 백업·복구 정책을 별도로 적용합니다.

데이터 백업·이전

현재 브라우저의 전체 데이터를 파일로 저장하거나, 백업 파일을 불러올 수 있습니다.

데모 데이터

브라우저에 저장된 시연 데이터를 초기 상태로 되돌립니다.

데이터는 이 브라우저에만 저장됩니다. 여러 직원이 함께 쓰는 실제 운영에는 서버 DB(예: Supabase) 연동이 필요합니다 — 필요하시면 이어서 설계해드릴 수 있습니다.
`); w.document.close(); } /* ============ CAREPLAN ============ */ function renderCarePlans(){ fillMemberSelects(); const list=DB.carePlans.slice().sort((a,b)=>b.updatedAt.localeCompare(a.updatedAt)); document.getElementById('carePlanList').innerHTML=list.length?list.map(p=>{ const m=memberById(p.memberId); return `
${esc(m?m.name:'삭제된 회원')}재검토 예정 ${esc(p.review)}
목표: ${esc(p.goal)}
작성 ${esc(p.writtenBy)} · ${esc(p.updatedAt)}
`; }).join(''):'
등록된 케어플랜이 없습니다.
'; } function saveCarePlan(){ const memberId=document.getElementById('cpMember').value; const goal=document.getElementById('cpGoal').value.trim(); const services=document.getElementById('cpServices').value.trim(); if(!currentStaffId){toast('상단에서 작성자(근무자)를 먼저 선택해주세요.');return;} if(!memberId||!goal){toast('회원과 돌봄 목표를 입력해주세요.');return;} DB.carePlans.unshift({id:uid('cp'),memberId,goal,services,review:document.getElementById('cpReview').value||addDaysStr(30),writtenBy:currentStaffName(),updatedAt:daysAgoLabel(0)}); saveDB(); document.getElementById('cpGoal').value=''; document.getElementById('cpServices').value=''; document.getElementById('cpReview').value=''; renderCarePlans(); toast('케어플랜을 저장했습니다.'); } function deleteCarePlan(id){ DB.carePlans=DB.carePlans.filter(p=>p.id!==id); saveDB(); renderCarePlans(); } /* ============ SCHEDULE ============ */ let calendarCursor=new Date(); calendarCursor.setDate(1); let selectedScheduleDate=todayStr(); const KR_HOLIDAYS_2026={ '2026-01-01':'신정', '2026-02-16':'설날 연휴','2026-02-17':'설날','2026-02-18':'설날 연휴', '2026-03-01':'삼일절','2026-03-02':'삼일절 대체공휴일', '2026-05-01':'노동절','2026-05-05':'어린이날', '2026-05-24':'부처님오신날','2026-05-25':'부처님오신날 대체공휴일', '2026-06-03':'전국동시지방선거','2026-06-06':'현충일', '2026-07-17':'제헌절', '2026-08-15':'광복절','2026-08-17':'광복절 대체공휴일', '2026-09-24':'추석 연휴','2026-09-25':'추석','2026-09-26':'추석 연휴', '2026-10-03':'개천절','2026-10-05':'개천절 대체공휴일', '2026-10-09':'한글날','2026-12-25':'기독탄신일' }; const FIXED_KR_HOLIDAYS={ '01-01':'신정','03-01':'삼일절','05-01':'노동절','05-05':'어린이날', '06-06':'현충일','07-17':'제헌절','08-15':'광복절','10-03':'개천절','10-09':'한글날','12-25':'기독탄신일' }; function localDateStr(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function holidayName(ds){ if(KR_HOLIDAYS_2026[ds])return KR_HOLIDAYS_2026[ds]; const y=Number(ds.slice(0,4)), md=ds.slice(5); // 2026 이후 법정 고정 공휴일은 반복 표시. 음력 명절·대체공휴일은 실서비스에서 특일정보 API 연동 권장. if(y>=2026 && FIXED_KR_HOLIDAYS[md])return FIXED_KR_HOLIDAYS[md]; return ''; } function calendarProgramEvents(ds){ const d=new Date(ds+'T12:00:00'), wd=WEEKDAY_KO[d.getDay()]; return DB.programs.filter(p=>(p.days||[]).includes(wd)).map(p=>({title:p.name,time:p.time||'',type:'program'})); } function calendarConsultEvents(ds){ if(ds!==todayStr())return []; return DB.consultations.filter(c=>c.status==='신규'||c.status==='체험예약').map(c=>({title:`상담 후속 · ${c.name}`,time:'',type:'cons'})); } function renderSchedule(){ const y=calendarCursor.getFullYear(), m=calendarCursor.getMonth(); document.getElementById('calendarTitle').textContent=`${y}년 ${m+1}월`; const first=new Date(y,m,1), start=new Date(y,m,1-first.getDay()); let cells=''; for(let i=0;i<42;i++){ const d=new Date(start); d.setDate(start.getDate()+i); const ds=localDateStr(d), holiday=holidayName(ds), other=d.getMonth()!==m; const evs=DB.events.filter(e=>e.date===ds).slice().sort((a,b)=>(a.time||'').localeCompare(b.time||'')); const pEvs=calendarProgramEvents(ds), cEvs=calendarConsultEvents(ds); const all=[ ...evs.map(e=>({title:e.title,time:e.time||'',type:e.type==='휴무'?'closed':'event',id:e.id})), ...pEvs,...cEvs ]; const shown=all.slice(0,4), more=all.length-shown.length; const classes=['cal-day',other?'other':'',ds===todayStr()?'today':'',d.getDay()===0?'sunday':'',d.getDay()===6?'saturday':'',holiday?'holiday':''].filter(Boolean).join(' '); cells+=`
${d.getDate()}${ds===todayStr()?'오늘':''}
${holiday?`
${esc(holiday)}
`:''} ${shown.map(e=>`
${e.time&&e.time!=='종일'?esc(e.time)+' ':''}${esc(e.title)}
`).join('')} ${more>0?`
+${more}개 더보기
`:''}
`; } document.getElementById('scheduleGrid').innerHTML=cells; renderSelectedDayEvents(); renderUpcomingEvents(); updateQuickDateDisplay(); } function moveCalendar(delta){calendarCursor.setMonth(calendarCursor.getMonth()+delta);calendarCursor.setDate(1);renderSchedule();} function goCalendarToday(){calendarCursor=new Date();calendarCursor.setDate(1);selectedScheduleDate=todayStr();document.getElementById('evDate').value=selectedScheduleDate;renderSchedule();} function selectCalendarDate(ds,focusTitle=false){ selectedScheduleDate=ds; document.getElementById('evDate').value=ds; updateQuickDateDisplay();renderSelectedDayEvents(); if(focusTitle){document.getElementById('evTitle').focus();} } function syncSelectedDate(){selectedScheduleDate=document.getElementById('evDate').value||todayStr();updateQuickDateDisplay();renderSelectedDayEvents();} function updateQuickDateDisplay(){ const box=document.getElementById('quickDateDisplay'); if(!box)return; const d=new Date((selectedScheduleDate||todayStr())+'T12:00:00'), h=holidayName(selectedScheduleDate||todayStr()); box.textContent=`${d.getMonth()+1}월 ${d.getDate()}일 ${WEEKDAY_KO[d.getDay()]}요일${h?' · '+h:''}`; } function quickSchedulePreset(type){ document.getElementById('evType').value=type; const defaults={회의:'직원 회의',상담:'보호자 상담',교육:'직원 교육',점검:'시설 안전점검',휴무:'센터 휴무'}; if(!document.getElementById('evTitle').value)document.getElementById('evTitle').value=defaults[type]||type; document.getElementById('evTitle').focus(); } function renderSelectedDayEvents(){ const box=document.getElementById('selectedDayEvents'); if(!box)return; const ds=selectedScheduleDate||todayStr(), events=DB.events.filter(e=>e.date===ds).slice().sort((a,b)=>(a.time||'').localeCompare(b.time||'')); const holiday=holidayName(ds); let items=holiday?`
${esc(holiday)}대한민국 공휴일
`:''; items+=events.map(e=>`
${esc(e.title)}${esc(e.type)} · ${esc(e.time||'종일')}${e.memo?' · '+esc(e.memo):''}
`).join(''); box.innerHTML=items||'
등록된 일정이 없습니다.
'; } function renderUpcomingEvents(){ const box=document.getElementById('upcomingEvents'); if(!box)return; const t=todayStr(), upcoming=DB.events.filter(e=>e.date>=t).slice().sort((a,b)=>(a.date+a.time).localeCompare(b.date+b.time)).slice(0,6); box.innerHTML=upcoming.length?upcoming.map(e=>`
${e.date.slice(5).replace('-','/')} · ${esc(e.title)}${esc(e.type)} · ${esc(e.time||'종일')}
`).join(''):'
다가오는 일정이 없습니다.
'; } function deleteEvent(id){DB.events=DB.events.filter(e=>e.id!==id);saveDB();renderSchedule();toast('일정을 삭제했습니다.');} function addEvent(){ const title=document.getElementById('evTitle').value.trim(), date=document.getElementById('evDate').value; if(!title||!date){toast('제목과 날짜를 입력해주세요.');return;} const time=document.getElementById('evTime').value||'종일',type=document.getElementById('evType').value,memo=document.getElementById('evMemo').value.trim(),repeat=document.getElementById('evRepeat').value; const add=(ds)=>DB.events.push({id:uid('ev'),title,date:ds,time,type,memo}); add(date); if(repeat){ let d=new Date(date+'T12:00:00'); for(let i=1;i<=11;i++){ const n=new Date(d); if(repeat==='weekly')n.setDate(n.getDate()+7*i); else n.setMonth(n.getMonth()+i); add(localDateStr(n)); } } saveDB(); document.getElementById('evTitle').value='';document.getElementById('evTime').value='';document.getElementById('evMemo').value='';document.getElementById('evRepeat').value=''; selectedScheduleDate=date;calendarCursor=new Date(date+'T12:00:00');calendarCursor.setDate(1); renderSchedule();toast(repeat?'반복 일정을 등록했습니다.':'일정을 추가했습니다.'); } /* ============ MEAL ============ */ /* V11 핵심: - STORE_KEY='careos_demo_v1'을 그대로 사용하므로 기존 테스트 데이터 유지 - 기존 DB.mealPlan(요일형)을 현재 주 DB.mealCalendar(날짜형)로 '복사'만 함 - 기존 mealPlan은 삭제하지 않고 호환용으로 계속 동기화 */ let mealViewModeV11='week'; let mealCursorV11=new Date(); mealCursorV11.setHours(12,0,0,0); function mealStartWeekV11(date){ const d=new Date(date); d.setHours(12,0,0,0); const diff=d.getDay()===0?-6:1-d.getDay(); d.setDate(d.getDate()+diff); return d; } function mealDateStrV11(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function ensureMealStoreV11(){ if(!DB.mealPlan)DB.mealPlan={'월':'','화':'','수':'','목':'','금':'','토':'','일':''}; if(!DB.mealCalendar)DB.mealCalendar={}; if(!DB.mealChecks)DB.mealChecks={}; // 기존 테스트 mealPlan을 현재 주 날짜에 옮김. 이미 날짜형 식단이 있으면 절대 덮어쓰지 않음. const st=mealStartWeekV11(new Date()); for(let i=0;i<7;i++){ const d=new Date(st); d.setDate(st.getDate()+i); const ds=mealDateStrV11(d), day=WEEKDAY_KO[d.getDay()]; if(!DB.mealCalendar[ds]){ DB.mealCalendar[ds]={lunch:DB.mealPlan[day]||'',snack:'',note:'',count:''}; }else{ DB.mealCalendar[ds].lunch=DB.mealCalendar[ds].lunch||''; DB.mealCalendar[ds].snack=DB.mealCalendar[ds].snack||''; DB.mealCalendar[ds].note=DB.mealCalendar[ds].note||''; } } } function mealEntryV11(ds){ ensureMealStoreV11(); if(!DB.mealCalendar[ds])DB.mealCalendar[ds]={lunch:'',snack:'',note:'',count:''}; return DB.mealCalendar[ds]; } function mealSpecialMembersV11(){ return (DB.members||[]).filter(m=>{ const s=((m.caution||'')+' '+(m.memo||'')).toLowerCase(); return /식|알레르|저염|당뇨|연하|죽|치아|금식|영양/.test(s); }); } function renderMeal(){ ensureMealStoreV11(); const at=todayAttendance(), count=Object.values(at).filter(r=>r.checkIn).length; const today=mealEntryV11(todayStr()), special=mealSpecialMembersV11(); document.getElementById('mealKpis').innerHTML=`
오늘 급식 예정 인원${activeMembers().length}이용중 회원 기준
오늘 입실(확정)${count}출결 자동연동
특이 식이 회원${special.length}회원 메모·주의사항
오늘 점심${esc(today.lunch||'미등록')}
`; renderMealPeriodV11(); renderMealSpecialV11(); loadMealChecksV11(); } function mealViewV11(mode){ mealViewModeV11=mode; document.getElementById('mealWeekView').style.display=mode==='week'?'block':'none'; document.getElementById('mealMonthView').style.display=mode==='month'?'block':'none'; document.getElementById('mealWeekBtn').classList.toggle('active',mode==='week'); document.getElementById('mealMonthBtn').classList.toggle('active',mode==='month'); renderMealPeriodV11(); } function mealMoveV11(n){ if(mealViewModeV11==='week')mealCursorV11.setDate(mealCursorV11.getDate()+7*n); else mealCursorV11.setMonth(mealCursorV11.getMonth()+n); renderMealPeriodV11(); } function mealTodayV11(){mealCursorV11=new Date();mealCursorV11.setHours(12,0,0,0);renderMeal();} function renderMealPeriodV11(){if(mealViewModeV11==='week')renderMealWeekV11();else renderMealMonthV11();} function renderMealWeekV11(){ const st=mealStartWeekV11(mealCursorV11), en=new Date(st);en.setDate(st.getDate()+6); document.getElementById('mealPeriod').textContent=`${st.getMonth()+1}.${st.getDate()} ~ ${en.getMonth()+1}.${en.getDate()}`; let s=''; for(let i=0;i<7;i++){ const d=new Date(st);d.setDate(st.getDate()+i); const ds=mealDateStrV11(d),day=WEEKDAY_KO[d.getDay()],e=mealEntryV11(ds); const h=(typeof holidayName==='function'?holidayName(ds):''); s+=`
${day}요일 · ${d.getMonth()+1}/${d.getDate()}${esc(h||'')}
`; } document.getElementById('mealGrid').innerHTML=s; } function renderMealMonthV11(){ const y=mealCursorV11.getFullYear(),m=mealCursorV11.getMonth(); document.getElementById('mealPeriod').textContent=`${y}년 ${m+1}월`; const first=new Date(y,m,1),st=new Date(y,m,1-first.getDay()); let s=''; for(let i=0;i<42;i++){ const d=new Date(st);d.setDate(st.getDate()+i); const ds=mealDateStrV11(d),e=DB.mealCalendar[ds]||{},other=d.getMonth()!==m; const h=(typeof holidayName==='function'?holidayName(ds):''); s+=`
${d.getDate()} ${WEEKDAY_KO[d.getDay()]}
${h?`
${esc(h)}
`:''} ${e.lunch?``:'
식단 미등록
'}
`; } document.getElementById('mealMonthGrid').innerHTML=s; } function mealOpenDateV11(ds){ mealCursorV11=new Date(ds+'T12:00:00'); mealViewV11('week'); setTimeout(()=>{const el=document.getElementById('mealLunch_'+ds);if(el){el.focus();el.scrollIntoView({behavior:'smooth',block:'center'});}},50); } function captureMealInputsV11(){ if(mealViewModeV11!=='week')return; const st=mealStartWeekV11(mealCursorV11); for(let i=0;i<7;i++){ const d=new Date(st);d.setDate(st.getDate()+i);const ds=mealDateStrV11(d),e=mealEntryV11(ds); const l=document.getElementById('mealLunch_'+ds),sn=document.getElementById('mealSnack_'+ds),nt=document.getElementById('mealNote_'+ds); if(l)e.lunch=l.value.trim(); if(sn)e.snack=sn.value.trim(); if(nt)e.note=nt.value.trim(); } } function syncLegacyMealPlanV11(){ // 현재 주 날짜형 식단 → 기존 mealPlan에도 저장해 다른 기존 기능과 호환 const st=mealStartWeekV11(new Date()); for(let i=0;i<7;i++){ const d=new Date(st);d.setDate(st.getDate()+i);const ds=mealDateStrV11(d),day=WEEKDAY_KO[d.getDay()]; if(DB.mealCalendar[ds])DB.mealPlan[day]=DB.mealCalendar[ds].lunch||''; } } function saveMealPlan(){ ensureMealStoreV11();captureMealInputsV11();syncLegacyMealPlanV11();saveDB();renderMeal(); toast('식단표를 저장했습니다. 기존 테스트 데이터도 유지됩니다.'); } function mealQuickRegisterV11(){ mealCursorV11=new Date();mealCursorV11.setHours(12,0,0,0);mealViewV11('week'); const ds=todayStr();setTimeout(()=>{const el=document.getElementById('mealLunch_'+ds);if(el){el.focus();el.scrollIntoView({behavior:'smooth',block:'center'});}},50); } function mealHolidayV11(){ captureMealInputsV11();const st=mealStartWeekV11(mealCursorV11);let n=0; for(let i=0;i<7;i++){const d=new Date(st);d.setDate(st.getDate()+i);const ds=mealDateStrV11(d),h=(typeof holidayName==='function'?holidayName(ds):'');if(h){const e=mealEntryV11(ds);if(!e.lunch){e.lunch='휴무';e.note=h;n++;}}} saveDB();renderMeal();toast(`${n}개 공휴일을 휴무로 표시했습니다.`); } function mealCopyPrevV11(){ captureMealInputsV11();const cur=mealStartWeekV11(mealCursorV11),prev=new Date(cur);prev.setDate(cur.getDate()-7); for(let i=0;i<7;i++){const c=new Date(cur);c.setDate(cur.getDate()+i);const p=new Date(prev);p.setDate(prev.getDate()+i);const src=DB.mealCalendar[mealDateStrV11(p)]||{},dst=mealEntryV11(mealDateStrV11(c));dst.lunch=src.lunch||'';dst.snack=src.snack||'';dst.note=src.note||'';} saveDB();renderMeal();toast('지난주 식단을 복사했습니다.'); } const MEAL_SETS_V11={ '일반 한식':[ '현미밥·근대된장국·닭갈비·두부조림·오이무침·배추김치','잡곡밥·소고기미역국·고등어구이·감자조림·시금치나물·깍두기','보리밥·순두부찌개·불고기·계란찜·콩나물무침·배추김치','잡곡밥·북엇국·닭안심채소볶음·연근조림·브로콜리무침·깍두기','현미밥·육개장·삼치구이·도토리묵무침·숙주나물·배추김치','쌀밥·들깨무국·수육·양배추찜·무생채·배추김치','야채죽·계란찜·두부부침·과일'], '부드러운 식단':[ '진밥·감자국·닭안심조림·계란찜·애호박볶음·백김치','진밥·미역국·가자미조림·두부찜·무나물·백김치','진밥·순두부국·소고기장조림·감자으깸·시금치나물·백김치','진밥·북엇국·닭살채소볶음·연두부·숙주나물·백김치','진밥·배추국·동태살전·계란찜·호박나물·백김치','진밥·들깨무국·수육·두부조림·양배추나물·백김치','야채죽·계란찜·연두부·바나나'], '저염 중심':[ '잡곡밥·저염된장국·닭가슴살구이·두부채소찜·오이무침·저염김치','현미밥·무맑은국·삼치구이·감자채볶음·시금치무침·저염김치','보리밥·순두부맑은국·소고기채소볶음·계란찜·콩나물무침·저염김치','잡곡밥·북어무국·닭안심조림·연근조림·브로콜리·저염김치','현미밥·버섯들깨국·가자미구이·두부조림·숙주나물·저염김치','쌀밥·배추맑은국·수육·양배추찜·무나물·저염김치','야채죽·연두부·계란찜·과일'], '단백질 강화':[ '잡곡밥·된장국·닭안심구이·두부조림·계란찜·배추김치','현미밥·미역국·고등어구이·소고기장조림·시금치나물·깍두기','보리밥·순두부찌개·불고기·계란말이·콩나물무침·배추김치','잡곡밥·북엇국·닭가슴살채소볶음·두부부침·브로콜리·깍두기','현미밥·육개장·삼치구이·계란찜·숙주나물·배추김치','쌀밥·들깨국·수육·두부조림·양배추찜·배추김치','닭죽·연두부·계란찜·요거트'] }; const MEAL_SNACKS_V11=['바나나·요거트','찐고구마·두유','제철과일','카스텔라·우유','삶은계란·보리차','떡·두유','과일·요거트']; function mealAiGenerateV11(fillOnly){ ensureMealStoreV11();captureMealInputsV11(); const style=document.getElementById('mealAiStyle').value,days=Number(document.getElementById('mealAiDays').value),note=document.getElementById('mealAiNote').value.trim(),set=MEAL_SETS_V11[style]||MEAL_SETS_V11['일반 한식'],st=mealStartWeekV11(mealCursorV11); let changed=0; for(let i=0;i<7;i++){ const d=new Date(st);d.setDate(st.getDate()+i);const ds=mealDateStrV11(d),e=mealEntryV11(ds),h=(typeof holidayName==='function'?holidayName(ds):''); if(i>=days){if(!fillOnly&&!e.lunch)e.lunch='미운영';continue;} if(h){if(!e.lunch)e.lunch='휴무';continue;} if(fillOnly&&e.lunch)continue; e.lunch=set[i];e.snack=MEAL_SNACKS_V11[i];e.note=note?`AI 요청: ${note}`:'AI 초안 · 직원 검수 필요';changed++; } syncLegacyMealPlanV11();saveDB();renderMeal(); document.getElementById('mealAiResult').textContent=`${style} · ${changed}일 식단 초안을 생성했습니다.\n기존 등록 메뉴 ${fillOnly?'보존':'검토 필요'} · 저장 완료\n※ 실제 운영에서는 영양사·알레르기·연하상태 검수 후 확정하세요.`; toast('AI 식단 초안을 반영했습니다.'); } function renderMealSpecialV11(){ const box=document.getElementById('mealSpecialList');if(!box)return;const list=mealSpecialMembersV11(); box.innerHTML=list.length?list.map(m=>`
${esc(m.name)} · ${esc(m.caution||m.memo||'식이 확인 필요')}
`).join(''):'
식이 관련 주의 회원이 없습니다.
'; } function saveMealChecksV11(){ ensureMealStoreV11();DB.mealChecks[todayStr()]={taste:document.getElementById('mcTaste').checked,sample:document.getElementById('mcSample').checked,temp:document.getElementById('mcTemp').checked,diet:document.getElementById('mcDiet').checked,memo:document.getElementById('mcMemo').value.trim(),staff:currentStaffId||''};saveDB(); } function loadMealChecksV11(){ ensureMealStoreV11();const c=DB.mealChecks[todayStr()]||{}; const ids={mcTaste:'taste',mcSample:'sample',mcTemp:'temp',mcDiet:'diet'}; Object.entries(ids).forEach(([id,k])=>{const el=document.getElementById(id);if(el)el.checked=!!c[k];}); const memo=document.getElementById('mcMemo');if(memo)memo.value=c.memo||''; } function printMealPlanV11(){ captureMealInputsV11();syncLegacyMealPlanV11();saveDB(); const st=mealStartWeekV11(mealCursorV11);let rows=''; for(let i=0;i<7;i++){const d=new Date(st);d.setDate(st.getDate()+i);const e=mealEntryV11(mealDateStrV11(d));rows+=`${d.getMonth()+1}/${d.getDate()} ${WEEKDAY_KO[d.getDay()]}${esc(e.lunch||'-')}${esc(e.snack||'-')}${esc(e.note||'-')}`;} openPrintView('주간 식단표',`

주간 식단표

${esc(DB.settings.name)}

${rows}
날짜점심간식메모
`); } /* ============ DONOR ============ */ function renderDonors(){ const total=DB.donors.reduce((s,d)=>s+d.total,0); document.getElementById('donorKpis').innerHTML=`
후원자 수${DB.donors.length}
누적 후원금${total.toLocaleString()}원
정기후원${DB.donors.filter(d=>d.kind==='정기후원').length}건
`; document.getElementById('donorTbody').innerHTML=DB.donors.length?DB.donors.map(d=>` ${esc(d.name)}${esc(d.type)}${esc(d.kind)}${d.total.toLocaleString()}원${esc(d.last)} `).join(''):`
등록된 후원자가 없습니다.
`; const sel=document.getElementById('docDonor'); if(sel)sel.innerHTML=DB.donors.map(d=>``).join(''); } function addDonor(){ const name=document.getElementById('dName').value.trim(); const amount=Number(document.getElementById('dAmount').value||0); if(!name){toast('후원자명을 입력해주세요.');return;} DB.donors.unshift({id:uid('d'),name,type:document.getElementById('dType').value,kind:document.getElementById('dKind').value,total:amount,last:daysAgoLabel(0),memo:document.getElementById('dMemo').value.trim()}); saveDB(); ['dName','dAmount','dMemo'].forEach(id=>document.getElementById(id).value=''); renderDonors(); toast('후원자 정보를 저장했습니다.'); } /* ============ DOCUMENTS ============ */ function renderDocSelects(){ fillMemberSelects(); renderDonors(); const sel=document.getElementById('docConsult'); if(sel)sel.innerHTML=DB.consultations.map(c=>``).join(''); } function genDoc(type){ if(type==='member'){ const m=memberById(document.getElementById('docMember1').value); if(!m)return; openPrintView('회원 등록카드',`

회원 등록카드

${DB.settings.name}

이름${esc(m.name)}
연령/성별${m.age}세 · ${esc(m.gender)}
연락처${esc(m.phone)}
장기요양등급${esc(m.grade)}
주요관심${esc(m.interest)}
보호자${esc(m.guardian)} · ${esc(m.guardianPhone)}
주소${esc(m.address)||'-'}
가입일${esc(m.joinDate)}
메모${esc(m.memo)||'-'}
`); }else if(type==='billing'){ const m=memberById(document.getElementById('docMember2').value); if(!m)return; const unit=GRADE_UNIT_PRICE[m.grade]||0; const cost=unit*m.monthly.visits; const copay=Math.round(cost*0.15); openPrintView('급여제공기록지',`

장기요양 급여제공기록지

${DB.settings.name} · ${daysAgoLabel(0)} 기준

수급자${esc(m.name)} (${esc(m.grade)})
이번 달 이용일수${m.monthly.visits}일
참여 프로그램${esc(DB.programs.filter(p=>p.enrolledIds.includes(m.id)).map(p=>p.name).join(', ')||'-')}
일일 단가(예시)${unit.toLocaleString()}원
급여비용 합계${cost.toLocaleString()}원
본인부담금(15%)${copay.toLocaleString()}원

본 기록지는 데모용 예시이며, 실제 급여제공기록은 장기요양보험 표준서식·수가기준을 따라야 합니다.

`); }else if(type==='consult'){ const c=DB.consultations.find(x=>x.id===document.getElementById('docConsult').value); if(!c)return; openPrintView('상담일지',`

상담일지

${DB.settings.name} · 상담일 ${esc(c.createdAt)}

이름${esc(c.name)}
연락처${esc(c.phone)}
관심서비스${esc(c.interest)}
상담상태${esc(c.status)}
상담내용${esc(c.memo)||'-'}
`); }else if(type==='donor'){ const d=DB.donors.find(x=>x.id===document.getElementById('docDonor').value); if(!d)return; openPrintView('후원금 영수증',`

후원금 영수증

${DB.settings.name} 발행 · ${daysAgoLabel(0)}

후원자${esc(d.name)} (${esc(d.type)})
후원방식${esc(d.kind)}
누적 후원금${d.total.toLocaleString()}원
최근 후원일${esc(d.last)}

위와 같이 후원하셨음을 확인합니다. 기부금 영수증 발급은 관련 법령·정관에 따라 별도 처리됩니다.

`); }else if(type==='blank'){ openPrintView('프로그램 참가신청서',`

프로그램 참가신청서

${DB.settings.name}

성명 
연락처 
신청 프로그램 
희망 요일/시간 
건강 유의사항 
신청일 
`); } } /* ============ AI ============ */ function askAIWith(q){document.getElementById('aiq').value=q;askAI();} function askAI(){ const q=document.getElementById('aiq').value.trim()||'오늘 미응답 상담 알려줘'; let r=''; const unresp=DB.consultations.filter(c=>c.status==='신규'); if(q.includes('미응답')||q.includes('상담')){ r=`오늘 확인할 상담 ${unresp.length}건\n`+unresp.map(c=>`• ${c.name} : ${c.memo}`).join('\n')+ `\n\n권장 행동: 우선순위 순으로 연락 후 상담 CRM에 결과를 기록하세요.`; }else if(q.includes('낙상')){ const risky=DB.members.filter(m=>m.memo.includes('낙상')||DB.healthLogs.some(h=>h.memberId===m.id&&h.type.includes('낙상'))); r=risky.length?`낙상 위험군 ${risky.length}명\n`+risky.map(m=>`• ${m.name} : ${m.memo||'최근 낙상 관련 관찰기록 있음'}`).join('\n')+`\n\n권장 행동: 보행 보조기·안전동선 점검 및 담당 사회복지사 확인 요청.`:'현재 낙상 위험으로 표시된 회원이 없습니다.'; }else if(q.includes('참여율')||q.includes('프로그램')){ const rows=DB.programs.map(p=>`• ${p.name} ${Math.round(p.enrolledIds.length/p.capacity*100)}%`).join('\n'); const avg=Math.round(DB.programs.reduce((s,p)=>s+p.enrolledIds.length/p.capacity,0)/DB.programs.length*100); r=`이번 달 프로그램 참여율\n${rows}\n\n평균 참여율 ${avg}%\n제안: 참여율이 낮은 프로그램은 시간대 조정 또는 초급반 세분화를 검토하세요.`; }else if(q.includes('오늘')&&(q.includes('할 일')||q.includes('확인'))){ const at=todayAttendance(); const notIn=activeMembers().filter(m=>!at[m.id]||!at[m.id].checkIn); r=`오늘 확인할 일\n• 미응답 상담 ${unresp.length}건\n• 미입실 회원 ${notIn.length}명 (${notIn.map(m=>m.name).join(', ')||'없음'})\n• 보호자 리포트 검수 필요`; }else{ const m=DB.members[0]; r=`${m.name} 최근 요약\n• 이용상태: ${m.status}\n• 이번 달 출석 ${m.monthly.visits}일\n• 프로그램 참여 ${m.monthly.programs}회\n• 직원 메모: ${m.monthly.note}\n\n※ 실제 서비스에서는 담당자 검수 후 사용합니다.`; } document.getElementById('air').textContent=r; } /* ---------- AI 탭 전환 ---------- */ function switchAiTab(btn,paneId){ btn.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('#ai .tabpanel').forEach(p=>p.classList.remove('active')); document.getElementById(paneId).classList.add('active'); if(paneId==='ai-risk')renderAiRisk(); if(paneId==='ai-doc')renderAiDocSelects(); if(paneId==='ai-guardian')fillMemberSelects(); if(paneId==='ai-ops')renderOpsOptimization(); if(paneId==='ai-match')renderMatchTargetSelect(); } function renderAiPanel(){ renderAiRisk(); renderAiDocSelects(); fillMemberSelects(); renderOpsOptimization(); renderMatchTargetSelect(); } /* ---------- ① 위험 신호 감지 ---------- */ function computeRiskList(){ const at=todayAttendance(); return activeMembers().map(m=>{ const reasons=[]; const fallLogs=DB.healthLogs.filter(h=>h.memberId===m.id&&(h.type.includes('낙상')||h.type.includes('통증'))); if(fallLogs.length)reasons.push(`낙상·안전 관련 관찰기록 ${fallLogs.length}건`); if(m.memo&&m.memo.includes('낙상'))reasons.push('회원 메모에 낙상 위험 표시'); if((!at[m.id]||!at[m.id].checkIn)&&m.monthly.visits>0&&m.monthly.visits<10)reasons.push('이번 달 이용일수가 적고 오늘도 미입실'); if(m.monthly.programs===0)reasons.push('참여 중인 프로그램이 없어 고립 위험'); return {member:m,reasons}; }).filter(r=>r.reasons.length); } function renderAiRisk(){ const list=computeRiskList(); document.getElementById('aiRiskList').innerHTML=list.length?list.map(r=>`
${esc(r.member.name)}${r.reasons.map(esc).join(' · ')}
`).join(''):'
현재 위험 신호가 감지된 회원이 없습니다.
'; } /* ---------- ② 문서 자동 초안 ---------- */ function renderAiDocSelects(){ const cSel=document.getElementById('aiDocConsult'); if(cSel)cSel.innerHTML=DB.consultations.map(c=>``).join(''); fillMemberSelects(); } function draftConsultNote(){ const c=DB.consultations.find(x=>x.id===document.getElementById('aiDocConsult').value); if(!c){toast('상담 건을 선택해주세요.');return;} const nextAction=c.status==='신규'?'48시간 내 유선 연락 후 상담상태 갱신':(c.status==='체험예약'?'체험일 하루 전 안내 문자 발송':(c.status==='상담중'?'추가 상담 일정 조율':'등록 절차 및 이용 안내')); document.getElementById('aiDocResult').dataset.type='consult'; document.getElementById('aiDocResult').dataset.id=c.id; document.getElementById('aiDocResult').textContent= `[상담일지 초안] 상담일: ${c.createdAt} 대상자: ${c.name} (${c.phone}) 관심 서비스: ${c.interest} 현재 상태: ${c.status} 상담 내용: ${c.memo||'기록된 메모 없음'} AI 제안 다음 행동: ${nextAction} ※ 초안은 담당자 검수 후 정식 상담일지로 사용하세요.`; } function draftBillingNote(){ const m=memberById(document.getElementById('aiDocMember').value); if(!m){toast('회원을 선택해주세요.');return;} const recentLog=DB.healthLogs.find(h=>h.memberId===m.id); document.getElementById('aiDocResult').dataset.type='member'; document.getElementById('aiDocResult').dataset.id=m.id; document.getElementById('aiDocResult').textContent= `[급여제공기록 비고란 초안] ${m.name}(${m.grade}) 회원은 이번 달 ${m.monthly.visits}일 이용, 프로그램 ${m.monthly.programs}회 참여함. 직원 메모: ${m.monthly.note} ${recentLog?`최근 관찰기록: ${recentLog.type} — ${recentLog.note}`:'최근 특이 관찰기록 없음.'} ※ 초안은 담당자 검수 후 정식 기록지에 사용하세요.`; } function applyDraftToConsult(){ const box=document.getElementById('aiDocResult'); if(box.dataset.type!=='consult'||!box.dataset.id){toast('먼저 상담일지 초안을 생성해주세요.');return;} const c=DB.consultations.find(x=>x.id===box.dataset.id); if(!c)return; c.memo=box.textContent; saveDB(); renderCrm(); toast(c.name+' 상담 메모에 AI 초안을 반영했습니다.'); } function applyDraftToMember(){ const box=document.getElementById('aiDocResult'); if(box.dataset.type!=='member'||!box.dataset.id){toast('먼저 급여기록 초안을 생성해주세요.');return;} const m=memberById(box.dataset.id); if(!m)return; m.memo=box.textContent; saveDB(); renderMembers(); toast(m.name+' 회원 메모에 AI 초안을 반영했습니다.'); } /* ---------- ③ 보호자 소통 ---------- */ function draftGuardianMessage(){ const id=document.getElementById('aiGuardianMember').value; const m=memberById(id); if(!m){toast('회원을 선택해주세요.');return;} const at=todayAttendance()[id]||{}; const progs=DB.programs.filter(p=>p.enrolledIds.includes(id)).map(p=>p.name); document.getElementById('aiGuardianDraft').textContent= `안녕하세요, ${DB.settings.name}입니다. ${m.name} 어르신께서는 오늘 ${at.checkIn?(at.checkIn+'에 입실하여')+'센터를 잘 이용하고 계십니다.':'아직 입실 확인 전입니다.'} ${progs.length?('오늘 '+progs.join(', ')+' 프로그램에 참여 예정입니다.'):''} 특이사항이 있으면 바로 안내드리겠습니다. 감사합니다.`; } function askGuardianBot(q){ q=(q&&typeof q==='string')?q:document.getElementById('gbQ').value.trim(); let r=''; if(!q){r='질문을 입력해주세요.';} else if(q.includes('시간')||q.includes('운영')){r=`센터 운영시간은 ${DB.settings.hours}입니다.`;} else if(q.includes('식단')||q.includes('급식')||q.includes('밥')){const today=WEEKDAY_KO[new Date().getDay()];ensureMealStoreV11();const e=DB.mealCalendar[todayStr()]||{};r=`오늘 식단\n• 점심: ${e.lunch||'미등록'}\n• 간식: ${e.snack||'미등록'}`;} else if(q.includes('차량')||q.includes('송영')||q.includes('픽업')){r='차량 송영은 회원별로 배정되어 있으며, 정확한 시간은 담당 직원을 통해 안내드립니다. (센터 대표번호: '+DB.settings.phone+')';} else if(q.includes('비용')||q.includes('요금')||q.includes('본인부담')){r='본인부담금은 장기요양등급에 따라 다르며, 일반적으로 급여비용의 약 15% 수준입니다(재가급여 일반 기준 예시). 정확한 금액은 담당자에게 문의해주세요.';} else if(q.includes('프로그램')){r=`현재 운영 중인 프로그램은 ${DB.programs.map(p=>p.name).join(', ')} 입니다.`;} else{r='운영시간·식단·차량·이용요금·프로그램에 대해 물어봐 주시면 더 정확히 답해드릴 수 있어요. 그 외 문의는 담당 직원에게 연결해드립니다.';} document.getElementById('gbResult').textContent=r; document.getElementById('gbQ').value=''; } /* ---------- ④ 운영 최적화 ---------- */ function renderOpsOptimization(){ const overs=DB.programs.filter(p=>p.enrolledIds.length/p.capacity>=0.9); const unders=DB.programs.filter(p=>p.enrolledIds.length/p.capacity<0.4); const ratio=(activeMembers().length/Math.max(DB.staff.length,1)); const lines=[]; lines.push(`직원 1인당 회원 비율: 약 ${ratio.toFixed(1)}:1 (이용중 회원 ${activeMembers().length}명 / 직원 ${DB.staff.length}명)`); lines.push(ratio>7?'⚠ 권장 비율(1:5~7)을 초과합니다 — 사회복지사·요양보호사 충원을 검토하세요.':'✓ 직원 배치 비율은 안정적인 범위입니다.'); lines.push(overs.length?`⚠ 정원 90% 이상 혼잡 프로그램: ${overs.map(p=>p.name).join(', ')} — 회차 추가 또는 정원 확대를 검토하세요.`:'✓ 정원 초과 임박 프로그램이 없습니다.'); lines.push(unders.length?`⚠ 참여율 40% 미만 프로그램: ${unders.map(p=>p.name).join(', ')} — 시간대 조정 또는 통합을 검토하세요.`:'✓ 저조한 참여율의 프로그램이 없습니다.'); document.getElementById('opsResult').textContent=lines.join('\n\n'); } /* ---------- ⑤ 신규회원 매칭 ---------- */ function renderMatchTargetSelect(){ const sel=document.getElementById('matchTarget'); if(!sel)return; sel.innerHTML=DB.members.map(m=>``).join(''); } function renderMatchRecommend(){ const id=document.getElementById('matchTarget').value; const m=memberById(id); if(!m){toast('대상 회원을 선택해주세요.');return;} const keywords=m.interest.split(/[·,\s]+/).filter(Boolean); const scored=DB.programs.map(p=>{ let score=0; const reasons=[]; keywords.forEach(kw=>{ if(p.name.includes(kw)||p.tag.includes(kw)){score+=2; reasons.push(`관심사 '${kw}' 일치`);} }); if(p.enrolledIds.includes(m.id)){reasons.push('이미 참여 중'); score-=5;} else if(p.enrolledIds.lengthb.score-a.score).slice(0,3); document.getElementById('matchResult').innerHTML=scored.map(s=>`
${esc(s.p.name)}${esc(scheduleText(s.p))} · ${s.reasons.join(', ')}
`).join('') || '
추천할 프로그램이 없습니다.
'; } /* ============ 출결 스캔 체크인 ============ */ function scanCheckIn(){ const input=document.getElementById('scanInput'); const val=input.value.trim(); const resultEl=document.getElementById('scanResult'); if(!val)return; const m=DB.members.find(x=>x.cardId&&x.cardId.toLowerCase()===val.toLowerCase()) ||DB.members.find(x=>x.phone.replace(/-/g,'').endsWith(val.replace(/-/g,''))) ||DB.members.find(x=>x.id===val); if(!m){ resultEl.innerHTML=`일치하는 회원을 찾을 수 없습니다: ${esc(val)} — 카드번호(예: C0001) 또는 등록된 연락처를 확인해주세요.`; input.value=''; input.focus(); return; } checkIn(m.id); resultEl.innerHTML=`✓ ${esc(m.name)} 회원 체크인 완료 (${nowHM()})`; input.value=''; input.focus(); } /* ============ 차량·송영관리 ============ */ function todayTransport(){ const t=todayStr(); if(!DB.transportLog[t])DB.transportLog[t]={}; DB.vehicles.forEach(v=>{ if(!DB.transportLog[t][v.id])DB.transportLog[t][v.id]={amCheck:false,amTime:'',pmCheck:false,pmTime:'',location:null,notifyLog:[]}; }); return DB.transportLog[t]; } function todayPhotos(){ const t=todayStr(); if(!DB.transportPhotos)DB.transportPhotos={}; if(!DB.transportPhotos[t])DB.transportPhotos[t]={}; return DB.transportPhotos[t]; } function renderTransport(){ fillMemberSelects(); const log=todayTransport(); document.getElementById('vehicleGrid').innerHTML=DB.vehicles.map(v=>{ const pct=Math.min(100,Math.round(v.memberIds.length/v.capacity*100)); const rec=log[v.id]; const locBadge=rec.location?'📍 위치 공유됨':'위치 미공유'; return `
${esc(v.name)}${locBadge}

${esc(v.driver)}

${esc(v.route)} · 배정 ${v.memberIds.length}/${v.capacity}명

`; }).join('')||'
등록된 차량이 없습니다.
'; document.getElementById('transportTbody').innerHTML=DB.vehicles.length?DB.vehicles.map(v=>{ const rec=log[v.id]; return `${esc(v.name)}${esc(v.driver)} `; }).join(''):`
등록된 차량이 없습니다.
`; const allNotify=[]; DB.vehicles.forEach(v=>{ (log[v.id].notifyLog||[]).forEach(n=>allNotify.push(Object.assign({vehicle:v.name},n))); }); allNotify.sort((a,b)=>b.time.localeCompare(a.time)); const nlist=document.getElementById('transportNotifyList'); if(nlist)nlist.innerHTML=allNotify.length?allNotify.slice(0,12).map(n=>`
${esc(n.type)}${esc(n.vehicle)} · ${esc(n.target)} · ${esc(n.time)}
${esc(n.preview)}
`).join(''):'
오늘 발송된 알림·사진이 없습니다.
'; } function toggleTransportCheck(vehicleId,slot,checked){ const log=todayTransport(); const rec=log[vehicleId]; if(!rec)return; if(slot==='am'){rec.amCheck=checked; rec.amTime=checked?nowHM():'';} else{rec.pmCheck=checked; rec.pmTime=checked?nowHM():'';} saveDB(); renderTransport(); if(checked)toast('하차·승차 확인을 기록했습니다.'); } function openVehicleModal(){ document.getElementById('vehicleModalTitle').textContent='신규 차량 등록'; document.getElementById('vehicleModalSub').textContent='차량 정보를 입력하세요.'; document.getElementById('vehicleModalNewBody').style.display='block'; document.getElementById('vehicleModalRosterBody').style.display='none'; ['vName','vDriver','vCapacity','vRoute'].forEach(id=>document.getElementById(id).value=''); document.getElementById('vehicleModalFoot').innerHTML=``; openModal('vehicleOverlay'); } function saveVehicle(){ const name=document.getElementById('vName').value.trim(); if(!name){toast('차량명을 입력해주세요.');return;} DB.vehicles.push({id:uid('v'),name,driver:document.getElementById('vDriver').value.trim()||'미배정',capacity:Number(document.getElementById('vCapacity').value||8),route:document.getElementById('vRoute').value.trim()||'미정',memberIds:[]}); saveDB(); renderTransport(); closeModal('vehicleOverlay'); toast('차량을 등록했습니다.'); } function openVehicleRoster(id){ const v=DB.vehicles.find(x=>x.id===id); if(!v)return; document.getElementById('vehicleModalTitle').textContent=v.name+' 탑승자 배정'; document.getElementById('vehicleModalSub').textContent=`${esc(v.driver)} · ${esc(v.route)} · 정원 ${v.capacity}명`; document.getElementById('vehicleModalNewBody').style.display='none'; const body=document.getElementById('vehicleModalRosterBody'); body.style.display='block'; const log=todayTransport(); const rec=log[v.id]; const photos=todayPhotos(); const locText=rec.location?(rec.location.address?rec.location.address:`위도 ${rec.location.lat}, 경도 ${rec.location.lng}`)+` (${rec.location.time} 기준)`:'아직 공유되지 않음'; const mapLink=rec.location&&rec.location.lat?` · 지도에서 보기`:''; body.innerHTML=`
실시간 위치: ${esc(locText)}${mapLink}
${activeMembers().map(m=>{ const checked=v.memberIds.includes(m.id); const atCap=!checked&&v.memberIds.length>=v.capacity; const hasPhoto=photos[m.id]&&photos[m.id].vehicleId===v.id; return ``; }).join('')}
${(rec.notifyLog&&rec.notifyLog.length)?`
이 차량 오늘 발송 이력
${rec.notifyLog.slice(0,5).map(n=>`
${esc(n.type)}${esc(n.target)} · ${esc(n.time)}
${esc(n.preview)}
`).join('')}
`:''} `; document.getElementById('vehicleModalFoot').innerHTML=``; openModal('vehicleOverlay'); } function shareVehicleLocation(vehicleId){ const log=todayTransport(); const rec=log[vehicleId]; if(!rec)return; if(!navigator.geolocation){ manualLocationEntry(vehicleId); return; } toast('위치 확인 중…'); navigator.geolocation.getCurrentPosition(pos=>{ rec.location={lat:pos.coords.latitude.toFixed(5),lng:pos.coords.longitude.toFixed(5),time:nowHM(),address:''}; saveDB(); openVehicleRoster(vehicleId); renderTransport(); toast('현재 위치를 공유했습니다.'); },err=>{ toast('위치 정보를 가져올 수 없습니다. 주소를 직접 입력해주세요.'); manualLocationEntry(vehicleId); },{timeout:8000,enableHighAccuracy:true}); } function manualLocationEntry(vehicleId){ const addr=prompt('현재 위치(주소 또는 인근 지명)를 입력해주세요. — 브라우저 위치 권한이 없거나 file:// 로 열었을 때 사용하는 대체 입력입니다.'); if(!addr)return; const log=todayTransport(); const rec=log[vehicleId]; if(!rec)return; rec.location={lat:'',lng:'',time:nowHM(),address:addr}; saveDB(); openVehicleRoster(vehicleId); renderTransport(); toast('위치를 기록했습니다.'); } function sendArrivalNotify(vehicleId){ const v=DB.vehicles.find(x=>x.id===vehicleId); if(!v)return; const log=todayTransport(); const rec=log[vehicleId]; if(!rec.location){toast('먼저 위치를 공유해주세요.');return;} const where=rec.location.address||`위도 ${rec.location.lat}, 경도 ${rec.location.lng}`; const members=v.memberIds.map(id=>memberById(id)).filter(Boolean); if(!members.length){toast('배정된 탑승자가 없습니다.');return;} rec.notifyLog=rec.notifyLog||[]; members.forEach(m=>{ rec.notifyLog.unshift({time:nowHM(),type:'도착 알림',target:m.name,preview:`${m.name}님이 ${v.name}에서 곧 하차합니다. 현재 위치: ${where}`}); }); saveDB(); openVehicleRoster(vehicleId); renderTransport(); toast(`탑승자 ${members.length}명의 보호자에게 도착 알림을 발송했습니다. (데모 — 실제 발송은 문자·알림톡 연동 필요)`); } function capturePhoto(vehicleId,memberId,inputEl){ const file=inputEl.files&&inputEl.files[0]; if(!file)return; const reader=new FileReader(); reader.onload=e=>{ const img=new Image(); img.onload=()=>{ const scale=Math.min(1,280/img.width); const canvas=document.createElement('canvas'); canvas.width=Math.round(img.width*scale); canvas.height=Math.round(img.height*scale); canvas.getContext('2d').drawImage(img,0,0,canvas.width,canvas.height); const dataUrl=canvas.toDataURL('image/jpeg',0.7); const photos=todayPhotos(); photos[memberId]={dataUrl,time:nowHM(),vehicleId}; const log=todayTransport(); const rec=log[vehicleId]; rec.notifyLog=rec.notifyLog||[]; const m=memberById(memberId); rec.notifyLog.unshift({time:nowHM(),type:'사진 전송',target:m?m.name:'',preview:'승하차 사진을 보호자에게 전송했습니다.'}); saveDB(); openVehicleRoster(vehicleId); renderTransport(); toast('사진을 촬영하여 보호자에게 전송했습니다. (데모)'); }; img.src=e.target.result; }; reader.readAsDataURL(file); inputEl.value=''; } function toggleVehicleAssign(vehicleId,memberId,checked){ const v=DB.vehicles.find(x=>x.id===vehicleId); if(!v)return; if(checked){ if(!v.memberIds.includes(memberId)&&v.memberIds.lengthid!==memberId); } saveDB(); renderTransport(); openVehicleRoster(vehicleId); } function deleteVehicle(id){ if(!confirm('이 차량을 삭제할까요?'))return; DB.vehicles=DB.vehicles.filter(v=>v.id!==id); saveDB(); renderTransport(); closeModal('vehicleOverlay'); toast('차량을 삭제했습니다.'); } /* ============ 인사관리 ============ */ function todayStaffAttendance(){ const t=todayStr(); if(!DB.staffAttendance[t])DB.staffAttendance[t]={}; return DB.staffAttendance[t]; } function renderHr(){ const at=todayStaffAttendance(); const belowTarget=DB.staff.filter(s=>s.eduHoursat[s.id]&&at[s.id].checkIn).length; document.getElementById('hrKpis').innerHTML=`
전체 직원${DB.staff.length}
오늘 출근${checkedIn}/${DB.staff.length}명
보수교육 미달${belowTarget}명
`; document.getElementById('hrTbody').innerHTML=DB.staff.map(s=>{ if(!at[s.id])at[s.id]={checkIn:'',checkOut:''}; const rec=at[s.id]; const eduWarn=s.eduHours ${esc(s.name)}${esc(s.role)} ${s.eduHours} / ${s.eduTarget}시간 ${rec.checkIn?('출근 '+rec.checkIn+(rec.methodIn?' · '+rec.methodIn:'')):'미출근'}${rec.checkOut?(' · 퇴근 '+rec.checkOut+(rec.methodOut?' · '+rec.methodOut:'')):''} `; }).join(''); } function staffCheckIn(id){ const at=todayStaffAttendance(); if(!at[id])at[id]={checkIn:'',checkOut:''}; at[id].checkIn=nowHM(); at[id].methodIn='관리자 처리'; saveDB(); renderHr(); renderStaffSelector(); toast('출근 처리되었습니다.'); } function staffCheckOut(id){ const at=todayStaffAttendance(); if(!at[id])return; at[id].checkOut=nowHM(); at[id].methodOut='관리자 처리'; saveDB(); renderHr(); renderStaffSelector(); toast('퇴근 처리되었습니다.'); } function updateStaffField(id,field,value){ const s=DB.staff.find(x=>x.id===id); if(!s)return; s[field]=value; saveDB(); toast('직원 정보를 저장했습니다.'); } /* ============ 안전관리 ============ */ function renderSafety(){ const memberSel=document.getElementById('inMember'); if(memberSel)memberSel.innerHTML=''+DB.members.map(m=>``).join(''); const acc=DB.incidents.filter(i=>i.type==='사고').length; const comp=DB.incidents.filter(i=>i.type==='민원').length; const done=DB.incidents.filter(i=>i.status==='완료').length; document.getElementById('safetyKpis').innerHTML=`
이번달 사고${acc}건
이번달 민원${comp}건
처리완료${done}/${DB.incidents.length}건
처리율${DB.incidents.length?Math.round(done/DB.incidents.length*100):0}%
`; document.getElementById('incidentTbody').innerHTML=DB.incidents.length?DB.incidents.slice().sort((a,b)=>b.createdAt.localeCompare(a.createdAt)).map(i=>{ const m=i.memberId?memberById(i.memberId):null; const cls={'접수':'warn','처리중':'info','완료':'ok'}; return `${esc(i.type)}${m?esc(m.name):'-'}${esc(i.title)} ${esc(i.handledBy)||'-'}${esc(i.createdAt)}`; }).join(''):`
접수된 사고·민원이 없습니다.
`; } function addIncident(){ const title=document.getElementById('inTitle').value.trim(); if(!currentStaffId){toast('상단에서 작성자(근무자)를 먼저 선택해주세요.');return;} if(!title){toast('제목을 입력해주세요.');return;} DB.incidents.unshift({id:uid('in'),type:document.getElementById('inType').value,memberId:document.getElementById('inMember').value,title,desc:document.getElementById('inDesc').value.trim(),status:'접수',handledBy:currentStaffName(),createdAt:daysAgoLabel(0)}); saveDB(); document.getElementById('inTitle').value=''; document.getElementById('inDesc').value=''; renderSafety(); toast('접수를 저장했습니다.'); } function updateIncidentStatus(id,status){ const i=DB.incidents.find(x=>x.id===id); if(!i)return; i.status=status; saveDB(); renderSafety(); toast('처리 상태를 변경했습니다.'); } /* ============ 품질관리 ============ */ function renderQuality(){ const allItems=DB.quality.flatMap(c=>c.items); const checkedCount=allItems.filter(i=>i.checked).length; const pct=allItems.length?Math.round(checkedCount/allItems.length*100):0; document.getElementById('qualityKpis').innerHTML=`
전체 이행률${pct}%${checkedCount}/${allItems.length}항목
점검 영역${DB.quality.length}개 영역
미이행 항목${allItems.length-checkedCount}건
`; document.getElementById('qualityList').innerHTML=DB.quality.map(cat=>{ const done=cat.items.filter(i=>i.checked).length; return `

${esc(cat.category)}

${done}/${cat.items.length} 완료
${cat.items.map(it=>``).join('')}
`; }).join(''); } function toggleQualityItem(itemId,checked){ DB.quality.forEach(cat=>{ const it=cat.items.find(x=>x.id===itemId); if(it)it.checked=checked; }); saveDB(); renderQuality(); } /* ============ BILLING ============ */ const GRADE_UNIT_PRICE={'1등급':71000,'2등급':65750,'3등급':50713,'4등급':46754,'5등급':40684,'인지지원등급':40684,'해당없음':0}; function renderBilling(){ const rows=DB.members.filter(m=>m.grade!=='해당없음'); let totalCost=0,totalClaim=0,doneCount=0; document.getElementById('billingTbody').innerHTML=rows.length?rows.map(m=>{ const unit=GRADE_UNIT_PRICE[m.grade]||0; const visits=m.monthly.visits; const cost=unit*visits; const copay=Math.round(cost*0.15); const claim=cost-copay; totalCost+=cost; totalClaim+=claim; const status=DB.billingStatus[m.id]||'청구대기'; if(status==='청구완료')doneCount++; return ` ${esc(m.name)}${esc(m.grade)}${visits}일 ${unit.toLocaleString()}원${cost.toLocaleString()}원 ${copay.toLocaleString()}원${claim.toLocaleString()}원 `; }).join(''):`
장기요양등급을 보유한 회원이 없습니다.
`; document.getElementById('billingKpis').innerHTML=`
청구 대상 회원${rows.length}장기요양등급 보유
이번달 급여비용 합계${totalCost.toLocaleString()}원
공단청구액 합계${totalClaim.toLocaleString()}원
청구완료${doneCount}/${rows.length}건
본인부담률(예시)15%일반 재가급여 기준
`; } function setBillingStatus(memberId,status){ DB.billingStatus[memberId]=status; saveDB(); renderBilling(); toast('청구 상태를 변경했습니다.'); } /* ============ KPI ============ */ function renderKpiPanel(){ const at=todayAttendance(); const active=activeMembers(); const checkedIn=Object.values(at).filter(r=>r.checkIn).length; const usage=Math.round(checkedIn/Math.max(active.length,1)*100); const total=DB.consultations.length||1; const conv=Math.round(DB.consultations.filter(c=>c.status==='등록완료').length/total*100); const avgFill=Math.round(DB.programs.reduce((s,p)=>s+p.enrolledIds.length/p.capacity,0)/DB.programs.length*100); document.getElementById('kpiGrid').innerHTML=`
이용률${usage}%목표 85%
상담 전환${conv}%목표 45%+
30일 유지율88%데모 값
프로그램 참여${avgFill}%등록 데이터 기준
중대사고0핵심 안전 KPI
`; } /* ============ SETTINGS ============ */ function renderSettings(){ document.getElementById('setName').value=DB.settings.name; document.getElementById('setPhone').value=DB.settings.phone; document.getElementById('setRegion').value=DB.settings.region; document.getElementById('setHours').value=DB.settings.hours; document.getElementById('productSub').textContent=DB.settings.name+' · 시니어센터 운영을 하나로 연결하는 AI 통합 운영 플랫폼'; const roleDot={'센터장':'green','사회복지사':'blue','일반직원':'gray'}; document.getElementById('staffList').innerHTML=DB.staff.map(s=>`
${esc(s.name)}${esc(s.role)}
`).join(''); renderStaffBox(); } function saveSettings(){ DB.settings.name=document.getElementById('setName').value.trim()||'데모센터'; DB.settings.phone=document.getElementById('setPhone').value.trim(); DB.settings.region=document.getElementById('setRegion').value.trim(); DB.settings.hours=document.getElementById('setHours').value.trim(); saveDB(); renderSettings(); toast('센터 정보를 저장했습니다.'); } function addStaff(){ const name=document.getElementById('staffName').value.trim(); if(!name){toast('이름을 입력해주세요.');return;} DB.staff.push({id:uid('st'),name,role:document.getElementById('staffRole').value,cert:'미등록',eduHours:0,eduTarget:24,pw:'1234'}); saveDB(); document.getElementById('staffName').value=''; renderSettings(); toast('직원을 추가했습니다. 인사관리 메뉴에서 자격증·보수교육 정보를 입력해주세요.'); } function removeStaff(id){ DB.staff=DB.staff.filter(s=>s.id!==id); saveDB(); if(currentStaffId===id){ currentStaffId=''; localStorage.removeItem('careos_current_staff_id'); } renderSettings(); } function resetStaffPassword(id){ const s=DB.staff.find(x=>x.id===id); if(!s)return; const next=prompt(s.name+' 님의 새 비밀번호를 입력해주세요. (4자 이상)'); if(next===null)return; if(next.trim().length<4){toast('비밀번호는 4자 이상으로 설정해주세요.');return;} s.pw=next.trim(); saveDB(); toast(s.name+' 님의 비밀번호를 재설정했습니다.'); } function downloadFile(filename,content,mime){ const blob=new Blob([content],{type:mime}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function exportJSON(){ downloadFile(`careos-backup-${todayStr()}.json`,JSON.stringify(DB,null,2),'application/json'); toast('전체 데이터를 JSON 파일로 내보냈습니다.'); } function importJSON(file){ if(!file)return; const reader=new FileReader(); reader.onload=e=>{ try{ const data=JSON.parse(e.target.result); if(!data.members||!Array.isArray(data.members))throw new Error('invalid'); DB=data; saveDB(); renderAll(); toast('백업 파일을 불러왔습니다.'); }catch(err){ toast('파일을 읽을 수 없습니다. 올바른 백업 파일인지 확인해주세요.'); } }; reader.readAsText(file); document.getElementById('importFile').value=''; } function exportMembersCSV(){ const header=['이름','연령','성별','연락처','이용상태','장기요양등급','주요관심','보호자','보호자연락처','주소','가입일']; const rows=DB.members.map(m=>[m.name,m.age,m.gender,m.phone,m.status,m.grade,m.interest,m.guardian,m.guardianPhone,m.address,m.joinDate]); const csv='\uFEFF'+[header,...rows].map(r=>r.map(v=>`"${String(v==null?'':v).replace(/"/g,'""')}"`).join(',')).join('\r\n'); downloadFile(`careos-members-${todayStr()}.csv`,csv,'text/csv;charset=utf-8'); toast('회원목록을 CSV로 내보냈습니다.'); } /* ============ CARE OS 2.1 NEW MODULES ============ */ function ensureV21Data(){ if(!DB.walkRecords)DB.walkRecords=[]; if(!DB.trayRecords)DB.trayRecords=[]; if(!DB.programQrAttendance)DB.programQrAttendance={}; if(!DB.guardianSendLog)DB.guardianSendLog=[]; } function fillV21MemberSelects(){ const opts=activeMembers().map(m=>``).join(''); ['qrMemberSelect','walkMember','trayMember'].forEach(id=>{const el=document.getElementById(id);if(el){const cur=el.value;el.innerHTML=opts;if(cur)el.value=cur;}}); const p=document.getElementById('progQrProgram');if(p){p.innerHTML=(DB.programs||[]).map(x=>``).join('');} } function renderQrPattern(seed,target){ const box=document.getElementById(target);if(!box)return; box.className='qr-box';box.innerHTML=''; let h=0;for(const c of seed)h=((h<<5)-h)+c.charCodeAt(0); for(let i=0;i<81;i++){const x=document.createElement('i');if(((h+i*37+i*i*11)&7)<4)x.className='on';box.appendChild(x);} } function renderMemberQr(){ fillV21MemberSelects(); const id=document.getElementById('qrMemberSelect')?.value||activeMembers()[0]?.id;if(!id)return; const m=DB.members.find(x=>x.id===id);if(!m)return; document.getElementById('qrMemberName').textContent=m.name; document.getElementById('qrMemberMeta').textContent=`회원번호 ${m.code||m.id} · ${m.age}세 · ${m.status}`; document.getElementById('qrPhoneName').textContent=m.name; document.getElementById('qrPhoneMeta').textContent=`${m.age}세 · ${m.interest||'관심정보 없음'}`; const av=document.getElementById('qrPhoneAvatar');av.innerHTML=m.photo?``:esc((m.name||'?')[0]); renderQrPattern('HARUE:'+id,'memberQrVisual'); const phone=document.getElementById('qrPhoneVisual');phone.innerHTML='
';renderQrPattern('HARUE:'+id,'qrPhoneBox'); } function printMemberQr(){ const id=document.getElementById('qrMemberSelect').value,m=DB.members.find(x=>x.id===id);if(!m)return; openPrintView('회원 QR 카드',`

${esc(m.name)} 회원카드

회원번호 ${esc(m.code||m.id)} · ${m.age}세

QR 토큰: HARUE:${esc(m.id)}

실제 서비스에서는 암호화 토큰을 사용합니다.

`); } function calculateWalkRisk(){ const tug=Number(document.getElementById('walkTug').value||0),chair=Number(document.getElementById('walkChair').value||0),fall=Number(document.getElementById('walkFall').value||0),aid=Number(document.getElementById('walkAid').value||0); let score=0;if(tug>=13.5)score+=3;else if(tug>=11)score+=1;if(chair>=15)score+=2;else if(chair>=12)score+=1;score+=fall*2+aid; score=Math.min(score,10); document.getElementById('walkScore').textContent=score+'/10'; document.getElementById('walkRiskBar').style.width=(score*10)+'%'; const text=score<=2?'낮은 위험 · 현재 활동 유지':score<=5?'주의 단계 · 균형·보행 프로그램 권장':'높은 위험 · 담당자 확인과 안전계획 검토'; document.getElementById('walkRiskText').textContent=text; return score; } function saveWalkRecord(){ ensureV21Data();const memberId=document.getElementById('walkMember').value;if(!memberId){toast('회원을 선택해주세요.');return;} const score=calculateWalkRisk();DB.walkRecords.unshift({id:uid('wr'),memberId,date:todayStr(),tug:Number(document.getElementById('walkTug').value||0),chair:Number(document.getElementById('walkChair').value||0),fall:Number(document.getElementById('walkFall').value||0),aid:Number(document.getElementById('walkAid').value||0),score,staff:currentStaffId||''}); saveDB();renderWalkHistory();toast('WalkCare 기록을 저장했습니다.'); } function renderWalkHistory(){ ensureV21Data();const box=document.getElementById('walkHistory');if(!box)return;const mid=document.getElementById('walkMember')?.value; const rows=DB.walkRecords.filter(r=>!mid||r.memberId===mid).slice(0,8); box.innerHTML=rows.length?rows.map(r=>{const m=DB.members.find(x=>x.id===r.memberId);return `
WalkCare${esc(r.date)}
${esc(m?.name||'회원')} · ${r.score}/10
TUG ${r.tug}s · 의자 ${r.chair}s
`}).join(''):'
아직 기록이 없습니다.
'; } let trayPhotoData=''; function handleTrayPhoto(file){ if(!file)return;const reader=new FileReader();reader.onload=e=>{trayPhotoData=e.target.result;document.getElementById('trayPreview').innerHTML=`식판 사진`;};reader.readAsDataURL(file); } function analyzeTrayDemo(){ if(!trayPhotoData){toast('먼저 식판 사진을 촬영하거나 선택해주세요.');return;} document.getElementById('trayDetect').innerHTML=`
메뉴 추정잡곡밥 · 국 · 단백질 반찬 · 나물 · 김치
섭취량약 75% 섭취
잔반밥 소량 · 나물 일부
주의AI 추정 결과 · 직원 확인 필요
`; toast('AI 식판 분석 시연을 완료했습니다.'); } function saveTrayRecord(){ ensureV21Data();const mid=document.getElementById('trayMember').value;if(!mid){toast('회원을 선택해주세요.');return;}if(!trayPhotoData){toast('사진이 없습니다.');return;} DB.trayRecords.unshift({id:uid('tray'),memberId:mid,date:todayStr(),photo:trayPhotoData,memo:document.getElementById('trayMemo').value.trim(),intake:75,staff:currentStaffId||''}); saveDB();renderTrayHistory();toast('식사기록을 저장했습니다.'); } function renderTrayHistory(){ ensureV21Data();const box=document.getElementById('trayHistory');if(!box)return; box.innerHTML=DB.trayRecords.slice(0,6).map(r=>{const m=DB.members.find(x=>x.id===r.memberId);return `
식사${esc(r.date)}
${esc(m?.name||'회원')} · 섭취 ${r.intake}%
${esc(r.memo||'직원 메모 없음')}
`}).join('')||'
저장된 식사 사진 기록이 없습니다.
'; } function programQrCheckin(){ ensureV21Data();const pid=document.getElementById('progQrProgram').value,code=document.getElementById('progQrCode').value.trim(); const m=DB.members.find(x=>(x.code||'')===code||x.id===code||('C'+String(DB.members.indexOf(x)+1).padStart(4,'0'))===code); if(!m){document.getElementById('progQrResult').textContent='회원 QR/번호를 찾지 못했습니다.';return;} const key=todayStr()+'_'+pid;if(!DB.programQrAttendance[key])DB.programQrAttendance[key]=[]; if(!DB.programQrAttendance[key].includes(m.id))DB.programQrAttendance[key].push(m.id); saveDB();document.getElementById('progQrResult').textContent=`${m.name} 회원 출석 완료 · 오늘 ${DB.programQrAttendance[key].length}명`;document.getElementById('progQrCode').value='';toast('프로그램 출석 처리했습니다.'); } function generateTodayBriefing(){ ensureV21Data();const box=document.getElementById('todayBriefing');if(!box)return; const at=todayAttendance(),checked=Object.values(at).filter(x=>x.checkIn).length; const consult=(DB.consultations||[]).filter(c=>c.status==='신규'||c.status==='상담중').length; const events=(DB.events||[]).filter(e=>e.date===todayStr()).length; const meal=(DB.mealCalendar?.[todayStr()]?.lunch)||(DB.mealPlan?.[WEEKDAY_KO[new Date().getDay()]])||'미등록'; const risk=(DB.walkRecords||[]).filter(r=>r.score>=6).length; box.innerHTML=`
출결${checked}명 입실 확인
상담후속관리 ${consult}건
일정오늘 ${events}건
급식${esc(meal)}
${risk?`
안전낙상위험 확인 ${risk}명
`:''}`; } function careSearch(){ const q=document.getElementById('globalSearch').value.trim();if(!q)return; const member=DB.members.find(m=>(m.name||'').includes(q)||(m.phone||'').includes(q)); if(member){showPanel('members');document.getElementById('memberSearch').value=member.name;renderMembers();toast(`${member.name} 회원을 찾았습니다.`);return;} const c=DB.consultations.find(x=>(x.name||'').includes(q)||(x.memo||'').includes(q));if(c){showPanel('crm');toast(`${c.name} 상담을 찾았습니다.`);return;} const e=DB.events.find(x=>(x.title||'').includes(q));if(e){showPanel('schedule');toast(`${e.title} 일정을 찾았습니다.`);return;} toast('검색 결과가 없습니다.'); } const _oldSendGuardianReport = typeof sendGuardianReport==='function'?sendGuardianReport:null; function sendGuardianReport(){ ensureV21Data();const mid=document.getElementById('guardianMember')?.value,m=DB.members.find(x=>x.id===mid); if(!m){toast('회원을 선택해주세요.');return;} DB.guardianSendLog.unshift({id:uid('gs'),memberId:mid,date:new Date().toISOString(),channel:'카카오 알림톡(데모)'}); saveDB();toast(`${m.name} 보호자에게 카카오 알림톡 발송을 시연했습니다.`); } /* ============ WELFARE INFO HUB 2.2 ============ */ let welfareTypeFilter=''; function ensureWelfareData(){ if(!DB.welfareItems)DB.welfareItems=[]; if(!DB.welfareBookmarks)DB.welfareBookmarks=[]; // Demo items only if empty. Real service replaces these through server/API sync. if(!DB.welfareItems.length){ DB.welfareItems=[ {id:'wf1',title:'노년층 맞춤 복지서비스 확인',type:'정책',source:'복지로',region:'전국',target:['노년','가족'],date:'2026-08-20',deadline:'',priority:5,free:true,summary:'연령·가구조건에 따라 이용 가능한 복지서비스를 확인하고 신청방법을 안내하는 정보입니다.',url:'https://www.bokjiro.go.kr/'}, {id:'wf2',title:'서울시 노년 복지서비스 맞춤검색',type:'무료지원',source:'서울복지포털',region:'서울',target:['노년'],date:'2026-08-19',deadline:'',priority:5,free:true,summary:'서울시 복지서비스를 생애주기·가구조건으로 검색할 수 있는 공식 복지정보입니다.',url:'https://wis.seoul.go.kr/'}, {id:'wf3',title:'도봉구 어르신·복지 분야 새소식 확인',type:'행사',source:'도봉구청',region:'도봉구',target:['노년','가족'],date:'2026-08-21',deadline:'2026-08-31',priority:5,free:true,summary:'도봉구 지역 행사·복지 모집·무료지원 사항을 센터와 회원 안내용으로 수집하는 데모 항목입니다.',url:'https://www.dobong.go.kr/'}, {id:'wf4',title:'50+ 일자리·교육 프로그램',type:'모집',source:'서울시50플러스',region:'서울',target:['중장년'],date:'2026-08-20',deadline:'2026-09-05',priority:4,free:true,summary:'중장년 대상 일자리·교육·경력전환 프로그램을 찾아 회원에게 연결하는 정보입니다.',url:'https://www.50plus.or.kr/'}, {id:'wf5',title:'사회복지 정책·사업 공지',type:'정책',source:'보건복지부',region:'전국',target:['복지기관','종사자'],date:'2026-08-18',deadline:'',priority:4,free:true,summary:'보건복지 정책과 사업 공지를 센터 운영 관점에서 모니터링하는 데모 항목입니다.',url:'https://www.mohw.go.kr/'}, {id:'wf6',title:'복지기관 대상 지원·공모 확인',type:'공모·요청',source:'도봉구청',region:'도봉구',target:['복지기관','종사자'],date:'2026-08-20',deadline:'2026-08-28',priority:5,free:true,summary:'구청 공고 중 복지기관 지원, 협력, 공모, 물품·프로그램 요청을 한곳에서 확인하도록 설계한 데모입니다.',url:'https://www.dobong.go.kr/'}, {id:'wf7',title:'50+ 무료 디지털·AI 교육',type:'교육',source:'서울시50플러스',region:'서울',target:['중장년','노년'],date:'2026-08-17',deadline:'2026-09-10',priority:4,free:true,summary:'스마트폰·AI·디지털 생활교육을 센터 프로그램과 연계하는 데모 정보입니다.',url:'https://www.50plus.or.kr/'} ]; saveDB(); } } function setWelfareType(type,btn){ welfareTypeFilter=type; document.querySelectorAll('#welfareTabs button').forEach(b=>b.classList.remove('active')); if(btn)btn.classList.add('active'); renderWelfareHub(); } function welfareDaysToDeadline(d){ if(!d)return 9999; return Math.ceil((new Date(d+'T23:59:59')-new Date())/86400000); } function welfareFilteredItems(){ ensureWelfareData(); const q=(document.getElementById('welfareSearch')?.value||'').trim().toLowerCase(); const region=document.getElementById('welfareRegion')?.value||''; const target=document.getElementById('welfareTarget')?.value||''; const source=document.getElementById('welfareSource')?.value||''; const sort=document.getElementById('welfareSort')?.value||'new'; let rows=DB.welfareItems.filter(x=>{ const text=(x.title+' '+x.summary+' '+x.source+' '+x.region+' '+x.type).toLowerCase(); return (!q||text.includes(q))&&(!region||x.region===region||x.region==='전국')&&(!target||(x.target||[]).includes(target))&&(!source||x.source===source)&&(!welfareTypeFilter||x.type===welfareTypeFilter); }); rows.sort((a,b)=>{ if(sort==='deadline')return welfareDaysToDeadline(a.deadline)-welfareDaysToDeadline(b.deadline); if(sort==='priority')return (b.priority||0)-(a.priority||0); return (b.date||'').localeCompare(a.date||''); }); return rows; } function renderWelfareHub(){ ensureWelfareData(); const rows=welfareFilteredItems(),list=document.getElementById('welfareList'); if(!list)return; list.innerHTML=rows.length?rows.map(x=>{ const bookmarked=DB.welfareBookmarks.includes(x.id),days=welfareDaysToDeadline(x.deadline); const deadline=x.deadline?`${x.deadline}${days>=0&&days<9999?` · D-${days}`:''}`:'상시/기한없음'; return `

${esc(x.title)}

${esc(x.summary)}

${esc(x.source)}${esc(x.region)}${esc(x.type)}${x.free?'무료/지원':''}${esc(deadline)}
공식 사이트
`; }).join(''):'
조건에 맞는 복지정보가 없습니다.
'; const counts={all:rows.length,free:rows.filter(x=>x.free).length,deadline:rows.filter(x=>{const d=welfareDaysToDeadline(x.deadline);return d>=0&&d<=14}).length,local:rows.filter(x=>x.region==='도봉구').length}; document.getElementById('welfareSummary').innerHTML=`
${counts.all}검색 결과
${counts.free}무료·지원
${counts.deadline}14일내 마감
${counts.local}도봉구 정보
`; renderWelfareBookmarks(); const badge=document.getElementById('welfareBadge');if(badge)badge.textContent=DB.welfareItems.filter(x=>x.date>=todayStr()).length; } function renderWelfareBookmarks(){ const box=document.getElementById('welfareBookmarks');if(!box)return; const rows=DB.welfareBookmarks.map(id=>DB.welfareItems.find(x=>x.id===id)).filter(Boolean); box.innerHTML=rows.length?rows.map(x=>`
${esc(x.title)}${esc(x.source)} · ${esc(x.type)}
`).join(''):'
저장한 정보가 없습니다.
'; } function toggleWelfareBookmark(id){ ensureWelfareData();const i=DB.welfareBookmarks.indexOf(id); if(i>=0)DB.welfareBookmarks.splice(i,1);else DB.welfareBookmarks.unshift(id); saveDB();renderWelfareHub(); } function welfareToSchedule(id){ const x=DB.welfareItems.find(v=>v.id===id);if(!x)return; showPanel('schedule'); setTimeout(()=>{ const date=x.deadline||todayStr(); const ed=document.getElementById('evDate');if(ed)ed.value=date; const et=document.getElementById('evTitle');if(et)et.value=`[복지정보] ${x.title}`; const ty=document.getElementById('evType');if(ty)ty.value=x.type==='교육'?'교육':'행사'; const memo=document.getElementById('evMemo');if(memo)memo.value=`출처: ${x.source} · ${x.url}`; if(typeof syncSelectedDate==='function')syncSelectedDate(); toast('일정 등록창에 복지정보를 넣었습니다. 저장을 눌러주세요.'); },80); } function welfareShareGuardian(id){ const x=DB.welfareItems.find(v=>v.id===id);if(!x)return; showPanel('guardian');toast(`'${x.title}' 안내를 보호자 리포트에서 공유할 수 있습니다.`); } function welfareAiSummary(id){ const x=DB.welfareItems.find(v=>v.id===id);if(!x)return; document.getElementById('welfareAiBrief').textContent=`[AI 요약]\n${x.title}\n• 대상: ${(x.target||[]).join(', ')}\n• 지역: ${x.region}\n• 유형: ${x.type}\n• 센터 활용: 회원 안내 / 일정등록 / 프로그램 연계 검토\n• 확인: 반드시 ${x.source} 공식 공고에서 신청조건·마감일을 최종 확인`; } function welfareAiBrief(){ const rows=welfareFilteredItems().slice(0,5); const urgent=rows.filter(x=>{const d=welfareDaysToDeadline(x.deadline);return d>=0&&d<=14}); const local=rows.filter(x=>x.region==='도봉구'); document.getElementById('welfareAiBrief').textContent=`오늘 복지 브리핑\n• 현재 필터 결과 ${rows.length}건\n• 14일 내 마감 ${urgent.length}건\n• 도봉구 관련 ${local.length}건\n• 우선 확인: ${rows[0]?.title||'없음'}\n\n중요 정보는 일정 등록 후 담당자를 지정하는 방식이 좋습니다.`; } function welfareSyncDemo(){ ensureWelfareData(); const now=todayStr(); DB.welfareLastSync=new Date().toISOString(); saveDB();renderWelfareHub(); toast('공식 사이트 연동을 시연했습니다. 실제 배포 시 서버 API를 연결합니다.'); } /* ============ V2.4 EASY MEMBER ACCESS + SCHEDULE ============ */ let memberQrStream=null; let memberAccessMode='auto'; function fillQuickAccessMember(){ const el=document.getElementById('quickAccessMember');if(!el)return; const cur=el.value; el.innerHTML=activeMembers().map(m=>``).join(''); if(cur)el.value=cur; } function todayAttendRecordV24(memberId){ const at=todayAttendance(); if(!at[memberId])at[memberId]={checkIn:'',checkOut:'',transport:'',methodIn:'',methodOut:''}; return at[memberId]; } function resolveMemberFromCodeV24(code){ code=(code||'').trim(); if(code.startsWith('HARUE:'))code=code.slice(6); return DB.members.find(m=>m.id===code||(m.code||'')===code)||DB.members.find((m,i)=>('C'+String(i+1).padStart(4,'0'))===code); } function applyMemberAccessV24(member,mode,method){ if(!member){toast('회원을 찾지 못했습니다.');return;} const r=todayAttendRecordV24(member.id),time=nowHM(); if(mode==='auto')mode=r.checkIn&&!r.checkOut?'out':'in'; if(mode==='in'){ r.checkIn=time;r.methodIn=method; const t=document.getElementById('quickAccessTransport');if(t)r.transport=t.value; toast(`${member.name} 회원 입실 처리되었습니다.`); }else{ r.checkOut=time;r.methodOut=method; toast(`${member.name} 회원 귀가 처리되었습니다.`); } saveDB();renderAttendance();renderDashboard(); const s=document.getElementById('memberAccessStatus'); if(s)s.textContent=`${member.name} · ${mode==='in'?'입실':'귀가'} ${time}\n처리방식: ${method}`; } function processMemberAccessCode(){ const el=document.getElementById('memberAccessInput'),m=findCredentialMember(el.value); if(!m){document.getElementById('memberAccessStatus').textContent='회원번호/QR을 찾지 못했습니다.';return;} applyMemberAccessV24(m,'auto','회원 QR/번호'); el.value=''; } function quickMemberAccess(mode){ fillQuickAccessMember(); const id=document.getElementById('quickAccessMember')?.value,m=DB.members.find(x=>x.id===id); if(!m){toast('회원을 선택해주세요.');return;} applyMemberAccessV24(m,mode,'직원 휴대폰'); } function openMemberQrScanner(){ const box=document.getElementById('memberQrCameraBox');box.classList.toggle('open'); if(box.classList.contains('open'))startMemberQrCamera();else stopMemberQrCamera(); } async function startMemberQrCamera(){ const video=document.getElementById('memberQrVideo'); try{ memberQrStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}},audio:false}); video.srcObject=memberQrStream;await video.play(); const st=document.getElementById('memberAccessStatus');if(st)st.textContent='회원 QR을 카메라 중앙에 맞춰주세요.'; if('BarcodeDetector' in window){ const detector=new BarcodeDetector({formats:['qr_code']}); const scan=async()=>{ if(!memberQrStream)return; try{ const codes=await detector.detect(video); if(codes.length){ const m=resolveMemberFromCodeV24(codes[0].rawValue||''); if(m){stopMemberQrCamera();document.getElementById('memberQrCameraBox').classList.remove('open');applyMemberAccessV24(m,'auto','회원 QR');return;} } }catch(e){} requestAnimationFrame(scan); };scan(); } }catch(e){ const st=document.getElementById('memberAccessStatus');if(st)st.textContent='카메라 권한을 확인해주세요. HTTPS 환경에서 이용하는 것을 권장합니다.'; } } function stopMemberQrCamera(){ if(memberQrStream){memberQrStream.getTracks().forEach(t=>t.stop());memberQrStream=null;} const v=document.getElementById('memberQrVideo');if(v)v.srcObject=null; } function demoMemberQrScan(){ const m=activeMembers()[0];if(!m){toast('등록 회원이 없습니다.');return;} stopMemberQrCamera();document.getElementById('memberQrCameraBox').classList.remove('open');applyMemberAccessV24(m,'auto','QR 데모'); } async function memberBluetoothAccess(){ fillQuickAccessMember(); const id=document.getElementById('quickAccessMember')?.value,m=DB.members.find(x=>x.id===id); if(!m){toast('회원을 선택해주세요.');return;} const st=document.getElementById('memberAccessStatus'); if(!navigator.bluetooth){if(st)st.textContent='이 브라우저는 Web Bluetooth를 지원하지 않습니다. QR 방식이 가장 안정적입니다.';return;} try{ if(st)st.textContent='회원 Bluetooth 태그/기기를 선택해주세요…'; const device=await navigator.bluetooth.requestDevice({acceptAllDevices:true}); if(device)applyMemberAccessV24(m,'auto','Bluetooth 근접인증'); }catch(e){if(st)st.textContent='Bluetooth 인증이 취소되었거나 사용할 수 없습니다.';} } function transportQuickMode(mode){ showPanel('transport');toast(`${mode} 체크 모드: 차량별 탑승자와 출결을 확인하세요.`); } function quickScheduleToday(type){ showPanel('schedule'); setTimeout(()=>{ const date=todayStr(); const titleMap={상담:'보호자 상담',회의:'직원 회의',프로그램:'센터 프로그램',휴무:'센터 휴무/행사'}; document.getElementById('evDate').value=date; document.getElementById('evType').value=type==='프로그램'?'행사':type; document.getElementById('evTitle').value=titleMap[type]||type; selectedScheduleDate=date; if(typeof syncSelectedDate==='function')syncSelectedDate(); document.getElementById('evTitle').focus(); toast('오늘 일정 등록창을 준비했습니다.'); },70); } /* ============ V2.5 TRANSPORT FLOW ============ */ function ensureTransportV25(){ if(!DB.transportAssignments)DB.transportAssignments={}; if(!DB.transportBoarding)DB.transportBoarding={}; if(!DB.transportNotifyLog)DB.transportNotifyLog=[]; } function transportKeyV25(vehicleId,mode){return todayStr()+'_'+vehicleId+'_'+mode;} function renderTransportVehicleSelectV25(){ ensureTransportV25(); const el=document.getElementById('transportVehicleSelect');if(!el)return; const cur=el.value; const vehicles=(DB.vehicles||[]); el.innerHTML=vehicles.length?vehicles.map(v=>``).join(''):''; if(cur)el.value=cur; } function transportAssignActiveMembers(){ ensureTransportV25();renderTransportVehicleSelectV25(); const vid=document.getElementById('transportVehicleSelect').value;if(!vid){toast('차량을 먼저 등록해주세요.');return;} const key=transportKeyV25(vid,document.getElementById('transportRunMode').value); DB.transportAssignments[key]=activeMembers().map(m=>m.id); if(!DB.transportBoarding[key])DB.transportBoarding[key]={}; saveDB();renderTransportRunner();toast('이용중 회원을 차량에 자동배정했습니다.'); } function renderTransportRunner(){ ensureTransportV25();renderTransportVehicleSelectV25(); const vid=document.getElementById('transportVehicleSelect')?.value,mode=document.getElementById('transportRunMode')?.value||'등원',box=document.getElementById('transportRunner'); if(!box)return; if(!vid){box.innerHTML='
차량을 등록해주세요.
';return;} const key=transportKeyV25(vid,mode),ids=DB.transportAssignments[key]||[],boarding=DB.transportBoarding[key]||{}; const vehicle=(DB.vehicles||[]).find(v=>v.id===vid); let html=`

${esc(vehicle?.name||vehicle?.number||'차량')} · ${esc(mode)}

${esc(vehicle?.driver||'기사 미등록')} · 배정회원 ${ids.length}명

승차 ${ids.filter(id=>boarding[id]?.boarded).length}명입실 ${ids.filter(id=>boarding[id]?.arrived).length}명
`; html+=`
`+ids.map(id=>{ const m=DB.members.find(x=>x.id===id);if(!m)return''; const s=boarding[id]||{}; return `
${esc(m.name)}${esc(m.code||m.id)} · ${esc(m.guardian||'보호자 미등록')}
`; }).join('')+`
`; box.innerHTML=html; renderTransportNotifyLogV25(); } function transportToggleBoard(memberId){ ensureTransportV25();const vid=document.getElementById('transportVehicleSelect').value,mode=document.getElementById('transportRunMode').value,key=transportKeyV25(vid,mode); if(!DB.transportBoarding[key])DB.transportBoarding[key]={};if(!DB.transportBoarding[key][memberId])DB.transportBoarding[key][memberId]={}; const rec=DB.transportBoarding[key][memberId];rec.boarded=!rec.boarded;rec.boardTime=rec.boarded?nowHM():''; saveDB();renderTransportRunner(); } function transportMarkArrived(memberId){ ensureTransportV25();const vid=document.getElementById('transportVehicleSelect').value,mode=document.getElementById('transportRunMode').value,key=transportKeyV25(vid,mode); if(!DB.transportBoarding[key])DB.transportBoarding[key]={};if(!DB.transportBoarding[key][memberId])DB.transportBoarding[key][memberId]={}; const rec=DB.transportBoarding[key][memberId];rec.arrived=true;rec.arriveTime=nowHM(); const m=DB.members.find(x=>x.id===memberId); if(mode==='등원'&&m)applyMemberAccessV24(m,'in','센터차량 도착'); if(mode==='하원'&&m)applyMemberAccessV24(m,'out','센터차량 귀가'); saveDB();renderTransportRunner(); } function transportScanPassenger(){ ensureTransportV25();const code=document.getElementById('transportScanInput').value.trim(),m=findCredentialMember(code); if(!m){document.getElementById('transportScanStatus').textContent='회원 QR/번호를 찾지 못했습니다.';return;} const vid=document.getElementById('transportVehicleSelect').value,mode=document.getElementById('transportRunMode').value,key=transportKeyV25(vid,mode); if(!(DB.transportAssignments[key]||[]).includes(m.id)){document.getElementById('transportScanStatus').textContent=`${m.name} 회원은 현재 차량 명단에 없습니다.`;return;} if(!DB.transportBoarding[key])DB.transportBoarding[key]={};DB.transportBoarding[key][m.id]={...(DB.transportBoarding[key][m.id]||{}),boarded:true,boardTime:nowHM()}; saveDB();document.getElementById('transportScanStatus').textContent=`${m.name} · ${mode} 승차 확인 ${nowHM()}`;document.getElementById('transportScanInput').value='';renderTransportRunner(); } function transportDemoScan(){const vid=document.getElementById('transportVehicleSelect').value,mode=document.getElementById('transportRunMode').value,key=transportKeyV25(vid,mode),id=(DB.transportAssignments[key]||[])[0];if(!id){toast('먼저 차량에 회원을 배정해주세요.');return;}const m=DB.members.find(x=>x.id===id);document.getElementById('transportScanInput').value=m?.code||m?.id||'';transportScanPassenger();} function transportBulkArrive(){ ensureTransportV25();const vid=document.getElementById('transportVehicleSelect').value,mode=document.getElementById('transportRunMode').value,key=transportKeyV25(vid,mode),ids=DB.transportAssignments[key]||[],boarding=DB.transportBoarding[key]||{}; let n=0;ids.forEach(id=>{if(boarding[id]?.boarded&&!boarding[id]?.arrived){const m=DB.members.find(x=>x.id===id);if(!m)return;boarding[id].arrived=true;boarding[id].arriveTime=nowHM();applyMemberAccessV24(m,mode==='등원'?'in':'out','센터차량 일괄처리');n++;}}); DB.transportBoarding[key]=boarding;saveDB();renderTransportRunner();toast(`${n}명 ${mode==='등원'?'입실':'귀가'} 처리했습니다.`); } function transportNotifyOne(memberId,type){ ensureTransportV25();const m=DB.members.find(x=>x.id===memberId);if(!m)return; DB.transportNotifyLog.unshift({id:uid('tn'),memberId,date:new Date().toISOString(),type,guardian:m.guardian||'',channel:'카카오 알림톡(데모)'}); saveDB();renderTransportNotifyLogV25();toast(`${m.name} 보호자 ${type} 알림을 시연했습니다.`); } function transportBulkNotify(type){ ensureTransportV25();const vid=document.getElementById('transportVehicleSelect').value,mode=document.getElementById('transportRunMode').value,key=transportKeyV25(vid,mode),ids=DB.transportAssignments[key]||[],boarding=DB.transportBoarding[key]||{}; const targets=ids.filter(id=>type==='도착'?boarding[id]?.arrived:boarding[id]?.boarded); targets.forEach(id=>{const m=DB.members.find(x=>x.id===id);if(m)DB.transportNotifyLog.unshift({id:uid('tn'),memberId:id,date:new Date().toISOString(),type,guardian:m.guardian||'',channel:'카카오 알림톡(데모)'});}); saveDB();renderTransportNotifyLogV25();toast(`${targets.length}건 보호자 ${type} 알림을 시연했습니다.`); } function renderTransportNotifyLogV25(){ ensureTransportV25();const box=document.getElementById('transportNotifyListV25');if(!box)return; box.innerHTML=DB.transportNotifyLog.slice(0,8).map(x=>{const m=DB.members.find(v=>v.id===x.memberId);return `
${esc(m?.name||'회원')} · ${esc(x.type)} 알림${new Date(x.date).toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'})} · ${esc(x.channel)}
`}).join('')||'
아직 발송 이력이 없습니다.
'; } /* ============ V2.6 DEVICE / AUTHENTICATION MANAGEMENT ============ */ function ensureDeviceData(){ if(!DB.staffDevices)DB.staffDevices=[]; if(!DB.memberCredentials)DB.memberCredentials=[]; if(!DB.beacons)DB.beacons=[]; } function fillDeviceSelects(){ ensureDeviceData(); const s=document.getElementById('devStaff');if(s){const cur=s.value;s.innerHTML=(DB.staff||[]).filter(x=>x.active!==false).map(x=>``).join('');if(cur)s.value=cur;} const m=document.getElementById('devMember');if(m){const cur=m.value;m.innerHTML=activeMembers().map(x=>``).join('');if(cur)m.value=cur;} } function registerStaffDevice(){ ensureDeviceData();fillDeviceSelects(); const staffId=document.getElementById('devStaff').value,name=document.getElementById('devStaffName').value.trim()||'직원 휴대폰',deviceId=document.getElementById('devStaffId').value.trim()||('DEV-'+Date.now().toString(36).toUpperCase()),scope=document.getElementById('devStaffScope').value; if(!staffId){toast('직원을 선택해주세요.');return;} DB.staffDevices.push({id:uid('sd'),staffId,name,deviceId,scope,status:'승인',created:todayStr()});saveDB();renderDeviceManager();toast('직원 휴대폰을 등록했습니다.'); } function registerMemberCredential(){ ensureDeviceData();fillDeviceSelects(); const memberId=document.getElementById('devMember').value,type=document.getElementById('devMemberType').value,token=document.getElementById('devMemberToken').value.trim()||(`${type.includes('QR')?'QR':'NFC'}-${memberId}`),status=document.getElementById('devMemberStatus').value; if(!memberId){toast('회원을 선택해주세요.');return;} DB.memberCredentials.push({id:uid('mc'),memberId,type,token,status,created:todayStr()});saveDB();renderDeviceManager();toast('회원 인증수단을 등록했습니다.'); } function registerBeacon(){ ensureDeviceData(); const location=document.getElementById('devBeaconLocation').value,beaconId=document.getElementById('devBeaconId').value.trim(),name=document.getElementById('devBeaconName').value.trim()||location,range=document.getElementById('devBeaconRange').value; if(!beaconId){toast('비콘 ID를 입력해주세요.');return;} DB.beacons.push({id:uid('bc'),location,beaconId,name,range,status:'사용중',created:todayStr()});saveDB();renderDeviceManager();toast('BLE 비콘을 등록했습니다.'); } function removeDeviceItem(kind,id){ ensureDeviceData(); if(kind==='staff')DB.staffDevices=DB.staffDevices.filter(x=>x.id!==id); if(kind==='member')DB.memberCredentials=DB.memberCredentials.filter(x=>x.id!==id); if(kind==='beacon')DB.beacons=DB.beacons.filter(x=>x.id!==id); saveDB();renderDeviceManager(); } function renderDeviceManager(){ ensureDeviceData();fillDeviceSelects(); const sd=document.getElementById('staffDeviceList');if(sd)sd.innerHTML=DB.staffDevices.length?DB.staffDevices.map(x=>{const s=DB.staff.find(v=>v.id===x.staffId);return `
${esc(s?.name||'직원')} · ${esc(x.name)}${esc(x.deviceId)}
${esc(x.scope)}${esc(x.status)}
`}).join(''):'
등록된 직원 휴대폰이 없습니다.
'; const mc=document.getElementById('memberCredentialList');if(mc)mc.innerHTML=DB.memberCredentials.length?DB.memberCredentials.map(x=>{const m=DB.members.find(v=>v.id===x.memberId);return `
${esc(m?.name||'회원')} · ${esc(x.type)}${esc(x.token)}
${esc(x.status)}
`}).join(''):'
등록된 회원 인증수단이 없습니다.
'; const bc=document.getElementById('beaconList');if(bc)bc.innerHTML=DB.beacons.length?DB.beacons.map(x=>`
${esc(x.name)}${esc(x.beaconId)}
${esc(x.location)}${esc(x.range)}
`).join(''):'
등록된 BLE 비콘이 없습니다.
'; } function deviceQuickSetup(){ toast('권장 순서: 직원 휴대폰 → 센터 BLE → 회원 QR/NFC → 차량 BLE 등록'); } function findCredentialMember(code){ ensureDeviceData(); const rec=DB.memberCredentials.find(x=>x.token===code&&x.status==='사용중'); if(rec)return DB.members.find(m=>m.id===rec.memberId); return resolveMemberFromCodeV24(code); } /* ============ V2.8 COMMUNICATION / ALERTS ============ */ let activeCommChannel='all'; function ensureCommData(){ if(!DB.commChannels)DB.commChannels=[]; if(!DB.commMessages)DB.commMessages=[]; if(!DB.memberFocus)DB.memberFocus=[]; if(!DB.handovers)DB.handovers=[]; if(!DB.timeAlerts)DB.timeAlerts=[]; if(!DB.notices)DB.notices=[]; if(!DB.commChannels.length){ DB.commChannels=[ {id:'all',name:'전체 직원',type:'staff'}, {id:'care',name:'케어·회원공유',type:'staff'}, {id:'transport',name:'송영팀',type:'staff'} ]; } } function renderComm(){ ensureCommData(); const cl=document.getElementById('commChannelList');if(!cl)return; cl.innerHTML=DB.commChannels.map(c=>`
${esc(c.name)}${c.type==='staff'?'직원 업무대화':'대화방'}
`).join(''); const ch=DB.commChannels.find(c=>c.id===activeCommChannel)||DB.commChannels[0]; document.getElementById('commThreadTitle').textContent=ch.name; const body=document.getElementById('commThreadBody'); const msgs=DB.commMessages.filter(m=>m.channelId===activeCommChannel).slice(-60); body.innerHTML=msgs.length?msgs.map(m=>{ const me=m.staffId===currentStaffId; return `
${esc(m.staffName||'직원')}${esc(m.time||'')}${esc(m.priority||'일반')}
${esc(m.text)}
${m.memberId?``:''}${messageReadLabel(m)}
`; }).join(''):'
아직 메시지가 없습니다.
'; body.scrollTop=body.scrollHeight; const quick=document.getElementById('commMemberQuickTags');if(quick)quick.innerHTML=activeMembers().slice(0,8).map(m=>``).join(''); const today=todayStr(); const tm=DB.commMessages.filter(m=>m.date===today); document.getElementById('commUnread').textContent=0; document.getElementById('commImportant').textContent=tm.filter(m=>m.priority==='중요'||m.priority==='긴급').length; document.getElementById('commTagged').textContent=tm.filter(m=>m.memberId).length; document.getElementById('commToday').textContent=tm.length; const badge=document.getElementById('commBadge');if(badge)badge.textContent=tm.filter(m=>m.priority==='긴급').length; } function selectCommChannel(id){activeCommChannel=id;renderComm();} function createCommChannelDemo(){ const name=prompt('대화방 이름을 입력하세요.');if(!name)return; DB.commChannels.push({id:uid('ch'),name,type:'staff'});saveDB();renderComm(); } function insertMemberTag(id){ const m=DB.members.find(x=>x.id===id),el=document.getElementById('commMessage');if(!m||!el)return;el.value+=(el.value?' ':'')+'@'+m.name+' ';el.focus(); } function detectTaggedMember(text){ return DB.members.find(m=>text.includes('@'+m.name))||null; } function sendCommMessage(){ ensureCommData();const text=document.getElementById('commMessage').value.trim();if(!text){toast('메시지를 입력해주세요.');return;} const s=DB.staff.find(x=>x.id===currentStaffId),tag=detectTaggedMember(text),priority=document.getElementById('commPriority').value,saveType=document.getElementById('commSaveType').value; DB.commMessages.push({id:uid('msg'),channelId:activeCommChannel,staffId:currentStaffId||'',staffName:s?.name||'직원',text,priority,date:todayStr(),time:nowHM(),memberId:tag?.id||'',saveType,targetStaffId:document.getElementById('alertStaffTarget')?.value||'',targetShift:document.getElementById('alertShiftTarget')?.value||''}); if(tag&&saveType){ DB.memberFocus.unshift({id:uid('mf'),memberId:tag.id,type:saveType,priority:priority==='확인 필요'?'확인필요':priority,text,date:todayStr(),staffId:currentStaffId||'',done:false}); if(saveType==='인수인계')DB.handovers.unshift({id:uid('ho'),memberId:tag.id,text,priority,date:todayStr(),done:false}); } saveDB();document.getElementById('commMessage').value='';renderComm();toast('메시지를 보냈습니다.'); } function buildCommAiBrief(){ const t=DB.commMessages.filter(m=>m.date===todayStr()).slice(-30),urgent=t.filter(m=>m.priority==='긴급'||m.priority==='중요'),tags=[...new Set(t.filter(m=>m.memberId).map(m=>DB.members.find(x=>x.id===m.memberId)?.name).filter(Boolean))]; document.getElementById('commAiSummary').textContent=`오늘 메시지 ${t.length}건\n중요·긴급 ${urgent.length}건\n회원 관련: ${tags.join(', ')||'없음'}\n\n확인 권장: ${urgent[0]?.text||'긴급 메시지 없음'}`; } function renderMemberFocus(){ ensureCommData();const q=(document.getElementById('memberFocusSearch')?.value||'').toLowerCase(),p=document.getElementById('memberFocusPriority')?.value||'',box=document.getElementById('memberFocusGrid');if(!box)return; const rows=DB.members.filter(m=>!q||(m.name+' '+(m.interest||'')+' '+(m.caution||'')).toLowerCase().includes(q)); box.innerHTML=rows.map(m=>{ const notes=DB.memberFocus.filter(x=>x.memberId===m.id&&!x.done&&(!p||x.priority===p)).slice(0,4); return `

${esc(m.name)} · ${m.age}세

관심: ${esc(m.interest||'미등록')}
주의: ${esc(m.caution||m.memo||'없음')}

${notes.map(n=>`${esc(n.priority)} · ${esc(n.type)}`).join('')}
${notes.map(n=>`

• ${esc(n.text)}

`).join('')}
`; }).join(''); } function openMemberFocusEditor(){ const m=activeMembers()[0];if(!m){toast('회원이 없습니다.');return;} const text=prompt(`${m.name} 회원 전달사항을 입력하세요.`);if(!text)return; DB.memberFocus.unshift({id:uid('mf'),memberId:m.id,type:'전달사항',priority:'확인필요',text,date:todayStr(),staffId:currentStaffId||'',done:false});saveDB();renderMemberFocus(); } function renderHandover(){ ensureCommData();const open=document.getElementById('handoverOpenList'),done=document.getElementById('handoverDoneList');if(!open)return; const draw=(rows)=>rows.map(h=>{const m=DB.members.find(x=>x.id===h.memberId);return `
${esc(m?.name||'업무')} · ${esc(h.priority||'일반')}${esc(h.text)}
`}).join('')||'
없음
'; open.innerHTML=draw(DB.handovers.filter(h=>!h.done));done.innerHTML=draw(DB.handovers.filter(h=>h.done)); } function quickHandover(){ const text=prompt('인수인계 내용을 입력하세요.');if(!text)return;DB.handovers.unshift({id:uid('ho'),memberId:'',text,priority:'확인필요',date:todayStr(),done:false});saveDB();renderHandover(); } function toggleHandover(id){const h=DB.handovers.find(x=>x.id===id);if(h){h.done=!h.done;saveDB();renderHandover();}} function fillAlertMember(){ const el=document.getElementById('alertMember');if(!el)return;const cur=el.value;el.innerHTML=''+activeMembers().map(m=>``).join('');if(cur)el.value=cur; } function addTimeAlert(){ ensureCommData();const title=document.getElementById('alertTitle').value.trim(),time=document.getElementById('alertTime').value;if(!title||!time){toast('제목과 시간을 입력해주세요.');return;} DB.timeAlerts.push({id:uid('ta'),title,time,repeat:document.getElementById('alertRepeat').value,priority:document.getElementById('alertPriority').value,memberId:document.getElementById('alertMember').value,type:document.getElementById('alertType').value,memo:document.getElementById('alertMemo').value.trim(),staffTarget:document.getElementById('alertStaffTarget').value,shiftTarget:document.getElementById('alertShiftTarget').value,ackMode:document.getElementById('alertAckMode').value,remind:Number(document.getElementById('alertRemind').value||0),created:todayStr(),doneDates:[]}); saveDB();document.getElementById('alertTitle').value='';document.getElementById('alertMemo').value='';renderTimeAlerts();toast('시간 알림을 저장했습니다.'); } function addAlertPreset(title,time,repeat,type){document.getElementById('alertTitle').value=title;document.getElementById('alertTime').value=time;document.getElementById('alertRepeat').value=repeat;document.getElementById('alertType').value=type;addTimeAlert();} function alertAppliesToday(a){ const d=new Date(),wd=d.getDay(); if(a.repeat==='daily')return true; if(a.repeat==='weekday')return wd>=1&&wd<=5; if(a.repeat==='weekly')return d.getDay()===new Date(a.created+'T12:00:00').getDay(); return a.created===todayStr(); } function renderTimeAlerts(){ ensureCommData();fillAlertMember();const today=document.getElementById('todayAlertList'),rep=document.getElementById('repeatAlertList');if(!today)return; const now=nowHM(),rows=DB.timeAlerts.filter(alertAppliesToday).sort((a,b)=>a.time.localeCompare(b.time)); const draw=(a)=>{const m=DB.members.find(x=>x.id===a.memberId),done=(a.doneDates||[]).includes(todayStr()),due=!done&&a.time<=now;return `
${esc(a.time)} · ${esc(a.title)}${esc(a.type)}${m?' · '+esc(m.name):''}${a.memo?' · '+esc(a.memo):''}
`}; today.innerHTML=rows.map(draw).join('')||'
오늘 알림이 없습니다.
'; rep.innerHTML=DB.timeAlerts.filter(a=>a.repeat!=='once').map(draw).join('')||'
반복 알림이 없습니다.
'; const badge=document.getElementById('alertBadge');if(badge)badge.textContent=rows.filter(a=>!(a.doneDates||[]).includes(todayStr())&&a.time<=now).length; } function toggleTimeAlertDone(id){const a=DB.timeAlerts.find(x=>x.id===id);if(!a)return;if(!a.doneDates)a.doneDates=[];const i=a.doneDates.indexOf(todayStr());if(i>=0)a.doneDates.splice(i,1);else a.doneDates.push(todayStr());saveDB();renderTimeAlerts();} function deleteTimeAlert(id){DB.timeAlerts=DB.timeAlerts.filter(x=>x.id!==id);saveDB();renderTimeAlerts();} function requestBrowserNotify(){ if(!('Notification' in window)){toast('이 브라우저는 알림을 지원하지 않습니다.');return;} Notification.requestPermission().then(p=>toast(p==='granted'?'브라우저 알림이 허용되었습니다.':'알림 권한이 허용되지 않았습니다.')); } function checkDueAlerts(){ ensureCommData();const hm=nowHM(); DB.timeAlerts.filter(alertAppliesToday).forEach(a=>{ if(a.time===hm&&!(a.doneDates||[]).includes(todayStr())&&a.lastNotified!==todayStr()+hm){ a.lastNotified=todayStr()+hm;saveDB(); if('Notification' in window&&Notification.permission==='granted')new Notification('HARUE+ Care OS',{body:a.title+(a.memo?' · '+a.memo:'')}); toast(`알림: ${a.title}`); } }); if(document.getElementById('alerts')?.classList.contains('active'))renderTimeAlerts(); } setInterval(checkDueAlerts,30000); function quickNotice(){const text=prompt('공지 내용을 입력하세요.');if(!text)return;DB.notices.unshift({id:uid('nt'),text,priority:'중요',date:todayStr(),time:nowHM(),staffId:currentStaffId||''});saveDB();renderNotices();} function renderNotices(){ensureCommData();const box=document.getElementById('noticeList');if(!box)return;box.innerHTML=DB.notices.map(n=>`
${esc(n.priority)} · ${esc(n.date)} ${esc(n.time)}${esc(n.text)}
`).join('')||'
등록된 공지가 없습니다.
';} /* ============ V2.9 COLLABORATION SUITE ============ */ function ensureCollabV29(){ if(!DB.memberRecurringSchedules)DB.memberRecurringSchedules=[]; if(!DB.guardianScheduledMessages)DB.guardianScheduledMessages=[]; if(!DB.messageReadState)DB.messageReadState={}; if(!DB.staffShifts)DB.staffShifts={}; } function fillCollabSelects(){ ensureCollabV29(); const staffOpts=''+(DB.staff||[]).filter(s=>s.active!==false).map(s=>``).join(''); const staffEl=document.getElementById('alertStaffTarget');if(staffEl){const c=staffEl.value;staffEl.innerHTML=staffOpts;if(c)staffEl.value=c;} const memOpts=activeMembers().map(m=>``).join(''); ['recMember','guardianScheduleMember'].forEach(id=>{const el=document.getElementById(id);if(el){const c=el.value;el.innerHTML=memOpts;if(c)el.value=c;}}); const gd=document.getElementById('guardianScheduleDate');if(gd&&!gd.value)gd.value=todayStr(); } function addMemberRecurring(){ ensureCollabV29();fillCollabSelects(); const memberId=document.getElementById('recMember').value,time=document.getElementById('recTime').value,type=document.getElementById('recType').value,repeat=document.getElementById('recRepeat').value,memo=document.getElementById('recMemo').value.trim(); if(!memberId||!time){toast('회원과 시간을 선택해주세요.');return;} DB.memberRecurringSchedules.push({id:uid('mrs'),memberId,time,type,repeat,memo,active:true,created:todayStr()}); saveDB();renderMemberRecurring();toast('회원 반복 스케줄을 등록했습니다.'); } function renderMemberRecurring(){ ensureCollabV29();const box=document.getElementById('recurringScheduleList');if(!box)return; box.innerHTML=DB.memberRecurringSchedules.map(x=>{const m=DB.members.find(v=>v.id===x.memberId);return `
${esc(m?.name||'회원')} · ${esc(x.time)} · ${esc(x.type)}${x.repeat==='daily'?'매일':x.repeat==='weekday'?'평일':'매주'}${x.memo?' · '+esc(x.memo):''}
`}).join('')||'
등록된 반복 스케줄이 없습니다.
'; } function deleteMemberRecurring(id){DB.memberRecurringSchedules=DB.memberRecurringSchedules.filter(x=>x.id!==id);saveDB();renderMemberRecurring();} function addGuardianScheduledMessage(){ ensureCollabV29();fillCollabSelects(); const memberId=document.getElementById('guardianScheduleMember').value,date=document.getElementById('guardianScheduleDate').value,time=document.getElementById('guardianScheduleTime').value,text=document.getElementById('guardianScheduleText').value.trim(),channel=document.getElementById('guardianScheduleChannel').value; if(!memberId||!date||!time||!text){toast('회원·일시·메시지를 입력해주세요.');return;} DB.guardianScheduledMessages.push({id:uid('gsm'),memberId,date,time,text,channel,status:'예약',created:new Date().toISOString()}); saveDB();document.getElementById('guardianScheduleText').value='';renderGuardianScheduled();toast('보호자 메시지를 예약했습니다.'); } function renderGuardianScheduled(){ ensureCollabV29();const box=document.getElementById('guardianScheduleQueue');if(!box)return; const rows=DB.guardianScheduledMessages.slice().sort((a,b)=>(a.date+a.time).localeCompare(b.date+b.time)); box.innerHTML=rows.map(x=>{const m=DB.members.find(v=>v.id===x.memberId);return `
${esc(x.date)} ${esc(x.time)} · ${esc(m?.name||'회원')}${esc(x.channel)} · ${esc(x.status)} · ${esc(x.text)}
`}).join('')||'
예약된 메시지가 없습니다.
'; } function sendGuardianScheduledNow(id){ const x=DB.guardianScheduledMessages.find(v=>v.id===id);if(!x)return;x.status='발송완료';x.sentAt=new Date().toISOString();saveDB();renderGuardianScheduled();toast('보호자 메시지 발송을 시연했습니다.'); } function deleteGuardianScheduled(id){DB.guardianScheduledMessages=DB.guardianScheduledMessages.filter(x=>x.id!==id);saveDB();renderGuardianScheduled();} function processGuardianScheduleQueue(){ ensureCollabV29();const hm=nowHM(),today=todayStr();let changed=false; DB.guardianScheduledMessages.forEach(x=>{if(x.status==='예약'&&x.date===today&&x.time<=hm){x.status='발송대기';changed=true;}}); if(changed)saveDB(); } setInterval(processGuardianScheduleQueue,30000); function markMessageRead(id){ ensureCollabV29();if(!DB.messageReadState[id])DB.messageReadState[id]=[];if(currentStaffId&&!DB.messageReadState[id].includes(currentStaffId))DB.messageReadState[id].push(currentStaffId);saveDB();renderComm(); } function messageReadLabel(msg){ const reads=(DB.messageReadState[msg.id]||[]).length,total=Math.max(1,(DB.staff||[]).filter(s=>s.active!==false).length); return reads>=total?`읽음 ${reads}/${total}`:`미확인 ${total-reads}`; } /* ============ INIT ============ */ function renderAll(){ ensureV21Data(); renderDashboard(); renderMembers(); renderCrm(); renderAttendance(); renderHealth(); renderPrograms(); renderGuardianSelect(); renderSettings(); } function init(){ DB=loadDB(); buildMobileTabs(); renderAll(); initAuth(); } init(); /* ---------- 로그인 / 로그아웃 ---------- */ function populateLoginStaffSelect(){ const sel=document.getElementById('loginStaffSelect'); if(!sel)return; sel.innerHTML=''+DB.staff.map(s=>``).join(''); } function showLoginError(msg){ const el=document.getElementById('loginError'); if(!el)return; el.textContent=msg; el.style.display='block'; } function attemptLogin(){ const id=document.getElementById('loginStaffSelect').value; const pw=document.getElementById('loginPw').value; if(!id){showLoginError('근무자를 선택해주세요.');return;} const staff=DB.staff.find(s=>s.id===id); if(!staff){showLoginError('근무자 정보를 찾을 수 없습니다.');return;} if(pw!==(staff.pw||'1234')){showLoginError('비밀번호가 일치하지 않습니다.');return;} document.getElementById('loginError').style.display='none'; document.getElementById('loginPw').value=''; localStorage.setItem('careos_authed_staff',id); setCurrentStaff(id); document.body.classList.add('authed'); try{sessionStorage.setItem('harue_careos_session_v26','1');localStorage.setItem('harue_careos_session_v26','1');}catch(e){} toast(staff.name+' 님, 환영합니다.'); } function logoutCareOS(){ localStorage.removeItem('careos_authed_staff'); localStorage.removeItem('harue_careos_session_v26'); sessionStorage.removeItem('harue_careos_session_v26'); document.body.classList.remove('authed'); location.href='/main/'; } function changeMyPassword(){ const id=document.getElementById('loginStaffSelect').value; if(!id){showLoginError('먼저 근무자를 선택해주세요.');return;} const staff=DB.staff.find(s=>s.id===id); if(!staff){showLoginError('근무자 정보를 찾을 수 없습니다.');return;} const cur=prompt(staff.name+' 님, 현재 비밀번호를 입력해주세요.'); if(cur===null)return; if(cur!==(staff.pw||'1234')){showLoginError('현재 비밀번호가 일치하지 않습니다.');return;} const next=prompt('새 비밀번호를 입력해주세요. (4자 이상)'); if(next===null)return; if(next.trim().length<4){showLoginError('비밀번호는 4자 이상으로 설정해주세요.');return;} const confirmPw=prompt('새 비밀번호를 한 번 더 입력해주세요.'); if(confirmPw!==next){showLoginError('입력한 비밀번호가 서로 다릅니다.');return;} staff.pw=next.trim(); saveDB(); document.getElementById('loginError').style.display='none'; toast('비밀번호가 변경되었습니다. 새 비밀번호로 로그인해주세요.'); } function initAuth(){ populateLoginStaffSelect(); const savedId=localStorage.getItem('careos_authed_staff'); const sessionOK = sessionStorage.getItem('harue_careos_session_v26')==='1' || localStorage.getItem('harue_careos_session_v26')==='1'; if((savedId && DB.staff.some(s=>s.id===savedId)) || sessionOK){ document.body.classList.add('authed'); const ls=document.getElementById('loginScreen'); if(ls){ ls.style.setProperty('display','none','important'); ls.style.setProperty('pointer-events','none','important'); ls.setAttribute('aria-hidden','true'); } }else{ location.replace('/main/'); } } /* casual right-click / image dragging deterrent */ try{ const evDateEl=document.getElementById('evDate'); if(evDateEl && !evDateEl.value) evDateEl.value=todayStr(); selectedScheduleDate=todayStr(); calendarCursor=new Date(); calendarCursor.setDate(1); }catch(e){} try{ensureV21Data();fillV21MemberSelects();generateTodayBriefing();}catch(e){} try{ensureWelfareData();}catch(e){} try{fillQuickAccessMember();}catch(e){} try{ensureTransportV25();}catch(e){} try{ensureDeviceData();}catch(e){} try{ensureCommData();fillAlertMember();checkDueAlerts();}catch(e){} try{ensureCollabV29();fillCollabSelects();processGuardianScheduleQueue();}catch(e){} document.addEventListener('contextmenu',e=>e.preventDefault()); document.addEventListener('dragstart',e=>{if(['IMG','SVG'].includes(e.target.tagName))e.preventDefault();}); document.querySelectorAll('.overlay').forEach(ov=>ov.addEventListener('click',e=>{if(e.target===ov)ov.classList.remove('open');}));