105 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
taekyoungc
287691328e feat: 대시보드 바로가기 새 탭 열기 + 탭 새로고침 시각 피드백
- 업무 현황의 "자주 가는 화면"·"최근 방문 메뉴"·메뉴검색 결과 클릭 시
  워크스페이스 새 탭으로 열기(부모 wsOpenTab 호출, 밖이면 화면 이동 폴백)
- 탭 새로고침(↻): 아이콘 회전 + 화면 잠깐 페이드 후 복구로 새로고침 확인 가능하게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:53:22 +09:00
taekyoungc
fd3da428ab feat: 로고 클릭 시 업무 현황 대시보드로 이동
- 관리자 레이아웃 로고 링크 admin → / (업무 현황)
- 워크스페이스 내 로고 클릭은 전체 새로고침 대신 1분할로 전환 후 업무 현황 탭 열기/포커스

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:56:29 +09:00
taekyoungc
a8afaf4af2 style: 표/패널 UI 전면 통일 + 화면설명 드로어·글씨크기·탭 개선
표 디자인
- 모든 표를 가벼운 스타일로 통일(.data-table 경량화: 작은 회색 헤더·연한 구분선·hover)
- 표/패널 바깥 테두리 둥글게(rounded-lg) 일괄 적용, 표 래퍼에 패딩 카드(p-4) 통일
- 표 헤더·데이터 정렬을 전 화면 좌측 기준으로 통일
  - .data-table th/td text-align:left (전역), 흩어진 center/right 정렬 정리
  - 재디자인 Tailwind 표(포장단위·단가·기본코드·담당자·업체·판매대행소·무료대상자·지정판매소)도 셀 좌측화
- 기본정보관리 등 나머지 소메뉴 표를 기본 코드 관리 스타일(가벼운 표·상태 pill)로 재디자인

워크스페이스/공통
- "이 화면 설명" → 새 탭 대신 우측 드로어 팝업(현재 화면과 동시에 보기, Esc·드래그 폭조절)
- 상단바 글씨 크기 조절(A−/A+), 작업 내용에 zoom 적용
- 탭 최대치 도달 시 자동 삭제 대신 안내 토스트, "모두 닫기"(업무 현황 탭은 보존)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:26:36 +09:00
taekyoungc
912ffdbe23 feat: 워크스페이스 분할 보기(2·4분할) + 구분선 드래그 크기 조절
- 탭바에 분할 레이아웃 버튼 추가: 1분할 / 2분할(좌우) / 2분할(상하) / 4분할
  - iframe reparent 없이 absolute 위치만 재계산해 작업 상태 보존
  - 포커스된 칸에 탭 클릭으로 화면 배치, 칸 헤더(↻ 새로고침 · × 비우기)
  - 칸 안 클릭 시 해당 칸 포커스
- 분할 구분선 드래그로 칸 크기(비율) 조절, 더블클릭 50% 초기화
  - 드래그 중 투명 오버레이로 iframe 위에서도 이벤트 유지
  - 비율 12~88% 제한
- 레이아웃·칸 배치·비율을 세션에 저장/복원(계정별 격리 유지)
- 단축키를 포커스 칸 기준으로 동작하도록 정리
- 매뉴얼: [화면 구성·워크스페이스] 에 분할 보기·크기 조절 절 추가, 개요 안내 보강
- e2e: 분할 보기(2·4분할 전환) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:19:37 +09:00
taekyoungc
4d9343e980 feat: GBLS 리브랜딩 + 매뉴얼 보강 + 워크스페이스/코드관리 UX 개선
리브랜딩
- 서비스명 "종량제 시스템" → "GBLS", 헤더 로고에 풀네임(Garbage Bag Logistics System) 병기
  (gov-portal·공통 브랜드·로그인/welcome 셸·타이틀·푸터 전반)

매뉴얼
- 신규 페이지 [로그인·회원가입·계정](01_account.md): 가입 항목·관리자 승인·2차 인증 흐름
- [화면 구성·워크스페이스·단축키]에 계정 전환 시 탭 초기화 안내 추가

워크스페이스(탭)
- 탭 전환 시 좌측 사이드바 강조 동기화(메뉴 없는 화면은 강조 해제, 경로 폴백 매칭)
- 소메뉴 좌측 아이콘(▸/·) 전부 제거 — 활성 메뉴는 배경 강조로만 구분
- 탭을 사용자(mb_idx)별로 격리: 다른 아이디 로그인 시 이전 탭 복원 안 함
- 사이드바 FAQ 링크 제거(자주 묻는 질문은 매뉴얼에 통합)

기본 코드관리 화면
- 업무현황 카드 스타일로 재디자인(가벼운 표·상태/범위 pill·단일 구분선)
- render()에 $bare 옵션 추가 → 이미 카드형인 화면은 바깥 래퍼 생략

기타
- .claude/settings.local.json(개인 권한 설정) .gitignore 추가
- e2e: 워크스페이스(동기화·계정격리) + 매뉴얼(계정·단축키·검색) 케이스 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:43:24 +09:00
taekyoungc
7e32f579e8 feat: 워크스페이스 편의 개선 + 매뉴얼에 화면구성·단축키 페이지 추가
워크스페이스(탭)
- 탭 전환 시 좌측 사이드바(대메뉴/소메뉴) 강조 자동 동기화
  - nav 스크립트에 window.govPortalNav.syncByUrl() 공개, renderSidebar(overrideHref) 확장
- 키보드 단축키(Alt 기반): Alt+1~9 탭 이동, Alt+W 닫기, Alt+[ / Alt+] 이전·다음
  - iframe 내부 포커스에서도 동작하도록 같은 출처 문서에 핸들러 부착
- 탭 가운데(휠) 클릭으로 닫기, 잘린 탭 제목 전체 툴팁

매뉴얼
- 신규 페이지 [화면 구성·워크스페이스·단축키] (05_workspace.md, 목차 2번째)
  - 화면 구성, 탭 사용법·유지 범위, 단축키 표, 이동/도움말 안내
- 개요 페이지에서 새 페이지로 안내

e2e: 워크스페이스(사이드바 동기화·가운데클릭) + 매뉴얼(새 페이지·단축키·검색) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:09:25 +09:00
taekyoungc
1a443de02e feat: 매뉴얼 검색·소메뉴 아이콘 개선·워크스페이스 탭 세션 유지
- 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가
  - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트
- 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관)
- 워크스페이스: 탭 목록을 sessionStorage에 저장·복원
  - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위)
  - 복원으로 무의미해진 beforeunload 새로고침 경고 제거
- e2e: 관리자 이동 후 탭 복원 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:52:53 +09:00
taekyoungc
e8d58b5837 화면별 매뉴얼·이 화면 설명 버튼·탭 새로고침/경고·최근방문 기록 보강.
- 매뉴얼: 화면(소메뉴)별 용어·버튼·필드 설명으로 확장 + 기본정보 페이지 신규,
  개요에 용어 사전 추가 (종량제 지식 없는 사용자 대상)
- "이 화면 설명" 버튼: 화면 경로→매뉴얼 매핑(Config\Manual::screenHelp,
  manual_help_url_for_path). 워크스페이스 탭은 새 탭으로, 직접 페이지는 새 창으로
- 워크스페이스: 개별 탭 새로고침(↻) 버튼, 탭 2개 이상일 때만 새로고침 경고,
  사이드바 하단 링크(매뉴얼 등)도 탭으로 열기
- 임베드: 탭 내 링크/폼 embed 유지(중첩 헤더 방지), 매뉴얼 리다이렉트 embed 유지
- 사이드바 하단: 종합그래프 → 사용자 매뉴얼 링크
- 최근 방문 메뉴: embed 페이지에도 방문 기록, 대시보드는 storage 이벤트로 실시간 갱신
- E2E qa_sweep 추가(주요 화면 콘솔/오버레이/매뉴얼/도움말 매핑 점검)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:04:41 +09:00
taekyoungc
c15e01bfa7 워크스페이스(탭) 도입 — 로그인 후 기본 화면을 탭 작업공간으로.
- /workspace: 헤더+사이드바+탭바+iframe 패널. 메뉴 클릭=탭 열기,
  전환해도 폼·스크롤·조회결과 등 작업 상태 유지(세션 동안)
- 로그인 후 / = 워크스페이스(첫 탭=대시보드). iframe 내부는 임베드 렌더
- 임베드 레이아웃(bag/layout/embed): 헤더·사이드바 없이 본문만
- 임베드 판정: ?embed=1 또는 Sec-Fetch-Dest=iframe (iframe 내 링크·폼·
  리다이렉트까지 중첩 크롬 없이 처리)
- iframe 안 세션만료 시 상위 창을 로그인으로 전환(auth/_shell)
- 포털 헤더에 워크스페이스 진입 링크, E2E(workspace.spec) 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:32:53 +09:00
taekyoungc
600a79788e 카카오 지오코딩 호출을 try/catch로 감싼다.
- 외부(카카오) SDK 예외가 미처리 예외로 전파되어 콘솔 오염·
  DevTools "예외에서 일시중지"로 화면이 멈추는 것을 방지
- geocodeChain 호출 및 마커 생성 콜백을 방어적으로 보호

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:53:02 +09:00
taekyoungc
6b1c118651 bfcache 복원 시 모달이 열린 채 남아 화면을 덮는 문제를 고친다.
- 전체화면 모달/팝업(.fixed.inset-0[id$=-modal|-popup])이 열린 상태로
  bfcache(뒤로가기/탭 복귀)에 저장·복원되면 회색 레이어가 클릭을 막던 문제
- pagehide(이탈 시)·pageshow(복원 시) 에서 해당 오버레이를 강제로 닫고
  body 스크롤 잠금 해제
