85 Commits

Author SHA1 Message Date
taekyoungc
215d87f991 feat: 워크스페이스 탭 자동복원 + 주문 입금확인 토글 + 메뉴 개명(전화접수→주문접수)
종량제3 세션 작업.
- 워크스페이스 탭 계정별 저장·자동복원(member_workspace_tabs 자동생성, pref/workspace-tabs)
- 주문접수 관리 입금확인(so_paid) 수동 토글(phoneOrderMarkPaid, order/phone/manage/paid)
- 메뉴 '전화 접수 관리' → '주문접수관리' 개명(피드백 #35) + 시드/개명 SQL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:04:45 +09:00
taekyoungc
dc5c38d242 style: 브랜드 로고 아이콘 SVG 교체 (로그인·헤더·포털·환영 화면)
종량제3 세션 작업. GBLS 브랜드 로고를 새 아이콘 형태로 통일.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:59:06 +09:00
taekyoungc
6c421153e4 feat: 전화접수 관리 — 스캔 현황·포장량·포장 명세 보기 UI
전화접수 관리 화면에 스캔 현황(포장량 합계, 최근 스캔), 포장 명세 보기 모달 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:45:28 +09:00
taekyoungc
5e8e81f404 feat: 지정판매소 개인정보(PII) 보호 — 마스킹·암호화·열람 게이트 (기본 OFF)
종량제3 세션 작업. 마스터 플래그(Config\Pii.designatedShopProtection)가 OFF면
현재 동작(원문 그대로) 유지, ON일 때만 마스킹·게이트 적용.

- 표시 계층 단일 지점 마스킹: 대표자명/전화/이메일/계좌/가상계좌 (목록·상세·엑셀·판매대장·전화주문)
- 원문 열람 게이트: 지자체 허용 IP 자동열람 or 2단계 인증(TOTP) step-up + 감사기록(revealPii)
- 정확일치 검색: 블라인드 인덱스(HMAC) 컬럼 있으면 사용, 없으면 평문 폴백
- 저장계층 암호화(encryptAtRest, 기본 OFF): 모델 콜백 + spark 명령 pii:protect-designated-shops
- SQL(추가·멱등): designated_shop_add_pii_bidx / local_government_add_allow_ips
- Auth: TOTP 실제 통과 시에만 세션 auth_2fa_verified=true

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:45:28 +09:00
taekyoungc
c5df784f91 feat: 실사 재고 바코드 리스트 레이지 로딩 — 스크롤 시 추가 로딩
- 상위 2,000건 상한 제거 → 초기 200건 렌더 후 스크롤 시 청크(200건) 추가 로딩
- 서버: ajax=1&offset&limit 로 청크 JSON 반환, 합계는 SQL 집계(전체 행 미적재)
- 클라: IntersectionObserver 자동 로딩 + '더 보기' 폴백 버튼
- 인쇄 시 전체 먼저 로드 후 출력(부분 인쇄 방지)
- fetch는 현재 origin 기준 상대경로로 호출(baseURL/포트 불일치에도 동작)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:29:50 +09:00
taekyoungc
a3f3e9cd64 feat: 실사 재고 바코드 리스트 — 창고에 있어야 하는 번호 조회/엑셀/인쇄
문의 #34: 실사 시 현재 창고에 남아있어야 하는(재고 상태) 봉투 번호(바코드)
목록을 뽑아 실물과 대조할 수 있게 신규 화면 추가.

- bag/inventory/stock-barcode-list 라우트 + Bag::stockBarcodeList()
- bag_receiving_pack_code 중 brpc_state='in_stock' 기준(기존 실사 스냅샷과 동일)
- 품목/번호(박스·팩·시트·LOT) 필터, 인쇄, 엑셀저장(전체)
- 화면 표시는 상위 2,000건 상한 + 안내 배너(합계·엑셀은 전체 기준)
- 재고 관리 메뉴에 전 지자체 등록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:59:13 +09:00
taekyoungc
f624c13b5b feat: 모든 상위메뉴 소메뉴에 아이콘 표시(기본정보관리 방식 확대)
기본정보관리에만 적용되던 사이드바 소메뉴 아이콘을 전체 상위메뉴로 확대.
전화접수·발주·입고·재고·판매·수불·분석·반품/취소·바코드 등 실제 메뉴명에
맞는 Font Awesome 아이콘 키워드 매핑 확장(서버 렌더 + JS 재구성 동일 유지).
2026-07-14 12:09:31 +09:00
taekyoungc
b814892352 feat: 맞춤 집계표 엑셀 다운로드 추가
- 조회 폼에 '엑셀저장' 버튼(현재 컬럼·필터·제목·집계방식 그대로 반영)
- Excel 2003 XML로 제목·기간·품목구분 머리행 + 선택 컬럼 + 합계행 출력

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:35:13 +09:00
taekyoungc
794d34635b feat: 기본정보관리 사이드바 소메뉴에 아이콘 추가
- 이름 키워드 기반 Font Awesome 아이콘 매핑(서버 렌더 + JS 재구성 양쪽 반영)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:13 +09:00
taekyoungc
e9f1cf2b49 feat: 브랜드 파비콘(SVG) 추가 — 네이비 배경·쓰레기통·에코 잎
- public/favicon.svg 신규, 관리자/인증/포털 head에 링크(기존 .ico 폴백 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:13 +09:00
taekyoungc
3a611972c1 feat: 메뉴 등록/수정 시 상위·하위 위치 선택 + 지자체 동기화 성능 개선
- 등록/수정 폼에 상위/하위 선택(상위 메뉴 지정), 기존 메뉴도 상위↔하위 이동 가능
- 하위 보유 메뉴의 하위 변경 차단 등 검증
- syncTypeToAllLgs를 dep별 batch insert로 재작성 → 메뉴 저장 시 원격 DB 왕복 급감(30초 타임아웃 해소)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:26:58 +09:00
taekyoungc
9365848f9d feat: 문의사항 상세에서 글(본문) 수정 기능
- 의견 내용 패널에 '수정' 토글 → 본문 편집·저장(계정 스코프·2000자 제한)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:26:58 +09:00
taekyoungc
65582c3439 feat: 맞춤 집계표 — 기간·품목구분·컬럼 선택 + 제목·계정별 프리셋 저장/인쇄
- 한 화면에서 기간·품목구분(봉투/음식물/폐기물)·집계방식·표시컬럼 선택, 제목 지정 후 조회/인쇄
- 컬럼 13종 선택, 상세/품목별 집계 지원, 실판매(bag_sale) 기준
- 계정별(mb_idx) 프리셋 저장/불러오기/삭제 (report_preset 테이블)
- report_preset 테이블 없으면 프리셋만 비활성, 리포트는 정상 동작

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:26:44 +09:00
taekyoungc
03c2bbc05c feat: 글자크기를 계정별·메뉴별로 저장(재로그인 유지)
기존 전역 localStorage 방식(모든 메뉴 일괄 적용)을 화면 경로(메뉴키) 기준
계정별 DB 저장으로 변경. A−/A+ 조정 시 해당 화면만 조정·저장되고, 다음
방문·재로그인 시 각 화면의 저장값이 서버 주입으로 복원된다.

- member_menu_font_scale 테이블 + 저장 엔드포인트(bag/pref/font-scale)
- member_font_scale_for() 헬퍼로 렌더 시 현재 화면 저장값 주입
- admin/portal/embed 레이아웃 + 워크스페이스(탭 iframe)에 적용
  · 워크스페이스는 포커스된 탭(메뉴)만 조정, 디바운스 저장으로 CSRF 경합 방지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:12:43 +09:00
taekyoungc
62f7bda290 feat: 발주입고(스캐너)·일괄입고 좌우 워크스페이스 재설계 + 실시간 스캔 입고
- 발주입고/일괄입고를 공용 워크스페이스로 통합: 좌측 발주현황(탭: 제작업체·발주기간 /
  인수자·인계자, 정렬) · 우측 선택발주 상세 + 스캔/수량 입고 · 하단 입고 세부내역(박스코드)
- 실시간 스캔 입고 엔드포인트 receivingScanBox 추가(스캔 1회=1박스, 수량 직접입력 병행)
- buildReceivingCandidateRows에 카테고리(봉투/음식물·폐기물)·발주기간·대행소 필터 추가
- 일괄입고를 '일괄입고(음식물,폐기물)'로 개편, 음식물·폐기물만 표시(메뉴명 변경 SQL 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:20:14 +09:00
taekyoungc
7e958a7ac9 feat: 리스트 페이징을 lazy loading(무한 스크롤)으로 표준화
공용 CI4 pager 템플릿(components/pager.php)을 페이지 번호 대신
무한 스크롤 방식으로 교체. 화면 하단 도달 시(IntersectionObserver) 다음
페이지 HTML을 받아 리스트 표에 이어붙이고, '더보기' 버튼을 폴백으로 둔다.
Config\Pager의 default 템플릿이라 전체 리스트 화면에 일괄 적용된다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:02:20 +09:00
taekyoungc
bfcdd055b2 style: 문의사항 '답변 완료' 배지 색상 회색으로 통일
목록형(index)의 '답변 완료' 배지를 teal → gray로 변경해 전체보기(all)와 통일.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:44:19 +09:00
taekyoungc
8d06c8eab4 fix: 사용자 의견 목록/전체보기에서 텍스트 드래그·선택 가능하도록 수정
관리자 레이아웃 body의 select-none 때문에 의견 본문을 선택·복사할 수 없던
문제를 opt-in 클래스(.fb-selectable)로 해제. 목록형·전체보기 화면에 적용하고,
목록형은 텍스트 선택 중 행 클릭 시 상세로 이동하지 않도록 가드 추가.
(의견 상세 화면은 이미 동일 방식으로 적용됨)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:32:57 +09:00
taekyoungc
8339f69303 feat: 의견 상세/전체보기의 화면 URL을 클릭 가능한 링크로
fb_page_url을 embed=1 파라미터 제거 후 전체 절대 URL로 변환해
새 탭(target=_blank)으로 바로 열리게 한다. 나머지 쿼리는 보존.
2026-07-06 19:29:27 +09:00
taekyoungc
b9cc810f97 fix: 전화접수 관리 접수번호를 접수일별 1번부터 표시
- 리스트 번호·지정판매소 접수번호를 실제 so_idx 대신 접수일별 순번(가장 오래된 건=1)으로 표시
- 정렬과 무관하게 각 건의 표시번호 고정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:46 +09:00
taekyoungc
9ee5d74f04 fix: 의견 상세 페이지 텍스트 복사 가능하도록 수정
- 관리자 레이아웃의 전역 select-none 상속을 이 페이지에서만 무효화(user-select:text)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:46 +09:00
taekyoungc
26384dbe2a feat: 발주 등록/변경/삭제 화면 개선 + 발주담당 추가
- 발주 등록: 폼(좌)·발주품목 선택(우, 스크롤)·발주 이력(하단) 재배치, 수량→박스수량
- 발주담당(담당자) 선택 항목 추가(bo_manager_idx), 등록/변경 시 저장
- 발주 이력 발주일 클릭 → 발주 내역 보기 모달(변경/삭제 버튼), 삭제·취소 발주는 두 버튼 숨김
- 발주 변경: 변경구분 라디오 제거, 원 발주번호(LOT)·이력 안내 표시, 취소 시 발주 등록 화면으로 이동
- 발주 등록 화면에서도 삭제 가능(정상 발주만), 삭제 후 등록 화면 복귀
- 삭제 화면에서 삭제 대상 발주 내역 표시
- 발주 상세 JSON 엔드포인트(bag/order/detail-json) 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:32 +09:00
taekyoungc
952ca3e3e7 feat: 판매대장 하단에 용량별·품목별 통계 표시
- 현재 필터(기간·판매소·대행소·카테고리·크기) 적용 결과 기준 집계
- 용량별(리터별): 크기 오름차순, 판매량·판매금액 합계
- 품목별: 봉투/음식물/폐기물 3분류, 판매량·판매금액 합계

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:06 +09:00
taekyoungc
a61e7887ea feat: 지정판매소 관리 화면 개선 — 좌우 배치·상세 폼·리스트 정렬·동코드 표시
- 리스트(좌)/상세정보(우) 좌우 배치, 상세를 라벨-값 폼 형태로 변경
- 리스트 컬럼(번호·대표자·상호·구군(동코드)·지정일·구역·상태) + 컬럼별 정렬
- 페이징 제거 → 전체 로드 후 패널 내 스크롤
- 주소로 동 기본코드(D) 산출해 저장·표시(ds_dong_code), 등록 시 주소 선택하면 동코드 표시
- 인쇄·엑셀저장을 관리 화면에도 추가(엑셀에 동코드·지정일 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:54 +09:00
taekyoungc
249053412c feat: 전화접수 관리 접수리스트↔지정판매소 정보 좌우 너비 조절
두 표 사이 드래그 손잡이로 너비를 조절(리스트 축소 시 정보 확대). 비율은
localStorage에 저장되어 재접속 시 유지되고, 더블클릭으로 기본값 복귀.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:27:29 +09:00
taekyoungc
de15cc9e47 feat: '발주파일 생성'(구 LOT-No 디스켓 불출) 이름변경 + 생성 전 내용 확인
- 메뉴/화면/매뉴얼 명칭을 '발주파일 생성'으로 변경(메뉴 rename 마이그레이션 SQL 포함)
- 발주파일(seed) 생성 전 '내용 확인' 미리보기 추가(암호화 전 발주 메타·품목 표시)
  · GET bag/order/lot-seed/preview 신설, 생성/미리보기 데이터 구성 공용화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:26:58 +09:00
taekyoungc
a5d995588b docs: 지정판매소 신규/취소 현황 매뉴얼 설명 추가
'지정 판매소 신규/취소 현황' 화면의 종전/지정/취소/현행 등 컬럼 의미와
사용법 섹션을 추가하고, 해당 화면의 '이 화면 설명' 연결 힌트를 매핑.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:26:00 +09:00
taekyoungc
ace0ce6296 feat: 메뉴 관리 하위메뉴 다른 상위로 드래그 이동 + 가장자리 자동 스크롤
- 하위 메뉴를 다른 상위 메뉴 아래로 드래그해 재부모(상위 변경) 지원
- 순서+상위+깊이를 함께 저장하는 MenuModel::setStructure() 추가(자식수 재계산)
- 드래그 중 화면 상/하단 근처에서 자동 스크롤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:25:29 +09:00
taekyoungc
b3518f6777 feat: 문의사항에 '답변 완료' 상태 추가
의견 처리 상태 셀렉트박스에 '답변 완료'(answered) 옵션을 추가하고
목록 배지 색상(teal)을 지정한다. fb_status는 VARCHAR라 DB 변경 불필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:25:16 +09:00
taekyoungc
9b3f5e4701 fix: 전화접수 포장명세 표 컬럼 간격·스캔 시 최신 항목 노출 개선(#14)
봉투종류 열을 줄이고 봉투코드 열을 넓혀 간격을 좁히고, 스캔 성공 시
포장명세 스크롤을 맨 위로 리셋해 8개 초과 시에도 방금 스캔한 봉투가
가려지지 않도록 한다.
2026-07-04 15:09:01 +09:00
taekyoungc
142ed1110d fix: 지정판매소 리스트 기본 표시 행 10 → 15개
.ds-list-scroll max-height 424px → 619px (헤더 + 약 15행).
2026-07-03 19:18:52 +09:00
taekyoungc
9b28d78a90 fix: 지정판매소 리스트 기본 10행 표시 + 스크롤(#25 일부)
리스트 스크롤 영역을 헤더+약 10행(424px)으로 제한하고 나머지는 스크롤.
스크롤 시 헤더 고정(sticky), 좌측 패널을 내용 높이로 맞춰(align-self)
리스트 아래 빈 공간 방지. 좁은 화면(1024px 이하)에선 기존 동작 유지.
2026-07-03 19:15:03 +09:00
taekyoungc
f0d59b1694 feat: 담당자에 소속·상호 필드 추가(#23)
- manager 테이블에 mg_affiliation(소속)·mg_company_name(상호) 컬럼 추가
  (공유 DB 반영 완료 + writable/database에 마이그레이션 SQL 기록)
- ManagerModel allowedFields, 등록/수정 폼(담당자명 아래), store/update 저장,
  목록에 소속·상호 열 추가
2026-07-03 18:35:12 +09:00
taekyoungc
4835b2daaf fix: 전화 주문접수표 높이를 좌측(지정판매소 정보 하단)에 맞춤
우측 컬럼 높이를 좌측 컬럼 offsetHeight(zoom 무관)에 동기화하고
표 래퍼를 flex-1로 채워, 표 하단이 지정판매소 정보 카드 하단과
정확히 정렬되게 한다. 초과 품목은 표 내부 스크롤(헤더·합계 고정).
1열로 쌓이는 좁은 화면에선 자동 높이.
2026-07-03 18:17:23 +09:00
taekyoungc
378dc4de38 fix: 전화 주문 접수 화면 2열 재배치(#19)
- 좌측(좁은 컬럼): 판매소 검색/선택 → 접수 정보 → 지정판매소 정보
  (결제/가상계좌를 지정판매소 정보 카드 안으로 병합)
- 우측(넓은 컬럼): 전화 주문접수표(일괄)를 최상단부터 표시
- 기존 결제/가상계좌가 있던 우측 자리를 접수표가 차지하도록 변경
2026-07-03 18:10:30 +09:00
taekyoungc
9d8e237eb0 fix: [개발용 임시] 후보 바코드 표를 페이지 맨 아래로 이동
스캔칸과 포장 명세 사이에 있던 후보 표를 두 카드 그리드 밖(페이지 최하단,
전체 폭)으로 옮기고, 상세 패널은 스캔칸→포장 명세 원래 배치로 복구.
기능(주문 선택 시 표시, 바코드 클릭 시 스캔칸 입력, 스캔 후 갱신)은 유지.
2026-07-02 20:08:07 +09:00
taekyoungc
e198838998 feat: 전화접수 관리에 [개발용 임시] 스캔 가능 바코드 후보 표 추가
지정판매소 판매의 dev-saleable-barcodes API를 재사용해, 주문 선택 시
그 판매소·주문 기준의 판매 테스트용 바코드 후보를 표로 표시한다.
바코드를 클릭하면 봉투코드 스캔칸에 자동 입력되어 테스트가 쉬워진다.
스캔 성공 시 후보 목록도 갱신(판매된 팩 제외). 개발 완료 후 제거 예정.
2026-07-02 19:31:35 +09:00
taekyoungc
130fb5ffdb feat: 전화접수 관리에 봉투코드 스캔 판매·포장 처리 기능 추가
지정판매소 판매의 스캔 로직(resolveDesignatedSaleBarcode 등)을 재사용해
전화접수 관리 화면에서 바로 봉투코드를 스캔하면:
- 접수 리스트에서 선택된 주문의 판매소로 즉시 판매처리(bag_sale 기록,
  재고 차감, 팩/박스코드 sold 전환, 전량 판매 시 so_received=1)
- 해당 품목의 포장량(soi_packed_qty)에 스캔 수량 누적 반영(수정 불가 표시)
- 포장 명세를 실제 스캔한 봉투코드 내역(봉투코드·수량·팩/박스/낱장)으로 표시

- 라우트: POST bag/order/phone/manage/scan → Bag::phoneOrderManageScan (JSON, 즉시 처리)
- 잔량 한도는 접수량(soi_qty), 중복/미등록/재고없음 바코드 거부
- CSRF는 쿠키+regenerate 환경이라 응답에 새 토큰을 실어 다음 스캔에 갱신
- phoneOrderManage 페이로드에 주문별 스캔 내역(scans)·포장량 집계 포함
2026-07-02 19:18:44 +09:00
taekyoungc
faaadf8543 fix: 전화 주문접수표(일괄) 높이 제한 + 헤더/합계 고정 스크롤
표가 화면에 맞는 높이(약 52vh)로 표시되고 나머지 품목은 스크롤로 보이게
한다. 스크롤 시 2행 그룹 헤더는 상단에, 합계 행은 하단에 sticky로 고정.
- 2번째 헤더행 top 오프셋은 JS가 첫 헤더행 offsetHeight로 계산(zoom 무관).
- 컨테이너 padding 제거로 헤더 위쪽 틈으로 행이 비치는 현상 방지.
2026-07-02 13:58:56 +09:00
taekyoungc
386b547e0e fix: 의견 전체보기 첨부 이미지 확대(클릭 없이 바로 보이게)
220x170 축소 썸네일 → 최대 640px 폭으로 확대, 여러 장일 때도
가로로 좁아지지 않게 세로로 나열. 원본 크기가 필요하면 계속
클릭해서 새 창으로 열 수 있다.
2026-07-02 13:31:31 +09:00
taekyoungc
9bf1b34faf fix: 전화 주문접수표 판매수량 입력 시 열 위치 흔들림 고정(#12)
table-layout: fixed + colgroup으로 전 컬럼 폭을 고정해, 판매수량
자릿수가 바뀌어도 다른 컬럼 위치가 좌우로 움직이지 않게 한다.

검증 중 발견해 함께 수정:
- "봉투종류" 컬럼을 width:auto로 두면 화면이 좁거나 텍스트 확대 시
  0으로 찌부러지는 문제가 있어, 최장 품목명 실측 기준 고정폭(11rem)으로 변경.
- "구분" 컬럼(4.5rem)이 "음식물 스티커"·"대형폐기물 스티커" 라벨보다 좁아
  옆 컬럼과 텍스트가 겹치던 문제를 8rem으로 넓혀 해결.
- 공간이 부족하면 기존 overflow-auto로 표 전체가 가로 스크롤된다.
2026-07-02 13:24:10 +09:00
taekyoungc
5a8e61227e fix: 접수 리스트 행이 적을 때 과도하게 늘어나는 문제 수정
h-full로 데이터 행을 늘려 표를 꽉 채우던 방식이, 행이 2~3개뿐일 때
각 행을 400px 이상으로 늘려버려 오히려 어색했다. h-full을 제거해
행 높이는 상단부터 항상 일정한(원래) 크기로 표시되고, 남는 공간은
빈 공간으로 유지되도록 되돌린다. 카드 높이 동기화(syncListHeight)는
그대로 유지되어 카드 간 높이 불일치는 계속 없다.
2026-07-02 12:52:32 +09:00
taekyoungc
f0182f75c2 feat: 사용자 의견(피드백) 전체보기 — 한 페이지 스크롤로 전체 확인/처리
- /admin/feedback/all: 모든 의견을 상세 클릭 없이 전체 내용+첨부이미지로
  세로 나열, 상단 빠른이동 앵커(#fb-{id})로 특정 건 바로 스크롤
- 각 항목에 상태·메모 인라인 저장 폼 포함(저장 후 같은 위치로 복귀)
- 목록형(index) 화면에 "전체보기(스크롤)" 링크 추가
2026-07-02 12:32:02 +09:00
taekyoungc
5ef50344af feat: 사용자 피드백(문의사항) 일괄 반영
- #5/#6/#12 전화 주문 접수: 판매수량 입력 중 엔터키 → 다음 봉투 종류 수량칸으로 이동
- #7 주문접수 관리: 배달일을 접수일 이전으로 변경 불가(클라이언트 min + 서버 검증)
- #9 주문접수 관리: 주문수정저장·영수증출력·주문취소 버튼을 접수품목내역 타이틀 옆으로 이동
- #10 주문접수 관리: 접수리스트 하단에 건수·금액 합계 표시(취소 제외)
- #13 전화 주문 접수: 접수표 행·열 간격 축소(한 화면 표시)
- #14 주문접수 관리: 포장명세 봉투종류~봉투코드 컬럼 간격 조정
- #16 판매대장: 품목 필터 순서 변경(일반용→재사용→공공용→음식물→폐기물)
- #18 재고 현황: 종류~계 사이 간격 축소(표 최대폭 제한)
- #20/#21 주문접수 관리: 화면명 '주문접수 관리'로 변경, 조회 기준 배달일→접수일,
  기본 조회일=오늘(초기화 시 당일), 접수번호 매일 1번부터 시작
- #23 담당자 구분 라벨 '구·군' → '지자체'
2026-07-02 11:53:31 +09:00
taekyoungc
f76892a6ce fix: 기본코드관리 표 텍스트 세로 줄바꿈 방지(whitespace-nowrap)
코드명 등 폭 미지정 열이 좁아지면 글자 단위로 줄바꿈되어 세로로
표시되던 문제. 두 테이블의 모든 th/td에 whitespace-nowrap을 추가해
가로로 표시되게 하고, 필요 시 컨테이너의 overflow-auto로 가로 스크롤.
2026-07-02 11:18:47 +09:00
taekyoungc
aca40797aa fix: 기본코드관리 두 패널 2열 전환 기준 xl→lg (텍스트 100%에서도 좌우 배치)
워크스페이스 탭은 텍스트 크기를 zoom으로 조절하는데 zoom이 반응형
브레이크포인트에 영향을 줘서, 100%에선 1280px(xl) 미만이라 두 패널이
세로로 쌓였다. 기준을 lg(1024px)로 낮춰 100%에서도 좌우 2열로 표시.
2026-07-02 10:09:08 +09:00
taekyoungc
762a32e8f2 fix: 전화접수 접수 리스트 높이를 상세 카드에 JS 동기화(텍스트 줄이기 시 공백 제거)
- 원인: 워크스페이스 탭(embed)의 '텍스트 줄이기'는 html에 zoom을 적용하는데,
  상세 카드(프로필+접수품목+포장명세+버튼)가 72vh보다 크면 리스트 스크롤이
  max-h-[72vh]로 잘려 카드보다 작아져 하단 공백 발생.
- 수정: 리스트 스크롤의 max-h-[72vh] 제거, offsetHeight(zoom 무관 레이아웃 px)로
  상세 카드 높이에 맞춰 리스트 스크롤 높이를 JS 동기화. 배율과 무관하게 두 카드가
  같은 높이가 되고, 행이 적으면 h-full로 채워지고 많으면 내부 스크롤.
- resize/storage(jrj_font_scale)/ResizeObserver로 재동기화.
2026-07-01 15:38:28 +09:00
taekyoungc
259a3159f0 fix: 전화접수 관리 접수 리스트 카드 높이·행 채움 개선
- 그리드 items-start 제거 → 접수 리스트 카드가 지정판매소 정보 카드와 같은 높이로 정렬
- 접수 리스트 카드를 flex 컬럼으로, 스크롤 영역을 flex-1 min-h-0로 하여 카드 높이를 꽉 채움
- 리스트 테이블에 h-full 적용 → 데이터 행이 적을 때 행 높이가 늘어나 표 하단 공백 제거(많을 땐 스크롤)
2026-07-01 14:46:02 +09:00
taekyoungc
9933a18e03 fix: 전화주문 접수 관리 화면 표 레이아웃/정렬 개선
- 접수 리스트(전화) 카드를 넓게, 지정판매소 정보 카드를 좁게 비율 조정
- 접수 품목 내역/포장 명세 표 헤더를 sticky 처리하고 높이를 행 4개 기준으로 고정
- 그리드 items-start 적용해 접수 리스트 카드 하단 빈 공간 제거
- 포장 명세 품목을 soi_idx 내림차순으로 정렬
2026-07-01 11:48:23 +09:00
taekyoungc
1e21f07fd6 fix: 포장명세 표 sticky 헤더 배경 투명 문제 수정
.data-table thead th { background: transparent } 규칙이 Tailwind bg-gray-50 클래스보다
CSS 우선순위가 높아 헤더가 투명해짐 → 스크롤 시 아래 행 텍스트가 헤더와 겹쳐 보임.
인라인 style로 배경을 강제 지정해 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:15:44 +09:00
taekyoungc
2415fc56bd fix: 전화주문 접수 관리 포장명세 표 스크롤 시 헤더 겹침 수정
thead에 직접 sticky를 걸면 스크롤 컨테이너 클리핑이 깨져 행이 헤더 위로 삐져나옴 →
각 th에 개별적으로 sticky top-0 적용하는 방식으로 변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:10:07 +09:00
taekyoungc
05d9d8aff3 hotfix: auditSkipUpdateOnlyFields 프로퍼티→메서드로 변경(로그인 500 수정)
trait와 MemberModel이 같은 프로퍼티를 다른 기본값으로 선언해 PHP 비호환 fatal →
로그인(member update) 시 500. 메서드 override 방식으로 변경해 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:06:07 +09:00
taekyoungc
329fbf1592 fix: 로그인/세션 갱신만 있는 member 수정은 활동 로그 제외
페이지 접속·로그인 시 세션토큰/접속시각 갱신이 '로그인'으로 잔뜩 기록되던 노이즈 제거.
Auditable에 auditSkipUpdateOnlyFields 추가, MemberModel에서 세션/로그인 필드 지정.
(로그인 자체는 '로그인 이력'에서 별도 확인)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:11:38 +09:00
taekyoungc
0a39668478 feat: 활동 로그에 엑셀 다운로드/인쇄 기록 추가
- export_*: 모든 엑셀/CSV 다운로드를 'export' 액션으로 자동 기록(파일명 포함)
- 인쇄: 레이아웃 beforeprint beacon → bag/activity/print-log 가 'print' 액션 기록(화면·경로)
- 활동 로그 뷰어에 엑셀 다운로드/인쇄 라벨·배지·필터·요약 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:39:37 +09:00
taekyoungc
bf9fbd57a4 fix: 기간별 판매현황 기본 조회기간을 '오늘로부터 일주일 전 ~ 오늘'로 수정
기존 'monday this week'는 월요일 접속 시 시작=종료=오늘이 되는 문제 →
시작일 = 오늘-7일, 종료일 = 오늘 으로 변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:26:53 +09:00
taekyoungc
8330790741 fix: 전화주문 접수 관리 포장 명세 높이 제한(40vh)+스크롤, 헤더 고정
포장 명세 표가 데이터만큼 길어져 화면을 넘기던 문제 →
컨테이너 max-height 40vh + overflow 스크롤, thead sticky 고정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:48:16 +09:00
taekyoungc
7cbe8877e3 fix: 판매대장 품목 필터 라벨 'OO 봉투'로 통일 + 스티커·공동주택용·용기 체크박스 제거
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:22:54 +09:00
taekyoungc
34a9ecec24 feat: 판매대장 크기 필터 봉투 카테고리 라벨 'OO 봉투'로 통일 + 스티커·공동주택용·용기 제외
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:02:29 +09:00
taekyoungc
b32656af91 docs: 매뉴얼에 활동 로그 접근 가능 등급 안내 추가
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:35:45 +09:00
taekyoungc
36b9ada903 style: 활동 로그 목록 값 축약 기준 30자 → 10자
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:11:58 +09:00
taekyoungc
634b29148c fix: 판매 대행소 관리 목록을 최신 등록순(번호 내림차순)으로 정렬
기존 sa_kind/sa_code 오름차순이라 새로 등록한 대행소가 중간에 표시되던 문제 →
방금 등록한 건이 맨 위에 보이도록 sa_idx DESC 정렬 (드롭다운용 orderForDisplay는 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:58:20 +09:00
taekyoungc
264f190121 feat: 활동 로그를 사람이 읽기 쉬운 문장으로 표시
- '내용' 컬럼: 대상명+작업(예: 업체 'OO' 수정), 변경 항목(라벨: 전→후) 인라인 표시
- 로그인(member housekeeping 수정)은 '로그인'으로 구분 표시
- 컬럼 한글 라벨(정확매칭+접미사 추정), 레코드 이름 자동 추출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:46:54 +09:00
taekyoungc
7294c56c50 fix: 앱 타임존 Asia/Seoul 적용(시각 9시간 오차 수정) + 활동로그 IP 표시 제거
- appTimezone UTC → Asia/Seoul: date()가 KST로 기록되어 DB(KST)와 일치
- 활동 로그 목록/상세에서 IP 컬럼 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:21:33 +09:00
taekyoungc
41b6ccc339 fix: 기본코드 등록/수정 화면 탭 안 중첩 셸 제거
- CodeKind/CodeDetail create·edit 가 view('admin/layout') 직접 사용 → 탭(iframe) 안에서 풀셸 중첩
- renderWorkPage 로 교체 + renderWorkPage 가 embed 요청이면 경로 무관하게 embed 레이아웃 사용하도록 일반화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:57 +09:00
taekyoungc
003cf28183 style: 활동 로그 조회 작업 필터 셀렉트 너비 확대
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:56 +09:00
taekyoungc
8d49247803 feat: 사용자 작업(감사) 로그 자동 기록 + 관리자 조회 화면
- Auditable 트레잇: 모델 insert/update/delete 시 activity_log 자동 기록
  (조회 제외, before/after JSON, 민감필드 마스킹, 사용자/IP)
- 도메인 모델 28개에 적용 (로그성 테이블 제외)
- 관리자 전용 조회: Admin\ActivityLog (기간/작업/대상/사용자 필터 + 상세 변경내역)
- 라우트 admin/access/activity-logs(+상세), 메뉴 '활동 로그' 추가
- 테넌트 분리: 슈퍼=전체, 지자체관리자=소속 지자체만

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:13:05 +09:00
taekyoungc
a277e083da fix: 로그인 후 뒤로가기 시 역할별 홈으로 복귀(엉뚱한 화면 방지)
이전 수정이 로그인 상태 사용자를 무조건 '/'(워크스페이스)로 보내,
관리자(/admin)에서 뒤로가기하면 다른 화면으로 가던 문제 수정.
역할별 홈으로 리다이렉트(지자체관리자→/admin, 슈퍼→지자체 선택여부에 따라 /admin 또는 선택화면, 일반→/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:22:49 +09:00
taekyoungc
933d174fa4 fix: 로그인 후 뒤로가기 시 로그인 화면 머무름 방지
- 로그인 페이지에 캐시 금지 헤더(no-store) 추가 → 뒤로가기 시 서버 재요청
- bfcache 복원(pageshow persisted) 시 새로고침 → 로그인 상태면 대시보드로 리다이렉트

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:15:17 +09:00
taekyoungc
4fef5556ca feat: 년 판매현황 엑셀 최상단에 지역/범위·단위·출력일 제목 블록 추가
- export_excel_2003_xml 에 헤더 위 제목행(topRows) 옵션 추가(하위호환)
- 년판매 export: '시도 구군 / 범위', '(단위 : 매/원)', '연도 판매현황 · 대행소', '출력일' 표기

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:59:53 +09:00
taekyoungc
9f16942366 feat: 기간별 판매현황 기본 조회 기간을 이번달 → 이번주(월~오늘)로
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:52:39 +09:00
taekyoungc
bdeef2c69d feat: 판매대장 크기 필터를 봉투/음식물/폐기물 카테고리별 그룹으로
- 크기 드롭다운을 카테고리별 optgroup(봉투(일반용)/음식물/폐기물 등)으로 묶음
- 선택값을 '카테고리|크기'(예: general|3L)로 바꿔 카테고리+크기 조합 필터링
- 엑셀저장에도 size 파라미터 반영(기존 누락 수정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:41:55 +09:00
taekyoungc
d366661b4b fix: embed(탭) GET 조회 폼에서 중첩 셸 표시 방지
GET 폼 제출 시 action 쿼리스트링에서 embed=1 이 빠져 전체 크롬이
탭 안에 다시 그려지는 문제 → 숨김 input(embed=1) 주입으로 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:29 +09:00
taekyoungc
5b903c9aa6 feat: 지정판매소 판매대장 크기(리터)별 품목 필터
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:29 +09:00
taekyoungc
acce968f9c feat: 전화접수 관리·주문 개선 + 매뉴얼 화면 바로가기
- 전화접수 관리: 배달일 기준 조회, 컬럼 보강(구분/포장 O·X/결제/수령/입금/총금액/취소),
  헤더 정렬, 배달일 수정 저장, 포장 명세·포장량 수정, 입금자 영수증 출력
- 전화 주문 접수: 행추가 → 일괄표(카테고리 그룹·포장 헤더)
- 매뉴얼 소제목에 '화면 열기' 바로가기(실제 GET 라우트만 연결)
- shop_order_item.soi_packed_qty 컬럼 추가(SQL)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:21 +09:00
taekyoungc
8e767ff234 feat(security): 중복 로그인 방지 (나중 로그인 우선)
- 로그인 시 세션 토큰 발급→회원 행(mb_session_token) 저장
- SessionGuardFilter(전역 before)로 매 요청 토큰 대조, 다르면 세션 무효화 후 로그인 이동
- DB 공유로 로컬·운영 등 서버 간에도 동작. 기존 로그인 세션은 재로그인 전까지 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:02:52 +09:00
taekyoungc
76260572b1 feat(security): 로그인 알림 — 직전 로그인과 IP 비교, 새 환경 로그인 시 경고
completeLogin에서 직전 성공 로그인 IP와 비교해 다르면 로그인 메시지에 경고 표시(이전 로그인 일시·IP 안내). 기존 member_log 활용, 외부 인프라 불필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:40:20 +09:00
taekyoungc
3b3f83461a feat(security): 비밀번호 정책 강화 — 8자+영문·숫자·특수 3종 + 안내문구
- 회원가입·비밀번호 변경·관리자 회원 등록/수정에 정책 적용
- 정규식 규칙을 배열 형식으로 적용(파이프 파싱 깨짐 → is_unique 등 검증 무력화 버그 수정)
- 비밀번호 입력란에 형식 안내문구 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:01:18 +09:00
taekyoungc
8ba9c650e8 feat: 전화접수 관리 — 접수 리스트 컬럼 보강·헤더 정렬, 배달일 수정 저장
- 접수 리스트에 접수일/배달일/금액 컬럼 추가, 헤더 클릭 정렬(번호·접수일·배달일·판매소·금액·상태, 오름/내림 토글)
- 우측 상세에서 배달일을 date 입력으로 수정 가능, 저장 시 so_delivery_date 변경 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:23:55 +09:00
taekyoungc
a7a2754fb7 feat: 전화접수 관리 기간조회·지정판매소 정보 패널 + 날짜 입력 월 한글화
- 전화접수 관리: 접수일 기간 조회 필터, 선택 주문의 지정판매소 정보(코드·담당자·전화·주소) + 접수 품목 내역 표시, 상단 '전화 주문 접수' 버튼 제거
- 전 화면 날짜 입력(date/month 등)에 lang=ko 지정 → 달력 월을 한글(6월)로 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:52 +09:00
taekyoungc
278e75284e style: 기타 입출고 카드 모서리 둥글게(overflow-hidden) — 헤더 삐져나옴 수정
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:43:20 +09:00
taekyoungc
ec1f8f6b03 style: 의견 목록 상태 셀렉트 너비 확대 + 추이 분석 표 모서리 둥글게(overflow-hidden)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:23:46 +09:00
taekyoungc
14b628b7ac feat: 의견 창 이동·크기 조절 + 글자수 2000자·카운터
- 의견 창 헤더 드래그 이동, 우하단 모서리 크기 조절(resize), 비차단 플로팅
- 입력 글자수 1000→2000자, '0/2000' 카운터 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:48:43 +09:00
taekyoungc
59b33ccb42 feat: 사용자 의견(피드백) 기능 — 화면 캡처+이미지 첨부, 관리자 검토
- 전 화면 우하단 '의견' 위젯(embed/portal/admin 레이아웃) — 로그인 사용자
- 의견 + html2canvas 화면 캡처 + 이미지 첨부(최대 5장) 저장
- 화면 URL/계정/지자체 자동 수집(계정·지자체는 서버 세션 기준)
- 관리자 /admin/feedback 목록·상세·상태(접수/처리중/완료/보류)·메모, 테넌트 분리
- feedback/feedback_file 테이블, 첨부 이미지는 관리자 전용 라우트로 서빙

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:39:02 +09:00
taekyoungc
4e681d08cc fix: 업무현황 지도 첫 진입 시 판매소 미표시 수정 (ResizeObserver relayout)
워크스페이스 탭(iframe) 안에서 컨테이너 크기 확정 전 지도가 초기화돼 반만/일본으로 보이던 문제.
크기 변화마다 relayout+setBounds 재적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:39:02 +09:00
taekyoungc
c3e8ebfe6d style: 분할 보기 칸 헤더의 화면 제목(sh-name) 제거 (화면 자체 제목과 중복)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:19:58 +09:00
127 changed files with 7981 additions and 937 deletions

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
/**
* 지정판매소 PII 일괄 암호화 + 블라인드 인덱스 백필 (설계안 P3·P4).
*
* ⚠️ 운영 데이터를 변경하는 비가역적 작업. 반드시 DB 백업 후 실행.
* php spark pii:protect-designated-shops --dry (미리보기: 변경 안 함)
* php spark pii:protect-designated-shops --force (실제 실행)
*
* - 멱등: 이미 ENC: 인 필드는 건너뜀. 두 번 돌려도 안전.
* - 앱 encrypter(.env encryption.key) 사용 → 반드시 키가 설정돼 있어야 함.
*/
class ProtectDesignatedShops extends BaseCommand
{
protected $group = 'PII';
protected $name = 'pii:protect-designated-shops';
protected $description = '지정판매소 PII를 암호화하고 블라인드 인덱스를 백필합니다(비가역적, 백업 필수).';
protected $usage = 'pii:protect-designated-shops [--dry|--force]';
public function run(array $params)
{
helper('pii_encryption');
$dry = array_key_exists('dry', $params) || in_array('--dry', $_SERVER['argv'] ?? [], true);
$force = array_key_exists('force', $params) || in_array('--force', $_SERVER['argv'] ?? [], true);
if (! $dry && ! $force) {
CLI::error('안전을 위해 --dry(미리보기) 또는 --force(실행) 중 하나가 필요합니다.');
return;
}
$cfg = config('Encryption');
if ((string) $cfg->key === '') {
CLI::error('encryption.key 가 비어 있습니다. .env 설정 후 실행하세요. (키 없이 암호화 불가)');
return;
}
$db = Database::connect();
$piiFields = ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
$hasTelBidx = $db->fieldExists('ds_tel_bidx', 'designated_shop');
$hasNameBidx = $db->fieldExists('ds_rep_name_bidx', 'designated_shop');
$rows = $db->table('designated_shop')->select('ds_idx, ' . implode(', ', $piiFields))->get()->getResultArray();
CLI::write('대상 행: ' . count($rows) . ($dry ? ' [DRY-RUN: 변경 없음]' : ' [실제 실행]'), $dry ? 'yellow' : 'green');
$encCount = 0; $rowCount = 0;
foreach ($rows as $r) {
$update = [];
foreach ($piiFields as $f) {
$v = (string) ($r[$f] ?? '');
if ($v !== '' && strpos($v, 'ENC:') !== 0) {
$update[$f] = pii_encrypt($v);
$encCount++;
}
}
// 블라인드 인덱스(원본값 기준)
if ($hasTelBidx) {
$update['ds_tel_bidx'] = pii_blind_index((string) ($r['ds_tel'] ?? ''));
}
if ($hasNameBidx) {
$update['ds_rep_name_bidx'] = pii_blind_index((string) ($r['ds_rep_name'] ?? ''));
}
if ($update === []) {
continue;
}
$rowCount++;
if (! $dry) {
$db->table('designated_shop')->where('ds_idx', (int) $r['ds_idx'])->update($update);
}
}
CLI::write('변경 대상 행: ' . $rowCount . ' / 암호화 필드: ' . $encCount, 'green');
if ($dry) {
CLI::write('DRY-RUN 이었습니다. 실제 반영하려면 --force 로 다시 실행하세요.', 'yellow');
} else {
CLI::write('완료. (멱등: 재실행해도 이미 암호화된 값은 건너뜁니다)', 'green');
}
}
}

View File

@@ -133,7 +133,7 @@ class App extends BaseConfig
* @see https://www.php.net/manual/en/timezones.php for list of timezones * @see https://www.php.net/manual/en/timezones.php for list of timezones
* supported by PHP. * supported by PHP.
*/ */
public string $appTimezone = 'UTC'; public string $appTimezone = 'Asia/Seoul';
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------

View File

@@ -28,6 +28,7 @@ class Filters extends BaseFilters
'adminAuth' => \App\Filters\AdminAuthFilter::class, 'adminAuth' => \App\Filters\AdminAuthFilter::class,
'loginAuth' => \App\Filters\LoginAuthFilter::class, 'loginAuth' => \App\Filters\LoginAuthFilter::class,
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class, 'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
'sessionGuard' => \App\Filters\SessionGuardFilter::class,
'csrf' => CSRF::class, 'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class, 'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class, 'honeypot' => Honeypot::class,
@@ -78,6 +79,7 @@ class Filters extends BaseFilters
// 'honeypot', // 'honeypot',
// 'csrf', // 'csrf',
// 'invalidchars', // 'invalidchars',
'sessionGuard', // 중복 로그인 방지(나중 로그인 우선)
], ],
'after' => [ 'after' => [
// 'honeypot', // 'honeypot',

View File

@@ -47,7 +47,7 @@ class Manual extends BaseConfig
*/ */
public array $screenHelp = [ public array $screenHelp = [
'bag/order/phone' => 'sales#전화 주문 접수', 'bag/order/phone' => 'sales#전화 주문 접수',
'bag/order/lot-seed' => 'order#LOT-No 디스켓 불출', 'bag/order/lot-seed' => 'order#발주파일 생성',
'bag/order' => 'order#발주 등록', 'bag/order' => 'order#발주 등록',
'bag/password-change' => 'account#비밀번호 변경', 'bag/password-change' => 'account#비밀번호 변경',
'bag/bag-orders' => 'order#발주 현황', 'bag/bag-orders' => 'order#발주 현황',
@@ -73,6 +73,8 @@ class Manual extends BaseConfig
'bag/reports/hometax-export' => 'reports#그 밖의 현황', 'bag/reports/hometax-export' => 'reports#그 밖의 현황',
'bag/reports' => 'reports#일계표', 'bag/reports' => 'reports#일계표',
'bag/analytics' => 'reports#그 밖의 현황', 'bag/analytics' => 'reports#그 밖의 현황',
'bag/designated-shops/status' => 'basic#지정 판매소 신규/취소 현황',
'bag/designated-shops/district-new-cancel' => 'basic#지정 판매소 신규/취소 현황',
'bag/designated-shops' => 'basic#지정판매소 관리', 'bag/designated-shops' => 'basic#지정판매소 관리',
'bag/bag-prices' => 'basic#단가 관리', 'bag/bag-prices' => 'basic#단가 관리',
'bag/prices' => 'basic#단가 관리', 'bag/prices' => 'basic#단가 관리',

32
app/Config/Pii.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 개인정보 비식별화·암호화 설정.
* 마스터 플래그가 OFF이면 현재 동작(원문 그대로) 유지 — 준비 완료 후 .env에서 켠다.
* .env: pii.designatedShopProtection = true
*/
class Pii extends BaseConfig
{
/** 지정판매소 PII 보호(마스킹·암호화·게이트) 마스터 스위치. 기본 OFF(안전). */
public bool $designatedShopProtection = false;
/** 지정판매소 PII 필드 (암호화 저장 + 마스킹 대상) */
public array $designatedShopPiiFields = [
'ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account',
];
/** 저장 계층 암호화 사용 여부 (P3). 마이그레이션·백업 후 .env에서 켠다. */
public bool $encryptAtRest = false;
/**
* 블라인드 인덱스 HMAC 키 (.env: pii.blindIndexKey).
* encryption.key 와 별도 키 권장. 비어 있으면 검색 인덱스 생성/사용 안 함.
*/
public string $blindIndexKey = '';
}

View File

@@ -38,6 +38,7 @@ $routes->get('bag/issue', 'Bag::issueLegacy');
$routes->get('bag/issue/cancel', 'Bag::issue'); $routes->get('bag/issue/cancel', 'Bag::issue');
$routes->get('bag/inventory', 'Bag::inventory'); $routes->get('bag/inventory', 'Bag::inventory');
$routes->get('bag/inventory/export', 'Bag::inventoryExport'); $routes->get('bag/inventory/export', 'Bag::inventoryExport');
$routes->get('bag/inventory/stock-barcode-list', 'Bag::stockBarcodeList'); // 실사 재고 바코드 리스트(창고에 있어야 하는 번호)
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect'); $routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork'); $routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun'); $routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
@@ -62,6 +63,9 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void
$routes->get('manual', 'Bag::manual'); $routes->get('manual', 'Bag::manual');
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저 $routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
$routes->get('manual/(:segment)', 'Bag::manualPage/$1'); $routes->get('manual/(:segment)', 'Bag::manualPage/$1');
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
$routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장
$routes->post('pref/workspace-tabs', 'Bag::saveWorkspaceTabs'); // 계정별 워크스페이스 탭 상태 저장(자동 복원)
}); });
$routes->get('bag/number-lookup', 'Bag::numberLookup'); $routes->get('bag/number-lookup', 'Bag::numberLookup');
@@ -73,13 +77,18 @@ $routes->post('bag/issue/store', 'Bag::issueStore');
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1'); $routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave'); $routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
$routes->get('bag/order/create', 'Bag::orderCreate'); $routes->get('bag/order/create', 'Bag::orderCreate');
$routes->get('bag/order/detail-json/(:num)', 'Bag::orderDetailJson/$1');
$routes->get('bag/order/phone', 'Bag::phoneOrderCreate'); $routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); $routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); $routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan');
$routes->post('bag/order/phone/manage/paid', 'Bag::phoneOrderMarkPaid');
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1'); $routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
$routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
$routes->get('bag/order/change', 'Bag::orderChange'); $routes->get('bag/order/change', 'Bag::orderChange');
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1'); $routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed'); $routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
$routes->get('bag/order/lot-seed/preview', 'Bag::orderLotSeedPreview');
$routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate'); $routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate');
$routes->post('bag/order/store', 'Bag::orderStore'); $routes->post('bag/order/store', 'Bag::orderStore');
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1'); $routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');
@@ -89,6 +98,7 @@ $routes->get('bag/receiving/create', 'Bag::receivingCreate');
$routes->post('bag/receiving/store', 'Bag::receivingStore'); $routes->post('bag/receiving/store', 'Bag::receivingStore');
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner'); $routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore'); $routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
$routes->post('bag/receiving/scan-box', 'Bag::receivingScanBox');
$routes->get('bag/receiving/batch', 'Bag::receivingBatch'); $routes->get('bag/receiving/batch', 'Bag::receivingBatch');
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore'); $routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
$routes->get('bag/receiving/status', 'Bag::receivingStatus'); $routes->get('bag/receiving/status', 'Bag::receivingStatus');
@@ -151,6 +161,8 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse'); $routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
$routes->get('designated-shops', 'Admin\DesignatedShop::index'); $routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
$routes->post('designated-shops/reveal-pii', 'Admin\DesignatedShop::revealPii');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1'); $routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1'); $routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
@@ -203,6 +215,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1'); $routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1'); $routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
$routes->get('reports/custom', 'Admin\SalesReport::customReport');
$routes->post('reports/custom/preset/save', 'Admin\SalesReport::customReportPresetSave');
$routes->post('reports/custom/preset/delete', 'Admin\SalesReport::customReportPresetDelete');
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger'); $routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary'); $routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales'); $routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
@@ -221,6 +236,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->post('password-change', 'Admin\PasswordChange::update'); $routes->post('password-change', 'Admin\PasswordChange::update');
}); });
// 사용자 의견 접수 (로그인 사용자)
$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
// Auth // Auth
$routes->get('login', 'Auth::showLoginForm'); $routes->get('login', 'Auth::showLoginForm');
$routes->post('login', 'Auth::login'); $routes->post('login', 'Auth::login');
@@ -236,6 +254,15 @@ $routes->post('register', 'Auth::register');
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void { $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index'); $routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
$routes->post('select-local-government', 'Admin\SelectLocalGovernment::store'); $routes->post('select-local-government', 'Admin\SelectLocalGovernment::store');
// 사용자 의견 검토
$routes->get('feedback', 'Admin\Feedback::index');
$routes->get('feedback/all', 'Admin\Feedback::all');
$routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1');
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');
$routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1');
$routes->post('feedback/(:num)/content', 'Admin\Feedback::updateContent/$1');
$routes->get('/', 'Admin\Dashboard::index'); $routes->get('/', 'Admin\Dashboard::index');
$routes->get('users', 'Admin\User::index'); $routes->get('users', 'Admin\User::index');
$routes->get('users/create', 'Admin\User::create'); $routes->get('users/create', 'Admin\User::create');
@@ -245,6 +272,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1'); $routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1'); $routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
$routes->get('access/login-history', 'Admin\Access::loginHistory'); $routes->get('access/login-history', 'Admin\Access::loginHistory');
$routes->get('access/activity-logs', 'Admin\ActivityLog::index');
$routes->get('access/activity-logs/(:num)', 'Admin\ActivityLog::show/$1');
$routes->get('access/approvals', 'Admin\Access::approvals'); $routes->get('access/approvals', 'Admin\Access::approvals');
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1'); $routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1'); $routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\ActivityLogModel;
use Config\Roles;
/**
* 사용자 작업(감사) 로그 조회 — 생성/수정/삭제 이력.
* 관리자 전용(adminAuth). 슈퍼/본부는 전체, 지자체관리자는 소속 지자체 사용자만.
*/
class ActivityLog extends BaseController
{
private ActivityLogModel $logModel;
/** 테이블명 → 한글 라벨 */
private const TABLE_LABELS = [
'member' => '회원',
'member_approval_request' => '회원 승인요청',
'company' => '업체',
'designated_shop' => '지정판매소',
'sales_agency' => '판매 대행소',
'free_recipient' => '무료 수령처',
'manager' => '담당자',
'local_government' => '지자체',
'code_kind' => '기본코드 종류',
'code_detail' => '기본코드',
'packaging_unit' => '포장 단위',
'packaging_unit_history' => '포장 단위 이력',
'bag_price' => '단가',
'bag_price_history' => '단가 이력',
'bag_order' => '발주',
'bag_order_item' => '발주 품목',
'bag_receiving' => '입고',
'bag_inventory' => '재고',
'bag_sale' => '판매',
'bag_issue' => '무료용 불출',
'bag_issue_item_code' => '불출 품목코드',
'shop_order' => '전화 주문',
'shop_order_item' => '전화 주문 품목',
'menu' => '메뉴',
'menu_type' => '메뉴 유형',
'feedback' => '사용자 의견',
'feedback_file' => '의견 첨부',
'blockchain_ledger' => '원장',
'export' => '엑셀 다운로드',
'print' => '인쇄',
];
/** action → 한글 라벨 */
private const ACTION_LABELS = [
'create' => '등록',
'update' => '수정',
'delete' => '삭제',
'export' => '엑셀 다운로드',
'print' => '인쇄',
];
/** 컬럼 → 한글 라벨 (없으면 접미사 추정 → 원문) */
private const FIELD_LABELS = [
'mb_id' => '아이디', 'mb_name' => '이름', 'mb_level' => '권한등급', 'mb_group' => '그룹',
'cp_type' => '구분', 'cp_biz_no' => '사업자번호', 'cp_rep_name' => '대표자',
'ds_shop_no' => '지정번호', 'ds_rep_name' => '대표자', 'ds_biz_no' => '사업자번호',
'ck_code' => '코드', 'cd_code' => '코드', 'cd_value' => '값', 'cd_sort' => '정렬순서',
'sa_kind' => '종류', 'lg_sido' => '시도', 'lg_gugun' => '구·군',
'mm_link' => '메뉴 링크', 'mm_is_view' => '노출여부', 'mm_level' => '노출등급',
'bs_bag_name' => '품목', 'bs_qty' => '수량', 'bs_amount' => '금액', 'bs_sale_date' => '판매일',
'bo_status' => '상태', 'bi2_status' => '상태',
];
/** 로그인 시 갱신되는 회원 필드(이것만 바뀌면 '로그인'으로 표시) */
private const LOGIN_FIELDS = ['mb_session_token', 'mb_latestdate', 'mb_login_fail_count', 'mb_locked_until'];
public function __construct()
{
$this->logModel = model(ActivityLogModel::class);
}
/** 컬럼명 → 한글 라벨 (정확 매칭 → 접미사 추정 → 원문) */
private function fieldLabel(string $col): string
{
if (isset(self::FIELD_LABELS[$col])) {
return self::FIELD_LABELS[$col];
}
$suffix = [
'_name' => '이름', '_tel' => '전화번호', '_phone' => '전화번호', '_addr' => '주소',
'_email' => '이메일', '_state' => '상태', '_regdate' => '등록일', '_date' => '일자',
'_qty' => '수량', '_amount' => '금액', '_price' => '단가', '_code' => '코드',
];
foreach ($suffix as $sfx => $lbl) {
if (str_ends_with($col, $sfx)) {
return $lbl;
}
}
return $col;
}
/** 레코드 표시 이름 추출 (이름 계열 필드 우선) */
private function recordName(array $data): string
{
foreach (['mb_name', 'cp_name', 'ds_name', 'lg_name', 'ck_name', 'cd_name', 'sa_name', 'fr_name', 'mn_name', 'mm_name'] as $k) {
if (! empty($data[$k])) {
return (string) $data[$k];
}
}
foreach ($data as $k => $v) {
if (str_ends_with((string) $k, '_name') && (string) $v !== '') {
return (string) $v;
}
}
return '';
}
private function fmtVal(mixed $v): string
{
if (is_array($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
}
$s = (string) ($v ?? '');
if ($s === '') {
return '(없음)';
}
return mb_strlen($s) > 10 ? mb_substr($s, 0, 10) . '…' : $s;
}
/** 로그 1행에 사람이 읽기 쉬운 요약(summaryText)·변경목록(changeList)·로그인여부(isLogin)를 붙인다 */
private function attachSummary(object $row): void
{
$table = (string) ($row->al_table ?? '');
$action = (string) ($row->al_action ?? '');
$before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : [];
$after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : [];
$tlabel = self::TABLE_LABELS[$table] ?? $table;
$changes = [];
$isLogin = false;
if ($action === 'update') {
foreach ($after as $k => $v) {
$bv = $before[$k] ?? null;
if (! array_key_exists($k, $before) || (string) $bv !== (string) $v) {
$changes[$k] = [$bv, $v];
}
}
if ($table === 'member' && $changes !== [] && array_diff(array_keys($changes), self::LOGIN_FIELDS) === []) {
$isLogin = true;
}
}
$name = $this->recordName($action === 'delete' ? $before : $after);
if ($name === '') {
$name = $this->recordName($before);
}
$namePart = $name !== '' ? " '" . $name . "'" : ((int) ($row->al_record_id ?? 0) ? ' #' . (int) $row->al_record_id : '');
if ($isLogin) {
$text = '로그인';
} elseif ($action === 'export') {
$text = '엑셀 다운로드 — ' . (string) ($after['파일'] ?? '');
} elseif ($action === 'print') {
$text = '인쇄 — ' . (string) ($after['화면'] ?? $after['경로'] ?? '');
} elseif ($action === 'create') {
$text = $tlabel . $namePart . ' 등록';
} elseif ($action === 'delete') {
$text = $tlabel . $namePart . ' 삭제';
} else {
$text = $tlabel . $namePart . ' 수정';
}
$skip = array_merge(self::LOGIN_FIELDS, ['mb_passwd', 'mb_totp_secret', 'mb_session_token']);
$changeList = [];
foreach ($changes as $k => $pair) {
if (in_array($k, $skip, true)) {
continue;
}
$changeList[] = [
'label' => $this->fieldLabel($k),
'from' => $this->fmtVal($pair[0]),
'to' => $this->fmtVal($pair[1]),
];
}
$row->summaryText = $text;
$row->isLogin = $isLogin;
$row->changeList = $changeList;
}
public function index(): string
{
helper('admin');
$start = (string) ($this->request->getGet('start') ?? '');
$end = (string) ($this->request->getGet('end') ?? '');
$action = (string) ($this->request->getGet('action') ?? '');
$table = (string) ($this->request->getGet('table') ?? '');
$user = trim((string) ($this->request->getGet('user') ?? ''));
$builder = $this->logModel
->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left');
// 테넌트 분리: 슈퍼/본부는 전체, 그 외 관리자는 소속 지자체 사용자만
$level = (int) session()->get('mb_level');
if (! Roles::isSuperAdminEquivalent($level)) {
$lgIdx = admin_effective_lg_idx();
$builder->where('member.mb_lg_idx', $lgIdx);
}
if ($start !== '') {
$builder->where('activity_log.al_regdate >=', $start . ' 00:00:00');
}
if ($end !== '') {
$builder->where('activity_log.al_regdate <=', $end . ' 23:59:59');
}
if (isset(self::ACTION_LABELS[$action])) {
$builder->where('activity_log.al_action', $action);
}
if ($table !== '' && isset(self::TABLE_LABELS[$table])) {
$builder->where('activity_log.al_table', $table);
}
if ($user !== '') {
$builder->groupStart()
->like('member.mb_id', $user)
->orLike('member.mb_name', $user)
->groupEnd();
}
$list = $builder->orderBy('activity_log.al_idx', 'DESC')->paginate(20);
$pager = $this->logModel->pager;
foreach ($list as $row) {
$this->attachSummary($row);
}
return view('admin/layout', [
'title' => '활동 로그',
'content' => view('admin/access/activity_log', [
'list' => $list,
'pager' => $pager,
'start' => $start,
'end' => $end,
'action' => $action,
'table' => $table,
'user' => $user,
'tableLabels' => self::TABLE_LABELS,
'actionLabels' => self::ACTION_LABELS,
]),
]);
}
public function show(int $id): string
{
helper('admin');
$row = $this->logModel
->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left')
->where('activity_log.al_idx', $id)
->first();
if ($row === null) {
return view('admin/layout', [
'title' => '활동 로그',
'content' => '<div class="p-6 text-gray-500">로그를 찾을 수 없습니다.</div>',
]);
}
// 테넌트 분리 검증
$level = (int) session()->get('mb_level');
if (! Roles::isSuperAdminEquivalent($level)) {
$lgIdx = admin_effective_lg_idx();
if ((int) ($row->mb_lg_idx ?? 0) !== (int) $lgIdx) {
return view('admin/layout', [
'title' => '활동 로그',
'content' => '<div class="p-6 text-gray-500">접근 권한이 없습니다.</div>',
]);
}
}
$before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : [];
$after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : [];
return view('admin/layout', [
'title' => '활동 로그 상세',
'content' => view('admin/access/activity_log_detail', [
'row' => $row,
'before' => $before,
'after' => $after,
'tableLabels' => self::TABLE_LABELS,
'actionLabels' => self::ACTION_LABELS,
]),
]);
}
}

View File

@@ -539,6 +539,11 @@ class BagOrder extends BaseController
'bo_regdate' => date('Y-m-d H:i:s'), 'bo_regdate' => date('Y-m-d H:i:s'),
]; ];
// 발주담당(manager.mg_idx) — 컬럼이 있을 때만 저장
if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order')) {
$orderData['bo_manager_idx'] = $this->request->getPost('bo_manager_idx') ?: null;
}
// 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성 // 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
$orderData['bo_hash'] = ''; $orderData['bo_hash'] = '';

View File

@@ -56,14 +56,11 @@ class CodeDetail extends BaseController
helper('admin'); helper('admin');
return view('admin/layout', [ return $this->renderWorkPage('세부코드 등록 — ' . $kind->ck_name, 'admin/code_detail/create', [
'title' => '세부코드 등록 — ' . $kind->ck_name,
'content' => view('admin/code_detail/create', [
'kind' => $kind, 'kind' => $kind,
'canPlatformScope' => $canPlatformScope, 'canPlatformScope' => $canPlatformScope,
'localGovernments' => $govs, 'localGovernments' => $govs,
'effectiveLgIdx' => admin_effective_lg_idx(), 'effectiveLgIdx' => admin_effective_lg_idx(),
]),
]); ]);
} }
@@ -156,12 +153,9 @@ class CodeDetail extends BaseController
$kind = $this->kindModel->find($item->cd_ck_idx); $kind = $this->kindModel->find($item->cd_ck_idx);
return view('admin/layout', [ return $this->renderWorkPage('세부코드 수정 — ' . ($kind->ck_name ?? ''), 'admin/code_detail/edit', [
'title' => '세부코드 수정 — ' . ($kind->ck_name ?? ''),
'content' => view('admin/code_detail/edit', [
'item' => $item, 'item' => $item,
'kind' => $kind, 'kind' => $kind,
]),
]); ]);
} }

View File

@@ -34,10 +34,7 @@ class CodeKind extends BaseController
return $r; return $r;
} }
return view('admin/layout', [ return $this->renderWorkPage('기본코드 종류 등록', 'admin/code_kind/create');
'title' => '기본코드 종류 등록',
'content' => view('admin/code_kind/create'),
]);
} }
public function store() public function store()
@@ -76,10 +73,7 @@ class CodeKind extends BaseController
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
} }
return view('admin/layout', [ return $this->renderWorkPage('기본코드 종류 수정', 'admin/code_kind/edit', ['item' => $item]);
'title' => '기본코드 종류 수정',
'content' => view('admin/code_kind/edit', ['item' => $item]),
]);
} }
public function update(int $id) public function update(int $id)

View File

@@ -14,6 +14,7 @@ class DesignatedShop extends BaseController
private DesignatedShopModel $shopModel; private DesignatedShopModel $shopModel;
private LocalGovernmentModel $lgModel; private LocalGovernmentModel $lgModel;
private Roles $roles; private Roles $roles;
private ?bool $dongColumnExists = null;
public function __construct() public function __construct()
{ {
@@ -173,7 +174,7 @@ class DesignatedShop extends BaseController
/** /**
* 목록 검색과 동일한 조건을 모델 쿼리에 적용한다. * 목록 검색과 동일한 조건을 모델 쿼리에 적용한다.
*/ */
private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): void private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): void
{ {
$model->where('ds_lg_idx', $lgIdx); $model->where('ds_lg_idx', $lgIdx);
if ($dsName !== null && $dsName !== '') { if ($dsName !== null && $dsName !== '') {
@@ -185,12 +186,47 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') { if ($dsState !== null && $dsState !== '') {
$model->where('ds_state', (int) $dsState); $model->where('ds_state', (int) $dsState);
} }
$this->applyPiiSearchFilter($model, $dsRep, $dsTel);
}
/**
* 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치,
* 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백.
*
* @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q
*/
private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void
{
$dsRep = $dsRep !== null ? trim($dsRep) : '';
$dsTel = $dsTel !== null ? trim($dsTel) : '';
if ($dsRep === '' && $dsTel === '') {
return;
}
helper('pii_encryption');
$db = \Config\Database::connect();
if ($dsRep !== '') {
$bidx = pii_blind_index($dsRep);
if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) {
$q->where('ds_rep_name_bidx', $bidx);
} else {
$q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치)
}
}
if ($dsTel !== '') {
$bidx = pii_blind_index($dsTel);
if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) {
$q->where('ds_tel_bidx', $bidx);
} else {
$q->where('ds_tel', $dsTel);
}
}
} }
/** /**
* @return array{1: int, 2: int, 3: int, total: int} * @return array{1: int, 2: int, 3: int, total: int}
*/ */
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array
{ {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$builder = $db->table('designated_shop'); $builder = $db->table('designated_shop');
@@ -204,6 +240,7 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') { if ($dsState !== null && $dsState !== '') {
$builder->where('ds_state', (int) $dsState); $builder->where('ds_state', (int) $dsState);
} }
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false) $rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
->groupBy('ds_state') ->groupBy('ds_state')
->get() ->get()
@@ -223,11 +260,12 @@ class DesignatedShop extends BaseController
/** /**
* @param list<object> $list * @param list<object> $list
* @param array<int, string> $lgMap * @param array<int, string> $lgMap
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
private function buildDesignatedShopDetailPayload(array $list, array $lgMap): array private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
{ {
helper('admin'); helper(['admin', 'pii_encryption']);
$lgIdx = admin_effective_lg_idx() ?? 0; $lgIdx = admin_effective_lg_idx() ?? 0;
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : []; $gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
$payload = []; $payload = [];
@@ -244,7 +282,19 @@ class DesignatedShop extends BaseController
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지']; $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
$da = $row->ds_designated_at ?? null; $da = $row->ds_designated_at ?? null;
$daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : ''; $daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : '';
$payload[] = [
$idxKey = (int) ($row->ds_idx ?? 0);
$dongCode = $dongMap[$idxKey]['code'] ?? (string) ($row->ds_dong_code ?? '');
$dongName = $dongMap[$idxKey]['name'] ?? '';
$gugunName = $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? '');
// 구·군 표시값: 동코드가 있으면 "동코드 (동명)", 없으면 기존 구·군명
if ($dongCode !== '') {
$dongDisplay = $dongCode . ($dongName !== '' ? ' (' . $dongName . ')' : '');
} else {
$dongDisplay = $gugunName;
}
$p = [
'ds_idx' => (int) $row->ds_idx, 'ds_idx' => (int) $row->ds_idx,
'ds_shop_no' => $sn, 'ds_shop_no' => $sn,
'shop_no_display' => $shortNo, 'shop_no_display' => $shortNo,
@@ -266,7 +316,10 @@ class DesignatedShop extends BaseController
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''), 'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
'ds_email' => (string) ($row->ds_email ?? ''), 'ds_email' => (string) ($row->ds_email ?? ''),
'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''), 'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''),
'gugun_name' => $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? ''), 'gugun_name' => $gugunName,
'ds_dong_code' => $dongCode,
'dong_name' => $dongName,
'dong_display' => $dongDisplay,
'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'), 'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'),
'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'), 'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
'ds_designated_at' => $daOut, 'ds_designated_at' => $daOut,
@@ -274,7 +327,18 @@ class DesignatedShop extends BaseController
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'), 'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
'ds_regdate' => (string) ($row->ds_regdate ?? ''), 'ds_regdate' => (string) ($row->ds_regdate ?? ''),
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '', 'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
'pii_masked' => false,
]; ];
// 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹
if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) {
foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) {
$p[$f] = mask_shop_field($f, (string) $p[$f]);
}
$p['pii_masked'] = true;
}
$payload[] = $p;
} }
return $payload; return $payload;
@@ -294,14 +358,17 @@ class DesignatedShop extends BaseController
return null; return null;
} }
// 다조건 검색 (P2-15) // 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
$dsName = $this->request->getGet('ds_name'); $dsName = $this->request->getGet('ds_name');
$dsGugunCode = $this->request->getGet('ds_gugun_code'); $dsGugunCode = $this->request->getGet('ds_gugun_code');
$dsState = $this->request->getGet('ds_state'); $dsState = $this->request->getGet('ds_state');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState); $dsRep = $this->request->getGet('ds_rep');
$dsTel = $this->request->getGet('ds_tel');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->paginate(20); // 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
$pager = $this->shopModel->pager; $list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
$pager = null;
// 지자체 이름 매핑용 // 지자체 이름 매핑용
$lgMap = []; $lgMap = [];
@@ -309,9 +376,23 @@ class DesignatedShop extends BaseController
$lgMap[$lg->lg_idx] = $lg->lg_name; $lgMap[$lg->lg_idx] = $lg->lg_name;
} }
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState); $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
$gugunNameMap = $this->gugunCodeNameMap($lgIdx); $gugunNameMap = $this->gugunCodeNameMap($lgIdx);
$detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap);
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
$this->persistDesignatedShopDongCodes($list, $dongMap);
$detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap, $dongMap);
// 리스트(서버 렌더)에서 동코드 표시용: ds_idx => "동코드 (동명)"
$dongDisplayMap = [];
foreach ($dongMap as $idx => $d) {
$code = (string) ($d['code'] ?? '');
$name = (string) ($d['name'] ?? '');
if ($code !== '') {
$dongDisplayMap[$idx] = $code . ($name !== '' ? ' (' . $name . ')' : '');
}
}
// 구군코드 목록 (검색 필터용) // 구군코드 목록 (검색 필터용)
$db = \Config\Database::connect(); $db = \Config\Database::connect();
@@ -324,9 +405,12 @@ class DesignatedShop extends BaseController
'dsName' => $dsName ?? '', 'dsName' => $dsName ?? '',
'dsGugunCode' => $dsGugunCode ?? '', 'dsGugunCode' => $dsGugunCode ?? '',
'dsState' => $dsState ?? '', 'dsState' => $dsState ?? '',
'dsRep' => $dsRep ?? '',
'dsTel' => $dsTel ?? '',
'gugunCodes' => $gugunCodes, 'gugunCodes' => $gugunCodes,
'stateCounts' => $stateCounts, 'stateCounts' => $stateCounts,
'gugunNameMap' => $gugunNameMap, 'gugunNameMap' => $gugunNameMap,
'dongDisplayMap' => $dongDisplayMap,
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE), 'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
]; ];
@@ -475,22 +559,45 @@ class DesignatedShop extends BaseController
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
} }
helper('pii_encryption');
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll(); $list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
// 동코드(구·군 동코드) 산출
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
$rows = []; $rows = [];
foreach ($list as $row) { foreach ($list as $row) {
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지']; $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
$idxKey = (int) ($row->ds_idx ?? 0);
$dCode = (string) ($dongMap[$idxKey]['code'] ?? '');
$dName = (string) ($dongMap[$idxKey]['name'] ?? '');
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
$da = $row->ds_designated_at ?? null;
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
// 엑셀에도 마스킹 적용(열람 권한 없으면)
$canView = can_view_shop_pii((int) ($row->ds_lg_idx ?? 0));
$repName = (string) ($row->ds_rep_name ?? '');
$telVal = (string) ($row->ds_tel ?? '');
$vaAccount = $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : (string) ($row->ds_va_number ?? '');
if (! $canView) {
$repName = mask_shop_field('ds_rep_name', $repName);
$telVal = mask_shop_field('ds_tel', $telVal);
$vaAccount = mask_shop_field('ds_va_account', $vaAccount);
}
$rows[] = [ $rows[] = [
$row->ds_idx, $row->ds_idx,
$row->ds_shop_no, $row->ds_shop_no,
$row->ds_name, $row->ds_name,
$row->ds_rep_name, $repName,
$dongDisp,
$daDisp,
$row->ds_biz_no, $row->ds_biz_no,
$this->designatedShopScalar($row, 'ds_biz_type'), $this->designatedShopScalar($row, 'ds_biz_type'),
$this->designatedShopScalar($row, 'ds_biz_kind'), $this->designatedShopScalar($row, 'ds_biz_kind'),
$this->designatedShopScalar($row, 'ds_va_bank'), $this->designatedShopScalar($row, 'ds_va_bank'),
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''), $vaAccount,
$row->ds_tel ?? '', $telVal,
$row->ds_addr ?? '', $row->ds_addr ?? '',
$this->designatedShopScalar($row, 'ds_zone_code'), $this->designatedShopScalar($row, 'ds_zone_code'),
$this->designatedShopScalar($row, 'ds_branch_no'), $this->designatedShopScalar($row, 'ds_branch_no'),
@@ -504,7 +611,7 @@ class DesignatedShop extends BaseController
export_csv( export_csv(
'지정판매소_' . date('Ymd') . '.csv', '지정판매소_' . date('Ymd') . '.csv',
[ [
'번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종', '번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호', '가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
'변경일자', '변경사유', '상태', '등록일', '변경일자', '변경사유', '상태', '등록일',
], ],
@@ -648,6 +755,8 @@ class DesignatedShop extends BaseController
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'), 'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
'ds_state' => 1, 'ds_state' => 1,
// 동코드(컬럼이 있을 때만 저장)
...($this->designatedShopHasDongColumn() ? ['ds_dong_code' => (string) ($resolvedNo['dong_code'] ?? '')] : []),
'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')), 'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'), 'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'),
'ds_regdate' => date('Y-m-d H:i:s'), 'ds_regdate' => date('Y-m-d H:i:s'),
@@ -770,6 +879,23 @@ class DesignatedShop extends BaseController
$va = $this->resolveVirtualAccountFromRequest(); $va = $this->resolveVirtualAccountFromRequest();
// 주소로 동코드 재산출(컬럼이 있을 때만 저장). 매칭 실패 시 동코드는 갱신하지 않음.
$dongCodeData = [];
if ($this->designatedShopHasDongColumn()) {
$resolvedDong = $this->resolveDesignatedShopNumberFromAddress(
$lgIdx,
$addrSido,
$addrSigungu,
$dsAddr,
$dsAddrJibun,
$dsZip,
$lg
);
if (($resolvedDong['ok'] ?? false) && ($resolvedDong['dong_code'] ?? '') !== '') {
$dongCodeData = ['ds_dong_code' => (string) $resolvedDong['dong_code']];
}
}
$data = [ $data = [
'ds_name' => (string) $this->request->getPost('ds_name'), 'ds_name' => (string) $this->request->getPost('ds_name'),
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'), 'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
@@ -786,6 +912,7 @@ class DesignatedShop extends BaseController
'ds_tel' => (string) $this->request->getPost('ds_tel'), 'ds_tel' => (string) $this->request->getPost('ds_tel'),
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'), 'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
'ds_email' => (string) $this->request->getPost('ds_email'), 'ds_email' => (string) $this->request->getPost('ds_email'),
...$dongCodeData,
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'), 'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'), 'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
@@ -1532,9 +1659,11 @@ class DesignatedShop extends BaseController
usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']); usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
$dCode = null; $dCode = null;
$dName = '';
foreach ($dCandidates as $cand) { foreach ($dCandidates as $cand) {
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) { if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
$dCode = $cand['cd']; $dCode = $cand['cd'];
$dName = $cand['nm'];
break; break;
} }
} }
@@ -1557,9 +1686,248 @@ class DesignatedShop extends BaseController
'ok' => true, 'ok' => true,
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1), 'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
'gugun_code' => $cCode, 'gugun_code' => $cCode,
'dong_code' => $dCode,
'dong_name' => $dName,
]; ];
} }
/**
* [AJAX] 주소 선택 시, 해당 주소의 동 기본코드(D)와 동명을 반환.
* 등록 폼에서 주소 검색 직후 「구·군(동코드)」 표시에 사용한다.
*/
public function resolveAddressCodes()
{
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '권한이 없습니다.']);
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return $this->response->setJSON(['ok' => false, 'error' => '작업할 지자체가 선택되지 않았습니다.']);
}
$lg = $this->lgModel->find($lgIdx);
if ($lg === null) {
return $this->response->setJSON(['ok' => false, 'error' => '지자체 정보를 찾을 수 없습니다.']);
}
$addrSido = (string) $this->request->getPost('addr_search_sido');
$addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
$dsAddr = (string) $this->request->getPost('ds_addr');
$dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
$dsZip = (string) $this->request->getPost('ds_zip');
$resolved = $this->resolveDesignatedShopNumberFromAddress(
$lgIdx,
$addrSido,
$addrSigungu,
$dsAddr,
$dsAddrJibun,
$dsZip,
$lg
);
if (! $resolved['ok']) {
return $this->response->setJSON(['ok' => false, 'error' => $resolved['error']]);
}
return $this->response->setJSON([
'ok' => true,
'dong_code' => $resolved['dong_code'] ?? '',
'dong_name' => $resolved['dong_name'] ?? '',
'gugun_code' => $resolved['gugun_code'] ?? '',
]);
}
/**
* [AJAX] 개인정보 원문 보기 — step-up 재인증(TOTP) + 열람사유 → 세션 열람 승인 + 감사기록.
* 조건 미충족(IP밖·Super Admin 등) 사용자가 마스킹된 PII를 열람할 때 사용.
*/
public function revealPii()
{
helper(['admin', 'pii_encryption']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return $this->response->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
}
$mbIdx = (int) session()->get('mb_idx');
$code = trim((string) $this->request->getPost('totp_code'));
$reason = trim((string) $this->request->getPost('reason'));
if ($code === '' || ! preg_match('/^\d{6}$/', $code)) {
return $this->response->setJSON(['ok' => false, 'error' => '6자리 인증코드를 입력해 주세요.']);
}
if ($reason === '') {
return $this->response->setJSON(['ok' => false, 'error' => '열람 사유를 입력해 주세요.']);
}
$member = model(\App\Models\MemberModel::class)->find($mbIdx);
$secret = $member ? pii_decrypt((string) ($member->mb_totp_secret ?? '')) : '';
if ($secret === '' || (int) ($member->mb_totp_enabled ?? 0) !== 1) {
return $this->response->setJSON(['ok' => false, 'error' => '2단계 인증(TOTP)이 등록돼 있지 않아 원문 열람을 승인할 수 없습니다.']);
}
$totp = new \App\Libraries\TotpService();
if (! $totp->verify($secret, $code)) {
return $this->response->setJSON(['ok' => false, 'error' => '인증코드가 올바르지 않습니다.']);
}
// 이 세션에서 해당 지자체 PII 원문 열람 승인(10분)
session()->set('pii_stepup_grant', ['lg_idx' => (int) $lgIdx, 'until' => time() + 600]);
// 감사기록 — 누가/언제/사유
helper('audit');
audit_log('pii_view', 'designated_shop', 0, null, [
'lg_idx' => (int) $lgIdx,
'reason' => mb_substr($reason, 0, 200),
'ip' => (string) $this->request->getIPAddress(),
]);
return $this->response->setJSON(['ok' => true]);
}
/**
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
*/
private function designatedShopHasDongColumn(): bool
{
if ($this->dongColumnExists === null) {
$this->dongColumnExists = \Config\Database::connect()->fieldExists('ds_dong_code', 'designated_shop');
}
return $this->dongColumnExists;
}
/**
* 여러 지정판매소의 동 기본코드(D)를 주소로 일괄 산출한다.
* code_detail(B·C·D) 행을 한 번만 로드해 각 판매소를 매칭한다.
*
* @param list<object> $shops
* @return array<int, array{code: string, name: string}> ds_idx => [동코드, 동명]
*/
private function resolveDongCodesForShops(int $lgIdx, array $shops): array
{
$out = [];
if ($shops === []) {
return $out;
}
$bCk = $this->codeKindIdxByCkCode('B');
$cCk = $this->codeKindIdxByCkCode('C');
$dCk = $this->codeKindIdxByCkCode('D');
if ($bCk === null || $cCk === null || $dCk === null) {
return $out;
}
$detailModel = model(CodeDetailModel::class);
$bRows = $detailModel->getByKind($bCk, true, $lgIdx);
$cRows = $detailModel->getByKind($cCk, true, $lgIdx);
$dRows = $detailModel->getByKind($dCk, true, $lgIdx);
foreach ($shops as $row) {
$idx = (int) ($row->ds_idx ?? 0);
$road = (string) ($row->ds_addr ?? '');
$jibun = (string) ($row->ds_addr_jibun ?? '');
$zip = (string) ($row->ds_zip ?? '');
$blob = trim($road . ' ' . $jibun . ' ' . $zip);
if ($blob === '') {
$out[$idx] = ['code' => '', 'name' => ''];
continue;
}
// B: 시·도
$bCode = null;
foreach ($bRows as $r) {
$nm = trim((string) $r->cd_name);
$cd = trim((string) $r->cd_code);
if ($nm === '' || $cd === '') {
continue;
}
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
$bCode = $cd;
break;
}
}
if ($bCode === null || $bCode === '') {
$out[$idx] = ['code' => '', 'name' => ''];
continue;
}
// C: 구·군
$cCode = null;
foreach ($cRows as $r) {
$cd = trim((string) $r->cd_code);
if ($cd === '' || ! str_starts_with($cd, $bCode)) {
continue;
}
$nm = trim((string) $r->cd_name);
if ($nm === '') {
continue;
}
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
$cCode = $cd;
break;
}
}
if ($cCode === null || $cCode === '') {
$out[$idx] = ['code' => '', 'name' => ''];
continue;
}
// D: 동 — 긴 이름 우선 매칭(예: '노원동1가'가 '노원동'보다 먼저)
$cands = [];
foreach ($dRows as $r) {
$cd = trim((string) $r->cd_code);
if ($cd === '' || ! str_starts_with($cd, $cCode)) {
continue;
}
$nm = trim((string) $r->cd_name);
if ($nm === '') {
continue;
}
$cands[] = ['len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd];
}
usort($cands, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
$dCode = '';
$dName = '';
foreach ($cands as $cand) {
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
$dCode = $cand['cd'];
$dName = $cand['nm'];
break;
}
}
$out[$idx] = ['code' => $dCode, 'name' => $dName];
}
return $out;
}
/**
* 산출된 동코드를 ds_dong_code 컬럼에 저장(값이 다를 때만). 컬럼이 없으면 아무것도 하지 않음.
*
* @param list<object> $shops
* @param array<int, array{code: string, name: string}> $dongMap
*/
private function persistDesignatedShopDongCodes(array $shops, array $dongMap): void
{
if ($dongMap === [] || ! $this->designatedShopHasDongColumn()) {
return;
}
$db = \Config\Database::connect();
foreach ($shops as $row) {
$idx = (int) ($row->ds_idx ?? 0);
if ($idx <= 0 || ! isset($dongMap[$idx])) {
continue;
}
$code = $dongMap[$idx]['code'];
$current = (string) ($row->ds_dong_code ?? '');
if ($code !== '' && $code !== $current) {
$db->table('designated_shop')->where('ds_idx', $idx)->update(['ds_dong_code' => $code]);
$row->ds_dong_code = $code; // 뷰 페이로드에도 반영
}
}
}
/** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */ /** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
private function kakaoJavascriptKey(): string private function kakaoJavascriptKey(): string
{ {

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\FeedbackFileModel;
use App\Models\FeedbackModel;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Roles;
/**
* 사용자 의견 검토(관리자). super·본부는 전체, 지자체관리자는 자기 지자체만.
*/
class Feedback extends BaseController
{
private function scopeLgIdx(): ?int
{
helper('admin');
$level = (int) session()->get('mb_level');
if (Roles::isSuperAdminEquivalent($level)) {
return null; // 전체
}
return admin_effective_lg_idx();
}
public function index(): string
{
$model = model(FeedbackModel::class);
$builder = $model->orderBy('fb_idx', 'DESC');
$scope = $this->scopeLgIdx();
if ($scope !== null) {
$builder->where('fb_lg_idx', $scope);
}
$status = (string) ($this->request->getGet('status') ?? '');
if (in_array($status, FeedbackModel::STATUSES, true)) {
$builder->where('fb_status', $status);
}
$list = $builder->paginate(20);
$pager = $model->pager;
return view('admin/layout', [
'title' => '사용자 의견',
'content' => view('admin/feedback/index', [
'list' => $list,
'pager' => $pager,
'status' => $status,
]),
]);
}
/**
* 전체 의견을 한 페이지에서 스크롤로 훑어볼 수 있는 화면(상세 클릭 없이 내용 전체 표시).
*/
public function all(): string
{
$model = model(FeedbackModel::class);
$builder = $model->orderBy('fb_idx', 'DESC');
$scope = $this->scopeLgIdx();
if ($scope !== null) {
$builder->where('fb_lg_idx', $scope);
}
$status = (string) ($this->request->getGet('status') ?? '');
if (in_array($status, FeedbackModel::STATUSES, true)) {
$builder->where('fb_status', $status);
}
$list = $builder->findAll();
$ids = array_map(static fn ($r): int => (int) $r->fb_idx, $list);
$filesByFb = [];
if ($ids !== []) {
$files = model(FeedbackFileModel::class)
->whereIn('ff_fb_idx', $ids)
->orderBy('ff_idx', 'ASC')
->findAll();
foreach ($files as $f) {
$filesByFb[(int) $f->ff_fb_idx][] = $f;
}
}
return view('admin/layout', [
'title' => '사용자 의견 · 전체보기',
'content' => view('admin/feedback/all', [
'list' => $list,
'filesByFb' => $filesByFb,
'status' => $status,
]),
]);
}
public function show(int $id): string|ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$files = model(FeedbackFileModel::class)
->where('ff_fb_idx', $id)
->orderBy('ff_idx', 'ASC')
->findAll();
return view('admin/layout', [
'title' => '의견 상세',
'content' => view('admin/feedback/show', ['row' => $row, 'files' => $files]),
]);
}
public function updateStatus(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$status = (string) $this->request->getPost('fb_status');
$memo = (string) $this->request->getPost('fb_admin_memo');
$update = ['fb_admin_memo' => mb_substr($memo, 0, 2000)];
if (in_array($status, FeedbackModel::STATUSES, true)) {
$update['fb_status'] = $status;
}
$model->update($id, $update);
// 전체보기 화면에서 저장한 경우 스크롤 위치(#fb-{id})를 유지한 채 그 화면으로 되돌아간다.
$returnTo = (string) $this->request->getPost('return_to');
if ($returnTo === 'all') {
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '처리 상태를 저장했습니다.');
}
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
}
/** 문의 글(본문) 수정 */
public function updateContent(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$content = trim((string) $this->request->getPost('fb_content'));
if ($content === '') {
return redirect()->to(site_url('admin/feedback/' . $id))->with('error', '내용을 입력해 주세요.');
}
$model->update($id, ['fb_content' => mb_substr($content, 0, 2000)]);
$returnTo = (string) $this->request->getPost('return_to');
if ($returnTo === 'all') {
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '문의 내용을 수정했습니다.');
}
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '문의 내용을 수정했습니다.');
}
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
public function image(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope) || empty($row->fb_screenshot)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $row->fb_screenshot);
if (! is_file($path)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$mime = str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg';
return $this->response
->setHeader('Content-Type', $mime)
->setHeader('Cache-Control', 'private, max-age=300')
->setBody((string) file_get_contents($path));
}
/** 첨부 이미지(feedback_file) 서빙 — 관리자만, 테넌트 검증 */
public function file(int $ffIdx): ResponseInterface
{
$ff = model(FeedbackFileModel::class)->find($ffIdx);
if ($ff === null) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$fb = model(FeedbackModel::class)->find((int) $ff->ff_fb_idx);
$scope = $this->scopeLgIdx();
if ($fb === null || ($scope !== null && (int) $fb->fb_lg_idx !== $scope)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $ff->ff_path);
if (! is_file($path)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$mime = (string) ($ff->ff_mime ?: (str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg'));
return $this->response
->setHeader('Content-Type', $mime)
->setHeader('Cache-Control', 'private, max-age=300')
->setBody((string) file_get_contents($path));
}
}

View File

@@ -29,7 +29,7 @@ class Manager extends BaseController
{ {
return [ return [
'company' => '제작업체', 'company' => '제작업체',
'district' => '구·군', 'district' => '지자체',
'agency' => '대행소', 'agency' => '대행소',
]; ];
} }
@@ -77,6 +77,8 @@ class Manager extends BaseController
$rules = [ $rules = [
'mg_name' => 'required|max_length[50]', 'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]', 'mg_category' => 'required|in_list[company,district,agency]',
'mg_affiliation' => 'permit_empty|max_length[100]',
'mg_company_name' => 'permit_empty|max_length[100]',
'mg_tel' => 'permit_empty|max_length[20]', 'mg_tel' => 'permit_empty|max_length[20]',
'mg_phone' => 'permit_empty|max_length[20]', 'mg_phone' => 'permit_empty|max_length[20]',
'mg_email' => 'permit_empty|valid_email|max_length[100]', 'mg_email' => 'permit_empty|valid_email|max_length[100]',
@@ -88,6 +90,8 @@ class Manager extends BaseController
$this->model->insert([ $this->model->insert([
'mg_lg_idx' => admin_effective_lg_idx(), 'mg_lg_idx' => admin_effective_lg_idx(),
'mg_name' => $this->request->getPost('mg_name'), 'mg_name' => $this->request->getPost('mg_name'),
'mg_affiliation' => $this->request->getPost('mg_affiliation') ?? '',
'mg_company_name' => $this->request->getPost('mg_company_name') ?? '',
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''), 'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '', 'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '', 'mg_tel' => $this->request->getPost('mg_tel') ?? '',
@@ -126,6 +130,8 @@ class Manager extends BaseController
$rules = [ $rules = [
'mg_name' => 'required|max_length[50]', 'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]', 'mg_category' => 'required|in_list[company,district,agency]',
'mg_affiliation' => 'permit_empty|max_length[100]',
'mg_company_name' => 'permit_empty|max_length[100]',
'mg_state' => 'required|in_list[0,1]', 'mg_state' => 'required|in_list[0,1]',
]; ];
if (! $this->validate($rules)) { if (! $this->validate($rules)) {
@@ -134,6 +140,8 @@ class Manager extends BaseController
$this->model->update($id, [ $this->model->update($id, [
'mg_name' => $this->request->getPost('mg_name'), 'mg_name' => $this->request->getPost('mg_name'),
'mg_affiliation' => $this->request->getPost('mg_affiliation') ?? '',
'mg_company_name' => $this->request->getPost('mg_company_name') ?? '',
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''), 'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '', 'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '', 'mg_tel' => $this->request->getPost('mg_tel') ?? '',

View File

@@ -210,7 +210,41 @@ class Menu extends BaseController
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx), 'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
]; ];
// 위치(상위↔하위) 변경 지원: mm_pidx 가 바뀌면 상위/하위로 이동
$oldPidx = (int) $row->mm_pidx;
$newPidx = (int) $this->request->getPost('mm_pidx');
$newDep = $newPidx > 0 ? 1 : 0;
$positionChanged = $newPidx !== $oldPidx;
if ($positionChanged) {
if ($newPidx === $id) {
return $this->menusRedirect((int) $row->mt_idx)->with('error', '자기 자신을 상위 메뉴로 지정할 수 없습니다.');
}
// 하위 메뉴를 보유한 상위 메뉴는 하위로 변경 불가(2단 구조 유지)
$childCnt = $this->menuModel->where('mt_idx', (int) $row->mt_idx)->where('lg_idx', $lgIdx)->where('mm_pidx', $id)->countAllResults();
if ($newPidx > 0 && $childCnt > 0) {
return $this->menusRedirect((int) $row->mt_idx)->with('error', '하위 메뉴가 있는 상위 메뉴는 하위로 변경할 수 없습니다. 먼저 하위 메뉴를 옮겨 주세요.');
}
if ($newPidx > 0) {
$parent = $this->menuModel->find($newPidx);
if (! $parent || (int) $parent->lg_idx !== $lgIdx || (int) $parent->mt_idx !== (int) $row->mt_idx || (int) $parent->mm_dep !== 0) {
return $this->menusRedirect((int) $row->mt_idx)->with('error', '올바른 상위 메뉴를 선택해 주세요.');
}
}
$data['mm_pidx'] = $newPidx;
$data['mm_dep'] = $newDep;
$data['mm_num'] = $this->menuModel->getNextNum((int) $row->mt_idx, $lgIdx, $newPidx, $newDep);
}
$this->menuModel->update($id, $data); $this->menuModel->update($id, $data);
if ($positionChanged) {
if ($oldPidx > 0) {
$this->menuModel->updateCnode($oldPidx, -1);
}
if ($newPidx > 0) {
$this->menuModel->updateCnode($newPidx, 1);
}
}
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.'); return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
@@ -256,8 +290,31 @@ class Menu extends BaseController
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.'); ->with('error', '지자체를 선택하세요.');
} }
$ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_idx'); $postMtIdx = (int) $this->request->getPost('mt_idx');
// 순서 + 상위(부모) 변경 구조가 함께 전달되면 우선 처리 (하위 메뉴의 상위 이동 지원)
$treeJson = (string) $this->request->getPost('menu_tree');
$structure = $treeJson !== '' ? json_decode($treeJson, true) : null;
if (is_array($structure) && $structure !== []) {
$orderedIds = array_values(array_filter(array_map(
static fn ($it): int => (int) ($it['idx'] ?? 0),
$structure
), static fn (int $v): bool => $v > 0));
if (empty($orderedIds)) {
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
}
$firstRow = $this->menuModel->find($orderedIds[0]);
$this->menuModel->setStructure($structure, $lgIdx);
if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
}
$mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
}
$ids = $this->request->getPost('mm_idx');
if (! is_array($ids) || empty($ids)) { if (! is_array($ids) || empty($ids)) {
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.'); return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
} }

View File

@@ -19,14 +19,15 @@ class PasswordChange extends BaseController
helper('admin'); helper('admin');
$rules = [ $rules = [
'current_password' => 'required', 'current_password' => 'required',
'new_password' => 'required|min_length[4]|max_length[255]', 'new_password' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'new_password_confirm' => 'required|matches[new_password]', 'new_password_confirm' => 'required|matches[new_password]',
]; ];
$messages = [ $messages = [
'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'], 'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'],
'new_password' => [ 'new_password' => [
'required' => '새 비밀번호를 입력해 주세요.', 'required' => '새 비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.', 'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
], ],
'new_password_confirm' => [ 'new_password_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.', 'required' => '비밀번호 확인을 입력해 주세요.',

View File

@@ -45,7 +45,8 @@ class SalesAgency extends BaseController
$builder->where('sa_idx', (int) $saIdx); $builder->where('sa_idx', (int) $saIdx);
} }
$list = $builder->orderForDisplay()->paginate(20); // 방금 등록한 대행소가 맨 위에 보이도록 최신 등록순(번호 내림차순)으로 정렬
$list = $builder->orderBy('sa_idx', 'DESC')->paginate(20);
$pager = $this->model->pager; $pager = $this->model->pager;
$queryForPager = [ $queryForPager = [

View File

@@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel;
use App\Models\DesignatedShopModel; use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel; use App\Models\LocalGovernmentModel;
use App\Models\SalesAgencyModel; use App\Models\SalesAgencyModel;
use App\Models\ReportPresetModel;
class SalesReport extends BaseController class SalesReport extends BaseController
{ {
/** 맞춤 집계표에서 선택 가능한 컬럼 카탈로그 (표시 순서 = 정의 순서) */
private const CUSTOM_REPORT_COLUMNS = [
'date' => ['label' => '일자', 'type' => 'str'],
'shop_no' => ['label' => '지정번호', 'type' => 'str'],
'shop_name' => ['label' => '판매소명', 'type' => 'str'],
'rep_name' => ['label' => '대표자', 'type' => 'str'],
'address' => ['label' => '소재지', 'type' => 'str'],
'zone' => ['label' => '구역', 'type' => 'str'],
'category' => ['label' => '품목구분', 'type' => 'str'],
'product' => ['label' => '품목', 'type' => 'str'],
'size' => ['label' => '용량', 'type' => 'str'],
'qty' => ['label' => '판매량', 'type' => 'num'],
'amount' => ['label' => '판매금액', 'type' => 'num'],
'fee' => ['label' => '수수료', 'type' => 'num'],
'total' => ['label' => '총액', 'type' => 'num'],
];
private const CUSTOM_REPORT_DEFAULT_COLUMNS = ['date', 'shop_no', 'shop_name', 'product', 'qty', 'amount'];
/** 품목명 → 3분류(봉투/음식물/폐기물) */
private function customReportCategory3(string $name): string
{
if (mb_strpos($name, '음식물') !== false) {
return 'food';
}
if (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
return 'waste';
}
return 'envelope';
}
private function customReportCategoryLabel(string $cat): string
{
return ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'][$cat] ?? '전체';
}
/** 품목명에서 용량 토큰 추출(예: 10L / 1000원) */
private function customReportSize(string $name): string
{
if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
return $m[1] . 'L';
}
if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
return str_replace(',', '', $m[1]) . '원';
}
return '';
}
/** /**
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄) * P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
*/ */
@@ -70,6 +118,16 @@ class SalesReport extends BaseController
$hasBsFee $hasBsFee
); );
// 대표자명 PII: 암호화 대비 복호화 + 권한 없으면 마스킹 (표시 계층)
helper('pii_encryption');
$canViewPii = can_view_shop_pii((int) $lgIdx);
foreach ($detailRows as $row) {
if (isset($row->ds_rep_name) && $row->ds_rep_name !== '') {
$rep = pii_decrypt((string) $row->ds_rep_name);
$row->ds_rep_name = $canViewPii ? $rep : mask_shop_field('ds_rep_name', $rep);
}
}
$filtered = []; $filtered = [];
foreach ($detailRows as $row) { foreach ($detailRows as $row) {
if ($this->ledgerRowMatchesCategories($row, $cats)) { if ($this->ledgerRowMatchesCategories($row, $cats)) {
@@ -77,6 +135,74 @@ class SalesReport extends BaseController
} }
} }
// 크기별(리터/금액) 조회 — 품목명에서 크기 토큰 추출(예: 10L, 1000원)
$sizeOf = static function (string $name): string {
if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
return $m[1] . 'L';
}
if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
return str_replace(',', '', $m[1]) . '원';
}
return '';
};
// 봉투(일반용)/음식물/폐기물 등 카테고리별로 선택 가능한 크기 목록을 구성한다.
// (카테고리 필터까지 적용된 현재 결과 기준)
$sizeCatLabels = [
'general' => '일반용 봉투',
'reuse' => '재사용 봉투',
'public_use' => '공공용 봉투',
'food' => '음식물 봉투',
'waste' => '폐기물 봉투',
];
// 스티커·공동주택용(apt)·용기(container)는 판매대장 크기 필터에서 제외
// 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
$sizeCatOrder = ['general', 'reuse', 'public_use', 'food', 'waste'];
$sizeByCat = []; // catKey => [size => true]
foreach ($filtered as $row) {
$sz = $sizeOf((string) ($row->bs_bag_name ?? ''));
if ($sz === '') {
continue;
}
$ck = $this->classifyLedgerProductCategory(
(string) ($row->bs_bag_name ?? ''),
(string) ($row->bs_bag_code ?? '')
);
$sizeByCat[$ck][$sz] = true;
}
$sizeGroups = [];
foreach ($sizeCatOrder as $ck) {
if (empty($sizeByCat[$ck])) {
continue;
}
$sizes = array_keys($sizeByCat[$ck]);
usort($sizes, static function (string $a, string $b): int {
return (int) preg_replace('/\D/', '', $a) <=> (int) preg_replace('/\D/', '', $b);
});
$sizeGroups[] = ['key' => $ck, 'label' => $sizeCatLabels[$ck] ?? $ck, 'sizes' => $sizes];
}
// 선택값은 "카테고리|크기" 형식(예: general|10L). 구버전(크기만) 값도 호환.
$size = trim((string) ($this->request->getGet('size') ?? ''));
if ($size !== '') {
if (strpos($size, '|') !== false) {
[$selCat, $selSize] = array_pad(explode('|', $size, 2), 2, '');
$filtered = array_values(array_filter($filtered, function ($row) use ($sizeOf, $selCat, $selSize): bool {
$ck = $this->classifyLedgerProductCategory(
(string) ($row->bs_bag_name ?? ''),
(string) ($row->bs_bag_code ?? '')
);
return $ck === $selCat && $sizeOf((string) ($row->bs_bag_name ?? '')) === $selSize;
}));
} else {
$filtered = array_values(array_filter($filtered, static fn ($row): bool => $sizeOf((string) ($row->bs_bag_name ?? '')) === $size));
}
}
// 하단 통계: 현재 필터(카테고리·크기)까지 적용된 결과 기준으로
// ① 용량별(리터별) ② 품목별(봉투/음식물/폐기물) 집계
[$sizeStats, $catStats] = $this->buildLedgerBottomStats($filtered, $sizeOf);
if ($mode === 'daily') { if ($mode === 'daily') {
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee); $built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
} else { } else {
@@ -169,6 +295,10 @@ class SalesReport extends BaseController
'dsIdx' => $dsIdx, 'dsIdx' => $dsIdx,
'saIdx' => $saIdx, 'saIdx' => $saIdx,
'cats' => $cats, 'cats' => $cats,
'sizeGroups' => $sizeGroups,
'size' => $size,
'sizeStats' => $sizeStats,
'catStats' => $catStats,
'shops' => $shops, 'shops' => $shops,
'agencies' => $agencies, 'agencies' => $agencies,
'lgName' => $lgName, 'lgName' => $lgName,
@@ -220,6 +350,68 @@ class SalesReport extends BaseController
return $builder->get()->getResult(); return $builder->get()->getResult();
} }
/**
* 판매대장 하단 통계: 용량별(리터별)·품목별(봉투/음식물/폐기물) 집계.
*
* @param list<object> $rows 필터가 적용된 상세 판매행
* @param callable $sizeOf 품목명 → 크기 토큰(예: '10L', '1000원', '')
* @return array{0: list<array{label:string,qty:int,amount:int}>, 1: list<array{label:string,qty:int,amount:int}>}
*/
private function buildLedgerBottomStats(array $rows, callable $sizeOf): array
{
$bySize = []; // 크기토큰 => [qty, amount]
$byCat = []; // '봉투'|'음식물'|'폐기물' => [qty, amount]
foreach ($rows as $row) {
$name = (string) ($row->bs_bag_name ?? '');
$qty = (int) ($row->line_qty ?? 0);
$amt = (float) ($row->line_amount ?? 0);
// ① 용량별(리터별) — 크기 토큰이 없으면 '기타'
$sz = (string) $sizeOf($name);
if ($sz === '') {
$sz = '기타';
}
$bySize[$sz]['qty'] = ($bySize[$sz]['qty'] ?? 0) + $qty;
$bySize[$sz]['amount'] = ($bySize[$sz]['amount'] ?? 0) + $amt;
// ② 품목별 — 봉투/음식물/폐기물 3분류
if (mb_strpos($name, '음식물') !== false) {
$cat = '음식물';
} elseif (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
$cat = '폐기물';
} else {
$cat = '봉투';
}
$byCat[$cat]['qty'] = ($byCat[$cat]['qty'] ?? 0) + $qty;
$byCat[$cat]['amount'] = ($byCat[$cat]['amount'] ?? 0) + $amt;
}
// 용량별: 숫자(리터/금액) 오름차순, '기타'는 맨 뒤
$sizeStats = [];
foreach ($bySize as $label => $v) {
$sizeStats[] = ['label' => (string) $label, 'qty' => (int) $v['qty'], 'amount' => (int) round($v['amount'])];
}
usort($sizeStats, static function (array $a, array $b): int {
$ao = $a['label'] === '기타' ? 1 : 0;
$bo = $b['label'] === '기타' ? 1 : 0;
if ($ao !== $bo) {
return $ao <=> $bo;
}
return (int) preg_replace('/\D/', '', $a['label']) <=> (int) preg_replace('/\D/', '', $b['label']);
});
// 품목별: 봉투 → 음식물 → 폐기물 고정 순서
$catStats = [];
foreach (['봉투', '음식물', '폐기물'] as $c) {
if (isset($byCat[$c])) {
$catStats[] = ['label' => $c, 'qty' => (int) $byCat[$c]['qty'], 'amount' => (int) round($byCat[$c]['amount'])];
}
}
return [$sizeStats, $catStats];
}
/** /**
* @param list<string> $cats 빈 배열이면 전체 * @param list<string> $cats 빈 배열이면 전체
*/ */
@@ -1038,7 +1230,8 @@ class SalesReport extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); // 기본 조회 기간: 오늘로부터 일주일 전 ~ 오늘
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-d', strtotime('-7 days')));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if ($startDate > $endDate) { if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate]; [$startDate, $endDate] = [$endDate, $startDate];
@@ -1216,6 +1409,30 @@ class SalesReport extends BaseController
if ($export) { if ($export) {
$headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec'])); $headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec']));
$colCount = count($headers);
// 최상단 제목 블록: (지역/범위) ……… (단위 : 매/원) / (제목) ……… 출력일
$region = trim(
(string) ($lgRow->lg_sido ?? '') . ' ' . (string) ($lgRow->lg_gugun ?? '')
);
if ($region === '') {
$region = $lgName;
}
$makeTopRow = static function (string $left, string $right) use ($colCount): array {
$row = array_fill(0, max($colCount, 1), '');
$row[0] = $left;
if ($right !== '') {
$row[max($colCount - 1, 0)] = $right;
}
return $row;
};
$topRows = [
$makeTopRow($region . ' / ' . $gugunLabel, '(단위 : 매/원)'),
$makeTopRow($year . '년 판매현황 · 대행소: ' . $agencyLabel, '출력일: ' . date('Y-m-d')),
array_fill(0, max($colCount, 1), ''), // 구분용 빈 줄
];
$exportRows = []; $exportRows = [];
foreach ($built['itemBlocks'] as $block) { foreach ($built['itemBlocks'] as $block) {
foreach ($block['lines'] as $li) { foreach ($block['lines'] as $li) {
@@ -1238,7 +1455,8 @@ class SalesReport extends BaseController
'년판매현황_' . $year . '_' . $exportStamp, '년판매현황_' . $year . '_' . $exportStamp,
$year . '년판매', $year . '년판매',
$headers, $headers,
$exportRows $exportRows,
$topRows
); );
} }
@@ -3218,4 +3436,256 @@ class SalesReport extends BaseController
'salesLabel' => $scopeLabel($salesScope), 'salesLabel' => $scopeLabel($salesScope),
]); ]);
} }
/**
* 맞춤 집계표 — 기간·품목구분·컬럼을 골라 제목을 붙여 조회/인쇄, 계정별 프리셋 저장.
*/
public function customReport()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$mbIdx = (int) session()->get('mb_idx');
// report_preset 테이블이 있을 때만 프리셋 기능 활성(없으면 조회·인쇄는 그대로 동작)
$presetsEnabled = \Config\Database::connect()->tableExists('report_preset');
$presetModel = model(ReportPresetModel::class);
$presets = [];
if ($presetsEnabled) {
$presets = $presetModel->where('rp_lg_idx', $lgIdx)->where('rp_mb_idx', $mbIdx)
->orderBy('rp_name', 'ASC')->findAll();
}
// 활성 설정 결정: 폼 적용(applied) > 프리셋 로드(preset) > 기본값
$presetIdx = (int) ($this->request->getGet('preset') ?? 0);
$loaded = null;
if ($presetsEnabled && $presetIdx > 0) {
$loaded = $presetModel->find($presetIdx);
if ($loaded === null || (int) $loaded->rp_lg_idx !== (int) $lgIdx || (int) $loaded->rp_mb_idx !== $mbIdx) {
$loaded = null;
}
}
$catalogKeys = array_keys(self::CUSTOM_REPORT_COLUMNS);
if ((string) ($this->request->getGet('applied') ?? '') === '1') {
$cols = (array) ($this->request->getGet('cols') ?? []);
$title = (string) ($this->request->getGet('title') ?? '');
$category = (string) ($this->request->getGet('category') ?? 'all');
$mode = (string) ($this->request->getGet('mode') ?? 'detail');
} elseif ($loaded !== null) {
$cols = json_decode((string) ($loaded->rp_columns ?? '[]'), true) ?: [];
$title = (string) $loaded->rp_title;
$category = (string) $loaded->rp_category;
$mode = (string) $loaded->rp_mode;
} else {
$cols = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
$title = '';
$category = 'all';
$mode = 'detail';
}
// 정규화
$selected = array_values(array_filter($catalogKeys, static fn ($k) => in_array($k, $cols, true)));
if ($selected === []) {
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
}
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
$category = 'all';
}
$mode = $mode === 'summary' ? 'summary' : 'detail';
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if (! $isYmd($startDate)) {
$startDate = date('Y-m-01');
}
if (! $isYmd($endDate)) {
$endDate = date('Y-m-d');
}
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$db = \Config\Database::connect();
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
$detail = $this->fetchSalesLedgerDetailRows($db, (int) $lgIdx, $startDate, $endDate, 0, 0, $hasBsFee);
// 카테고리 필터 + 행 매핑
$rows = [];
foreach ($detail as $r) {
$name = (string) ($r->bs_bag_name ?? '');
$cat3 = $this->customReportCategory3($name);
if ($category !== 'all' && $cat3 !== $category) {
continue;
}
$qty = (int) ($r->line_qty ?? 0);
$amount = (float) ($r->line_amount ?? 0);
$fee = (float) ($r->line_fee ?? 0);
$rows[] = [
'date' => substr((string) ($r->bs_sale_date ?? ''), 0, 10),
'shop_no' => (string) ($r->ds_shop_no ?? ''),
'shop_name' => (string) ($r->ds_name ?? ''),
'rep_name' => (string) ($r->ds_rep_name ?? ''),
'address' => (string) ($r->ds_addr ?? ''),
'zone' => (string) ($r->ds_zone_code ?? ''),
'category' => $this->customReportCategoryLabel($cat3),
'product' => $name,
'size' => $this->customReportSize($name),
'qty' => $qty,
'amount' => (int) round($amount),
'fee' => (int) round($fee),
'total' => (int) round($amount + $fee),
];
}
// 집계 모드: 품목(구분+품명+용량) 기준 합산
if ($mode === 'summary') {
$agg = [];
foreach ($rows as $row) {
$key = $row['category'] . '|' . $row['product'] . '|' . $row['size'];
if (! isset($agg[$key])) {
$agg[$key] = ['category' => $row['category'], 'product' => $row['product'], 'size' => $row['size'],
'date' => '', 'shop_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'zone' => '',
'qty' => 0, 'amount' => 0, 'fee' => 0, 'total' => 0];
}
$agg[$key]['qty'] += $row['qty'];
$agg[$key]['amount'] += $row['amount'];
$agg[$key]['fee'] += $row['fee'];
$agg[$key]['total'] += $row['total'];
}
$rows = array_values($agg);
}
// 숫자 컬럼 합계
$totals = [];
foreach ($selected as $key) {
if ((self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num') {
$totals[$key] = array_sum(array_map(static fn ($r) => (int) $r[$key], $rows));
}
}
// 엑셀 다운로드 — 현재 컬럼·필터·제목 그대로
if ((string) ($this->request->getGet('export') ?? '') === '1') {
$headers = array_map(static fn ($k) => self::CUSTOM_REPORT_COLUMNS[$k]['label'], $selected);
$exportRows = [];
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
$line[] = $isNum ? number_format((int) ($row[$key] ?? 0)) : (string) ($row[$key] ?? '');
}
$exportRows[] = $line;
}
// 합계 행
$totalLine = [];
foreach ($selected as $i => $key) {
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
if ($i === 0) {
$totalLine[] = '합계';
} else {
$totalLine[] = $isNum ? number_format((int) ($totals[$key] ?? 0)) : '';
}
}
$exportRows[] = $totalLine;
$fileTitle = $title !== '' ? $title : '맞춤집계표';
$topRows = [
[$fileTitle],
['조회기간: ' . $startDate . ' ~ ' . $endDate . ' / 품목구분: ' . $this->customReportCategoryLabel($category)],
];
export_excel_2003_xml($fileTitle . '_' . $startDate . '_' . $endDate, mb_substr($fileTitle, 0, 31), $headers, $exportRows, $topRows);
}
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
'selected' => $selected,
'title' => $title,
'category' => $category,
'mode' => $mode,
'startDate' => $startDate,
'endDate' => $endDate,
'rows' => $rows,
'totals' => $totals,
'presets' => $presets,
'activePresetIdx' => $loaded !== null ? (int) $loaded->rp_idx : 0,
'presetsEnabled' => $presetsEnabled,
]);
}
/** 맞춤 집계표 프리셋 저장(신규/수정) — 계정 소유 */
public function customReportPresetSave()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$mbIdx = (int) session()->get('mb_idx');
if (! \Config\Database::connect()->tableExists('report_preset')) {
return redirect()->to(site_url('bag/reports/custom'))
->with('error', '프리셋 저장 테이블이 아직 생성되지 않았습니다. (report_preset_table.sql 실행 필요)');
}
$name = trim((string) $this->request->getPost('rp_name'));
if ($name === '') {
return redirect()->to(site_url('bag/reports/custom'))->with('error', '프리셋 이름을 입력해 주세요.');
}
$cols = (array) ($this->request->getPost('cols') ?? []);
$catalog = array_keys(self::CUSTOM_REPORT_COLUMNS);
$selected = array_values(array_filter($catalog, static fn ($k) => in_array($k, $cols, true)));
if ($selected === []) {
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
}
$category = (string) $this->request->getPost('rp_category');
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
$category = 'all';
}
$mode = (string) $this->request->getPost('rp_mode') === 'summary' ? 'summary' : 'detail';
$data = [
'rp_lg_idx' => $lgIdx,
'rp_mb_idx' => $mbIdx,
'rp_name' => mb_substr($name, 0, 100),
'rp_title' => mb_substr((string) $this->request->getPost('rp_title'), 0, 200),
'rp_category' => $category,
'rp_mode' => $mode,
'rp_columns' => json_encode($selected, JSON_UNESCAPED_UNICODE),
];
$presetModel = model(ReportPresetModel::class);
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
if ($rpIdx > 0) {
$existing = $presetModel->find($rpIdx);
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
$presetModel->update($rpIdx, $data);
}
} else {
$presetModel->insert($data);
$rpIdx = (int) $presetModel->getInsertID();
}
$q = http_build_query(['preset' => $rpIdx, 'start_date' => $this->request->getPost('start_date'), 'end_date' => $this->request->getPost('end_date')]);
return redirect()->to(site_url('bag/reports/custom') . '?' . $q)->with('success', '프리셋을 저장했습니다.');
}
/** 맞춤 집계표 프리셋 삭제 — 계정 소유 */
public function customReportPresetDelete()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
$mbIdx = (int) session()->get('mb_idx');
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
$presetModel = model(ReportPresetModel::class);
$existing = $rpIdx > 0 ? $presetModel->find($rpIdx) : null;
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
$presetModel->delete($rpIdx);
return redirect()->to(site_url('bag/reports/custom'))->with('success', '프리셋을 삭제했습니다.');
}
return redirect()->to(site_url('bag/reports/custom'))->with('error', '삭제할 프리셋을 찾을 수 없습니다.');
}
} }

View File

@@ -83,13 +83,13 @@ class User extends BaseController
{ {
$rules = [ $rules = [
'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]', 'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]', 'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'mb_name' => 'required|max_length[50]', 'mb_name' => 'required|max_length[50]',
'mb_email' => 'permit_empty|valid_email|max_length[100]', 'mb_email' => 'permit_empty|valid_email|max_length[100]',
'mb_phone' => 'permit_empty|max_length[20]', 'mb_phone' => 'permit_empty|max_length[20]',
'mb_level' => 'required', 'mb_level' => 'required',
]; ];
if (! $this->validate($rules)) { if (! $this->validate($rules, ['mb_passwd' => ['min_length' => '비밀번호는 8자 이상이어야 합니다.', 'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.']])) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$allowedLevels = array_keys($this->getAssignableLevels()); $allowedLevels = array_keys($this->getAssignableLevels());
@@ -154,9 +154,9 @@ class User extends BaseController
]; ];
$passwd = $this->request->getPost('mb_passwd'); $passwd = $this->request->getPost('mb_passwd');
if ($passwd !== '') { if ($passwd !== '') {
$rules['mb_passwd'] = 'min_length[4]|max_length[255]'; $rules['mb_passwd'] = ['min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'];
} }
if (! $this->validate($rules)) { if (! $this->validate($rules, ['mb_passwd' => ['min_length' => '비밀번호는 8자 이상이어야 합니다.', 'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.']])) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$allowedLevels = array_keys($this->getAssignableLevels()); $allowedLevels = array_keys($this->getAssignableLevels());

View File

@@ -19,15 +19,42 @@ class Auth extends BaseController
public function showLoginForm() public function showLoginForm()
{ {
if (session()->get('logged_in')) { if (session()->get('logged_in')) {
return redirect()->to('/'); // 이미 로그인 상태면 역할별 홈으로(뒤로가기로 로그인 화면에 와도 작업 화면으로 복귀)
return redirect()->to($this->loggedInHomeUrl());
} }
// 로그인 후 뒤로가기 시 브라우저 캐시(bfcache)로 로그인 화면이 다시 보이는 것을 방지.
// 캐시 금지 → 뒤로가기 시 서버 재요청 → 로그인 상태면 위에서 작업 화면으로 리다이렉트.
$this->response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
$this->response->setHeader('Pragma', 'no-cache');
$this->response->setHeader('Expires', '0');
return view('auth/login', [ return view('auth/login', [
'pageTitle' => '로그인 - GBLS', 'pageTitle' => '로그인 - GBLS',
'cardMax' => 'max-w-md', 'cardMax' => 'max-w-md',
]); ]);
} }
/**
* 로그인 상태 사용자의 역할별 기본 홈 URL.
* (로그인 성공 시 이동 지점과 일치 — 단, 슈퍼/본부는 지자체를 이미 선택했으면 작업 화면으로)
*/
private function loggedInHomeUrl(): string
{
$level = (int) session()->get('mb_level');
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return site_url('admin');
}
if (\Config\Roles::isSuperAdminEquivalent($level)) {
$hasLg = (int) (session()->get('admin_selected_lg_idx') ?? 0) > 0;
return site_url($hasLg ? 'admin' : 'admin/select-local-government');
}
return site_url('/');
}
public function login() public function login()
{ {
$rules = [ $rules = [
@@ -206,7 +233,7 @@ class Auth extends BaseController
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
} }
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx), true);
} }
public function showTotpSetup() public function showTotpSetup()
@@ -302,7 +329,7 @@ class Auth extends BaseController
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.'); return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
} }
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx)); return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx), true);
} }
public function logout() public function logout()
@@ -357,7 +384,7 @@ class Auth extends BaseController
{ {
$rules = [ $rules = [
'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]', 'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]', 'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'mb_passwd_confirm' => 'required|matches[mb_passwd]', 'mb_passwd_confirm' => 'required|matches[mb_passwd]',
'mb_name' => 'required|max_length[100]', 'mb_name' => 'required|max_length[100]',
'mb_email' => 'permit_empty|valid_email|max_length[100]', 'mb_email' => 'permit_empty|valid_email|max_length[100]',
@@ -375,8 +402,9 @@ class Auth extends BaseController
], ],
'mb_passwd' => [ 'mb_passwd' => [
'required' => '비밀번호를 입력해 주세요.', 'required' => '비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.', 'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'max_length' => '비밀번호는 255자 이하여야 합니다.', 'max_length' => '비밀번호는 255자 이하여야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
], ],
'mb_passwd_confirm' => [ 'mb_passwd_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.', 'required' => '비밀번호 확인을 입력해 주세요.',
@@ -545,9 +573,11 @@ class Auth extends BaseController
/** /**
* @param array<string, mixed> $logData * @param array<string, mixed> $logData
*/ */
private function completeLogin(object $member, array $logData): RedirectResponse private function completeLogin(object $member, array $logData, bool $twofaVerified = false): RedirectResponse
{ {
$this->clearPending2faSession(); $this->clearPending2faSession();
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
$sessionToken = bin2hex(random_bytes(16));
$sessionData = [ $sessionData = [
'mb_idx' => $member->mb_idx, 'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id, 'mb_id' => $member->mb_id,
@@ -555,6 +585,9 @@ class Auth extends BaseController
'mb_level' => $member->mb_level, 'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null, 'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true, 'logged_in' => true,
'session_token' => $sessionToken,
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
'auth_2fa_verified' => $twofaVerified,
]; ];
session()->set($sessionData); session()->set($sessionData);
@@ -562,18 +595,42 @@ class Auth extends BaseController
'mb_latestdate' => date('Y-m-d H:i:s'), 'mb_latestdate' => date('Y-m-d H:i:s'),
'mb_login_fail_count' => 0, 'mb_login_fail_count' => 0,
'mb_locked_until' => null, 'mb_locked_until' => null,
'mb_session_token' => $sessionToken,
]); ]);
// 로그인 알림 — 직전 성공 로그인과 IP 비교(새 IP면 경고). 현재 로그인 기록 INSERT 이전에 조회.
$loginNotice = '';
try {
$curIp = (string) $this->request->getIPAddress();
$prev = model(MemberLogModel::class)
->where('mb_idx', (int) $member->mb_idx)
->where('mll_success', 1)
->orderBy('mll_idx', 'DESC')
->first();
if ($prev) {
$prevIp = (string) ($prev->mll_ip ?? '');
$prevAt = (string) ($prev->mll_regdate ?? '');
if ($prevIp !== '' && $prevIp !== $curIp) {
$loginNotice = ' ⚠ 새로운 환경(IP ' . $curIp . ')에서 로그인되었습니다. 본인이 아니면 비밀번호를 변경하세요. (이전 로그인: ' . $prevAt . ' · ' . $prevIp . ')';
} elseif ($prevAt !== '') {
$loginNotice = ' (이전 로그인: ' . $prevAt . ')';
}
}
} catch (\Throwable $e) {
// 알림 계산 실패는 로그인에 영향 주지 않음
}
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx); $this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
$successMsg = '로그인되었습니다.' . $loginNotice;
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) { if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('admin'))->with('success', $successMsg);
} }
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) { if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('admin/select-local-government'))->with('success', $successMsg);
} }
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('/'))->with('success', $successMsg);
} }
private function buildLogData(string $mbId, ?int $mbIdx): array private function buildLogData(string $mbId, ?int $mbIdx): array

File diff suppressed because it is too large Load Diff

View File

@@ -74,9 +74,17 @@ abstract class BaseController extends Controller
while (str_starts_with($path, 'index.php/')) { while (str_starts_with($path, 'index.php/')) {
$path = substr($path, strlen('index.php/')); $path = substr($path, strlen('index.php/'));
} }
// 워크스페이스 탭(iframe) 안이면 경로와 무관하게 임베드 레이아웃 → 셸 중첩 방지
// (예: bag/code-kinds 탭에서 admin/code-kinds/create 등록 화면을 열 때)
if ($this->isEmbeddedRequest()) {
return view('bag/layout/embed', [
'title' => $title,
'content' => $content,
]);
}
if ($path === 'bag' || str_starts_with($path, 'bag/')) { if ($path === 'bag' || str_starts_with($path, 'bag/')) {
// /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal 셸 return view('bag/layout/portal', [
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,
]); ]);

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\FeedbackFileModel;
use App\Models\FeedbackModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* 사용자 의견 접수. 로그인 사용자가 화면에서 남긴 의견 + 스크린샷을 저장한다.
* (계정·지자체는 클라이언트 값을 믿지 않고 세션에서 채운다.)
*/
class Feedback extends BaseController
{
public function store(): ResponseInterface
{
if (! session()->get('logged_in')) {
return $this->response->setStatusCode(401)->setJSON(['ok' => false, 'message' => '로그인이 필요합니다.']);
}
$content = trim((string) $this->request->getPost('content'));
if ($content === '') {
return $this->response->setJSON(['ok' => false, 'message' => '의견 내용을 입력해 주세요.']);
}
if (mb_strlen($content) > 2000) {
$content = mb_substr($content, 0, 2000);
}
helper('admin');
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
if (! $lgIdx) {
$raw = session()->get('mb_lg_idx');
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
$model = model(FeedbackModel::class);
$id = $model->insert([
'fb_lg_idx' => $lgIdx,
'fb_mb_idx' => (int) session()->get('mb_idx'),
'fb_mb_name' => (string) (session()->get('mb_name') ?? ''),
'fb_mb_level' => (int) session()->get('mb_level'),
'fb_content' => $content,
'fb_page_url' => mb_substr((string) $this->request->getPost('page_url'), 0, 255),
'fb_page_title' => mb_substr((string) $this->request->getPost('page_title'), 0, 255),
'fb_user_agent' => mb_substr((string) $this->request->getHeaderLine('User-Agent'), 0, 255),
'fb_status' => 'new',
], true);
if (! $id) {
return $this->response->setJSON(['ok' => false, 'message' => '저장 중 오류가 발생했습니다.']);
}
$dir = WRITEPATH . 'uploads/feedback/';
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$fileModel = model(FeedbackFileModel::class);
// 1) 화면 캡처(dataURL) — 실패해도 의견 본문은 이미 저장됨
$shot = (string) $this->request->getPost('screenshot');
if ($shot !== '' && preg_match('#^data:image/(png|jpeg);base64,#', $shot, $m) === 1) {
$ext = $m[1] === 'png' ? 'png' : 'jpg';
$bin = base64_decode(substr($shot, strpos($shot, ',') + 1), true);
if ($bin !== false && strlen($bin) <= 5 * 1024 * 1024) {
$file = 'fb_' . (int) $id . '_cap_' . bin2hex(random_bytes(4)) . '.' . $ext;
if (@file_put_contents($dir . $file, $bin) !== false) {
$model->update($id, ['fb_screenshot' => $file]); // 목록 캡처여부 표시용
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'capture', 'ff_path' => $file, 'ff_mime' => 'image/' . ($ext === 'png' ? 'png' : 'jpeg')]);
}
}
}
// 2) 사용자가 첨부한 이미지(여러 장)
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
$count = 0;
foreach ($this->request->getFileMultiple('files') ?? [] as $f) {
if ($count >= 5 || $f === null || ! $f->isValid() || $f->hasMoved()) {
continue;
}
$mime = (string) $f->getMimeType();
if (! isset($allowed[$mime]) || $f->getSize() > 5 * 1024 * 1024) {
continue;
}
$file = 'fb_' . (int) $id . '_up_' . bin2hex(random_bytes(4)) . '.' . $allowed[$mime];
try {
$f->move($dir, $file);
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'upload', 'ff_path' => $file, 'ff_mime' => $mime]);
$count++;
} catch (\Throwable $e) {
// 개별 파일 실패는 건너뜀
}
}
return $this->response->setJSON(['ok' => true]);
}
}

View File

@@ -44,7 +44,11 @@ class Home extends BaseController
} }
helper('admin'); helper('admin');
return view('bag/layout/workspace'); // 계정별 저장된 탭 상태(자동 복원). 없으면 'null'.
$savedWorkspaceJson = (new \App\Controllers\Bag())
->loadWorkspaceTabsState((int) (session()->get('mb_idx') ?? 0));
return view('bag/layout/workspace', ['savedWorkspaceJson' => $savedWorkspaceJson]);
} }
/** /**

View File

@@ -42,6 +42,8 @@
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 | | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) | | 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
> **활동 로그(사용자 작업 기록)**: **지자체 관리자·슈퍼 관리자만** 접근할 수 있습니다. (일반 사용자·지정판매소는 볼 수 없습니다.)
## 5. 화면별 설명은 어디에? ## 5. 화면별 설명은 어디에?
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다. 좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.

View File

@@ -67,14 +67,17 @@
--- ---
## LOT-No 디스켓 불출 · *발주 입고 관리 LOT-No 디스켓 불출* ## 발주파일 생성 · *발주 입고 관리 발주파일 생성*
발주별 **LOT 번호 시드(seed) 데이터를 파일로 생성**하는 화면입니다. (바코드·LOT 부여에 쓰는 디스켓/파일 형태 데이터) 선택한 발주 건의 내용을 **봉투 생산기(바코드 인쇄) 전달용 파일**로 만드는 화면입니다. (예전 이름: *LOT-No 디스켓 불출*) 생성되는 파일은 발주 정보와 품목이 담긴 **암호화된 발주파일**(`.seed.json`)이며, 봉투 생산 장비가 이 파일을 읽어 그 발주의 바코드를 인쇄합니다.
**LOT-No란?** 발주를 등록할 때 그 건에 자동 부여되는 **6자리 배치(묶음) 번호**입니다. 이후 그 발주로 인쇄되는 모든 봉투 바코드의 앞자리(예: `ZLCH2M-000006-…`)가 되어, 어느 발주에서 나온 봉투인지 구분하는 기준이 됩니다.
**필터**: 시작월 · 종료월 · LOT-No · 제작업체. **필터**: 시작월 · 종료월 · LOT-No · 제작업체.
**순서** **순서**
1. 기간·조건으로 발주 목록을 조회합니다. 1. 기간·조건으로 발주 목록을 조회합니다.
2. 해당 발주 행의 **`seed 생성`** 을 눌러 그 발주의 LOT 시드 파일을 만듭니다. 2. 대상 발주 행의 **`내용 확인`** 을 누르면, 파일에 담길 **발주 정보와 품목 목록(봉투코드·수량·금액 등)** 을 팝업으로 미리 볼 수 있습니다.
3. 내용을 확인한 뒤 **`발주파일 생성(다운로드)`** 을 누르면 암호화된 발주파일이 내려받아집니다.
**표 컬럼**: 발주일자 · LOT-No · 품명 · 수량 · 제작업체 · (생성 버튼). **표 컬럼**: 발주일자 · LOT-No · 품명 · 수량 · 제작업체 · (내용 확인 버튼).
> 정상·취소 발주가 함께 조회되며, 시드 생성은 발주 단위로 동작합니다. > 정상·취소 발주가 함께 조회되며, 파일 생성은 발주 단위로 동작합니다. 생성 전 **`내용 확인`** 단계에서 반드시 품목·수량을 검토하세요.

View File

@@ -19,11 +19,44 @@
**함께 제공되는 화면** **함께 제공되는 화면**
- **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다. - **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다.
- **구별 신규/취소 명단**: 기간 내 새로 등록되거나 취소된 판매소 명단을 조회·엑셀저장합니다. - **지정 판매소 신규/취소 현황**: 한 해 동안 군·구별로 몇 곳이 새로 생기고 없어졌는지 **건수**를 집계하는 보고서입니다. (아래 별도 설명)
- **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다. - **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다.
--- ---
## 지정 판매소 신규/취소 현황 · *기본정보관리 지정 판매소 신규/취소 현황*
선택한 **한 해 동안** 지정판매소가 **몇 곳 새로 생기고(지정)**, **몇 곳 없어졌는지(취소)**, 그래서 **연말에 몇 곳이 남았는지**를 **군·구별 건수**로 집계해 보여주는 현황판입니다. 개별 가게 명단이 아니라 **숫자 집계표**입니다.
**핵심 개념 — 4개의 기둥 컬럼**
- **종전(전년도말)**: **전년 12월 31일** 기준 정상 운영 중이던 판매소 수 → *작년 말 잔고*.
- **지정(당해년)**: 조회년도 중 **새로 지정**된(신규) 판매소 수.
- **취소(당해년)**: 조회년도 중 **취소**(폐업·해지)된 판매소 수.
- **현행(금년도말)**: **조회년도 12월 31일** 기준 정상 운영 중인 판매소 수 → *올해 말 잔고*.
> 관계식: `현행 ≈ 종전 + 지정 취소`.
> 예) 종전 120 · 지정 15 · 취소 8 → 현행 127. “올해 15곳 새로 지정하고 8곳이 취소되어 순증 7곳, 연말 기준 127곳이 운영 중”이라는 뜻입니다.
**그 밖의 계산 컬럼**
- **구코드**: 판매소에 저장된 구·군 코드 값.
- **증감(현행−종전)**: 작년 말 대비 올해 말 **순증감 곳 수**.
- **지정−취소**: 그 해 신규에서 취소를 뺀 값(당해 순증).
- **현행비중(%)**: 전체 현행 합계에서 그 군·구가 차지하는 비율.
- **전년대비 증감률(%)**: `((현행−종전) ÷ 종전) × 100` (종전이 0이면 표시 안 함).
- 맨 아래 **합계** 행: 전 군·구 총계.
**함께 표시되는 것**
- **동별 현행 요약**: 선택 군·구 안에서 동(구역)별 현행 수를 칩(badge)과 표로 보여줍니다.
- **연도별 요약(참고, 접이식)**: 활성/비활성 합계와 연도별 신규등록·취소 건수.
**사용법**: 상단에서 **조회년도**를 고르고 **[조회]**. 군·구는 로그인한 지자체 기준으로 자동 고정됩니다. 우측 상단 **엑셀저장 · 인쇄**로 내보낼 수 있습니다. (각 컬럼 제목 옆 **?** 배지에 마우스를 올리면 설명이 나옵니다.)
> **비슷한 이름과 혼동 주의**
> - **지정 판매소 신규/취소 현황**(이 화면): 군·구별 신규/취소 **건수 집계·통계**.
> - **지정판매소 관리 / 조회**: **개별 가게**를 등록·검색·열람하는 화면.
---
## 단가 관리 · *기본정보관리 단가 관리* ## 단가 관리 · *기본정보관리 단가 관리*
봉투 **가격**을 기간별로 관리합니다. 봉투 **가격**을 기간별로 관리합니다.

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Models\MemberModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* 중복 로그인 방지(나중 로그인 우선).
* 로그인 시 발급한 세션 토큰(session_token)을 회원 행(mb_session_token)과 매 요청마다 대조해,
* 다른 곳에서 로그인해 토큰이 바뀌면 이 세션을 무효화하고 로그인으로 보낸다.
* (DB를 공유하므로 로컬·운영 등 서로 다른 서버 간에도 동작)
*/
class SessionGuardFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
if (! $session->get('logged_in')) {
return null; // 비로그인 요청은 통과
}
$mbIdx = (int) ($session->get('mb_idx') ?? 0);
if ($mbIdx <= 0) {
return null;
}
$member = model(MemberModel::class)->find($mbIdx);
if ($member === null) {
return null;
}
$dbToken = (string) ($member->mb_session_token ?? '');
$myToken = (string) ($session->get('session_token') ?? '');
// DB에 토큰이 있고 내 세션 토큰과 다르면 → 다른 곳에서 더 늦게 로그인한 것
if ($dbToken !== '' && $dbToken !== $myToken) {
$session->destroy();
// AJAX/JSON 요청은 401, 일반 요청은 로그인으로
if (strtolower((string) $request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest'
|| str_contains(strtolower((string) $request->getHeaderLine('Accept')), 'application/json')) {
return service('response')->setStatusCode(401)->setJSON([
'ok' => false, 'message' => '다른 곳에서 로그인되어 로그아웃되었습니다.',
]);
}
return redirect()->to(site_url('login'))->with('error', '다른 곳에서 로그인되어 자동 로그아웃되었습니다.');
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// no-op
}
}

View File

@@ -229,6 +229,37 @@ if (! function_exists('current_nav_request_path')) {
} }
} }
if (! function_exists('member_font_scale_for')) {
/**
* 로그인 계정의 특정 메뉴(화면 경로)에 저장된 글자크기(zoom %)를 반환.
* 저장값이 없거나 범위(70~150) 밖이면 100.
*/
function member_font_scale_for(string $menuKey): int
{
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
$menuKey = trim($menuKey);
if ($mbIdx <= 0 || $menuKey === '') {
return 100;
}
try {
$db = \Config\Database::connect();
if (! $db->tableExists('member_menu_font_scale')) {
return 100;
}
$row = $db->table('member_menu_font_scale')
->select('mmf_scale')
->where('mmf_mb_idx', $mbIdx)
->where('mmf_menu_key', $menuKey)
->get()->getRow();
} catch (\Throwable $e) {
return 100;
}
$s = $row ? (int) $row->mmf_scale : 100;
return ($s >= 70 && $s <= 150) ? $s : 100;
}
}
if (! function_exists('normalize_menu_link_for_url')) { if (! function_exists('normalize_menu_link_for_url')) {
/** /**
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다. * menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.

View File

@@ -8,6 +8,22 @@ declare(strict_types=1);
* UTF-8 BOM 포함으로 한글 엑셀 호환성 보장 * UTF-8 BOM 포함으로 한글 엑셀 호환성 보장
*/ */
if (! function_exists('audit_export_log')) {
/**
* 엑셀/CSV 다운로드를 활동 로그에 기록 (누가·무엇을 다운로드).
* 모든 export_* 함수 진입 시 호출 — 실패해도 다운로드에 영향 없음.
*/
function audit_export_log(string $filename): void
{
try {
helper('audit');
audit_log('export', 'export', 0, null, ['파일' => $filename]);
} catch (\Throwable $e) {
// 무시: 로깅 실패가 다운로드를 막지 않도록
}
}
}
if (! function_exists('export_csv')) { if (! function_exists('export_csv')) {
/** /**
* CSV 파일을 브라우저로 다운로드 전송 * CSV 파일을 브라우저로 다운로드 전송
@@ -18,6 +34,7 @@ if (! function_exists('export_csv')) {
*/ */
function export_csv(string $filename, array $headers, array $rows): void function export_csv(string $filename, array $headers, array $rows): void
{ {
audit_export_log($filename);
// 파일명에 .csv 확장자 보장 // 파일명에 .csv 확장자 보장
if (! str_ends_with($filename, '.csv')) { if (! str_ends_with($filename, '.csv')) {
$filename .= '.csv'; $filename .= '.csv';
@@ -76,9 +93,11 @@ if (! function_exists('export_excel_2003_xml')) {
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자) * @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
* @param string[] $headers 컬럼 헤더 * @param string[] $headers 컬럼 헤더
* @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력) * @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력)
* @param array $topRows 헤더 위에 추가할 제목/부제 행(각 행은 배열). 비우면 없음.
*/ */
function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows): void function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows, array $topRows = []): void
{ {
audit_export_log($filename);
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls'; $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName); $safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
@@ -97,6 +116,15 @@ if (! function_exists('export_excel_2003_xml')) {
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">'; $parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
$parts[] = '<Table>'; $parts[] = '<Table>';
// 상단 제목/부제 행(있을 때만)
foreach ($topRows as $topRow) {
$parts[] = '<Row>';
foreach (array_values((array) $topRow) as $cell) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '<Row>'; $parts[] = '<Row>';
foreach ($headers as $h) { foreach ($headers as $h) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>'; $parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
@@ -137,6 +165,7 @@ if (! function_exists('export_excel_2003_xml_workbook')) {
*/ */
function export_excel_2003_xml_workbook(string $filename, array $sheets): void function export_excel_2003_xml_workbook(string $filename, array $sheets): void
{ {
audit_export_log($filename);
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls'; $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
$esc = static function (mixed $v): string { $esc = static function (mixed $v): string {
@@ -392,6 +421,7 @@ if (! function_exists('export_bag_flow_report_excel')) {
array $metaLines, array $metaLines,
array $reportRows array $reportRows
): void { ): void {
audit_export_log($filename);
$baseName = preg_replace('/\.[^.]+$/u', '', $filename); $baseName = preg_replace('/\.[^.]+$/u', '', $filename);
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow'; $baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow';
$baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow'; $baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow';
@@ -442,6 +472,7 @@ if (! function_exists('export_xlsx')) {
*/ */
function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void
{ {
audit_export_log($filename);
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx'; $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName); $safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);

View File

@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
if (! defined('PII_ENCRYPTED_FIELDS')) { if (! defined('PII_ENCRYPTED_FIELDS')) {
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']); define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
} }
/* =========================================================================
* 개인정보 마스킹(비식별화) 헬퍼 — 표시 계층에서만 사용(원본 데이터는 그대로).
* ======================================================================= */
if (! function_exists('pii_mask_name')) {
/** 이름 마스킹: 홍길동→홍*동, 홍길→홍*, 홍→홍 */
function pii_mask_name(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
$len = mb_strlen($v, 'UTF-8');
if ($len === 1) {
return $v;
}
if ($len === 2) {
return mb_substr($v, 0, 1, 'UTF-8') . '*';
}
return mb_substr($v, 0, 1, 'UTF-8') . str_repeat('*', $len - 2) . mb_substr($v, $len - 1, 1, 'UTF-8');
}
}
if (! function_exists('pii_mask_phone')) {
/** 전화 마스킹: 앞 국번 + 마지막 4자리만 노출, 가운데 마스킹. 010-1234-5678→010-****-5678 */
function pii_mask_phone(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
// 하이픈 형식이면 가운데 그룹만 마스킹
if (preg_match('/^(\d{2,4})-(\d{3,4})-(\d{4})$/', $v, $m) === 1) {
return $m[1] . '-' . str_repeat('*', strlen($m[2])) . '-' . $m[3];
}
// 그 외: 숫자만 추출해 앞3·뒤4 노출
$digits = preg_replace('/\D/', '', $v);
$n = strlen($digits);
if ($n <= 4) {
return str_repeat('*', $n);
}
$head = substr($digits, 0, 3);
$tail = substr($digits, -4);
return $head . str_repeat('*', max(1, $n - 7)) . $tail;
}
}
if (! function_exists('pii_mask_email')) {
/** 이메일 마스킹: 앞부분 첫 글자만 노출. hong@a.com→h***@a.com */
function pii_mask_email(?string $v): string
{
$v = trim((string) $v);
if ($v === '' || strpos($v, '@') === false) {
return $v === '' ? '' : pii_mask_name($v);
}
[$local, $domain] = explode('@', $v, 2);
$llen = mb_strlen($local, 'UTF-8');
$maskedLocal = $llen <= 1 ? '*' : mb_substr($local, 0, 1, 'UTF-8') . str_repeat('*', $llen - 1);
return $maskedLocal . '@' . $domain;
}
}
if (! function_exists('pii_mask_account')) {
/** 계좌/번호 마스킹: 마지막 4자리만 노출 */
function pii_mask_account(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
$len = mb_strlen($v, 'UTF-8');
if ($len <= 4) {
return str_repeat('*', $len);
}
return str_repeat('*', $len - 4) . mb_substr($v, $len - 4, 4, 'UTF-8');
}
}
if (! function_exists('pii_blind_index')) {
/**
* 블라인드 인덱스(정확일치 검색용): HMAC-SHA256(정규화값, 별도 인덱스키).
* 키(pii.blindIndexKey)가 없으면 '' 반환(검색 인덱스 비활성).
*/
function pii_blind_index(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
try {
$key = (string) (config('Pii')->blindIndexKey ?? '');
} catch (Throwable $e) {
$key = '';
}
if ($key === '') {
return '';
}
// 정규화: 공백 제거 + 소문자 (전화는 숫자만)
$norm = preg_replace('/\s+/u', '', $value);
if (preg_match('/^[\d\-()+ ]+$/', $value) === 1) {
$norm = preg_replace('/\D/', '', $value);
} else {
$norm = mb_strtolower($norm, 'UTF-8');
}
return hash_hmac('sha256', $norm, $key);
}
}
/* =========================================================================
* 지정판매소 PII 열람 게이트 — canViewShopPii (단일 판별 지점).
* ======================================================================= */
if (! function_exists('pii_shop_protection_enabled')) {
/** 지정판매소 PII 보호 마스터 플래그 (OFF면 현재 동작=항상 원문) */
function pii_shop_protection_enabled(): bool
{
try {
return (bool) (config('Pii')->designatedShopProtection ?? false);
} catch (Throwable $e) {
return false;
}
}
}
if (! function_exists('pii_ip_in_cidr')) {
/** IP가 CIDR/단일IP 목록(쉼표·공백 구분)에 포함되는지 (IPv4) */
function pii_ip_in_cidr(string $ip, string $list): bool
{
$ip = trim($ip);
if ($ip === '' || $list === '') {
return false;
}
$ipLong = ip2long($ip);
if ($ipLong === false) {
return false;
}
foreach (preg_split('/[\s,]+/', $list, -1, PREG_SPLIT_NO_EMPTY) as $entry) {
if (strpos($entry, '/') !== false) {
[$subnet, $bits] = explode('/', $entry, 2);
$subnetLong = ip2long(trim($subnet));
$bits = (int) $bits;
if ($subnetLong === false || $bits < 0 || $bits > 32) {
continue;
}
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
if (($ipLong & $mask) === ($subnetLong & $mask)) {
return true;
}
} elseif (ip2long(trim($entry)) === $ipLong) {
return true;
}
}
return false;
}
}
if (! function_exists('pii_request_ip_in_lg_range')) {
/** 현재 요청 IP가 해당 지자체 허용 IP 대역에 속하는지 (local_government.lg_allow_ips) */
function pii_request_ip_in_lg_range(int $lgIdx): bool
{
if ($lgIdx <= 0) {
return false;
}
try {
$db = \Config\Database::connect();
if (! $db->fieldExists('lg_allow_ips', 'local_government')) {
return false; // 컬럼 없으면 대역 미설정 → 자동 원문 불가(마스킹+step-up)
}
$row = $db->table('local_government')->select('lg_allow_ips')->where('lg_idx', $lgIdx)->get()->getRowArray();
$list = (string) ($row['lg_allow_ips'] ?? '');
if ($list === '') {
return false;
}
return pii_ip_in_cidr((string) service('request')->getIPAddress(), $list);
} catch (Throwable $e) {
return false;
}
}
}
if (! function_exists('pii_stepup_granted')) {
/** 이 세션에서 step-up 재인증으로 원문 열람이 승인됐는지 (P2에서 세팅) */
function pii_stepup_granted(int $dsLgIdx): bool
{
$g = session('pii_stepup_grant');
if (! is_array($g)) {
return false;
}
$until = (int) ($g['until'] ?? 0);
$lg = (int) ($g['lg_idx'] ?? -1);
return $until > time() && ($lg === 0 || $lg === $dsLgIdx);
}
}
if (! function_exists('can_view_shop_pii')) {
/**
* 지정판매소 개인정보 원문 열람 가능 여부(단일 판별 지점).
* 보호 OFF → 항상 true(현재 동작). 보호 ON → 조건 판별.
*/
function can_view_shop_pii(int $dsLgIdx): bool
{
if (! pii_shop_protection_enabled()) {
return true;
}
$level = (int) session('mb_level');
$adminLg = (int) session('admin_lg_idx');
$twofa = (bool) session('auth_2fa_verified');
// 정상(자동 원문): 지자체관리자 + 본인 지자체 + 2FA + 지자체 IP대역
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN
&& $adminLg === $dsLgIdx && $adminLg > 0
&& $twofa
&& pii_request_ip_in_lg_range($dsLgIdx)) {
return true;
}
// 그 외(Super Admin·본부 포함, IP 밖 등): step-up 재인증으로 승인된 경우만
return pii_stepup_granted($dsLgIdx);
}
}
if (! function_exists('mask_shop_field')) {
/** 필드명 기준 마스킹 라우팅 (원문 표시 불가일 때 사용) */
function mask_shop_field(string $field, ?string $value): string
{
$value = (string) $value;
switch ($field) {
case 'ds_rep_name':
return pii_mask_name($value);
case 'ds_tel':
case 'ds_rep_phone':
return pii_mask_phone($value);
case 'ds_email':
return pii_mask_email($value);
case 'ds_va_account':
case 'ds_va_number':
return pii_mask_account($value);
default:
return $value === '' ? '' : pii_mask_name($value);
}
}
}

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagInventoryModel extends Model class BagInventoryModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_inventory'; protected $table = 'bag_inventory';
protected $primaryKey = 'bi_idx'; protected $primaryKey = 'bi_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagIssueItemCodeModel extends Model class BagIssueItemCodeModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_issue_item_code'; protected $table = 'bag_issue_item_code';
protected $primaryKey = 'bic_idx'; protected $primaryKey = 'bic_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagIssueModel extends Model class BagIssueModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_issue'; protected $table = 'bag_issue';
protected $primaryKey = 'bi2_idx'; protected $primaryKey = 'bi2_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagOrderItemModel extends Model class BagOrderItemModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_order_item'; protected $table = 'bag_order_item';
protected $primaryKey = 'boi_idx'; protected $primaryKey = 'boi_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagOrderModel extends Model class BagOrderModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_order'; protected $table = 'bag_order';
protected $primaryKey = 'bo_idx'; protected $primaryKey = 'bo_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -30,7 +32,7 @@ class BagOrderModel extends Model
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code', 'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date', 'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes', 'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_manager_idx',
'bo_regdate', 'bo_moddate', 'bo_regdate', 'bo_moddate',
]; ];
} }

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagPriceHistoryModel extends Model class BagPriceHistoryModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_price_history'; protected $table = 'bag_price_history';
protected $primaryKey = 'bph_idx'; protected $primaryKey = 'bph_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagPriceModel extends Model class BagPriceModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_price'; protected $table = 'bag_price';
protected $primaryKey = 'bp_idx'; protected $primaryKey = 'bp_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagReceivingModel extends Model class BagReceivingModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_receiving'; protected $table = 'bag_receiving';
protected $primaryKey = 'br_idx'; protected $primaryKey = 'br_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagSaleModel extends Model class BagSaleModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_sale'; protected $table = 'bag_sale';
protected $primaryKey = 'bs_idx'; protected $primaryKey = 'bs_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BlockchainLedgerModel extends Model class BlockchainLedgerModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'blockchain_ledger'; protected $table = 'blockchain_ledger';
protected $primaryKey = 'bl_idx'; protected $primaryKey = 'bl_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -8,6 +8,8 @@ use CodeIgniter\Model;
class CodeDetailModel extends Model class CodeDetailModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'code_detail'; protected $table = 'code_detail';
protected $primaryKey = 'cd_idx'; protected $primaryKey = 'cd_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class CodeKindModel extends Model class CodeKindModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'code_kind'; protected $table = 'code_kind';
protected $primaryKey = 'ck_idx'; protected $primaryKey = 'ck_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class CompanyModel extends Model class CompanyModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'company'; protected $table = 'company';
protected $primaryKey = 'cp_idx'; protected $primaryKey = 'cp_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class DesignatedShopModel extends Model class DesignatedShopModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'designated_shop'; protected $table = 'designated_shop';
protected $primaryKey = 'ds_idx'; protected $primaryKey = 'ds_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -31,6 +33,7 @@ class DesignatedShopModel extends Model
'ds_rep_phone', 'ds_rep_phone',
'ds_email', 'ds_email',
'ds_gugun_code', 'ds_gugun_code',
'ds_dong_code',
'ds_zone_code', 'ds_zone_code',
'ds_branch_no', 'ds_branch_no',
'ds_designated_at', 'ds_designated_at',
@@ -38,6 +41,118 @@ class DesignatedShopModel extends Model
'ds_state_changed_at', 'ds_state_changed_at',
'ds_change_reason', 'ds_change_reason',
'ds_regdate', 'ds_regdate',
// 블라인드 인덱스(정확일치 검색용). 컬럼이 있을 때만 실제로 기록됨.
'ds_tel_bidx',
'ds_rep_name_bidx',
]; ];
// PII 암복호화·블라인드인덱스 콜백 (설계안 P3·P4)
protected $beforeInsert = ['piiBlindIndex', 'piiEncrypt'];
protected $beforeUpdate = ['piiBlindIndex', 'piiEncrypt'];
protected $afterFind = ['piiDecrypt'];
private ?bool $telBidxExists = null;
private ?bool $nameBidxExists = null;
/** 저장 계층 암호화 사용 여부 (Config\Pii.encryptAtRest) */
private function encryptAtRest(): bool
{
try {
return (bool) (config('Pii')->encryptAtRest ?? false);
} catch (\Throwable $e) {
return false;
}
}
/** @return list<string> 암호화·마스킹 대상 PII 필드 */
private function piiFields(): array
{
try {
$f = config('Pii')->designatedShopPiiFields ?? [];
return is_array($f) && $f !== [] ? $f : ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
} catch (\Throwable $e) {
return ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
}
}
/** 저장 전 PII 필드 암호화 (encryptAtRest ON일 때만, 이미 ENC:면 건너뜀) */
protected function piiEncrypt(array $data): array
{
if (! $this->encryptAtRest() || empty($data['data']) || ! is_array($data['data'])) {
return $data;
}
helper('pii_encryption');
foreach ($this->piiFields() as $f) {
if (array_key_exists($f, $data['data']) && $data['data'][$f] !== null && $data['data'][$f] !== '') {
$v = (string) $data['data'][$f];
if (strpos($v, 'ENC:') !== 0) {
$data['data'][$f] = pii_encrypt($v);
}
}
}
return $data;
}
/** 저장 전 블라인드 인덱스(전화·이름) 기록 — 컬럼과 인덱스키가 있을 때만 */
protected function piiBlindIndex(array $data): array
{
if (empty($data['data']) || ! is_array($data['data'])) {
return $data;
}
helper('pii_encryption');
$db = $this->db;
if ($this->telBidxExists === null) {
$this->telBidxExists = $db->fieldExists('ds_tel_bidx', $this->table);
}
if ($this->nameBidxExists === null) {
$this->nameBidxExists = $db->fieldExists('ds_rep_name_bidx', $this->table);
}
// 원본값이 넘어온 경우에만 인덱스 갱신(암호화 콜백이 먼저 돌면 ENC:라 인덱스 불가 → piiBlindIndex를 piiEncrypt보다 먼저 두지 않음에 주의)
if ($this->telBidxExists && array_key_exists('ds_tel', $data['data'])) {
$raw = (string) $data['data']['ds_tel'];
$data['data']['ds_tel_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
}
if ($this->nameBidxExists && array_key_exists('ds_rep_name', $data['data'])) {
$raw = (string) $data['data']['ds_rep_name'];
$data['data']['ds_rep_name_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
}
return $data;
}
/** 조회 후 PII 필드 복호화 (ENC: 값만; 평문은 그대로) */
protected function piiDecrypt(array $data): array
{
if (empty($data['data'])) {
return $data;
}
helper('pii_encryption');
$fields = $this->piiFields();
$decodeOne = static function ($row) use ($fields) {
if (is_object($row)) {
foreach ($fields as $f) {
if (isset($row->$f) && is_string($row->$f) && strpos($row->$f, 'ENC:') === 0) {
$row->$f = pii_decrypt($row->$f);
}
}
} elseif (is_array($row)) {
foreach ($fields as $f) {
if (isset($row[$f]) && is_string($row[$f]) && strpos($row[$f], 'ENC:') === 0) {
$row[$f] = pii_decrypt($row[$f]);
}
}
}
return $row;
};
if (is_array($data['data'])) {
// findAll 등: 결과 배열
foreach ($data['data'] as $i => $row) {
$data['data'][$i] = $decodeOne($row);
}
} else {
// find/first: 단일
$data['data'] = $decodeOne($data['data']);
}
return $data;
}
} }

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
/**
* 사용자 의견 첨부 이미지(캡처/업로드).
*/
class FeedbackFileModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'feedback_file';
protected $primaryKey = 'ff_idx';
protected $returnType = 'object';
protected $useTimestamps = true;
protected $createdField = 'ff_regdate';
protected $updatedField = '';
protected $dateFormat = 'datetime';
protected $allowedFields = ['ff_fb_idx', 'ff_kind', 'ff_path', 'ff_mime'];
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
/**
* 사용자 의견(피드백) 모델.
*/
class FeedbackModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'feedback';
protected $primaryKey = 'fb_idx';
protected $returnType = 'object';
protected $useTimestamps = true;
protected $createdField = 'fb_regdate';
protected $updatedField = 'fb_updated';
protected $dateFormat = 'datetime';
protected $allowedFields = [
'fb_lg_idx', 'fb_mb_idx', 'fb_mb_name', 'fb_mb_level',
'fb_content', 'fb_page_url', 'fb_page_title', 'fb_screenshot',
'fb_user_agent', 'fb_status', 'fb_admin_memo',
];
/** 허용 상태 값 */
public const STATUSES = ['new', 'in_progress', 'answered', 'done', 'hold'];
public static function statusLabel(string $s): string
{
return [
'new' => '접수',
'in_progress' => '처리중',
'answered' => '답변 완료',
'done' => '완료',
'hold' => '보류',
][$s] ?? $s;
}
}

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class FreeRecipientModel extends Model class FreeRecipientModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'free_recipient'; protected $table = 'free_recipient';
protected $primaryKey = 'fr_idx'; protected $primaryKey = 'fr_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class LocalGovernmentModel extends Model class LocalGovernmentModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'local_government'; protected $table = 'local_government';
protected $primaryKey = 'lg_idx'; protected $primaryKey = 'lg_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,12 +6,15 @@ use CodeIgniter\Model;
class ManagerModel extends Model class ManagerModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'manager'; protected $table = 'manager';
protected $primaryKey = 'mg_idx'; protected $primaryKey = 'mg_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'mg_lg_idx', 'mg_name', 'mg_dept_code', 'mg_position_code', 'mg_lg_idx', 'mg_name', 'mg_affiliation', 'mg_company_name',
'mg_dept_code', 'mg_position_code',
'mg_tel', 'mg_phone', 'mg_email', 'mg_state', 'mg_regdate', 'mg_tel', 'mg_phone', 'mg_email', 'mg_state', 'mg_regdate',
]; ];
} }

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MemberApprovalRequestModel extends Model class MemberApprovalRequestModel extends Model
{ {
use \App\Models\Traits\Auditable;
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved'; public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected'; public const STATUS_REJECTED = 'rejected';

View File

@@ -6,6 +6,14 @@ use CodeIgniter\Model;
class MemberModel extends Model class MemberModel extends Model
{ {
use \App\Models\Traits\Auditable;
/** 로그인/세션 갱신만 있는 update는 활동 로그에 남기지 않음(로그인 이력은 별도 기록) */
protected function auditSkipUpdateOnlyFields(): array
{
return ['mb_latestdate', 'mb_login_fail_count', 'mb_locked_until', 'mb_session_token'];
}
protected $table = 'member'; protected $table = 'member';
protected $primaryKey = 'mb_idx'; protected $primaryKey = 'mb_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -28,6 +36,7 @@ class MemberModel extends Model
'mb_leavedate', 'mb_leavedate',
'mb_login_fail_count', 'mb_login_fail_count',
'mb_locked_until', 'mb_locked_until',
'mb_session_token',
]; ];
/** /**

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuModel extends Model class MenuModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'menu'; protected $table = 'menu';
protected $primaryKey = 'mm_idx'; protected $primaryKey = 'mm_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -80,6 +82,60 @@ class MenuModel extends Model
} }
} }
/**
* 순서 + 상위(부모)·깊이 변경을 함께 반영한다.
* 하위 메뉴를 다른 상위 메뉴로 옮기는 드래그를 지원하기 위한 용도.
*
* @param array<int,array{idx:int,pidx:int,dep:int}> $items 화면 표시 순서대로 정렬된 구조 배열
*/
public function setStructure(array $items, int $lgIdx): void
{
$affectedTypes = [];
$i = 0;
foreach ($items as $it) {
$mmIdx = (int) ($it['idx'] ?? 0);
if ($mmIdx <= 0) {
continue;
}
$row = $this->find($mmIdx);
if (! $row || (int) $row->lg_idx !== $lgIdx) {
continue;
}
$this->update($mmIdx, [
'mm_num' => $i,
'mm_pidx' => max(0, (int) ($it['pidx'] ?? 0)),
'mm_dep' => max(0, (int) ($it['dep'] ?? 0)),
]);
$affectedTypes[(int) $row->mt_idx] = true;
$i++;
}
// 상위가 바뀌었을 수 있으므로 종류별로 mm_cnode(자식 수)를 재계산
foreach (array_keys($affectedTypes) as $mtIdx) {
$this->recountChildNodes((int) $mtIdx, $lgIdx);
}
}
/**
* 특정 메뉴 종류·지자체의 각 메뉴에 대해 mm_cnode(직속 자식 수)를 실제 값으로 재계산한다.
*/
private function recountChildNodes(int $mtIdx, int $lgIdx): void
{
$rows = $this->where('mt_idx', $mtIdx)->where('lg_idx', $lgIdx)->findAll();
$childCount = [];
foreach ($rows as $r) {
$pid = (int) $r->mm_pidx;
if ($pid > 0) {
$childCount[$pid] = ($childCount[$pid] ?? 0) + 1;
}
}
foreach ($rows as $r) {
$want = (int) ($childCount[(int) $r->mm_idx] ?? 0);
if ((int) $r->mm_cnode !== $want) {
$this->update((int) $r->mm_idx, ['mm_cnode' => $want]);
}
}
}
/** /**
* 추가 시 같은 레벨에서 mm_num 결정 (동일 지자체·메뉴종류·부모·깊이 기준) * 추가 시 같은 레벨에서 mm_num 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
*/ */
@@ -214,6 +270,13 @@ class MenuModel extends Model
->get() ->get()
->getResultArray(); ->getResultArray();
// 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑
$byDep = [];
foreach ($source as $row) {
$byDep[(int) ($row->mm_dep ?? 0)][] = $row;
}
ksort($byDep);
foreach ($lgRows as $lgRow) { foreach ($lgRows as $lgRow) {
$destLg = (int) ($lgRow['lg_idx'] ?? 0); $destLg = (int) ($lgRow['lg_idx'] ?? 0);
if ($destLg <= 0 || $destLg === $sourceLg) { if ($destLg <= 0 || $destLg === $sourceLg) {
@@ -225,28 +288,41 @@ class MenuModel extends Model
->where('lg_idx', $destLg) ->where('lg_idx', $destLg)
->delete(); ->delete();
// 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑
$idMap = []; $idMap = [];
foreach ($source as $row) { foreach ($byDep as $dep => $levelRows) {
$oldId = (int) ($row->mm_idx ?? 0); $batch = [];
foreach ($levelRows as $row) {
$oldP = (int) ($row->mm_pidx ?? 0); $oldP = (int) ($row->mm_pidx ?? 0);
$newPidx = 0; $newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0;
if ($oldP > 0 && isset($idMap[$oldP])) { $batch[] = [
$newPidx = (int) $idMap[$oldP];
}
$this->insert([
'mt_idx' => $mtIdx, 'mt_idx' => $mtIdx,
'lg_idx' => $destLg, 'lg_idx' => $destLg,
'mm_name' => (string) ($row->mm_name ?? ''), 'mm_name' => (string) ($row->mm_name ?? ''),
'mm_link' => (string) ($row->mm_link ?? ''), 'mm_link' => (string) ($row->mm_link ?? ''),
'mm_pidx' => $newPidx, 'mm_pidx' => $newPidx,
'mm_dep' => (int) ($row->mm_dep ?? 0), 'mm_dep' => (int) $dep,
'mm_num' => (int) ($row->mm_num ?? 0), 'mm_num' => (int) ($row->mm_num ?? 0),
'mm_cnode' => (int) ($row->mm_cnode ?? 0), 'mm_cnode' => (int) ($row->mm_cnode ?? 0),
'mm_level' => (string) ($row->mm_level ?? ''), 'mm_level' => (string) ($row->mm_level ?? ''),
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'), 'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
]); ];
$idMap[$oldId] = (int) $this->getInsertID(); }
if ($batch === []) {
continue;
}
$this->insertBatch($batch);
// 방금 삽입한 이 레벨의 행을 삽입 순서(mm_idx ASC)대로 조회해 원본과 1:1 매핑
$inserted = $this->where('mt_idx', $mtIdx)
->where('lg_idx', $destLg)
->where('mm_dep', (int) $dep)
->orderBy('mm_idx', 'ASC')
->findAll();
foreach ($levelRows as $i => $row) {
if (isset($inserted[$i])) {
$idMap[(int) ($row->mm_idx ?? 0)] = (int) $inserted[$i]->mm_idx;
}
}
} }
$this->db->transComplete(); $this->db->transComplete();
} }

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuTypeModel extends Model class MenuTypeModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'menu_type'; protected $table = 'menu_type';
protected $primaryKey = 'mt_idx'; protected $primaryKey = 'mt_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitHistoryModel extends Model class PackagingUnitHistoryModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'packaging_unit_history'; protected $table = 'packaging_unit_history';
protected $primaryKey = 'puh_idx'; protected $primaryKey = 'puh_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitModel extends Model class PackagingUnitModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'packaging_unit'; protected $table = 'packaging_unit';
protected $primaryKey = 'pu_idx'; protected $primaryKey = 'pu_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
class ReportPresetModel extends Model
{
protected $table = 'report_preset';
protected $primaryKey = 'rp_idx';
protected $returnType = 'object';
protected $useTimestamps = true;
protected $createdField = 'rp_regdate';
protected $updatedField = 'rp_updated';
protected $allowedFields = [
'rp_lg_idx', 'rp_mb_idx', 'rp_name', 'rp_title',
'rp_category', 'rp_mode', 'rp_columns',
];
}

View File

@@ -8,6 +8,8 @@ use CodeIgniter\Model;
class SalesAgencyModel extends Model class SalesAgencyModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'sales_agency'; protected $table = 'sales_agency';
protected $primaryKey = 'sa_idx'; protected $primaryKey = 'sa_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,12 +6,14 @@ use CodeIgniter\Model;
class ShopOrderItemModel extends Model class ShopOrderItemModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'shop_order_item'; protected $table = 'shop_order_item';
protected $primaryKey = 'soi_idx'; protected $primaryKey = 'soi_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price', 'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price',
'soi_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count', 'soi_qty', 'soi_packed_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count',
]; ];
} }

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class ShopOrderModel extends Model class ShopOrderModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'shop_order'; protected $table = 'shop_order';
protected $primaryKey = 'so_idx'; protected $primaryKey = 'so_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Models\Traits;
/**
* 모델 CRUD(생성/수정/삭제) 자동 감사 로그 트레잇.
*
* 모델에 `use \App\Models\Traits\Auditable;` 만 추가하면
* insert/update/delete 시 activity_log 에 "누가·무엇을·언제" 자동 기록한다.
* (조회(select)는 기록하지 않음)
*
* - 변경 전(before)/후(after) 데이터를 JSON 으로 보관
* - 비밀번호·OTP 비밀키·이메일·전화 등 민감 필드는 마스킹
* - 기록 실패는 audit_log() 내부에서 흡수되어 본 로직에 영향 없음
*/
trait Auditable
{
/** 변경/삭제 전 원본 보관 (PK => row) */
protected array $auditBeforeRows = [];
/**
* 모델 이벤트(콜백) 등록.
* BaseModel 이 이미 $afterInsert 등 속성을 선언하므로(속성 충돌 방지),
* 트레잇에서는 속성 대신 initialize()에서 런타임에 콜백을 덧붙인다.
*/
protected function initialize()
{
$this->allowCallbacks = true;
$this->afterInsert[] = 'auditAfterInsert';
$this->beforeUpdate[] = 'auditBeforeUpdate';
$this->afterUpdate[] = 'auditAfterUpdate';
$this->beforeDelete[] = 'auditBeforeDelete';
$this->afterDelete[] = 'auditAfterDelete';
}
/** 마스킹 대상 필드명 */
protected array $auditMaskFields = ['mb_passwd', 'mb_totp_secret', 'mb_session_token', 'mb_email', 'mb_phone'];
/**
* 이 필드들'만' 변경된 update는 활동 로그를 남기지 않는다.
* (예: 로그인/세션 갱신 같은 시스템 내부 변경 — 노이즈 제거)
* 모델에서 이 메서드를 override 해 사용. (프로퍼티로 두면 trait/class 비호환 오류)
*
* @return list<string>
*/
protected function auditSkipUpdateOnlyFields(): array
{
return [];
}
private function auditMask(?array $row): ?array
{
if ($row === null) {
return null;
}
foreach ($this->auditMaskFields as $f) {
if (array_key_exists($f, $row) && (string) $row[$f] !== '') {
$row[$f] = '***';
}
}
return $row;
}
/** find 콜백 재귀를 피하려고 쿼리빌더로 원본 행을 직접 조회 */
private function auditFetchRaw(int|string $id): ?array
{
try {
$row = $this->db->table($this->table)
->where($this->primaryKey, $id)
->get()
->getRowArray();
return $row ?: null;
} catch (\Throwable $e) {
return null;
}
}
public function auditAfterInsert(array $eventData): array
{
helper('audit');
$after = is_array($eventData['data'] ?? null) ? $eventData['data'] : null;
audit_log('create', $this->table, (int) ($eventData['id'] ?? 0), null, $this->auditMask($after));
return $eventData;
}
public function auditBeforeUpdate(array $eventData): array
{
$this->auditBeforeRows = [];
$ids = $eventData['id'] ?? null;
if ($ids !== null) {
foreach ((array) $ids as $one) {
$row = $this->auditFetchRaw($one);
if ($row !== null) {
$this->auditBeforeRows[(string) $one] = $row;
}
}
}
return $eventData;
}
public function auditAfterUpdate(array $eventData): array
{
helper('audit');
$ids = $eventData['id'] ?? null;
$changes = is_array($eventData['data'] ?? null) ? $eventData['data'] : [];
// 지정 필드만 변경된 경우(로그인/세션 갱신 등) → 기록 생략
$skipOnly = $this->auditSkipUpdateOnlyFields();
if ($skipOnly !== [] && $changes !== []) {
$changedKeys = array_values(array_diff(array_keys($changes), [$this->primaryKey]));
if ($changedKeys !== [] && array_diff($changedKeys, $skipOnly) === []) {
$this->auditBeforeRows = [];
return $eventData;
}
}
if ($ids !== null) {
foreach ((array) $ids as $one) {
$before = $this->auditBeforeRows[(string) $one] ?? null;
$after = $before !== null ? array_merge($before, $changes) : $changes;
audit_log('update', $this->table, (int) $one, $this->auditMask($before), $this->auditMask($after));
}
} else {
// 조건(where) 기반 일괄 수정: 대상 PK 미상 → 변경값만 기록
audit_log('update', $this->table, 0, null, $this->auditMask($changes));
}
$this->auditBeforeRows = [];
return $eventData;
}
public function auditBeforeDelete(array $eventData): array
{
$this->auditBeforeRows = [];
$ids = $eventData['id'] ?? null;
if ($ids !== null) {
foreach ((array) $ids as $one) {
$row = $this->auditFetchRaw($one);
if ($row !== null) {
$this->auditBeforeRows[(string) $one] = $row;
}
}
}
return $eventData;
}
public function auditAfterDelete(array $eventData): array
{
helper('audit');
$ids = $eventData['id'] ?? null;
if ($ids !== null) {
foreach ((array) $ids as $one) {
$before = $this->auditBeforeRows[(string) $one] ?? null;
audit_log('delete', $this->table, (int) $one, $this->auditMask($before), null);
}
} else {
audit_log('delete', $this->table, 0, null, null);
}
$this->auditBeforeRows = [];
return $eventData;
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/**
* @var list<object> $list
* @var string $start
* @var string $end
* @var string $action
* @var string $table
* @var string $user
* @var array<string,string> $tableLabels
* @var array<string,string> $actionLabels
*/
$actionBadge = static function (string $a): string {
$map = [
'create' => 'background:#dcfce7;color:#166534;',
'update' => 'background:#dbeafe;color:#1e40af;',
'delete' => 'background:#fee2e2;color:#991b1b;',
'export' => 'background:#cffafe;color:#155e75;',
'print' => 'background:#f3e8ff;color:#6b21a8;',
];
return $map[$a] ?? 'background:#f3f4f6;color:#374151;';
};
?>
<?= view('components/print_header', ['printTitle' => '활동 로그']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<form method="GET" action="<?= base_url('admin/access/activity-logs') ?>" class="flex flex-wrap items-center gap-2 text-sm">
<label class="font-bold text-gray-700">조회기간</label>
<input type="date" name="start" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($start) ?>"/>
<span>~</span>
<input type="date" name="end" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($end) ?>"/>
<select name="action" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="">작업 전체</option>
<?php foreach ($actionLabels as $k => $lbl): ?>
<option value="<?= esc($k, 'attr') ?>" <?= $action === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
<?php endforeach; ?>
</select>
<select name="table" class="border border-gray-300 rounded px-2 py-1 text-sm max-w-[12rem]">
<option value="">대상 전체</option>
<?php foreach ($tableLabels as $k => $lbl): ?>
<option value="<?= esc($k, 'attr') ?>" <?= $table === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="user" placeholder="사용자(아이디/이름)" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($user) ?>"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition border border-transparent">조회</button>
<a href="<?= base_url('admin/access/activity-logs') ?>" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="text-left">일시</th>
<th class="text-left">사용자</th>
<th class="text-center">작업</th>
<th class="text-left">내용</th>
<th class="text-center no-print">상세</th>
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<?php
$act = (string) ($row->al_action ?? '');
$isLogin = ! empty($row->isLogin);
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
$badgeText = $isLogin ? '로그인' : ($actionLabels[$act] ?? $act);
$badgeStyle = $isLogin ? 'background:#ede9fe;color:#5b21b6;' : $actionBadge($act);
$changeList = $row->changeList ?? [];
?>
<tr>
<td class="text-left pl-2 whitespace-nowrap"><?= esc((string) ($row->al_regdate ?? '')) ?></td>
<td class="text-left pl-2"><?= $who !== '' ? esc($who) : '<span class="text-gray-400">시스템</span>' ?></td>
<td class="text-center">
<span style="<?= $badgeStyle ?>display:inline-block;padding:1px 8px;border-radius:9999px;font-size:11px;font-weight:700;white-space:nowrap;">
<?= esc($badgeText) ?>
</span>
</td>
<td class="text-left pl-2">
<span class="font-semibold text-gray-800"><?= esc((string) ($row->summaryText ?? '')) ?></span>
<?php if ($changeList !== []): ?>
<div class="text-xs text-gray-500 mt-0.5">
<?php foreach (array_slice($changeList, 0, 4) as $c): ?>
<span class="inline-block mr-2">
<span class="text-gray-600"><?= esc($c['label']) ?>:</span>
<span class="text-gray-400"><?= esc($c['from']) ?></span>
<span class="text-gray-700"><?= esc($c['to']) ?></span>
</span>
<?php endforeach; ?>
<?php if (count($changeList) > 4): ?><span class="text-gray-400">외 <?= count($changeList) - 4 ?>건</span><?php endif; ?>
</div>
<?php endif; ?>
</td>
<td class="text-center no-print">
<a href="<?= base_url('admin/access/activity-logs/' . (int) $row->al_idx) ?>" class="text-blue-700 hover:underline">보기</a>
</td>
</tr>
<?php endforeach; ?>
<?php if ($list === []): ?>
<tr><td colspan="5" class="text-center text-gray-400 py-6">조회된 로그가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3 no-print"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* @var object $row
* @var array<string,mixed> $before
* @var array<string,mixed> $after
* @var array<string,string> $tableLabels
* @var array<string,string> $actionLabels
*/
$act = (string) ($row->al_action ?? '');
$tbl = (string) ($row->al_table ?? '');
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
// 변경된 키 강조용: before/after 값이 다른 키
$allKeys = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
$fmt = static function ($v): string {
if (is_array($v)) {
return json_encode($v, JSON_UNESCAPED_UNICODE);
}
return (string) ($v ?? '');
};
?>
<section class="p-4">
<div class="flex items-center justify-between mb-3 no-print">
<a href="<?= base_url('admin/access/activity-logs') ?>" class="text-sm text-blue-700 hover:underline">&larr; 목록으로</a>
<button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
</div>
<table class="w-full data-table text-sm mb-5" style="max-width:680px;">
<tbody>
<tr><th class="text-left bg-gray-50 w-32">일시</th><td class="text-left pl-2"><?= esc((string) ($row->al_regdate ?? '')) ?></td></tr>
<tr><th class="text-left bg-gray-50">사용자</th><td class="text-left pl-2"><?= $who !== '' ? esc($who) : '시스템' ?></td></tr>
<tr><th class="text-left bg-gray-50">작업</th><td class="text-left pl-2"><?= esc($actionLabels[$act] ?? $act) ?></td></tr>
<tr><th class="text-left bg-gray-50">대상</th><td class="text-left pl-2"><?= esc($tableLabels[$tbl] ?? $tbl) ?> (<?= esc($tbl) ?>) · 번호 <?= (int) ($row->al_record_id ?? 0) ?></td></tr>
</tbody>
</table>
<h3 class="font-bold text-gray-700 mb-2">변경 내역 (전 → 후)</h3>
<?php if ($allKeys === []): ?>
<p class="text-gray-400 text-sm">상세 데이터가 없습니다.</p>
<?php else: ?>
<div class="border border-gray-300 rounded-lg overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="text-left w-40">항목</th>
<th class="text-left">변경 전</th>
<th class="text-left">변경 후</th>
</tr>
</thead>
<tbody>
<?php foreach ($allKeys as $k): ?>
<?php
$b = array_key_exists($k, $before) ? $fmt($before[$k]) : null;
$a = array_key_exists($k, $after) ? $fmt($after[$k]) : null;
$changed = ($act === 'update') && ($b !== $a);
?>
<tr<?= $changed ? ' style="background:#fffbeb;"' : '' ?>>
<td class="text-left pl-2 font-semibold text-gray-700"><?= esc((string) $k) ?></td>
<td class="text-left pl-2 <?= $changed ? 'text-red-700' : 'text-gray-500' ?>"><?= $b === null ? '<span class="text-gray-300">—</span>' : esc($b) ?></td>
<td class="text-left pl-2 <?= $changed ? 'text-blue-700 font-semibold' : 'text-gray-700' ?>"><?= $a === null ? '<span class="text-gray-300">—</span>' : esc($a) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>

View File

@@ -115,8 +115,9 @@
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구코드</label> <label class="block text-sm font-bold text-gray-700 w-28">구·군(동코드)</label>
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div> <input type="text" id="ds_dong_display" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60 bg-gray-100 text-gray-800 cursor-not-allowed" value="" readonly tabindex="-1" placeholder="주소 검색 시 자동 표시"/>
<span id="ds_dong_hint" class="text-xs text-gray-500">주소를 검색하면 해당 동코드가 표시됩니다.</span>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -165,3 +166,53 @@
'detailFieldName' => 'ds_addr_detail', 'detailFieldName' => 'ds_addr_detail',
]) ?> ]) ?>
<script>
(function () {
var form = document.getElementById('designated-shop-create-form');
if (!form) return;
var display = document.getElementById('ds_dong_display');
var hint = document.getElementById('ds_dong_hint');
var endpoint = new URL('<?= mgmt_url('designated-shops/resolve-address-codes') ?>', window.location.href).pathname;
var csrfName = '<?= csrf_token() ?>';
function setHint(msg, isError) {
if (!hint) return;
hint.textContent = msg;
hint.className = 'text-xs ' + (isError ? 'text-red-600' : 'text-gray-500');
}
// 주소 검색 완료 시 서버에서 동코드 조회 → 표시
form.addEventListener('kakao-address-selected', function () {
if (display) display.value = '';
setHint('동코드 조회 중…', false);
var body = new URLSearchParams();
body.set('addr_search_sido', (form.querySelector('[name="addr_search_sido"]') || {}).value || '');
body.set('addr_search_sigungu', (form.querySelector('[name="addr_search_sigungu"]') || {}).value || '');
body.set('ds_addr', (form.querySelector('[name="ds_addr"]') || {}).value || '');
body.set('ds_addr_jibun', (form.querySelector('[name="ds_addr_jibun"]') || {}).value || '');
body.set('ds_zip', (form.querySelector('[name="ds_zip"]') || {}).value || '');
var csrfEl = form.querySelector('[name="' + csrfName + '"]');
if (csrfEl) body.set(csrfName, csrfEl.value);
fetch(endpoint, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: body
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) {
if (display) display.value = d.dong_code + (d.dong_name ? ' (' + d.dong_name + ')' : '');
setHint('등록 시 판매소번호에 이 동코드가 반영됩니다.', false);
} else {
setHint((d && d.error) ? d.error : '동코드를 조회하지 못했습니다.', true);
}
})
.catch(function () {
setHint('동코드 조회 중 오류가 발생했습니다.', true);
});
});
})();
</script>

View File

@@ -1,5 +1,9 @@
<?php <?php
helper('admin'); helper(['admin', 'pii_encryption']);
$repDisp = static function ($row): string {
$v = (string) ($row->ds_rep_name ?? '');
return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v);
};
$currentPath = current_nav_request_path(); $currentPath = current_nav_request_path();
if ($currentPath === 'bag/designated-shops') { if ($currentPath === 'bag/designated-shops') {
$readOnly = false; $readOnly = false;
@@ -11,27 +15,37 @@ if ($currentPath === 'bag/designated-shops') {
?> ?>
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?> <?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
<style> <style>
/* 목록 → 지정판매소 정보 아래 (가로 2열 없음) */ /* 목록 왼쪽 → 지정판매소 정보 오른쪽 (좌우 2열) */
.ds-split { .ds-split {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.5rem; gap: 0.5rem;
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
align-items: stretch;
} }
.ds-list-panel { .ds-list-panel {
flex: 0 1 auto; flex: 1 1 55%;
width: 100%; min-width: 0;
max-height: 42vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
border: 1px solid #ccc; border: 1px solid #ccc;
background: #fff; background: #fff;
/* 기본은 내용 높이만 차지(리스트를 10행 정도로 제한하고 아래 빈 공간 없이). */
align-self: flex-start;
}
/* 지정판매소 리스트: 헤더 + 약 15행만 보이고 나머지는 스크롤 (행 39px×15 + 헤더 34) */
.ds-list-scroll { max-height: 619px; }
.ds-list-panel thead th {
position: sticky;
top: 0;
background: #f8f9fa;
z-index: 2;
box-shadow: inset 0 -1px 0 #e5e7eb;
} }
.ds-detail-panel { .ds-detail-panel {
flex: 1 1 auto; flex: 1 1 45%;
width: 100%;
min-width: 0; min-width: 0;
min-height: 12rem; min-height: 12rem;
border: 1px solid #ccc; border: 1px solid #ccc;
@@ -39,6 +53,12 @@ if ($currentPath === 'bag/designated-shops') {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* 좁은 화면에서는 다시 위·아래로 쌓아 가독성 확보 */
@media (max-width: 1024px) {
.ds-split { flex-direction: column; }
.ds-list-panel { flex: 0 1 auto; max-height: 42vh; align-self: stretch; }
.ds-detail-panel { flex: 1 1 auto; }
}
.ds-panel-title { .ds-panel-title {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
@@ -175,10 +195,8 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span> <span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a> <a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<?php endif; ?>
<?php if (! $readOnly): ?> <?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a> <a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
<?php endif; ?> <?php endif; ?>
@@ -199,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option> <option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<label class="text-sm text-gray-600">대표자명</label>
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
<label class="text-sm text-gray-600">전화</label>
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
<label class="text-sm text-gray-600">상태</label> <label class="text-sm text-gray-600">상태</label>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm"> <select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option> <option value="">전체</option>
@@ -222,21 +244,17 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<div class="ds-split no-print mx-2 mb-2 mt-2 flex-1 min-h-0"> <div class="ds-split no-print mx-2 mb-2 mt-2 flex-1 min-h-0">
<div class="ds-list-panel"> <div class="ds-list-panel">
<div class="ds-panel-title shrink-0">지정판매소 리스트</div> <div class="ds-panel-title shrink-0">지정판매소 리스트</div>
<div class="overflow-auto flex-1 min-h-0"> <div class="ds-list-scroll overflow-auto flex-1 min-h-0">
<table class="w-full text-[13px]"> <table class="w-full text-[13px]">
<thead> <thead>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200"> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 w-14 text-left">번호</th> <th class="ds-sort-th py-2.5 px-2 w-14 text-left cursor-pointer select-none" data-sort-key="no" data-sort-type="num">번호<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 w-24">구·군</th> <th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="rep" data-sort-type="str">대표자명<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 w-24 text-left">지정일</th> <th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="name" data-sort-type="str">상호명<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 w-24">구역</th> <th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="dong" data-sort-type="str">구·군(동코드)<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 ds-col-tight">대표자명</th> <th class="ds-sort-th py-2.5 px-2 w-24 text-left cursor-pointer select-none" data-sort-key="designated" data-sort-type="str">지정일<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 ds-col-tight">상호명</th> <th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="zone" data-sort-type="str">구역<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 ds-col-zip text-left">우편번호</th> <th class="ds-sort-th py-2.5 px-2 w-16 text-left cursor-pointer select-none" data-sort-key="state" data-sort-type="num">상태<span class="ds-sort-ind"></span></th>
<th class="py-2.5 px-2 text-left">주소</th>
<th class="py-2.5 px-2 w-28">사업자번호</th>
<th class="py-2.5 px-2 w-28">전화</th>
<th class="py-2.5 px-2 w-16 text-left">상태</th>
</tr> </tr>
</thead> </thead>
<tbody id="ds-list-body"> <tbody id="ds-list-body">
@@ -266,18 +284,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
if ($addrCombinedList === '') { if ($addrCombinedList === '') {
$addrCombinedList = $addrMainList; $addrCombinedList = $addrMainList;
} }
// 구·군(동코드) 표시: 동코드가 있으면 "동코드 (동명)", 없으면 구·군명
$dongDisp = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabel);
?> ?>
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60" data-row-index="<?= (int) $i ?>" role="button" tabindex="0"> <tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
data-sort-rep="<?= esc($repDisp($row), 'attr') ?>"
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
data-sort-zone="<?= esc($zone, 'attr') ?>"
data-sort-state="<?= (int) $st ?>">
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc($ggLabel) ?></td> <td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></td>
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc($zone) ?></td>
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td> <td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="py-2.5 px-2 text-left font-mono text-gray-700 ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td> <td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
<td class="py-2.5 px-2 text-gray-600 ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></td> <td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_biz_no ?? '') ?></td> <td class="py-2.5 px-2 text-gray-600" title="<?= esc($zone) ?>"><?= esc($zone) ?></td>
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_tel ?? '') ?></td>
<td class="py-2.5 px-2 text-left"> <td class="py-2.5 px-2 text-left">
<?php if ($st === 1): ?> <?php if ($st === 1): ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span> <span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span>
@@ -296,70 +320,108 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<div class="ds-panel-title shrink-0">지정판매소 정보</div> <div class="ds-panel-title shrink-0">지정판매소 정보</div>
<div class="ds-detail-inner" id="ds-detail-box"> <div class="ds-detail-inner" id="ds-detail-box">
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p> <p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
<div class="flex items-center justify-between gap-2">
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
</div>
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
<div class="flex gap-1">
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
</div>
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
</div>
</div>
<div id="ds-detail-fields" class="hidden"> <div id="ds-detail-fields" class="hidden">
<div class="ds-detail-info-wrap"> <!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
<table class="w-full data-table text-sm" id="ds-detail-info-table" aria-label="지정판매소 상세"> <div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
<thead> <div class="ds-row ds-row-wide">
<tr> <div class="ds-field-label">판매소번호</div>
<th>판매소번호</th> <div class="ds-field-value ds-value-shop-wide" data-ro="ds_shop_no">—</div>
<th class="ds-col-tight-head">상호명</th> </div>
<th>우편번호</th> <div class="ds-row ds-row-4-even">
<th>사업자번호</th> <div class="ds-field-label">상호명</div>
<th>일반전화</th> <div class="ds-field-value" data-ro="ds_name">—</div>
<th class="ds-col-tight-head">대표자명</th> <div class="ds-field-label">우편번호</div>
<th>이메일</th> <div class="ds-field-value" data-ro="ds_zip">—</div>
<th>업태</th> </div>
<th>업종</th> <div class="ds-row ds-row-4-even">
<th>지정일자</th> <div class="ds-field-label">사업자번호</div>
<th>지자체</th> <div class="ds-field-value" data-ro="ds_biz_no">—</div>
<th>도로명주소</th> <div class="ds-field-label">일반전화</div>
<th>지번주소</th> <div class="ds-field-value" data-ro="ds_tel">—</div>
<th>상세주소</th> </div>
<th>개인전화</th> <div class="ds-row ds-row-4-even">
<th>구·군</th> <div class="ds-field-label">대표자명</div>
<th>구역</th> <div class="ds-field-value" data-ro="ds_rep_name">—</div>
<th>가상계좌(은행)</th> <div class="ds-field-label">이메일</div>
<th>계좌번호</th> <div class="ds-field-value" data-ro="ds_email">—</div>
<th>종사업장번호</th> </div>
<th>변경일자</th> <div class="ds-row ds-row-wide">
<th>영업상태</th> <div class="ds-field-label">도로명주소</div>
<th>등록일시</th> <div class="ds-field-value ds-field-value-with-map">
<th>변경사유</th> <span class="ds-addr-text" data-ro="ds_addr">—</span>
<th class="no-print w-14 text-center">지도</th> <button type="button" class="no-print border border-btn-print-border text-gray-700 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 shrink-0" id="ds-ro-map-btn">지도 보기</button>
</tr> </div>
</thead> </div>
<tbody> <div class="ds-row ds-row-wide">
<tr> <div class="ds-field-label">지번주소</div>
<td class="text-left" data-ro="ds_shop_no">—</td> <div class="ds-field-value" data-ro="ds_addr_jibun">—</div>
<td class="text-left ds-col-tight-cell" data-ro="ds_name">—</td> </div>
<td class="text-left" data-ro="ds_zip">—</td> <div class="ds-row ds-row-wide">
<td class="text-left" data-ro="ds_biz_no">—</td> <div class="ds-field-label">상세주소</div>
<td class="text-left" data-ro="ds_tel">—</td> <div class="ds-field-value" data-ro="ds_addr_detail">—</div>
<td class="text-left ds-col-tight-cell" data-ro="ds_rep_name">—</td> </div>
<td class="text-left" data-ro="ds_email">—</td> <div class="ds-row ds-row-wide">
<td class="text-left" data-ro="ds_biz_type">—</td> <div class="ds-field-label">개인전화</div>
<td class="text-left" data-ro="ds_biz_kind">—</td> <div class="ds-field-value" data-ro="ds_rep_phone">—</div>
<td class="text-left" data-ro="ds_designated_at">—</td> </div>
<td class="text-left" data-ro="lg_name">—</td> <div class="ds-row ds-row-4-even">
<td class="text-left min-w-[10rem]"><span data-ro="ds_addr">—</span></td> <div class="ds-field-label">지정일자</div>
<td class="text-left" data-ro="ds_addr_jibun">—</td> <div class="ds-field-value" data-ro="ds_designated_at">—</div>
<td class="text-left" data-ro="ds_addr_detail">—</td> <div class="ds-field-label">업태</div>
<td class="text-left" data-ro="ds_rep_phone">—</td> <div class="ds-field-value" data-ro="ds_biz_type">—</div>
<td class="text-left" data-ro="gugun_name">—</td> </div>
<td class="text-left" data-ro="ds_zone_code">—</td> <div class="ds-row ds-row-4-even">
<td class="text-left" data-ro="ds_va_bank">—</td> <div class="ds-field-label">업종</div>
<td class="text-left" data-ro="ds_va_account">—</td> <div class="ds-field-value" data-ro="ds_biz_kind">—</div>
<td class="text-left" data-ro="ds_branch_no">—</td> <div class="ds-field-label">구·군(동코드)</div>
<td class="text-left" data-ro="ds_state_changed_at">—</td> <div class="ds-field-value" data-ro="dong_display">—</div>
<td class="text-left" data-ro="state_label">—</td> </div>
<td class="text-left" data-ro="ds_regdate">—</td> <div class="ds-row ds-row-4-even">
<td class="text-left min-w-[8rem]" data-ro="ds_change_reason">—</td> <div class="ds-field-label">구역</div>
<td class="text-center no-print"> <div class="ds-field-value" data-ro="ds_zone_code">—</div>
<button type="button" class="border border-btn-print-border text-gray-700 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50" id="ds-ro-map-btn">지도</button> <div class="ds-field-label">종사업장번호</div>
</td> <div class="ds-field-value" data-ro="ds_branch_no">—</div>
</tr> </div>
</tbody> <div class="ds-row ds-row-4-even">
</table> <div class="ds-field-label">가상계좌</div>
<div class="ds-field-value" data-ro="ds_va_bank">—</div>
<div class="ds-field-label">계좌번호</div>
<div class="ds-field-value" data-ro="ds_va_account">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">영업상태</div>
<div class="ds-field-value" data-ro="state_label">—</div>
<div class="ds-field-label">변경일자</div>
<div class="ds-field-value" data-ro="ds_state_changed_at">—</div>
</div>
<div class="ds-row ds-row-wide">
<div class="ds-field-label">변경사유</div>
<div class="ds-field-value" data-ro="ds_change_reason">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">지자체</div>
<div class="ds-field-value" data-ro="lg_name">—</div>
<div class="ds-field-label">등록일시</div>
<div class="ds-field-value" data-ro="ds_regdate">—</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -442,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
fieldsWrap.classList.remove('hidden'); fieldsWrap.classList.remove('hidden');
fillDetailInfoTable(d); fillDetailInfoTable(d);
// 마스킹된 행이면 "원문 보기" 안내 노출
var revealBox = document.getElementById('ds-pii-reveal');
if (revealBox) {
revealBox.classList.toggle('hidden', !d.pii_masked);
var rf = document.getElementById('ds-pii-reveal-form');
if (rf) rf.classList.add('hidden');
}
if (!readOnly && editLink && delForm && delBtn) { if (!readOnly && editLink && delForm && delBtn) {
var id = d.ds_idx; var id = d.ds_idx;
editLink.href = editBase + '/' + id; editLink.href = editBase + '/' + id;
@@ -469,6 +539,44 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
}); });
} }
// ── 컬럼 헤더 클릭 정렬 (DOM 행 재배치 — data-row-index 유지) ──
(function initSort() {
if (!body) return;
var ths = document.querySelectorAll('.ds-sort-th');
var curKey = null, curDir = 'asc';
function apply(key, type, dir) {
var trs = Array.prototype.slice.call(body.querySelectorAll('tr.ds-list-row'));
trs.sort(function (a, b) {
var va = a.getAttribute('data-sort-' + key) || '';
var vb = b.getAttribute('data-sort-' + key) || '';
var r;
if (type === 'num') {
r = (parseFloat(va) || 0) - (parseFloat(vb) || 0);
} else {
r = String(va).localeCompare(String(vb), 'ko');
}
return dir === 'asc' ? r : -r;
});
trs.forEach(function (tr) { body.appendChild(tr); });
}
ths.forEach(function (th) {
th.addEventListener('click', function () {
var key = th.getAttribute('data-sort-key');
var type = th.getAttribute('data-sort-type') || 'str';
if (curKey === key) {
curDir = curDir === 'asc' ? 'desc' : 'asc';
} else {
curKey = key; curDir = 'asc';
}
apply(key, type, curDir);
ths.forEach(function (t) {
var ind = t.querySelector('.ds-sort-ind');
if (ind) ind.textContent = (t === th) ? (curDir === 'asc' ? ' ▲' : ' ▼') : '';
});
});
});
})();
var mapBtnRo = document.getElementById('ds-ro-map-btn'); var mapBtnRo = document.getElementById('ds-ro-map-btn');
if (mapBtnRo) { if (mapBtnRo) {
mapBtnRo.addEventListener('click', function (ev) { mapBtnRo.addEventListener('click', function (ev) {
@@ -495,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
delBtn.disabled = true; delBtn.disabled = true;
delBtn.classList.add('pointer-events-none', 'opacity-40'); delBtn.classList.add('pointer-events-none', 'opacity-40');
} }
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
(function () {
var box = document.getElementById('ds-pii-reveal');
if (!box) return;
var btn = document.getElementById('ds-pii-reveal-btn');
var form = document.getElementById('ds-pii-reveal-form');
var otp = document.getElementById('ds-pii-otp');
var reason = document.getElementById('ds-pii-reason');
var submit = document.getElementById('ds-pii-reveal-submit');
var cancel = document.getElementById('ds-pii-reveal-cancel');
var msg = document.getElementById('ds-pii-reveal-msg');
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
var csrfName = '<?= csrf_token() ?>';
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
submit.addEventListener('click', function () {
msg.textContent = '';
var body = new URLSearchParams();
body.set('totp_code', (otp.value || '').trim());
body.set('reason', (reason.value || '').trim());
var t = document.querySelector('meta[name="' + csrfName + '"]');
body.set(csrfName, '<?= csrf_hash() ?>');
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) { location.reload(); }
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
})
.catch(function () { msg.textContent = '통신 오류'; });
});
})();
})(); })();
</script> </script>
@@ -505,7 +645,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr> <tr>
<th class="text-center">번호</th> <th class="text-center">번호</th>
<th>지자체</th> <th>지자체</th>
<th>구·군</th> <th>구·군(동코드)</th>
<th class="text-center">지정일</th> <th class="text-center">지정일</th>
<th>구역</th> <th>구역</th>
<th>대표자명</th> <th>대표자명</th>
@@ -548,11 +688,15 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr> <tr>
<td class="text-center"><?= esc($shortNoP) ?></td> <td class="text-center"><?= esc($shortNoP) ?></td>
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td> <td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?> <?php
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td> $gCodeP = (string) ($row->ds_gugun_code ?? '');
$ggLabelP = (string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP);
$dongDispP = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabelP);
?>
<td class="text-left"><?= esc($dongDispP) ?></td>
<td class="text-center"><?= esc($daDispP) ?></td> <td class="text-center"><?= esc($daDispP) ?></td>
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td> <td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td> <td class="text-left"><?= esc($repDisp($row)) ?></td>
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td> <td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-left"><?= esc($zipP) ?></td> <td class="text-left"><?= esc($zipP) ?></td>
<td class="text-left"><?= esc($addrCombinedP) ?></td> <td class="text-left"><?= esc($addrCombinedP) ?></td>

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* @var list<object> $list
* @var array<int, list<object>> $filesByFb
* @var string $status
*/
use App\Models\FeedbackModel;
$list = $list ?? [];
$filesByFb = $filesByFb ?? [];
$status = (string) ($status ?? '');
$badge = static function (string $s): string {
$map = [
'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-gray-100 text-gray-500',
'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500',
];
$c = $map[$s] ?? 'bg-gray-100 text-gray-600';
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
};
?>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견 · 전체보기 (<?= count($list) ?>건)</span>
<div class="flex items-center gap-2">
<form method="get" class="flex items-center gap-2 text-sm">
<select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" onchange="this.form.submit()">
<option value="">전체 상태</option>
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= $status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
</form>
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline whitespace-nowrap">목록형으로 보기</a>
</div>
</section>
<?php if (empty($list)): ?>
<div class="border border-gray-300 rounded-lg p-10 text-center text-gray-400 mt-2">접수된 의견이 없습니다.</div>
<?php else: ?>
<!-- 빠른 이동 — 클릭하면 아래 해당 의견으로 스크롤 -->
<div class="border border-gray-300 rounded-lg bg-white p-2 mt-2 sticky top-0 z-10 shadow-sm">
<div class="flex flex-wrap gap-1.5 max-h-24 overflow-auto">
<?php foreach ($list as $row): ?>
<a href="#fb-<?= (int) $row->fb_idx ?>" class="inline-flex items-center gap-1 border border-gray-200 rounded-full px-2 py-0.5 text-[11px] text-gray-600 hover:bg-blue-50 hover:border-blue-200 whitespace-nowrap">
#<?= (int) $row->fb_idx ?> <?= $badge((string) ($row->fb_status ?? 'new')) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<div class="space-y-3 mt-3">
<?php foreach ($list as $row): $fid = (int) $row->fb_idx; $files = $filesByFb[$fid] ?? []; ?>
<section id="fb-<?= $fid ?>" class="border border-gray-300 rounded-lg overflow-hidden bg-white scroll-mt-20">
<div class="px-3 py-2 border-b bg-gray-50 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2 text-sm">
<span class="font-bold text-gray-800">#<?= $fid ?></span>
<?= $badge((string) ($row->fb_status ?? 'new')) ?>
<span class="text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></span>
<span class="text-gray-400 text-[12px]">·</span>
<span class="text-gray-600 text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></span>
</div>
<?php
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
$fbUrlOpen = $fbUrlRaw;
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
}
?>
<div class="text-[12px] text-gray-500">
<?= esc((string) ($row->fb_page_title ?: '-')) ?>
<?php if ($fbUrlRaw !== ''): ?>
<span class="text-gray-400"> · </span><a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
<?php endif; ?>
</div>
</div>
<div class="p-3 grid grid-cols-1 lg:grid-cols-[1fr_20rem] gap-3">
<div>
<p class="whitespace-pre-wrap text-sm text-gray-800"><?= esc((string) $row->fb_content) ?></p>
<?php if (! empty($files)): ?>
<div class="flex flex-col gap-3 mt-3">
<?php foreach ($files as $f): ?>
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener" title="새 창에서 원본 크기로 보기">
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
style="max-width:100%;width:640px;height:auto;border:1px solid #e5e7eb;border-radius:6px;"/>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<form action="<?= site_url('admin/feedback/' . $fid . '/status') ?>" method="post" class="space-y-2 border-t lg:border-t-0 lg:border-l border-gray-200 pt-3 lg:pt-0 lg:pl-3">
<?= csrf_field() ?>
<input type="hidden" name="return_to" value="all"/>
<div class="flex items-center gap-2">
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1 text-sm flex-1">
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
<a href="<?= site_url('admin/feedback/' . $fid) ?>" class="text-[12px] text-gray-500 hover:underline whitespace-nowrap">상세</a>
</div>
<textarea name="fb_admin_memo" rows="3" placeholder="처리 메모" class="w-full border border-gray-300 rounded px-2 py-1.5 text-[12px]" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-[12px] w-full">저장</button>
</form>
</div>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><!-- /.fb-selectable -->

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* @var list<object> $list
* @var string $status
*/
use App\Models\FeedbackModel;
$list = $list ?? [];
$status = (string) ($status ?? '');
$badge = static function (string $s): string {
$map = [
'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-gray-100 text-gray-500',
'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500',
];
$c = $map[$s] ?? 'bg-gray-100 text-gray-600';
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
};
?>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견</span>
<div class="flex items-center gap-2">
<form method="get" class="flex items-center gap-2 text-sm">
<select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" onchange="this.form.submit()">
<option value="">전체 상태</option>
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= $status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
</form>
<a href="<?= site_url('admin/feedback/all') ?>" class="inline-flex items-center rounded-lg bg-[#243a5e] px-3 py-1.5 text-white text-xs font-semibold shadow-sm hover:opacity-90 whitespace-nowrap">전체보기(스크롤)</a>
</div>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-16 text-left">번호</th>
<th class="w-36 text-left">일시</th>
<th class="w-20 text-left">상태</th>
<th class="text-left">화면</th>
<th class="text-left">내용</th>
<th class="w-24 text-left">작성자</th>
<th class="w-16 text-center">캡처</th>
</tr>
</thead>
<tbody>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-6">접수된 의견이 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($list as $row): ?>
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="if(!window.getSelection().toString()){window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>';}">
<td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td>
<td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td>
<td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td>
<td class="text-left text-[12px] text-gray-600"><?= esc((string) ($row->fb_page_title ?: $row->fb_page_url)) ?></td>
<td class="text-left"><?= esc(mb_substr((string) $row->fb_content, 0, 50)) ?><?= mb_strlen((string) $row->fb_content) > 50 ? '…' : '' ?></td>
<td class="text-left text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></td>
<td class="text-center"><?= ! empty($row->fb_screenshot) ? '<i class="fa-regular fa-image text-gray-400"></i>' : '' ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
</div><!-- /.fb-selectable -->

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/** @var object $row */
use App\Models\FeedbackModel;
?>
<style>
/* 의견 상세: 레이아웃 body의 select-none 상속을 무효화하여 본문 텍스트를 복사 가능하게 함 */
.fb-selectable, .fb-selectable * {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
</style>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">의견 상세 #<?= (int) $row->fb_idx ?></span>
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline">목록으로</a>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
<section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
<span>의견 내용</span>
<button type="button" id="fb-content-edit-btn" class="text-xs font-medium text-blue-600 hover:underline">수정</button>
</div>
<div class="p-3 space-y-2 text-sm">
<!-- 보기 모드 -->
<div id="fb-content-view">
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
</div>
<!-- 수정 모드 -->
<form id="fb-content-edit" action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/content') ?>" method="post" class="hidden">
<?= csrf_field() ?>
<textarea name="fb_content" rows="6" maxlength="2000" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"><?= esc((string) $row->fb_content) ?></textarea>
<div class="flex gap-2 mt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">수정 저장</button>
<button type="button" id="fb-content-cancel" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded-sm text-sm hover:bg-gray-300">취소</button>
</div>
</form>
<hr class="my-2"/>
<?php
// 화면 URL — embed 파라미터를 떼고 전체 화면 절대 URL로 변환(클릭 시 해당 화면으로 바로 이동).
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
$fbUrlOpen = $fbUrlRaw;
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
}
?>
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600">
<dt class="font-semibold">화면</dt>
<dd>
<?= esc((string) ($row->fb_page_title ?: '-')) ?><br>
<?php if ($fbUrlRaw !== ''): ?>
<a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
<?php else: ?>
<span class="text-gray-400">-</span>
<?php endif; ?>
</dd>
<dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd>
<dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd>
<dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd>
<dt class="font-semibold">브라우저</dt><dd class="break-all text-gray-400"><?= esc((string) ($row->fb_user_agent ?? '')) ?></dd>
</dl>
</div>
</section>
<section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">처리</div>
<form action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/status') ?>" method="post" class="p-3 space-y-3">
<?= csrf_field() ?>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">상태</label>
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-40">
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">처리 메모</label>
<textarea name="fb_admin_memo" rows="4" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">저장</button>
</form>
</section>
</div>
<?php $files = $files ?? []; ?>
<?php if (! empty($files)): ?>
<section class="border border-gray-300 rounded-lg overflow-hidden mt-3">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">첨부 이미지 (<?= count($files) ?>)</div>
<div class="p-3 flex flex-wrap gap-3">
<?php foreach ($files as $f): ?>
<figure class="m-0">
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener">
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
style="max-width:280px;max-height:220px;border:1px solid #e5e7eb;border-radius:6px;"/>
</a>
<figcaption class="text-[11px] text-gray-400 mt-1"><?= $f->ff_kind === 'capture' ? '화면 캡처' : '사용자 첨부' ?></figcaption>
</figure>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</div><!-- /.fb-selectable -->
<script>
(function () {
var btn = document.getElementById('fb-content-edit-btn');
var view = document.getElementById('fb-content-view');
var edit = document.getElementById('fb-content-edit');
var cancel = document.getElementById('fb-content-cancel');
if (!btn || !view || !edit) return;
var show = function (editing) {
view.classList.toggle('hidden', editing);
edit.classList.toggle('hidden', !editing);
btn.classList.toggle('hidden', editing);
if (editing) { var ta = edit.querySelector('textarea'); if (ta) ta.focus(); }
};
btn.addEventListener('click', function () { show(true); });
if (cancel) cancel.addEventListener('click', function () { show(false); });
})();
</script>

View File

@@ -33,7 +33,7 @@ if ($navItems === []) {
$navItems = [ $navItems = [
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false], ['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [ ['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
$mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'), $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('활동 로그', 'admin/access/activity-logs'), $mk('승인 대기', 'admin/access/approvals'),
]], ]],
['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [ ['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'), $mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
@@ -62,6 +62,9 @@ $navPartial = [
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - GBLS</title> <title><?= esc($title ?? '관리자') ?> - GBLS</title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
@@ -91,6 +94,13 @@ tailwind.config = {
.data-table tbody td { color: #374151; } .data-table tbody td { color: #374151; }
.data-table tbody tr:last-child td { border-bottom: 0; } .data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover td { background-color: #f9fafb; } .data-table tbody tr:hover td { background-color: #f9fafb; }
/* body의 select-none 상속을 무효화해 본문 텍스트를 드래그·복사할 수 있게 하는 opt-in 클래스 */
.fb-selectable, .fb-selectable * {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
@media print { @media print {
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; } .portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
body.gov-portal-shell { background: #fff; display: block; } body.gov-portal-shell { background: #fff; display: block; }
@@ -167,28 +177,56 @@ tailwind.config = {
window.addEventListener('pagehide', closeStuckOverlays); window.addEventListener('pagehide', closeStuckOverlays);
})(); })();
// 글씨 크기 조절(A/A+) — 본문 + 상단 대메뉴 + 좌측 사이드바에 zoom 적용. 사이트/워크스페이스와 배율 공유. // 글씨 크기 조절(A/A+) — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
(function () { (function () {
var FONT_KEY = 'jrj_font_scale'; var MENU_KEY = <?= json_encode($__fmk) ?>;
// 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다. var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main']; var scaleSelectors = ['.sidebar', '.work-main'];
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; } var saveTimer = null;
function applyScale(s) { function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s)); s = Math.min(150, Math.max(70, s));
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {} scale = s;
var z = s / 100; var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; }); scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
var topnav = document.querySelector('.portal-top-nav'); var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem'; if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%'; var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
} }
applyScale(curScale()); applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus'); var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); }); if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); }); if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
})(); })();
</script> </script>
<script>
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
(function () {
function fixLang() {
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
})();
</script>
<?= view('components/feedback_widget') ?>
<script>
/* 인쇄 시 활동 로그 기록(beacon) */
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
</script>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,16 @@
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name')) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name')) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">소속</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_affiliation" type="text" value="<?= esc(old('mg_affiliation')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_company_name" type="text" value="<?= esc(old('mg_company_name')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>

View File

@@ -10,6 +10,16 @@
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name', $item->mg_name)) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name', $item->mg_name)) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">소속</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_affiliation" type="text" value="<?= esc(old('mg_affiliation', $item->mg_affiliation ?? '')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_company_name" type="text" value="<?= esc(old('mg_company_name', $item->mg_company_name ?? '')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>

View File

@@ -27,6 +27,8 @@
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200"> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 w-16 text-left">번호</th> <th class="py-2.5 px-2 w-16 text-left">번호</th>
<th class="py-2.5 px-2">담당자명</th> <th class="py-2.5 px-2">담당자명</th>
<th class="py-2.5 px-2">소속</th>
<th class="py-2.5 px-2">상호</th>
<th class="py-2.5 px-2">카테고리</th> <th class="py-2.5 px-2">카테고리</th>
<th class="py-2.5 px-2">전화</th> <th class="py-2.5 px-2">전화</th>
<th class="py-2.5 px-2">휴대전화</th> <th class="py-2.5 px-2">휴대전화</th>
@@ -40,6 +42,8 @@
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50"> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->mg_idx) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->mg_idx) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->mg_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->mg_name) ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_affiliation ?? '') ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_company_name ?? '') ?></td>
<td class="py-2.5 px-2"> <td class="py-2.5 px-2">
<?php <?php
$cat = (string) ($row->mg_dept_code ?? ''); $cat = (string) ($row->mg_dept_code ?? '');
@@ -67,7 +71,7 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr> <tr><td colspan="10" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

@@ -72,6 +72,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>"> <form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/> <input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
<input type="hidden" name="menu_tree" id="menu-tree-payload" value=""/>
<table class="data-table w-full"> <table class="data-table w-full">
<thead> <thead>
<tr> <tr>
@@ -92,7 +93,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
?> ?>
<tr class="menu-row" data-mm-idx="<?= (int) $row->mm_idx ?>" data-mm-pidx="<?= (int) $row->mm_pidx ?>" data-mm-dep="<?= (int) $row->mm_dep ?>"> <tr class="menu-row" data-mm-idx="<?= (int) $row->mm_idx ?>" data-mm-pidx="<?= (int) $row->mm_pidx ?>" data-mm-dep="<?= (int) $row->mm_dep ?>">
<td class="text-center align-middle"> <td class="text-center align-middle">
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경하세요">↕</span> <span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경 · 하위 메뉴는 다른 상위 메뉴 아래로 이동 가능">↕</span>
</td> </td>
<td class="text-center"> <td class="text-center">
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/> <input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
@@ -144,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
data-link="<?= esc($row->mm_link) ?>" data-link="<?= esc($row->mm_link) ?>"
data-level="<?= esc($row->mm_level) ?>" data-level="<?= esc($row->mm_level) ?>"
data-view="<?= (string) $row->mm_is_view ?>" data-view="<?= (string) $row->mm_is_view ?>"
data-dep="<?= (int) $row->mm_dep ?>"> data-dep="<?= (int) $row->mm_dep ?>"
data-pidx="<?= (int) $row->mm_pidx ?>">
수정 수정
</button> </button>
<?php if ($dep === 0): ?> <?php if ($dep === 0): ?>
@@ -189,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<label class="block text-sm font-medium text-gray-700">링크</label> <label class="block text-sm font-medium text-gray-700">링크</label>
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/> <input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
</div> </div>
<div class="space-y-2 mb-3" id="menu-position-wrap">
<label class="block text-sm font-medium text-gray-700">메뉴 위치</label>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="pos_type" value="top" id="pos-top" checked/>
<span class="text-sm">상위 메뉴로 등록</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="pos_type" value="child" id="pos-child"/>
<span class="text-sm">하위 메뉴로 등록</span>
</label>
</div>
<select id="mm-parent-select" class="border border-gray-300 rounded px-2 py-1 w-full text-sm mt-1 hidden">
<option value="">— 상위 메뉴 선택 —</option>
<?php foreach ($list as $prow): ?>
<?php if ((int) $prow->mm_dep !== 0) { continue; } ?>
<option value="<?= (int) $prow->mm_idx ?>" data-dep="<?= (int) $prow->mm_dep ?>"><?= esc($prow->mm_name) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="space-y-2 mb-3"> <div class="space-y-2 mb-3">
<label class="block text-sm font-medium text-gray-700">노출 대상</label> <label class="block text-sm font-medium text-gray-700">노출 대상</label>
<?php if ($mtCode === 'admin'): ?> <?php if ($mtCode === 'admin'): ?>
@@ -258,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
const editIdInput = document.getElementById('mm_idx_edit'); const editIdInput = document.getElementById('mm_idx_edit');
const mmPidxInput = form.querySelector('[name="mm_pidx"]'); const mmPidxInput = form.querySelector('[name="mm_pidx"]');
const mmDepInput = form.querySelector('[name="mm_dep"]'); const mmDepInput = form.querySelector('[name="mm_dep"]');
const posWrap = document.getElementById('menu-position-wrap');
const posTop = document.getElementById('pos-top');
const posChild = document.getElementById('pos-child');
const parentSelect = document.getElementById('mm-parent-select');
// 상위/하위 위치 선택 → 숨은 mm_pidx / mm_dep 동기화
function applyParentFromSelect() {
const opt = parentSelect.options[parentSelect.selectedIndex];
if (opt && opt.value) {
mmPidxInput.value = opt.value;
mmDepInput.value = String((parseInt(opt.dataset.dep || '0', 10)) + 1);
} else {
mmPidxInput.value = '0';
mmDepInput.value = '1';
}
}
function setPosition(type, parentId) {
if (type === 'child') {
if (posChild) posChild.checked = true;
parentSelect.classList.remove('hidden');
if (parentId != null) parentSelect.value = String(parentId);
applyParentFromSelect();
} else {
if (posTop) posTop.checked = true;
parentSelect.classList.add('hidden');
mmPidxInput.value = '0';
mmDepInput.value = '0';
}
}
if (posTop) posTop.addEventListener('change', function () { if (posTop.checked) setPosition('top'); });
if (posChild) posChild.addEventListener('change', function () { if (posChild.checked) setPosition('child', parentSelect.value); });
if (parentSelect) parentSelect.addEventListener('change', applyParentFromSelect);
// 하위 선택인데 상위 미지정이면 등록 차단
function enableAllParentOptions() {
if (!parentSelect) return;
Array.prototype.forEach.call(parentSelect.options, function (o) { o.disabled = false; });
}
form.addEventListener('submit', function (e) {
if (posChild && posChild.checked && !parentSelect.value) {
e.preventDefault();
alert('하위 메뉴로 지정하려면 상위 메뉴를 선택해 주세요.');
}
});
const levelAll = document.getElementById('mm_level_all'); const levelAll = document.getElementById('mm_level_all');
const levelCbs = document.querySelectorAll('.mm-level-cb'); const levelCbs = document.querySelectorAll('.mm-level-cb');
const isAdminType = '<?= esc($mtCode) ?>' === 'admin'; const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
@@ -297,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
const level = (this.dataset.level || '').toString().trim(); const level = (this.dataset.level || '').toString().trim();
const view = this.dataset.view || 'Y'; const view = this.dataset.view || 'Y';
const dep = parseInt(this.dataset.dep || '0', 10); const dep = parseInt(this.dataset.dep || '0', 10);
const pidx = parseInt(this.dataset.pidx || '0', 10);
form.action = '<?= base_url('admin/menus/update/') ?>' + id; form.action = '<?= base_url('admin/menus/update/') ?>' + id;
form.querySelector('[name="mm_name"]').value = name; form.querySelector('[name="mm_name"]').value = name;
form.querySelector('[name="mm_link"]').value = link; form.querySelector('[name="mm_link"]').value = link;
mmPidxInput.value = '0'; // 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능
mmDepInput.value = String(dep); if (posWrap) posWrap.style.display = '';
// 자기 자신은 상위 후보에서 제외
if (parentSelect) {
Array.prototype.forEach.call(parentSelect.options, function (o) {
o.disabled = (o.value === String(id));
});
}
if (pidx > 0) {
setPosition('child', pidx);
} else {
setPosition('top');
}
if (!isAdminType && levelAll) { if (!isAdminType && levelAll) {
levelAll.checked = (level === ''); levelAll.checked = (level === '');
levelCbs.forEach(function(cb){ levelCbs.forEach(function(cb){
@@ -339,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '하위 메뉴 등록'; formTitle.textContent = '하위 메뉴 등록';
btnSubmit.textContent = '등록'; btnSubmit.textContent = '등록';
btnCancel.style.display = 'inline-block'; btnCancel.style.display = 'inline-block';
// 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시)
if (posWrap) posWrap.style.display = '';
enableAllParentOptions();
setPosition('child', parentId);
// 노출 기본값 재설정 // 노출 기본값 재설정
form.querySelector('[name="mm_is_view"]').checked = true; form.querySelector('[name="mm_is_view"]').checked = true;
if (!isAdminType && levelAll) { if (!isAdminType && levelAll) {
@@ -367,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '메뉴 등록'; formTitle.textContent = '메뉴 등록';
btnSubmit.textContent = '등록'; btnSubmit.textContent = '등록';
btnCancel.style.display = 'none'; btnCancel.style.display = 'none';
// 위치 선택 UI 복원 → 기본 '상위'
if (posWrap) posWrap.style.display = '';
enableAllParentOptions();
setPosition('top');
}); });
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반) // 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
@@ -444,11 +530,43 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
} }
}; };
// ── 드래그 중 화면 상/하단 가장자리 근처에서 자동 스크롤 ──────────────
// 스크롤되는 컨테이너(.main-content-area)가 있으면 그걸, 없으면 window를 스크롤.
let autoScrollRaf = null;
const getScrollContainer = function() {
const main = tbody.closest('.main-content-area');
if (main && main.scrollHeight > main.clientHeight + 2) return main;
return null; // null이면 window 스크롤
};
const autoScrollTick = function() {
if (!draggingActive) { autoScrollRaf = null; return; }
const EDGE = 70; // 가장자리 감지 영역(px)
const STEP_MAX = 20; // 프레임당 최대 스크롤(px)
const vh = window.innerHeight;
let dy = 0;
if (lastClientY < EDGE) {
dy = -Math.ceil((EDGE - lastClientY) / EDGE * STEP_MAX);
} else if (lastClientY > vh - EDGE) {
dy = Math.ceil((lastClientY - (vh - EDGE)) / EDGE * STEP_MAX);
}
if (dy !== 0) {
const sc = getScrollContainer();
if (sc) { sc.scrollTop += dy; } else { window.scrollBy(0, dy); }
// 스크롤로 행 위치가 바뀌었으니 placeholder 위치 재계산
if (!rafId) rafId = window.requestAnimationFrame(updatePlaceholderPosition);
}
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
};
const clearDragState = function() { const clearDragState = function() {
if (rafId) { if (rafId) {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
rafId = null; rafId = null;
} }
if (autoScrollRaf) {
window.cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
document.body.classList.remove('menu-row-dragging'); document.body.classList.remove('menu-row-dragging');
window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('mouseup', onMouseUp);
@@ -463,41 +581,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
draggingActive = false; draggingActive = false;
}; };
// 드래그된 행(하위 메뉴)의 새 상위 메뉴를 결정한다.
// 평면 목록에서 바로 위쪽으로 올라가며 dep이 (자신-1)인 가장 가까운 행을 상위로 삼는다.
const resolveNewParentIdx = function(row, draggedDep) {
var prev = row.previousElementSibling;
while (prev) {
if (prev.classList.contains('menu-row')) {
var pdep = parseInt(prev.dataset.mmDep || '0', 10);
if (pdep === draggedDep - 1) {
return parseInt(prev.dataset.mmIdx || '0', 10);
}
if (pdep < draggedDep - 1) {
return 0; // 유효한 상위가 없음
}
}
prev = prev.previousElementSibling;
}
return 0;
};
// 현재 화면 순서 그대로 구조(순서/상위/깊이) 페이로드를 만든다.
const buildTreePayload = function() {
var arr = [];
tbody.querySelectorAll('tr.menu-row').forEach(function(row){
var idInput = row.querySelector('input[name="mm_idx[]"]');
if (!idInput) return;
arr.push({
idx: parseInt(idInput.value, 10),
pidx: parseInt(row.dataset.mmPidx || '0', 10),
dep: parseInt(row.dataset.mmDep || '0', 10)
});
});
return arr;
};
const payloadInput = document.getElementById('menu-tree-payload');
// 수동 "순서 적용" 버튼 제출 시에도 구조가 반영되도록 payload 채움
moveForm.addEventListener('submit', function(){
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
});
var onMouseUp = function() { var onMouseUp = function() {
if (!draggingActive || !draggingRow || !placeholderRow) { if (!draggingActive || !draggingRow || !placeholderRow) {
clearDragState(); clearDragState();
return; return;
} }
var prevRow = placeholderRow.previousElementSibling;
tbody.insertBefore(draggingRow, placeholderRow); tbody.insertBefore(draggingRow, placeholderRow);
refreshOrderNos(); refreshOrderNos();
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10); var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
if (draggedDep > 0) { if (draggedDep > 0) {
var valid = false; // 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
if (prevRow && prevRow.classList.contains('menu-row')) { var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10); if (newPidx <= 0) {
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10); alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
if (prevRow === draggingRow) {
valid = true;
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
valid = true;
}
}
if (!valid) {
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
restoreOrderByIds(originalOrderIds); restoreOrderByIds(originalOrderIds);
clearDragState(); clearDragState();
return; return;
} }
draggingRow.dataset.mmPidx = String(newPidx);
} }
const approved = window.confirm('변경한 순서를 적용할까요?'); const approved = window.confirm('변경한 순서를 적용할까요?');
if (!approved) { if (!approved) {
// 취소 시 원래 순서·상위로 되돌림
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
}
restoreOrderByIds(originalOrderIds); restoreOrderByIds(originalOrderIds);
} else { } else {
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
moveForm.submit(); moveForm.submit();
} }
clearDragState(); clearDragState();
@@ -511,6 +664,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
e.preventDefault(); e.preventDefault();
originalOrderIds = collectCurrentOrderIds(); originalOrderIds = collectCurrentOrderIds();
draggingRow = row; draggingRow = row;
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
placeholderRow = makePlaceholder(); placeholderRow = makePlaceholder();
draggingActive = true; draggingActive = true;
row.classList.add('dragging'); row.classList.add('dragging');
@@ -518,6 +673,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
tbody.insertBefore(placeholderRow, row.nextSibling); tbody.insertBefore(placeholderRow, row.nextSibling);
lastClientY = e.clientY; lastClientY = e.clientY;
updatePlaceholderPosition(); updatePlaceholderPosition();
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
window.addEventListener('mousemove', onMouseMove, { passive: true }); window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
}); });

View File

@@ -13,6 +13,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/>
<p class="w-full text-xs text-gray-500 pl-32">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
/**
* @var array<string, array{label:string,type:string}> $columnCatalog
* @var list<string> $selected
* @var string $title
* @var string $category
* @var string $mode
* @var string $startDate
* @var string $endDate
* @var list<array<string,mixed>> $rows
* @var array<string,int> $totals
* @var list<object> $presets
* @var int $activePresetIdx
*/
$catLabels = ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'];
$printTitle = $title !== '' ? $title : '맞춤 집계표';
$nf = static fn ($n): string => number_format((int) $n);
?>
<?= view('components/print_header', [
'printTitle' => $printTitle,
'printDate' => date('Y-m-d'),
'printExtraLines' => ['조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 품목구분: ' . ($catLabels[$category] ?? '전체') . ' · (단위: 매 / 원)'],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">맞춤 집계표</span>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
</section>
<!-- 조회/구성 폼 -->
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('reports/custom') ?>" id="custom-report-form" class="space-y-3">
<input type="hidden" name="applied" value="1"/>
<input type="hidden" name="export" id="cr-export" value="0"/>
<div class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
<input type="text" name="title" value="<?= esc($title, 'attr') ?>" maxlength="200" placeholder="예: 2026년 음식물 봉투 판매 집계" class="border border-gray-300 rounded px-2 py-1 w-72"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">종료일</label>
<input type="date" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">품목구분(종류)</label>
<select name="category" class="border border-gray-300 rounded px-2 py-1 w-32">
<?php foreach ($catLabels as $ck => $cl): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= $category === $ck ? 'selected' : '' ?>><?= esc($cl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">집계 방식</label>
<select name="mode" class="border border-gray-300 rounded px-2 py-1 w-40">
<option value="detail" <?= $mode === 'detail' ? 'selected' : '' ?>>상세(건별)</option>
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
</select>
</div>
<button type="submit" onclick="document.getElementById('cr-export').value='0'" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
<button type="submit" onclick="document.getElementById('cr-export').value='1'" class="border border-btn-excel-border text-btn-excel-text px-4 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</button>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">표시할 컬럼 선택</label>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<?php foreach ($columnCatalog as $key => $meta): ?>
<label class="inline-flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" name="cols[]" value="<?= esc($key, 'attr') ?>" <?= in_array($key, $selected, true) ? 'checked' : '' ?>/>
<span><?= esc($meta['label']) ?></span>
</label>
<?php endforeach; ?>
</div>
<p class="text-[11px] text-gray-500 mt-1">집계 방식이 「품목별 집계」이면 일자·판매소 등 건별 컬럼은 빈 값으로 표시됩니다.</p>
</div>
</form>
</section>
<!-- 프리셋 (계정별 저장) -->
<?php if (! empty($presetsEnabled)): ?>
<section class="p-3 bg-gray-50 border-b border-gray-200 no-print">
<div class="flex flex-wrap items-end gap-3 text-sm">
<form method="get" action="<?= mgmt_url('reports/custom') ?>" class="flex items-end gap-2">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">저장된 프리셋 불러오기</label>
<select name="preset" class="border border-gray-300 rounded px-2 py-1 min-w-[14rem]" onchange="this.form.submit()">
<option value="">— 프리셋 선택 —</option>
<?php foreach ($presets as $p): ?>
<option value="<?= (int) $p->rp_idx ?>" <?= $activePresetIdx === (int) $p->rp_idx ? 'selected' : '' ?>><?= esc((string) $p->rp_name) ?></option>
<?php endforeach; ?>
</select>
</div>
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
</form>
<?php if ($activePresetIdx > 0): ?>
<form method="post" action="<?= mgmt_url('reports/custom/preset/delete') ?>" onsubmit="return confirm('이 프리셋을 삭제할까요?');" class="flex items-end">
<?= csrf_field() ?>
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
<button type="submit" class="text-red-600 hover:underline text-xs pb-1">현재 프리셋 삭제</button>
</form>
<?php endif; ?>
<!-- 현재 화면 구성 그대로 저장 (JS로 현재 폼 값 복사) -->
<form method="post" action="<?= mgmt_url('reports/custom/preset/save') ?>" id="preset-save-form" class="flex items-end gap-2">
<?= csrf_field() ?>
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
<input type="hidden" name="rp_title" id="ps-title"/>
<input type="hidden" name="rp_category" id="ps-category"/>
<input type="hidden" name="rp_mode" id="ps-mode"/>
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
<div id="ps-cols-holder"></div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">프리셋 이름</label>
<input type="text" name="rp_name" maxlength="100" placeholder="예: 음식물 담당 월집계" class="border border-gray-300 rounded px-2 py-1 w-56" required/>
</div>
<button type="submit" class="bg-[#243a5e] text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">현재 구성 저장</button>
</form>
</div>
</section>
<?php endif; ?>
<!-- 결과 표 -->
<div class="p-3">
<div class="mb-2 text-center no-print">
<h1 class="text-lg font-bold m-0"><?= esc($printTitle) ?></h1>
<p class="text-xs text-gray-500 mt-0.5">조회기간 <?= esc($startDate) ?> ~ <?= esc($endDate) ?> · 품목구분 <?= esc($catLabels[$category] ?? '전체') ?> · 건수 <?= $nf(count($rows)) ?></p>
</div>
<div class="overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12 text-center">No</th>
<?php foreach ($selected as $key): ?>
<th class="<?= ($columnCatalog[$key]['type'] ?? '') === 'num' ? 'text-right' : 'text-left' ?> px-2"><?= esc($columnCatalog[$key]['label']) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="<?= count($selected) + 1 ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($rows as $i => $row): ?>
<tr>
<td class="text-center text-gray-500"><?= $i + 1 ?></td>
<?php foreach ($selected as $key): ?>
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
<td class="<?= $isNum ? 'text-right' : 'text-left' ?> px-2"><?= $isNum ? $nf($row[$key] ?? 0) : esc((string) ($row[$key] ?? '')) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<tr class="bg-amber-50 font-bold">
<td class="text-center">합계</td>
<?php foreach ($selected as $key): ?>
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
<td class="<?= $isNum ? 'text-right' : '' ?> px-2"><?= $isNum ? $nf($totals[$key] ?? 0) : '' ?></td>
<?php endforeach; ?>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>
(function () {
// "현재 구성 저장" 시, 조회 폼의 현재 값(제목/종류/방식/컬럼)을 프리셋 저장 폼으로 복사
var mainForm = document.getElementById('custom-report-form');
var saveForm = document.getElementById('preset-save-form');
if (!mainForm || !saveForm) return;
saveForm.addEventListener('submit', function () {
document.getElementById('ps-title').value = mainForm.querySelector('[name="title"]').value;
document.getElementById('ps-category').value = mainForm.querySelector('[name="category"]').value;
document.getElementById('ps-mode').value = mainForm.querySelector('[name="mode"]').value;
var holder = document.getElementById('ps-cols-holder');
holder.innerHTML = '';
mainForm.querySelectorAll('input[name="cols[]"]:checked').forEach(function (cb) {
var h = document.createElement('input');
h.type = 'hidden'; h.name = 'cols[]'; h.value = cb.value;
holder.appendChild(h);
});
});
})();
</script>

View File

@@ -111,7 +111,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
<div class="grid grid-cols-1 xl:grid-cols-4 gap-2 p-2"> <div class="grid grid-cols-1 xl:grid-cols-4 gap-2 p-2">
<!-- 입출고 리스트 --> <!-- 입출고 리스트 -->
<section class="border border-gray-300 rounded-lg bg-white xl:col-span-1"> <section class="border border-gray-300 rounded-lg overflow-hidden bg-white xl:col-span-1">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 리스트</div> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 리스트</div>
<div class="overflow-auto max-h-[520px]"> <div class="overflow-auto max-h-[520px]">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
@@ -187,7 +187,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
</form> </form>
<!-- 입출고 일자 (상세) --> <!-- 입출고 일자 (상세) -->
<section class="border border-gray-300 rounded-lg bg-white"> <section class="border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 일자</div> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 일자</div>
<div class="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> <div class="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<?php if ($selectedGroup): ?> <?php if ($selectedGroup): ?>
@@ -216,7 +216,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
</section> </section>
<!-- 입출고 봉투 코드 --> <!-- 입출고 봉투 코드 -->
<section class="border border-gray-300 rounded-lg bg-white"> <section class="border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 봉투 코드</div> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 봉투 코드</div>
<div class="overflow-auto max-h-[280px]"> <div class="overflow-auto max-h-[280px]">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
@@ -265,7 +265,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
<!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) --> <!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) -->
<?php if ($tableExists ?? false): ?> <?php if ($tableExists ?? false): ?>
<section class="border border-gray-300 rounded-lg bg-white no-print"> <section class="border border-gray-300 rounded-lg overflow-hidden bg-white no-print">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">품목 등록</div> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">품목 등록</div>
<form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm"> <form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -18,16 +18,15 @@ declare(strict_types=1);
$printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장'; $printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장';
$printDate = date('Y-m-d'); $printDate = date('Y-m-d');
$printExtraLines = $printSubtitleLines ?? []; $printExtraLines = $printSubtitleLines ?? [];
$catKeys = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste']; // 스티커·공동주택용(apt)·용기(container)는 품목 필터에서 제외
// 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
$catKeys = ['general', 'reuse', 'public_use', 'food', 'waste'];
$catLabels = [ $catLabels = [
'general' => '일반용', 'general' => '일반용 봉투',
'food' => '음식물', 'reuse' => '재사용 봉투',
'sticker' => '스티커', 'public_use' => '공공용 봉투',
'reuse' => '재사용', 'food' => '음식물 봉투',
'apt' => '공동주택용', 'waste' => '폐기물 봉투',
'public_use' => '공공용',
'container' => '용기',
'waste' => '폐기물',
]; ];
$exportParams = [ $exportParams = [
@@ -41,6 +40,9 @@ $exportParams = [
if ($cats !== []) { if ($cats !== []) {
$exportParams['cat'] = $cats; $exportParams['cat'] = $cats;
} }
if (($size ?? '') !== '') {
$exportParams['size'] = $size;
}
$excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams)); $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
?> ?>
<?= view('components/print_header', [ <?= view('components/print_header', [
@@ -115,6 +117,20 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
<?= esc($catLabels[$ck] ?? $ck) ?> <?= esc($catLabels[$ck] ?? $ck) ?>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
<span class="inline-flex items-center gap-1 ml-2 pl-3 border-l border-gray-200">
<span class="text-xs text-gray-600">크기</span>
<select name="size" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="">전체</option>
<?php foreach (($sizeGroups ?? []) as $g): ?>
<optgroup label="<?= esc($g['label'], 'attr') ?>">
<?php foreach (($g['sizes'] ?? []) as $sz): ?>
<?php $val = $g['key'] . '|' . $sz; ?>
<option value="<?= esc($val, 'attr') ?>" <?= ($size ?? '') === $val ? 'selected' : '' ?>><?= esc($sz) ?></option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
</span>
</div> </div>
</fieldset> </fieldset>
<div> <div>
@@ -234,6 +250,80 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
</table> </table>
</div> </div>
<p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p> <p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p>
<?php
$sizeStats = $sizeStats ?? [];
$catStats = $catStats ?? [];
$sizeTotQty = array_sum(array_map(static fn ($r) => (int) $r['qty'], $sizeStats));
$sizeTotAmt = array_sum(array_map(static fn ($r) => (int) $r['amount'], $sizeStats));
$catTotQty = array_sum(array_map(static fn ($r) => (int) $r['qty'], $catStats));
$catTotAmt = array_sum(array_map(static fn ($r) => (int) $r['amount'], $catStats));
?>
<div class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 용량별(리터별) 통계 -->
<div>
<div class="text-sm font-bold text-gray-700 mb-1">용량별(리터별) 통계</div>
<table class="w-full data-table text-sm">
<thead>
<tr class="bg-gray-50">
<th class="text-left px-2 py-1">용량</th>
<th class="text-right px-2 py-1">판매량(매)</th>
<th class="text-right px-2 py-1">판매금액(원)</th>
</tr>
</thead>
<tbody>
<?php if ($sizeStats === []): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터 없음</td></tr>
<?php else: ?>
<?php foreach ($sizeStats as $r): ?>
<tr>
<td class="text-left px-2 py-1"><?= esc($r['label']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['qty']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['amount']) ?></td>
</tr>
<?php endforeach; ?>
<tr class="bg-amber-50 font-bold">
<td class="text-left px-2 py-1">합계</td>
<td class="text-right px-2 py-1"><?= number_format((int) $sizeTotQty) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $sizeTotAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 품목별(봉투/음식물/폐기물) 통계 -->
<div>
<div class="text-sm font-bold text-gray-700 mb-1">품목별(봉투·음식물·폐기물) 통계</div>
<table class="w-full data-table text-sm">
<thead>
<tr class="bg-gray-50">
<th class="text-left px-2 py-1">품목</th>
<th class="text-right px-2 py-1">판매량(매)</th>
<th class="text-right px-2 py-1">판매금액(원)</th>
</tr>
</thead>
<tbody>
<?php if ($catStats === []): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터 없음</td></tr>
<?php else: ?>
<?php foreach ($catStats as $r): ?>
<tr>
<td class="text-left px-2 py-1"><?= esc($r['label']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['qty']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['amount']) ?></td>
</tr>
<?php endforeach; ?>
<tr class="bg-amber-50 font-bold">
<td class="text-left px-2 py-1">합계</td>
<td class="text-right px-2 py-1"><?= number_format((int) $catTotQty) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $catTotAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</section> </section>
<script> <script>

View File

@@ -11,6 +11,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-20">비밀번호 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" required/>
<p class="w-full text-xs text-gray-500 pl-20">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>

View File

@@ -24,6 +24,7 @@ $editLoginLocked = $editLockUntil !== null && $editLockUntil !== '' && strtotime
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호</label> <label class="block text-sm font-bold text-gray-700 w-20">비밀번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" placeholder="변경 시에만 입력"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" placeholder="변경 시에만 입력"/>
<p class="w-full text-xs text-gray-500 pl-20">변경 시 8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>

View File

@@ -15,6 +15,9 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($pageTitle ?? 'GBLS') ?></title> <title><?= esc($pageTitle ?? 'GBLS') ?></title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
@@ -41,7 +44,7 @@ if (window.top !== window.self) { try { window.top.location.href = <?= json_enco
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow"> <header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)"> <a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
<span class="leading-none flex flex-col"> <span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong> <strong class="text-base font-extrabold tracking-wide">GBLS</strong>

View File

@@ -18,4 +18,11 @@
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a> <a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a>
</div> </div>
</form> </form>
<script>
// 뒤로가기로 bfcache(뒤로/앞으로 캐시)에서 복원된 경우, 서버에 재요청하여
// 이미 로그인 상태면 대시보드로 리다이렉트되게 한다.
window.addEventListener('pageshow', function (e) {
if (e.persisted) { window.location.reload(); }
});
</script>
<?= $this->endSection() ?> <?= $this->endSection() ?>

View File

@@ -13,6 +13,7 @@
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label>
<input class="<?= $inputCls ?>" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/> <input class="<?= $inputCls ?>" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/>
<p class="mt-1 text-xs text-gray-500">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label>

View File

@@ -158,9 +158,32 @@ $mapId = 'mainKakaoMap';
var bounds = new kakao.maps.LatLngBounds(); var bounds = new kakao.maps.LatLngBounds();
var placed = 0, pending = SHOPS.length; var placed = 0, pending = SHOPS.length;
// 컨테이너 크기에 맞춰 지도를 다시 그리고 영역을 재설정한다.
function refit() {
if (!mapRef) return;
mapRef.relayout();
if (placed > 0) { mapRef.setBounds(bounds); }
else { mapRef.setCenter(new kakao.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng)); }
}
var refitScheduled = false;
function scheduleRefit() {
if (refitScheduled) return;
refitScheduled = true;
(window.requestAnimationFrame || window.setTimeout)(function () { refitScheduled = false; refit(); });
}
// 탭(iframe) 안에서 컨테이너 크기가 늦게 확정되거나 바뀌면(표시 전환·창 크기 변경 등)
// 지도가 반만 그려지거나 엉뚱한 위치(일본 등)로 보이므로, 크기 변화 때마다 다시 맞춘다.
try {
if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(scheduleRefit).observe(el); }
} catch (e) {}
window.addEventListener('resize', scheduleRefit);
window.addEventListener('pageshow', function () { setTimeout(refit, 50); });
function done() { function done() {
if (placed > 0) map.setBounds(bounds); refit();
setTimeout(function () { map.relayout(); if (placed > 0) map.setBounds(bounds); }, 150); setTimeout(refit, 150);
setTimeout(refit, 600);
} }
if (pending === 0) { done(); return; } if (pending === 0) { done(); return; }

View File

@@ -79,7 +79,7 @@ $prevAvgLabel = ($trendBasis ?? '') === 'month' ? '전년 동월' : '전년 평
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="m-2 border border-gray-300 rounded-lg"> <div class="m-2 border border-gray-300 rounded-lg overflow-hidden">
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">월별 판매 추이 분석 조회 내역</div> <div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">월별 판매 추이 분석 조회 내역</div>
<div class="overflow-auto max-h-[28rem]"> <div class="overflow-auto max-h-[28rem]">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">

View File

@@ -59,7 +59,7 @@ $seasonScope = $seasonMonthsLabel !== ''
</p> </p>
</section> </section>
<div class="m-2 border border-gray-300 rounded-lg"> <div class="m-2 border border-gray-300 rounded-lg overflow-hidden">
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b"> <div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">
계절별 판매 추이 분석 조회 내역 계절별 판매 추이 분석 조회 내역
<?php if ($queried): ?> — <?= esc($seasonScope) ?><?php endif; ?> <?php if ($queried): ?> — <?= esc($seasonScope) ?><?php endif; ?>

View File

@@ -21,7 +21,7 @@ $stateBadge = static function (int $state): string {
: '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>'; : '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>';
}; };
?> ?>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 기본코드 종류 --> <!-- 기본코드 종류 -->
<section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm"> <section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-2 mb-3"> <div class="flex flex-wrap items-center justify-between gap-2 mb-3">
@@ -36,14 +36,14 @@ $stateBadge = static function (int $state): string {
<table class="w-full text-[13px]"> <table class="w-full text-[13px]">
<thead> <thead>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200"> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 w-12 text-left">번호</th> <th class="py-2.5 px-2 w-12 text-left whitespace-nowrap">번호</th>
<th class="py-2.5 px-2 w-20">코드</th> <th class="py-2.5 px-2 w-20 whitespace-nowrap">코드</th>
<th class="py-2.5 px-2">코드명</th> <th class="py-2.5 px-2 whitespace-nowrap">코드명</th>
<th class="py-2.5 px-2 w-20 text-left">세부코드</th> <th class="py-2.5 px-2 w-20 text-left whitespace-nowrap">세부코드</th>
<th class="py-2.5 px-2 w-16 text-left">상태</th> <th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">상태</th>
<th class="py-2.5 px-2 w-32">등록일</th> <th class="py-2.5 px-2 w-32 whitespace-nowrap">등록일</th>
<?php if ($showKindActions): ?> <?php if ($showKindActions): ?>
<th class="py-2.5 px-2 w-28 text-left">작업</th> <th class="py-2.5 px-2 w-28 text-left whitespace-nowrap">작업</th>
<?php endif; ?> <?php endif; ?>
</tr> </tr>
</thead> </thead>
@@ -56,14 +56,14 @@ $stateBadge = static function (int $state): string {
?> ?>
<tr class="border-b border-gray-200 last:border-0 cursor-pointer hover:bg-blue-50/60 <?= $isSelected ? 'bg-blue-50' : '' ?>" <tr class="border-b border-gray-200 last:border-0 cursor-pointer hover:bg-blue-50/60 <?= $isSelected ? 'bg-blue-50' : '' ?>"
onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'"> onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'">
<td class="py-2.5 px-2 text-left text-gray-500"><?= (string) $i ?></td> <td class="py-2.5 px-2 text-left text-gray-500 whitespace-nowrap"><?= (string) $i ?></td>
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->ck_code) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700 whitespace-nowrap"><?= esc($row->ck_code) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->ck_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900 whitespace-nowrap"><?= esc($row->ck_name) ?></td>
<td class="py-2.5 px-2 text-left text-gray-600"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td> <td class="py-2.5 px-2 text-left text-gray-600 whitespace-nowrap"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
<td class="py-2.5 px-2 text-left"><?= $stateBadge((int) ($row->ck_state ?? 0)) ?></td> <td class="py-2.5 px-2 text-left whitespace-nowrap"><?= $stateBadge((int) ($row->ck_state ?? 0)) ?></td>
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->ck_regdate ?? '') ?></td> <td class="py-2.5 px-2 text-gray-500 text-[12px] whitespace-nowrap"><?= esc($row->ck_regdate ?? '') ?></td>
<?php if ($showKindActions): ?> <?php if ($showKindActions): ?>
<td class="py-2.5 px-2 text-left text-xs" onclick="event.stopPropagation()"> <td class="py-2.5 px-2 text-left text-xs whitespace-nowrap" onclick="event.stopPropagation()">
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a> <a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');"> <form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -102,15 +102,15 @@ $stateBadge = static function (int $state): string {
<table class="w-full text-[13px]"> <table class="w-full text-[13px]">
<thead> <thead>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200"> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 w-12 text-left">번호</th> <th class="py-2.5 px-2 w-12 text-left whitespace-nowrap">번호</th>
<th class="py-2.5 px-2 w-20">코드</th> <th class="py-2.5 px-2 w-20 whitespace-nowrap">코드</th>
<th class="py-2.5 px-2">코드명</th> <th class="py-2.5 px-2 whitespace-nowrap">코드명</th>
<th class="py-2.5 px-2 w-16 text-left">범위</th> <th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">범위</th>
<th class="py-2.5 px-2 w-14 text-left">정렬</th> <th class="py-2.5 px-2 w-14 text-left whitespace-nowrap">정렬</th>
<th class="py-2.5 px-2 w-16 text-left">상태</th> <th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">상태</th>
<th class="py-2.5 px-2 w-32">등록일</th> <th class="py-2.5 px-2 w-32 whitespace-nowrap">등록일</th>
<?php if ($canManageDetails): ?> <?php if ($canManageDetails): ?>
<th class="py-2.5 px-2 w-24 text-left">작업</th> <th class="py-2.5 px-2 w-24 text-left whitespace-nowrap">작업</th>
<?php endif; ?> <?php endif; ?>
</tr> </tr>
</thead> </thead>
@@ -122,17 +122,17 @@ $stateBadge = static function (int $state): string {
$scopeLabel = $isPlatform ? '공통' : '지자체'; $scopeLabel = $isPlatform ? '공통' : '지자체';
?> ?>
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50"> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="py-2.5 px-2 text-left text-gray-500"><?= (string) $dNo ?></td> <td class="py-2.5 px-2 text-left text-gray-500 whitespace-nowrap"><?= (string) $dNo ?></td>
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->cd_code) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700 whitespace-nowrap"><?= esc($row->cd_code) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->cd_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900 whitespace-nowrap"><?= esc($row->cd_name) ?></td>
<td class="py-2.5 px-2 text-left"> <td class="py-2.5 px-2 text-left whitespace-nowrap">
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isPlatform ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700' ?>"><?= esc($scopeLabel) ?></span> <span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isPlatform ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700' ?>"><?= esc($scopeLabel) ?></span>
</td> </td>
<td class="py-2.5 px-2 text-left text-gray-600"><?= (int) ($row->cd_sort ?? 0) ?></td> <td class="py-2.5 px-2 text-left text-gray-600 whitespace-nowrap"><?= (int) ($row->cd_sort ?? 0) ?></td>
<td class="py-2.5 px-2 text-left"><?= $stateBadge((int) ($row->cd_state ?? 0)) ?></td> <td class="py-2.5 px-2 text-left whitespace-nowrap"><?= $stateBadge((int) ($row->cd_state ?? 0)) ?></td>
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->cd_regdate ?? '') ?></td> <td class="py-2.5 px-2 text-gray-500 text-[12px] whitespace-nowrap"><?= esc($row->cd_regdate ?? '') ?></td>
<?php if ($canManageDetails): ?> <?php if ($canManageDetails): ?>
<td class="py-2.5 px-2 text-left text-xs"> <td class="py-2.5 px-2 text-left text-xs whitespace-nowrap">
<?php if (! empty($rowCanEdit[$row->cd_idx])): ?> <?php if (! empty($rowCanEdit[$row->cd_idx])): ?>
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline">수정</a> <a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline">수정</a>
<form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="ml-1 inline" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');"> <form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="ml-1 inline" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');">

View File

@@ -114,7 +114,7 @@
<tr class="<?= $rowNormal ? '' : 'opacity-60' ?>"> <tr class="<?= $rowNormal ? '' : 'opacity-60' ?>">
<td class="text-center align-middle"> <td class="text-center align-middle">
<?php if ($rowNormal): ?> <?php if ($rowNormal): ?>
<input type="radio" name="bo_idx" value="<?= (int) $history->bo_idx ?>" class="accent-red-600" <?= (int) $history->bo_idx === $selectedDeleteBoIdx ? 'checked' : '' ?> <?= $deleteRadioFirst ? 'required' : '' ?> /> <input type="radio" name="bo_idx" value="<?= (int) $history->bo_idx ?>" class="accent-red-600 js-delete-pick" <?= (int) $history->bo_idx === $selectedDeleteBoIdx ? 'checked' : '' ?> <?= $deleteRadioFirst ? 'required' : '' ?> />
<?php $deleteRadioFirst = false; ?> <?php $deleteRadioFirst = false; ?>
<?php else: ?> <?php else: ?>
<span class="text-gray-400" title="삭제할 수 없는 상태">—</span> <span class="text-gray-400" title="삭제할 수 없는 상태">—</span>
@@ -135,7 +135,8 @@
</section> </section>
<section class="xl:col-span-7 border border-red-200 bg-red-50 p-4"> <section class="xl:col-span-7 border border-red-200 bg-red-50 p-4">
<p class="text-sm font-bold text-red-800 mb-2">발주 삭제</p> <p class="text-sm font-bold text-red-800 mb-2">발주 삭제</p>
<p class="text-sm text-gray-700 mb-4">목록에서 선택한 발주를 삭제 처리합니다. 계속하시겠습니까?</p> <p class="text-sm text-gray-700 mb-3">아래 <b>삭제 대상 발주 내역</b>을 확인한 뒤 「삭제 실행」을 누르세요.</p>
<div id="delete-detail" class="bg-white border border-red-200 rounded p-3 text-sm mb-4 max-h-[320px] overflow-auto">삭제할 발주를 선택하세요.</div>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
<button type="submit" class="bg-red-600 text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90" <?= $firstNormalBoIdx === null ? 'disabled' : '' ?>>삭제 실행</button> <button type="submit" class="bg-red-600 text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90" <?= $firstNormalBoIdx === null ? 'disabled' : '' ?>>삭제 실행</button>
<a href="<?= base_url('bag/order/change?month=' . rawurlencode($orderReturnMonth !== '' ? $orderReturnMonth : substr($defaultOrderDate, 0, 7))) ?>" class="bg-gray-200 text-gray-800 px-6 py-1.5 rounded-sm text-sm">취소</a> <a href="<?= base_url('bag/order/change?month=' . rawurlencode($orderReturnMonth !== '' ? $orderReturnMonth : substr($defaultOrderDate, 0, 7))) ?>" class="bg-gray-200 text-gray-800 px-6 py-1.5 rounded-sm text-sm">취소</a>
@@ -143,28 +144,64 @@
</section> </section>
</div> </div>
</form> </form>
<script>
(() => {
const detailBox = document.getElementById('delete-detail');
if (!detailBox) return;
const detailBase = new URL(<?= json_encode(base_url('bag/order/detail-json'), JSON_UNESCAPED_SLASHES) ?>, window.location.href).pathname;
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const load = (id) => {
if (!id) { detailBox.innerHTML = '삭제할 발주를 선택하세요.'; return; }
detailBox.innerHTML = '불러오는 중…';
fetch(detailBase + '/' + id, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then((r) => r.json())
.then((d) => {
if (!d || !d.ok) { detailBox.innerHTML = '<span class="text-red-600">' + esc((d && d.error) || '불러오지 못했습니다.') + '</span>'; return; }
const rows = (d.items || []).map((it, i) => `<tr>
<td class="text-center">${i + 1}</td>
<td class="text-left pl-2">${esc(it.bag_name || it.bag_code)}</td>
<td class="text-right pr-2">${nf(it.qty_box)}</td>
<td class="text-right pr-2">${nf(it.qty_sheet)}</td>
<td class="text-right pr-2">${nf(it.amount)}</td></tr>`).join('');
detailBox.innerHTML = `
<dl class="grid grid-cols-[6rem_1fr] gap-y-0.5 mb-2">
<dt class="font-semibold text-gray-600">발주번호</dt><dd class="font-mono">${esc(d.bo_lot_no)} <span class="text-gray-400">(v${d.bo_version})</span></dd>
<dt class="font-semibold text-gray-600">발주일</dt><dd>${esc(d.order_date)}</dd>
<dt class="font-semibold text-gray-600">제작업체</dt><dd>${esc(d.company || '-')}</dd>
<dt class="font-semibold text-gray-600">입고처</dt><dd>${esc(d.agency || '-')}</dd>
</dl>
<table class="w-full data-table text-sm">
<thead><tr><th class="w-8 text-center">번호</th><th class="text-left">품명</th><th class="text-right">박스수량</th><th class="text-right">낱장</th><th class="text-right">금액</th></tr></thead>
<tbody>${rows || '<tr><td colspan="5" class="text-center text-gray-400 py-3">품목이 없습니다.</td></tr>'}</tbody>
</table>`;
})
.catch(() => { detailBox.innerHTML = '<span class="text-red-600">오류가 발생했습니다.</span>'; });
};
document.querySelectorAll('.js-delete-pick').forEach((r) => {
r.addEventListener('change', () => { if (r.checked) load(r.value); });
});
const checked = document.querySelector('.js-delete-pick:checked');
if (checked) load(checked.value);
})();
</script>
<?php else: ?> <?php else: ?>
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form"> <form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php if ($editMode): ?> <?php if ($editMode): ?>
<input type="hidden" name="bo_source_idx" value="<?= esc((string) ($editDefaults['bo_source_idx'] ?? 0)) ?>" /> <input type="hidden" name="bo_source_idx" value="<?= esc((string) ($editDefaults['bo_source_idx'] ?? 0)) ?>" />
<fieldset class="border border-gray-200 rounded px-3 py-2 mb-1 text-sm bg-white"> <input type="hidden" name="bo_change_mode" value="meta" />
<legend class="text-xs font-bold text-gray-600 px-1">변경 구분</legend> <div class="border border-amber-300 rounded px-3 py-2 mb-1 text-sm bg-amber-50 flex flex-wrap items-center gap-x-3 gap-y-1">
<div class="flex flex-wrap gap-x-4 gap-y-1"> <span class="font-bold text-gray-700">원 발주번호(LOT)</span>
<label class="inline-flex items-center gap-1 cursor-pointer"> <span class="font-mono text-gray-900"><?= esc($orderLotNo !== '' ? $orderLotNo : '-') ?></span>
<input type="radio" name="bo_change_mode" value="price" <?= $changeMode === 'price' ? 'checked' : '' ?> /> <span class="text-gray-500">발주 #<?= (int) ($editDefaults['bo_source_idx'] ?? 0) ?></span>
<span>발주·도매·판매 단가</span> <span class="text-gray-500">— 변경 저장 시 원 발주번호는 유지되고, 이전 내용은 이력(버전)으로 보존됩니다.</span>
</label> <a class="text-red-600 hover:underline ml-auto" href="<?= base_url('bag/order/revise/' . (int) ($editDefaults['bo_source_idx'] ?? 0) . '?change_mode=delete') ?>">발주 삭제</a>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="bo_change_mode" value="meta" <?= $changeMode === 'meta' ? 'checked' : '' ?> />
<span>업체·수수료·협회·발주</span>
</label>
</div> </div>
<p class="text-xs text-gray-500 mt-1">
발주 삭제는 <a class="text-blue-600 hover:underline" href="<?= base_url('bag/order/revise/' . (int) ($editDefaults['bo_source_idx'] ?? 0) . '?change_mode=delete') ?>">발주 삭제 화면</a>으로 이동합니다.
</p>
</fieldset>
<?php endif; ?> <?php endif; ?>
<?php if ($hubReturn && $orderReturnMonth !== ''): ?> <?php if ($hubReturn && $orderReturnMonth !== ''): ?>
<input type="hidden" name="order_return_hub" value="1" /> <input type="hidden" name="order_return_hub" value="1" />
@@ -188,48 +225,26 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2"> <div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-start">
<section class="xl:col-span-5 border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<div class="overflow-auto max-h-[410px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-28 text-center">발주일</th>
<th class="text-left">제작업체</th>
<th class="text-left">입고처</th>
<th class="w-16 text-center">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($recentOrders ?? []) as $history): ?>
<tr>
<td class="text-center">
<a href="<?= base_url('bag/order/revise/' . (int) $history->bo_idx) ?>" class="text-blue-600 hover:underline">
<?= esc((string) $history->bo_order_date) ?>
</a>
</td>
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-7 border border-gray-300 rounded-lg overflow-hidden bg-white"> <section class="xl:col-span-7 border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700"><?= $editMode ? '발주 변경 수정' : '발주 Form' ?></div> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700"><?= $editMode ? '발주 변경 수정' : '발주등록' ?></div>
<div class="p-2 space-y-2"> <div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<label for="bo_order_date" class="w-20 font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label> <label for="bo_order_date" class="w-20 font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1 w-full" /> <input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1 w-full" />
</div> </div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_manager_idx" class="w-20 font-bold text-gray-700">발주담당</label>
<select id="bo_manager_idx" name="bo_manager_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($managers ?? []) as $manager): ?>
<option value="<?= esc((string) $manager->mg_idx) ?>" <?= (int) old('bo_manager_idx', $editDefaults['bo_manager_idx'] ?? 0) === (int) $manager->mg_idx ? 'selected' : '' ?>>
<?= esc((string) $manager->mg_name) ?><?= ($manager->mg_affiliation ?? '') !== '' ? ' (' . esc((string) $manager->mg_affiliation) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label> <label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full"> <select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
@@ -272,7 +287,7 @@
<th class="w-12 text-center">번호</th> <th class="w-12 text-center">번호</th>
<th class="w-16 text-center">선택</th> <th class="w-16 text-center">선택</th>
<th class="text-left">품명</th> <th class="text-left">품명</th>
<th class="w-24 text-right">수량</th> <th class="w-24 text-right">박스수량</th>
<th class="w-24 text-right">단가</th> <th class="w-24 text-right">단가</th>
<th class="w-24 text-right">낱장환산</th> <th class="w-24 text-right">낱장환산</th>
<?php if ($editMode): ?> <?php if ($editMode): ?>
@@ -325,15 +340,16 @@
<div class="flex gap-2 pt-1"> <div class="flex gap-2 pt-1">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition"><?= $editMode ? '변경 저장' : '발주' ?></button> <button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition"><?= $editMode ? '변경 저장' : '발주' ?></button>
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a> <?php if ($editMode): ?>
<a href="<?= base_url('bag/order/create') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
<?php endif; ?>
</div> </div>
</div> </div>
</section> </section>
</div>
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white"> <section class="xl:col-span-5 border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주품목 선택</div>
<div class="overflow-auto"> <div class="overflow-auto max-h-[560px]">
<table class="w-full data-table text-sm order-reference-table"> <table class="w-full data-table text-sm order-reference-table">
<thead> <thead>
<tr> <tr>
@@ -367,6 +383,40 @@
</table> </table>
</div> </div>
</section> </section>
</div>
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<div class="overflow-auto max-h-[410px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-28 text-center">발주일</th>
<th class="text-left">제작업체</th>
<th class="text-left">입고처</th>
<th class="w-16 text-center">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($recentOrders ?? []) as $history): ?>
<tr>
<td class="text-center">
<button type="button" class="js-order-view text-blue-600 hover:underline font-medium" data-order-id="<?= (int) $history->bo_idx ?>" title="발주 내역 보기">
<?= esc((string) $history->bo_order_date) ?>
</button>
</td>
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</form> </form>
<?php endif; ?> <?php endif; ?>
@@ -384,6 +434,96 @@
} }
</style> </style>
<!-- 발주 내역 보기 모달 -->
<div id="order-view-modal" class="hidden fixed inset-0 z-50 items-center justify-center" style="background:rgba(0,0,0,.4)">
<div class="bg-white rounded-lg shadow-xl w-[680px] max-w-[95vw] max-h-[85vh] overflow-auto">
<div class="flex items-center justify-between border-b px-4 py-2">
<span class="font-bold text-gray-800">발주 내역</span>
<button type="button" id="order-view-close" class="text-gray-500 hover:text-gray-800 text-lg leading-none">✕</button>
</div>
<div class="p-4 text-sm" id="order-view-body">불러오는 중…</div>
<div class="flex items-center justify-end gap-2 border-t px-4 py-2">
<form id="order-view-delete-form" action="<?= base_url('bag/order/delete') ?>" method="post" class="mr-auto">
<?= csrf_field() ?>
<input type="hidden" name="bo_idx" id="order-view-delete-id" value="" />
<input type="hidden" name="return_to" value="create" />
<button type="submit" id="order-view-delete" class="hidden bg-red-600 text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">삭제</button>
</form>
<a id="order-view-edit" href="#" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">발주변경</a>
<button type="button" id="order-view-close2" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded-sm text-sm hover:bg-gray-300">닫기</button>
</div>
</div>
</div>
<script>
(() => {
const modal = document.getElementById('order-view-modal');
if (!modal) return;
const body = document.getElementById('order-view-body');
const editLink = document.getElementById('order-view-edit');
const deleteBtn = document.getElementById('order-view-delete');
const deleteIdInput = document.getElementById('order-view-delete-id');
const deleteForm = document.getElementById('order-view-delete-form');
const reviseBase = <?= json_encode(base_url('bag/order/revise'), JSON_UNESCAPED_SLASHES) ?>;
const detailBase = new URL(<?= json_encode(base_url('bag/order/detail-json'), JSON_UNESCAPED_SLASHES) ?>, window.location.href).pathname;
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const open = () => { modal.classList.remove('hidden'); modal.style.display = 'flex'; };
const close = () => { modal.classList.add('hidden'); modal.style.display = 'none'; };
document.getElementById('order-view-close')?.addEventListener('click', close);
document.getElementById('order-view-close2')?.addEventListener('click', close);
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
if (deleteForm) {
deleteForm.addEventListener('submit', (e) => {
if (!confirm('이 발주를 삭제하시겠습니까?')) { e.preventDefault(); }
});
}
document.querySelectorAll('.js-order-view').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.orderId;
if (!id) return;
editLink.href = reviseBase + '/' + id;
if (deleteIdInput) deleteIdInput.value = id;
if (deleteBtn) deleteBtn.classList.add('hidden'); // 상태 확인 전까지 숨김
if (editLink) editLink.classList.add('hidden'); // 상태 확인 전까지 숨김
body.innerHTML = '불러오는 중…';
open();
fetch(detailBase + '/' + id, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then((r) => r.json())
.then((d) => {
if (!d || !d.ok) { body.innerHTML = '<p class="text-red-600">' + esc((d && d.error) || '불러오지 못했습니다.') + '</p>'; return; }
// 정상 상태 발주만 삭제·변경 가능 (삭제/취소된 발주는 두 버튼 모두 숨김)
const isNormal = d.status === 'normal';
if (deleteBtn) deleteBtn.classList.toggle('hidden', !isNormal);
if (editLink) editLink.classList.toggle('hidden', !isNormal);
const rows = (d.items || []).map((it, i) => `<tr>
<td class="text-center">${i + 1}</td>
<td class="text-left pl-2">${esc(it.bag_name || it.bag_code)}</td>
<td class="text-right pr-2">${nf(it.qty_box)}</td>
<td class="text-right pr-2">${nf(it.qty_sheet)}</td>
<td class="text-right pr-2">${nf(it.unit_price)}</td>
<td class="text-right pr-2">${nf(it.amount)}</td></tr>`).join('');
body.innerHTML = `
<dl class="grid grid-cols-[7rem_1fr] gap-y-1 mb-3">
<dt class="font-semibold text-gray-600">발주번호(LOT)</dt><dd class="font-mono">${esc(d.bo_lot_no)} <span class="text-gray-400">(v${d.bo_version})</span></dd>
<dt class="font-semibold text-gray-600">발주일</dt><dd>${esc(d.order_date)}</dd>
<dt class="font-semibold text-gray-600">제작업체</dt><dd>${esc(d.company || '-')}</dd>
<dt class="font-semibold text-gray-600">입고처</dt><dd>${esc(d.agency || '-')}</dd>
<dt class="font-semibold text-gray-600">발주담당</dt><dd>${esc(d.manager || '-')}</dd>
<dt class="font-semibold text-gray-600">조달수수료</dt><dd>${nf(d.fee_rate)}%</dd>
</dl>
<table class="w-full data-table text-sm">
<thead><tr><th class="w-10 text-center">번호</th><th class="text-left">품명</th><th class="text-right">박스수량</th><th class="text-right">낱장</th><th class="text-right">단가</th><th class="text-right">금액</th></tr></thead>
<tbody>${rows || '<tr><td colspan="6" class="text-center text-gray-400 py-3">품목이 없습니다.</td></tr>'}</tbody>
</table>`;
})
.catch(() => { body.innerHTML = '<p class="text-red-600">오류가 발생했습니다.</p>'; });
});
});
})();
</script>
<script> <script>
(() => { (() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>; const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
@@ -548,7 +688,7 @@
const renderSelectedRows = () => { const renderSelectedRows = () => {
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code)); const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
if (codes.length === 0) { if (codes.length === 0) {
selectedBody.innerHTML = `<tr><td colspan="${colEmpty}" class="text-center text-gray-400 py-4">아래 "발주 등록 종류"에서 봉투를 선택해 주세요.</td></tr>`; selectedBody.innerHTML = `<tr><td colspan="${colEmpty}" class="text-center text-gray-400 py-4">우측 "발주품목 선택"에서 봉투를 선택해 주세요.</td></tr>`;
setActiveRow(null); setActiveRow(null);
updateTotals(); updateTotals();
updateReferenceSelectionUi(); updateReferenceSelectionUi();
@@ -648,6 +788,16 @@
} }
}); });
// 박스수량 입력 중 Enter → 다음 봉투의 박스수량 입력칸으로 이동(#39)
selectedBody.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || !event.target.classList.contains('item-qty-box')) return;
event.preventDefault();
const inputs = Array.from(selectedBody.querySelectorAll('.item-qty-box'));
const next = inputs[inputs.indexOf(event.target) + 1];
if (next) { next.focus(); next.select(); }
else { event.target.blur(); }
});
referenceRows.forEach((row) => { referenceRows.forEach((row) => {
row.addEventListener('click', (event) => { row.addEventListener('click', (event) => {
const button = event.target.closest('.js-toggle-bag'); const button = event.target.closest('.js-toggle-bag');

View File

@@ -48,7 +48,8 @@ foreach ($subtotals as $subtotal) {
<div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0"> <div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0">
<div class="overflow-auto"> <div class="overflow-auto">
<table class="w-full data-table text-sm"> <!-- 종류~계 사이 간격 축소 — 표 폭을 내용에 맞게 제한 (피드백 #18) -->
<table class="w-full max-w-4xl data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-36 text-center">품 목 구 분</th> <th class="w-36 text-center">품 목 구 분</th>

View File

@@ -0,0 +1,271 @@
<?php
/**
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는 봉투 번호(바코드) 목록.
* 초기 1페이지만 렌더하고, 스크롤 시 AJAX 로 다음 청크를 이어 붙인다(레이지 로딩).
* @var string $bagCode
* @var string $keyword
* @var list<array<string,mixed>> $itemOptions
* @var list<array<string,mixed>> $rows 초기 청크
* @var int $totalBoxes
* @var int $totalPacks
* @var int $totalSheets
* @var int $pageSize
* @var int $loadedCount
*/
$bagCode = (string) ($bagCode ?? '');
$keyword = (string) ($keyword ?? '');
$itemOptions = is_array($itemOptions ?? null) ? $itemOptions : [];
$rows = is_array($rows ?? null) ? $rows : [];
$totalBoxes = (int) ($totalBoxes ?? 0);
$totalPacks = (int) ($totalPacks ?? 0);
$totalSheets = (int) ($totalSheets ?? 0);
$pageSize = (int) ($pageSize ?? 200);
$loadedCount = (int) ($loadedCount ?? count($rows));
$baseUrl = site_url('bag/inventory/stock-barcode-list');
$exportQs = http_build_query(['bag_code' => $bagCode, 'keyword' => $keyword, 'export' => 1]);
$hasMore = $loadedCount < $totalPacks;
?>
<div class="space-y-2">
<section class="border border-gray-300 rounded-lg bg-white p-3 no-print">
<div class="flex items-start justify-between gap-2 mb-2">
<div>
<h2 class="text-base font-bold text-gray-800">실사 재고 바코드 리스트</h2>
<p class="text-xs text-gray-500 mt-0.5">현재 창고에 남아있어야 하는(재고 상태) 봉투 번호를 뽑아 실사 시 실물과 대조하세요.</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button type="button" id="sbl-print" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($baseUrl . '?' . $exportQs) ?>" class="border border-green-500 text-green-700 px-3 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
<form method="get" action="<?= esc($baseUrl) ?>" class="flex flex-wrap items-end gap-2 text-sm">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">품목</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 w-56">
<option value="">— 전체 품목 —</option>
<?php foreach ($itemOptions as $opt): ?>
<?php $oc = (string) ($opt['brpc_bag_code'] ?? ''); ?>
<option value="<?= esc($oc, 'attr') ?>" <?= $oc === $bagCode ? 'selected' : '' ?>>
<?= esc(($opt['bag_name'] ?? '') !== '' ? $opt['bag_name'] : $oc) ?>
(팩 <?= number_format((int) ($opt['pack_cnt'] ?? 0)) ?> · <?= number_format((int) ($opt['sheet_qty'] ?? 0)) ?>매)
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">번호 검색</label>
<input type="text" name="keyword" value="<?= esc($keyword, 'attr') ?>" placeholder="박스/팩/시트번호/LOT" class="border border-gray-300 rounded px-2 py-1 w-56"/>
</div>
<button type="submit" class="text-white px-4 py-1.5 rounded-sm text-sm hover:opacity-90 transition" style="background:#1f2b4d;">조회</button>
<?php if ($bagCode !== '' || $keyword !== ''): ?>
<a href="<?= esc($baseUrl) ?>" class="text-gray-500 hover:underline text-sm pb-1">초기화</a>
<?php endif; ?>
</form>
</section>
<section class="border border-gray-300 rounded-lg bg-white p-3">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm mb-2 print-title">
<span class="font-bold text-gray-800">창고 재고 바코드</span>
<span class="text-gray-600">박스 <b class="text-gray-900"><?= number_format($totalBoxes) ?></b></span>
<span class="text-gray-600">팩 <b class="text-gray-900"><?= number_format($totalPacks) ?></b></span>
<span class="text-gray-600">총 매수 <b class="text-gray-900"><?= number_format($totalSheets) ?></b></span>
<span class="text-gray-400 text-xs ml-auto no-print"><?= esc(date('Y-m-d H:i')) ?> 기준 · 표시 <b id="sbl-shown"><?= number_format($loadedCount) ?></b>/<?= number_format($totalPacks) ?></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse"
id="sbl-table"
data-endpoint="<?= esc($baseUrl, 'attr') ?>"
data-bag-code="<?= esc($bagCode, 'attr') ?>"
data-keyword="<?= esc($keyword, 'attr') ?>"
data-offset="<?= (int) $loadedCount ?>"
data-page-size="<?= (int) $pageSize ?>"
data-total="<?= (int) $totalPacks ?>"
data-has-more="<?= $hasMore ? '1' : '0' ?>">
<thead>
<tr class="bg-gray-100 text-gray-700">
<th class="border border-gray-300 px-2 py-1 text-center w-12">No</th>
<th class="border border-gray-300 px-2 py-1 text-left">품목</th>
<th class="border border-gray-300 px-2 py-1 text-left w-24">LOT</th>
<th class="border border-gray-300 px-2 py-1 text-left">박스코드</th>
<th class="border border-gray-300 px-2 py-1 text-left">팩코드</th>
<th class="border border-gray-300 px-2 py-1 text-left">시작번호</th>
<th class="border border-gray-300 px-2 py-1 text-left">끝번호</th>
<th class="border border-gray-300 px-2 py-1 text-right w-20">매수</th>
</tr>
</thead>
<tbody id="sbl-tbody">
<?php if ($rows === []): ?>
<tr id="sbl-empty"><td colspan="8" class="border border-gray-300 px-2 py-6 text-center text-gray-400">재고로 남아있는 바코드가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($rows as $i => $r): ?>
<tr class="hover:bg-blue-50/40">
<td class="border border-gray-300 px-2 py-1 text-center text-gray-500"><?= $i + 1 ?></td>
<td class="border border-gray-300 px-2 py-1"><?= esc(($r['brpc_bag_name'] ?? '') !== '' ? $r['brpc_bag_name'] : ($r['brpc_bag_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1"><?= esc((string) ($r['brpc_lot_no'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_box_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_pack_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_sheet_start_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_sheet_end_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 text-right"><?= number_format((int) ($r['brpc_sheet_qty'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
<?php if ($rows !== []): ?>
<tfoot>
<tr class="bg-gray-50 font-bold text-gray-800">
<td class="border border-gray-300 px-2 py-1 text-center" colspan="3">합계</td>
<td class="border border-gray-300 px-2 py-1">박스 <?= number_format($totalBoxes) ?></td>
<td class="border border-gray-300 px-2 py-1" colspan="3">팩 <?= number_format($totalPacks) ?></td>
<td class="border border-gray-300 px-2 py-1 text-right"><?= number_format($totalSheets) ?></td>
</tr>
</tfoot>
<?php endif; ?>
</table>
</div>
<!-- 레이지 로딩 감시 지점 + 수동 더보기 폴백 -->
<div id="sbl-sentinel" class="no-print py-3 text-center text-sm text-gray-400" <?= $hasMore ? '' : 'style="display:none;"' ?>>
<span id="sbl-loading" style="display:none;">불러오는 중…</span>
<button type="button" id="sbl-more" class="border border-gray-300 text-gray-600 px-4 py-1.5 rounded-sm hover:bg-gray-50 transition">더 보기</button>
</div>
</section>
</div>
<style>
@media print {
.no-print { display: none !important; }
.print-title { margin-bottom: 6px; }
table { font-size: 11px; }
}
</style>
<script>
(function () {
var table = document.getElementById('sbl-table');
if (!table) return;
var tbody = document.getElementById('sbl-tbody');
var sentinel = document.getElementById('sbl-sentinel');
var loading = document.getElementById('sbl-loading');
var moreBtn = document.getElementById('sbl-more');
var shownEl = document.getElementById('sbl-shown');
var printBtn = document.getElementById('sbl-print');
// baseURL(예: 로컬 :8080)과 실제 접속 origin(:8045 등)이 달라도 항상 현재 origin 기준으로 호출
var endpoint = table.getAttribute('data-endpoint');
try { endpoint = location.origin + new URL(endpoint, location.href).pathname; }
catch (e) { endpoint = location.pathname; }
var bagCode = table.getAttribute('data-bag-code') || '';
var keyword = table.getAttribute('data-keyword') || '';
var pageSize = parseInt(table.getAttribute('data-page-size'), 10) || 200;
var offset = parseInt(table.getAttribute('data-offset'), 10) || 0;
var total = parseInt(table.getAttribute('data-total'), 10) || 0;
var hasMore = table.getAttribute('data-has-more') === '1';
var busy = false;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmt(n) { return (n || 0).toLocaleString(); }
function rowHtml(r, no) {
return '<tr class="hover:bg-blue-50/40">'
+ '<td class="border border-gray-300 px-2 py-1 text-center text-gray-500">' + no + '</td>'
+ '<td class="border border-gray-300 px-2 py-1">' + esc(r.name) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1">' + esc(r.lot) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.box) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.pack) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.start) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.end) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 text-right">' + fmt(r.qty) + '</td>'
+ '</tr>';
}
function buildUrl(off, lim) {
var p = new URLSearchParams();
p.set('ajax', '1');
p.set('offset', off);
p.set('limit', lim);
if (bagCode) p.set('bag_code', bagCode);
if (keyword) p.set('keyword', keyword);
return endpoint + '?' + p.toString();
}
function appendRows(rows) {
if (!rows || !rows.length) return;
var html = '';
for (var i = 0; i < rows.length; i++) {
html += rowHtml(rows[i], offset + i + 1);
}
tbody.insertAdjacentHTML('beforeend', html);
offset += rows.length;
if (shownEl) shownEl.textContent = fmt(offset);
}
// 다음 청크 한 번 로드
function loadMore() {
if (busy || !hasMore) return Promise.resolve();
busy = true;
if (loading) loading.style.display = 'inline';
if (moreBtn) moreBtn.style.display = 'none';
return fetch(buildUrl(offset, pageSize), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
appendRows(data.rows);
hasMore = !!data.hasMore;
if (!hasMore && sentinel) sentinel.style.display = 'none';
})
.catch(function () { /* 네트워크 오류 시 더보기 버튼 유지 */ })
.finally(function () {
busy = false;
if (loading) loading.style.display = 'none';
if (hasMore && moreBtn) moreBtn.style.display = '';
});
}
// 남은 전체를 한 번에 로드(인쇄용)
function loadAll() {
if (!hasMore) return Promise.resolve();
busy = true;
if (loading) loading.style.display = 'inline';
if (moreBtn) moreBtn.style.display = 'none';
return fetch(buildUrl(offset, 5000), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
appendRows(data.rows);
hasMore = !!data.hasMore;
busy = false;
if (hasMore) return loadAll();
if (sentinel) sentinel.style.display = 'none';
if (loading) loading.style.display = 'none';
});
}
// 스크롤 자동 로딩(IntersectionObserver) — 미지원/iframe 대비 더보기 버튼 폴백 병행
if (hasMore && 'IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
if (entries[0] && entries[0].isIntersecting) loadMore();
}, { rootMargin: '300px' });
io.observe(sentinel);
}
if (moreBtn) moreBtn.addEventListener('click', loadMore);
// 인쇄 — 전체를 먼저 불러온 뒤 출력(부분만 인쇄되는 것 방지)
if (printBtn) {
printBtn.addEventListener('click', function () {
if (!hasMore) { window.print(); return; }
printBtn.disabled = true;
var orig = printBtn.textContent;
printBtn.textContent = '전체 불러오는 중…';
loadAll().then(function () {
printBtn.disabled = false;
printBtn.textContent = orig;
window.print();
});
});
}
})();
</script>

View File

@@ -175,12 +175,13 @@ tailwind.config = {
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
})(); })();
// 글씨 크기(zoom) — 상단바에서 조절한 값을 적용. localStorage 공유 + storage 이벤트로 실시간 반영. // 글씨 크기(zoom) — 계정별·메뉴별 저장값(서버 주입)을 이 화면에 적용.
function applyFontScale() { <?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
try { var s = parseInt(localStorage.getItem('jrj_font_scale') || '100', 10); if (!(s >= 70 && s <= 150)) s = 100; document.documentElement.style.zoom = (s / 100); } catch (e) {} (function () {
} var s = <?= (int) $__fsc ?>;
applyFontScale(); if (!(s >= 70 && s <= 150)) s = 100;
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') applyFontScale(); }); try { document.documentElement.style.zoom = (s / 100); } catch (e) {}
})();
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var a = e.target.closest ? e.target.closest('a[href]') : null; var a = e.target.closest ? e.target.closest('a[href]') : null;
@@ -196,8 +197,19 @@ tailwind.config = {
document.addEventListener('submit', function (e) { document.addEventListener('submit', function (e) {
var f = e.target; var f = e.target;
if (!f || f.tagName !== 'FORM') return; if (!f || f.tagName !== 'FORM') return;
var method = (f.getAttribute('method') || 'get').toLowerCase();
if (method === 'get') {
// GET 폼은 제출 시 action의 쿼리스트링이 폼 필드로 대체되므로,
// action 수정 대신 hidden input 으로 embed=1 을 유지한다(중첩 셸 방지).
if (!f.querySelector('input[name="embed"]')) {
var h = document.createElement('input');
h.type = 'hidden'; h.name = 'embed'; h.value = '1';
f.appendChild(h);
}
} else {
var ne = withEmbed(f.getAttribute('action') || location.href); var ne = withEmbed(f.getAttribute('action') || location.href);
if (ne) f.setAttribute('action', ne); if (ne) f.setAttribute('action', ne);
}
}, true); }, true);
// 표 '번호' 컬럼 역순 채번 (사이트 레이아웃과 동일) // 표 '번호' 컬럼 역순 채번 (사이트 레이아웃과 동일)
var run = function () { var run = function () {
@@ -218,5 +230,20 @@ tailwind.config = {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run();
})(); })();
</script> </script>
<script>
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
(function () {
function fixLang() {
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
})();
</script>
<?= view('components/feedback_widget') ?>
<script>
/* 인쇄 시 활동 로그 기록(beacon) */
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
</script>
</body> </body>
</html> </html>

View File

@@ -196,5 +196,9 @@ body { overflow: hidden; }
window.addEventListener('pagehide', closeStuckOverlays); window.addEventListener('pagehide', closeStuckOverlays);
})(); })();
</script> </script>
<script>
/* 인쇄 시 활동 로그 기록(beacon) */
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
</script>
</body> </body>
</html> </html>

View File

@@ -264,26 +264,55 @@ tailwind.config = {
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
// 글씨 크기 조절 — 본문 + 좌측 사이드바에 zoom 적용. // 글씨 크기 조절 — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
// 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다. <?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
var FONT_KEY = 'jrj_font_scale'; var MENU_KEY = <?= json_encode($__fmk) ?>;
var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main']; var scaleSelectors = ['.sidebar', '.work-main'];
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; } var saveTimer = null;
function applyScale(s) { function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s)); s = Math.min(150, Math.max(70, s));
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {} scale = s;
var z = s / 100; var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; }); scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
var topnav = document.querySelector('.portal-top-nav'); var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem'; if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%'; var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
} }
applyScale(curScale()); applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus'); var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); }); if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); }); if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
})(); })();
</script> </script>
<script>
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
(function () {
function fixLang() {
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
})();
</script>
<?= view('components/feedback_widget') ?>
<script>
/* 인쇄 시 활동 로그 기록(beacon) */
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
</script>
</body> </body>
</html> </html>

View File

@@ -191,9 +191,12 @@ if ($effectiveLgIdx) {
var STORE_KEY = 'jrj_ws_tabs'; var STORE_KEY = 'jrj_ws_tabs';
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별 var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
function persist() { var WS_SAVE_URL = '<?= site_url('bag/pref/workspace-tabs') ?>';
try { var WS_CSRF_NAME = '<?= csrf_token() ?>';
sessionStorage.setItem(STORE_KEY, JSON.stringify({ var wsCsrfHash = '<?= csrf_hash() ?>';
var wsSaveTimer = null;
function wsStateObj() {
return {
owner: WS_OWNER, owner: WS_OWNER,
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }), tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
layout: layout, layout: layout,
@@ -201,8 +204,20 @@ if ($effectiveLgIdx) {
vRatio: vRatio, vRatio: vRatio,
hRatio: hRatio, hRatio: hRatio,
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; }) slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
})); };
} catch (e) {} }
function persist() {
var obj = wsStateObj();
var json = JSON.stringify(obj);
try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {}
// 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스)
clearTimeout(wsSaveTimer);
wsSaveTimer = setTimeout(function () {
var b = new URLSearchParams();
b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json);
fetch(WS_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) wsCsrfHash = d.csrf; }).catch(function () {});
}, 500);
} }
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성 // 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
@@ -211,10 +226,11 @@ if ($effectiveLgIdx) {
(function (idx) { (function (idx) {
var h = document.createElement('div'); var h = document.createElement('div');
h.className = 'ws-slot-head'; h.className = 'ws-slot-head';
var nm = document.createElement('span'); nm.className = 'sh-name'; // 칸 제목(sh-name)은 표시하지 않음 — 칸 안 화면에 이미 제목이 있어 중복.
var sp = document.createElement('span'); sp.className = 'sh-spacer'; sp.style.flex = '1';
var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침'; var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침';
var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기'; var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기';
h.appendChild(nm); h.appendChild(rl); h.appendChild(cl); h.appendChild(sp); h.appendChild(rl); h.appendChild(cl);
h.addEventListener('click', function (e) { h.addEventListener('click', function (e) {
if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; } if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; }
if (e.target === cl) { slots[idx] = null; render(); return; } if (e.target === cl) { slots[idx] = null; render(); return; }
@@ -287,8 +303,6 @@ if ($effectiveLgIdx) {
head.style.display = 'flex'; head.style.display = 'flex';
head.style.left = r.l; head.style.top = r.t; head.style.width = r.w; head.style.left = r.l; head.style.top = r.t; head.style.width = r.w;
head.classList.toggle('focused', i === focused); head.classList.toggle('focused', i === focused);
var sid = slots[i];
head.querySelector('.sh-name').textContent = (sid && tabs[sid]) ? tabs[sid].title : '비어 있음';
} else { } else {
head.style.display = 'none'; head.style.display = 'none';
} }
@@ -332,7 +346,7 @@ if ($effectiveLgIdx) {
persist(); persist();
} }
function focusSlot(i) { focused = i; render(); } function focusSlot(i) { focused = i; render(); try { updateFontPct(); } catch (e) {} }
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈) // 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
function placeInFocused(id) { function placeInFocused(id) {
@@ -344,6 +358,7 @@ if ($effectiveLgIdx) {
else { slots[focused] = id; } else { slots[focused] = id; }
} }
render(); render();
try { updateFontPct(); } catch (e) {}
} }
function setLayout(mode) { function setLayout(mode) {
@@ -424,6 +439,7 @@ if ($effectiveLgIdx) {
d.addEventListener('keydown', handleShortcut); d.addEventListener('keydown', handleShortcut);
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true); d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
} catch (e) {} } catch (e) {}
try { readTabScale(id); } catch (e) {}
}); });
panels.appendChild(frame); panels.appendChild(frame);
@@ -480,29 +496,62 @@ if ($effectiveLgIdx) {
} }
document.addEventListener('keydown', handleShortcut); document.addEventListener('keydown', handleShortcut);
// 글씨 크기 조절 — 각 탭(iframe) 내용에 zoom 적용. localStorage 공유 + storage 이벤트로 새 탭/실시간 반영. // 글씨 크기 조절 — 포커스된 탭(메뉴) 내용에만 적용하고, 계정·메뉴별로 서버에 저장한다.
var FONT_KEY = 'jrj_font_scale'; // (예전엔 모든 탭·셸에 일괄 적용돼 "모든 메뉴가 함께 바뀌는" 문제가 있었음)
function curFontScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; } var FONT_MIN = 70, FONT_MAX = 150;
function setFontScale(s) { var FONT_SAVE_URL = '<?= site_url('bag/pref/font-scale') ?>';
s = Math.min(150, Math.max(70, s)); var FONT_CSRF_NAME = '<?= csrf_token() ?>';
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {} var fontCsrfHash = '<?= csrf_hash() ?>';
var z = s / 100; function menuKeyOf(id) {
var u = (tabs[id] && tabs[id].url) ? tabs[id].url : id;
try { return new URL(u, location.href).pathname.replace(/^\/+/, '').replace(/^index\.php\//, ''); } catch (e) { return String(u); }
}
function updateFontPct() {
var fid = slots[focused];
var s = (fid && tabs[fid] && tabs[fid].scale) ? tabs[fid].scale : 100;
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%'; var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
// 탭(iframe) 내용 }
Object.keys(tabs).forEach(function (k) { try { tabs[k].frame.contentDocument.documentElement.style.zoom = z; } catch (e) {} }); // 새 탭 로드 시: embed 레이아웃이 적용한 저장 배율을 읽어 탭 상태로 보관.
// 좌측 사이드바 + 탭 바 텍스트도 함께 확대. function readTabScale(id) {
['.sidebar', '#wsTabBar'].forEach(function (sel) { var s = 100;
var el = document.querySelector(sel); if (el) el.style.zoom = z; try { var z = parseFloat(tabs[id].frame.contentDocument.documentElement.style.zoom || '1'); if (z > 0) s = Math.round(z * 100); } catch (e) {}
}); if (!(s >= FONT_MIN && s <= FONT_MAX)) s = 100;
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림). if (tabs[id]) tabs[id].scale = s;
var topnav = document.querySelector('.portal-top-nav'); if (slots[focused] === id) updateFontPct();
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem'; }
var fontSaveTimer = null, fontSavePending = null;
function flushFontSave() {
if (!fontSavePending) return;
var p = fontSavePending; fontSavePending = null;
var b = new URLSearchParams();
b.set(FONT_CSRF_NAME, fontCsrfHash); b.set('menu_key', p.key); b.set('scale', String(p.scale));
fetch(FONT_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) fontCsrfHash = d.csrf; }).catch(function () {});
}
function applyTabFont(id, s, save) {
if (!tabs[id]) return;
s = Math.min(FONT_MAX, Math.max(FONT_MIN, s));
tabs[id].scale = s;
try { tabs[id].frame.contentDocument.documentElement.style.zoom = (s / 100); } catch (e) {}
if (slots[focused] === id) updateFontPct();
if (save) {
var key = menuKeyOf(id);
try { localStorage.setItem('jrj_font_scale::' + key, String(s)); } catch (e) {}
// 연속 클릭 시 CSRF 토큰 회전 경합 방지 → 디바운스로 마지막 값 1회만 저장
fontSavePending = { key: key, scale: s };
clearTimeout(fontSaveTimer);
fontSaveTimer = setTimeout(flushFontSave, 400);
}
} }
(function () { (function () {
setFontScale(curFontScale()); // 저장된 배율을 셸 메뉴에도 적용(초기 로드)
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus'); var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { setFontScale(curFontScale() + 10); }); function bump(delta) {
if (minus) minus.addEventListener('click', function () { setFontScale(curFontScale() - 10); }); var fid = slots[focused];
if (!fid || !tabs[fid]) { showToast('먼저 메뉴 탭을 여세요.'); return; }
applyTabFont(fid, (tabs[fid].scale || 100) + delta, true);
}
if (plus) plus.addEventListener('click', function () { bump(10); });
if (minus) minus.addEventListener('click', function () { bump(-10); });
})(); })();
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기 // 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
@@ -526,11 +575,19 @@ if ($effectiveLgIdx) {
}); });
}); });
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할 // 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할
var SERVER_STATE = <?= $savedWorkspaceJson ?? 'null' ?>;
(function restore() { (function restore() {
var saved = null; var saved = null;
// ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원)
if (SERVER_STATE && SERVER_STATE.tabs && SERVER_STATE.tabs.length) {
saved = SERVER_STATE;
}
// ② 없으면 이 브라우저 세션 저장 사용
if (!saved) {
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {} try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; } if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
}
if (saved && saved.tabs && saved.tabs.length) { if (saved && saved.tabs && saved.tabs.length) {
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); }); saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single'; layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';

View File

@@ -44,11 +44,15 @@ $searchQ = (string) (service('request')->getGet('q') ?? '');
.manual-prose tbody tr:nth-child(even) td { background: #f9fafb; } .manual-prose tbody tr:nth-child(even) td { background: #f9fafb; }
.manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; } .manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; }
.manual-toc a.active { background: #1a2b4b; color: #fff; font-weight: 700; } .manual-toc a.active { background: #1a2b4b; color: #fff; font-weight: 700; }
/* 소제목 옆 "화면 열기" 바로가기 */
.manual-jump { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; vertical-align: middle; font-size: 0.72rem; font-weight: 700; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 9999px; padding: 2px 9px; text-decoration: none; cursor: pointer; white-space: nowrap; }
.manual-jump:hover { background: #1d4ed8; color: #fff; border-color: #1d4ed8; }
.manual-jump i { font-size: 0.65rem; }
/* "이 화면 설명"으로 들어왔을 때 해당 소제목 강조 */ /* "이 화면 설명"으로 들어왔을 때 해당 소제목 강조 */
.manual-prose h2.hl-flash, .manual-prose h3.hl-flash { background: #fef9c3; box-shadow: -6px 0 0 #fef9c3, 6px 0 0 #fef9c3; border-radius: 4px; animation: hlFlash 2.6s ease-out 1; } .manual-prose h2.hl-flash, .manual-prose h3.hl-flash { background: #fef9c3; box-shadow: -6px 0 0 #fef9c3, 6px 0 0 #fef9c3; border-radius: 4px; animation: hlFlash 2.6s ease-out 1; }
@keyframes hlFlash { 0%, 45% { background: #fde047; box-shadow: -6px 0 0 #fde047, 6px 0 0 #fde047; } 100% { background: transparent; box-shadow: -6px 0 0 transparent, 6px 0 0 transparent; } } @keyframes hlFlash { 0%, 45% { background: #fde047; box-shadow: -6px 0 0 #fde047, 6px 0 0 #fde047; } 100% { background: transparent; box-shadow: -6px 0 0 transparent, 6px 0 0 transparent; } }
@media print { @media print {
.manual-toc, .manual-actions, .manual-nav { display: none !important; } .manual-toc, .manual-actions, .manual-nav, .manual-jump { display: none !important; }
.manual-layout { display: block !important; } .manual-layout { display: block !important; }
.manual-prose { max-width: none; } .manual-prose { max-width: none; }
} }
@@ -111,6 +115,8 @@ $searchQ = (string) (service('request')->getGet('q') ?? '');
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>; var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
// 이 매뉴얼 페이지가 설명하는 화면 바로가기 목록 [{hint, url}, ...]
var JUMPS = <?= json_encode($jumps ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
// "이 화면 설명"으로 들어온 경우: 해당 소제목으로 스크롤 + 강조. // "이 화면 설명"으로 들어온 경우: 해당 소제목으로 스크롤 + 강조.
// 드로어를 껐다 다시 켜도(같은 URL이라 iframe 재로드 안 됨) 부모가 보낸 메시지로 다시 강조한다. // 드로어를 껐다 다시 켜도(같은 URL이라 iframe 재로드 안 됨) 부모가 보낸 메시지로 다시 강조한다.
var hlFlashTimer = null; var hlFlashTimer = null;
@@ -140,6 +146,41 @@ $searchQ = (string) (service('request')->getGet('q') ?? '');
if (e.origin !== location.origin) return; if (e.origin !== location.origin) return;
if (e.data && e.data.type === 'manual-hl') runHl(); if (e.data && e.data.type === 'manual-hl') runHl();
}); });
// 해당 화면으로 이동: 워크스페이스(탭) 안이면 새 탭으로, 아니면 최상위 창을 이동.
function openScreen(url, title) {
try { if (window.top && typeof window.top.wsOpenTab === 'function') { window.top.wsOpenTab(url, title); return; } } catch (e) {}
try { if (window.parent && typeof window.parent.wsOpenTab === 'function') { window.parent.wsOpenTab(url, title); return; } } catch (e) {}
try { window.top.location.href = url; } catch (e) { window.location.href = url; }
}
// 각 소제목(h2/h3) 옆에 "화면 열기" 바로가기 버튼을 붙인다(hint 매칭).
(function attachJumps() {
if (!JUMPS || !JUMPS.length) return;
var prose = document.querySelector('.manual-prose');
if (!prose) return;
var norm = function (s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); };
var heads = prose.querySelectorAll('h2, h3');
JUMPS.forEach(function (j) {
var needle = norm(j.hint);
if (!needle || !j.url) return;
for (var i = 0; i < heads.length; i++) {
var h = heads[i];
if (h.getAttribute('data-jump')) continue; // 한 제목엔 하나만
if (norm(h.textContent).indexOf(needle) < 0) continue;
var label = (h.textContent || '화면').trim(); // 버튼 추가 전 제목 확보
var url = j.url;
var a = document.createElement('a');
a.className = 'manual-jump';
a.href = url;
a.title = '이 화면으로 이동';
a.innerHTML = '<i class="fa-solid fa-arrow-up-right-from-square"></i> 화면 열기';
a.addEventListener('click', function (ev) { ev.preventDefault(); openScreen(url, label); });
h.appendChild(a);
h.setAttribute('data-jump', '1');
break;
}
});
})();
var input = document.getElementById('manualSearchInput'); var input = document.getElementById('manualSearchInput');
var box = document.getElementById('manualSearchResults'); var box = document.getElementById('manualSearchResults');

View File

@@ -21,7 +21,7 @@ for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
?> ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">LOT-No 디스켓 불출</span> <span class="text-sm font-bold text-gray-700">발주파일 생성</span>
</section> </section>
<section class="border border-gray-300 rounded-lg p-2 mt-2 bg-white"> <section class="border border-gray-300 rounded-lg p-2 mt-2 bg-white">
@@ -92,11 +92,12 @@ for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
?> ?>
<tr> <tr>
<td class="text-center"> <td class="text-center">
<form method="post" action="<?= base_url('bag/order/lot-seed/generate') ?>" class="inline"> <button type="button"
<?= csrf_field() ?> class="lot-preview-btn text-blue-600 hover:underline text-xs"
<input type="hidden" name="bo_idx" value="<?= $boIdx ?>" /> data-bo-idx="<?= $boIdx ?>"
<button type="submit" class="text-blue-600 hover:underline text-xs">seed 생성</button> data-lot="<?= esc((string) ($order->bo_lot_no ?? ''), 'attr') ?>">
</form> 내용 확인
</button>
</td> </td>
<td class="text-center"><?= esc((string) ($order->bo_order_date ?? '')) ?></td> <td class="text-center"><?= esc((string) ($order->bo_order_date ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($order->bo_lot_no ?? '')) ?></td> <td class="text-center font-mono"><?= esc((string) ($order->bo_lot_no ?? '')) ?></td>
@@ -128,3 +129,133 @@ for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
<?php if (isset($pager)): ?> <?php if (isset($pager)): ?>
<div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div> <div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?> <?php endif; ?>
<!-- 발주파일 생성 전 '내용 확인' 모달 -->
<div id="lot-preview-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<span class="text-sm font-bold text-gray-800">발주파일 내용 확인</span>
<button type="button" id="lot-preview-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">&times;</button>
</div>
<div class="p-4 overflow-auto text-sm">
<div id="lot-preview-loading" class="text-center text-gray-400 py-8">불러오는 중…</div>
<div id="lot-preview-error" class="hidden text-center text-red-600 py-8"></div>
<div id="lot-preview-body" class="hidden">
<p class="text-xs text-gray-500 mb-2">아래 내용으로 <b>암호화된 발주파일(.seed.json)</b>이 생성·다운로드됩니다. 생성 전 내용을 확인하세요.</p>
<dl class="grid grid-cols-[6rem_1fr] gap-y-1 text-[13px] mb-3 border border-gray-200 rounded p-2 bg-gray-50">
<dt class="font-semibold text-gray-600">LOT-No</dt><dd class="font-mono" id="lp-lot"></dd>
<dt class="font-semibold text-gray-600">발주일</dt><dd id="lp-date"></dd>
<dt class="font-semibold text-gray-600">제작업체</dt><dd id="lp-company"></dd>
<dt class="font-semibold text-gray-600">버전</dt><dd id="lp-version"></dd>
<dt class="font-semibold text-gray-600">무결성해시</dt><dd class="font-mono text-[11px] text-gray-500 break-all" id="lp-hash"></dd>
</dl>
<div class="overflow-auto border border-gray-200 rounded">
<table class="w-full data-table text-[13px]">
<thead>
<tr>
<th class="text-left pl-2">봉투코드</th>
<th class="text-left">봉투명</th>
<th class="w-20 text-right">단가</th>
<th class="w-16 text-right">박스</th>
<th class="w-16 text-right">팩</th>
<th class="w-20 text-right">낱장</th>
<th class="w-24 text-right pr-2">금액</th>
</tr>
</thead>
<tbody id="lp-items"></tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td class="pl-2" colspan="3">합계 (<span id="lp-line-count">0</span>품목)</td>
<td class="text-right" id="lp-sum-box">0</td>
<td class="text-right">—</td>
<td class="text-right" id="lp-sum-sheet">0</td>
<td class="text-right pr-2" id="lp-sum-amount">0</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-end gap-2">
<button type="button" id="lot-preview-cancel" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
<form method="post" action="<?= base_url('bag/order/lot-seed/generate') ?>" class="inline" id="lot-generate-form">
<?= csrf_field() ?>
<input type="hidden" name="bo_idx" id="lot-generate-bo-idx" value="" />
<button type="submit" id="lot-generate-submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 disabled:opacity-50" disabled>발주파일 생성(다운로드)</button>
</form>
</div>
</div>
</div>
<script>
(function () {
var previewUrl = '<?= base_url('bag/order/lot-seed/preview') ?>';
var overlay = document.getElementById('lot-preview-overlay');
var elLoading = document.getElementById('lot-preview-loading');
var elError = document.getElementById('lot-preview-error');
var elBody = document.getElementById('lot-preview-body');
var elSubmit = document.getElementById('lot-generate-submit');
var elBoIdx = document.getElementById('lot-generate-bo-idx');
var nf = function (n) { return new Intl.NumberFormat('ko-KR').format(n || 0); };
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]; }); };
function openModal() { overlay.classList.remove('hidden'); overlay.classList.add('flex'); }
function closeModal() { overlay.classList.add('hidden'); overlay.classList.remove('flex'); }
function showState(state) {
elLoading.classList.toggle('hidden', state !== 'loading');
elError.classList.toggle('hidden', state !== 'error');
elBody.classList.toggle('hidden', state !== 'body');
elSubmit.disabled = state !== 'body';
}
document.querySelectorAll('.lot-preview-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var boIdx = btn.getAttribute('data-bo-idx');
elBoIdx.value = boIdx;
openModal();
showState('loading');
fetch(previewUrl + '?bo_idx=' + encodeURIComponent(boIdx), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || !d.ok) {
elError.textContent = (d && d.error) || '내용을 불러오지 못했습니다.';
showState('error');
return;
}
document.getElementById('lp-lot').textContent = d.lot_no || '';
document.getElementById('lp-date').textContent = d.order_date || '';
document.getElementById('lp-company').textContent = d.company || '-';
document.getElementById('lp-version').textContent = 'v' + (d.version || 1);
document.getElementById('lp-hash').textContent = d.order_hash || '';
var rows = (d.items || []).map(function (it) {
return '<tr>'
+ '<td class="pl-2 font-mono">' + esc(it.bag_code) + '</td>'
+ '<td>' + esc(it.bag_name) + '</td>'
+ '<td class="text-right">' + nf(it.unit_price) + '</td>'
+ '<td class="text-right">' + nf(it.qty_box) + '</td>'
+ '<td class="text-right">' + nf(it.qty_pack) + '</td>'
+ '<td class="text-right">' + nf(it.qty_sheet) + '</td>'
+ '<td class="text-right pr-2">' + nf(it.amount) + '</td>'
+ '</tr>';
}).join('');
document.getElementById('lp-items').innerHTML = rows || '<tr><td colspan="7" class="text-center text-gray-400 py-4">품목이 없습니다.</td></tr>';
var t = d.totals || {};
document.getElementById('lp-line-count').textContent = nf(t.line_count);
document.getElementById('lp-sum-box').textContent = nf(t.qty_box);
document.getElementById('lp-sum-sheet').textContent = nf(t.qty_sheet);
document.getElementById('lp-sum-amount').textContent = nf(t.amount);
showState('body');
})
.catch(function () {
elError.textContent = '통신 오류가 발생했습니다.';
showState('error');
});
});
});
document.getElementById('lot-preview-close').addEventListener('click', closeModal);
document.getElementById('lot-preview-cancel').addEventListener('click', closeModal);
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
})();
</script>

View File

@@ -18,19 +18,22 @@
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="return_to" value="bag/order/phone"/> <input type="hidden" name="return_to" value="bag/order/phone"/>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4"> <input type="hidden" name="so_delivery_date" value="<?= esc(date('Y-m-d', strtotime('+1 day'))) ?>"/>
<div class="xl:col-span-2 space-y-3">
<div class="flex flex-wrap items-center gap-2"> <div class="grid grid-cols-1 xl:grid-cols-3 gap-4 items-start">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 검색</label> <!-- 좌측(좁게): 판매소 선택 → 접수정보 → 지정판매소 정보(+결제/가상계좌) -->
<div class="relative flex-1 min-w-[20rem]"> <div id="phone-left-col" class="xl:col-span-1 space-y-3">
<input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-[34rem] max-w-full" type="text" autocomplete="off" placeholder="코드/사업자번호/대표자명/상호/전화/주소 중 하나 입력"/> <div>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[48rem] max-w-[90vw] max-h-72 overflow-auto border border-gray-300 rounded-lg bg-white shadow-lg z-30"></div> <label class="block text-sm font-bold text-gray-700 mb-1">판매소 검색</label>
<div class="relative">
<input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full" type="text" autocomplete="off" placeholder="코드/사업자번호/대표자명/상호/전화/주소"/>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[28rem] max-w-[90vw] max-h-72 overflow-auto border border-gray-300 rounded-lg bg-white shadow-lg z-30"></div>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div>
<label class="block text-sm font-bold text-gray-700 w-28">판매소 선택 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1">판매소 선택 <span class="text-red-500">*</span></label>
<select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-[34rem] max-w-full" name="so_ds_idx" required> <select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full" name="so_ds_idx" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($shops as $shop): ?> <?php foreach ($shops as $shop): ?>
<option <option
@@ -50,7 +53,6 @@
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
</div>
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50"> <div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">접수 정보</div> <div class="text-sm font-bold text-gray-700 mb-2">접수 정보</div>
@@ -61,9 +63,7 @@
<tr><th class="text-left py-1">담당자</th><td class="py-1 text-gray-700"><?= esc((string) (session()->get('mb_name') ?? '담당자')) ?></td></tr> <tr><th class="text-left py-1">담당자</th><td class="py-1 text-gray-700"><?= esc((string) (session()->get('mb_name') ?? '담당자')) ?></td></tr>
</table> </table>
</div> </div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50"> <div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div> <div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
<table class="w-full text-sm"> <table class="w-full text-sm">
@@ -74,9 +74,7 @@
<tr><th class="text-left py-1">전화번호</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr> <tr><th class="text-left py-1">전화번호</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr> <tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr>
</table> </table>
</div> <div class="mt-3 pt-3 border-t border-gray-200">
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">결제/가상계좌</div> <div class="text-sm font-bold text-gray-700 mb-2">결제/가상계좌</div>
<div class="flex flex-wrap items-center gap-2 mb-2"> <div class="flex flex-wrap items-center gap-2 mb-2">
<label class="block text-sm font-bold text-gray-700 w-24">결제구분 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-24">결제구분 <span class="text-red-500">*</span></label>
@@ -93,77 +91,152 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /좌측 컬럼 -->
<input type="hidden" name="so_delivery_date" value="<?= esc(date('Y-m-d', strtotime('+1 day'))) ?>"/> <?php
// 일괄표: 봉투코드를 카테고리(일반용·재사용·음식물 스티커·대형폐기물 스티커)로 묶어
<div class="mt-4"> // 봉투류는 리터 오름차순, 폐기물 스티커는 금액 오름차순으로 정렬한다.
<div class="flex items-center justify-between mb-2"> $catOf = static function (string $name): ?array {
<label class="block text-sm font-bold text-gray-700">전화 주문접수표</label> if (mb_strpos($name, '일반용') === 0) { return [1, '일반용', 'liter']; }
<button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button> if (mb_strpos($name, '재사용') === 0) { return [2, '재사용', 'liter']; }
</div> if (mb_strpos($name, '음식물 스티커') === 0) { return [3, '음식물 스티커', 'liter']; }
<div class="border border-gray-300 rounded-lg p-4 overflow-auto"> if (mb_strpos($name, '폐기물 스티커') === 0) { return [4, '대형폐기물 스티커', 'amount']; }
<table class="w-full data-table text-sm"> return null;
};
$orderGroups = [];
foreach (($bagCodes ?? []) as $cd) {
$name = (string) ($cd->cd_name ?? '');
$cat = $catOf($name);
if ($cat === null) { continue; }
$code = (string) $cd->cd_code;
$price = $priceMap[$code] ?? null;
$unit = $unitMap[$code] ?? null;
if ($cat[2] === 'amount') {
preg_match('/([\d,]+)\s*원/u', $name, $m);
$sortVal = (int) str_replace(',', '', $m[1] ?? '0');
} else {
preg_match('/(\d+)\s*[lL]/u', $name, $m);
$sortVal = (int) ($m[1] ?? 0);
}
$orderGroups[$cat[0]]['label'] = $cat[1];
$orderGroups[$cat[0]]['rows'][] = [
'code' => $code,
'name' => $name,
'price' => (int) ($price->bp_consumer ?? 0),
'box' => (int) ($unit->pu_total_per_box ?? 0),
'pack' => (int) ($unit->pu_pack_per_sheet ?? 0),
'sort' => $sortVal,
];
}
ksort($orderGroups);
foreach ($orderGroups as &$g) {
usort($g['rows'], static fn ($a, $b) => $a['sort'] <=> $b['sort']);
}
unset($g);
?>
<!-- 우측(넓게): 전화 주문접수표(일괄) — 최상단부터, 좌측 컬럼 높이에 맞춤 -->
<div id="phone-right-col" class="xl:col-span-2 flex flex-col">
<label class="block text-sm font-bold text-gray-700 mb-2">전화 주문접수표 (일괄)</label>
<style>
/* 그룹 헤더(rowspan/colspan) 경계선이 단가까지 이어지는 현상 방지 — 헤더 맨 아래 한 줄만 */
/* table-layout: fixed — 판매수량 입력 시 숫자 자릿수 변화로 열 너비가 흔들리는 현상 방지 */
table.phone-batch-table { border-collapse: separate; border-spacing: 0; table-layout: fixed; }
table.phone-batch-table thead th { border-bottom: 0; }
table.phone-batch-table thead tr:last-child th { border-bottom: 1px solid #e5e7eb; } /* 수량/금액 등 하위 헤더 */
table.phone-batch-table thead tr:first-child th[rowspan] { border-bottom: 1px solid #e5e7eb; } /* 구분·봉투종류·단가 */
table.phone-batch-table tbody td { border-bottom: 1px solid #f1f3f5; }
/* 행·열 간격 축소 — 한 화면에 전체 품목이 보이도록 (피드백 #13) */
table.phone-batch-table th, table.phone-batch-table td { padding: 0.2rem 0.35rem; }
table.phone-batch-table .item-qty-input { padding-top: 0.1rem; padding-bottom: 0.1rem; }
/* 스크롤 시 2행 그룹 헤더 + 합계 행 고정. --hdr-row1-h는 JS가 첫 헤더행 높이로 설정.
표 높이는 JS가 좌측 컬럼 높이에 맞춰 우측 컬럼에 지정하고, 래퍼(flex-1)가 그 높이를 채운다. */
table.phone-batch-table thead th { position: sticky; z-index: 3; background: #f3f4f6; }
table.phone-batch-table thead tr:first-child th { top: 0; }
table.phone-batch-table thead tr:last-child th { top: var(--hdr-row1-h, 1.6rem); }
table.phone-batch-table tfoot td { position: sticky; bottom: 0; z-index: 2; background: #f9fafb; box-shadow: inset 0 1px 0 #e5e7eb; }
</style>
<div class="phone-batch-wrap flex-1 min-h-0 border border-gray-300 rounded-lg overflow-auto">
<table class="w-full data-table text-sm phone-batch-table">
<colgroup>
<col style="width:8rem"/> <!-- 구분: 최장 라벨("대형폐기물 스티커") 기준 고정폭 -->
<col style="width:11rem"/> <!-- 봉투종류: 최장 품목명(예: "폐기물 스티커 10,000원") 기준 고정폭.
width:auto는 화면이 좁거나 텍스트 확대 시 0으로 찌부러질 수 있어 사용 안 함. -->
<col style="width:4rem"/> <!-- Pack 수량 -->
<col style="width:5.5rem"/> <!-- Pack 금액 -->
<col style="width:4rem"/> <!-- Box 수량 -->
<col style="width:5.5rem"/> <!-- Box 금액 -->
<col style="width:5rem"/> <!-- 단가 -->
<col style="width:6rem"/> <!-- 판매수량 -->
<col style="width:6rem"/> <!-- 금액 -->
<col style="width:3rem"/> <!-- B -->
<col style="width:3rem"/> <!-- P -->
<col style="width:3rem"/> <!-- E -->
</colgroup>
<thead> <thead>
<tr> <tr>
<th class="w-14 text-center">구분</th> <th class="text-center" rowspan="2">구분</th>
<th class="w-56">품목</th> <th rowspan="2">봉투종류</th>
<th class="w-40 text-right">1박스(낱장/판매가)</th> <th class="text-center" colspan="2">Pack</th>
<th class="w-40 text-right">1팩(낱장/판매가)</th> <th class="text-center" colspan="2">Box</th>
<th class="w-24 text-right">단가</th> <th class="text-right" rowspan="2">단가</th>
<th class="w-28 text-right">주문수량</th> <th class="text-center" colspan="2">주문내용</th>
<th class="w-28 text-right">금액</th> <th class="text-center" colspan="3">포장</th>
<th class="w-44 text-right">포장(박스/팩/낱장)</th> </tr>
<th class="w-20 text-center">삭제</th> <tr>
<th class="text-right">수량</th>
<th class="text-right">금액</th>
<th class="text-right">수량</th>
<th class="text-right">금액</th>
<th class="text-right">판매수량(팩)</th>
<th class="text-right">금액</th>
<th class="text-center">B</th>
<th class="text-center">P</th>
<th class="text-center">E</th>
</tr> </tr>
</thead> </thead>
<tbody id="order-rows"> <tbody id="order-rows">
<?php for ($i = 0; $i < 3; $i++): ?> <?php if (empty($orderGroups)): ?>
<tr class="order-row"> <tr><td colspan="12" class="text-center text-gray-400 py-6">등록된 봉투 품목이 없습니다.</td></tr>
<td class="text-center item-kind-cell">-</td> <?php else: ?>
<td> <?php foreach ($orderGroups as $g): ?>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]"> <?php $rc = count($g['rows']); $firstRow = true; ?>
<option value="">선택</option> <?php foreach ($g['rows'] as $r): ?>
<?php foreach ($bagCodes as $cd): ?> <?php $boxPrice = $r['box'] * $r['price']; $packPrice = $r['pack'] * $r['price']; ?>
<?php <tr class="order-row" data-unit-price="<?= esc((string) $r['price'], 'attr') ?>" data-box-sheets="<?= esc((string) $r['box'], 'attr') ?>" data-pack-sheets="<?= esc((string) $r['pack'], 'attr') ?>">
$code = (string) $cd->cd_code; <?php if ($firstRow): ?>
$name = (string) ($cd->cd_name ?? ''); <td class="text-center align-top font-semibold text-gray-700" rowspan="<?= (int) $rc ?>"><?= esc($g['label']) ?></td>
$price = $priceMap[$code] ?? null; <?php endif; ?>
$unit = $unitMap[$code] ?? null; <td class="text-left pl-2"><?= esc($r['name']) ?><input type="hidden" name="item_bag_code[]" value="<?= esc($r['code'], 'attr') ?>"/></td>
$unitPrice = (int) ($price->bp_consumer ?? 0); <td class="text-right px-2"><?= number_format($r['pack']) ?></td>
$boxSheets = (int) ($unit->pu_total_per_box ?? 0); <td class="text-right px-2"><?= number_format($packPrice) ?></td>
$packSheets = (int) ($unit->pu_pack_per_sheet ?? 0); <td class="text-right px-2"><?= number_format($r['box']) ?></td>
$kindLabel = (mb_strpos($name, '스티커') !== false) ? '스티커' : '봉투'; <td class="text-right px-2"><?= number_format($boxPrice) ?></td>
?> <td class="text-right px-2"><?= number_format($r['price']) ?></td>
<option value="<?= esc($code) ?>" data-name="<?= esc($name, 'attr') ?>" data-kind-label="<?= esc($kindLabel, 'attr') ?>" data-unit-price="<?= esc((string) $unitPrice, 'attr') ?>" data-box-sheets="<?= esc((string) $boxSheets, 'attr') ?>" data-pack-sheets="<?= esc((string) $packSheets, 'attr') ?>"> <td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input item-pack-input" type="number" min="0" value="0"/><input type="hidden" name="item_qty[]" class="item-qty-hidden" value="0"/></td>
<?= esc($code) ?> — <?= esc($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td> <td class="text-right px-2 item-amount-cell">0</td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td> <td class="text-center item-box-cell">0</td>
<td class="text-center px-2"> <td class="text-center item-pack-cell">0</td>
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button> <td class="text-center item-sheet-cell">0</td>
</td>
</tr> </tr>
<?php endfor; ?> <?php $firstRow = false; ?>
<?php endforeach; ?>
<?php endforeach; ?>
<?php endif; ?>
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="font-semibold bg-gray-50"> <tr class="font-semibold bg-gray-50">
<td colspan="5" class="text-right px-2 py-1">합계</td> <td colspan="7" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="sum-qty">0</td> <td class="text-right px-2 py-1" id="sum-qty">0</td>
<td class="text-right px-2 py-1" id="sum-amount">0</td> <td class="text-right px-2 py-1" id="sum-amount">0</td>
<td class="text-right px-2 py-1" id="sum-pack">박스=0, 팩=0, 낱장=0</td> <td class="text-center px-2 py-1" id="sum-box">0</td>
<td></td> <td class="text-center px-2 py-1" id="sum-pack">0</td>
<td class="text-center px-2 py-1" id="sum-sheet">0</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div> </div><!-- /우측 컬럼 -->
</div><!-- /2열 그리드 -->
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button> <button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
@@ -172,40 +245,6 @@
</form> </form>
</div> </div>
<template id="order-row-template">
<tr class="order-row">
<td class="text-center item-kind-cell">-</td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<?php
$code = (string) $cd->cd_code;
$name = (string) ($cd->cd_name ?? '');
$price = $priceMap[$code] ?? null;
$unit = $unitMap[$code] ?? null;
$unitPrice = (int) ($price->bp_consumer ?? 0);
$boxSheets = (int) ($unit->pu_total_per_box ?? 0);
$packSheets = (int) ($unit->pu_pack_per_sheet ?? 0);
$kindLabel = (mb_strpos($name, '스티커') !== false) ? '스티커' : '봉투';
?>
<option value="<?= esc($code) ?>" data-name="<?= esc($name, 'attr') ?>" data-kind-label="<?= esc($kindLabel, 'attr') ?>" data-unit-price="<?= esc((string) $unitPrice, 'attr') ?>" data-box-sheets="<?= esc((string) $boxSheets, 'attr') ?>" data-pack-sheets="<?= esc((string) $packSheets, 'attr') ?>">
<?= esc($code) ?> — <?= esc($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td>
</tr>
</template>
<script> <script>
(() => { (() => {
@@ -214,9 +253,7 @@
const shopSuggest = document.getElementById('shop-search-suggest'); const shopSuggest = document.getElementById('shop-search-suggest');
const paymentType = document.getElementById('payment-type'); const paymentType = document.getElementById('payment-type');
const paymentGuide = document.getElementById('payment-guide'); const paymentGuide = document.getElementById('payment-guide');
const addRowButton = document.getElementById('add-order-row');
const orderRows = document.getElementById('order-rows'); const orderRows = document.getElementById('order-rows');
const rowTemplate = document.getElementById('order-row-template');
const form = document.getElementById('phone-order-form'); const form = document.getElementById('phone-order-form');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0); const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
@@ -293,15 +330,17 @@
} }
function calcRow(row) { function calcRow(row) {
const select = row.querySelector('.bag-code-select'); const qtyInput = row.querySelector('.item-pack-input');
const qtyInput = row.querySelector('.item-qty-input'); const hiddenQty = row.querySelector('.item-qty-hidden');
const selected = select.options[select.selectedIndex]; const packQty = parseInt(qtyInput.value || '0', 10) || 0; // 입력값 = 팩 수
const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0;
const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0;
const qty = parseInt(qtyInput.value || '0', 10) || 0; // 팩당 낱장 수(팩 매수 미설정 봉투는 1:1로 처리)
const unitPrice = parseInt(selected?.dataset?.unitPrice || '0', 10) || 0; const mult = packSheets > 0 ? packSheets : 1;
const boxSheets = parseInt(selected?.dataset?.boxSheets || '0', 10) || 0; const qty = packQty * mult; // 실제 낱장 수 → 서버로 전송
const packSheets = parseInt(selected?.dataset?.packSheets || '0', 10) || 0; if (hiddenQty) hiddenQty.value = String(qty);
const kindLabel = selected?.dataset?.kindLabel || '-';
let box = 0; let box = 0;
let pack = 0; let pack = 0;
@@ -321,24 +360,19 @@
} }
const amount = unitPrice * qty; const amount = unitPrice * qty;
const boxPrice = boxSheets * unitPrice;
const packPrice = packSheets * unitPrice;
row.querySelector('.item-kind-cell').textContent = kindLabel;
row.querySelector('.box-info-cell').textContent = nf(boxSheets) + ' / ' + nf(boxPrice);
row.querySelector('.pack-info-cell').textContent = nf(packSheets) + ' / ' + nf(packPrice);
row.querySelector('.unit-price-cell').textContent = nf(unitPrice);
row.querySelector('.item-amount-cell').textContent = nf(amount); row.querySelector('.item-amount-cell').textContent = nf(amount);
row.querySelector('.pack-result-cell').textContent = '박스=' + nf(box) + ', 팩=' + nf(pack) + ', 낱장=' + nf(sheet); row.querySelector('.item-box-cell').textContent = box ? nf(box) : '';
row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : '';
row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : '';
return { qty, amount, box, pack, sheet }; return { packQty, amount, box, pack, sheet };
} }
function recalcAllRows() { function recalcAllRows() {
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0; let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
document.querySelectorAll('.order-row').forEach((row) => { document.querySelectorAll('.order-row').forEach((row) => {
const r = calcRow(row); const r = calcRow(row);
sumQty += r.qty; sumQty += r.packQty;
sumAmount += r.amount; sumAmount += r.amount;
sumBox += r.box; sumBox += r.box;
sumPack += r.pack; sumPack += r.pack;
@@ -346,7 +380,9 @@
}); });
document.getElementById('sum-qty').textContent = nf(sumQty); document.getElementById('sum-qty').textContent = nf(sumQty);
document.getElementById('sum-amount').textContent = nf(sumAmount); document.getElementById('sum-amount').textContent = nf(sumAmount);
document.getElementById('sum-pack').textContent = '박스=' + nf(sumBox) + ', 팩=' + nf(sumPack) + ', 낱장=' + nf(sumSheet); document.getElementById('sum-box').textContent = nf(sumBox);
document.getElementById('sum-pack').textContent = nf(sumPack);
document.getElementById('sum-sheet').textContent = nf(sumSheet);
} }
function clearSearchInputOnly() { function clearSearchInputOnly() {
@@ -394,51 +430,66 @@
shopSelect?.addEventListener('change', updateShopInfo); shopSelect?.addEventListener('change', updateShopInfo);
paymentType?.addEventListener('change', updateShopInfo); paymentType?.addEventListener('change', updateShopInfo);
orderRows?.addEventListener('change', function (e) {
if (e.target.closest('.bag-code-select') || e.target.closest('.item-qty-input')) {
recalcAllRows();
}
});
orderRows?.addEventListener('input', function (e) { orderRows?.addEventListener('input', function (e) {
if (e.target.closest('.item-qty-input')) { if (e.target.closest('.item-qty-input')) {
recalcAllRows(); recalcAllRows();
} }
}); });
orderRows?.addEventListener('click', function (e) {
const removeButton = e.target.closest('.remove-order-row');
if (!removeButton) return;
const row = removeButton.closest('.order-row');
if (!row) return;
if (orderRows.querySelectorAll('.order-row').length <= 1) {
alert('최소 1개 행은 유지해야 합니다.');
return;
}
row.remove();
recalcAllRows();
});
addRowButton?.addEventListener('click', function () { // 판매수량 입력 중 엔터키 → 다음 봉투 종류의 수량 입력칸으로 이동
if (!rowTemplate || !orderRows) return; orderRows?.addEventListener('keydown', function (e) {
const fragment = rowTemplate.content.cloneNode(true); if (e.key !== 'Enter' || !e.target.classList.contains('item-qty-input')) return;
orderRows.appendChild(fragment); e.preventDefault();
recalcAllRows(); const inputs = Array.from(orderRows.querySelectorAll('.item-qty-input'));
const next = inputs[inputs.indexOf(e.target) + 1];
if (next) { next.focus(); next.select(); }
}); });
form?.addEventListener('submit', function (e) { form?.addEventListener('submit', function (e) {
let hasItem = false; let hasItem = false;
document.querySelectorAll('.order-row').forEach((row) => { document.querySelectorAll('.order-row').forEach((row) => {
const code = row.querySelector('.bag-code-select').value;
const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0; const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0;
if (code && qty > 0) hasItem = true; if (qty > 0) hasItem = true;
}); });
if (!hasItem) { if (!hasItem) {
e.preventDefault(); e.preventDefault();
alert('주문 품목과 수량을 1개 이상 입력해 주세요.'); alert('주문 수량을 1개 이상 입력해 주세요.');
} }
}); });
updateShopInfo(); updateShopInfo();
recalcAllRows(); recalcAllRows();
// 스크롤 시 두 번째 헤더행이 첫 번째 헤더행 바로 아래에 붙도록 오프셋 계산.
// offsetHeight는 화면 zoom(텍스트 크기)과 무관한 레이아웃 px라 배율이 바뀌어도 정확하다.
(function () {
const tbl = document.querySelector('table.phone-batch-table');
if (!tbl) return;
const r1 = tbl.querySelector('thead tr:first-child');
function setHeaderOffset() {
if (r1) tbl.style.setProperty('--hdr-row1-h', r1.offsetHeight + 'px');
}
setHeaderOffset();
window.addEventListener('resize', setHeaderOffset);
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') setHeaderOffset(); });
})();
// 전화 주문접수표(우측 컬럼) 높이를 좌측 컬럼(지정판매소 정보 하단까지)에 맞춘다.
// offsetHeight/offsetTop은 zoom과 무관한 레이아웃 px라 배율이 바뀌어도 정확하다.
(function () {
const leftCol = document.getElementById('phone-left-col');
const rightCol = document.getElementById('phone-right-col');
if (!leftCol || !rightCol) return;
function syncTableHeight() {
// 2열(나란히)일 때만 높이 맞춤. 1열로 쌓이면 자동 높이로 둔다.
const sideBySide = Math.abs(leftCol.offsetTop - rightCol.offsetTop) < 4;
rightCol.style.height = sideBySide ? (leftCol.offsetHeight + 'px') : '';
}
syncTableHeight();
window.addEventListener('resize', syncTableHeight);
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') syncTableHeight(); });
if ('ResizeObserver' in window) { new ResizeObserver(syncTableHeight).observe(leftCol); }
})();
})(); })();
</script> </script>

View File

@@ -1,84 +1,110 @@
<?php $startDate = (string) ($startDate ?? ''); $endDate = (string) ($endDate ?? ''); ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">전화 주문 접수 관리</span> <span class="text-sm font-bold text-gray-700">주문접수 관리</span>
<a href="<?= base_url('bag/order/phone') ?>" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm shadow hover:opacity-90">전화 주문 접수</a>
</div> </div>
<form method="get" action="<?= base_url('bag/order/phone/manage') ?>" class="mt-2 flex flex-wrap items-center gap-2 text-sm">
<label class="font-bold text-gray-700">접수일</label>
<input type="date" lang="ko" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span class="text-gray-400">~</span>
<input type="date" lang="ko" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm">조회</button>
<?php if ($startDate !== '' || $endDate !== ''): ?>
<a href="<?= base_url('bag/order/phone/manage') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
<?php endif; ?>
</form>
</section> </section>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3 mt-2"> <div id="order-split" class="flex flex-col xl:flex-row gap-3 mt-2 items-stretch">
<section class="xl:col-span-2 border border-gray-300 rounded-lg bg-white overflow-hidden"> <section id="list-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col min-w-0 xl:shrink-0">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div> <div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div>
<div class="max-h-[72vh] overflow-auto"> <div id="list-scroll" class="overflow-auto">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-14 text-center">번호</th> <th class="w-12 text-center sort-th cursor-pointer select-none" data-sort="so_idx" data-type="num">번호<span class="sort-ind"></span></th>
<th class="text-left">판매소</th> <th class="w-24 text-center sort-th cursor-pointer select-none" data-sort="so_order_date" data-type="str">접수일<span class="sort-ind"></span></th>
<th class="w-28 text-center">접수일</th> <th class="w-24 text-center sort-th cursor-pointer select-none" data-sort="so_delivery_date" data-type="str">배달일<span class="sort-ind"></span></th>
<th class="w-24 text-center">상태</th> <th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_channel" data-type="str">구분<span class="sort-ind"></span></th>
<th class="text-left sort-th cursor-pointer select-none" data-sort="so_ds_name" data-type="str">판매소<span class="sort-ind"></span></th>
<th class="w-16 text-center sort-th cursor-pointer select-none" data-sort="so_payment_type" data-type="str">결제<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_received" data-type="num">포장<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_paid" data-type="num">입금<span class="sort-ind"></span></th>
<th class="w-24 text-right sort-th cursor-pointer select-none" data-sort="so_total_amount" data-type="num">총금액<span class="sort-ind"></span></th>
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_status" data-type="str">취소<span class="sort-ind"></span></th>
</tr> </tr>
</thead> </thead>
<tbody id="order-list-body"> <tbody id="order-list-body"></tbody>
<?php foreach (($orders ?? []) as $row): ?>
<?php $isCancelled = (($row['so_status'] ?? 'normal') === 'cancelled'); ?>
<tr class="order-list-row cursor-pointer hover:bg-blue-50 <?= $isCancelled ? 'bg-gray-50 text-gray-400' : '' ?>" data-order-id="<?= esc((string) ($row['so_idx'] ?? 0), 'attr') ?>">
<td class="text-center"><?= esc((string) ($row['so_idx'] ?? 0)) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['so_ds_name'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['so_order_date'] ?? '')) ?></td>
<td class="text-center"><?= $isCancelled ? '취소' : '정상' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($orders)): ?>
<tr>
<td colspan="4" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table> </table>
</div> </div>
<div id="list-summary" class="px-3 py-2 border-t border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 flex flex-wrap items-center justify-between gap-2">
<span>건수 <span id="list-sum-count" class="text-blue-700">0</span>건</span>
<span>금액 <span id="list-sum-amount" class="text-blue-700">0</span>원</span>
</div>
</section> </section>
<section class="xl:col-span-3 border border-gray-300 rounded-lg bg-white overflow-hidden"> <!-- 드래그 가능한 너비 조절 손잡이 (xl 이상 좌우 배치일 때만 표시) -->
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">상세 정보</div> <div id="split-handle" class="hidden xl:flex items-center justify-center cursor-col-resize select-none shrink-0" style="width:8px;touch-action:none;" role="separator" aria-orientation="vertical" title="드래그하여 표 너비를 조절합니다">
<div class="w-1.5 h-16 rounded-full bg-gray-300 hover:bg-blue-400 transition-colors"></div>
</div>
<section id="detail-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden min-w-0 xl:flex-1">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3"> <form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="so_idx" id="detail-so-idx" value=""/> <input type="hidden" name="so_idx" id="detail-so-idx" value=""/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> <div class="border border-gray-200 rounded-lg p-3 bg-gray-50 text-sm">
<div class="border border-gray-200 rounded-lg p-2 bg-gray-50"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1">
<div><span class="font-semibold text-gray-700">접수번호:</span> <span id="detail-so-no">-</span></div> <div><span class="font-semibold text-gray-700 inline-block w-20">접수번호</span> <span id="detail-so-no">-</span></div>
<div><span class="font-semibold text-gray-700">판매소:</span> <span id="detail-shop-name">-</span></div> <div><span class="font-semibold text-gray-700 inline-block w-20">판매소코드</span> <span id="detail-shop-code">-</span></div>
<div><span class="font-semibold text-gray-700">결제구분:</span> <span id="detail-payment">-</span></div> <div><span class="font-semibold text-gray-700 inline-block w-20">판매소명</span> <span id="detail-shop-name">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">담당자</span> <span id="detail-shop-rep">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">전화번호</span> <span id="detail-shop-tel">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">결제수단</span> <span id="detail-payment">-</span></div>
<div class="md:col-span-2"><span class="font-semibold text-gray-700 inline-block w-20">주소</span> <span id="detail-shop-addr">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">접수일</span> <span id="detail-order-date">-</span></div>
<div class="flex items-center"><span class="font-semibold text-gray-700 inline-block w-20">배달일</span>
<input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/>
</div> </div>
<div class="border border-gray-200 rounded-lg p-2 bg-gray-50"> <div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div>
<div><span class="font-semibold text-gray-700">접수일:</span> <span id="detail-order-date">-</span></div> <div><span class="font-semibold text-gray-700 inline-block w-20">입금상태</span> <span id="detail-paid-status" class="font-semibold">-</span></div>
<div><span class="font-semibold text-gray-700">배달일:</span> <span id="detail-delivery-date">-</span></div>
<div><span class="font-semibold text-gray-700">상태:</span> <span id="detail-status">-</span></div>
</div> </div>
</div> </div>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto"> <div class="flex flex-wrap items-center justify-between gap-2 px-1">
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
<div class="flex items-center gap-2">
<button type="button" id="btn-toggle-paid" class="border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50" disabled>입금확인</button>
<button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
<button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
<button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
</div>
</div>
<div class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-14 text-center">번호</th> <th class="w-14 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">번호</th>
<th class="text-left">품목</th> <th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">품목</th>
<th class="w-24 text-right">단가</th> <th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">단가</th>
<th class="w-24 text-right">접수량</th> <th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">접수량</th>
<th class="w-28 text-right">접수금액</th> <th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장량</th>
<th class="w-40 text-right">포장단위(박스/팩/낱장)</th> <th class="w-28 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">접수금액</th>
<th class="w-40 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장단위(박스/팩/낱장)</th>
</tr> </tr>
</thead> </thead>
<tbody id="detail-items-body"> <tbody id="detail-items-body">
<tr> <tr>
<td colspan="6" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td> <td colspan="7" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td>
</tr> </tr>
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="font-semibold bg-gray-50"> <tr class="font-semibold bg-gray-50">
<td colspan="3" class="text-right px-2 py-1">합계</td> <td colspan="3" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="detail-sum-qty">0</td> <td class="text-right px-2 py-1" id="detail-sum-qty">0</td>
<td class="text-right px-2 py-1" id="detail-sum-packed">0</td>
<td class="text-right px-2 py-1" id="detail-sum-amount">0</td> <td class="text-right px-2 py-1" id="detail-sum-amount">0</td>
<td class="text-right px-2 py-1" id="detail-sum-pack">박스=0, 팩=0, 낱장=0</td> <td class="text-right px-2 py-1" id="detail-sum-pack">박스=0, 팩=0, 낱장=0</td>
</tr> </tr>
@@ -86,22 +112,125 @@
</table> </table>
</div> </div>
<div class="flex gap-2"> <!-- 봉투코드 스캔 → 즉시 판매·포장 처리 (선택된 주문의 판매소로 판매) -->
<button type="submit" id="btn-save" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button> <div class="mt-3 border border-gray-300 rounded-lg p-2 bg-gray-50">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 whitespace-nowrap"><i class="fa-solid fa-barcode text-emerald-600 mr-1"></i>봉투코드 스캔</span>
<input type="text" id="scan-input" autocomplete="off" placeholder="봉투 바코드를 스캔/입력 후 Enter" class="border border-gray-300 rounded px-2 py-1 text-sm flex-1 min-w-[12rem]" disabled/>
<span id="scan-msg" class="text-xs text-gray-500"></span>
</div> </div>
</div>
<div class="mt-3">
<div class="flex items-center justify-between px-1 mb-1 text-sm">
<span class="font-semibold text-gray-700">스캔 현황</span>
<span class="flex items-center gap-2">
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
<button type="button" id="btn-packing-modal" class="border border-gray-300 text-gray-600 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 disabled:opacity-50" disabled>포장 명세 보기</button>
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<!-- 최근 스캔 2건 -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
<div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</div>
</div>
<!-- 앞으로 찍을 목록(품목별 남은 수량) -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
</div>
</div>
</div><!-- /스캔 현황 -->
</form> </form>
<form id="order-cancel-form" method="POST" class="px-3 pb-3"> <!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
<form id="order-cancel-form" method="POST" class="hidden">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" id="btn-cancel-order" class="border border-red-300 text-red-600 px-5 py-1.5 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
</form> </form>
</section> </section>
</div> </div>
<!-- 포장 명세(전체 봉투코드) 팝업 -->
<div id="packing-modal-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<span class="text-sm font-bold text-gray-800">포장 명세 — <span id="packing-modal-title">-</span></span>
<button type="button" id="packing-modal-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">&times;</button>
</div>
<div class="p-3 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="w-40 text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="w-16 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-modal-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-between">
<span class="text-xs text-gray-500">총 <span id="packing-modal-count">0</span>건 · 포장량 <span id="packing-modal-total">0</span></span>
<button type="button" id="packing-modal-close2" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
</div>
</div>
</div>
<!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) -->
<div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm">
<p class="text-[11px] text-amber-900 mb-1 leading-relaxed">
<strong class="text-amber-950">[개발용 임시]</strong> 선택 주문의 판매소 기준 판매 테스트용 바코드 후보. <strong>바코드를 클릭하면 봉투코드 스캔칸에 입력</strong>됩니다. 개발 완료 후 이 블록과 API를 제거하세요.
</p>
<div class="max-h-52 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="w-28 text-left">출처</th>
<th class="w-44 text-center">바코드(대표)</th>
<th class="text-left">봉투 종류</th>
<th class="w-20 text-center">포장</th>
<th class="w-12 text-right">수량</th>
<th class="w-14 text-center">상태</th>
<th class="text-left">비고(낱장범위 등)</th>
</tr>
</thead>
<tbody id="dev-saleable-tbody">
<tr><td colspan="7" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>
</tbody>
</table>
</div>
</div>
<script> <script>
(() => { (() => {
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>; const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o])); const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
// 표시용 접수번호: 접수일(so_order_date)별로 매일 1번부터 순차 부여(그 날의 가장 오래된 건 = 1).
// 정렬과 무관하게 고정.
const dateKey = (o) => String(o && o.so_order_date ? o.so_order_date : '').slice(0, 10);
const displayNoMap = new Map();
(() => {
const perDate = {};
orders.slice().sort((a, b) => Number(a.so_idx || 0) - Number(b.so_idx || 0)).forEach((o) => {
const k = dateKey(o);
perDate[k] = (perDate[k] || 0) + 1;
displayNoMap.set(String(o.so_idx), perDate[k]);
});
})();
const displayNo = (o) => displayNoMap.get(String(o && o.so_idx)) || '';
// 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신)
// base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전
const scanApi = new URL('<?= base_url('bag/order/phone/manage/scan') ?>', window.location.href).pathname;
// [개발용 임시] 판매 가능 바코드 후보 API (지정판매소 판매의 dev API 재사용)
const devSaleableApi = new URL('<?= base_url('bag/sale/designated/dev-saleable-barcodes') ?>', window.location.href).pathname;
const csrfName = '<?= csrf_token() ?>';
let csrfHash = '<?= csrf_hash() ?>';
const listBody = document.getElementById('order-list-body'); const listBody = document.getElementById('order-list-body');
const form = document.getElementById('order-detail-form'); const form = document.getElementById('order-detail-form');
@@ -110,16 +239,87 @@
const inputSoIdx = document.getElementById('detail-so-idx'); const inputSoIdx = document.getElementById('detail-so-idx');
const btnSave = document.getElementById('btn-save'); const btnSave = document.getElementById('btn-save');
const btnCancel = document.getElementById('btn-cancel-order'); const btnCancel = document.getElementById('btn-cancel-order');
const btnReceipt = document.getElementById('btn-receipt');
const btnTogglePaid = document.getElementById('btn-toggle-paid');
const paidApi = new URL('<?= base_url('bag/order/phone/manage/paid') ?>', window.location.href).pathname;
const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>';
btnReceipt?.addEventListener('click', () => {
const id = inputSoIdx.value;
if (!id) return;
const o = orderMap.get(String(id));
// 이미 포장(수령)이 완료된 건의 영수증 재발행 시 안내
if (o && Number(o.so_received) === 1) {
if (!confirm('포장이 완료된 건입니다.\n그래도 영수증을 다시 출력할까요?')) return;
}
window.open(receiptBase + '/' + encodeURIComponent(id), '_blank');
});
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0); const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
function paintPaidButton(order) {
if (!btnTogglePaid) return;
const paid = order && Number(order.so_paid) === 1;
btnTogglePaid.textContent = paid ? '입금확인 취소' : '입금확인';
btnTogglePaid.className = paid
? 'border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50'
: 'border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50';
}
btnTogglePaid?.addEventListener('click', async () => {
const id = inputSoIdx.value;
if (!id) return;
const o = orderMap.get(String(id));
if (!o) return;
const nextPaid = Number(o.so_paid) === 1 ? 0 : 1;
if (nextPaid === 0 && !confirm('입금확인을 취소하면 이 주문은 포장(스캔)이 차단됩니다.\n계속할까요?')) return;
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('so_idx', String(id));
body.set('paid', String(nextPaid));
btnTogglePaid.disabled = true;
try {
const res = await fetch(paidApi, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body });
const data = await res.json();
if (data && data.csrf) csrfHash = data.csrf;
if (!data || !data.ok) { alert((data && data.message) || '처리에 실패했습니다.'); btnTogglePaid.disabled = false; return; }
o.so_paid = nextPaid;
setHeader(o);
paintPaidButton(o);
btnTogglePaid.disabled = false;
renderList(); // 리스트의 입금 표시 갱신
} catch (e) {
alert('네트워크 오류로 처리하지 못했습니다.');
btnTogglePaid.disabled = false;
}
});
function setHeader(order) { function setHeader(order) {
document.getElementById('detail-so-no').textContent = order ? String(order.so_idx || '-') : '-'; var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
document.getElementById('detail-shop-name').textContent = order ? (order.so_ds_name || '-') : '-'; t('detail-so-no', order ? displayNo(order) : '');
document.getElementById('detail-payment').textContent = order ? (order.so_payment_type || '-') : '-'; t('detail-shop-code', order ? order.ds_shop_no : '');
document.getElementById('detail-order-date').textContent = order ? (order.so_order_date || '-') : '-'; t('detail-shop-name', order ? order.so_ds_name : '');
document.getElementById('detail-delivery-date').textContent = order ? (order.so_delivery_date || '-') : '-'; t('detail-shop-rep', order ? order.ds_rep_name : '');
document.getElementById('detail-status').textContent = order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '-'; t('detail-shop-tel', order ? order.ds_tel : '');
t('detail-payment', order ? order.so_payment_type : '');
t('detail-shop-addr', order ? order.ds_addr : '');
t('detail-order-date', order ? order.so_order_date : '');
t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '');
var ps = document.getElementById('detail-paid-status');
if (ps) {
var paid = order && Number(order.so_paid) === 1;
ps.textContent = order ? (paid ? '입금완료' : '미입금') : '-';
ps.style.color = order ? (paid ? '#059669' : '#e11d48') : '';
}
// 배달일 — 수정 가능한 date 입력
var dd = document.getElementById('detail-delivery-date-input');
if (dd) {
var v = order && order.so_delivery_date ? String(order.so_delivery_date) : '';
dd.value = /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : '';
dd.disabled = !order || order.so_status === 'cancelled';
// 배달일은 접수일 이후로만 선택 가능
var od = order && order.so_order_date ? String(order.so_order_date).slice(0, 10) : '';
if (/^\d{4}-\d{2}-\d{2}$/.test(od)) { dd.min = od; } else { dd.removeAttribute('min'); }
}
} }
function calcRow(tr) { function calcRow(tr) {
@@ -148,14 +348,18 @@
sheet = qty % packSheets; sheet = qty % packSheets;
} }
const packedCell = tr.querySelector('.item-packed-cell');
const packed = packedCell ? Math.max(0, parseInt((packedCell.textContent || '0').replace(/[^0-9]/g, '') || '0', 10) || 0) : 0;
const amount = qty * unitPrice; const amount = qty * unitPrice;
tr.querySelector('.item-amount-cell').textContent = nf(amount); tr.querySelector('.item-amount-cell').textContent = nf(amount);
tr.querySelector('.item-pack-cell').textContent = `박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}`; tr.querySelector('.item-pack-cell').textContent = `박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}`;
return { qty, amount, box, pack, sheet }; return { qty, packed, amount, box, pack, sheet };
} }
function recalcTotals() { function recalcTotals() {
let sumQty = 0; let sumQty = 0;
let sumPacked = 0;
let sumAmount = 0; let sumAmount = 0;
let sumBox = 0; let sumBox = 0;
let sumPack = 0; let sumPack = 0;
@@ -164,6 +368,7 @@
detailBody.querySelectorAll('tr.order-item-row').forEach((tr) => { detailBody.querySelectorAll('tr.order-item-row').forEach((tr) => {
const r = calcRow(tr); const r = calcRow(tr);
sumQty += r.qty; sumQty += r.qty;
sumPacked += r.packed;
sumAmount += r.amount; sumAmount += r.amount;
sumBox += r.box; sumBox += r.box;
sumPack += r.pack; sumPack += r.pack;
@@ -171,6 +376,7 @@
}); });
document.getElementById('detail-sum-qty').textContent = nf(sumQty); document.getElementById('detail-sum-qty').textContent = nf(sumQty);
document.getElementById('detail-sum-packed').textContent = nf(sumPacked);
document.getElementById('detail-sum-amount').textContent = nf(sumAmount); document.getElementById('detail-sum-amount').textContent = nf(sumAmount);
document.getElementById('detail-sum-pack').textContent = `박스=${nf(sumBox)}, 팩=${nf(sumPack)}, 낱장=${nf(sumSheet)}`; document.getElementById('detail-sum-pack').textContent = `박스=${nf(sumBox)}, 팩=${nf(sumPack)}, 낱장=${nf(sumSheet)}`;
} }
@@ -186,10 +392,14 @@
const isCancelled = order.so_status === 'cancelled'; const isCancelled = order.so_status === 'cancelled';
btnSave.disabled = isCancelled; btnSave.disabled = isCancelled;
btnCancel.disabled = isCancelled; btnCancel.disabled = isCancelled;
if (btnReceipt) btnReceipt.disabled = false;
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
renderPacking(order); // 포장 명세 탭 동기화
const items = Array.isArray(order.items) ? order.items : []; const items = Array.isArray(order.items) ? order.items : [];
if (items.length === 0) { if (items.length === 0) {
detailBody.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>'; detailBody.innerHTML = '<tr><td colspan="7" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
recalcTotals(); recalcTotals();
return; return;
} }
@@ -198,6 +408,7 @@
const itemId = String(item.soi_idx || ''); const itemId = String(item.soi_idx || '');
const bagName = `${item.soi_bag_code || ''} ${item.soi_bag_name || ''}`.trim(); const bagName = `${item.soi_bag_code || ''} ${item.soi_bag_name || ''}`.trim();
const qty = parseInt(item.soi_qty || 0, 10) || 0; const qty = parseInt(item.soi_qty || 0, 10) || 0;
const packedQty = parseInt(item.soi_packed_qty || 0, 10) || 0;
const unitPrice = parseInt(item.soi_unit_price || 0, 10) || 0; const unitPrice = parseInt(item.soi_unit_price || 0, 10) || 0;
const amount = parseInt(item.soi_amount || 0, 10) || 0; const amount = parseInt(item.soi_amount || 0, 10) || 0;
const box = parseInt(item.soi_box_count || 0, 10) || 0; const box = parseInt(item.soi_box_count || 0, 10) || 0;
@@ -214,6 +425,7 @@
<td class="text-right pr-2"> <td class="text-right pr-2">
<input type="number" min="0" class="item-qty-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_qty[${itemId}]" value="${qty}" ${isCancelled ? 'disabled' : ''}/> <input type="number" min="0" class="item-qty-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_qty[${itemId}]" value="${qty}" ${isCancelled ? 'disabled' : ''}/>
</td> </td>
<td class="text-right pr-2 item-packed-cell font-semibold ${packedQty >= qty && qty > 0 ? 'text-emerald-600' : 'text-gray-700'}" data-bag-code="${esc(item.soi_bag_code || '')}">${nf(packedQty)}</td>
<td class="text-right pr-2 item-amount-cell">${nf(amount)}</td> <td class="text-right pr-2 item-amount-cell">${nf(amount)}</td>
<td class="text-right pr-2 item-pack-cell">박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}</td> <td class="text-right pr-2 item-pack-cell">박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}</td>
</tr> </tr>
@@ -221,18 +433,381 @@
}).join(''); }).join('');
recalcTotals(); recalcTotals();
// 봉투코드 스캔 입력: 정상 주문일 때만 활성화
const scanInput = document.getElementById('scan-input');
if (scanInput) {
scanInput.disabled = isCancelled;
const msg = document.getElementById('scan-msg');
if (msg) msg.textContent = '';
} }
// [개발용 임시] 선택 주문 판매소 기준 후보 바코드 표
loadDevSaleable(order.so_ds_idx, order.so_idx);
syncListHeight(); // 상세 카드 높이 확정 후 접수 리스트 높이를 맞춘다
}
// [개발용 임시] 스캔 가능 바코드 후보 로딩(선택 주문 판매소·주문 기준)
const devPanelEl = document.getElementById('dev-saleable-panel');
const devTbodyEl = document.getElementById('dev-saleable-tbody');
async function loadDevSaleable(dsIdx, soIdx) {
if (!devPanelEl || !devTbodyEl) return;
const idx = Number(dsIdx || 0);
if (!idx) { devPanelEl.classList.add('hidden'); return; }
devPanelEl.classList.remove('hidden');
devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
try {
let url = devSaleableApi + '?ds_idx=' + encodeURIComponent(String(idx));
if (Number(soIdx || 0) > 0) url += '&so_idx=' + encodeURIComponent(String(soIdx));
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok) { devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-red-600 py-4">' + esc(data.message || '조회 실패') + '</td></tr>'; return; }
const rows = Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) { devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 바코드가 없습니다.</td></tr>'; return; }
devTbodyEl.innerHTML = rows.map((r) => {
const code = String(r.code || '');
const codeCell = (code && code !== '-')
? '<button type="button" class="dev-pick text-blue-600 hover:underline font-mono" data-code="' + esc(code) + '">' + esc(code) + '</button>'
: '<span class="text-gray-400">' + esc(code) + '</span>';
return '<tr>'
+ '<td class="text-left pl-1">' + esc(r.source || '') + '</td>'
+ '<td class="text-center">' + codeCell + '</td>'
+ '<td class="text-left pl-1">' + esc((r.bag_code || '') + ' ' + (r.bag_name || '')) + '</td>'
+ '<td class="text-center">' + esc(r.unit || '') + '</td>'
+ '<td class="text-right pr-1">' + nf(r.qty || 0) + '</td>'
+ '<td class="text-center">' + esc(r.state || '') + '</td>'
+ '<td class="text-left pl-1">' + esc(r.extra || '') + '</td>'
+ '</tr>';
}).join('');
} catch (e) {
devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-red-600 py-4">네트워크 오류</td></tr>';
}
}
// 후보 바코드 클릭 → 스캔칸에 입력(개발 편의)
devTbodyEl?.addEventListener('click', (e) => {
const btn = e.target.closest('.dev-pick');
if (!btn) return;
const inp = document.getElementById('scan-input');
if (inp && !inp.disabled) { inp.value = btn.getAttribute('data-code') || ''; inp.focus(); }
});
// 봉투코드 스캔 → 즉시 판매·포장 처리 후 화면 갱신
const scanInputEl = document.getElementById('scan-input');
const scanMsgEl = document.getElementById('scan-msg');
function setScanMsg(text, isErr) {
if (!scanMsgEl) return;
scanMsgEl.textContent = text || '';
scanMsgEl.className = 'text-xs ' + (isErr ? 'text-red-600 font-semibold' : 'text-emerald-600');
}
async function submitScan() {
if (!scanInputEl) return;
const code = (scanInputEl.value || '').trim();
if (!code) return;
if (!selectedId) { setScanMsg('먼저 접수 리스트에서 주문을 선택해 주세요.', true); return; }
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('so_idx', String(selectedId));
body.set('barcode', code);
let data;
try {
const res = await fetch(scanApi, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
credentials: 'same-origin',
body: body.toString(),
});
data = await res.json();
} catch (e) {
setScanMsg('통신 오류가 발생했습니다.', true);
return;
}
if (data && data.csrf) csrfHash = data.csrf; // 다음 스캔용 토큰 갱신
if (!data || !data.ok) {
setScanMsg((data && data.message) || '스캔 처리 실패', true);
scanInputEl.select();
return;
}
// 선택된 주문 객체에 스캔 반영(재선택 시 유지)
const order = orderMap.get(String(data.so_idx));
if (order) {
if (!Array.isArray(order.scans)) order.scans = [];
order.scans.push({ bag_code: data.bag_code, bag_name: data.bag_name, code: data.code, unit: data.unit, qty: Number(data.qty || 0) });
const it = (order.items || []).find((x) => String(x.soi_bag_code) === String(data.bag_code));
if (it) it.soi_packed_qty = Number(data.packed_qty || 0);
order.so_received = Number(data.so_received || 0);
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
if (String(selectedId) === String(data.so_idx)) {
renderPacking(order);
const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]');
if (cell) {
cell.textContent = nf(Number(data.packed_qty || 0));
const row = cell.closest('tr');
const qtyInp = row ? row.querySelector('.item-qty-input') : null;
const rq = qtyInp ? (parseInt(qtyInp.value || '0', 10) || 0) : 0;
cell.classList.toggle('text-emerald-600', Number(data.packed_qty || 0) >= rq && rq > 0);
cell.classList.toggle('text-gray-700', !(Number(data.packed_qty || 0) >= rq && rq > 0));
}
recalcTotals();
}
// 접수 리스트의 '포장' 표시(O/X) 갱신
renderList();
// [개발용 임시] 후보 목록 갱신(방금 판매된 팩 제외)
loadDevSaleable(order.so_ds_idx, order.so_idx);
}
scanInputEl.value = '';
scanInputEl.focus();
setScanMsg('등록: ' + data.bag_code + ' / ' + data.unit + ' / ' + nf(data.qty) + ' (잔량 ' + nf(data.remain) + ')', false);
}
scanInputEl?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submitScan(); }
});
// 접수 리스트 스크롤 높이를 지정판매소 정보(상세) 카드 높이에 맞춘다.
// offsetHeight는 화면 zoom(텍스트 줄이기)과 무관한 레이아웃 px라, 배율과 상관없이 두 카드가 같은 높이가 되고 하단 공백이 생기지 않는다.
const listScrollEl = document.getElementById('list-scroll');
const detailCardEl = document.getElementById('detail-card');
function syncListHeight() {
if (!listScrollEl || !detailCardEl) return;
const listCard = listScrollEl.closest('section');
const header = listCard ? listCard.firstElementChild : null;
const summary = document.getElementById('list-summary');
listScrollEl.style.height = '0px'; // 리스트를 접어 상세 카드의 본래 높이를 측정
const target = detailCardEl.offsetHeight
- (header ? header.offsetHeight : 0)
- (summary ? summary.offsetHeight : 0);
listScrollEl.style.height = Math.max(160, target) + 'px';
}
window.addEventListener('resize', syncListHeight);
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') syncListHeight(); });
if ('ResizeObserver' in window && detailCardEl) {
new ResizeObserver(function () { syncListHeight(); }).observe(detailCardEl);
}
// ── 접수 리스트 ↔ 지정판매소 정보 사이 너비 조절(드래그 스플리터) ──────
// 손잡이를 접수 리스트 쪽(왼쪽)으로 끌면 리스트 폭이 줄고 지정판매소 정보 폭이 넓어진다.
const splitEl = document.getElementById('order-split');
const listCardEl = document.getElementById('list-card');
const splitHandleEl = document.getElementById('split-handle');
const SPLIT_KEY = 'jrj_order_split_pct';
const SPLIT_MIN = 25, SPLIT_MAX = 75, SPLIT_DEFAULT = 55.5; // 기본값 ≈ 기존 5:4 비율
const xlMedia = window.matchMedia('(min-width: 1280px)');
function clampPct(p) { return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, p)); }
function currentPct() {
const saved = parseFloat(localStorage.getItem(SPLIT_KEY) || '');
return clampPct(isFinite(saved) ? saved : SPLIT_DEFAULT);
}
function applySplit(pct) {
if (!listCardEl) return;
if (xlMedia.matches) {
// 좌우 배치일 때만 폭 지정(세로로 쌓이는 좁은 화면에서는 전체폭 사용)
listCardEl.style.flex = '0 0 ' + pct + '%';
listCardEl.style.maxWidth = pct + '%';
} else {
listCardEl.style.flex = '';
listCardEl.style.maxWidth = '';
}
syncListHeight();
}
if (splitHandleEl && splitEl && listCardEl) {
let dragging = false, rafId = 0, pendingPct = null;
function onMove(clientX) {
const rect = splitEl.getBoundingClientRect();
if (rect.width <= 0) return;
pendingPct = clampPct((clientX - rect.left) / rect.width * 100);
if (!rafId) {
rafId = requestAnimationFrame(function () {
rafId = 0;
if (pendingPct != null) applySplit(pendingPct);
});
}
}
function pointerMove(e) { if (dragging) { e.preventDefault(); onMove(e.clientX); } }
function pointerUp() {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
window.removeEventListener('pointermove', pointerMove);
window.removeEventListener('pointerup', pointerUp);
if (pendingPct != null) localStorage.setItem(SPLIT_KEY, String(Math.round(pendingPct * 10) / 10));
}
splitHandleEl.addEventListener('pointerdown', function (e) {
if (!xlMedia.matches) return; // 좁은 화면에서는 비활성
e.preventDefault();
dragging = true;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
window.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp);
});
// 더블클릭 시 기본 비율로 초기화
splitHandleEl.addEventListener('dblclick', function () {
localStorage.removeItem(SPLIT_KEY);
applySplit(SPLIT_DEFAULT);
});
// 브레이크포인트 전환 시 폭 지정 적용/해제
(xlMedia.addEventListener ? xlMedia.addEventListener('change', function () { applySplit(currentPct()); })
: xlMedia.addListener(function () { applySplit(currentPct()); }));
applySplit(currentPct());
}
// ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
const packingTotal = document.getElementById('packing-total');
const recentScansEl = document.getElementById('recent-scans');
const remainingListEl = document.getElementById('remaining-list');
const btnPackingModal = document.getElementById('btn-packing-modal');
const packingModalOverlay = document.getElementById('packing-modal-overlay');
const packingModalBody = document.getElementById('packing-modal-body');
const packingModalTitle = document.getElementById('packing-modal-title');
const packingModalCount = document.getElementById('packing-modal-count');
const packingModalTotal = document.getElementById('packing-modal-total');
let packingModalOrderId = null;
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
// 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
function scanFullRows(order) {
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
let no = 0, total = 0;
const rows = scans.map((s) => {
no++;
const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
total += qty;
return '<tr><td class="text-center">' + no + '</td>'
+ '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
});
return { html: rows.join(''), count: no, total: total };
}
function fillPackingModal(order) {
const full = scanFullRows(order);
if (packingModalBody) packingModalBody.innerHTML = full.count ? full.html
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingModalCount) packingModalCount.textContent = nf(full.count);
if (packingModalTotal) packingModalTotal.textContent = nf(full.total);
}
// 앞으로 찍을 목록 = 품목별 남은(접수량 포장량)
function renderRemaining(order) {
if (!remainingListEl) return;
const items = Array.isArray(order.items) ? order.items : [];
const rows = items.map((it) => {
const qty = parseInt(it.soi_qty || 0, 10) || 0;
const packed = parseInt(it.soi_packed_qty || 0, 10) || 0;
if (qty <= 0) return '';
const remain = Math.max(0, qty - packed);
const name = (String(it.soi_bag_code || '') + ' ' + String(it.soi_bag_name || '')).trim();
if (remain <= 0) {
return '<div class="flex items-center justify-between text-emerald-600 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap">✓ 완료</span></div>';
}
return '<div class="flex items-center justify-between text-gray-700 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap font-semibold text-amber-700">' + nf(remain) + ' 매</span></div>';
}).filter(Boolean);
remainingListEl.innerHTML = rows.length ? rows.join('') : '<span class="text-gray-400">남은 수량이 없습니다.</span>';
}
// 스캔 현황 갱신(최근 2건 + 남은 목록 + 포장량 + 열린 팝업)
function renderPacking(order) {
const scans = Array.isArray(order.scans) ? order.scans : [];
const full = scanFullRows(order);
if (packingTotal) packingTotal.textContent = nf(full.total);
if (btnPackingModal) btnPackingModal.disabled = false;
if (recentScansEl) {
const last = scans.slice(-2).reverse();
recentScansEl.innerHTML = last.length
? last.map((s, i) => '<div class="flex items-center justify-between py-0.5 ' + (i === 0 ? 'font-semibold text-emerald-700' : 'text-gray-500') + '">'
+ '<span class="truncate mr-2 font-mono">' + (i === 0 ? '▶ ' : '') + packEsc(s.code || '') + '</span>'
+ '<span class="whitespace-nowrap text-gray-500">' + packEsc(s.bag_name || '') + ' · ' + nf(parseInt(s.qty || 0, 10) || 0) + '</span></div>').join('')
: '<span class="text-gray-400">스캔 내역이 없습니다.</span>';
}
renderRemaining(order);
if (packingModalOverlay && !packingModalOverlay.classList.contains('hidden') && String(packingModalOrderId) === String(order.so_idx)) {
fillPackingModal(order);
}
}
// 포장 명세 팝업 열기/닫기
function openPackingModal() {
const order = orderMap.get(String(selectedId));
if (!order) return;
packingModalOrderId = selectedId;
if (packingModalTitle) packingModalTitle.textContent = '접수 #' + displayNo(order) + ' · ' + (order.so_ds_name || '');
fillPackingModal(order);
packingModalOverlay.classList.remove('hidden');
packingModalOverlay.classList.add('flex');
}
function closePackingModal() { packingModalOverlay.classList.add('hidden'); packingModalOverlay.classList.remove('flex'); }
if (btnPackingModal) btnPackingModal.addEventListener('click', openPackingModal);
document.getElementById('packing-modal-close')?.addEventListener('click', closePackingModal);
document.getElementById('packing-modal-close2')?.addEventListener('click', closePackingModal);
if (packingModalOverlay) packingModalOverlay.addEventListener('click', (e) => { if (e.target === packingModalOverlay) closePackingModal(); });
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
function rowHtml(o) {
const cancelled = o.so_status === 'cancelled';
const dDate = o.so_delivery_date ? String(o.so_delivery_date).slice(0, 10) : '';
const channel = (o.so_channel && o.so_channel !== 'phone') ? o.so_channel : '전화';
const packed = Number(o.so_received) === 1;
return `<tr class="order-list-row cursor-pointer hover:bg-blue-50 ${cancelled ? 'bg-gray-50 text-gray-400' : ''} ${String(o.so_idx) === String(selectedId) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
<td class="text-center">${displayNo(o)}</td>
<td class="text-center">${esc(o.so_order_date)}</td>
<td class="text-center">${esc(dDate)}</td>
<td class="text-center">${esc(channel)}</td>
<td class="text-left pl-2">${esc(o.so_ds_name)}</td>
<td class="text-center">${esc(o.so_payment_type || '-')}</td>
<td class="text-center" style="text-align:center;">${packed ? '<span style="color:#2563eb;font-weight:700;">O</span>' : '<span class="text-gray-400">X</span>'}</td>
<td class="text-center" style="text-align:center;">${Number(o.so_paid) === 1 ? '<span style="color:#e11d48;font-size:11px;" title="입금">●</span>' : ''}</td>
<td class="text-right pr-2">${nf(o.so_total_amount || 0)}</td>
<td class="text-center">${cancelled ? '취소' : '정상'}</td>
</tr>`;
}
function renderList() {
const th = document.querySelector('.sort-th[data-sort="' + sortKey + '"]');
const type = th ? th.dataset.type : 'str';
const arr = orders.slice().sort((a, b) => {
let va = a[sortKey], vb = b[sortKey];
if (type === 'num') { va = Number(va || 0); vb = Number(vb || 0); return sortDir === 'asc' ? va - vb : vb - va; }
va = String(va || ''); vb = String(vb || '');
return sortDir === 'asc' ? va.localeCompare(vb, 'ko') : vb.localeCompare(va, 'ko');
});
listBody.innerHTML = arr.length
? arr.map(rowHtml).join('')
: '<tr><td colspan="10" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td></tr>';
document.querySelectorAll('.sort-th .sort-ind').forEach((s) => { s.textContent = ''; });
if (th) th.querySelector('.sort-ind').textContent = sortDir === 'asc' ? ' ▲' : ' ▼';
// 하단 합계 — 취소 주문은 제외
const normals = arr.filter((o) => o.so_status !== 'cancelled');
const sumCountEl = document.getElementById('list-sum-count');
const sumAmountEl = document.getElementById('list-sum-amount');
if (sumCountEl) sumCountEl.textContent = nf(normals.length);
if (sumAmountEl) sumAmountEl.textContent = nf(normals.reduce((s, o) => s + (Number(o.so_total_amount) || 0), 0));
}
document.querySelectorAll('.sort-th').forEach((th) => {
th.addEventListener('click', () => {
const k = th.dataset.sort;
if (sortKey === k) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = k; sortDir = 'asc'; }
renderList();
});
});
listBody?.addEventListener('click', (e) => { listBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.order-list-row'); const tr = e.target.closest('.order-list-row');
if (!tr) return; if (!tr) return;
selectedId = tr.dataset.orderId;
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100')); listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100'); tr.classList.add('bg-blue-100');
renderDetail(tr.dataset.orderId); renderDetail(selectedId);
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
}); });
detailBody?.addEventListener('input', (e) => { detailBody?.addEventListener('input', (e) => {
if (e.target.closest('.item-qty-input')) { if (e.target.closest('.item-qty-input') || e.target.closest('.item-packed-input')) {
recalcTotals(); recalcTotals();
} }
}); });
@@ -250,11 +825,14 @@
} }
}); });
renderList();
const firstRow = listBody?.querySelector('.order-list-row'); const firstRow = listBody?.querySelector('.order-list-row');
if (firstRow) { if (firstRow) {
selectedId = firstRow.dataset.orderId;
firstRow.classList.add('bg-blue-100'); firstRow.classList.add('bg-blue-100');
renderDetail(firstRow.dataset.orderId); renderDetail(selectedId);
} }
syncListHeight();
})(); })();
</script> </script>

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* 입금자 영수증 출력용 (독립 인쇄 화면).
* @var object $order
* @var list<object> $items
* @var array|null $shop
* @var string $lgName
*/
$nf = static fn ($n): string => number_format((int) $n);
$paid = (int) ($order->so_paid ?? 0) === 1;
$addr = $shop ? trim((string) ($shop['ds_addr'] ?? '') . ' ' . (string) ($shop['ds_addr_detail'] ?? '')) : '';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>입금 영수증 #<?= (int) $order->so_idx ?></title>
<style>
* { box-sizing: border-box; }
body { font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif; color: #111; margin: 0; padding: 24px; }
.receipt { max-width: 620px; margin: 0 auto; }
h1 { text-align: center; font-size: 1.5rem; letter-spacing: 8px; margin: 0 0 4px; }
.sub { text-align: center; color: #555; font-size: .85rem; margin-bottom: 18px; }
.meta { width: 100%; border-collapse: collapse; margin-bottom: 14px; font-size: .85rem; }
.meta th { text-align: left; width: 90px; color: #555; padding: 3px 6px; vertical-align: top; }
.meta td { padding: 3px 6px; }
table.items { width: 100%; border-collapse: collapse; font-size: .82rem; }
table.items th, table.items td { border: 1px solid #bbb; padding: 5px 7px; }
table.items th { background: #f1f3f5; }
table.items td.num { text-align: right; }
tfoot td { font-weight: bold; background: #fafafa; }
.paidbox { margin-top: 16px; text-align: center; font-size: 1rem; font-weight: bold; }
.paid { color: #c0392b; }
.unpaid { color: #888; }
.foot { margin-top: 26px; text-align: center; color: #666; font-size: .75rem; }
.noprint { text-align: center; margin: 18px 0; }
@media print { .noprint { display: none; } body { padding: 0; } }
</style>
</head>
<body>
<div class="receipt">
<h1>영 수 증</h1>
<div class="sub"><?= esc($lgName) ?> 종량제 봉투 · 전화 접수 입금 영수증</div>
<table class="meta">
<tr><th>접수번호</th><td>#<?= (int) $order->so_idx ?></td><th>접수일</th><td><?= esc((string) ($order->so_order_date ?? '')) ?></td></tr>
<tr><th>판매소</th><td><?= esc($shop['ds_name'] ?? (string) ($order->so_ds_name ?? '')) ?> <?= $shop ? '(' . esc((string) $shop['ds_shop_no']) . ')' : '' ?></td><th>배달일</th><td><?= esc((string) ($order->so_delivery_date ?? '')) ?></td></tr>
<tr><th>담당자</th><td><?= esc($shop['ds_rep_name'] ?? '') ?></td><th>전화</th><td><?= esc($shop['ds_tel'] ?? '') ?></td></tr>
<tr><th>주소</th><td colspan="3"><?= esc($addr) ?></td></tr>
<tr><th>결제수단</th><td><?= esc((string) ($order->so_payment_type ?? '')) ?></td><th>입금</th><td><?= $paid ? '입금완료' : '미입금' ?></td></tr>
</table>
<table class="items">
<thead>
<tr><th>품목</th><th>단가</th><th>수량</th><th>금액</th></tr>
</thead>
<tbody>
<?php $sumQty = 0; $sumAmt = 0; foreach (($items ?? []) as $it): ?>
<?php $q = (int) ($it->soi_qty ?? 0); $a = (int) ($it->soi_amount ?? 0); $sumQty += $q; $sumAmt += $a; ?>
<tr>
<td><?= esc((string) ($it->soi_bag_code ?? '')) ?> <?= esc((string) ($it->soi_bag_name ?? '')) ?></td>
<td class="num"><?= $nf($it->soi_unit_price ?? 0) ?></td>
<td class="num"><?= $nf($q) ?></td>
<td class="num"><?= $nf($a) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($items)): ?>
<tr><td colspan="4" style="text-align:center;color:#999;">품목 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr><td>합계</td><td></td><td class="num"><?= $nf($sumQty) ?></td><td class="num"><?= $nf($sumAmt) ?></td></tr>
</tfoot>
</table>
<div class="paidbox <?= $paid ? 'paid' : 'unpaid' ?>"><?= $paid ? '● 입금 완료' : '미입금' ?></div>
<div class="foot">출력일: <?= date('Y-m-d H:i') ?></div>
<div class="noprint">
<button type="button" onclick="window.print()" style="padding:8px 18px;font-size:.9rem;cursor:pointer;">인쇄</button>
<button type="button" onclick="window.close()" style="padding:8px 18px;font-size:.9rem;cursor:pointer;margin-left:6px;">닫기</button>
</div>
</div>
<script>window.addEventListener('load', function () { setTimeout(function () { window.print(); }, 300); });</script>
</body>
</html>

View File

@@ -1,148 +1,360 @@
<?php
$mode = (string) ($mode ?? 'scanner');
$isBatch = (bool) ($isBatch ?? false);
$tab = (string) ($tab ?? 'company');
$companies = is_array($companies ?? null) ? $companies : [];
$agencies = is_array($agencies ?? null) ? $agencies : [];
$rows = is_array($rows ?? null) ? $rows : [];
$monthOptions = is_array($monthOptions ?? null) ? $monthOptions : [];
$receiverOptions = is_array($receiverOptions ?? null) ? $receiverOptions : [];
$senders = is_array($senders ?? null) ? $senders : [];
$companyIdx = (int) ($companyIdx ?? 0);
$agencyIdx = (int) ($agencyIdx ?? 0);
$baseUrl = $isBatch ? base_url('bag/receiving/batch') : base_url('bag/receiving/scanner');
$otherUrl = $isBatch ? base_url('bag/receiving/scanner') : base_url('bag/receiving/batch');
$otherLabel = $isBatch ? '발주 입고(스캐너)' : '일괄입고(음식물,폐기물)';
$tabUrl = static function (string $t) use ($baseUrl): string {
return $baseUrl . '?' . http_build_query(['tab' => $t]);
};
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">발주 입고(스캐너 대체 수동입력)</span> <span class="text-sm font-bold text-gray-700"><?= esc((string) ($workspaceTitle ?? '발주 입고')) ?></span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="<?= base_url('bag/receiving/batch') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">일괄 입고</a> <a href="<?= esc($otherUrl) ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50"><?= esc($otherLabel) ?></a>
<a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a> <a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a>
</div> </div>
</section> </section>
<?php /* 플래시 메시지는 레이아웃(embed/portal)에서 한 번만 표시 — 중복 출력 방지 */ ?> <div class="grid grid-cols-1 xl:grid-cols-12 gap-3 mt-2">
<!-- 좌: 발주 현황 -->
<section class="xl:col-span-7 border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">발주 현황</div>
<section class="p-2 bg-white border border-gray-300 rounded-lg mt-2"> <!-- 조회 기준 탭 -->
<form method="get" action="<?= base_url('bag/receiving/scanner') ?>" class="flex flex-wrap items-end gap-2"> <div class="flex border-b border-gray-200 text-sm">
<div class="flex flex-col min-w-[14rem] max-w-[22rem]"> <a href="<?= esc($tabUrl('company')) ?>" class="px-4 py-2 border-b-2 <?= $tab === 'company' ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700' ?>">제작업체 · 발주기간</a>
<label class="text-xs text-gray-500 mb-1">제작업체</label> <a href="<?= esc($tabUrl('agency')) ?>" class="px-4 py-2 border-b-2 <?= $tab === 'agency' ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700' ?>">인수자 · 인계자</a>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1.5 text-sm bg-white"> </div>
<option value="0">제작업체 선택</option>
<?php foreach (($companies ?? []) as $company): ?> <!-- 조회 필터 -->
<option value="<?= (int) ($company->cp_idx ?? 0) ?>" <?= (int) ($companyIdx ?? 0) === (int) ($company->cp_idx ?? 0) ? 'selected' : '' ?>> <form method="get" action="<?= esc($baseUrl) ?>" class="p-2 flex flex-wrap items-end gap-2 border-b border-gray-100 text-sm">
<?= esc((string) ($company->cp_name ?? '')) ?> <input type="hidden" name="tab" value="<?= esc($tab) ?>"/>
</option> <?php if ($tab === 'company'): ?>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">제작업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($companies as $c): ?>
<option value="<?= (int) ($c->cp_idx ?? 0) ?>" <?= $companyIdx === (int) ($c->cp_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($c->cp_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">발주기간(시작월)</label>
<select name="start_month" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($monthOptions as $opt): ?>
<option value="<?= esc((string) $opt['value']) ?>" <?= (string) ($startMonth ?? '') === (string) $opt['value'] ? 'selected' : '' ?>><?= esc((string) $opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">종료월</label>
<select name="end_month" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($monthOptions as $opt): ?>
<option value="<?= esc((string) $opt['value']) ?>" <?= (string) ($endMonth ?? '') === (string) $opt['value'] ? 'selected' : '' ?>><?= esc((string) $opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php else: ?>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">인수자(대행소)</label>
<select name="agency_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($agencies as $a): ?>
<option value="<?= (int) ($a->sa_idx ?? 0) ?>" <?= $agencyIdx === (int) ($a->sa_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($a->sa_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">인계자(제작업체)</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($companies as $c): ?>
<option value="<?= (int) ($c->cp_idx ?? 0) ?>" <?= $companyIdx === (int) ($c->cp_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($c->cp_name ?? '')) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shrink-0">조회</button>
</form>
<?php if ((int) ($companyIdx ?? 0) <= 0): ?>
<p class="text-xs text-gray-600 mt-2">제작업체를 선택하면 해당 업체 발주 중 미입고 내역을 조회합니다.</p>
<?php elseif (empty($rows ?? [])): ?>
<p class="text-xs text-amber-700 mt-2">미입고 잔량이 있는 발주가 없습니다. 발주 등록 후 다시 확인해 주세요.</p>
<?php endif; ?> <?php endif; ?>
</section> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
<form action="<?= base_url('bag/receiving/scanner/store') ?>" method="post" class="mt-2 space-y-2"> <div class="overflow-auto max-h-[60vh]">
<?= csrf_field() ?> <table class="w-full data-table text-sm" id="order-status-table">
<input type="hidden" name="company_idx" value="<?= (int) ($companyIdx ?? 0) ?>" />
<section class="p-2 bg-white border border-gray-300 rounded-lg">
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-end">
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인수자 (대행소)</label>
<select name="br_receiver_ref" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required>
<?php foreach (($receiverOptions ?? []) as $opt): ?>
<option value="<?= esc((string) ($opt['ref'] ?? '')) ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>>
<?= esc((string) ($opt['label'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인계자 (제작업체)</label>
<select name="br_sender_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-full">
<?php foreach (($senders ?? []) as $sender): ?>
<option value="<?= (int) ($sender->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($sender->mg_idx ?? 0) ? 'selected' : '' ?>>
<?= esc((string) ($sender->mg_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-2 flex items-center gap-2">
<label class="text-sm text-gray-600 w-16">입고일</label>
<input type="date" name="br_receive_date" value="<?= esc((string) old('br_receive_date', date('Y-m-d'))) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required />
</div>
<div class="xl:col-span-2">
<button type="submit" class="w-full border border-blue-600 text-blue-700 px-2 py-1 rounded-sm text-sm hover:bg-blue-50">입고 처리</button>
</div>
<div class="xl:col-span-2 text-xs text-gray-500">상단에서 제작업체를 조회한 뒤, 아래에서 입고량(매)을 입력해 저장합니다.</div>
</div>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto bg-white">
<table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="text-center">발주일자</th> <th class="text-center sort-th cursor-pointer select-none" data-sort="order_date" data-type="str" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">발주일자<span class="sort-ind"></span></th>
<th>봉투종류</th> <th class="text-left sort-th cursor-pointer select-none" data-sort="bag_name" data-type="str" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류<span class="sort-ind"></span></th>
<th class="text-right">발주량(매)</th> <th class="text-right sort-th cursor-pointer select-none" data-sort="order_qty" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">발주량<span class="sort-ind"></span></th>
<th class="text-right">미입고량(매)</th> <th class="text-right sort-th cursor-pointer select-none" data-sort="pending" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">미입고량<span class="sort-ind"></span></th>
<th class="text-right">입고량(매)</th> <th class="text-right sort-th cursor-pointer select-none" data-sort="received" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">입고량<span class="sort-ind"></span></th>
<th>제작업체</th> <th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">제작업체</th>
<th class="text-center">LOT NO</th> <th class="text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">LOT</th>
<th class="text-center">발주NO</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="order-status-body">
<?php foreach (($rows ?? []) as $row): ?> <?php foreach ($rows as $row): $k = (string) ($row['row_key'] ?? ''); ?>
<?php $k = (string) ($row['row_key'] ?? ''); ?> <tr class="os-row cursor-pointer hover:bg-blue-50"
<tr data-row-key="<?= esc($k) ?>" data-lot-no="<?= esc((string) ($row['lot_no'] ?? '')) ?>" data-bag-code="<?= esc((string) ($row['bag_code'] ?? '')) ?>" data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>" data-pack-per-sheet="<?= (int) ($row['pack_per_sheet'] ?? 1) ?>" data-pending-original="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>"> data-row-key="<?= esc($k, 'attr') ?>"
data-bag-name="<?= esc((string) ($row['bag_name'] ?? ''), 'attr') ?>"
data-company="<?= esc((string) ($row['company_name'] ?? ''), 'attr') ?>"
data-lot="<?= esc((string) ($row['lot_no'] ?? ''), 'attr') ?>"
data-order-qty="<?= (int) ($row['order_qty_sheet'] ?? 0) ?>"
data-pending="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>"
data-received="<?= (int) ($row['received_qty_sheet'] ?? 0) ?>"
data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>">
<td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td> <td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td> <td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-right"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td> <td class="text-right pr-2"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
<td class="text-right pending-cell"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td> <td class="text-right pr-2 cell-pending"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td>
<td class="text-right"> <td class="text-right pr-2 cell-received"><?= number_format((int) ($row['received_qty_sheet'] ?? 0)) ?></td>
<input type="number" min="0" max="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>" name="receive_qty_sheet[<?= esc($k) ?>]" value="<?= esc((string) old('receive_qty_sheet.' . $k, '0')) ?>" class="w-24 border border-gray-300 rounded px-1 py-0.5 text-sm text-right receive-input" />
</td>
<td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td> <td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td> <td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['order_no'] ?? '')) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($rows ?? [])): ?> <?php if (empty($rows)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4"><?php <tr><td colspan="7" class="text-center text-gray-400 py-6">조회 조건에 맞는 미입고 발주가 없습니다.</td></tr>
if ((int) ($companyIdx ?? 0) <= 0) {
echo '제작업체를 선택하고 조회해 주세요.';
} else {
echo '해당 제작업체의 미입고 발주 내역이 없습니다.';
}
?></td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<div class="flex items-center gap-2"> <!-- 우: 선택 발주 상세 + 스캔/수량 입고 + 입고 세부내역 -->
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm">취소</a> <section class="xl:col-span-5 flex flex-col gap-3">
<div class="border border-gray-300 rounded-lg bg-white overflow-hidden">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">선택 발주 · 입고 처리</div>
<div class="p-3 space-y-3 text-sm">
<div id="recv-empty" class="text-gray-400 py-6 text-center">좌측 발주 현황에서 발주를 선택하세요.</div>
<div id="recv-detail" class="hidden space-y-3">
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[13px] border border-gray-200 rounded p-2 bg-gray-50">
<dt class="font-semibold text-gray-600">봉투종류</dt><dd id="sel-bag"></dd>
<dt class="font-semibold text-gray-600">제작업체</dt><dd id="sel-company"></dd>
<dt class="font-semibold text-gray-600">발주량</dt><dd><span id="sel-orderqty">0</span> 매</dd>
<dt class="font-semibold text-gray-600">입고량</dt><dd><span id="sel-received" class="font-bold text-blue-700">0</span> 매</dd>
<dt class="font-semibold text-gray-600">미입고량</dt><dd><span id="sel-pending" class="font-bold text-red-600">0</span> 매</dd>
</dl>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col text-xs text-gray-500">인수자(대행소)
<select id="br_receiver_ref" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm">
<?php foreach ($receiverOptions as $opt): ?>
<option value="<?= esc((string) ($opt['ref'] ?? ''), 'attr') ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>><?= esc((string) ($opt['label'] ?? '')) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="flex flex-col text-xs text-gray-500">인계자(제작업체)
<select id="br_sender_idx" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm">
<?php foreach ($senders as $s): ?>
<option value="<?= (int) ($s->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($s->mg_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($s->mg_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</label>
</div> </div>
</form> <label class="flex flex-col text-xs text-gray-500">입고일
<input type="date" id="br_receive_date" value="<?= esc(date('Y-m-d'), 'attr') ?>" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm w-40"/>
</label>
<div class="border border-emerald-300 rounded-lg p-2 bg-emerald-50/40">
<label class="block text-xs font-semibold text-emerald-800 mb-1"><i class="fa-solid fa-barcode mr-1"></i>박스 바코드 스캔 (스캔 1회 = 1박스 입고)</label>
<input type="text" id="scan-input" autocomplete="off" placeholder="박스 바코드를 스캔 후 Enter" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" disabled/>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-gray-500 shrink-0">또는 수량 직접입력(매)</span>
<input type="number" id="qty-input" min="1" placeholder="매수" class="border border-gray-300 rounded px-2 py-1 text-sm w-28 text-right" disabled/>
<button type="button" id="btn-receive" class="border border-blue-600 text-blue-700 px-3 py-1 rounded-sm text-sm hover:bg-blue-50 disabled:opacity-50" disabled>입고 처리</button>
</div>
<p id="scan-msg" class="text-xs text-gray-500 mt-1"></p>
</div>
</div>
</div>
</div>
<div class="border border-gray-300 rounded-lg bg-white overflow-hidden">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
<span>입고 세부내역 (박스코드)</span>
<span class="text-gray-500 text-xs">박스 <span id="detail-count" class="font-bold text-blue-700">0</span></span>
</div>
<div class="overflow-auto max-h-[36vh]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">박스코드</th>
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
</tr>
</thead>
<tbody id="detail-body">
<tr><td colspan="4" class="text-center text-gray-400 py-6">스캔/입고 시 박스코드가 추가됩니다.</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<script> <script>
(() => { (() => {
const parseIntSafe = (v) => { const mode = <?= json_encode($mode) ?>;
const n = Number(String(v ?? '').replace(/,/g, '')); const scanApi = new URL('<?= base_url('bag/receiving/scan-box') ?>', window.location.href).pathname;
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0; const csrfName = '<?= csrf_token() ?>';
}; let csrfHash = '<?= csrf_hash() ?>';
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const refreshPending = (row) => { const tbody = document.getElementById('order-status-body');
const pendingCell = row.querySelector('.pending-cell'); const detailBody = document.getElementById('detail-body');
const input = row.querySelector('.receive-input'); const detailCountEl = document.getElementById('detail-count');
if (!pendingCell || !input) return; const scanInput = document.getElementById('scan-input');
const original = parseIntSafe(row.getAttribute('data-pending-original')); const qtyInput = document.getElementById('qty-input');
const current = parseIntSafe(input.value); const btnReceive = document.getElementById('btn-receive');
const remain = Math.max(0, original - current); const scanMsg = document.getElementById('scan-msg');
pendingCell.textContent = Number(remain || 0).toLocaleString('ko-KR'); let selectedRow = null;
}; let detailNo = 0;
document.querySelectorAll('.receive-input').forEach((input) => { // ── 정렬 ──
input.addEventListener('input', (e) => { let sortKey = '', sortDir = 'asc';
const row = e.target.closest('tr'); const numOf = (tr, key) => {
if (!row) return; const map = { order_qty: 'data-order-qty', pending: 'data-pending', received: 'data-received' };
const original = parseIntSafe(row.getAttribute('data-pending-original')); if (map[key]) return Number(tr.getAttribute(map[key]) || 0);
const current = Math.min(parseIntSafe(input.value), original); return 0;
input.value = String(current); };
refreshPending(row); const strOf = (tr, key) => {
if (key === 'order_date') return tr.children[0].textContent.trim();
if (key === 'bag_name') return tr.getAttribute('data-bag-name') || '';
return '';
};
document.querySelectorAll('#order-status-table .sort-th').forEach((th) => {
th.addEventListener('click', () => {
const key = th.dataset.sort, type = th.dataset.type;
if (sortKey === key) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = key; sortDir = 'asc'; }
const rows = Array.prototype.slice.call(tbody.querySelectorAll('tr.os-row'));
rows.sort((a, b) => {
let va, vb;
if (type === 'num') { va = numOf(a, key); vb = numOf(b, key); return sortDir === 'asc' ? va - vb : vb - va; }
va = strOf(a, key); vb = strOf(b, key);
return sortDir === 'asc' ? va.localeCompare(vb, 'ko') : vb.localeCompare(va, 'ko');
}); });
const row = input.closest('tr'); rows.forEach((r) => tbody.appendChild(r));
if (row) refreshPending(row); document.querySelectorAll('#order-status-table .sort-ind').forEach((s) => { s.textContent = ''; });
const ind = th.querySelector('.sort-ind');
if (ind) ind.textContent = sortDir === 'asc' ? ' ▲' : ' ▼';
});
});
// ── 행 선택 ──
function selectRow(tr) {
if (selectedRow) selectedRow.classList.remove('bg-blue-100');
selectedRow = tr;
tr.classList.add('bg-blue-100');
document.getElementById('recv-empty').classList.add('hidden');
document.getElementById('recv-detail').classList.remove('hidden');
document.getElementById('sel-bag').textContent = tr.getAttribute('data-bag-name') || '';
document.getElementById('sel-company').textContent = tr.getAttribute('data-company') || '-';
document.getElementById('sel-orderqty').textContent = nf(tr.getAttribute('data-order-qty'));
document.getElementById('sel-received').textContent = nf(tr.getAttribute('data-received'));
document.getElementById('sel-pending').textContent = nf(tr.getAttribute('data-pending'));
const pending = Number(tr.getAttribute('data-pending') || 0);
const enabled = pending > 0;
scanInput.disabled = !enabled;
qtyInput.disabled = !enabled;
btnReceive.disabled = !enabled;
scanMsg.textContent = enabled ? '' : '이미 전량 입고된 발주입니다.';
scanMsg.className = 'text-xs mt-1 ' + (enabled ? 'text-gray-500' : 'text-amber-700');
if (enabled) scanInput.focus();
}
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('tr.os-row');
if (tr) selectRow(tr);
});
// ── 입고 처리(스캔/수량) AJAX ──
async function doReceive(barcode, qtySheet) {
if (!selectedRow) return;
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('mode', mode);
body.set('row_key', selectedRow.getAttribute('data-row-key'));
body.set('br_receiver_ref', document.getElementById('br_receiver_ref').value || '');
body.set('br_sender_idx', document.getElementById('br_sender_idx').value || '0');
body.set('br_receive_date', document.getElementById('br_receive_date').value || '');
body.set('barcode', barcode || '');
body.set('qty_sheet', String(qtySheet || 0));
let data;
try {
const res = await fetch(scanApi, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.toString(),
});
data = await res.json();
} catch (err) {
scanMsg.textContent = '통신 오류가 발생했습니다.'; scanMsg.className = 'text-xs mt-1 text-red-600';
return;
}
if (data && data.csrf) csrfHash = data.csrf;
if (!data || !data.ok) {
scanMsg.textContent = (data && data.message) || '입고 처리 실패';
scanMsg.className = 'text-xs mt-1 text-red-600 font-semibold';
return;
}
// 선택 행 · 상세 수치 갱신
selectedRow.setAttribute('data-received', String(data.received_qty_sheet));
selectedRow.setAttribute('data-pending', String(data.pending_qty_sheet));
selectedRow.querySelector('.cell-received').textContent = nf(data.received_qty_sheet);
selectedRow.querySelector('.cell-pending').textContent = nf(data.pending_qty_sheet);
document.getElementById('sel-received').textContent = nf(data.received_qty_sheet);
document.getElementById('sel-pending').textContent = nf(data.pending_qty_sheet);
// 입고 세부내역에 박스코드 추가(최신이 위로)
(data.boxes || []).forEach((b) => {
detailNo++;
if (detailNo === 1) detailBody.innerHTML = '';
const tr = document.createElement('tr');
tr.innerHTML = '<td class="text-center">' + detailNo + '</td>'
+ '<td class="text-left pl-2">' + esc(data.bag_name) + '</td>'
+ '<td class="text-left pl-2 font-mono text-gray-600">' + esc(b.box_code) + '</td>'
+ '<td class="text-right pr-2">' + nf(b.sheet) + '</td>';
detailBody.insertBefore(tr, detailBody.firstChild);
});
detailCountEl.textContent = nf(detailNo);
scanMsg.textContent = '입고: ' + data.bag_name + ' / ' + nf(data.qty_sheet) + '매' + (data.qty_box ? ' (' + data.qty_box + '박스)' : '') + ' · 잔량 ' + nf(data.pending_qty_sheet);
scanMsg.className = 'text-xs mt-1 text-emerald-700';
// 전량 입고되면 입력 잠금
if (Number(data.pending_qty_sheet) <= 0) {
scanInput.disabled = true; qtyInput.disabled = true; btnReceive.disabled = true;
}
}
scanInput.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const code = (scanInput.value || '').trim();
if (!code) return;
doReceive(code, 0);
scanInput.value = '';
scanInput.focus();
});
btnReceive.addEventListener('click', () => {
const q = Math.floor(Number(qtyInput.value || 0));
if (!(q > 0)) { scanMsg.textContent = '입고 수량(매)을 입력하세요.'; scanMsg.className = 'text-xs mt-1 text-amber-700'; return; }
doReceive('', q);
qtyInput.value = '';
}); });
})(); })();
</script> </script>

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
/**
* 사용자 의견 위젯 — 화면 우하단 플로팅 버튼 + 모달.
* 보내기 시 화면을 html2canvas 로 캡처해 의견과 함께 전송한다.
* 필요한 화면(뷰)에서 <?= view('components/feedback_widget') ?> 로 포함.
*/
$apiPath = (string) parse_url(base_url('feedback'), PHP_URL_PATH);
?>
<div id="fbWidget" class="no-print" style="position:fixed;right:16px;bottom:16px;z-index:11000;">
<button type="button" id="fbOpen" title="이 화면에 대한 의견 보내기"
style="display:inline-flex;align-items:center;gap:.4rem;background:#243a5e;color:#fff;border:0;border-radius:999px;padding:.6rem .9rem;font-size:.8rem;font-weight:700;box-shadow:0 4px 14px rgba(0,0,0,.25);cursor:pointer;">
<i class="fa-regular fa-comment-dots"></i> 의견
</button>
</div>
<!-- 떠 있는(비차단) 의견 창 — 헤더 드래그로 이동, 우하단 모서리로 크기 조절 -->
<div id="fbModal" class="no-print" style="display:none;position:fixed;inset:0;z-index:11001;pointer-events:none;">
<div id="fbPanel" style="position:fixed;right:16px;bottom:72px;width:380px;height:auto;min-width:300px;min-height:280px;max-width:95vw;max-height:88vh;background:#fff;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.3);overflow:hidden;resize:both;display:flex;flex-direction:column;pointer-events:auto;">
<div id="fbHead" style="display:flex;align-items:center;justify-content:space-between;padding:.7rem .9rem;background:#1a2b4b;color:#fff;cursor:move;flex-shrink:0;user-select:none;">
<span style="font-size:.85rem;font-weight:700;"><i class="fa-regular fa-comment-dots"></i> 이 화면 의견 보내기</span>
<button type="button" id="fbClose" title="닫기" style="background:transparent;border:0;color:#fff;font-size:1.1rem;cursor:pointer;">&times;</button>
</div>
<div style="padding:.9rem;flex:1;overflow:auto;display:flex;flex-direction:column;min-height:0;">
<textarea id="fbContent" maxlength="2000" placeholder="이 화면에서 불편하거나 바꿨으면 하는 점을 자유롭게 적어 주세요."
style="width:100%;flex:1;min-height:80px;border:1px solid #d1d5db;border-radius:8px;padding:.6rem;font-size:.85rem;resize:none;box-sizing:border-box;"></textarea>
<div id="fbCount" style="text-align:right;font-size:.7rem;color:#9ca3af;margin-top:.2rem;">0/2000</div>
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.5rem;font-size:.75rem;color:#6b7280;">
<input type="checkbox" id="fbShot" checked/> 현재 화면 캡처 첨부
</label>
<div style="margin-top:.5rem;">
<label for="fbFiles" style="display:inline-flex;align-items:center;gap:.35rem;font-size:.75rem;color:#243a5e;border:1px dashed #c7cdd6;border-radius:8px;padding:.4rem .6rem;cursor:pointer;">
<i class="fa-regular fa-image"></i> 이미지 첨부 (최대 5장)
</label>
<input type="file" id="fbFiles" accept="image/*" multiple style="display:none;"/>
<div id="fbPreview" style="display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.4rem;"></div>
</div>
<div id="fbMsg" style="font-size:.75rem;margin-top:.4rem;min-height:1em;"></div>
<div style="display:flex;justify-content:flex-end;gap:.4rem;margin-top:.5rem;flex-shrink:0;">
<button type="button" id="fbCancel" style="border:1px solid #d1d5db;background:#fff;border-radius:8px;padding:.45rem .8rem;font-size:.8rem;cursor:pointer;">취소</button>
<button type="button" id="fbSend" style="border:0;background:#243a5e;color:#fff;border-radius:8px;padding:.45rem .9rem;font-size:.8rem;font-weight:700;cursor:pointer;">보내기</button>
</div>
</div>
</div>
</div>
<script>
(function () {
var API = location.origin + <?= json_encode($apiPath, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var widget = document.getElementById('fbWidget');
var modal = document.getElementById('fbModal');
var content = document.getElementById('fbContent');
var msg = document.getElementById('fbMsg');
var sendBtn = document.getElementById('fbSend');
var shotChk = document.getElementById('fbShot');
var fileInput = document.getElementById('fbFiles');
var preview = document.getElementById('fbPreview');
var counter = document.getElementById('fbCount');
var attachments = []; // 선택된 이미지 File 목록(최대 5)
function updateCount() { if (counter) counter.textContent = (content.value || '').length + '/2000'; }
content.addEventListener('input', updateCount);
function renderPreview() {
preview.innerHTML = '';
attachments.forEach(function (file, i) {
var url = URL.createObjectURL(file);
var box = document.createElement('span');
box.style.cssText = 'position:relative;display:inline-block;';
box.innerHTML = '<img src="' + url + '" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid #e5e7eb;">'
+ '<button type="button" data-i="' + i + '" title="제거" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:0;background:#dc2626;color:#fff;font-size:11px;line-height:1;cursor:pointer;">&times;</button>';
preview.appendChild(box);
});
}
fileInput.addEventListener('change', function () {
Array.prototype.forEach.call(fileInput.files || [], function (f) {
if (attachments.length < 5 && /^image\//.test(f.type)) { attachments.push(f); }
});
fileInput.value = '';
renderPreview();
});
preview.addEventListener('click', function (e) {
var b = e.target.closest('button[data-i]');
if (!b) return;
attachments.splice(parseInt(b.getAttribute('data-i'), 10), 1);
renderPreview();
});
function open() {
modal.style.display = 'block'; msg.textContent = '';
var panel = document.getElementById('fbPanel');
// 처음 열 때만 우하단 근처를 left/top 기준으로 배치(드래그·리사이즈 동작 일관)
if (!panel.dataset.positioned) {
var w = panel.offsetWidth || 380, h = panel.offsetHeight || 320;
panel.style.left = Math.max(8, window.innerWidth - w - 16) + 'px';
panel.style.top = Math.max(8, window.innerHeight - h - 16) + 'px';
panel.style.right = 'auto'; panel.style.bottom = 'auto';
panel.dataset.positioned = '1';
}
content.focus();
}
function close() { modal.style.display = 'none'; }
document.getElementById('fbOpen').addEventListener('click', open);
document.getElementById('fbClose').addEventListener('click', close);
document.getElementById('fbCancel').addEventListener('click', close);
// 헤더 드래그로 창 이동 (크기 조절은 패널 우하단 모서리 resize:both 가 처리)
(function () {
var panel = document.getElementById('fbPanel');
var head = document.getElementById('fbHead');
var dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
// 드래그 중 iframe(예: 지도)이 마우스를 가로채지 않도록 투명 오버레이
var ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;z-index:11002;cursor:move;display:none;';
document.body.appendChild(ov);
head.addEventListener('mousedown', function (e) {
if (e.target.closest('#fbClose')) return;
e.preventDefault();
var r = panel.getBoundingClientRect();
// right/bottom 기준을 left/top 기준으로 전환(드래그 계산 일관성)
panel.style.left = r.left + 'px'; panel.style.top = r.top + 'px';
panel.style.right = 'auto'; panel.style.bottom = 'auto';
sx = e.clientX; sy = e.clientY; sl = r.left; st = r.top;
dragging = true; ov.style.display = 'block'; document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
var w = panel.offsetWidth, h = panel.offsetHeight;
var nl = Math.min(Math.max(0, sl + (e.clientX - sx)), window.innerWidth - w);
var nt = Math.min(Math.max(0, st + (e.clientY - sy)), window.innerHeight - h);
panel.style.left = nl + 'px'; panel.style.top = nt + 'px';
});
document.addEventListener('mouseup', function () {
if (!dragging) return;
dragging = false; ov.style.display = 'none'; document.body.style.userSelect = '';
});
})();
// html2canvas 지연 로드
function ensureH2C(cb) {
if (typeof html2canvas === 'function') { cb(); return; }
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
s.onload = function () { cb(); };
s.onerror = function () { cb(); }; // 실패해도 텍스트만 전송
document.head.appendChild(s);
}
function capture(cb) {
if (!shotChk.checked) { cb(''); return; }
ensureH2C(function () {
if (typeof html2canvas !== 'function') { cb(''); return; }
var prevW = widget.style.display, prevM = modal.style.display;
widget.style.display = 'none'; modal.style.display = 'none';
var scale = Math.min(1, 1280 / Math.max(1, window.innerWidth));
html2canvas(document.body, { scale: scale, useCORS: true, logging: false, backgroundColor: '#ffffff' })
.then(function (canvas) {
widget.style.display = prevW; modal.style.display = prevM;
try { cb(canvas.toDataURL('image/jpeg', 0.7)); } catch (e) { cb(''); }
})
.catch(function () { widget.style.display = prevW; modal.style.display = prevM; cb(''); });
});
}
function send() {
var text = (content.value || '').trim();
if (!text) { msg.style.color = '#dc2626'; msg.textContent = '의견 내용을 입력해 주세요.'; return; }
sendBtn.disabled = true; msg.style.color = '#6b7280'; msg.textContent = '전송 중…';
capture(function (shot) {
var fd = new FormData();
fd.append('content', text);
fd.append('page_url', location.pathname + location.search);
fd.append('page_title', (document.title || '').slice(0, 200));
if (shot) fd.append('screenshot', shot);
attachments.forEach(function (file) { fd.append('files[]', file, file.name); });
fetch(API, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
sendBtn.disabled = false;
if (d && d.ok) {
msg.style.color = '#059669'; msg.textContent = '의견이 접수되었습니다. 감사합니다.';
content.value = ''; updateCount();
attachments.length = 0; renderPreview();
setTimeout(close, 900);
} else {
msg.style.color = '#dc2626'; msg.textContent = (d && d.message) || '전송에 실패했습니다.';
}
})
.catch(function () { sendBtn.disabled = false; msg.style.color = '#dc2626'; msg.textContent = '전송 중 오류가 발생했습니다.'; });
});
}
sendBtn.addEventListener('click', send);
})();
</script>

View File

@@ -8,7 +8,7 @@ $linkClass = $linkClass ?? 'app-brand flex items-center gap-2 shrink-0 text-base
?> ?>
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="GBLS (Garbage Bag Logistics System)"> <a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="GBLS (Garbage Bag Logistics System)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-blue-900 translate-y-[1px] shrink-0" aria-hidden="true" focusable="false"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-blue-900 translate-y-[1px] shrink-0" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
<span class="leading-none flex flex-col"> <span class="leading-none flex flex-col">
<strong class="font-extrabold tracking-wide">GBLS</strong> <strong class="font-extrabold tracking-wide">GBLS</strong>

Some files were not shown because too many files have changed in this diff Show More