93 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
taekyoungc
ec34ae348c docs: 화면-매뉴얼 1:1 보강 (비밀번호 변경·LOT 디스켓 불출·수급 계획·기타 입출고 등)
- 모든 메뉴 화면이 알맞은 매뉴얼 섹션으로 연결되도록 screenHelp 정비
- 비밀번호 변경/LOT-No 디스켓 불출/쓰레기봉투 수급 계획/기타 입출고 섹션 신설
- 지정판매소 바코드·신규/취소 명단·조회 설명 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:24:58 +09:00
taekyoungc
9d7d1e8a94 feat: 리포트 첫 진입 자동조회 + 글자 확대(탭·대메뉴 포함, 버튼 고정)
- 수급 계획·반품/파기 현황·기간별 봉투 수불 현황: 첫 진입에도 기본 조건 자동 조회
- 글자 확대 시 탭 텍스트·상단 대메뉴(font-size)도 확대, A−/A+ 버튼은 위치·크기 고정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:24:58 +09:00
taekyoungc
24ac3f169e feat: 지자체 전환 현재 선택 표시 + 전화주문 결제구분 기본 가상계좌
- 지자체 전환 화면에서 현재 작업 지자체 라디오 체크 + '현재 선택' 배지
- 전화 주문 접수 결제구분 기본값을 가상계좌로 설정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:56:49 +09:00
taekyoungc
71534fb198 feat: 인쇄 A4 맞춤 + 재고현황 실사버튼 제거
- 회원관리·승인대기 표를 인쇄 시 A4 너비에 맞게(줄바꿈·관리 열 제외)
- 재고현황의 '실사 선별 조회' 버튼 제거(소메뉴로 이동 가능)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:56 +09:00
taekyoungc
e6a49e379a style: 표 카드 모서리 둥글게(overflow-hidden) 일괄 적용
헤더 바(bg-gray-50)가 둥근 테두리를 뚫고 나오던 카드 컨테이너에 overflow-hidden 추가(12개 파일).
order_change: 중복 플래시 출력도 함께 제거.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:46 +09:00
taekyoungc
77c7d3e119 fix: 플래시 메시지 중복 표시 제거
레이아웃(embed/portal)에서 이미 success/error 를 표시하므로 뷰 안의 중복 출력 제거.
일괄입고·스캐너입고·전화주문 화면에서 메시지가 한 번만 표시됨.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:38 +09:00
taekyoungc
b9dd24082c feat: 화면설명 소제목 스크롤·강조 + 글씨크기 메뉴 확대 + 드로어 개선
- screenHelp 앵커(?hl=)로 '이 화면 설명' 클릭 시 해당 소제목으로 스크롤·강조, 재오픈 시 재강조(postMessage)
- 글씨 크기(A−/A+)가 상단 대메뉴·좌측 사이드바까지 확대, 관리자 페이지에도 조절 기능 추가
- 화면 설명 드로어 양방향 리사이즈(좁히기 가능) + 기본 너비 2배

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:31 +09:00
taekyoungc
56dadb3478 fix: 워크스페이스 탭 중첩 셸 방지 + 세션 만료 시 로그인 리다이렉트
- EmbedRedirectFilter 추가: 임베드(embed=1) 요청의 리다이렉트 Location에 embed 유지(중첩 셸 방지)
- bag/* 전체에 loginAuth 적용, 임베드 대시보드 로그아웃 시 로그인으로 이동
- 기본코드 종류 선택 시 embed 유지, 일괄입고 오류 복귀를 명시 URL로(back() 제거)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:22 +09:00
144 changed files with 8355 additions and 1028 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
* supported by PHP.
*/
public string $appTimezone = 'UTC';
public string $appTimezone = 'Asia/Seoul';
/**
* --------------------------------------------------------------------------

View File

@@ -27,6 +27,8 @@ class Filters extends BaseFilters
public array $aliases = [
'adminAuth' => \App\Filters\AdminAuthFilter::class,
'loginAuth' => \App\Filters\LoginAuthFilter::class,
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
'sessionGuard' => \App\Filters\SessionGuardFilter::class,
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,
@@ -77,10 +79,12 @@ class Filters extends BaseFilters
// 'honeypot',
// 'csrf',
// 'invalidchars',
'sessionGuard', // 중복 로그인 방지(나중 로그인 우선)
],
'after' => [
// 'honeypot',
// 'secureheaders',
'embedRedirect', // 임베드(탭) 리다이렉트에 embed=1 유지 → 중첩 셸 방지
],
];
@@ -108,5 +112,10 @@ class Filters extends BaseFilters
*
* @var array<string, array<string, list<string>>>
*/
public array $filters = [];
public array $filters = [
// 모든 업무(bag) 화면은 로그인 필요. 세션 만료 후 어떤 버튼을 눌러도
// 깨진 화면 대신 로그인으로 리다이렉트되도록 일괄 보호한다.
// (login/logout/register 는 bag/* 가 아니므로 영향 없음. 관리자 화면은 adminAuth 가 별도 처리)
'loginAuth' => ['before' => ['bag', 'bag/*']],
];
}

View File