- portal·admin·main 레이아웃에 공통 적용(모든 페이지 커버)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:26:19 +09:00
taekyoungc
abc8a405e1 메인 대시보드에 지정판매소 지도·메뉴검색을 추가한다.
- 카카오 지도(지도 2/3 + 판매소 목록 1/3, 높이 고정·스크롤), 목록 클릭 시 줌인
- 지오코딩 폴백(정밀→도로명→지번→키워드→행정동)으로 마커 표시
- 메뉴검색: 자동완성 드롭다운 + 기본 "최근 방문 메뉴"(localStorage, 뒤로가기/bfcache 갱신)
- 메뉴검색 박스 녹색(#009688), 지도와 높이 일치
- resolveLgLabel: 선택 지자체 실제 이름 사용, '(데모)' 문구 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:10:54 +09:00
232 changed files with 11655 additions and 2003 deletions

3
.gitignore vendored
View File

@@ -176,3 +176,6 @@ blob-report/
/phpunit*.xml
# 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
/docs/
# Claude Code 개인 권한 설정(비밀 포함) — 커밋 금지
.claude/settings.local.json

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

@@ -23,12 +23,68 @@ class Manual extends BaseConfig
*/
public array $pages = [
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
'account' => ['title' => '로그인·회원가입·계정', 'file' => '01_account.md'],
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
'sales' => ['title' => '판매·불출', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '판매현황·수불·통계', 'file' => '50_reports.md'],
'sales' => ['title' => '판매·반품·불출·주문', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '현황·리포트·수불', 'file' => '50_reports.md'],
'basic' => ['title' => '기본정보(판매소·단가·코드)', 'file' => '60_basic_info.md'],
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'],
];
/**
* 화면 경로(접두) → 그 화면을 설명하는 매뉴얼 slug.
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
*
* 값에 "slug#소제목힌트" 형식으로 적으면, 매뉴얼 창이 열릴 때 해당 소제목으로
* 자동 스크롤하고 잠시 강조 표시한다(힌트는 그 페이지의 H2/H3 텍스트 일부와 일치).
*
* @var array<string, string>
*/
public array $screenHelp = [
'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

@@ -6,6 +6,7 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes
*/
$routes->get('/', 'Home::index');
$routes->get('workspace', 'Home::workspace');
$routes->get('dashboard', 'Home::dashboard');
$routes->get('dashboard/simple', 'Home::dashboardSimple');
$routes->get('dashboard/compact', 'Home::dashboardCompact');
@@ -37,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');
@@ -59,7 +61,11 @@ $routes->get('bag/help', 'Bag::help');
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
$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');
@@ -71,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');
@@ -87,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');
@@ -149,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');
@@ -201,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');
@@ -219,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');
@@ -234,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');
@@ -243,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' => '로그인 - 종량제 시스템',
'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 = [
@@ -160,7 +187,7 @@ class Auth extends BaseController
return view('auth/login_two_factor', [
'memberId' => $member->mb_id,
'pageTitle' => '2차 인증 - 종량제 시스템',
'pageTitle' => '2차 인증 - GBLS',
'cardMax' => 'max-w-md',
]);
}
@@ -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()
@@ -244,7 +271,7 @@ class Auth extends BaseController
'memberId' => $member->mb_id,
'qrDataUri' => $qrDataUri,
'secret' => $secret,
'pageTitle' => '2차 인증 등록 - 종량제 시스템',
'pageTitle' => '2차 인증 등록 - GBLS',
'cardMax' => 'max-w-lg',
]);
}
@@ -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()
@@ -348,7 +375,7 @@ class Auth extends BaseController
return view('auth/register', [
'localGovernments' => $localGovernments,
'pageTitle' => '회원가입 - 종량제 시스템',
'pageTitle' => '회원가입 - GBLS',
'cardMax' => 'max-w-md',
]);
}
@@ -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

@@ -48,6 +48,20 @@ abstract class BaseController extends Controller
*
* @param array<string, mixed> $contentData
*/
/**
* 워크스페이스 탭(iframe) 안에서 열린 요청인지. ?embed=1 또는 Sec-Fetch-Dest=iframe.
* iframe 내 링크 이동·폼 전송·리다이렉트까지 모두 임베드로 처리되도록 헤더로도 판정한다.
*/
protected function isEmbeddedRequest(): bool
{
if ($this->request->getGet('embed') !== null) {
return true;
}
$dest = strtolower(trim((string) $this->request->getHeaderLine('Sec-Fetch-Dest')));
return $dest === 'iframe' || $dest === 'frame';
}
protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
{
$content = view($contentView, $contentData);
@@ -60,8 +74,16 @@ 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/')) {
// 사이트 업무 페이지: gov-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

@@ -10,21 +10,49 @@ class Home extends BaseController
public function index()
{
if (session()->get('logged_in')) {
// 메인(/) — gov-portal 디자인 셸 + 종량제 실데이터 대시보드.
helper('admin');
return view('bag/layout/portal', [
'title' => '업무 현황',
'bare' => true,
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
]);
// 워크스페이스 탭(iframe) 안: 대시보드 본문을 임베드로.
if ($this->isEmbeddedRequest()) {
return view('bag/layout/embed', [
'title' => '업무 현황',
'bare' => true,
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
]);
}
// 로그인 후 기본 화면 = 워크스페이스(탭). 메뉴를 탭으로 열어 작업 상태 유지.
return view('bag/layout/workspace');
}
// 워크스페이스 탭(iframe) 안에서 세션이 만료된 경우: 공개 랜딩 대신 로그인으로 보내
// 로그인 페이지의 프레임 이탈 스크립트가 상위 창 전체를 로그인으로 전환하게 한다.
if ($this->isEmbeddedRequest()) {
return redirect()->to(base_url('login'));
}
return view('welcome_message');
}
/**
* 메인 대시보드용 — 종량제 시스템에 실제 존재하는 데이터만 집계.
* 워크스페이스 — 메뉴를 탭(iframe)으로 열어두고 작업 상태를 유지하는 화면.
*/
public function workspace()
{
if (! session()->get('logged_in')) {
return redirect()->to(base_url('login'));
}
helper('admin');
// 계정별 저장된 탭 상태(자동 복원). 없으면 'null'.
$savedWorkspaceJson = (new \App\Controllers\Bag())
->loadWorkspaceTabsState((int) (session()->get('mb_idx') ?? 0));
return view('bag/layout/workspace', ['savedWorkspaceJson' => $savedWorkspaceJson]);
}
/**
* 메인 대시보드용 — GBLS에 실제 존재하는 데이터만 집계.
*
* @return array<string, mixed>
*/
@@ -104,6 +132,34 @@ class Home extends BaseController
$pendingApprovals = 0;
}
// 지도용 — 현재 지자체 지정판매소(이름·주소). 좌표는 클라이언트(카카오 지오코딩)에서 변환.
$mapShops = [];
try {
if ($lgIdx !== null && $db->tableExists('designated_shop')) {
$rows = $db->table('designated_shop')
->select('ds_name, ds_addr, ds_addr_jibun')
->where('ds_lg_idx', $lgIdx)
->where('ds_addr IS NOT NULL')
->where('ds_addr <>', '')
->orderBy('ds_idx', 'ASC')
->limit(40)
->get()->getResultArray();
foreach ($rows as $r) {
$addr = trim((string) ($r['ds_addr'] ?? ''));
if ($addr === '') {
continue;
}
$mapShops[] = [
'name' => (string) ($r['ds_name'] ?? ''),
'addr' => $addr,
'jibun' => trim((string) ($r['ds_addr_jibun'] ?? '')),
];
}
}
} catch (\Throwable $e) {
$mapShops = [];
}
// 최근 활동(activity_log) — 실제 변경 이력
$actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소'];
$tableLabel = [
@@ -142,9 +198,44 @@ class Home extends BaseController
'stockMix' => $stockMix,
'lowStock' => $lowStock,
'recentActivity' => $recent,
'mapShops' => $mapShops,
'kakaoJsKey' => config(\Config\Kakao::class)->javascriptKey,
'menuSearchOptions' => (function_exists('gov_portal_nav_context') && function_exists('gov_portal_menu_search_options'))
? gov_portal_menu_search_options(gov_portal_nav_context(false)['navItems'])
: [],
'menuFlat' => $this->buildMenuFlat(),
];
}
/**
* 메뉴검색 자동완성용 — 사이트 메뉴를 (상위·메뉴명·URL) 평탄 목록으로.
*
* @return list<array{parent:string,name:string,url:string}>
*/
private function buildMenuFlat(): array
{
if (! function_exists('gov_portal_nav_context')) {
return [];
}
$flat = [];
foreach (gov_portal_nav_context(false)['navItems'] as $parent) {
$pName = (string) ($parent['name'] ?? '');
if (! empty($parent['children'])) {
foreach ($parent['children'] as $child) {
$url = (string) ($child['url'] ?? '');
if ($url === '') {
continue;
}
$flat[] = ['parent' => $pName, 'name' => (string) ($child['name'] ?? ''), 'url' => $url];
}
} elseif (! empty($parent['url'])) {
$flat[] = ['parent' => '', 'name' => $pName, 'url' => (string) $parent['url']];
}
}
return $flat;
}
/**
* 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
*/
@@ -376,9 +467,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string
{
try {
$idx = session()->get('mb_lg_idx');
if ($idx === null || $idx === '') {
return '로그인 지자체 (미지정)';
helper('admin');
$idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
if ($idx === null) {
$raw = session()->get('mb_lg_idx');
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
if ($idx === null) {
return '지자체 미지정';
}
$row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -388,7 +484,7 @@ class Home extends BaseController
// 테이블 미생성 등
}
return '북구 (데모)';
return '지자체';
}
}

View File

@@ -1,49 +1,52 @@
# 시작하기 · 시스템 개요
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. 이 문서는 실제 업무를 처리하는 담당자(지자체 관리자·지정판매소)를 위한 **빠른 시작 안내서**입니다.
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. **이 시스템을 처음 쓰는 분**도 화면을 이해하고 업무를 처리할 수 있도록, 화면마다 쓰이는 용어와 버튼의 의미를 설명합니다.
## 1. 시스템은 무엇을 하나요?
## 1. 시스템은 무엇을 하나요?
지자체 종량제 쓰레기봉투의 **발주 → 입고 → 재고 → 판매/불출 → 정산·통계** 전 과정을 관리합니다.
지자체가 주민에게 파는 **종량제 쓰레기봉투**가 ① 제작업체에 **주문(발주)** 되고 → ② 창고로 **들어오고(입고)** → ③ **재고**로 보관되다가 → ④ 동네 가게(지정판매소)에 **팔리거나(판매)** 무료 대상자에게 **나눠지는(불출)** 전 과정을 컴퓨터로 관리합니다. 마지막엔 ⑤ 얼마나 팔렸는지 **집계·정산(현황/리포트)** 합니다.
- 봉투를 제작업체에 **발주**하고, 들어온 물량을 **입고** 처리합니다.
- 현재 **재고**를 확인하고, 실사로 실제 수량과 맞춥니다.
- 지정판매소에 **판매**하거나 무료 대상자에게 **불출**합니다.
- 일계표·기간별·연간 등 **판매현황**과 **수불·통계**를 조회합니다.
## 2. 꼭 알아야 할 기본 용어 (용어 사전)
## 2. 로그인과 화면 구성
| 용어 | 쉬운 설명 |
|---|---|
| **발주** | 봉투를 제작업체에 "이만큼 만들어 주세요"라고 **주문**하는 것 |
| **입고** | 주문한 봉투가 창고에 **도착해 들여놓는** 것 |
| **재고** | 지금 창고에 **남아 있는 봉투 수량** |
| **불출** | 봉투를 창고에서 **꺼내 내보내는** 것 (주로 무료 배부) |
| **수불(受拂)** | **들어오고(수입)·나가는(불출)** 움직임을 적은 장부 |
| **지정판매소** | 봉투를 파는 **동네 가게**(편의점·마트 등) |
| **대행소(판매대행소)** | 봉투 **배송·유통을 대행**하는 업체 |
| **실사** | 컴퓨터 기록과 **실제 창고 수량이 맞는지 직접 세어 확인**하는 것 |
| **박스 / 팩 / 낱장** | 포장 단위. **박스 > 팩 > 낱장(봉투 1장)**. 1박스 = 여러 팩, 1팩 = 여러 낱장 |
| **LOT(로트)** | 한 번의 발주 묶음에 부여되는 **추적용 일련번호** |
| **바코드(봉투번호)** | 박스·팩·낱장마다 붙는 **고유 번호**(스캔용) |
| **무료용 / 공공용** | 주민 무료 배부용 / 공공기관 사용용 봉투 구분 |
1. 발급받은 아이디·비밀번호로 로그인합니다.
2. 관리자(지자체/슈퍼)는 보안을 위해 **2차 인증(OTP)** 이 적용될 수 있습니다.
3. 로그인하면 상단에 **대메뉴**가 보이고, 각 대메뉴에 마우스를 올리면 **소메뉴**가 펼쳐집니다.
4. 화면 상단의 제목 바에 현재 보고 있는 화면 이름이 표시됩니다.
> 박스·팩·낱장·LOT·바코드의 자세한 규칙은 좌측 목차 **[봉투·LOT·바코드 코드체계]** 참고.
> 슈퍼 관리자는 작업할 **지자체를 먼저 선택**해야 업무 화면이 열립니다.
## 3. 화면 구성과 사용법
## 3. 사용자 역할(권한)
- 로그인하면 **워크스페이스**(탭 작업공간)가 열립니다. 상단에 대분류 메뉴, **대분류를 클릭하면 왼쪽에 소메뉴**가 펼쳐집니다.
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
시스템은 4단계 역할로 접근 권한을 구분합니다.
> 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
| 레벨 | 역할 | 할 수 있는 일 |
|---|---|---|
| 1 | 일반 사용자 | 기본 조회 |
| 2 | 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 3 | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 4 | 슈퍼 관리자 | 전체 시스템 관리 + 기본코드 등 마스터 관리 (지자체 선택 후 작업) |
## 4. 사용자 역할(권한)
### 역할별 접근 한눈에 보기
| 역할 | 할 수 있는 일 |
|---|---|
| 일반 사용자 | 기본 조회 |
| 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
| 기능군 | 일반 | 판매소 | 지자체관리자 | 슈퍼 |
|---|:--:|:--:|:--:|:--:|
| 기본정보 조회(코드·단가·포장) | △ | ○ | ○ | ○ |
| 기본코드·마스터 편집 | ✕ | ✕ | △(지자체분) | ○ |
| 발주·입고·재고·불출 | ✕ | ✕ | ○ | ○ |
| 판매·반품 등록 | ✕ | ○ | ○ | ○ |
| 판매현황·수불·통계 | ✕ | △(자기분) | ○ | ○ |
> **활동 로그(사용자 작업 기록)**: **지자체 관리자·슈퍼 관리자만** 접근할 수 있습니다. (일반 사용자·지정판매소는 볼 수 없습니다.)
(○ 사용 가능 · △ 제한적 · ✕ 불가)
## 5. 화면별 설명은 어디에?
## 4. 다음 단계
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
- **발주·입고** / **재고·실사** / **판매·반품·불출·주문** / **현황·리포트·수불** / **기본정보(판매소·단가·코드)**
- 전체 업무가 어떻게 이어지는지 먼저 보려면 **[핵심 업무 흐름]** 으로 이동하세요.
- 특정 작업 방법은 좌측 목차에서 해당 항목(발주·입고 / 재고·실사 / 판매·불출 / 판매현황·수불·통계)을 선택하세요.
각 화면 설명은 **그 화면 고유의 용어·입력 항목·버튼·작업 순서**만 담았습니다.

View File

@@ -0,0 +1,69 @@
# 로그인 · 회원가입 · 계정
시스템을 쓰려면 **계정(아이디)** 이 필요합니다. 이 페이지는 회원가입부터 로그인, 2차 인증, 로그아웃까지의 과정을 설명합니다.
## 1. 회원가입
로그인 화면 아래쪽 **[회원가입]** 을 눌러 신청합니다. 입력 항목은 다음과 같습니다.
| 항목 | 필수 | 설명 |
|---|:---:|---|
| **아이디** | 필수 | 로그인에 쓰는 ID. **4자 이상**, 이미 쓰는 아이디는 사용할 수 없습니다. |
| **비밀번호** | 필수 | **4자 이상**. 안전을 위해 영문·숫자·기호를 섞는 것을 권장합니다. |
| **비밀번호 확인** | 필수 | 위 비밀번호와 똑같이 한 번 더 입력(오타 방지). |
| **이름** | 필수 | 담당자 이름. |
| **이메일** | 선택 | 안내용. 입력 시 형식 검사를 합니다. |
| **연락처** | 선택 | 전화번호. |
| **지자체** | 선택 | 소속 지자체. 해당되면 선택합니다. |
| **사용자 역할** | 필수 | 신청할 권한(아래 표 참고). 실제 권한은 **관리자 승인 시 확정**됩니다. |
> 이메일·연락처 같은 개인정보는 **암호화되어 저장**됩니다.
### 신청할 수 있는 역할
| 역할 | 주로 하는 일 |
|---|---|
| **일반 사용자** | 기본 조회 |
| **지정판매소** | 봉투 판매·반품, 자기 판매 현황 조회 |
| **지자체 관리자** | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
### 가입 후 — 관리자 승인 대기
회원가입을 제출하면 **바로 로그인되지 않고 "승인 대기" 상태**가 됩니다.
- **관리자가 승인하면** 그때부터 로그인할 수 있습니다.
- 승인 전에 로그인하면 *"관리자 승인 후 로그인 가능합니다."* 안내가 나옵니다.
- 승인이 **반려**되면 *"승인이 반려되었습니다. 관리자에게 문의해 주세요."* 가 나옵니다 — 담당 관리자에게 문의하세요.
## 2. 로그인
로그인 화면에서 **아이디**와 **비밀번호**를 입력합니다.
- 성공하면 **워크스페이스**(탭 작업공간)로 들어갑니다.
- 아이디·비밀번호가 틀리면 안내 메시지가 나옵니다. (승인 대기/반려 상태도 위와 같이 안내됩니다.)
### 2차 인증(OTP)
보안 설정에 따라 비밀번호 확인 뒤 **2차 인증** 단계가 나올 수 있습니다.
- **이미 OTP를 등록한 경우** — 스마트폰 인증 앱(예: Google Authenticator)에 표시되는 **6자리 숫자**를 입력합니다.
- **처음 사용하는 경우** — 화면의 **QR코드 또는 설정 키**를 인증 앱에 등록한 뒤, 앱에 나온 6자리 숫자로 설정을 완료합니다. 이후 로그인부터 OTP를 입력하게 됩니다.
> OTP 숫자는 일정 시간마다 바뀌므로, **현재 표시된 숫자**를 입력해야 합니다. 휴대폰 시간이 자동(네트워크 시간)으로 맞춰져 있어야 정확합니다.
## 3. 로그아웃
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
## 4. 비밀번호 변경 · *비밀번호 변경*
로그인한 본인이 직접 비밀번호를 바꾸는 화면입니다.
**입력**: 현재 비밀번호 → 새 비밀번호 → 새 비밀번호 확인.
**순서**: 현재 비밀번호가 맞아야 변경되며, 새 비밀번호는 확인란과 동일해야 합니다.
> 주기적으로 변경하고, 다른 사이트와 같은 비밀번호는 피하세요.
## 5. 비밀번호 분실 · 계정·권한 문제
- **비밀번호를 분실**해 로그인 자체가 안 되는 경우에는 **담당 관리자**가 초기화해 줍니다. 관리자에게 문의하세요.
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.

View File

@@ -0,0 +1,92 @@
# 화면 구성 · 워크스페이스 · 단축키
이 시스템은 **여러 작업 화면을 탭으로 열어 두고 오가며** 일할 수 있도록 만들어졌습니다(웹 브라우저의 탭과 비슷합니다). 이 페이지는 화면이 어떻게 구성되는지, 탭을 어떻게 쓰는지, 그리고 빠르게 쓰는 단축키를 설명합니다.
## 1. 전체 화면 구성
로그인하면 **워크스페이스**(탭 작업공간)가 기본으로 열립니다. 화면은 크게 네 부분입니다.
| 영역 | 위치 | 설명 |
|---|---|---|
| **상단 헤더** | 맨 위 | 로고, **대분류 메뉴**, 오른쪽에 소속 지자체·접속자·관리자·로그아웃 |
| **왼쪽 사이드바** | 좌측 | 현재 선택한 대분류의 **소메뉴 목록**. 아래에는 매뉴얼·FAQ 링크 |
| **탭바** | 본문 위 | 지금 열어 둔 작업 화면들의 **탭 목록** |
| **본문** | 가운데 | 현재 탭의 실제 작업 화면 |
> **대분류를 클릭하면** 왼쪽 사이드바에 그 안의 소메뉴가 펼쳐집니다. 현재 위치한 메뉴는 왼쪽에서 **▸** 로 표시됩니다.
## 2. 탭(워크스페이스) 사용법
- **메뉴를 클릭하면 탭으로 열립니다.** 같은 메뉴를 다시 누르면 새 탭을 또 만들지 않고 **이미 열린 탭으로 이동**합니다.
- **탭을 전환해도 작업 내용이 유지됩니다.** A 화면에 무언가 입력하다가 B 화면을 잠깐 보고 와도, A 탭의 입력은 그대로 남아 있습니다.
- **탭 ↔ 왼쪽 메뉴 연동:** 탭을 전환하면 왼쪽 사이드바의 강조 위치도 그 탭의 메뉴로 **자동으로 따라갑니다.**
- 탭이 많아지면 가로로 스크롤되며, **최대 12개**까지 열 수 있습니다. 12개를 넘기면 가장 오래된 탭이 자동으로 닫힙니다.
### 탭의 버튼
| 버튼 | 동작 |
|---|---|
| **↻** (탭 위) | 그 **탭만** 새로고침합니다. 다른 탭은 그대로 둡니다. |
| **×** (탭 위) | 그 탭을 닫습니다. |
| 탭을 **가운데(휠) 클릭** | 마우스 휠을 누르면 그 탭이 닫힙니다(브라우저와 동일). |
| 탭에 **마우스를 올리면** | 이름이 길어 잘렸을 때 **전체 제목**이 말풍선으로 보입니다. |
### 탭이 유지되는 범위
- **브라우저를 새로고침**하거나 **관리자 페이지에 갔다가 돌아와도** 열어 두었던 탭이 **다시 복원**됩니다.
- 단, **브라우저 탭(창)을 완전히 닫으면** 작업공간은 초기화됩니다. (이 유지는 "이번 접속 동안"만 적용됩니다.)
- **다른 아이디로 로그인하면** 이전 사용자의 탭은 복원되지 않고 **기본 화면으로 초기화**됩니다. (계정별로 분리됩니다.)
- 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.**
## 3. 분할 보기 (여러 화면 한눈에)
여러 화면을 **동시에 펼쳐 놓고** 비교하거나 함께 작업할 수 있습니다. 탭바 오른쪽의 **분할 버튼**으로 화면을 나눕니다.
| 버튼 | 모양 | 설명 |
|---|---|---|
| **1분할** | □ | 한 화면만 크게 (기본) |
| **2분할 (좌우)** | ◫ | 화면을 왼쪽/오른쪽 두 칸으로 |
| **2분할 (상하)** | ⬓ | 화면을 위/아래 두 칸으로 |
| **4분할** | ⊞ | 2×2 네 칸으로 |
### 칸에 화면 배치하기
- 분할하면 열어 둔 화면들이 칸에 자동으로 채워집니다. 빈 칸에는 안내 문구가 표시됩니다.
- **칸을 클릭하면 그 칸이 "선택"**(파란 테두리)됩니다. 이 상태에서 **위 탭바의 탭을 클릭**하면 그 화면이 **선택된 칸**에 들어갑니다.
- 각 칸 위의 **헤더**에는 화면 이름과 함께 **↻(이 칸 새로고침)·×(이 칸 비우기)** 버튼이 있습니다.
### 칸 크기 조절
- 칸 사이의 **구분선에 마우스를 올리면 ↔/↕ 커서**가 나타납니다. **드래그**하면 칸 크기(비율)를 자유롭게 조절할 수 있습니다.
- 구분선을 **더블클릭**하면 **50:50으로 초기화**됩니다.
- 조절한 분할 모양·크기도 새로고침·관리자 왕복 후 **그대로 복원**됩니다.
> 분할 상태에서도 각 화면의 작업 내용은 그대로 유지됩니다. 마음껏 나눴다 합쳤다 해도 입력하던 내용이 사라지지 않습니다.
## 4. 키보드 단축키
자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다.
동작은 **현재 선택된 칸**을 기준으로 적용됩니다(1분할이면 그 한 화면).
| 단축키 | 동작 |
|---|---|
| **Alt + 1 ~ 9** | **n번째 탭**을 선택된 칸에 표시 |
| **Alt + W** | 선택된 칸의 **탭 닫기** |
| **Alt + ]** | 선택된 칸을 **다음 탭**으로 |
| **Alt + [** | 선택된 칸을 **이전 탭**으로 |
> macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1).
>
> 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다.
## 5. 그 밖의 이동
- **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다.
- **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다.
- **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다.
## 6. 도움말 보는 법
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.