@@ -40,34 +40,51 @@ class Manual extends BaseConfig
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
*
* 값에 "slug#소제목힌트" 형식으로 적으면, 매뉴얼 창이 열릴 때 해당 소제목으로
* 자동 스크롤하고 잠시 강조 표시한다(힌트는 그 페이지의 H2/H3 텍스트 일부와 일치).
*
* @var array<string, string>
*/
public array $screenHelp = [
'bag/order/phone' => 'sales',
'bag/order' => 'order',
'bag/bag-orders' => 'order',
'bag/receiving' => 'order',
'bag/bag-receivings' => 'order',
'bag/inventory' => 'inventory',
'bag/sale' => 'sales',
'bag/sales' => 'sales',
'bag/issue' => 'sales',
'bag/bag-issues' => 'sales',
'bag/bag-sales' => 'sales',
'bag/shop-orders' => 'sales',
'bag/flow' => 'reports',
'bag/reports' => 'reports',
'bag/analytics' => 'reports',
'bag/designated-shops' => 'basic',
'bag/bag-prices' => 'basic',
'bag/prices' => 'basic',
'bag/packaging-units' => 'basic',
'bag/code-kinds' => 'basic',
'bag/code-details' => 'basic',
'bag/managers' => 'basic',
'bag/companies' => 'basic',
'bag/sales-agencies' => 'basic',
'bag/free-recipients' => 'basic',
'bag/order/phone' => 'sales#전화 주문 접수',
'bag/order/lot-seed' => 'order#발주파일 생성',
'bag/order' => 'order#발주 등록',
'bag/password-change' => 'account#비밀번호 변경',
'bag/bag-orders' => 'order#발주 현황',
'bag/receiving' => 'order#입고 처리',
'bag/bag-receivings' => 'order#입고 현황',
'bag/inventory' => 'inventory#재고 현황',
'bag/sale' => 'sales#지정판매소 판매',
'bag/sales' => 'sales#지정판매소 판매',
'bag/issue' => 'sales#무료용 불출 처리',
'bag/bag-issues' => 'sales#무료용 불출 처리',
'bag/bag-sales' => 'sales#판매/반품 현황',
'bag/shop-orders' => 'sales#전화 주문 접수',
'bag/flow' => 'reports#기간별 봉투 수불 현황',
'bag/reports/misc-flow' => 'reports#기타 입출고',
'bag/reports/returns' => 'reports#반품/파기 현황',
'bag/reports/lot-flow' => 'reports#LOT 수불 조회',
'bag/reports/supply-demand' => 'reports#쓰레기봉투 수급 계획',
'bag/reports/shop-sales' => 'reports#지정 판매소별 판매현황',
'bag/reports/daily-summary' => 'reports#일계표',
'bag/reports/period-sales' => 'reports#그 밖의 현황',
'bag/reports/yearly-sales' => 'reports#그 밖의 현황',
'bag/reports/sales-ledger' => 'reports#그 밖의 현황',
'bag/reports/hometax-export' => 'reports#그 밖의 현황',
'bag/reports' => 'reports#일계표',
'bag/analytics' => 'reports#그 밖의 현황',
'bag/designated-shops/status' => 'basic#지정 판매소 신규/취소 현황',
'bag/designated-shops/district-new-cancel' => 'basic#지정 판매소 신규/취소 현황',
'bag/designated-shops' => 'basic#지정판매소 관리',
'bag/bag-prices' => 'basic#단가 관리',
'bag/prices' => 'basic#단가 관리',
'bag/packaging-units' => 'basic#포장 단위 관리',
'bag/code-kinds' => 'basic#기본코드 관리',
'bag/code-details' => 'basic#기본코드 관리',
'bag/managers' => 'basic#그 밖의 기본정보',
'bag/companies' => 'basic#그 밖의 기본정보',
'bag/sales-agencies' => 'basic#그 밖의 기본정보',
'bag/free-recipients' => 'basic#그 밖의 기본정보',
'bag/number-lookup' => 'codes',
];
}

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/inventory', 'Bag::inventory');
$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-work', 'Bag::inspectionWork');
$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/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
$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');
@@ -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-save', 'Bag::issueCancelSave');
$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/manage', 'Bag::phoneOrderManage');
$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->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
$routes->get('bag/order/change', 'Bag::orderChange');
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
$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/store', 'Bag::orderStore');
$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->get('bag/receiving/scanner', 'Bag::receivingScanner');
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
$routes->post('bag/receiving/scan-box', 'Bag::receivingScanBox');
$routes->get('bag/receiving/batch', 'Bag::receivingBatch');
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
$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', 'Admin\DesignatedShop::index');
$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->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$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->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/daily-summary', 'Admin\SalesReport::dailySummary');
$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('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
// Auth
$routes->get('login', 'Auth::showLoginForm');
$routes->post('login', 'Auth::login');
@@ -236,6 +254,15 @@ $routes->post('register', 'Auth::register');
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
$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('users', 'Admin\User::index');
$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/delete/(:num)', 'Admin\User::delete/$1');
$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->post('access/approve/(:num)', 'Admin\Access::approve/$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'),
];
// 발주담당(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 기준으로 해시를 계산하므로 우선 빈값으로 생성
$orderData['bo_hash'] = '';

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ class DesignatedShop extends BaseController
private DesignatedShopModel $shopModel;
private LocalGovernmentModel $lgModel;
private Roles $roles;
private ?bool $dongColumnExists = null;
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);
if ($dsName !== null && $dsName !== '') {
@@ -185,12 +186,47 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $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}
*/
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();
$builder = $db->table('designated_shop');
@@ -204,6 +240,7 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') {
$builder->where('ds_state', (int) $dsState);
}
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
->groupBy('ds_state')
->get()
@@ -223,11 +260,12 @@ class DesignatedShop extends BaseController
/**
* @param list<object> $list
* @param array<int, string> $lgMap
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
* @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;
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
$payload = [];
@@ -244,7 +282,19 @@ class DesignatedShop extends BaseController
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
$da = $row->ds_designated_at ?? null;
$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_shop_no' => $sn,
'shop_no_display' => $shortNo,
@@ -266,7 +316,10 @@ class DesignatedShop extends BaseController
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
'ds_email' => (string) ($row->ds_email ?? ''),
'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_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
'ds_designated_at' => $daOut,
@@ -274,7 +327,18 @@ class DesignatedShop extends BaseController
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
'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;
@@ -294,14 +358,17 @@ class DesignatedShop extends BaseController
return null;
}
// 다조건 검색 (P2-15)
// 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
$dsName = $this->request->getGet('ds_name');
$dsGugunCode = $this->request->getGet('ds_gugun_code');
$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 = [];
@@ -309,9 +376,23 @@ class DesignatedShop extends BaseController
$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);
$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();
@@ -324,9 +405,12 @@ class DesignatedShop extends BaseController
'dsName' => $dsName ?? '',
'dsGugunCode' => $dsGugunCode ?? '',
'dsState' => $dsState ?? '',
'dsRep' => $dsRep ?? '',
'dsTel' => $dsTel ?? '',
'gugunCodes' => $gugunCodes,
'stateCounts' => $stateCounts,
'gugunNameMap' => $gugunNameMap,
'dongDisplayMap' => $dongDisplayMap,
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
];
@@ -475,22 +559,45 @@ class DesignatedShop extends BaseController
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();
// 동코드(구·군 동코드) 산출
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
$rows = [];
foreach ($list as $row) {
$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[] = [
$row->ds_idx,
$row->ds_shop_no,
$row->ds_name,
$row->ds_rep_name,
$repName,
$dongDisp,
$daDisp,
$row->ds_biz_no,
$this->designatedShopScalar($row, 'ds_biz_type'),
$this->designatedShopScalar($row, 'ds_biz_kind'),
$this->designatedShopScalar($row, 'ds_va_bank'),
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''),
$row->ds_tel ?? '',
$vaAccount,
$telVal,
$row->ds_addr ?? '',
$this->designatedShopScalar($row, 'ds_zone_code'),
$this->designatedShopScalar($row, 'ds_branch_no'),
@@ -504,7 +611,7 @@ class DesignatedShop extends BaseController
export_csv(
'지정판매소_' . date('Ymd') . '.csv',
[
'번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종',
'번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
'변경일자', '변경사유', '상태', '등록일',
],
@@ -648,6 +755,8 @@ class DesignatedShop extends BaseController
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
'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_change_reason' => (string) $this->request->getPost('ds_change_reason'),
'ds_regdate' => date('Y-m-d H:i:s'),
@@ -770,6 +879,23 @@ class DesignatedShop extends BaseController
$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 = [
'ds_name' => (string) $this->request->getPost('ds_name'),
'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_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
'ds_email' => (string) $this->request->getPost('ds_email'),
...$dongCodeData,
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
'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']);
$dCode = null;
$dName = '';
foreach ($dCandidates as $cand) {
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
$dCode = $cand['cd'];
$dName = $cand['nm'];
break;
}
}
@@ -1557,9 +1686,248 @@ class DesignatedShop extends BaseController
'ok' => true,
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
'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) */
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 [
'company' => '제작업체',
'district' => '구·군',
'district' => '지자체',
'agency' => '대행소',
];
}
@@ -77,6 +77,8 @@ class Manager extends BaseController
$rules = [
'mg_name' => 'required|max_length[50]',
'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_phone' => 'permit_empty|max_length[20]',
'mg_email' => 'permit_empty|valid_email|max_length[100]',
@@ -88,6 +90,8 @@ class Manager extends BaseController
$this->model->insert([
'mg_lg_idx' => admin_effective_lg_idx(),
'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_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
@@ -126,6 +130,8 @@ class Manager extends BaseController
$rules = [
'mg_name' => 'required|max_length[50]',
'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]',
];
if (! $this->validate($rules)) {
@@ -134,6 +140,8 @@ class Manager extends BaseController
$this->model->update($id, [
'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_position_code' => $this->request->getPost('mg_position_code') ?? '',
'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_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);
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->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
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'))
->with('error', '지자체를 선택하세요.');
}
$ids = $this->request->getPost('mm_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)) {
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
}

View File

@@ -19,14 +19,15 @@ class PasswordChange extends BaseController
helper('admin');
$rules = [
'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]',
];
$messages = [
'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'],
'new_password' => [
'required' => '새 비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
'required' => '새 비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
],
'new_password_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.',

View File

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

View File

@@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use App\Models\SalesAgencyModel;
use App\Models\ReportPresetModel;
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: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
*/
@@ -70,6 +118,16 @@ class SalesReport extends BaseController
$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 = [];
foreach ($detailRows as $row) {
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') {
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
} else {
@@ -169,6 +295,10 @@ class SalesReport extends BaseController
'dsIdx' => $dsIdx,
'saIdx' => $saIdx,
'cats' => $cats,
'sizeGroups' => $sizeGroups,
'size' => $size,
'sizeStats' => $sizeStats,
'catStats' => $catStats,
'shops' => $shops,
'agencies' => $agencies,
'lgName' => $lgName,
@@ -220,6 +350,68 @@ class SalesReport extends BaseController
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 빈 배열이면 전체
*/
@@ -1038,7 +1230,8 @@ class SalesReport extends BaseController
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'));
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
@@ -1216,6 +1409,30 @@ class SalesReport extends BaseController
if ($export) {
$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 = [];
foreach ($built['itemBlocks'] as $block) {
foreach ($block['lines'] as $li) {
@@ -1238,7 +1455,8 @@ class SalesReport extends BaseController
'년판매현황_' . $year . '_' . $exportStamp,
$year . '년판매',
$headers,
$exportRows
$exportRows,
$topRows
);
}
@@ -2560,7 +2778,8 @@ class SalesReport extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$queried = $this->request->getGet('search') === '1';
// 첫 진입(search 파라미터 없음)에도 기본 조건(이번 달·불출)으로 자동 조회해 바로 표시한다.
$queried = ($this->request->getGet('search') ?? '1') === '1';
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if ($startDate > $endDate) {
@@ -3189,7 +3408,8 @@ class SalesReport extends BaseController
$salesScope = 'all';
}
$queried = $this->request->getGet('search') === '1';
// 첫 진입(search 파라미터 없음)에도 기본 조건(오늘·40일·ALL)으로 자동 조회해 바로 표시한다.
$queried = ($this->request->getGet('search') ?? '1') === '1';
$built = (new \App\Libraries\BagSupplyPlanBuilder())->build(
$lgIdx,
$refDate,
@@ -3216,4 +3436,256 @@ class SalesReport extends BaseController
'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

@@ -22,10 +22,13 @@ class SelectLocalGovernment extends BaseController
->orderBy('lg_name', 'ASC')
->findAll();
$currentLgIdx = (int) (session()->get('admin_selected_lg_idx') ?? 0);
return view('admin/layout', [
'title' => '지자체 선택',
'content' => view('admin/select_local_government/index', [
'list' => $list,
'list' => $list,
'currentLgIdx' => $currentLgIdx,
]),
]);
}

View File

@@ -83,13 +83,13 @@ class User extends BaseController
{
$rules = [
'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_email' => 'permit_empty|valid_email|max_length[100]',
'mb_phone' => 'permit_empty|max_length[20]',
'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());
}
$allowedLevels = array_keys($this->getAssignableLevels());
@@ -154,9 +154,9 @@ class User extends BaseController
];
$passwd = $this->request->getPost('mb_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());
}
$allowedLevels = array_keys($this->getAssignableLevels());

View File

@@ -19,15 +19,42 @@ class Auth extends BaseController
public function showLoginForm()
{
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', [
'pageTitle' => '로그인 - GBLS',
'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()
{
$rules = [
@@ -206,7 +233,7 @@ class Auth extends BaseController
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()
@@ -302,7 +329,7 @@ class Auth extends BaseController
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()
@@ -357,7 +384,7 @@ class Auth extends BaseController
{
$rules = [
'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_name' => 'required|max_length[100]',
'mb_email' => 'permit_empty|valid_email|max_length[100]',
@@ -374,9 +401,10 @@ class Auth extends BaseController
'is_unique' => '이미 사용 중인 아이디입니다.',
],
'mb_passwd' => [
'required' => '비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
'required' => '비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'max_length' => '비밀번호는 255자 이하여야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
],
'mb_passwd_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.',
@@ -545,9 +573,11 @@ class Auth extends BaseController
/**
* @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();
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
$sessionToken = bin2hex(random_bytes(16));
$sessionData = [
'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id,
@@ -555,6 +585,9 @@ class Auth extends BaseController
'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true,
'session_token' => $sessionToken,
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
'auth_2fa_verified' => $twofaVerified,
];
session()->set($sessionData);
@@ -562,18 +595,42 @@ class Auth extends BaseController
'mb_latestdate' => date('Y-m-d H:i:s'),
'mb_login_fail_count' => 0,
'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);
$successMsg = '로그인되었습니다.' . $loginNotice;
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)) {
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

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/')) {
$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/')) {
// /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal 셸
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
return view('bag/layout/portal', [
'title' => $title,
'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

@@ -25,6 +25,12 @@ class Home extends BaseController
return view('bag/layout/workspace');
}
// 워크스페이스 탭(iframe) 안에서 세션이 만료된 경우: 공개 랜딩 대신 로그인으로 보내
// 로그인 페이지의 프레임 이탈 스크립트가 상위 창 전체를 로그인으로 전환하게 한다.
if ($this->isEmbeddedRequest()) {
return redirect()->to(base_url('login'));
}
return view('welcome_message');
}
@@ -38,7 +44,11 @@ class Home extends BaseController
}
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. 화면별 설명은 어디에?
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.

View File

@@ -55,7 +55,15 @@
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
## 4. 비밀번호·계정 문제
## 4. 비밀번호 변경 · *비밀번호 변경*
- **비밀번호를 바꾸거나 분실**한 경우, 계정·권한 변경은 **담당 관리자**가 처리합니다. 관리자에게 문의하세요.
로그인한 본인이 직접 비밀번호를 바꾸는 화면입니다.
**입력**: 현재 비밀번호 → 새 비밀번호 → 새 비밀번호 확인.
**순서**: 현재 비밀번호가 맞아야 변경되며, 새 비밀번호는 확인란과 동일해야 합니다.
> 주기적으로 변경하고, 다른 사이트와 같은 비밀번호는 피하세요.
## 5. 비밀번호 분실 · 계정·권한 문제
- **비밀번호를 분실**해 로그인 자체가 안 되는 경우에는 **담당 관리자**가 초기화해 줍니다. 관리자에게 문의하세요.
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.

View File

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

View File

@@ -56,6 +56,47 @@
---
## 기타 입출고 · *봉투 수불 관리 기타 입출고*
정상적인 **발주입고 · 판매 · 불출** 흐름에 속하지 않는 **그 밖의 재고 증감**을 수동으로 기록하는 화면입니다.
(예: 분실·파손, 실사 차이 보정, 행정상 조정, 타 부서 이관 등)
**구분**
- **입고(in)**: 재고가 **늘어나는** 조정 (예: 누락분 추가, 반입).
- **출고(out)**: 재고가 **줄어드는** 조정 (예: 파손·분실 차감).
**등록 입력값** *(모두 필수)*
- **구분**: 입고 / 출고
- **봉투**: 봉투코드(봉투구분으로 먼저 좁혀서 선택)
- **수량**: 1 이상
- **일자**: 처리 기준일
- **사유**: 왜 조정하는지(필수, 최대 200자) — 추후 추적·감사를 위해 구체적으로 적습니다.
**조회·표시**
- **수불년월 · 구분**으로 묶여 좌측 **목록(마스터)** → 우측 **상세 내역**으로 표시됩니다.
- 봉투구분으로 필터링할 수 있습니다.
**반영**: 등록한 입고/출고 수량은 **재고와 봉투 수불 집계에 그대로 반영**되므로, 정확한 사유와 수량으로 신중히 입력하세요. 잘못 등록한 행은 삭제로 정정합니다.
> 정상 업무(발주·판매·불출)는 각 전용 화면에서 처리하고, **여기서는 그 흐름으로 설명되지 않는 예외적 증감만** 기록합니다.
---
## 쓰레기봉투 수급 계획 · *봉투 수불 관리 쓰레기봉투 수급 계획*
봉투 품목별로 **현재고가 며칠 버티는지, 언제·얼마나 발주할지**를 보여 주는 수급·발주 계획표입니다. 처음 들어가면 기본 조건으로 자동 조회됩니다.
**필터**
- **기준일**: 계산 기준 날짜.
- **제작기일(리드타임, 일)**: 발주 후 입고까지 걸리는 기간(기본 40일). 재고 소진 전에 미리 발주하려는 여유.
- **재고/판매 범위**: 기존(수기·바코드 미등록) / 바코드(등록) / 전체.
**표 구성(3그룹)**: ① 최근 발주 내역 ② 현재고 및 예상 판매일수 ③ 추가발주 예정내역.
**핵심 계산**: **발주예정일 = 기준일 + 소진일수 보유일수**. 발주예정일이 지난(긴급) 품목은 **빨간색**으로 표시되고, 그 시점에 맞춘 **추가 발주 제안 장수**가 함께 나옵니다.
> 발주 타이밍을 놓치지 않도록 돕는 화면입니다. 빨간 품목부터 발주를 검토하세요.
---
## 그 밖의 현황
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.

View File

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

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* 워크스페이스 탭(iframe, embed=1) 안에서 발생한 리다이렉트가 embed 파라미터를 잃지 않도록
* 응답 Location 헤더에 embed=1 을 유지시킨다.
*
* 배경: 폼 전송 후 redirect()->to(...) 하면 Location 에 embed 가 빠지고, iframe 안에서
* 그 주소로 다시 GET 하면(특히 HTTP 환경에서 Sec-Fetch-Dest 미전송) 전체 셸(헤더·사이드바)이
* 한 번 더 렌더되어 "화면 안에 전체 화면이 또" 생긴다. 이를 전역에서 한 번에 방지한다.
*/
class EmbedRedirectFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// no-op
}
/**
* @param array<string>|null $arguments
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// 임베드 요청에서 시작된 리다이렉트만 처리
if ($request->getGet('embed') === null) {
return;
}
$location = $response->getHeaderLine('Location');
if ($location === '') {
return; // 리다이렉트 응답이 아님
}
// 이미 embed 파라미터가 있으면 그대로 둔다
if (preg_match('/[?&]embed=/', $location) === 1) {
return;
}
// 인증 흐름(로그인/로그아웃/회원가입)은 상위 프레임에서 처리하므로 embed 를 붙이지 않는다
if (preg_match('#/(login|logout|register)(/|\?|$)#', $location) === 1) {
return;
}
$sep = strpos($location, '?') !== false ? '&' : '?';
$response->setHeader('Location', $location . $sep . 'embed=1');
}
}

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')) {
/**
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.
@@ -950,18 +981,28 @@ if (! function_exists('manual_help_url_for_path')) {
return '';
}
$map = config(\Config\Manual::class)->screenHelp ?? [];
$bestSlug = '';
$bestLen = -1;
foreach ($map as $prefix => $slug) {
$bestVal = '';
$bestLen = -1;
foreach ($map as $prefix => $val) {
$p = strtolower((string) $prefix);
if ($path === $p || str_starts_with($path . '/', $p . '/')) {
if (strlen($p) > $bestLen) {
$bestLen = strlen($p);
$bestSlug = (string) $slug;
$bestLen = strlen($p);
$bestVal = (string) $val;
}
}
}
if ($bestVal === '') {
return '';
}
return $bestSlug !== '' ? base_url('bag/manual/' . $bestSlug) : '';
// 값은 "slug" 또는 "slug#소제목힌트" 형식. 힌트가 있으면 ?hl= 로 전달해 해당 소제목으로 스크롤·강조.
[$slug, $hint] = array_pad(explode('#', $bestVal, 2), 2, null);
$url = base_url('bag/manual/' . $slug);
if ($hint !== null && trim($hint) !== '') {
$url .= '?hl=' . rawurlencode(trim($hint));
}
return $url;
}
}

View File

@@ -8,6 +8,22 @@ declare(strict_types=1);
* 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')) {
/**
* CSV 파일을 브라우저로 다운로드 전송
@@ -18,6 +34,7 @@ if (! function_exists('export_csv')) {
*/
function export_csv(string $filename, array $headers, array $rows): void
{
audit_export_log($filename);
// 파일명에 .csv 확장자 보장
if (! str_ends_with($filename, '.csv')) {
$filename .= '.csv';
@@ -76,9 +93,11 @@ if (! function_exists('export_excel_2003_xml')) {
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
* @param string[] $headers 컬럼 헤더
* @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';
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
@@ -97,6 +116,15 @@ if (! function_exists('export_excel_2003_xml')) {
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
$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>';
foreach ($headers as $h) {
$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
{
audit_export_log($filename);
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
$esc = static function (mixed $v): string {
@@ -392,6 +421,7 @@ if (! function_exists('export_bag_flow_report_excel')) {
array $metaLines,
array $reportRows
): void {
audit_export_log($filename);
$baseName = preg_replace('/\.[^.]+$/u', '', $filename);
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $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
{
audit_export_log($filename);
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);

View File

@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
if (! defined('PII_ENCRYPTED_FIELDS')) {
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
{
use \App\Models\Traits\Auditable;
protected $table = 'bag_inventory';
protected $primaryKey = 'bi_idx';
protected $returnType = 'object';

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagOrderModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'bag_order';
protected $primaryKey = 'bo_idx';
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_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
'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',
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class DesignatedShopModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'designated_shop';
protected $primaryKey = 'ds_idx';
protected $returnType = 'object';
@@ -31,6 +33,7 @@ class DesignatedShopModel extends Model
'ds_rep_phone',
'ds_email',
'ds_gugun_code',
'ds_dong_code',
'ds_zone_code',
'ds_branch_no',
'ds_designated_at',
@@ -38,6 +41,118 @@ class DesignatedShopModel extends Model
'ds_state_changed_at',
'ds_change_reason',
'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
{
use \App\Models\Traits\Auditable;
protected $table = 'free_recipient';
protected $primaryKey = 'fr_idx';
protected $returnType = 'object';

View File

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

View File

@@ -6,12 +6,15 @@ use CodeIgniter\Model;
class ManagerModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'manager';
protected $primaryKey = 'mg_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
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',
];
}

View File

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

View File

@@ -6,6 +6,14 @@ use CodeIgniter\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 $primaryKey = 'mb_idx';
protected $returnType = 'object';
@@ -28,6 +36,7 @@ class MemberModel extends Model
'mb_leavedate',
'mb_login_fail_count',
'mb_locked_until',
'mb_session_token',
];
/**

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'menu';
protected $primaryKey = 'mm_idx';
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 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
*/
@@ -214,6 +270,13 @@ class MenuModel extends Model
->get()
->getResultArray();
// 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑
$byDep = [];
foreach ($source as $row) {
$byDep[(int) ($row->mm_dep ?? 0)][] = $row;
}
ksort($byDep);
foreach ($lgRows as $lgRow) {
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
if ($destLg <= 0 || $destLg === $sourceLg) {
@@ -225,28 +288,41 @@ class MenuModel extends Model
->where('lg_idx', $destLg)
->delete();
// 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑
$idMap = [];
foreach ($source as $row) {
$oldId = (int) ($row->mm_idx ?? 0);
$oldP = (int) ($row->mm_pidx ?? 0);
$newPidx = 0;
if ($oldP > 0 && isset($idMap[$oldP])) {
$newPidx = (int) $idMap[$oldP];
foreach ($byDep as $dep => $levelRows) {
$batch = [];
foreach ($levelRows as $row) {
$oldP = (int) ($row->mm_pidx ?? 0);
$newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0;
$batch[] = [
'mt_idx' => $mtIdx,
'lg_idx' => $destLg,
'mm_name' => (string) ($row->mm_name ?? ''),
'mm_link' => (string) ($row->mm_link ?? ''),
'mm_pidx' => $newPidx,
'mm_dep' => (int) $dep,
'mm_num' => (int) ($row->mm_num ?? 0),
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
'mm_level' => (string) ($row->mm_level ?? ''),
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
];
}
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->insert([
'mt_idx' => $mtIdx,
'lg_idx' => $destLg,
'mm_name' => (string) ($row->mm_name ?? ''),
'mm_link' => (string) ($row->mm_link ?? ''),
'mm_pidx' => $newPidx,
'mm_dep' => (int) ($row->mm_dep ?? 0),
'mm_num' => (int) ($row->mm_num ?? 0),
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
'mm_level' => (string) ($row->mm_level ?? ''),
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
]);
$idMap[$oldId] = (int) $this->getInsertID();
}
$this->db->transComplete();
}

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'packaging_unit';
protected $primaryKey = 'pu_idx';
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
{
use \App\Models\Traits\Auditable;
protected $table = 'sales_agency';
protected $primaryKey = 'sa_idx';
protected $returnType = 'object';

View File

@@ -6,12 +6,14 @@ use CodeIgniter\Model;
class ShopOrderItemModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'shop_order_item';
protected $primaryKey = 'soi_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'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
{
use \App\Models\Traits\Auditable;
protected $table = 'shop_order';
protected $primaryKey = 'so_idx';
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

@@ -5,8 +5,26 @@
<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>
</div>
</section>
<div class="border border-gray-300 rounded-lg p-4 mt-2">
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<style>
/* 인쇄 시 승인 대기 표를 A4 너비에 맞춤(잘림 방지) */
@media print {
@page { size: A4 portrait; margin: 10mm; }
.approval-print-wrap { border: 0 !important; padding: 0 !important; overflow: visible !important; }
.approval-print-wrap table.data-table { width: 100% !important; table-layout: fixed; font-size: 9px; }
.approval-print-wrap .data-table th,
.approval-print-wrap .data-table td {
white-space: normal !important; /* nowrap 해제 → 줄바꿈 허용 */
word-break: break-word;
overflow-wrap: anywhere;
padding: 2px 3px !important;
vertical-align: top;
}
/* '관리'(승인/반려 버튼) 열은 인쇄에 불필요 → 제외해 가로 폭 확보 */
.approval-print-wrap .col-actions { display: none !important; }
}
</style>
<div class="approval-print-wrap border border-gray-300 rounded-lg p-4 mt-2">
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="no-print mb-4 flex flex-wrap items-center gap-2 text-sm">
<label for="status" class="font-bold text-gray-700 shrink-0">상태</label>
<select id="status" name="status" class="border border-gray-300 rounded px-3 py-1.5 text-sm min-w-[12rem] w-48 max-w-full">
<option value="pending" <?= ($status ?? 'pending') === 'pending' ? 'selected' : '' ?>>승인 대기</option>
@@ -25,7 +43,7 @@
<th class="text-center">요청 역할</th>
<th class="text-center">상태</th>
<th class="text-center">처리일</th>
<th class="text-center">관리</th>
<th class="text-center col-actions">관리</th>
</tr>
</thead>
<tbody>
@@ -45,7 +63,7 @@
<?php if (($row->mar_status ?? '') === 'rejected'): ?>반려<?php endif; ?>
</td>
<td class="text-center"><?= esc($row->mar_processed_at ?? '-') ?></td>
<td class="text-center">
<td class="text-center col-actions">
<?php if (($row->mar_status ?? '') === 'pending'): ?>
<div class="flex items-center justify-center gap-1">
<form action="<?= base_url('admin/access/approve/' . $row->mar_idx) ?>" method="post" class="inline">

View File

@@ -115,8 +115,9 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구코드</label>
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div>
<label class="block text-sm font-bold text-gray-700 w-28">구·군(동코드)</label>
<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 class="flex flex-wrap items-center gap-2">
@@ -165,3 +166,53 @@
'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
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();
if ($currentPath === 'bag/designated-shops') {
$readOnly = false;
@@ -11,27 +15,37 @@ if ($currentPath === 'bag/designated-shops') {
?>
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
<style>
/* 목록 → 지정판매소 정보 아래 (가로 2열 없음) */
/* 목록 왼쪽 → 지정판매소 정보 오른쪽 (좌우 2열) */
.ds-split {
display: flex;
flex-direction: column;
flex-direction: row;
gap: 0.5rem;
min-height: 0;
flex: 1 1 auto;
align-items: stretch;
}
.ds-list-panel {
flex: 0 1 auto;
width: 100%;
max-height: 42vh;
flex: 1 1 55%;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #ccc;
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 {
flex: 1 1 auto;
width: 100%;
flex: 1 1 45%;
min-width: 0;
min-height: 12rem;
border: 1px solid #ccc;
@@ -39,6 +53,12 @@ if ($currentPath === 'bag/designated-shops') {
display: flex;
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 {
font-size: 12px;
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">
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<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>
<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): ?>
<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; ?>
@@ -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>
<?php endforeach; ?>
</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>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<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-list-panel">
<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]">
<thead>
<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="py-2.5 px-2 w-24">구·군</th>
<th class="py-2.5 px-2 w-24 text-left">지정일</th>
<th class="py-2.5 px-2 w-24">구역</th>
<th class="py-2.5 px-2 ds-col-tight">대표자명</th>
<th class="py-2.5 px-2 ds-col-tight">상호명</th>
<th class="py-2.5 px-2 ds-col-zip text-left">우편번호</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>
<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="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="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="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="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="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="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>
</tr>
</thead>
<tbody id="ds-list-body">
@@ -266,18 +284,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
if ($addrCombinedList === '') {
$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-gray-600"><?= esc($ggLabel) ?></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 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></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 ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></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 font-mono text-gray-700"><?= esc($row->ds_tel ?? '') ?></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-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
<td class="py-2.5 px-2 text-gray-600" title="<?= esc($zone) ?>"><?= esc($zone) ?></td>
<td class="py-2.5 px-2 text-left">
<?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>
@@ -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-detail-inner" id="ds-detail-box">
<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 class="ds-detail-info-wrap">
<table class="w-full data-table text-sm" id="ds-detail-info-table" aria-label="지정판매소 상세">
<thead>
<tr>
<th>판매소번호</th>
<th class="ds-col-tight-head">상호명</th>
<th>우편번호</th>
<th>사업자번호</th>
<th>일반전화</th>
<th class="ds-col-tight-head">대표자명</th>
<th>이메일</th>
<th>업태</th>
<th>업종</th>
<th>지정일자</th>
<th>지자체</th>
<th>도로명주소</th>
<th>지번주소</th>
<th>상세주소</th>
<th>개인전화</th>
<th>구·군</th>
<th>구역</th>
<th>가상계좌(은행)</th>
<th>계좌번호</th>
<th>종사업장번호</th>
<th>변경일자</th>
<th>영업상태</th>
<th>등록일시</th>
<th>변경사유</th>
<th class="no-print w-14 text-center">지도</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-left" data-ro="ds_shop_no">—</td>
<td class="text-left ds-col-tight-cell" data-ro="ds_name">—</td>
<td class="text-left" data-ro="ds_zip">—</td>
<td class="text-left" data-ro="ds_biz_no">—</td>
<td class="text-left" data-ro="ds_tel">—</td>
<td class="text-left ds-col-tight-cell" data-ro="ds_rep_name">—</td>
<td class="text-left" data-ro="ds_email">—</td>
<td class="text-left" data-ro="ds_biz_type">—</td>
<td class="text-left" data-ro="ds_biz_kind">—</td>
<td class="text-left" data-ro="ds_designated_at">—</td>
<td class="text-left" data-ro="lg_name">—</td>
<td class="text-left min-w-[10rem]"><span data-ro="ds_addr">—</span></td>
<td class="text-left" data-ro="ds_addr_jibun">—</td>
<td class="text-left" data-ro="ds_addr_detail">—</td>
<td class="text-left" data-ro="ds_rep_phone">—</td>
<td class="text-left" data-ro="gugun_name">—</td>
<td class="text-left" data-ro="ds_zone_code">—</td>
<td class="text-left" data-ro="ds_va_bank">—</td>
<td class="text-left" data-ro="ds_va_account">—</td>
<td class="text-left" data-ro="ds_branch_no">—</td>
<td class="text-left" data-ro="ds_state_changed_at">—</td>
<td class="text-left" data-ro="state_label">—</td>
<td class="text-left" data-ro="ds_regdate">—</td>
<td class="text-left min-w-[8rem]" data-ro="ds_change_reason">—</td>
<td class="text-center no-print">
<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>
</td>
</tr>
</tbody>
</table>
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
<div class="ds-row ds-row-wide">
<div class="ds-field-label">판매소번호</div>
<div class="ds-field-value ds-value-shop-wide" data-ro="ds_shop_no">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">상호명</div>
<div class="ds-field-value" data-ro="ds_name">—</div>
<div class="ds-field-label">우편번호</div>
<div class="ds-field-value" data-ro="ds_zip">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">사업자번호</div>
<div class="ds-field-value" data-ro="ds_biz_no">—</div>
<div class="ds-field-label">일반전화</div>
<div class="ds-field-value" data-ro="ds_tel">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">대표자명</div>
<div class="ds-field-value" data-ro="ds_rep_name">—</div>
<div class="ds-field-label">이메일</div>
<div class="ds-field-value" data-ro="ds_email">—</div>
</div>
<div class="ds-row ds-row-wide">
<div class="ds-field-label">도로명주소</div>
<div class="ds-field-value ds-field-value-with-map">
<span class="ds-addr-text" data-ro="ds_addr">—</span>
<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>
</div>
</div>
<div class="ds-row ds-row-wide">
<div class="ds-field-label">지번주소</div>
<div class="ds-field-value" data-ro="ds_addr_jibun">—</div>
</div>
<div class="ds-row ds-row-wide">
<div class="ds-field-label">상세주소</div>
<div class="ds-field-value" data-ro="ds_addr_detail">—</div>
</div>
<div class="ds-row ds-row-wide">
<div class="ds-field-label">개인전화</div>
<div class="ds-field-value" data-ro="ds_rep_phone">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">지정일자</div>
<div class="ds-field-value" data-ro="ds_designated_at">—</div>
<div class="ds-field-label">업태</div>
<div class="ds-field-value" data-ro="ds_biz_type">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">업종</div>
<div class="ds-field-value" data-ro="ds_biz_kind">—</div>
<div class="ds-field-label">구·군(동코드)</div>
<div class="ds-field-value" data-ro="dong_display">—</div>
</div>
<div class="ds-row ds-row-4-even">
<div class="ds-field-label">구역</div>
<div class="ds-field-value" data-ro="ds_zone_code">—</div>
<div class="ds-field-label">종사업장번호</div>
<div class="ds-field-value" data-ro="ds_branch_no">—</div>
</div>
<div class="ds-row ds-row-4-even">
<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>
@@ -442,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
fieldsWrap.classList.remove('hidden');
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) {
var id = d.ds_idx;
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');
if (mapBtnRo) {
mapBtnRo.addEventListener('click', function (ev) {
@@ -495,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
delBtn.disabled = true;
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>
@@ -505,7 +645,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr>
<th class="text-center">번호</th>
<th>지자체</th>
<th>구·군</th>
<th>구·군(동코드)</th>
<th class="text-center">지정일</th>
<th>구역</th>
<th>대표자명</th>
@@ -548,11 +688,15 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr>
<td class="text-center"><?= esc($shortNoP) ?></td>
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
<?php
$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-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($zipP) ?></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 = [
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
['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' => [
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
@@ -62,6 +62,9 @@ $navPartial = [
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<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 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>
@@ -91,6 +94,13 @@ tailwind.config = {
.data-table tbody td { color: #374151; }
.data-table tbody tr:last-child td { border-bottom: 0; }
.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 {
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
body.gov-portal-shell { background: #fff; display: block; }
@@ -105,6 +115,11 @@ tailwind.config = {
<?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('/')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_click', $navPartial) ?>
<div class="portal-header-utils" style="display:flex;align-items:center;gap:.5rem;">
<div class="ws-fontctl" title="글씨 크기 조절" style="display:inline-flex;align-items:center;gap:2px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.25);border-radius:6px;padding:1px;">
<button type="button" id="wsFontMinus" title="글씨 작게" style="width:24px;height:22px;border:0;background:transparent;color:#fff;cursor:pointer;font-size:11px;line-height:1;border-radius:5px;">A</button>
<span id="wsFontPct" style="min-width:34px;text-align:center;color:#fff;font-size:.68rem;font-weight:600;">100%</span>
<button type="button" id="wsFontPlus" title="글씨 크게" style="width:24px;height:22px;border:0;background:transparent;color:#fff;cursor:pointer;font-size:14px;line-height:1;border-radius:5px;">A+</button>
</div>
<span class="user-line">
<?php if ($effectiveLgName !== ''): ?><strong><?= esc($effectiveLgName) ?></strong> · <?php endif; ?>
<?= esc($levelName) ?> · <?= esc($mbName) ?>님
@@ -161,6 +176,57 @@ tailwind.config = {
window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
window.addEventListener('pagehide', closeStuckOverlays);
})();
// 글씨 크기 조절(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 () {
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 saveTimer = null;
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));
scale = s;
var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
}
applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
})();
</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>
</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/>
</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">
<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>

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/>
</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">
<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>

View File

@@ -27,6 +27,8 @@
<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">담당자명</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">
<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 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">
<?php
$cat = (string) ($row->mg_dept_code ?? '');
@@ -67,7 +71,7 @@
</tr>
<?php endforeach; ?>
<?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; ?>
</tbody>
</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') ?>">
<?= csrf_field() ?>
<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">
<thead>
<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 ?>">
<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 class="text-center">
<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-level="<?= esc($row->mm_level) ?>"
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>
<?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>
<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 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">
<label class="block text-sm font-medium text-gray-700">노출 대상</label>
<?php if ($mtCode === 'admin'): ?>
@@ -258,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
const editIdInput = document.getElementById('mm_idx_edit');
const mmPidxInput = form.querySelector('[name="mm_pidx"]');
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 levelCbs = document.querySelectorAll('.mm-level-cb');
const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
@@ -297,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
const level = (this.dataset.level || '').toString().trim();
const view = this.dataset.view || 'Y';
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.querySelector('[name="mm_name"]').value = name;
form.querySelector('[name="mm_link"]').value = link;
mmPidxInput.value = '0';
mmDepInput.value = String(dep);
// 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능
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) {
levelAll.checked = (level === '');
levelCbs.forEach(function(cb){
@@ -339,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '하위 메뉴 등록';
btnSubmit.textContent = '등록';
btnCancel.style.display = 'inline-block';
// 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시)
if (posWrap) posWrap.style.display = '';
enableAllParentOptions();
setPosition('child', parentId);
// 노출 기본값 재설정
form.querySelector('[name="mm_is_view"]').checked = true;
if (!isAdminType && levelAll) {
@@ -367,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '메뉴 등록';
btnSubmit.textContent = '등록';
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() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
if (autoScrollRaf) {
window.cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
document.body.classList.remove('menu-row-dragging');
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
@@ -463,41 +581,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
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() {
if (!draggingActive || !draggingRow || !placeholderRow) {
clearDragState();
return;
}
var prevRow = placeholderRow.previousElementSibling;
tbody.insertBefore(draggingRow, placeholderRow);
refreshOrderNos();
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
if (draggedDep > 0) {
var valid = false;
if (prevRow && prevRow.classList.contains('menu-row')) {
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10);
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10);
if (prevRow === draggingRow) {
valid = true;
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
valid = true;
}
}
if (!valid) {
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
// 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
if (newPidx <= 0) {
alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
restoreOrderByIds(originalOrderIds);
clearDragState();
return;
}
draggingRow.dataset.mmPidx = String(newPidx);
}
const approved = window.confirm('변경한 순서를 적용할까요?');
if (!approved) {
// 취소 시 원래 순서·상위로 되돌림
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
}
restoreOrderByIds(originalOrderIds);
} else {
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
moveForm.submit();
}
clearDragState();
@@ -511,6 +664,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
e.preventDefault();
originalOrderIds = collectCurrentOrderIds();
draggingRow = row;
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
placeholderRow = makePlaceholder();
draggingActive = true;
row.classList.add('dragging');
@@ -518,6 +673,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
tbody.insertBefore(placeholderRow, row.nextSibling);
lastClientY = e.clientY;
updatePlaceholderPosition();
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseup', onMouseUp);
});

View File

@@ -13,6 +13,7 @@
<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>
<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 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">
<!-- 입출고 리스트 -->
<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="overflow-auto max-h-[520px]">
<table class="w-full data-table text-sm">
@@ -187,7 +187,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
</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="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<?php if ($selectedGroup): ?>
@@ -216,7 +216,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
</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="overflow-auto max-h-[280px]">
<table class="w-full data-table text-sm">
@@ -265,7 +265,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
<!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) -->
<?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>
<form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm">
<?= csrf_field() ?>

View File

@@ -18,16 +18,15 @@ declare(strict_types=1);
$printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장';
$printDate = date('Y-m-d');
$printExtraLines = $printSubtitleLines ?? [];
$catKeys = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste'];
// 스티커·공동주택용(apt)·용기(container)는 품목 필터에서 제외
// 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
$catKeys = ['general', 'reuse', 'public_use', 'food', 'waste'];
$catLabels = [
'general' => '일반용',
'food' => '음식물',
'sticker' => '스티커',
'reuse' => '재사용',
'apt' => '공동주택용',
'public_use' => '공공용',
'container' => '용기',
'waste' => '폐기물',
'general' => '일반용 봉투',
'reuse' => '재사용 봉투',
'public_use' => '공공용 봉투',
'food' => '음식물 봉투',
'waste' => '폐기물 봉투',
];
$exportParams = [
@@ -41,6 +40,9 @@ $exportParams = [
if ($cats !== []) {
$exportParams['cat'] = $cats;
}
if (($size ?? '') !== '') {
$exportParams['size'] = $size;
}
$excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
?>
<?= view('components/print_header', [
@@ -115,6 +117,20 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
<?= esc($catLabels[$ck] ?? $ck) ?>
</label>
<?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>
</fieldset>
<div>
@@ -234,6 +250,80 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
</table>
</div>
<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>
<script>

View File

@@ -7,11 +7,18 @@
<?php else: ?>
<form action="<?= base_url('admin/select-local-government') ?>" method="POST" class="space-y-3">
<?= csrf_field() ?>
<?php $currentLgIdx = (int) ($currentLgIdx ?? 0); ?>
<ul class="space-y-2">
<?php foreach ($list as $lg): ?>
<?php $isCurrent = ((int) $lg->lg_idx === $currentLgIdx); ?>
<li class="flex items-center gap-2">
<input type="radio" name="lg_idx" id="lg_<?= $lg->lg_idx ?>" value="<?= (int) $lg->lg_idx ?>" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
<input type="radio" name="lg_idx" id="lg_<?= $lg->lg_idx ?>" value="<?= (int) $lg->lg_idx ?>" <?= $isCurrent ? 'checked' : '' ?> class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
<label for="lg_<?= $lg->lg_idx ?>" class="text-sm font-medium text-gray-800 cursor-pointer"><?= esc($lg->lg_name) ?> (<?= esc($lg->lg_code) ?>)</label>
<?php if ($isCurrent): ?>
<span class="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5">
<i class="fa-solid fa-check"></i> 현재 선택
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>

View File

@@ -11,6 +11,7 @@
<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>
<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 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>

View File

@@ -24,6 +24,7 @@ $editLoginLocked = $editLockUntil !== null && $editLockUntil !== '' && strtotime
<div class="flex flex-wrap items-center gap-2">
<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="변경 시에만 입력"/>
<p class="w-full text-xs text-gray-500 pl-20">변경 시 8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div>
<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>

View File

@@ -8,7 +8,25 @@
</div>
</div>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<style>
/* 인쇄 시 회원 목록 표를 A4 너비에 맞춤(잘림 방지) */
@media print {
@page { size: A4 portrait; margin: 10mm; }
.user-print-wrap { border: 0 !important; padding: 0 !important; overflow: visible !important; }
.user-print-wrap table.data-table { width: 100% !important; table-layout: fixed; font-size: 9px; }
.user-print-wrap .data-table th,
.user-print-wrap .data-table td {
white-space: normal !important; /* nowrap 해제 → 줄바꿈 허용 */
word-break: break-word;
overflow-wrap: anywhere;
padding: 2px 3px !important;
vertical-align: top;
}
/* '관리'(작업 버튼) 열은 인쇄에 불필요 → 제외해 가로 폭 확보 */
.user-print-wrap .col-actions { display: none !important; }
}
</style>
<div class="user-print-wrap border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
@@ -20,7 +38,7 @@
<th class="text-center">상태</th>
<th class="text-left">로그인 잠금</th>
<th class="text-left">가입일</th>
<th class="text-center">관리</th>
<th class="text-center col-actions">관리</th>
</tr>
</thead>
<tbody class="text-right">
@@ -56,7 +74,7 @@
?>
</td>
<td class="text-left pl-2"><?= esc($row->mb_regdate ?? '') ?></td>
<td class="text-center">
<td class="text-center col-actions">
<?php if ((int) $row->mb_state !== 0): ?>
<?php if ($loginLocked): ?>
<form action="<?= base_url('admin/users/unlock-login/' . $row->mb_idx) ?>" method="POST" class="inline mr-1" onsubmit="return confirm('로그인 잠금을 해제할까요?');">

View File

@@ -15,6 +15,9 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<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>
<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"/>
@@ -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">
<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">
<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>
<span class="leading-none flex flex-col">
<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>
</div>
</form>
<script>
// 뒤로가기로 bfcache(뒤로/앞으로 캐시)에서 복원된 경우, 서버에 재요청하여
// 이미 로그인 상태면 대시보드로 리다이렉트되게 한다.
window.addEventListener('pageshow', function (e) {
if (e.persisted) { window.location.reload(); }
});
</script>
<?= $this->endSection() ?>

View File

@@ -13,6 +13,7 @@
<div>
<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"/>
<p class="mt-1 text-xs text-gray-500">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</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>

View File

@@ -158,9 +158,32 @@ $mapId = 'mainKakaoMap';
var bounds = new kakao.maps.LatLngBounds();
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() {
if (placed > 0) map.setBounds(bounds);
setTimeout(function () { map.relayout(); if (placed > 0) map.setBounds(bounds); }, 150);
refit();
setTimeout(refit, 150);
setTimeout(refit, 600);
}
if (pending === 0) { done(); return; }

View File

@@ -79,7 +79,7 @@ $prevAvgLabel = ($trendBasis ?? '') === 'month' ? '전년 동월' : '전년 평
</div>
<?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="overflow-auto max-h-[28rem]">
<table class="w-full data-table text-sm">

View File

@@ -59,7 +59,7 @@ $seasonScope = $seasonMonthsLabel !== ''
</p>
</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">
계절별 판매 추이 분석 조회 내역
<?php if ($queried): ?> — <?= esc($seasonScope) ?><?php endif; ?>

View File

@@ -12,6 +12,7 @@ $showKindActions = $canManageKinds;
$selectedKindId = (int) ($selectedKind->ck_idx ?? 0);
$colCount = 6 + ($showKindActions ? 1 : 0);
$detailColCount = 7 + ($canManageDetails ? 1 : 0);
$embedQs = ! empty($isEmbed) ? '&embed=1' : ''; // 워크스페이스 탭 안에서는 embed 유지(중첩 셸 방지)
/** 상태 배지 (업무현황 스타일의 가벼운 pill) */
$stateBadge = static function (int $state): string {
@@ -20,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>';
};
?>
<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">
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
@@ -35,14 +36,14 @@ $stateBadge = static function (int $state): string {
<table class="w-full text-[13px]">
<thead>
<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-20">코드</th>
<th class="py-2.5 px-2">코드명</th>
<th class="py-2.5 px-2 w-20 text-left">세부코드</th>
<th class="py-2.5 px-2 w-16 text-left">상태</th>
<th class="py-2.5 px-2 w-32">등록일</th>
<th class="py-2.5 px-2 w-12 text-left whitespace-nowrap">번호</th>
<th class="py-2.5 px-2 w-20 whitespace-nowrap">코드</th>
<th class="py-2.5 px-2 whitespace-nowrap">코드명</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 whitespace-nowrap">상태</th>
<th class="py-2.5 px-2 w-32 whitespace-nowrap">등록일</th>
<?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; ?>
</tr>
</thead>
@@ -51,18 +52,18 @@ $stateBadge = static function (int $state): string {
<?php $i = 0; foreach ($codeKinds as $row): $i++; ?>
<?php
$isSelected = (int) $row->ck_idx === $selectedKindId;
$detailUrl = base_url('bag/code-kinds?ck_idx=' . (int) $row->ck_idx);
$detailUrl = base_url('bag/code-kinds?ck_idx=' . (int) $row->ck_idx . $embedQs);
?>
<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') ?>'">
<td class="py-2.5 px-2 text-left text-gray-500"><?= (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 font-medium text-gray-900"><?= 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"><?= $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-left text-gray-500 whitespace-nowrap"><?= (string) $i ?></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 whitespace-nowrap"><?= esc($row->ck_name) ?></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 whitespace-nowrap"><?= $stateBadge((int) ($row->ck_state ?? 0)) ?></td>
<td class="py-2.5 px-2 text-gray-500 text-[12px] whitespace-nowrap"><?= esc($row->ck_regdate ?? '') ?></td>
<?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>
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
<?= csrf_field() ?>
@@ -101,15 +102,15 @@ $stateBadge = static function (int $state): string {
<table class="w-full text-[13px]">
<thead>
<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-20">코드</th>
<th class="py-2.5 px-2">코드명</th>
<th class="py-2.5 px-2 w-16 text-left">범위</th>
<th class="py-2.5 px-2 w-14 text-left">정렬</th>
<th class="py-2.5 px-2 w-16 text-left">상태</th>
<th class="py-2.5 px-2 w-32">등록일</th>
<th class="py-2.5 px-2 w-12 text-left whitespace-nowrap">번호</th>
<th class="py-2.5 px-2 w-20 whitespace-nowrap">코드</th>
<th class="py-2.5 px-2 whitespace-nowrap">코드명</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 whitespace-nowrap">정렬</th>
<th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">상태</th>
<th class="py-2.5 px-2 w-32 whitespace-nowrap">등록일</th>
<?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; ?>
</tr>
</thead>
@@ -121,17 +122,17 @@ $stateBadge = static function (int $state): string {
$scopeLabel = $isPlatform ? '공통' : '지자체';
?>
<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 font-mono text-gray-700"><?= 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 text-left">
<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 whitespace-nowrap"><?= esc($row->cd_code) ?></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 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>
</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"><?= $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-left text-gray-600 whitespace-nowrap"><?= (int) ($row->cd_sort ?? 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] whitespace-nowrap"><?= esc($row->cd_regdate ?? '') ?></td>
<?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])): ?>
<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('이 세부코드를 삭제하시겠습니까?');">

View File

@@ -144,7 +144,7 @@ if ($initialRows === []) {
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2 mt-2">
<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="overflow-auto max-h-[300px]">
<table class="w-full data-table text-sm">
@@ -180,7 +180,7 @@ if ($initialRows === []) {
</div>
</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="overflow-auto max-h-[300px]">
<table class="w-full data-table text-sm">

View File

@@ -94,7 +94,7 @@
<span class="text-gray-600 ml-2">왼쪽 목록에서 삭제할 발주를 선택한 뒤 「삭제 실행」을 누르세요.</span>
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<section class="xl:col-span-5 border border-gray-300 rounded-lg 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="overflow-auto max-h-[410px]">
<table class="w-full data-table text-sm">
@@ -114,7 +114,7 @@
<tr class="<?= $rowNormal ? '' : 'opacity-60' ?>">
<td class="text-center align-middle">
<?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 else: ?>
<span class="text-gray-400" title="삭제할 수 없는 상태">—</span>
@@ -135,7 +135,8 @@
</section>
<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 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">
<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>
@@ -143,28 +144,64 @@
</section>
</div>
</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: ?>
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form">
<?= csrf_field() ?>
<?php if ($editMode): ?>
<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">
<legend class="text-xs font-bold text-gray-600 px-1">변경 구분</legend>
<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="bo_change_mode" value="price" <?= $changeMode === 'price' ? 'checked' : '' ?> />
<span>발주·도매·판매 단가</span>
</label>
<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>
<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>
<input type="hidden" name="bo_change_mode" value="meta" />
<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">
<span class="font-bold text-gray-700">원 발주번호(LOT)</span>
<span class="font-mono text-gray-900"><?= esc($orderLotNo !== '' ? $orderLotNo : '-') ?></span>
<span class="text-gray-500">발주 #<?= (int) ($editDefaults['bo_source_idx'] ?? 0) ?></span>
<span class="text-gray-500">— 변경 저장 시 원 발주번호는 유지되고, 이전 내용은 이력(버전)으로 보존됩니다.</span>
<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>
</div>
<?php endif; ?>
<?php if ($hubReturn && $orderReturnMonth !== ''): ?>
<input type="hidden" name="order_return_hub" value="1" />
@@ -188,48 +225,26 @@
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<section class="xl:col-span-5 border border-gray-300 rounded-lg 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 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="grid grid-cols-1 xl:grid-cols-12 gap-2 items-start">
<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 ? '발주 변경 수정' : '발주등록' ?></div>
<div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<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>
<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 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">
<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">
@@ -272,7 +287,7 @@
<th class="w-12 text-center">번호</th>
<th class="w-16 text-center">선택</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>
<?php if ($editMode): ?>
@@ -324,16 +339,17 @@
</div>
<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>
<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>
<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>
<?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>
</section>
</div>
<section class="border border-gray-300 rounded-lg 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">
<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-[560px]">
<table class="w-full data-table text-sm order-reference-table">
<thead>
<tr>
@@ -365,6 +381,40 @@
<?php endif; ?>
</tbody>
</table>
</div>
</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>
@@ -384,6 +434,96 @@
}
</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>
(() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
@@ -548,7 +688,7 @@
const renderSelectedRows = () => {
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
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);
updateTotals();
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) => {
row.addEventListener('click', (event) => {
const button = event.target.closest('.js-toggle-bag');

View File

@@ -26,7 +26,7 @@ $devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
</form>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300 rounded-lg">
<section class="xl:col-span-2 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>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
@@ -52,7 +52,7 @@ $devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
</div>
</section>
<section class="xl:col-span-3 border border-gray-300 rounded-lg">
<section class="xl:col-span-3 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>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
@@ -85,7 +85,7 @@ $devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
</section>
</div>
<section class="border border-gray-300 rounded-lg">
<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>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">

View File

@@ -22,7 +22,7 @@ $returns = is_array($returns ?? null) ? $returns : [];
</form>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300 rounded-lg">
<section class="xl:col-span-2 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>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
@@ -50,7 +50,7 @@ $returns = is_array($returns ?? null) ? $returns : [];
</div>
</section>
<section class="xl:col-span-3 border border-gray-300 rounded-lg">
<section class="xl:col-span-3 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>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
@@ -79,7 +79,7 @@ $returns = is_array($returns ?? null) ? $returns : [];
</section>
</div>
<section class="border border-gray-300 rounded-lg">
<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>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">

View File

@@ -35,7 +35,7 @@
<div id="scan-message" class="text-sm text-gray-600"></div>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300 rounded-lg">
<section class="xl:col-span-2 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>
<table class="w-full text-sm">
<tr><th class="text-left px-3 py-1.5 w-28">판매소 코드</th><td id="shop-info-code" class="px-3 py-1.5">-</td></tr>
@@ -46,7 +46,7 @@
</table>
</section>
<section class="xl:col-span-3 border border-gray-300 rounded-lg">
<section class="xl:col-span-3 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>
<div class="max-h-56 overflow-auto">
<table class="w-full data-table text-sm">
@@ -97,7 +97,7 @@
</section>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<section class="border border-gray-300 rounded-lg">
<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>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
@@ -118,7 +118,7 @@
</div>
</section>
<section class="border border-gray-300 rounded-lg">
<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>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">

View File

@@ -28,7 +28,7 @@ $devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
<div id="scan-message" class="text-sm text-gray-600"></div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<section class="border border-gray-300 rounded-lg">
<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>
<table class="w-full text-sm">
<tr><th class="text-left px-3 py-1.5 w-28">판매소 코드</th><td id="shop-info-code" class="px-3 py-1.5">-</td></tr>
@@ -39,7 +39,7 @@ $devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
</table>
</section>
<section class="border border-gray-300 rounded-lg">
<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>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
@@ -70,7 +70,7 @@ $devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
</section>
</div>
<section class="border border-gray-300 rounded-lg">
<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>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">

View File

@@ -42,14 +42,14 @@ foreach ($subtotals as $subtotal) {
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/inventory/export?' . http_build_query(['base_date' => $baseDate, 'agency_idx' => $agencyIdx])) ?>" 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>
<a href="<?= base_url('bag/inventory/inspection-select') ?>" class="no-print border border-blue-300 text-blue-700 px-3 py-1 rounded-sm text-sm hover:bg-blue-50 transition">실사 선별 조회</a>
</div>
</form>
</section>
<div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0">
<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>
<tr>
<th class="w-36 text-center">품 목 구 분</th>

View File

@@ -74,7 +74,7 @@ $sheetTotalDiff = $sheetTotalActual - $sheetTotalQty;
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-2">
<div class="border border-gray-300 rounded-lg bg-white">
<div 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-[500px]">
<table class="w-full data-table text-sm">
@@ -134,7 +134,7 @@ $sheetTotalDiff = $sheetTotalActual - $sheetTotalQty;
</div>
<div class="space-y-2">
<div class="border border-gray-300 rounded-lg bg-white">
<div 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>
<form method="post" action="<?= base_url('bag/inventory/inspection-select/save') ?>" id="inspection-save-form">
<?= csrf_field() ?>
@@ -224,7 +224,7 @@ $sheetTotalDiff = $sheetTotalActual - $sheetTotalQty;
</form>
</div>
<div class="border border-gray-300 rounded-lg bg-white">
<div 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-[240px]">
<table class="w-full data-table text-sm">

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