View File

@@ -1,41 +1,83 @@
# 발주 · 입고
제작업체에 봉투를 주문(발주)하고, 도착한 물량을 시스템에 등록(입고)하는 단계입니다. **지자체 관리자** 이상이 사용합니다.
봉투를 제작업체에 **주문(발주)** 하고, 도착한 봉투를 창고에 **들여놓는(입고)** 단계입니다.
## 발주
---
### 발주 등록
## 발주 등록 · *발주 입고 관리 발주 등록*
**발주 입고 관리 발주 등록**
봉투를 **얼마나 주문할지** 입력해 발주서를 만드는 화면입니다. 저장하면 추적용 **LOT 번호**가 자동으로 붙습니다.
1. 봉투 **품목**(종류·용량)과 **수량**, 납품 관련 정보를 입력합니다.
2. 박스/낱장 수량과 금액·총계가 자동으로 계산됩니다.
3. 저장하면 발주 건이 생성되고, 추적용 **LOT 번호**가 자동 부여됩니다.
**이 화면의 용어**
- **발주가능봉투**: 조달청(나라장터)에 등록되어 주문할 수 있는 봉투 종류.
- **입고처**: 들어온 봉투를 받을 창고/장소.
- **조달수수료**: 발주 금액에 붙는 수수료율(%).
- **Box당 팩 / 팩당 낱장 / 1박스 총 낱장**: 포장 환산 정보(참고용 표).
> 발주 내용은 무결성 보호를 위해 버전·해시로 관리됩니다. 수정(재발주) 시 기존 LOT는 유지됩니다.
**입력 항목**: 발주월, 발주일, 협회, 제작업체, 입고처, **봉투 품목별 수량(박스 단위)**.
### 발주 변경 · 현황
**버튼**: `발주`(저장) · `변경 저장`(수정 시) · `취소`.
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 발주 변경 | 발주 변경 | 기존 발주 수정·재발주 |
| 발주 현황 | 발주 현황 | 발주 목록을 기간·상태로 조회, 엑셀 내보내기 |
| 발주 상세 | (현황에서 행 선택) | 개별 발주 상세 확인, 취소 처리 |
**작업 순서**
1. 발주월·발주일, 제작업체·입고처를 고릅니다.
2. 아래 봉투 종류별로 **주문할 박스 수량**을 입력합니다(금액·총 낱장은 자동 계산).
3. `발주` 를 누르면 발주가 생성되고 LOT 번호가 부여됩니다.
## 입고
---
발주분이 실제 도착하면 입고로 등록합니다. 입고 시 **박스·팩·낱장 바코드**가 생성되어 재고에 반영됩니다.
## 발주 현황 · *발주 입고 관리 발주 현황*
| 방식 | 메뉴 | 언제 사용 |
|---|---|---|
| 스캐너 입고 | 발주 입고[스캐너] | 바코드를 스캔하며 입고 |
| 일괄 입고 | 일괄입고 | 다량을 한 번에 입고 |
| 입고 현황 | 입고 현황 | 입고 기록 조회, 엑셀 내보내기 |
낸 발주를 **조회·관리**하는 목록 화면입니다.
### 입고 처리 순서
**필터**: 발주기간(월~월) · 제작업체 · 품명 · **입고구분(전체/입고완료/미입고)**.
1. 입고할 발주 건(LOT)을 선택합니다.
2. 도착 수량(박스/낱장)을 확인·입력합니다.
3. 저장하면 재고가 증가하고, 단위별 바코드가 부여됩니다.
**표 컬럼**: 발주일자 · 제작업체 · 품명 · **발주수량 · 입고수량 · 미입고수량** · 발주금액 · 입고처 · 비고.
- **미입고수량** = 발주했지만 아직 안 들어온 수량.
> 입고가 끝나면 **재고 관리**에서 수량이 정상 반영됐는지 확인하세요.
**버튼**: `엑셀저장` · `인쇄` · `발주등록`. (목록에서 개별 발주의 상세·취소 가능)
---
## 입고 처리 · *발주 입고 관리 입고[스캐너] / 일괄입고*
도착한 봉투를 시스템에 **들여놓는** 화면입니다. 입고하면 박스·팩·낱장 **바코드가 생성**되고 재고가 늘어납니다.
**이 화면의 용어**
- **인계자(제작업체)** / **인수자(대행소)**: 봉투를 넘기는 쪽 / 받는 쪽.
- **입고량(매)**: 실제로 들어온 **낱장 수**("매" = 장).
- **LOT NO / 발주 NO**: 어떤 발주분인지 식별하는 번호.
**입고[스캐너]**: 발주 건을 보고 행마다 **입고량(매)** 을 직접 입력 → `입고처리`.
**일괄입고**: 여러 발주 건을 **체크박스로 골라** 한 번에 입고. 미입고량은 파란색으로 강조됩니다.
**작업 순서**
1. 제작업체·인수자·인계자·입고일을 고릅니다.
2. 들어온 만큼 **입고량(매)** 을 입력(또는 일괄 선택)합니다.
3. `입고처리` → 재고 반영. **재고 관리**에서 수량이 늘었는지 확인하세요.
---
## 입고 현황 · *발주 입고 관리 입고 현황*
입고 기록을 기간별로 조회합니다.
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
**버튼**: `엑셀저장` · `인쇄`.
---
## 발주파일 생성 · *발주 입고 관리 발주파일 생성*
선택한 발주 건의 내용을 **봉투 생산기(바코드 인쇄) 전달용 파일**로 만드는 화면입니다. (예전 이름: *LOT-No 디스켓 불출*) 생성되는 파일은 발주 정보와 품목이 담긴 **암호화된 발주파일**(`.seed.json`)이며, 봉투 생산 장비가 이 파일을 읽어 그 발주의 바코드를 인쇄합니다.
**LOT-No란?** 발주를 등록할 때 그 건에 자동 부여되는 **6자리 배치(묶음) 번호**입니다. 이후 그 발주로 인쇄되는 모든 봉투 바코드의 앞자리(예: `ZLCH2M-000006-…`)가 되어, 어느 발주에서 나온 봉투인지 구분하는 기준이 됩니다.
**필터**: 시작월 · 종료월 · LOT-No · 제작업체.
**순서**
1. 기간·조건으로 발주 목록을 조회합니다.
2. 대상 발주 행의 **`내용 확인`** 을 누르면, 파일에 담길 **발주 정보와 품목 목록(봉투코드·수량·금액 등)** 을 팝업으로 미리 볼 수 있습니다.
3. 내용을 확인한 뒤 **`발주파일 생성(다운로드)`** 을 누르면 암호화된 발주파일이 내려받아집니다.
**표 컬럼**: 발주일자 · LOT-No · 품명 · 수량 · 제작업체 · (내용 확인 버튼).
> 정상·취소 발주가 함께 조회되며, 파일 생성은 발주 단위로 동작합니다. 생성 전 **`내용 확인`** 단계에서 반드시 품목·수량을 검토하세요.

View File

@@ -1,39 +1,39 @@
# 재고 · 실사
현재 보유 봉투 수량을 확인하고, 정기적으로 실사를 통해 실제 수량 맞추는 단계입니다.
지금 창고에 **남은 봉투**를 확인하고, 컴퓨터 기록과 **실제 수량 맞추는(실사)** 단계입니다.
## 재고 현황
---
**재고 관리 재고 현황**
## 재고 현황 · *재고 관리 재고 현황*
- 품목별·상태별 현재 재고를 조회합니다.
- 지자체·봉투 종류 등으로 필터링할 수 있습니다.
- **엑셀 내보내기**로 목록을 저장할 수 있습니다.
품목별로 **현재 남은 수량**을 봅니다.
| 항목 | 설명 |
|---|---|
| 품목 | 봉투 종류·용량 |
| 재고 수량 | 입고 (판매 + 불출 + 파기) |
| 상태 | 재고/판매 등 단위별 상태 |
**이 화면의 용어**
- **시군구재고**: 지자체(시·군·구) **창고**에 있는 재고.
- **대행소재고**: 배송 **대행소**가 보유 중인 재고.
- **계**: 둘을 합친 총 재고.
## 실사 (재고 조사)
**필터**: 기준일자 · 대행소(전체/선택).
**표 컬럼**: 품목구분 · 봉투/스티커종류 · **계 · 시군구재고 · 대행소재고**.
**버튼**: `조회` · `엑셀저장` · `인쇄` · `실사선별조회`.
장부상 재고와 실제 창고 수량을 맞추는 작업입니다. 다음 순서로 진행합니다.
---
```
실사 선별 ─→ 실사 등록(작업) ─→ 적용
```
## 실사 (재고 확인) · *재고 관리 실사 선별 조회 / 실사 선별 관리*
| 단계 | 메뉴 | 하는 일 |
|---|---|---|
| ① 선별 | 실사 선별 조회 | 실사 대상 범위를 골라 선별 |
| ② 작업 | 실사 선별 관리 | 실제 수량을 입력·기록 |
| ③ 상세·적용 | 실사 상세 | 실사 결과를 확인하고 재고에 **적용** |
**실사**는 시스템에 적힌 수량(전산재고)과 **창고에서 직접 센 수량(실사재고)** 을 비교해 차이를 바로잡는 작업입니다.
### 실사 진행 순서
**이 화면의 용어**
- **전산재고**: 시스템 기록상 수량.
- **실사재고**: 현장에서 직접 센 수량(직접 입력).
- **차이**: 실사 전산. (양수 = 더 많음, 음수 = 부족)
- **박스 / 팩 / 낱장**: 셀 단위. 팩코드·낱장(시작~끝) 구간으로 표시됩니다.
1. **선별**: 실사할 품목·구간을 선택해 실사 건을 만듭니다.
2. **등록**: 실제 센 수량을 입력합니다. 장부 수량과의 차이가 표시됩니다.
3. **적용**: 검토 후 적용하면 차이가 재고에 반영됩니다.
**작업 순서**
1. **실사 선별**: 실사할 기간·품목을 골라 대상 목록을 만듭니다(팝업에서 작업일자·품목 선택).
2. **실사재고 입력**: 팩/박스별로 실제 센 수량을 입력하면 **차이**가 자동 표시됩니다.
3. **저장(적용)**: 검토 후 적용하면 차이가 재고에 반영됩니다.
> 적용 전까지는 재고에 영향을 주지 않으므로, 입력 도중 중단해도 안전합니다.
**주요 표 컬럼**: 팩코드 · 포장량 · 재고(전산) · **실사재고(입력)** · 차이 · 낱장(시작) · 낱장(끝).
> 적용 전까지는 재고에 영향을 주지 않으므로, 세는 도중 중단해도 안전합니다.

View File

@@ -1,46 +1,83 @@
# 판매 · 불출
# 판매 · 반품 · 불출 · 주문
재고를 외부로 내보내는 두 가지 경로입니다. **판매**는 지정판매소 대상 유상 거래, **불출**무료 대상자에 대한 무상 지급입니다.
재고를 외부로 내보내는 단계입니다. **판매**(가게에 유상 공급)·**불출**(무료 배부)·**주문 접수**(전화 등).
## 판매 (지정판매소)
---
**판매 관리** 메뉴에서 처리합니다.
## 지정판매소 판매 · *판매 관리 지정 판매소 판매*
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 판매 등록 | 지정 판매소 판매 | 판매소를 선택해 판매 거래 기록 |
| 스캔 판매 | 지정 판매소 판매[스캔] | 바코드를 스캔하며 판매(판매소 현장용) |
| 판매 취소 | 지정 판매소 판매 취소 | 기존 판매 거래 취소 |
| 반품 | 지정 판매소 반품 | 판매분 반품 등록 |
| 반품 취소 | 지정 판매소 반품 취소 | 반품 취소 처리 |
동네 가게(지정판매소)에 봉투를 **판매**하고, 어떤 봉투를 줬는지 **바코드로 기록**합니다.
### 판매 등록 순서
**이 화면의 용어**
- **판매소코드/상호/대표자**: 판매하는 가게 정보(검색해서 선택).
- **봉투코드(스캔)**: 내보내는 봉투의 바코드. 스캔/입력하면 어떤 LOT·포장단위인지 식별됩니다.
- **포장단위(Box/Pack/Sheet)**: 박스/팩/낱장.
1. 판매할 **지정판매소**를 선택합니다.
2. 봉투 **품목·수량**을 입력(또는 바코드 스캔)합니다.
3. 저장하면 재고가 감소하고 판매 내역이 기록됩니다.
**입력/순서**
1. 위에서 **판매소를 검색·선택**합니다(코드·상호·전화·주소로 검색).
2. 판매할 봉투 종류·수량을 고르거나 **봉투코드를 스캔**합니다.
3. `판매저장` → 재고가 줄고 판매 내역이 기록됩니다.
### 전화 접수(주문)
**표 컬럼**: (판매내역) 봉투종류·접수량·판매량·단가·판매금액 / (상세) 봉투종류·봉투코드·수량·포장단위.
| 작업 | 메뉴 |
|---|---|
| 전화 접수(신규) | 전화 접수 |
| 전화 접수 관리 | 전화 접수 관리(수정·취소) |
---
## 불출 (무료 대상자)
## 지정 판매소 반품 / 판매·반품 취소 · *판매 관리*
**불출 관리** 메뉴에서 무료 대상자에게 봉투를 무상 지급합니다.
- **반품**: 가게가 안 팔린 봉투를 **되돌려 받는** 것. 스캔/선택 후 저장하면 재고가 다시 늘어납니다.
- **판매 취소 / 반품 취소**: 잘못 처리한 건을 되돌립니다.
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 불출 처리 | 무료용 불출 처리 | 무료 배분 등록(재고 감소) |
| 불출 취소 | 무료용 불출 취소 | 불출 취소(재고 복원) |
| 불출 현황 | 무료 불출 현황 | 기간별 불출 기록 조회 |
---
### 불출 처리 순서
## 판매/반품 현황 · *판매 관리* 또는 *판매 현황*
1. 무료 **대상처**와 봉투 **품목·수량**을 선택합니다.
2. 저장하면 재고가 감소하고 불출 내역이 기록됩니다.
3. 잘못 처리한 경우 **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
기간별 판매·반품 내역을 봅니다.
> 판매·불출 모두 재고를 감소시키므로, 처리 후 **재고 현황**에서 반영 결과를 확인하세요.
**필터**: 조회기간.
**표 컬럼**: 판매소 · 판매일 · 봉투코드 · 봉투명 · 수량 · 단가 · 금액 · **구분(판매/반품/취소)**.
**버튼**: `조회` · `초기화` · `주문등록` · `판매등록`.
---
## 전화 주문 접수 · *판매 관리 전화 접수*
가게가 전화로 주문한 내용을 **접수**합니다(실제 출고/판매는 이후 단계).
**이 화면의 용어**
- **접수일 / 배달일**: 주문 받은 날 / 가져다줄 날(보통 다음날 자동).
- **결제구분**: 이체 / 가상계좌.
- **1박스·1팩(낱장/판매가)**: 포장별 수량·가격 참고값.
**입력/순서**
1. **판매소 검색·선택**(코드·사업자번호·상호·전화·주소).
2. 결제구분을 고르고, 봉투 **품목·주문수량·포장단위(박스/팩/낱장)** 를 입력(`행추가`로 여러 품목).
3. `등록` → 주문 접수 완료.
> **주문 접수(간편)**: 판매소·배달일·결제방법과 봉투별 수량만 입력하는 간단 버전.
---
## 무료용 불출 처리 · *불출 관리 무료용 불출 처리*
무료 대상자(동사무소 등)에게 봉투를 **무상으로 내보내는(불출)** 화면입니다.
**이 화면의 용어**
- **불출구분(무료용/공공용)**: 주민 무료 배부용 / 공공기관용.
- **불출처**: 봉투를 최종 전달할 곳(동사무소·구청·기타).
- **재고(낱장) / 환산(낱장)**: 현재 재고 / 입력 수량을 낱장으로 환산한 값.
**입력/순서**
1. 불출년도·분기, 불출구분, 불출일, **불출처(동)** 를 고릅니다.
2. **바코드 스캔** 또는 `행추가`로 봉투 종류·수량·포장단위를 입력합니다.
3. `저장` → 재고가 줄고 불출 내역이 기록됩니다.
**표 컬럼**: 봉투코드 · 봉투종류 · 수량 · 포장 · 재고(낱장) · 환산(낱장).
---
## 무료용 불출 취소 · *불출 관리 무료용 불출 취소*
잘못 불출한 건을 **되돌려 재고를 복원**합니다.
**필터**: 불출월 · 불출처 · 불출구분 · 봉투종류.
**순서**: 불출 목록에서 건을 고르고, 품목 내역에서 **취소할 항목을 체크 → 취소수량 입력** 후 처리.

View File

@@ -1,42 +1,104 @@
# 판매현황 · 수불 · 통계
# 현황 · 리포트 · 수불
판매·재고 흐름을 집계해 보여주는 조회·리포트 화면 모음입니다. 대부분 **기간을 지정해 조회**하고 인쇄·엑셀로 내보낼 수 있습니다.
입고·판매·불출 기록을 **모아 보여주는** 조회 화면들입니다. 대부분 **기간을 지정해 조회**하고 `엑셀저장`·`인쇄`로 내보낼 수 있습니다.
## 판매 현황
---
| 메뉴 | 내용 |
|---|---|
| 지정 판매소 일/기간 판매대장 | 판매소별 일자·기간 거래 장부 |
| 일계표 | 하루 전체 판매 요약(일계 + 월 누계) |
| 기간별 판매현황 | 지정 기간의 판매 집계(일집계/기간집계) |
| 년 판매 현황 | 연간 판매 통계(월별/분기별) |
| 지정 판매소별 판매현황 | 판매소별 수량·금액 비교 |
| 홈텍스 처리 | 세금계산서용 데이터(엑셀) 생성 |
## 기간별 봉투 수불 현황 · *봉투 수불 관리 기간별 봉투 수불 현황*
## 봉투 수불 관리
**수불(受拂)** = 들어오고 나간 움직임. 기간 동안 봉투가 얼마나 들어오고(입고) 나갔는지(판매·불출 등)를 한 표로 봅니다.
입고·판매·불출·반품·파기를 한데 모아 **수불(수입·불출)** 흐름을 봅니다.
**이 화면의 용어**
- **전일재고**: 조회 시작일 **전날**의 재고.
- **입고**: 입고량 + 반품 + 기타.
- **출고**: 판매 + 무료불출 + 반품 + 기타.
- **잔량**: 전일재고 + 입고 출고.
| 메뉴 | 내용 |
|---|---|
| 기간별 봉투 수불 현황 | 기간 내 재고/입고/판매/불출 종합(엑셀 가능) |
| 기타 입출고 | 손상·기증·폐기 등 기타 입출고 등록·조회 |
| 반품/파기 현황 | 반품 및 파기 내역 |
| LOT 수불 조회 | 봉투번호(바코드)·LOT 단위 이력 추적 |
| 쓰레기 봉투 수급 계획 | 공급·수요 계획 |
**필터**: 조회기간 · 봉투형식 · 봉투구분 · 대행소 · **집계방식(일자별/기간별)**.
**표 컬럼**: 일자 · 품목 · 전일재고 · 입고(소계) · 출고(소계) · 잔량.
### LOT 수불 조회 사용법
---
1. **봉투번호(바코드)** 또는 LOT 번호를 입력합니다.
2. 조회하면 해당 단위의 입고·판매·반품 이력이 표시됩니다.
3. 입력할 코드 형식이 헷갈리면 **도움말 번호알기**로 먼저 확인하세요.
## 일계표 · *판매 현황 일계표*
## 통계 분석
하루치 판매를 **일계(당일)****누계(월 누적)** 로 집계합니다.
| 메뉴 | 내용 |
|---|---|
| 전년 대비 판매 분석 | 작년 동기 대비 비교(차트) |
| 월별 판매 추이 분석 | 월별 추이 시각화 |
| 계절별 판매 추이 분석 | 계절 패턴 분석 |
**용어**: **일계** = 그날 합계, **누계(월)** = 월초~당일 누적, **징수액** = 판매금액 수수료.
**필터**: 조회일자 · 대행소 · 구분.
**표**: 봉투종류별 — 일계(수량·판매금액·수수료·징수액) / 누계(월) 동일 항목.
> 리포트 화면은 조회 조건(기간·품목·판매소)을 바꿔가며 반복 조회할 수 있습니다. 결과는 인쇄 버튼 또는 엑셀 내보내기로 저장하세요.
---
## 지정 판매소별 판매현황 · *판매 현황 판매소별 판매현황*
판매소마다 **얼마나 팔았는지**(수량 또는 금액)를 월별로 비교합니다.
**필터**: 기간 · 읍면동 · 봉투종류 · **지표(수량/금액)**.
**표**: 판매소명 · 판매소코드 · 월별 값 · 합계.
---
## LOT 수불 조회 · *봉투 수불 관리 LOT 수불 조회*
특정 **봉투번호(바코드)** 또는 **LOT**의 입고·판매·반품 **이력**을 추적합니다.
**입력**: 봉투번호(바코드/팩코드/박스코드/낱장코드).
**표**: 일자 · 품목 · 포장단위 · **구분(입고/판매/반품)** · 수량 · LOT번호.
> 입력할 코드 형식이 헷갈리면 좌측 **[봉투·LOT·바코드 코드체계]** 또는 도움말의 **번호알기**를 참고하세요.
---
## 반품/파기 현황 · *봉투 수불 관리 반품/파기 현황*
**용어**: **반품** = 판매소가 되돌린 봉투(출고 탭) / **파기** = 반품분의 폐기(입고 탭).
**필터**: 조회기간 · 입출고구분.
**표**: 일자 · 판매소명 · 봉투종류 · 수량 · 구분(반품/파기).
---
## 기타 입출고 · *봉투 수불 관리 기타 입출고*
정상적인 **발주입고 · 판매 · 불출** 흐름에 속하지 않는 **그 밖의 재고 증감**을 수동으로 기록하는 화면입니다.
(예: 분실·파손, 실사 차이 보정, 행정상 조정, 타 부서 이관 등)
**구분**
- **입고(in)**: 재고가 **늘어나는** 조정 (예: 누락분 추가, 반입).
- **출고(out)**: 재고가 **줄어드는** 조정 (예: 파손·분실 차감).
**등록 입력값** *(모두 필수)*
- **구분**: 입고 / 출고
- **봉투**: 봉투코드(봉투구분으로 먼저 좁혀서 선택)
- **수량**: 1 이상
- **일자**: 처리 기준일
- **사유**: 왜 조정하는지(필수, 최대 200자) — 추후 추적·감사를 위해 구체적으로 적습니다.
**조회·표시**
- **수불년월 · 구분**으로 묶여 좌측 **목록(마스터)** → 우측 **상세 내역**으로 표시됩니다.
- 봉투구분으로 필터링할 수 있습니다.
**반영**: 등록한 입고/출고 수량은 **재고와 봉투 수불 집계에 그대로 반영**되므로, 정확한 사유와 수량으로 신중히 입력하세요. 잘못 등록한 행은 삭제로 정정합니다.
> 정상 업무(발주·판매·불출)는 각 전용 화면에서 처리하고, **여기서는 그 흐름으로 설명되지 않는 예외적 증감만** 기록합니다.
---
## 쓰레기봉투 수급 계획 · *봉투 수불 관리 쓰레기봉투 수급 계획*
봉투 품목별로 **현재고가 며칠 버티는지, 언제·얼마나 발주할지**를 보여 주는 수급·발주 계획표입니다. 처음 들어가면 기본 조건으로 자동 조회됩니다.
**필터**
- **기준일**: 계산 기준 날짜.
- **제작기일(리드타임, 일)**: 발주 후 입고까지 걸리는 기간(기본 40일). 재고 소진 전에 미리 발주하려는 여유.
- **재고/판매 범위**: 기존(수기·바코드 미등록) / 바코드(등록) / 전체.
**표 구성(3그룹)**: ① 최근 발주 내역 ② 현재고 및 예상 판매일수 ③ 추가발주 예정내역.
**핵심 계산**: **발주예정일 = 기준일 + 소진일수 보유일수**. 발주예정일이 지난(긴급) 품목은 **빨간색**으로 표시되고, 그 시점에 맞춘 **추가 발주 제안 장수**가 함께 나옵니다.
> 발주 타이밍을 놓치지 않도록 돕는 화면입니다. 빨간 품목부터 발주를 검토하세요.
---
## 그 밖의 현황
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
- **홈택스 처리**: 세금계산서용 데이터(엑셀) 생성.
- **통계 분석(전년대비·월별·계절 추이)**: 판매 추세를 그래프로.

View File

@@ -0,0 +1,107 @@
# 기본정보 (판매소 · 단가 · 코드)
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
---
## 지정판매소 관리 · *기본정보관리 지정 판매소 관리/조회*
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
**이 화면의 용어**
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
**함께 제공되는 화면**
- **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다.
- **지정 판매소 신규/취소 현황**: 한 해 동안 군·구별로 몇 곳이 새로 생기고 없어졌는지 **건수**를 집계하는 보고서입니다. (아래 별도 설명)
- **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다.
---
## 지정 판매소 신규/취소 현황 · *기본정보관리 지정 판매소 신규/취소 현황*
선택한 **한 해 동안** 지정판매소가 **몇 곳 새로 생기고(지정)**, **몇 곳 없어졌는지(취소)**, 그래서 **연말에 몇 곳이 남았는지**를 **군·구별 건수**로 집계해 보여주는 현황판입니다. 개별 가게 명단이 아니라 **숫자 집계표**입니다.
**핵심 개념 — 4개의 기둥 컬럼**
- **종전(전년도말)**: **전년 12월 31일** 기준 정상 운영 중이던 판매소 수 → *작년 말 잔고*.
- **지정(당해년)**: 조회년도 중 **새로 지정**된(신규) 판매소 수.
- **취소(당해년)**: 조회년도 중 **취소**(폐업·해지)된 판매소 수.
- **현행(금년도말)**: **조회년도 12월 31일** 기준 정상 운영 중인 판매소 수 → *올해 말 잔고*.
> 관계식: `현행 ≈ 종전 + 지정 취소`.
> 예) 종전 120 · 지정 15 · 취소 8 → 현행 127. “올해 15곳 새로 지정하고 8곳이 취소되어 순증 7곳, 연말 기준 127곳이 운영 중”이라는 뜻입니다.
**그 밖의 계산 컬럼**
- **구코드**: 판매소에 저장된 구·군 코드 값.
- **증감(현행−종전)**: 작년 말 대비 올해 말 **순증감 곳 수**.
- **지정−취소**: 그 해 신규에서 취소를 뺀 값(당해 순증).
- **현행비중(%)**: 전체 현행 합계에서 그 군·구가 차지하는 비율.
- **전년대비 증감률(%)**: `((현행−종전) ÷ 종전) × 100` (종전이 0이면 표시 안 함).
- 맨 아래 **합계** 행: 전 군·구 총계.
**함께 표시되는 것**
- **동별 현행 요약**: 선택 군·구 안에서 동(구역)별 현행 수를 칩(badge)과 표로 보여줍니다.
- **연도별 요약(참고, 접이식)**: 활성/비활성 합계와 연도별 신규등록·취소 건수.
**사용법**: 상단에서 **조회년도**를 고르고 **[조회]**. 군·구는 로그인한 지자체 기준으로 자동 고정됩니다. 우측 상단 **엑셀저장 · 인쇄**로 내보낼 수 있습니다. (각 컬럼 제목 옆 **?** 배지에 마우스를 올리면 설명이 나옵니다.)
> **비슷한 이름과 혼동 주의**
> - **지정 판매소 신규/취소 현황**(이 화면): 군·구별 신규/취소 **건수 집계·통계**.
> - **지정판매소 관리 / 조회**: **개별 가게**를 등록·검색·열람하는 화면.
---
## 단가 관리 · *기본정보관리 단가 관리*
봉투 **가격**을 기간별로 관리합니다.
**이 화면의 용어**
- **발주단가**: 제작업체에 주는 가격(살 때).
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
- **판매단가**: 최종 소비자 판매가.
- **수수료율**: 판매수수료율(%).
- **적용시작/종료**: 그 단가가 유효한 기간.
**필터**: 봉투구분 · 봉투코드 · 조회기간.
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
> 조회 전용이며, 등록·수정은 `단가관리(CRUD)` 화면에서 합니다(이력 보존).
---
## 포장 단위 관리 · *기본정보관리 포장 단위 관리*
봉투 1박스·1팩에 **몇 장이 들어가는지** 정의합니다. 이 값으로 박스↔낱장이 환산됩니다.
**이 화면의 용어**
- **박스당 팩수**: 1박스 안의 팩 개수.
- **팩당 낱장수**: 1팩 안의 낱장(봉투) 수.
- **1박스 총 낱장** = 박스당 팩수 × 팩당 낱장수.
**표 컬럼**: 봉투코드 · 봉투명 · 박스당팩수 · 팩당낱장수 · 1박스총낱장 · 적용시작/종료 · 상태(사용/만료).
---
## 기본코드 관리 · *기본정보관리 기본 코드 관리*
시스템 곳곳의 **선택 항목(드롭다운)** 값을 관리합니다. 왼쪽에 **코드 종류**, 오른쪽에 그 종류의 **세부코드**가 나옵니다.
**이 화면의 용어**
- **코드 종류**: 분류(예: 봉투구분, 동코드, 결제구분, 불출구분).
- **세부코드**: 그 분류의 실제 값(예: 봉투구분 → 봉투/스티커).
**자주 쓰는 코드 종류**
- **봉투구분**(봉투/스티커) · **동코드**(지역 동) · **결제구분**(이체/가상계좌) · **불출구분**(무료용/공공용).
**표 컬럼**: 코드 · 코드명 · (세부코드 개수) · 상태(사용/미사용) · 작업(수정/삭제 — 관리자).
> 등록·수정·삭제는 슈퍼 관리자만 가능합니다. 조회는 누구나 가능합니다.
---
## 그 밖의 기본정보
- **판매 대행소 / 담당자 / 업체(제작·협회·회수) / 무료용 대상자 관리**: 각각 거래처·담당자·대상처를 등록·조회하는 목록 화면입니다(등록·수정은 관리자).

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() 인자로 쓸 수 있는 상대 경로로 정규화합니다.
@@ -936,3 +967,42 @@ if (! function_exists('gov_portal_nav_partial_vars')) {
return $out;
}
}
if (! function_exists('manual_help_url_for_path')) {
/**
* 현재(또는 주어진) 화면 경로에 대응하는 매뉴얼 URL. 매칭 없으면 ''.
* Config\Manual::$screenHelp 의 가장 긴(구체적) 접두를 우선 매칭한다.
*/
function manual_help_url_for_path(?string $path = null): string
{
helper('url');
$path = strtolower(trim($path ?? current_nav_request_path(), '/'));
if ($path === '') {
return '';
}
$map = config(\Config\Manual::class)->screenHelp ?? [];
$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);
$bestVal = (string) $val;
}
}
}
if ($bestVal === '') {
return '';
}
// 값은 "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

@@ -58,6 +58,61 @@ class ManualRenderer
return $this->config->pages[$slug] ?? null;
}
/**
* 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''.
*/
public function plainText(string $slug): string
{
$html = $this->render($slug);
if ($html === null || $html === '') {
return '';
}
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $text));
}
/**
* 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순).
*
* @return list<array{slug:string,title:string,snippet:string,hits:int}>
*/
public function search(string $q): array
{
$q = trim($q);
if ($q === '' || mb_strlen($q) < 1) {
return [];
}
$needle = mb_strtolower($q);
$out = [];
foreach ($this->pages() as $slug => $page) {
$text = $this->plainText((string) $slug);
if ($text === '') {
continue;
}
$hay = mb_strtolower($text);
$pos = mb_strpos($hay, $needle);
if ($pos === false) {
continue;
}
$hits = mb_substr_count($hay, $needle);
$start = max(0, $pos - 30);
$snippet = mb_substr($text, $start, 100);
if ($start > 0) {
$snippet = '…' . $snippet;
}
$out[] = [
'slug' => (string) $slug,
'title' => (string) $page['title'],
'snippet' => trim($snippet),
'hits' => $hits,
];
}
usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']);
return $out;
}
/**
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
*/

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 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>
@@ -18,14 +36,14 @@
<table class="w-full data-table">
<thead>
<tr>
<th>요청일</th>
<th>아이디</th>
<th>이름</th>
<th>지자체</th>
<th>요청 역할</th>
<th>상태</th>
<th>처리일</th>
<th>관리</th>
<th class="text-center">요청일</th>
<th class="text-center">아이디</th>
<th class="text-center">이름</th>
<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

@@ -13,15 +13,15 @@
<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 overflow-auto mt-2">
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>일시</th>
<th>아이디</th>
<th>성공</th>
<th>IP</th>
<th>메시지</th>
<th class="text-left">일시</th>
<th class="text-left">아이디</th>
<th class="text-center">성공</th>
<th class="text-left">IP</th>
<th class="text-left">메시지</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -8,15 +8,15 @@
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<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">번호</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>현재재고(낱장)</th>
<th>최종갱신</th>
<th class="w-16 text-center">번호</th>
<th class="text-center">봉투코드</th>
<th class="text-left">봉투명</th>
<th class="text-right">현재재고(낱장)</th>
<th class="text-center">최종갱신</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-issues/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -18,20 +18,20 @@
<a href="<?= mgmt_url('bag-issues') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<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">번호</th>
<th>연도</th>
<th>분기</th>
<th>구분</th>
<th>불출일</th>
<th class="w-16 text-center">번호</th>
<th class="text-center">연도</th>
<th class="text-center">분기</th>
<th class="text-center">구분</th>
<th class="text-center">불출일</th>
<th>불출처</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>수량</th>
<th class="w-24">작업</th>
<th class="text-center">봉투코드</th>
<th class="text-left">봉투명</th>
<th class="text-right">수량</th>
<th class="w-24 text-center">작업</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -48,7 +48,7 @@
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="mt-2 space-y-2">
<?= csrf_field() ?>
<div class="border border-gray-300 bg-white p-2">
<div class="border border-gray-300 rounded-lg bg-white p-2">
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
@@ -63,16 +63,16 @@
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<section class="xl:col-span-5 border border-gray-300 bg-white">
<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">발주일</th>
<th>제작업체</th>
<th>입고처</th>
<th class="w-16">상태</th>
<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>
@@ -92,7 +92,7 @@
</div>
</section>
<section class="xl:col-span-7 border border-gray-300 bg-white">
<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">발주 Form</div>
<div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -136,17 +136,17 @@
</div>
</div>
<div class="border border-gray-300 overflow-auto">
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
<table class="w-full data-table text-sm order-input-table" id="order-item-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-16">선택</th>
<th>품명</th>
<th class="w-28">수량(BOX)</th>
<th class="w-24">단가</th>
<th class="w-24">환산수량</th>
<th class="w-28">금액</th>
<th class="w-12 text-center">번호</th>
<th class="w-16 text-center">선택</th>
<th class="text-left">품명</th>
<th class="w-28 text-right">수량(BOX)</th>
<th class="w-24 text-right">단가</th>
<th class="w-24 text-right">환산수량</th>
<th class="w-28 text-right">금액</th>
</tr>
</thead>
<tbody id="selected-order-items-body"></tbody>
@@ -170,20 +170,20 @@
</section>
</div>
<section class="border border-gray-300 bg-white">
<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>
<p class="text-xs text-gray-600 px-2 py-1">아래 목록에서 봉투를 선택하면 발주 품목에 추가됩니다. (개수 제한 없음)</p>
<div class="overflow-auto">
<table class="w-full data-table text-sm order-reference-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-20">선택</th>
<th class="w-12 text-center">번호</th>
<th class="w-20 text-center">선택</th>
<th>봉투 종류</th>
<th class="w-24">발주단가</th>
<th class="w-24">Box당 팩</th>
<th class="w-24">팩당 낱장</th>
<th class="w-28">1박스 총 낱장</th>
<th class="w-24 text-right">발주단가</th>
<th class="w-24 text-right">Box당 팩</th>
<th class="w-24 text-right">팩당 낱장</th>
<th class="w-28 text-right">1박스 총 낱장</th>
</tr>
</thead>
<tbody>

View File

@@ -5,7 +5,7 @@
<span class="text-sm font-bold text-gray-700">발주 상세 <?= esc($order->bo_lot_no) ?></span>
</div>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-4xl">
<table class="w-full text-sm">
<tbody>
<tr class="border-b">
@@ -53,16 +53,16 @@
</table>
</div>
<div class="border border-gray-300 overflow-auto mt-4">
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-4">
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>단가</th>
<th>박스수</th>
<th>낱장수</th>
<th>금액</th>
<th class="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 class="text-right">

View File

@@ -90,19 +90,19 @@
</form>
</section>
<div class="bag-order-print-wrap border border-gray-300 overflow-auto mt-2">
<div class="bag-order-print-wrap border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="bag-order-print-table w-full data-table">
<thead>
<tr>
<th class="w-32">발주일자</th>
<th class="min-w-[10rem]">제작 업체</th>
<th class="min-w-[12rem]">품 명</th>
<th class="w-28">발주 수량</th>
<th class="w-28">입고 수량</th>
<th class="w-28">미입고수량</th>
<th class="w-32">발주 금액</th>
<th class="min-w-[9rem]">입고처</th>
<th class="min-w-[8rem]">비 고</th>
<th class="w-32 text-center">발주일자</th>
<th class="min-w-[10rem] text-left">제작 업체</th>
<th class="min-w-[12rem] text-left">품 명</th>
<th class="w-28 text-right">발주 수량</th>
<th class="w-28 text-right">입고 수량</th>
<th class="w-28 text-right">미입고수량</th>
<th class="w-32 text-right">발주 금액</th>
<th class="min-w-[9rem] text-left">입고처</th>
<th class="min-w-[8rem] text-left">비 고</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">봉투 단가 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-prices/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -5,15 +5,15 @@
<span class="text-sm font-bold text-gray-700">단가 변경 이력 <?= esc($item->bp_bag_name) ?> (<?= esc($item->bp_bag_code) ?>)</span>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<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">번호</th>
<th>변경 필드</th>
<th>이전 값</th>
<th>변경 값</th>
<th>변경일시</th>
<th class="w-16 text-center">번호</th>
<th class="text-left">변경 필드</th>
<th class="text-right">이전 값</th>
<th class="text-right">변경 값</th>
<th class="text-center">변경일시</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -94,20 +94,20 @@
</div>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<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">번호</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>발주단가</th>
<th>도매가</th>
<th>소비자가</th>
<th>적용시작</th>
<th>적용종료</th>
<th class="w-20">상태</th>
<th class="w-36 no-print">작업</th>
<th class="w-16 text-center">번호</th>
<th class="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-center">적용시작</th>
<th class="text-center">적용종료</th>
<th class="w-20 text-center">상태</th>
<th class="w-36 no-print text-center">작업</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">입고 처리</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-receivings/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -18,18 +18,18 @@
<a href="<?= mgmt_url('bag-receivings') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<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">번호</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>박스수</th>
<th>낱장수</th>
<th>입고일</th>
<th>구분</th>
<th>등록일</th>
<th class="w-16 text-center">번호</th>
<th class="text-center">봉투코드</th>
<th class="text-left">봉투명</th>
<th class="text-right">박스수</th>
<th class="text-right">낱장수</th>
<th class="text-center">입고일</th>
<th class="text-center">구분</th>
<th class="text-center">등록일</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">판매 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-sales/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -26,19 +26,19 @@
<a href="<?= mgmt_url('bag-sales') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<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">번호</th>
<th>판매소</th>
<th>판매일</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>수량</th>
<th>단가</th>
<th>금액</th>
<th class="w-20">구분</th>
<th class="w-16 text-center">번호</th>
<th class="text-left">판매소</th>
<th class="text-center">판매일</th>
<th class="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="w-20 text-center">구분</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -5,7 +5,7 @@
<span class="text-sm font-bold text-gray-700">세부코드 등록</span>
</div>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/code-details/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<input type="hidden" name="cd_ck_idx" value="<?= (int) $kind->ck_idx ?>"/>

View File

@@ -5,7 +5,7 @@
<span class="text-sm font-bold text-gray-700">세부코드 수정</span>
</div>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/code-details/update/' . (int) $item->cd_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">기본코드 종류 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/code-kinds/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">기본코드 종류 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/code-kinds/update/' . (int) $item->ck_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">업체 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('companies/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">업체 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('companies/update/' . (int) $item->cp_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

View File

@@ -21,19 +21,19 @@
<a href="<?= mgmt_url('companies') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<div class="overflow-auto mt-2">
<table class="w-full text-[13px]">
<thead>
<tr>
<th class="w-16">번호</th>
<th>업체유형</th>
<th>업체명</th>
<th>사업자번호</th>
<th>대표자</th>
<th>전화</th>
<th>주소</th>
<th class="w-20">상태</th>
<th class="w-36">작업</th>
<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 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 w-20 text-left">상태</th>
<th class="py-2.5 px-2 w-36 text-left">작업</th>
</tr>
</thead>
<tbody>
@@ -46,26 +46,34 @@
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr>
<td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center"><?= esc($row->cp_type) ?></td>
<td class="text-left pl-2"><?= esc($row->cp_name) ?></td>
<td class="text-center"><?= esc($row->cp_biz_no) ?></td>
<td class="text-center"><?= esc($row->cp_rep_name) ?></td>
<td class="text-center"><?= esc($row->cp_tel) ?></td>
<td class="text-left pl-2"><?= esc($row->cp_addr) ?></td>
<td class="text-center"><?= (int) $row->cp_state === 1 ? '사용' : '미사용' ?></td>
<td class="text-center">
<a href="<?= mgmt_url('companies/edit/' . (int) $row->cp_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
<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"><?= (int) $startNo + (int) $idx ?></td>
<td class="py-2.5 px-2 text-left">
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-600"><?= esc($row->cp_type) ?></span>
</td>
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->cp_name) ?></td>
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->cp_biz_no) ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->cp_rep_name) ?></td>
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->cp_tel) ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->cp_addr) ?></td>
<td class="py-2.5 px-2 text-left">
<?php if ((int) $row->cp_state === 1): ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">사용</span>
<?php else: ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>
<?php endif; ?>
</td>
<td class="py-2.5 px-2 text-left text-xs">
<a href="<?= mgmt_url('companies/edit/' . (int) $row->cp_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= mgmt_url('companies/delete/' . (int) $row->cp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
<button type="submit" class="text-red-600 hover:underline">삭제</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr>
<tr><td colspan="9" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>

View File

@@ -17,22 +17,22 @@
<!-- 통계 카드 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="border border-gray-300 p-4 bg-white">
<div class="border border-gray-300 rounded-lg p-4 bg-white">
<div class="text-xs text-gray-500">총 발주 건수</div>
<div class="text-2xl font-bold text-blue-700"><?= number_format($s['order_count'] ?? 0) ?></div>
<div class="text-xs text-gray-400 mt-1">금액: <?= number_format($s['order_amount'] ?? 0) ?>원</div>
</div>
<div class="border border-gray-300 p-4 bg-white">
<div class="border border-gray-300 rounded-lg p-4 bg-white">
<div class="text-xs text-gray-500">총 판매 건수</div>
<div class="text-2xl font-bold text-green-700"><?= number_format($s['sale_count'] ?? 0) ?></div>
<div class="text-xs text-gray-400 mt-1">금액: <?= number_format($s['sale_amount'] ?? 0) ?>원</div>
</div>
<div class="border border-gray-300 p-4 bg-white">
<div class="border border-gray-300 rounded-lg p-4 bg-white">
<div class="text-xs text-gray-500">재고 품목 수</div>
<div class="text-2xl font-bold text-purple-700"><?= number_format($s['inventory_count'] ?? 0) ?></div>
<div class="text-xs text-gray-400 mt-1">현재 재고가 있는 봉투 품목</div>
</div>
<div class="border border-gray-300 p-4 bg-white">
<div class="border border-gray-300 rounded-lg p-4 bg-white">
<div class="text-xs text-gray-500">이번 달 불출</div>
<div class="text-2xl font-bold text-orange-700"><?= number_format($s['issue_count_month'] ?? 0) ?></div>
<div class="text-xs text-gray-400 mt-1"><?= date('Y년 n월') ?> 무료용 불출</div>
@@ -47,13 +47,13 @@
<h3 class="text-sm font-bold text-gray-700">최근 발주 5건</h3>
<a href="<?= base_url('bag/bag-orders') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</div>
<div class="border border-gray-300 overflow-auto">
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>LOT번호</th>
<th>발주일</th>
<th>상태</th>
<th class="text-center">LOT번호</th>
<th class="text-center">발주일</th>
<th class="text-center">상태</th>
</tr>
</thead>
<tbody class="text-center">
@@ -92,15 +92,15 @@
<h3 class="text-sm font-bold text-gray-700">최근 판매 5건</h3>
<a href="<?= base_url('bag/bag-sales') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</div>
<div class="border border-gray-300 overflow-auto">
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>판매소</th>
<th>봉투명</th>
<th>수량</th>
<th>금액</th>
<th>구분</th>
<th class="text-left pl-2">판매소</th>
<th class="text-left pl-2">봉투명</th>
<th class="text-right">수량</th>
<th class="text-right">금액</th>
<th class="text-center">구분</th>
</tr>
</thead>
<tbody class="text-right">

View File

@@ -1,11 +1,7 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
<style>
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; }
.ds-bc-table th { background: #e9ecef; color: #2d3748; }
.ds-bc-table td { background: #fff; }
.ds-bc-table td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-table td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-check { width: 14px; height: 14px; }
</style>
@@ -55,17 +51,17 @@
<label class="inline-flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ds-bc-check-all" class="ds-bc-check"> 전체선택</label>
<span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
</div>
<div class="overflow-auto border border-gray-300 bg-white">
<table class="ds-bc-table">
<div class="overflow-auto">
<table class="w-full text-[13px]">
<thead>
<tr>
<th class="w-14">출력</th>
<th class="w-36">판매소 코드</th>
<th>판매소명</th>
<th class="w-24">대표자명</th>
<th class="w-32">사업자번호</th>
<th>사업장 주소</th>
<th class="w-16">상태</th>
<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-36">판매소 코드</th>
<th class="py-2.5 px-2">판매소명</th>
<th class="py-2.5 px-2 w-24">대표자명</th>
<th class="py-2.5 px-2 w-32">사업자번호</th>
<th class="py-2.5 px-2">사업장 주소</th>
<th class="py-2.5 px-2 w-16 text-left">상태</th>
</tr>
</thead>
<tbody>
@@ -74,18 +70,18 @@
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '사용' : '정지';
?>
<tr>
<td class="text-center"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
<td class="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
<td class="name-cell text-blue-700" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
<td class="addr-cell" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
<td class="<?= $st === 1 ? 'text-blue-700' : 'text-red-600' ?>"><?= esc($stLabel) ?></td>
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="py-2.5 px-2 text-left"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
<td class="py-2.5 px-2 name-cell font-medium text-gray-900" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
<td class="py-2.5 px-2 text-gray-600"><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
<td class="py-2.5 px-2 addr-cell text-gray-500" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
<td class="py-2.5 px-2 text-left"><span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $st === 1 ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' ?>"><?= esc($stLabel) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회된 지정판매소가 없습니다.</td></tr>
<tr><td colspan="7" class="text-center text-gray-400 py-6">조회된 지정판매소가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form id="designated-shop-create-form" action="<?= mgmt_url('designated-shops/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
@@ -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

@@ -29,6 +29,7 @@ $exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . htt
font-weight: 600;
}
.gbms-dnc-table tbody td.text-left { text-align: left; }
.gbms-dnc-table thead th.gbms-region-head { text-align: left; }
.gbms-dnc-table tbody tr.gbms-total td {
font-weight: 700;
border: 2px solid #c62828;
@@ -144,11 +145,11 @@ $exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . htt
<div class="mx-2 mt-3 mb-4 gbms-dnc-wrap">
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div>
<div class="overflow-x-auto border border-gray-400 bg-white">
<div class="overflow-x-auto border border-gray-400 rounded-lg bg-white">
<table class="gbms-dnc-table">
<thead>
<tr>
<th rowspan="2" class="min-w-[6rem]">군·구</th>
<th rowspan="2" class="min-w-[6rem] gbms-region-head">군·구</th>
<th rowspan="2">
<span class="gbms-tip">
종전

View File

@@ -20,7 +20,7 @@ $dateField = static function (string $key) use ($shop, $v): string {
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form id="designated-shop-edit-form" action="<?= mgmt_url('designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>

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,24 +244,20 @@ $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">
<table class="w-full data-table">
<div class="ds-list-scroll overflow-auto flex-1 min-h-0">
<table class="w-full text-[13px]">
<thead>
<tr>
<th class="w-14">번호</th>
<th class="w-24">구·군</th>
<th class="w-24">지정일</th>
<th class="w-24">구역</th>
<th class="ds-col-tight">대표자명</th>
<th class="ds-col-tight">상호명</th>
<th class="ds-col-zip">우편번호</th>
<th class="text-left">주소</th>
<th class="w-28">사업자번호</th>
<th class="w-28">전화</th>
<th class="w-16">상태</th>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<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" class="text-right">
<tbody id="ds-list-body">
<?php foreach ($list as $i => $row): ?>
<?php
$sn = (string) ($row->ds_shop_no ?? '');
@@ -266,19 +284,31 @@ $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" data-row-index="<?= (int) $i ?>" role="button" tabindex="0">
<td class="text-center"><?= esc($shortNo) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($ggLabel) ?></td>
<td class="text-center text-xs"><?= esc($daDisp) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($zone) ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-center text-xs ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td>
<td class="text-left pl-1 text-xs ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_biz_no ?? '') ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_tel ?? '') ?></td>
<td class="text-center <?= $st === 2 ? 'text-pink-600 font-medium' : ($st === 3 ? 'text-orange-700' : '') ?>"><?= esc($stLabel) ?></td>
<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 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-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>
<?php elseif ($stLabel !== ''): ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500"><?= esc($stLabel) ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -290,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">지도</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>
@@ -436,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;
@@ -463,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) {
@@ -489,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>
@@ -497,10 +643,10 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<table class="w-full data-table text-xs">
<thead>
<tr>
<th>번호</th>
<th class="text-center">번호</th>
<th>지자체</th>
<th>구·군</th>
<th>지정일</th>
<th>구·군(동코드)</th>
<th class="text-center">지정일</th>
<th>구역</th>
<th>대표자명</th>
<th>상호명</th>
@@ -510,7 +656,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<th>전화</th>
<th>판매소번호</th>
<th>가상계좌</th>
<th>상태</th>
<th class="text-center">상태</th>
<th>등록일</th>
</tr>
</thead>
@@ -542,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>

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