36 Commits

Author SHA1 Message Date
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
taekyoungc
ec3119799c gov-portal 디자인을 시스템 전체에 적용한다.
- 사이트 업무 페이지: 공통 셸 bag/layout/portal(헤더+대메뉴 클릭+좌측 사이드바 소메뉴)
- 관리자 페이지: admin/layout 을 동일 포털 셸로 재작성(관리자 메뉴 트리, 폴백)
- 메인(/): gov-portal 대시보드, 종량제 실데이터만(재고/주문/승인/활동로그)
- 로그인/회원가입/2차인증/TOTP: 공통 auth/_shell 로 통일, 사이트 공통 로고
- 버튼색 통일: btn-search 등 주요 버튼을 #243a5e(메뉴바 네이비보다 살짝 밝게),
  밝은 파랑 채움 버튼(#2b4c8c/#1e548a)도 동일 색으로
- gov_portal_nav_context() 임의 메뉴 트리 수용, 업무 셸은 실제 bag/* 링크 유지
- Admin\Menu 권한거부 리다이렉트 admin/dashboard(404) → admin 수정
- E2E redesign.spec.js 추가, 기능 무변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:47:36 +09:00
taekyoungc
707182ad2d 운영 PHP 8.2와 호환되도록 의존성 잠금을 정정한다.
- config.platform.php=8.2.30 고정 — 로컬(8.3)에서 8.3 전용 버전이 잠기는 문제 방지
- maennchen/zipstream-php 3.2.2(php^8.3) → 3.1.2(php^8.1)로 재잠금
- league/commonmark 유지, 운영 서버 composer install 가능하도록 정정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 01:18:48 +09:00
180 changed files with 6359 additions and 1928 deletions

3
.gitignore vendored
View File

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

View File

@@ -27,6 +27,8 @@ class Filters extends BaseFilters
public array $aliases = [ public array $aliases = [
'adminAuth' => \App\Filters\AdminAuthFilter::class, 'adminAuth' => \App\Filters\AdminAuthFilter::class,
'loginAuth' => \App\Filters\LoginAuthFilter::class, 'loginAuth' => \App\Filters\LoginAuthFilter::class,
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
'sessionGuard' => \App\Filters\SessionGuardFilter::class,
'csrf' => CSRF::class, 'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class, 'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class, 'honeypot' => Honeypot::class,
@@ -77,10 +79,12 @@ class Filters extends BaseFilters
// 'honeypot', // 'honeypot',
// 'csrf', // 'csrf',
// 'invalidchars', // 'invalidchars',
'sessionGuard', // 중복 로그인 방지(나중 로그인 우선)
], ],
'after' => [ 'after' => [
// 'honeypot', // 'honeypot',
// 'secureheaders', // 'secureheaders',
'embedRedirect', // 임베드(탭) 리다이렉트에 embed=1 유지 → 중첩 셸 방지
], ],
]; ];
@@ -108,5 +112,10 @@ class Filters extends BaseFilters
* *
* @var array<string, array<string, list<string>>> * @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,66 @@ class Manual extends BaseConfig
*/ */
public array $pages = [ public array $pages = [
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'], 'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
'account' => ['title' => '로그인·회원가입·계정', 'file' => '01_account.md'],
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'], 'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'], 'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'], 'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
'sales' => ['title' => '판매·불출', 'file' => '40_sales_issue.md'], 'sales' => ['title' => '판매·반품·불출·주문', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '판매현황·수불·통계', 'file' => '50_reports.md'], 'reports' => ['title' => '현황·리포트·수불', 'file' => '50_reports.md'],
'basic' => ['title' => '기본정보(판매소·단가·코드)', 'file' => '60_basic_info.md'],
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'], 'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.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#LOT-No 디스켓 불출',
'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' => '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',
];
} }

View File

@@ -6,6 +6,7 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes * @var RouteCollection $routes
*/ */
$routes->get('/', 'Home::index'); $routes->get('/', 'Home::index');
$routes->get('workspace', 'Home::workspace');
$routes->get('dashboard', 'Home::dashboard'); $routes->get('dashboard', 'Home::dashboard');
$routes->get('dashboard/simple', 'Home::dashboardSimple'); $routes->get('dashboard/simple', 'Home::dashboardSimple');
$routes->get('dashboard/compact', 'Home::dashboardCompact'); $routes->get('dashboard/compact', 'Home::dashboardCompact');
@@ -59,6 +60,7 @@ $routes->get('bag/help', 'Bag::help');
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용 // 사용자 매뉴얼(설명서) — 로그인 사용자 전용
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void { $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
$routes->get('manual', 'Bag::manual'); $routes->get('manual', 'Bag::manual');
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
$routes->get('manual/(:segment)', 'Bag::manualPage/$1'); $routes->get('manual/(:segment)', 'Bag::manualPage/$1');
}); });
@@ -75,6 +77,7 @@ $routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); $routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); $routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1'); $routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
$routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
$routes->get('bag/order/change', 'Bag::orderChange'); $routes->get('bag/order/change', 'Bag::orderChange');
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1'); $routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed'); $routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
@@ -219,6 +222,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->post('password-change', 'Admin\PasswordChange::update'); $routes->post('password-change', 'Admin\PasswordChange::update');
}); });
// 사용자 의견 접수 (로그인 사용자)
$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
// Auth // Auth
$routes->get('login', 'Auth::showLoginForm'); $routes->get('login', 'Auth::showLoginForm');
$routes->post('login', 'Auth::login'); $routes->post('login', 'Auth::login');
@@ -234,6 +240,13 @@ $routes->post('register', 'Auth::register');
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void { $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index'); $routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
$routes->post('select-local-government', 'Admin\SelectLocalGovernment::store'); $routes->post('select-local-government', 'Admin\SelectLocalGovernment::store');
// 사용자 의견 검토
$routes->get('feedback', 'Admin\Feedback::index');
$routes->get('feedback/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->get('/', 'Admin\Dashboard::index'); $routes->get('/', 'Admin\Dashboard::index');
$routes->get('users', 'Admin\User::index'); $routes->get('users', 'Admin\User::index');
$routes->get('users/create', 'Admin\User::create'); $routes->get('users/create', 'Admin\User::create');

View File

@@ -0,0 +1,145 @@
<?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 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);
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

@@ -345,7 +345,7 @@ class Menu extends BaseController
]); ]);
} }
return redirect()->to(base_url('admin/dashboard')) return redirect()->to(base_url('admin'))
->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.'); ->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.');
} }
} }

View File

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

View File

@@ -77,6 +77,36 @@ 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 '';
};
// 현재(카테고리 필터까지 적용된) 결과에서 선택 가능한 크기 목록 구성
$sizeOptions = [];
foreach ($filtered as $row) {
$sz = $sizeOf((string) ($row->bs_bag_name ?? ''));
if ($sz !== '') {
$sizeOptions[$sz] = true;
}
}
$sizeOptions = array_keys($sizeOptions);
usort($sizeOptions, static function (string $a, string $b): int {
$na = (int) preg_replace('/\D/', '', $a);
$nb = (int) preg_replace('/\D/', '', $b);
return $na <=> $nb;
});
$size = trim((string) ($this->request->getGet('size') ?? ''));
if ($size !== '') {
$filtered = array_values(array_filter($filtered, static fn ($row): bool => $sizeOf((string) ($row->bs_bag_name ?? '')) === $size));
}
if ($mode === 'daily') { if ($mode === 'daily') {
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee); $built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
} else { } else {
@@ -169,6 +199,8 @@ class SalesReport extends BaseController
'dsIdx' => $dsIdx, 'dsIdx' => $dsIdx,
'saIdx' => $saIdx, 'saIdx' => $saIdx,
'cats' => $cats, 'cats' => $cats,
'sizeOptions' => $sizeOptions,
'size' => $size,
'shops' => $shops, 'shops' => $shops,
'agencies' => $agencies, 'agencies' => $agencies,
'lgName' => $lgName, 'lgName' => $lgName,
@@ -2560,7 +2592,8 @@ class SalesReport extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); 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')); $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if ($startDate > $endDate) { if ($startDate > $endDate) {
@@ -3189,7 +3222,8 @@ class SalesReport extends BaseController
$salesScope = 'all'; $salesScope = 'all';
} }
$queried = $this->request->getGet('search') === '1'; // 첫 진입(search 파라미터 없음)에도 기본 조건(오늘·40일·ALL)으로 자동 조회해 바로 표시한다.
$queried = ($this->request->getGet('search') ?? '1') === '1';
$built = (new \App\Libraries\BagSupplyPlanBuilder())->build( $built = (new \App\Libraries\BagSupplyPlanBuilder())->build(
$lgIdx, $lgIdx,
$refDate, $refDate,

View File

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

View File

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

View File

@@ -22,7 +22,10 @@ class Auth extends BaseController
return redirect()->to('/'); return redirect()->to('/');
} }
return view('auth/login'); return view('auth/login', [
'pageTitle' => '로그인 - GBLS',
'cardMax' => 'max-w-md',
]);
} }
public function login() public function login()
@@ -157,6 +160,8 @@ class Auth extends BaseController
return view('auth/login_two_factor', [ return view('auth/login_two_factor', [
'memberId' => $member->mb_id, 'memberId' => $member->mb_id,
'pageTitle' => '2차 인증 - GBLS',
'cardMax' => 'max-w-md',
]); ]);
} }
@@ -239,6 +244,8 @@ class Auth extends BaseController
'memberId' => $member->mb_id, 'memberId' => $member->mb_id,
'qrDataUri' => $qrDataUri, 'qrDataUri' => $qrDataUri,
'secret' => $secret, 'secret' => $secret,
'pageTitle' => '2차 인증 등록 - GBLS',
'cardMax' => 'max-w-lg',
]); ]);
} }
@@ -341,6 +348,8 @@ class Auth extends BaseController
return view('auth/register', [ return view('auth/register', [
'localGovernments' => $localGovernments, 'localGovernments' => $localGovernments,
'pageTitle' => '회원가입 - GBLS',
'cardMax' => 'max-w-md',
]); ]);
} }
@@ -348,7 +357,7 @@ class Auth extends BaseController
{ {
$rules = [ $rules = [
'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]', 'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]', 'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'mb_passwd_confirm' => 'required|matches[mb_passwd]', 'mb_passwd_confirm' => 'required|matches[mb_passwd]',
'mb_name' => 'required|max_length[100]', 'mb_name' => 'required|max_length[100]',
'mb_email' => 'permit_empty|valid_email|max_length[100]', 'mb_email' => 'permit_empty|valid_email|max_length[100]',
@@ -366,8 +375,9 @@ class Auth extends BaseController
], ],
'mb_passwd' => [ 'mb_passwd' => [
'required' => '비밀번호를 입력해 주세요.', 'required' => '비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.', 'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'max_length' => '비밀번호는 255자 이하여야 합니다.', 'max_length' => '비밀번호는 255자 이하여야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
], ],
'mb_passwd_confirm' => [ 'mb_passwd_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.', 'required' => '비밀번호 확인을 입력해 주세요.',
@@ -539,6 +549,8 @@ class Auth extends BaseController
private function completeLogin(object $member, array $logData): RedirectResponse private function completeLogin(object $member, array $logData): RedirectResponse
{ {
$this->clearPending2faSession(); $this->clearPending2faSession();
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
$sessionToken = bin2hex(random_bytes(16));
$sessionData = [ $sessionData = [
'mb_idx' => $member->mb_idx, 'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id, 'mb_id' => $member->mb_id,
@@ -546,6 +558,7 @@ class Auth extends BaseController
'mb_level' => $member->mb_level, 'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null, 'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true, 'logged_in' => true,
'session_token' => $sessionToken,
]; ];
session()->set($sessionData); session()->set($sessionData);
@@ -553,18 +566,42 @@ class Auth extends BaseController
'mb_latestdate' => date('Y-m-d H:i:s'), 'mb_latestdate' => date('Y-m-d H:i:s'),
'mb_login_fail_count' => 0, 'mb_login_fail_count' => 0,
'mb_locked_until' => null, 'mb_locked_until' => null,
'mb_session_token' => $sessionToken,
]); ]);
// 로그인 알림 — 직전 성공 로그인과 IP 비교(새 IP면 경고). 현재 로그인 기록 INSERT 이전에 조회.
$loginNotice = '';
try {
$curIp = (string) $this->request->getIPAddress();
$prev = model(MemberLogModel::class)
->where('mb_idx', (int) $member->mb_idx)
->where('mll_success', 1)
->orderBy('mll_idx', 'DESC')
->first();
if ($prev) {
$prevIp = (string) ($prev->mll_ip ?? '');
$prevAt = (string) ($prev->mll_regdate ?? '');
if ($prevIp !== '' && $prevIp !== $curIp) {
$loginNotice = ' ⚠ 새로운 환경(IP ' . $curIp . ')에서 로그인되었습니다. 본인이 아니면 비밀번호를 변경하세요. (이전 로그인: ' . $prevAt . ' · ' . $prevIp . ')';
} elseif ($prevAt !== '') {
$loginNotice = ' (이전 로그인: ' . $prevAt . ')';
}
}
} catch (\Throwable $e) {
// 알림 계산 실패는 로그인에 영향 주지 않음
}
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx); $this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
$successMsg = '로그인되었습니다.' . $loginNotice;
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) { if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('admin'))->with('success', $successMsg);
} }
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) { if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('admin/select-local-government'))->with('success', $successMsg);
} }
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('/'))->with('success', $successMsg);
} }
private function buildLogData(string $mbId, ?int $mbIdx): array private function buildLogData(string $mbId, ?int $mbIdx): array

View File

@@ -212,11 +212,15 @@ class Bag extends BaseController
return $row ? trim((string) ($row->mg_name ?? '')) : ''; return $row ? trim((string) ($row->mg_name ?? '')) : '';
} }
private function render(string $title, string $viewFile, array $data = []): string private function render(string $title, string $viewFile, array $data = [], bool $bare = false): string
{ {
return view('bag/layout/main', [ // /workspace 탭(iframe) 안에서는 임베드 레이아웃(헤더·사이드바 없이 본문만).
$layout = $this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal';
return view($layout, [
'title' => $title, 'title' => $title,
'content' => view($viewFile, $data), 'content' => view($viewFile, $data),
'bare' => $bare, // true면 바깥 카드 래퍼 없이 본문을 그대로(이미 카드형 화면용)
]); ]);
} }
@@ -588,7 +592,8 @@ class Bag extends BaseController
'selectedKind' => $selectedKind, 'selectedKind' => $selectedKind,
'detailList' => $detailList, 'detailList' => $detailList,
'rowCanEdit' => $rowCanEdit, 'rowCanEdit' => $rowCanEdit,
]); 'isEmbed' => $this->isEmbeddedRequest(), // 행 클릭 시 embed 유지(중첩 셸 방지)
], true); // 본문이 이미 카드 2개라 바깥 래퍼 생략
} }
/** /**
@@ -3297,7 +3302,8 @@ SQL);
public function flow(): string public function flow(): string
{ {
$lgIdx = $this->lgIdx(); $lgIdx = $this->lgIdx();
$queried = $this->request->getGet('search') === '1' // 첫 진입(search 파라미터 없음)에도 기본 조건(이번 달·전체)으로 자동 조회해 바로 표시한다.
$queried = ($this->request->getGet('search') ?? '1') === '1'
|| $this->request->getGet('start_date') !== null || $this->request->getGet('start_date') !== null
|| $this->request->getGet('end_date') !== null; || $this->request->getGet('end_date') !== null;
@@ -3577,8 +3583,23 @@ SQL);
public function manual(): \CodeIgniter\HTTP\RedirectResponse public function manual(): \CodeIgniter\HTTP\RedirectResponse
{ {
$first = (new \App\Libraries\ManualRenderer())->firstSlug(); $first = (new \App\Libraries\ManualRenderer())->firstSlug();
$url = site_url('bag/manual/' . $first);
if ($this->isEmbeddedRequest()) {
$url .= '?embed=1'; // 워크스페이스 탭 안에서는 임베드 유지(중첩 헤더 방지)
}
return redirect()->to(site_url('bag/manual/' . $first)); return redirect()->to($url);
}
/**
* 사용자 매뉴얼 전체 검색 (JSON). q 와 일치하는 페이지·스니펫 목록.
*/
public function manualSearch(): \CodeIgniter\HTTP\ResponseInterface
{
$q = (string) ($this->request->getGet('q') ?? '');
$results = (new \App\Libraries\ManualRenderer())->search($q);
return $this->response->setJSON(['q' => $q, 'results' => $results]);
} }
/** /**
@@ -3593,11 +3614,36 @@ SQL);
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('매뉴얼 문서를 찾을 수 없습니다.'); throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('매뉴얼 문서를 찾을 수 없습니다.');
} }
// 이 매뉴얼 페이지가 설명하는 화면 → 바로가기 링크(screenHelp 역방향).
// screenHelp 에는 "현재경로 매칭"용 비(非)라우트 prefix(bag/sale, bag/order 등)도 섞여 있어
// 그대로 쓰면 404가 난다. 같은 소제목(hint)의 후보 중 "실제 GET 라우트"인 첫 경로만 채택.
$routeCollection = service('routes');
$routeCollection->loadRoutes();
$validGet = $routeCollection->getRoutes('GET'); // [경로 => 핸들러] (정적 경로는 키가 곧 경로)
$screenHelp = config(\Config\Manual::class)->screenHelp ?? [];
$jumpByHint = [];
foreach ($screenHelp as $path => $val) {
[$pSlug, $hint] = array_pad(explode('#', (string) $val, 2), 2, null);
if ($pSlug !== $slug || $hint === null || trim($hint) === '') {
continue;
}
if (isset($jumpByHint[$hint]) || ! isset($validGet[(string) $path])) {
continue; // 이미 채택했거나, 실제 라우트가 아니면 건너뜀
}
$jumpByHint[$hint] = (string) $path;
}
$jumps = [];
foreach ($jumpByHint as $hint => $path) {
$jumps[] = ['hint' => $hint, 'url' => base_url($path)];
}
return $this->render('사용자 매뉴얼 · ' . $page['title'], 'bag/manual', [ return $this->render('사용자 매뉴얼 · ' . $page['title'], 'bag/manual', [
'pages' => $renderer->pages(), 'pages' => $renderer->pages(),
'current' => $slug, 'current' => $slug,
'title' => $page['title'], 'title' => $page['title'],
'body' => $body, 'body' => $body,
'jumps' => $jumps,
]); ]);
} }
@@ -4749,14 +4795,18 @@ SQL);
$receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef); $receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
$senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0); $senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
// 오류 시 명시적으로 일괄 입고 화면으로 복귀(back()은 도움말 드로어 등이 남긴
// previous_url 로 잘못 이동할 수 있어 사용하지 않는다)
$batchUrl = site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode));
if (empty($selected)) { if (empty($selected)) {
return redirect()->back()->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.'); return redirect()->to($batchUrl)->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.');
} }
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) { if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.'); return redirect()->to($batchUrl)->withInput()->with('error', '입고일 형식을 확인해 주세요.');
} }
if ($receiverIdx <= 0) { if ($receiverIdx <= 0) {
return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.'); return redirect()->to($batchUrl)->withInput()->with('error', '인수자를 선택해 주세요.');
} }
$senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx); $senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx);
@@ -4797,7 +4847,7 @@ SQL);
} }
if (empty($insertRows)) { if (empty($insertRows)) {
return redirect()->back()->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.'); return redirect()->to($batchUrl)->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.');
} }
$recvModel = model(BagReceivingModel::class); $recvModel = model(BagReceivingModel::class);
@@ -4827,7 +4877,7 @@ SQL);
$db->transComplete(); $db->transComplete();
if (! $db->transStatus()) { if (! $db->transStatus()) {
return redirect()->back()->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.'); return redirect()->to($batchUrl)->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.');
} }
return redirect()->to(site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode))) return redirect()->to(site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode)))
@@ -6997,12 +7047,26 @@ SQL;
return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
} }
// 일자별·기간별 조회 (배달일 so_delivery_date 기준)
$startDate = trim((string) ($this->request->getGet('start_date') ?? ''));
$endDate = trim((string) ($this->request->getGet('end_date') ?? ''));
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
if ($isYmd($startDate) && $isYmd($endDate) && $startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$orderModel = model(ShopOrderModel::class); $orderModel = model(ShopOrderModel::class);
$builder = $orderModel->where('so_lg_idx', $lgIdx); $builder = $orderModel->where('so_lg_idx', $lgIdx);
if ($db->fieldExists('so_channel', 'shop_order')) { if ($db->fieldExists('so_channel', 'shop_order')) {
$builder->where('so_channel', 'phone'); $builder->where('so_channel', 'phone');
} }
if ($isYmd($startDate)) {
$builder->where('so_delivery_date >=', $startDate);
}
if ($isYmd($endDate)) {
$builder->where('so_delivery_date <=', $endDate . ' 23:59:59');
}
$orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll(); $orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll();
$orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders)); $orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders));
@@ -7031,6 +7095,25 @@ SQL;
]; ];
} }
// 주문의 지정판매소 상세(코드·담당자·전화·주소) — 우측 '지정판매소 정보' 표시용
$shopMap = [];
$dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0)));
if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
$shopRows = $db->table('designated_shop')
->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
->whereIn('ds_idx', $dsIdxs)
->get()->getResultArray();
foreach ($shopRows as $s) {
$shopMap[(int) $s['ds_idx']] = [
'shop_no' => (string) ($s['ds_shop_no'] ?? ''),
'name' => (string) ($s['ds_name'] ?? ''),
'rep_name' => (string) ($s['ds_rep_name'] ?? ''),
'tel' => (string) ($s['ds_tel'] ?? ''),
'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
];
}
}
$itemsByOrder = []; $itemsByOrder = [];
foreach ($itemRows as $item) { foreach ($itemRows as $item) {
$orderId = (int) ($item->soi_so_idx ?? 0); $orderId = (int) ($item->soi_so_idx ?? 0);
@@ -7042,6 +7125,7 @@ SQL;
'soi_bag_name' => (string) ($item->soi_bag_name ?? ''), 'soi_bag_name' => (string) ($item->soi_bag_name ?? ''),
'soi_unit_price' => (int) ($item->soi_unit_price ?? 0), 'soi_unit_price' => (int) ($item->soi_unit_price ?? 0),
'soi_qty' => (int) ($item->soi_qty ?? 0), 'soi_qty' => (int) ($item->soi_qty ?? 0),
'soi_packed_qty' => (int) ($item->soi_packed_qty ?? 0),
'soi_amount' => (int) ($item->soi_amount ?? 0), 'soi_amount' => (int) ($item->soi_amount ?? 0),
'soi_box_count' => (int) ($item->soi_box_count ?? 0), 'soi_box_count' => (int) ($item->soi_box_count ?? 0),
'soi_pack_count' => (int) ($item->soi_pack_count ?? 0), 'soi_pack_count' => (int) ($item->soi_pack_count ?? 0),
@@ -7054,12 +7138,22 @@ SQL;
$payload = []; $payload = [];
foreach ($orders as $order) { foreach ($orders as $order) {
$id = (int) ($order->so_idx ?? 0); $id = (int) ($order->so_idx ?? 0);
$dsIdx = (int) ($order->so_ds_idx ?? 0);
$shop = $shopMap[$dsIdx] ?? ['shop_no' => '', 'name' => '', 'rep_name' => '', 'tel' => '', 'addr' => ''];
$payload[] = [ $payload[] = [
'so_idx' => $id, 'so_idx' => $id,
'so_ds_idx' => $dsIdx,
'so_ds_name' => (string) ($order->so_ds_name ?? ''), 'so_ds_name' => (string) ($order->so_ds_name ?? ''),
'ds_shop_no' => $shop['shop_no'],
'ds_rep_name' => $shop['rep_name'],
'ds_tel' => $shop['tel'],
'ds_addr' => $shop['addr'],
'so_order_date' => (string) ($order->so_order_date ?? ''), 'so_order_date' => (string) ($order->so_order_date ?? ''),
'so_delivery_date' => (string) ($order->so_delivery_date ?? ''), 'so_delivery_date' => (string) ($order->so_delivery_date ?? ''),
'so_channel' => (string) ($order->so_channel ?? ''),
'so_payment_type' => (string) ($order->so_payment_type ?? ''), 'so_payment_type' => (string) ($order->so_payment_type ?? ''),
'so_received' => (int) ($order->so_received ?? 0),
'so_paid' => (int) ($order->so_paid ?? 0),
'so_total_qty' => (int) ($order->so_total_qty ?? 0), 'so_total_qty' => (int) ($order->so_total_qty ?? 0),
'so_total_amount' => (int) ($order->so_total_amount ?? 0), 'so_total_amount' => (int) ($order->so_total_amount ?? 0),
'so_status' => (string) ($order->so_status ?? 'normal'), 'so_status' => (string) ($order->so_status ?? 'normal'),
@@ -7069,6 +7163,49 @@ SQL;
return $this->render('전화접수 관리', 'bag/order_phone_manage', [ return $this->render('전화접수 관리', 'bag/order_phone_manage', [
'orders' => $payload, 'orders' => $payload,
'startDate' => $isYmd($startDate) ? $startDate : '',
'endDate' => $isYmd($endDate) ? $endDate : '',
]);
}
/**
* 입금자 영수증 출력용 — 단일 접수건의 인쇄용 영수증(독립 HTML, 자동 인쇄).
*/
public function phoneOrderReceipt(int $id): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.');
}
$order = model(ShopOrderModel::class)->where('so_idx', $id)->where('so_lg_idx', $lgIdx)->first();
if (! $order) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$items = model(ShopOrderItemModel::class)
->where('soi_so_idx', $id)
->orderBy('soi_idx', 'ASC')
->findAll();
$shop = null;
$dsIdx = (int) ($order->so_ds_idx ?? 0);
if ($dsIdx > 0) {
$shop = \Config\Database::connect()->table('designated_shop')
->select('ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
->where('ds_idx', $dsIdx)
->get()->getRowArray();
}
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? (string) $lgRow->lg_name : '';
return view('bag/order_phone_receipt', [
'order' => $order,
'items' => $items,
'shop' => $shop,
'lgName' => $lgName,
]); ]);
} }
@@ -7107,6 +7244,10 @@ SQL;
if (! is_array($qtyInput)) { if (! is_array($qtyInput)) {
$qtyInput = []; $qtyInput = [];
} }
$packedInput = $this->request->getPost('item_packed') ?? [];
if (! is_array($packedInput)) {
$packedInput = [];
}
$codeSet = []; $codeSet = [];
foreach ($items as $item) { foreach ($items as $item) {
@@ -7163,8 +7304,10 @@ SQL;
$sheetCount = $qty % $packSheets; $sheetCount = $qty % $packSheets;
} }
$packedQty = isset($packedInput[$itemId]) ? max(0, (int) $packedInput[$itemId]) : (int) ($item->soi_packed_qty ?? 0);
$itemModel->update($itemId, [ $itemModel->update($itemId, [
'soi_qty' => $qty, 'soi_qty' => $qty,
'soi_packed_qty' => $packedQty,
'soi_amount' => $amount, 'soi_amount' => $amount,
'soi_box_count' => $boxCount, 'soi_box_count' => $boxCount,
'soi_pack_count' => $packCount, 'soi_pack_count' => $packCount,
@@ -7175,10 +7318,16 @@ SQL;
$sumAmt += $amount; $sumAmt += $amount;
} }
$orderModel->update($soIdx, [ $orderUpdate = [
'so_total_qty' => $sumQty, 'so_total_qty' => $sumQty,
'so_total_amount' => $sumAmt, 'so_total_amount' => $sumAmt,
]); ];
// 배달일 수정 — 입력값이 유효한 날짜면 변경 반영(수정일자로 변경 처리)
$deliveryDate = trim((string) ($this->request->getPost('so_delivery_date') ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryDate) === 1) {
$orderUpdate['so_delivery_date'] = $deliveryDate;
}
$orderModel->update($soIdx, $orderUpdate);
$db->transComplete(); $db->transComplete();

View File

@@ -48,6 +48,20 @@ abstract class BaseController extends Controller
* *
* @param array<string, mixed> $contentData * @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 protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
{ {
$content = view($contentView, $contentData); $content = view($contentView, $contentData);
@@ -61,7 +75,8 @@ abstract class BaseController extends Controller
$path = substr($path, strlen('index.php/')); $path = substr($path, strlen('index.php/'));
} }
if ($path === 'bag' || str_starts_with($path, 'bag/')) { if ($path === 'bag' || str_starts_with($path, 'bag/')) {
return view('bag/layout/main', [ // /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal 셸
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,
]); ]);

View File

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

View File

@@ -10,14 +10,228 @@ class Home extends BaseController
public function index() public function index()
{ {
if (session()->get('logged_in')) { if (session()->get('logged_in')) {
// 메인(/) 본문은 「요약(simple)」 대시보드로 노출한다. helper('admin');
// 종래의 「종합·그래프(blend)」 본문은 /dashboard (또는 /dashboard/blend)로 이동.
return $this->dashboardSimple(); // 워크스페이스 탭(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'); return view('welcome_message');
} }
/**
* 워크스페이스 — 메뉴를 탭(iframe)으로 열어두고 작업 상태를 유지하는 화면.
*/
public function workspace()
{
if (! session()->get('logged_in')) {
return redirect()->to(base_url('login'));
}
helper('admin');
return view('bag/layout/workspace');
}
/**
* 메인 대시보드용 — GBLS에 실제 존재하는 데이터만 집계.
*
* @return array<string, mixed>
*/
private function portalDashboardData(): array
{
helper('admin');
$db = \Config\Database::connect();
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
if ($lgIdx === null) {
$raw = session()->get('mb_lg_idx');
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
$inventory = [];
$totalQty = 0;
$orderCount = 0;
$palette = ['#3b82f6', '#10b981', '#f59e0b', '#6366f1', '#ef4444', '#0ea5e9', '#14b8a6', '#a855f7', '#f97316'];
try {
if ($lgIdx !== null && $db->tableExists('bag_inventory')) {
$rows = $db->table('bag_inventory')
->select('bi_bag_name, bi_bag_code, bi_qty')
->where('bi_lg_idx', $lgIdx)
->orderBy('bi_qty', 'DESC')
->get()->getResultArray();
foreach ($rows as $r) {
$inventory[] = [
'name' => (string) ($r['bi_bag_name'] ?? $r['bi_bag_code'] ?? ''),
'qty' => (int) ($r['bi_qty'] ?? 0),
];
$totalQty += (int) ($r['bi_qty'] ?? 0);
}
}
} catch (\Throwable $e) {
$inventory = [];
}
// 재고 구성(상위 품목 비율)
$stockMix = [];
foreach (array_slice($inventory, 0, 6) as $i => $item) {
$stockMix[] = [
'name' => $item['name'],
'value' => $totalQty > 0 ? (int) round($item['qty'] / $totalQty * 100) : 0,
'color' => $palette[$i % count($palette)],
];
}
// 부족 재고(수량 적은 하위 품목) — 최대 재고 대비 비율
$maxQty = $inventory !== [] ? max(array_column($inventory, 'qty')) : 0;
$lowStock = [];
foreach (array_slice(array_reverse($inventory), 0, 5) as $item) {
$lowStock[] = [
'name' => $item['name'],
'qty' => $item['qty'],
'percent' => $maxQty > 0 ? (int) round($item['qty'] / $maxQty * 100) : 0,
];
}
try {
if ($lgIdx !== null && $db->tableExists('shop_order')) {
$orderCount = (int) $db->table('shop_order')
->where('so_lg_idx', $lgIdx)
->where('so_status', 'normal')
->countAllResults();
}
} catch (\Throwable $e) {
$orderCount = 0;
}
$pendingApprovals = 0;
try {
if ($db->tableExists('member_approval_request')) {
$pendingApprovals = (int) $db->table('member_approval_request')
->where('mar_status', 'pending')
->countAllResults();
}
} catch (\Throwable $e) {
$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 = [
'bag_order' => '발주', 'bag_receiving' => '입고', 'bag_sale' => '판매',
'bag_issue' => '불출', 'bag_inventory' => '재고', 'shop_order' => '주문접수',
'designated_shop' => '지정판매소', 'bag_price' => '단가', 'member' => '회원',
];
$recent = [];
try {
if ($db->tableExists('activity_log')) {
$logs = $db->table('activity_log')
->select('al_action, al_table, al_regdate')
->orderBy('al_idx', 'DESC')->limit(6)->get()->getResultArray();
foreach ($logs as $l) {
$t = (string) ($l['al_regdate'] ?? '');
$recent[] = [
'time' => $t !== '' ? date('m.d H:i', strtotime($t)) : '',
'text' => ($tableLabel[$l['al_table']] ?? (string) $l['al_table'])
. ' ' . ($actionLabel[$l['al_action']] ?? (string) $l['al_action']),
];
}
}
} catch (\Throwable $e) {
$recent = [];
}
return [
'lgLabel' => $this->resolveLgLabel(),
'mbName' => (string) (session()->get('mb_name') ?? '담당자'),
'mbId' => (string) (session()->get('mb_id') ?? ''),
'levelName' => config(\Config\Roles::class)->getLevelName((int) session()->get('mb_level')),
'totalQty' => $totalQty,
'itemCount' => count($inventory),
'orderCount' => $orderCount,
'pendingApprovals' => $pendingApprovals,
'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) 본문 * 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
*/ */
@@ -249,9 +463,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string protected function resolveLgLabel(): string
{ {
try { try {
$idx = session()->get('mb_lg_idx'); helper('admin');
if ($idx === null || $idx === '') { $idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
return '로그인 지자체 (미지정)'; 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); $row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') { if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -261,7 +480,7 @@ class Home extends BaseController
// 테이블 미생성 등 // 테이블 미생성 등
} }
return '북구 (데모)'; return '지자체';
} }
} }

View File

@@ -1,49 +1,50 @@
# 시작하기 · 시스템 개요 # 시작하기 · 시스템 개요
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. 이 문서는 실제 업무를 처리하는 담당자(지자체 관리자·지정판매소)를 위한 **빠른 시작 안내서**입니다. 종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. **이 시스템을 처음 쓰는 분**도 화면을 이해하고 업무를 처리할 수 있도록, 화면마다 쓰이는 용어와 버튼의 의미를 설명합니다.
## 1. 시스템은 무엇을 하나요? ## 1. 시스템은 무엇을 하나요?
지자체 종량제 쓰레기봉투의 **발주 → 입고 → 재고 → 판매/불출 → 정산·통계** 전 과정을 관리합니다. 지자체가 주민에게 파는 **종량제 쓰레기봉투**가 ① 제작업체에 **주문(발주)** 되고 → ② 창고로 **들어오고(입고)** → ③ **재고**로 보관되다가 → ④ 동네 가게(지정판매소)에 **팔리거나(판매)** 무료 대상자에게 **나눠지는(불출)** 전 과정을 컴퓨터로 관리합니다. 마지막엔 ⑤ 얼마나 팔렸는지 **집계·정산(현황/리포트)** 합니다.
- 봉투를 제작업체에 **발주**하고, 들어온 물량을 **입고** 처리합니다. ## 2. 꼭 알아야 할 기본 용어 (용어 사전)
- 현재 **재고**를 확인하고, 실사로 실제 수량과 맞춥니다.
- 지정판매소에 **판매**하거나 무료 대상자에게 **불출**합니다.
- 일계표·기간별·연간 등 **판매현황**과 **수불·통계**를 조회합니다.
## 2. 로그인과 화면 구성 | 용어 | 쉬운 설명 |
|---|---|
| **발주** | 봉투를 제작업체에 "이만큼 만들어 주세요"라고 **주문**하는 것 |
| **입고** | 주문한 봉투가 창고에 **도착해 들여놓는** 것 |
| **재고** | 지금 창고에 **남아 있는 봉투 수량** |
| **불출** | 봉투를 창고에서 **꺼내 내보내는** 것 (주로 무료 배부) |
| **수불(受拂)** | **들어오고(수입)·나가는(불출)** 움직임을 적은 장부 |
| **지정판매소** | 봉투를 파는 **동네 가게**(편의점·마트 등) |
| **대행소(판매대행소)** | 봉투 **배송·유통을 대행**하는 업체 |
| **실사** | 컴퓨터 기록과 **실제 창고 수량이 맞는지 직접 세어 확인**하는 것 |
| **박스 / 팩 / 낱장** | 포장 단위. **박스 > 팩 > 낱장(봉투 1장)**. 1박스 = 여러 팩, 1팩 = 여러 낱장 |
| **LOT(로트)** | 한 번의 발주 묶음에 부여되는 **추적용 일련번호** |
| **바코드(봉투번호)** | 박스·팩·낱장마다 붙는 **고유 번호**(스캔용) |
| **무료용 / 공공용** | 주민 무료 배부용 / 공공기관 사용용 봉투 구분 |
1. 발급받은 아이디·비밀번호로 로그인합니다. > 박스·팩·낱장·LOT·바코드의 자세한 규칙은 좌측 목차 **[봉투·LOT·바코드 코드체계]** 참고.
2. 관리자(지자체/슈퍼)는 보안을 위해 **2차 인증(OTP)** 이 적용될 수 있습니다.
3. 로그인하면 상단에 **대메뉴**가 보이고, 각 대메뉴에 마우스를 올리면 **소메뉴**가 펼쳐집니다.
4. 화면 상단의 제목 바에 현재 보고 있는 화면 이름이 표시됩니다.
> 슈퍼 관리자는 작업할 **지자체를 먼저 선택**해야 업무 화면이 열립니다. ## 3. 화면 구성과 사용법
## 3. 사용자 역할(권한) - 로그인하면 **워크스페이스**(탭 작업공간)가 열립니다. 상단에 대분류 메뉴, **대분류를 클릭하면 왼쪽에 소메뉴**가 펼쳐집니다.
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
시스템은 4단계 역할로 접근 권한을 구분합니다. > 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
| 레벨 | 역할 | 할 수 있는 일 | ## 4. 사용자 역할(권한)
|---|---|---|
| 1 | 일반 사용자 | 기본 조회 |
| 2 | 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 3 | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 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,80 @@
# 발주 · 입고 # 발주 · 입고
제작업체에 봉투를 주문(발주)하고, 도착한 물량을 시스템에 등록(입고)하는 단계입니다. **지자체 관리자** 이상이 사용합니다. 봉투를 제작업체에 **주문(발주)** 하고, 도착한 봉투를 창고에 **들여놓는(입고)** 단계입니다.
## 발주 ---
### 발주 등록 ## 발주 등록 · *발주 입고 관리 발주 등록*
**발주 입고 관리 발주 등록** 봉투를 **얼마나 주문할지** 입력해 발주서를 만드는 화면입니다. 저장하면 추적용 **LOT 번호**가 자동으로 붙습니다.
1. 봉투 **품목**(종류·용량)과 **수량**, 납품 관련 정보를 입력합니다. **이 화면의 용어**
2. 박스/낱장 수량과 금액·총계가 자동으로 계산됩니다. - **발주가능봉투**: 조달청(나라장터)에 등록되어 주문할 수 있는 봉투 종류.
3. 저장하면 발주 건이 생성되고, 추적용 **LOT 번호**가 자동 부여됩니다. - **입고처**: 들어온 봉투를 받을 창고/장소.
- **조달수수료**: 발주 금액에 붙는 수수료율(%).
- **Box당 팩 / 팩당 낱장 / 1박스 총 낱장**: 포장 환산 정보(참고용 표).
> 발주 내용은 무결성 보호를 위해 버전·해시로 관리됩니다. 수정(재발주) 시 기존 LOT는 유지됩니다. **입력 항목**: 발주월, 발주일, 협회, 제작업체, 입고처, **봉투 품목별 수량(박스 단위)**.
### 발주 변경 · 현황 **버튼**: `발주`(저장) · `변경 저장`(수정 시) · `취소`.
| 작업 | 메뉴 | 설명 | **작업 순서**
|---|---|---| 1. 발주월·발주일, 제작업체·입고처를 고릅니다.
| 발주 변경 | 발주 변경 | 기존 발주 수정·재발주 | 2. 아래 봉투 종류별로 **주문할 박스 수량**을 입력합니다(금액·총 낱장은 자동 계산).
| 발주 현황 | 발주 현황 | 발주 목록을 기간·상태로 조회, 엑셀 내보내기 | 3. `발주` 를 누르면 발주가 생성되고 LOT 번호가 부여됩니다.
| 발주 상세 | (현황에서 행 선택) | 개별 발주 상세 확인, 취소 처리 |
## 입고 ---
발주분이 실제 도착하면 입고로 등록합니다. 입고 시 **박스·팩·낱장 바코드**가 생성되어 재고에 반영됩니다. ## 발주 현황 · *발주 입고 관리 발주 현황*
| 방식 | 메뉴 | 언제 사용 | 낸 발주를 **조회·관리**하는 목록 화면입니다.
|---|---|---|
| 스캐너 입고 | 발주 입고[스캐너] | 바코드를 스캔하며 입고 |
| 일괄 입고 | 일괄입고 | 다량을 한 번에 입고 |
| 입고 현황 | 입고 현황 | 입고 기록 조회, 엑셀 내보내기 |
### 입고 처리 순서 **필터**: 발주기간(월~월) · 제작업체 · 품명 · **입고구분(전체/입고완료/미입고)**.
1. 입고할 발주 건(LOT)을 선택합니다. **표 컬럼**: 발주일자 · 제작업체 · 품명 · **발주수량 · 입고수량 · 미입고수량** · 발주금액 · 입고처 · 비고.
2. 도착 수량(박스/낱장)을 확인·입력합니다. - **미입고수량** = 발주했지만 아직 안 들어온 수량.
3. 저장하면 재고가 증가하고, 단위별 바코드가 부여됩니다.
> 입고가 끝나면 **재고 관리**에서 수량이 정상 반영됐는지 확인하세요. **버튼**: `엑셀저장` · `인쇄` · `발주등록`. (목록에서 개별 발주의 상세·취소 가능)
---
## 입고 처리 · *발주 입고 관리 입고[스캐너] / 일괄입고*
도착한 봉투를 시스템에 **들여놓는** 화면입니다. 입고하면 박스·팩·낱장 **바코드가 생성**되고 재고가 늘어납니다.
**이 화면의 용어**
- **인계자(제작업체)** / **인수자(대행소)**: 봉투를 넘기는 쪽 / 받는 쪽.
- **입고량(매)**: 실제로 들어온 **낱장 수**("매" = 장).
- **LOT NO / 발주 NO**: 어떤 발주분인지 식별하는 번호.
**입고[스캐너]**: 발주 건을 보고 행마다 **입고량(매)** 을 직접 입력 → `입고처리`.
**일괄입고**: 여러 발주 건을 **체크박스로 골라** 한 번에 입고. 미입고량은 파란색으로 강조됩니다.
**작업 순서**
1. 제작업체·인수자·인계자·입고일을 고릅니다.
2. 들어온 만큼 **입고량(매)** 을 입력(또는 일괄 선택)합니다.
3. `입고처리` → 재고 반영. **재고 관리**에서 수량이 늘었는지 확인하세요.
---
## 입고 현황 · *발주 입고 관리 입고 현황*
입고 기록을 기간별로 조회합니다.
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
**버튼**: `엑셀저장` · `인쇄`.
---
## LOT-No 디스켓 불출 · *발주 입고 관리 LOT-No 디스켓 불출*
발주별 **LOT 번호 시드(seed) 데이터를 파일로 생성**하는 화면입니다. (바코드·LOT 부여에 쓰는 디스켓/파일 형태 데이터)
**필터**: 시작월 · 종료월 · LOT-No · 제작업체.
**순서**
1. 기간·조건으로 발주 목록을 조회합니다.
2. 해당 발주 행의 **`seed 생성`** 을 눌러 그 발주의 LOT 시드 파일을 만듭니다.
**표 컬럼**: 발주일자 · LOT-No · 품명 · 수량 · 제작업체 · (생성 버튼).
> 정상·취소 발주가 함께 조회되며, 시드 생성은 발주 단위로 동작합니다.

View File

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

View File

@@ -1,46 +1,83 @@
# 판매 · 불출 # 판매 · 반품 · 불출 · 주문
재고를 외부로 내보내는 두 가지 경로입니다. **판매**는 지정판매소 대상 유상 거래, **불출**무료 대상자에 대한 무상 지급입니다. 재고를 외부로 내보내는 단계입니다. **판매**(가게에 유상 공급)·**불출**(무료 배부)·**주문 접수**(전화 등).
## 판매 (지정판매소) ---
**판매 관리** 메뉴에서 처리합니다. ## 지정판매소 판매 · *판매 관리 지정 판매소 판매*
| 작업 | 메뉴 | 설명 | 동네 가게(지정판매소)에 봉투를 **판매**하고, 어떤 봉투를 줬는지 **바코드로 기록**합니다.
|---|---|---|
| 판매 등록 | 지정 판매소 판매 | 판매소를 선택해 판매 거래 기록 |
| 스캔 판매 | 지정 판매소 판매[스캔] | 바코드를 스캔하며 판매(판매소 현장용) |
| 판매 취소 | 지정 판매소 판매 취소 | 기존 판매 거래 취소 |
| 반품 | 지정 판매소 반품 | 판매분 반품 등록 |
| 반품 취소 | 지정 판매소 반품 취소 | 반품 취소 처리 |
### 판매 등록 순서 **이 화면의 용어**
- **판매소코드/상호/대표자**: 판매하는 가게 정보(검색해서 선택).
- **봉투코드(스캔)**: 내보내는 봉투의 바코드. 스캔/입력하면 어떤 LOT·포장단위인지 식별됩니다.
- **포장단위(Box/Pack/Sheet)**: 박스/팩/낱장.
1. 판매할 **지정판매소**를 선택합니다. **입력/순서**
2. 봉투 **품목·수량**을 입력(또는 바코드 스캔)합니다. 1. 위에서 **판매소를 검색·선택**합니다(코드·상호·전화·주소로 검색).
3. 저장하면 재고가 감소하고 판매 내역이 기록됩니다. 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,74 @@
# 기본정보 (판매소 · 단가 · 코드)
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
---
## 지정판매소 관리 · *기본정보관리 지정 판매소 관리/조회*
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
**이 화면의 용어**
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
**함께 제공되는 화면**
- **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다.
- **구별 신규/취소 명단**: 기간 내 새로 등록되거나 취소된 판매소 명단을 조회·엑셀저장합니다.
- **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다.
---
## 단가 관리 · *기본정보관리 단가 관리*
봉투 **가격**을 기간별로 관리합니다.
**이 화면의 용어**
- **발주단가**: 제작업체에 주는 가격(살 때).
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
- **판매단가**: 최종 소비자 판매가.
- **수수료율**: 판매수수료율(%).
- **적용시작/종료**: 그 단가가 유효한 기간.
**필터**: 봉투구분 · 봉투코드 · 조회기간.
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
> 조회 전용이며, 등록·수정은 `단가관리(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

@@ -687,11 +687,11 @@ if (! function_exists('gov_portal_nav_context')) {
* dashboardAliases: list<string> * dashboardAliases: list<string>
* } * }
*/ */
function gov_portal_nav_context(): array function gov_portal_nav_context(bool $remapLinks = true, ?array $tree = null): array
{ {
helper('url'); helper('url');
$tree = get_site_nav_tree(); $tree = $tree ?? get_site_nav_tree();
$rawPath = current_nav_request_path(); $rawPath = current_nav_request_path();
$variant = gov_portal_active_variant($rawPath); $variant = gov_portal_active_variant($rawPath);
$currentPath = gov_portal_nav_match_path($rawPath); $currentPath = gov_portal_nav_match_path($rawPath);
@@ -702,10 +702,10 @@ if (! function_exists('gov_portal_nav_context')) {
foreach ($tree as $pIdx => $parent) { foreach ($tree as $pIdx => $parent) {
$children = []; $children = [];
foreach ($parent->children ?? [] as $child) { foreach ($parent->children ?? [] as $child) {
$href = gov_portal_menu_href_remap( $href = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
menu_link_preferred_href_path($child->mm_link ?? null, $currentPath), if ($remapLinks) {
$variant $href = gov_portal_menu_href_remap($href, $variant);
); }
$children[] = [ $children[] = [
'idx' => (int) ($child->mm_idx ?? 0), 'idx' => (int) ($child->mm_idx ?? 0),
'name' => (string) ($child->mm_name ?? ''), 'name' => (string) ($child->mm_name ?? ''),
@@ -936,3 +936,42 @@ if (! function_exists('gov_portal_nav_partial_vars')) {
return $out; 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

@@ -58,6 +58,61 @@ class ManualRenderer
return $this->config->pages[$slug] ?? null; 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. * slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
*/ */

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
/**
* 사용자 의견 첨부 이미지(캡처/업로드).
*/
class FeedbackFileModel extends Model
{
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,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
/**
* 사용자 의견(피드백) 모델.
*/
class FeedbackModel extends Model
{
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', 'done', 'hold'];
public static function statusLabel(string $s): string
{
return [
'new' => '접수',
'in_progress' => '처리중',
'done' => '완료',
'hold' => '보류',
][$s] ?? $s;
}
}

View File

@@ -28,6 +28,7 @@ class MemberModel extends Model
'mb_leavedate', 'mb_leavedate',
'mb_login_fail_count', 'mb_login_fail_count',
'mb_locked_until', 'mb_locked_until',
'mb_session_token',
]; ];
/** /**

View File

@@ -12,6 +12,6 @@ class ShopOrderItemModel extends Model
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price', 'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price',
'soi_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count', 'soi_qty', 'soi_packed_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count',
]; ];
} }

View File

@@ -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> <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> </div>
</section> </section>
<div class="border border-gray-300 p-4 mt-2"> <style>
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="mb-4 flex flex-wrap items-center gap-2 text-sm"> /* 인쇄 시 승인 대기 표를 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> <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"> <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> <option value="pending" <?= ($status ?? 'pending') === 'pending' ? 'selected' : '' ?>>승인 대기</option>
@@ -18,14 +36,14 @@
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>요청일</th> <th class="text-center">요청일</th>
<th>아이디</th> <th class="text-center">아이디</th>
<th>이름</th> <th class="text-center">이름</th>
<th>지자체</th> <th class="text-center">지자체</th>
<th>요청 역할</th> <th class="text-center">요청 역할</th>
<th>상태</th> <th class="text-center">상태</th>
<th>처리일</th> <th class="text-center">처리일</th>
<th>관리</th> <th class="text-center col-actions">관리</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,7 +63,7 @@
<?php if (($row->mar_status ?? '') === 'rejected'): ?>반려<?php endif; ?> <?php if (($row->mar_status ?? '') === 'rejected'): ?>반려<?php endif; ?>
</td> </td>
<td class="text-center"><?= esc($row->mar_processed_at ?? '-') ?></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'): ?> <?php if (($row->mar_status ?? '') === 'pending'): ?>
<div class="flex items-center justify-center gap-1"> <div class="flex items-center justify-center gap-1">
<form action="<?= base_url('admin/access/approve/' . $row->mar_idx) ?>" method="post" class="inline"> <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> <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> </div>
</section> </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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>일시</th> <th class="text-left">일시</th>
<th>아이디</th> <th class="text-left">아이디</th>
<th>성공</th> <th class="text-center">성공</th>
<th>IP</th> <th class="text-left">IP</th>
<th>메시지</th> <th class="text-left">메시지</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,19 +90,19 @@
</form> </form>
</section> </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"> <table class="bag-order-print-table w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-32">발주일자</th> <th class="w-32 text-center">발주일자</th>
<th class="min-w-[10rem]">제작 업체</th> <th class="min-w-[10rem] text-left">제작 업체</th>
<th class="min-w-[12rem]">품 명</th> <th class="min-w-[12rem] text-left">품 명</th>
<th class="w-28">발주 수량</th> <th class="w-28 text-right">발주 수량</th>
<th class="w-28">입고 수량</th> <th class="w-28 text-right">입고 수량</th>
<th class="w-28">미입고수량</th> <th class="w-28 text-right">미입고수량</th>
<th class="w-32">발주 금액</th> <th class="w-32 text-right">발주 금액</th>
<th class="min-w-[9rem]">입고처</th> <th class="min-w-[9rem] text-left">입고처</th>
<th class="min-w-[8rem]">비 고</th> <th class="min-w-[8rem] text-left">비 고</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

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

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span> <span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
</section> </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"> <form action="<?= mgmt_url('bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= 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> <span class="text-sm font-bold text-gray-700">단가 변경 이력 <?= esc($item->bp_bag_name) ?> (<?= esc($item->bp_bag_code) ?>)</span>
</div> </div>
</section> </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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>변경 필드</th> <th class="text-left">변경 필드</th>
<th>이전 값</th> <th class="text-right">이전 값</th>
<th>변경 값</th> <th class="text-right">변경 값</th>
<th>변경일시</th> <th class="text-center">변경일시</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

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

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">입고 처리</span> <span class="text-sm font-bold text-gray-700">입고 처리</span>
</section> </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"> <form action="<?= mgmt_url('bag-receivings/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

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

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">판매 등록</span> <span class="text-sm font-bold text-gray-700">판매 등록</span>
</section> </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"> <form action="<?= mgmt_url('bag-sales/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

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

View File

@@ -5,7 +5,7 @@
<span class="text-sm font-bold text-gray-700">세부코드 등록</span> <span class="text-sm font-bold text-gray-700">세부코드 등록</span>
</div> </div>
</section> </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"> <form action="<?= base_url('admin/code-details/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="cd_ck_idx" value="<?= (int) $kind->ck_idx ?>"/> <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> <span class="text-sm font-bold text-gray-700">세부코드 수정</span>
</div> </div>
</section> </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"> <form action="<?= base_url('admin/code-details/update/' . (int) $item->cd_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">업체 등록</span> <span class="text-sm font-bold text-gray-700">업체 등록</span>
</section> </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"> <form action="<?= mgmt_url('companies/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

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

View File

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

View File

@@ -17,22 +17,22 @@
<!-- 통계 카드 --> <!-- 통계 카드 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4"> <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-xs text-gray-500">총 발주 건수</div>
<div class="text-2xl font-bold text-blue-700"><?= number_format($s['order_count'] ?? 0) ?></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 class="text-xs text-gray-400 mt-1">금액: <?= number_format($s['order_amount'] ?? 0) ?>원</div>
</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-xs text-gray-500">총 판매 건수</div>
<div class="text-2xl font-bold text-green-700"><?= number_format($s['sale_count'] ?? 0) ?></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 class="text-xs text-gray-400 mt-1">금액: <?= number_format($s['sale_amount'] ?? 0) ?>원</div>
</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-xs text-gray-500">재고 품목 수</div>
<div class="text-2xl font-bold text-purple-700"><?= number_format($s['inventory_count'] ?? 0) ?></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 class="text-xs text-gray-400 mt-1">현재 재고가 있는 봉투 품목</div>
</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-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-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> <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> <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> <a href="<?= base_url('bag/bag-orders') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>LOT번호</th> <th class="text-center">LOT번호</th>
<th>발주일</th> <th class="text-center">발주일</th>
<th>상태</th> <th class="text-center">상태</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-center"> <tbody class="text-center">
@@ -92,15 +92,15 @@
<h3 class="text-sm font-bold text-gray-700">최근 판매 5건</h3> <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> <a href="<?= base_url('bag/bag-sales') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>판매소</th> <th class="text-left pl-2">판매소</th>
<th>봉투명</th> <th class="text-left pl-2">봉투명</th>
<th>수량</th> <th class="text-right">수량</th>
<th>금액</th> <th class="text-right">금액</th>
<th>구분</th> <th class="text-center">구분</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -1,11 +1,7 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?> <?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
<style> <style>
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; } td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; } td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
.ds-bc-check { width: 14px; height: 14px; } .ds-bc-check { width: 14px; height: 14px; }
</style> </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> <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> <span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
</div> </div>
<div class="overflow-auto border border-gray-300 bg-white"> <div class="overflow-auto">
<table class="ds-bc-table"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="w-14">출력</th> <th class="py-2.5 px-2 w-14 text-left">출력</th>
<th class="w-36">판매소 코드</th> <th class="py-2.5 px-2 w-36">판매소 코드</th>
<th>판매소명</th> <th class="py-2.5 px-2">판매소명</th>
<th class="w-24">대표자명</th> <th class="py-2.5 px-2 w-24">대표자명</th>
<th class="w-32">사업자번호</th> <th class="py-2.5 px-2 w-32">사업자번호</th>
<th>사업장 주소</th> <th class="py-2.5 px-2">사업장 주소</th>
<th class="w-16">상태</th> <th class="py-2.5 px-2 w-16 text-left">상태</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -74,18 +70,18 @@
$st = (int) ($row->ds_state ?? 1); $st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '사용' : '정지'; $stLabel = $st === 1 ? '사용' : '정지';
?> ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<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="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="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td> <td class="py-2.5 px-2 font-mono text-gray-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 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><?= esc((string) ($row->ds_rep_name ?? '')) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= 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="py-2.5 px-2 addr-cell text-gray-500" 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> <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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?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; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

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

View File

@@ -29,6 +29,7 @@ $exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . htt
font-weight: 600; font-weight: 600;
} }
.gbms-dnc-table tbody td.text-left { text-align: left; } .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 { .gbms-dnc-table tbody tr.gbms-total td {
font-weight: 700; font-weight: 700;
border: 2px solid #c62828; 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="mx-2 mt-3 mb-4 gbms-dnc-wrap">
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div> <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"> <table class="gbms-dnc-table">
<thead> <thead>
<tr> <tr>
<th rowspan="2" class="min-w-[6rem]">군·구</th> <th rowspan="2" class="min-w-[6rem] gbms-region-head">군·구</th>
<th rowspan="2"> <th rowspan="2">
<span class="gbms-tip"> <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"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 수정</span> <span class="text-sm font-bold text-gray-700">지정판매소 수정</span>
</section> </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"> <form id="designated-shop-edit-form" action="<?= mgmt_url('designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -223,23 +223,23 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<div class="ds-list-panel"> <div class="ds-list-panel">
<div class="ds-panel-title shrink-0">지정판매소 리스트</div> <div class="ds-panel-title shrink-0">지정판매소 리스트</div>
<div class="overflow-auto flex-1 min-h-0"> <div class="overflow-auto flex-1 min-h-0">
<table class="w-full data-table"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="w-14">번호</th> <th class="py-2.5 px-2 w-14 text-left">번호</th>
<th class="w-24">구·군</th> <th class="py-2.5 px-2 w-24">구·군</th>
<th class="w-24">지정일</th> <th class="py-2.5 px-2 w-24 text-left">지정일</th>
<th class="w-24">구역</th> <th class="py-2.5 px-2 w-24">구역</th>
<th class="ds-col-tight">대표자명</th> <th class="py-2.5 px-2 ds-col-tight">대표자명</th>
<th class="ds-col-tight">상호명</th> <th class="py-2.5 px-2 ds-col-tight">상호명</th>
<th class="ds-col-zip">우편번호</th> <th class="py-2.5 px-2 ds-col-zip text-left">우편번호</th>
<th class="text-left">주소</th> <th class="py-2.5 px-2 text-left">주소</th>
<th class="w-28">사업자번호</th> <th class="py-2.5 px-2 w-28">사업자번호</th>
<th class="w-28">전화</th> <th class="py-2.5 px-2 w-28">전화</th>
<th class="w-16">상태</th> <th class="py-2.5 px-2 w-16 text-left">상태</th>
</tr> </tr>
</thead> </thead>
<tbody id="ds-list-body" class="text-right"> <tbody id="ds-list-body">
<?php foreach ($list as $i => $row): ?> <?php foreach ($list as $i => $row): ?>
<?php <?php
$sn = (string) ($row->ds_shop_no ?? ''); $sn = (string) ($row->ds_shop_no ?? '');
@@ -267,18 +267,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
$addrCombinedList = $addrMainList; $addrCombinedList = $addrMainList;
} }
?> ?>
<tr class="ds-list-row cursor-pointer" data-row-index="<?= (int) $i ?>" role="button" tabindex="0"> <tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60" data-row-index="<?= (int) $i ?>" role="button" tabindex="0">
<td class="text-center"><?= esc($shortNo) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($ggLabel) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($ggLabel) ?></td>
<td class="text-center text-xs"><?= esc($daDisp) ?></td> <td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($zone) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= 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="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td> <td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-center text-xs ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700 ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td>
<td class="text-left pl-1 text-xs ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></td> <td class="py-2.5 px-2 text-gray-600 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="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_biz_no ?? '') ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_tel ?? '') ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_tel ?? '') ?></td>
<td class="text-center <?= $st === 2 ? 'text-pink-600 font-medium' : ($st === 3 ? 'text-orange-700' : '') ?>"><?= esc($stLabel) ?></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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -319,7 +325,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<th>영업상태</th> <th>영업상태</th>
<th>등록일시</th> <th>등록일시</th>
<th>변경사유</th> <th>변경사유</th>
<th class="no-print w-14">지도</th> <th class="no-print w-14 text-center">지도</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -497,10 +503,10 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<table class="w-full data-table text-xs"> <table class="w-full data-table text-xs">
<thead> <thead>
<tr> <tr>
<th>번호</th> <th class="text-center">번호</th>
<th>지자체</th> <th>지자체</th>
<th>구·군</th> <th>구·군</th>
<th>지정일</th> <th class="text-center">지정일</th>
<th>구역</th> <th>구역</th>
<th>대표자명</th> <th>대표자명</th>
<th>상호명</th> <th>상호명</th>
@@ -510,7 +516,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<th>전화</th> <th>전화</th>
<th>판매소번호</th> <th>판매소번호</th>
<th>가상계좌</th> <th>가상계좌</th>
<th>상태</th> <th class="text-center">상태</th>
<th>등록일</th> <th>등록일</th>
</tr> </tr>
</thead> </thead>

View File

@@ -40,42 +40,45 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a> <a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="w-16">번호</th> <th class="py-2.5 px-2 w-16 text-left">번호</th>
<th>지자체</th> <th class="py-2.5 px-2">지자체</th>
<th>판매소번호</th> <th class="py-2.5 px-2">판매소번호</th>
<th>상호명</th> <th class="py-2.5 px-2">상호명</th>
<th>대표자</th> <th class="py-2.5 px-2">대표자</th>
<th>사업자번호</th> <th class="py-2.5 px-2">사업자번호</th>
<th>가상계좌</th> <th class="py-2.5 px-2">가상계좌</th>
<th>상태</th> <th class="py-2.5 px-2 text-left">상태</th>
<th>등록일</th> <th class="py-2.5 px-2">등록일</th>
<?php if (! $readOnly): ?> <?php if (! $readOnly): ?>
<th class="w-28">작업</th> <th class="py-2.5 px-2 w-28 text-left">작업</th>
<?php endif; ?> <?php endif; ?>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="text-center"><?= esc($row->ds_idx) ?></td> <td class="py-2.5 px-2 text-left text-gray-500"><?= esc($row->ds_idx) ?></td>
<td class="text-left pl-2"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_shop_no) ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_shop_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->ds_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_rep_name) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($row->ds_rep_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_biz_no) ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_biz_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_va_number) ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_va_number) ?></td>
<td class="text-center"><?= (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></td> <td class="py-2.5 px-2 text-left">
<td class="text-left pl-2"><?= esc($row->ds_regdate ?? '') ?></td> <?php $isActive = (int) $row->ds_state === 1; ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isActive ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' ?>"><?= $isActive ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></span>
</td>
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->ds_regdate ?? '') ?></td>
<?php if (! $readOnly): ?> <?php if (! $readOnly): ?>
<td class="text-center"> <td class="py-2.5 px-2 text-left text-xs">
<a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a> <a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline">수정</a>
<form action="<?= mgmt_url('designated-shops/delete/' . (int) $row->ds_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');"> <form action="<?= mgmt_url('designated-shops/delete/' . (int) $row->ds_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');">
<?= csrf_field() ?> <?= 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> </form>
</td> </td>
<?php endif; ?> <?php endif; ?>

View File

@@ -5,7 +5,7 @@
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a> <a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
</div> </div>
</section> </section>
<div id="kakao-map" class="w-full border border-gray-300 mt-2" style="height:600px;"></div> <div id="kakao-map" class="w-full border border-gray-300 rounded-lg mt-2" style="height:600px;"></div>
<div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div> <div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=<?= esc($kakaoJavascriptKey ?? '', 'attr') ?>&libraries=services"></script> <script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=<?= esc($kakaoJavascriptKey ?? '', 'attr') ?>&libraries=services"></script>

View File

@@ -13,13 +13,11 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
overflow-y: visible; overflow-y: visible;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scroll-behavior: smooth; scroll-behavior: smooth;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff; background: #fff;
max-width: 100%; max-width: 100%;
} }
@media print { @media print {
.ds-status-x-scroll { overflow: visible !important; border: none; } .ds-status-x-scroll { overflow: visible !important; }
} }
.ds-status-x-scroll .ds-status-table { .ds-status-x-scroll .ds-status-table {
width: max-content; width: max-content;
@@ -29,15 +27,6 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
.ds-status-x-scroll .ds-status-table th, .ds-status-x-scroll .ds-status-table th,
.ds-status-x-scroll .ds-status-table td { .ds-status-x-scroll .ds-status-table td {
white-space: nowrap; white-space: nowrap;
padding: 6px 10px;
font-size: 12px;
}
.ds-status-x-scroll .ds-status-table thead th {
background: #e9ecef;
border: 1px solid #ccc;
}
.ds-status-x-scroll .ds-status-table tbody td {
border: 1px solid #ccc;
} }
.ds-status-x-scroll th.sticky-num, .ds-status-x-scroll th.sticky-num,
.ds-status-x-scroll td.sticky-num { .ds-status-x-scroll td.sticky-num {
@@ -48,25 +37,21 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
max-width: 3rem; max-width: 3rem;
width: 3rem; width: 3rem;
box-sizing: border-box; box-sizing: border-box;
background: #e9ecef; background: #fff;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
} }
.ds-status-x-scroll td.sticky-num { .ds-status-x-scroll td.sticky-num {
background: #fff; background: #fff;
text-align: center; text-align: center;
} }
.ds-status-x-scroll tr.sum-row td.sticky-num { .ds-status-x-scroll tr.sum-row td.sticky-num {
background: #f3f4f6; background: #f9fafb;
} }
.ds-status-x-scroll th.sticky-region, .ds-status-x-scroll th.sticky-region,
.ds-status-x-scroll td.sticky-region { .ds-status-x-scroll td.sticky-region {
position: sticky; position: sticky;
left: 3rem; left: 3rem;
z-index: 2; z-index: 2;
background: #e9ecef; background: #fff;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
max-width: 16rem; max-width: 16rem;
text-align: left; text-align: left;
} }
@@ -76,7 +61,7 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.ds-status-x-scroll tr.sum-row td.sticky-region { .ds-status-x-scroll tr.sum-row td.sticky-region {
background: #f3f4f6; background: #f9fafb;
} }
.ds-help { .ds-help {
position: relative; position: relative;
@@ -159,39 +144,39 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
<!-- 인쇄 시에도 보이는 본표 --> <!-- 인쇄 시에도 보이는 본표 -->
<div class="mx-2 mt-2 mb-2 ds-status-x-scroll"> <div class="mx-2 mt-2 mb-2 ds-status-x-scroll">
<table class="ds-status-table data-table"> <table class="ds-status-table w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="sticky-num text-center w-12">순번</th> <th class="sticky-num py-2.5 px-2 text-center w-12">순번</th>
<th class="sticky-region"><?= esc($regionColLabel) ?></th> <th class="sticky-region py-2.5 px-2"><?= esc($regionColLabel) ?></th>
<th class="text-left"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span> <span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span>
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말) <span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말)
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년) <span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년)
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년) <span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년)
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말) <span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말)
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span> <span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span>
<br/><span class="font-normal text-xs">(현행−종전)</span> <br/><span class="font-normal text-xs">(현행−종전)</span>
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span> <span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span>
<br/><span class="font-normal text-xs">(<?= $ry ?>년)</span> <br/><span class="font-normal text-xs">(<?= $ry ?>년)</span>
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span> <span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span>
<br/><span class="font-normal text-xs">(%)</span> <br/><span class="font-normal text-xs">(%)</span>
</th> </th>
<th class="text-right"> <th class="py-2.5 px-2 text-left">
<span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span> <span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span>
<br/><span class="font-normal text-xs">증감률(%)</span> <br/><span class="font-normal text-xs">증감률(%)</span>
</th> </th>
@@ -207,36 +192,36 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
$pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0; $pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0;
$yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null; $yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null;
?> ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="sticky-num"><?= $rowNo ?></td> <td class="sticky-num py-2.5 px-2 text-center text-gray-500"><?= $rowNo ?></td>
<td class="sticky-region" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td> <td class="sticky-region py-2.5 px-2 font-medium text-gray-900" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
<td class="text-left text-xs"><?= esc((string) ($row->gugun_code ?? '')) ?></td> <td class="py-2.5 px-2 text-left font-mono text-[12px] text-gray-700"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
<td><?= number_format($prev) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format($prev) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format($curr) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900"><?= number_format($curr) ?></td>
<td><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
<td><?= $pctShare ?></td> <td class="py-2.5 px-2 text-gray-600"><?= $pctShare ?></td>
<td><?= $yoyPct !== null ? $yoyPct : '—' ?></td> <td class="py-2.5 px-2 text-gray-600"><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($districtRows)): ?> <?php if (empty($districtRows)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr> <tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?> <?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="font-bold bg-gray-50 sum-row"> <tr class="font-bold bg-gray-50 sum-row border-b border-gray-200 last:border-0">
<td class="sticky-num">—</td> <td class="sticky-num py-2.5 px-2 text-center text-gray-500">—</td>
<td class="sticky-region"><?= esc($districtTotal->region_label) ?></td> <td class="sticky-region py-2.5 px-2 text-gray-900"><?= esc($districtTotal->region_label) ?></td>
<td class="text-left">—</td> <td class="py-2.5 px-2 text-left text-gray-500">—</td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td> <td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td> <td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td> <td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td> <td class="py-2.5 px-2 text-gray-900"><?= number_format((int) $districtTotal->curr_end) ?></td>
<td><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td> <td class="py-2.5 px-2 text-gray-700"><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td> <td class="py-2.5 px-2 text-gray-700"><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
<td>100</td> <td class="py-2.5 px-2 text-gray-700">100</td>
<td> <td class="py-2.5 px-2 text-gray-700">
<?php <?php
$tPrev = (int) $districtTotal->prev_end; $tPrev = (int) $districtTotal->prev_end;
$tCurr = (int) $districtTotal->curr_end; $tCurr = (int) $districtTotal->curr_end;
@@ -260,27 +245,27 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
</span> </span>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="border border-gray-300 bg-white overflow-auto max-h-56"> <div class="overflow-auto max-h-56">
<table class="w-full data-table text-xs"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="text-left">동</th> <th class="py-2.5 px-2 text-left">동</th>
<th class="text-right">종전</th> <th class="py-2.5 px-2 text-left">종전</th>
<th class="text-right">지정</th> <th class="py-2.5 px-2 text-left">지정</th>
<th class="text-right">취소</th> <th class="py-2.5 px-2 text-left">취소</th>
<th class="text-right">현행</th> <th class="py-2.5 px-2 text-left">현행</th>
<th class="text-right">증감</th> <th class="py-2.5 px-2 text-left">증감</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($zoneRows as $z): ?> <?php foreach ($zoneRows as $z): ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="text-left"><?= esc((string) $z->zone_label) ?></td> <td class="py-2.5 px-2 text-left font-medium text-gray-900"><?= esc((string) $z->zone_label) ?></td>
<td><?= number_format((int) $z->prev_end) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->prev_end) ?></td>
<td><?= number_format((int) $z->designated_y) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->designated_y) ?></td>
<td><?= number_format((int) $z->cancelled_y) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->cancelled_y) ?></td>
<td><?= number_format((int) $z->curr_end) ?></td> <td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $z->curr_end) ?></td>
<td><?= number_format((int) $z->delta_curr_prev) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->delta_curr_prev) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -345,7 +330,7 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
<details class="mx-2 mb-4 no-print text-sm"> <details class="mx-2 mb-4 no-print text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary> <summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary>
<div class="flex gap-4 mt-2"> <div class="flex gap-4 mt-2">
<div class="border border-gray-300 p-2 flex-1"> <div class="border border-gray-300 rounded-lg p-2 flex-1">
<div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div> <div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div>
<div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div> <div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div>
</div> </div>
@@ -353,15 +338,23 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div> <div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3> <h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3>
<div class="border border-gray-300 overflow-auto max-h-48"> <div class="overflow-auto max-h-48">
<table class="w-full data-table text-xs"> <table class="w-full text-[13px]">
<thead><tr><th>연도</th><th>건수</th></tr></thead> <thead>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 text-left">연도</th>
<th class="py-2.5 px-2 text-left">건수</th>
</tr>
</thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach (($newByYear ?? []) as $row): ?> <?php foreach (($newByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="py-2.5 px-2 text-left font-medium text-gray-900"><?= esc($row->yr) ?>년</td>
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($newByYear)): ?> <?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr> <tr><td colspan="2" class="text-center text-gray-400 py-6">없음</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@@ -369,15 +362,23 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
</div> </div>
<div> <div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3> <h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3>
<div class="border border-gray-300 overflow-auto max-h-48"> <div class="overflow-auto max-h-48">
<table class="w-full data-table text-xs"> <table class="w-full text-[13px]">
<thead><tr><th>연도</th><th>건수</th></tr></thead> <thead>
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="py-2.5 px-2 text-left">연도</th>
<th class="py-2.5 px-2 text-left">건수</th>
</tr>
</thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach (($cancelByYear ?? []) as $row): ?> <?php foreach (($cancelByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="py-2.5 px-2 text-left font-medium text-gray-900"><?= esc($row->yr) ?>년</td>
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($cancelByYear)): ?> <?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr> <tr><td colspan="2" class="text-center text-gray-400 py-6">없음</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

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

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/** @var object $row */
use App\Models\FeedbackModel;
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">의견 상세 #<?= (int) $row->fb_idx ?></span>
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline">목록으로</a>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
<section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">의견 내용</div>
<div class="p-3 space-y-2 text-sm">
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
<hr class="my-2"/>
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600">
<dt class="font-semibold">화면</dt><dd><?= esc((string) ($row->fb_page_title ?: '-')) ?><br><span class="text-gray-400"><?= esc((string) ($row->fb_page_url ?: '')) ?></span></dd>
<dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd>
<dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd>
<dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd>
<dt class="font-semibold">브라우저</dt><dd class="break-all text-gray-400"><?= esc((string) ($row->fb_user_agent ?? '')) ?></dd>
</dl>
</div>
</section>
<section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">처리</div>
<form action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/status') ?>" method="post" class="p-3 space-y-3">
<?= csrf_field() ?>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">상태</label>
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-40">
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">처리 메모</label>
<textarea name="fb_admin_memo" rows="4" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">저장</button>
</form>
</section>
</div>
<?php $files = $files ?? []; ?>
<?php if (! empty($files)): ?>
<section class="border border-gray-300 rounded-lg overflow-hidden mt-3">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">첨부 이미지 (<?= count($files) ?>)</div>
<div class="p-3 flex flex-wrap gap-3">
<?php foreach ($files as $f): ?>
<figure class="m-0">
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener">
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
style="max-width:280px;max-height:220px;border:1px solid #e5e7eb;border-radius:6px;"/>
</a>
<figcaption class="text-[11px] text-gray-400 mt-1"><?= $f->ff_kind === 'capture' ? '화면 캡처' : '사용자 첨부' ?></figcaption>
</figure>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">무료용 대상 등록</span> <span class="text-sm font-bold text-gray-700">무료용 대상 등록</span>
</section> </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('free-recipients/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('free-recipients/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">무료용 대상 수정</span> <span class="text-sm font-bold text-gray-700">무료용 대상 수정</span>
</section> </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('free-recipients/update/' . (int) $item->fr_idx) ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('free-recipients/update/' . (int) $item->fr_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -8,18 +8,18 @@
</div> </div>
</div> </div>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="w-16">번호</th> <th class="py-2.5 px-2 w-16 text-left">번호</th>
<th class="w-28">동코드</th> <th class="py-2.5 px-2 w-28">동코드</th>
<th class="w-40">구분</th> <th class="py-2.5 px-2 w-40">구분</th>
<th>명칭</th> <th class="py-2.5 px-2">명칭</th>
<th class="w-28">종료일자</th> <th class="py-2.5 px-2 w-28 text-left">종료일자</th>
<th class="w-48">비고</th> <th class="py-2.5 px-2 w-48">비고</th>
<th class="w-20">상태</th> <th class="py-2.5 px-2 w-20 text-left">상태</th>
<th class="w-36">작업</th> <th class="py-2.5 px-2 w-36 text-left">작업</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -36,19 +36,25 @@
$dongCode = (string) ($row->fr_dong_code ?? ''); $dongCode = (string) ($row->fr_dong_code ?? '');
$dongLabel = $dongCode !== '' ? (string) (($dongNameMap[$dongCode] ?? $dongCode) . ' (' . $dongCode . ')') : '-'; $dongLabel = $dongCode !== '' ? (string) (($dongNameMap[$dongCode] ?? $dongCode) . ' (' . $dongCode . ')') : '-';
?> ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="text-center"><?= esc((string) $rowNo) ?></td> <td class="py-2.5 px-2 text-left text-gray-500"><?= esc((string) $rowNo) ?></td>
<td class="text-center"><?= esc($dongLabel) ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($dongLabel) ?></td>
<td class="text-center"><?= esc($typeName) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($typeName) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->fr_name) ?></td>
<td class="text-center"><?= esc($row->fr_end_date ?: '9999.99.99') ?></td> <td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($row->fr_end_date ?: '9999.99.99') ?></td>
<td class="text-left pl-2"><?= esc($row->fr_note) ?></td> <td class="py-2.5 px-2 text-gray-500"><?= esc($row->fr_note) ?></td>
<td class="text-center"><?= (int) $row->fr_state === 1 ? '사용' : '미사용' ?></td> <td class="py-2.5 px-2 text-left">
<td class="text-center"> <?php if ((int) $row->fr_state === 1): ?>
<a href="<?= mgmt_url('free-recipients/edit/' . (int) $row->fr_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a> <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('free-recipients/edit/' . (int) $row->fr_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= mgmt_url('free-recipients/delete/' . (int) $row->fr_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');"> <form action="<?= mgmt_url('free-recipients/delete/' . (int) $row->fr_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?> <?= 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> </form>
</td> </td>
</tr> </tr>
@@ -56,7 +62,7 @@
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr> <tr>
<td colspan="8" class="text-center text-gray-500 py-4 text-sm space-y-1"> <td colspan="8" class="text-center text-gray-400 py-6 space-y-1">
<p>등록된 데이터가 없습니다.</p> <p>등록된 데이터가 없습니다.</p>
<p class="text-gray-400">다른 지자체를 선택 중이면 해당 지자체 기준으로만 조회됩니다. Super Admin 은 상단에서 작업 지자체를 바꿔 보세요.</p> <p class="text-gray-400">다른 지자체를 선택 중이면 해당 지자체 기준으로만 조회됩니다. Super Admin 은 상단에서 작업 지자체를 바꿔 보세요.</p>
</td> </td>

View File

@@ -1,181 +1,205 @@
<?php <?php
declare(strict_types=1);
/**
* 관리자 공통 레이아웃 — gov-portal 디자인 적용판.
* 헤더 + 관리자 대메뉴(클릭) + 좌측 사이드바(소메뉴) + 본문($content).
*
* @var string $title
* @var string $content
*/
helper('admin'); helper('admin');
$uriObj = service('request')->getUri();
$n = $uriObj->getTotalSegments();
$uri = $n >= 2 ? $uriObj->getSegment(2) : '';
$seg3 = $n >= 3 ? $uriObj->getSegment(3) : '';
$mbLevel = (int) session()->get('mb_level'); $mbLevel = (int) session()->get('mb_level');
$isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel); $isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel);
$mbName = (string) (session()->get('mb_name') ?? '담당자');
$levelName = config(\Config\Roles::class)->getLevelName($mbLevel);
$effectiveLgIdx = admin_effective_lg_idx(); $effectiveLgIdx = admin_effective_lg_idx();
$effectiveLgName = null; $effectiveLgName = '';
if ($effectiveLgIdx) { if ($effectiveLgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx); $lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx);
$effectiveLgName = $lgRow ? $lgRow->lg_name : null; $effectiveLgName = $lgRow ? (string) $lgRow->lg_name : '';
} }
$userNav = session_user_nav_display();
$currentPath = current_nav_request_path();
$adminNavTree = get_admin_nav_tree();
/** DB 링크(mm_link)만 사용. 짧게 적은 항목(menus 등)은 실제 URI(admin/menus)와 맞춰 후보 비교 */ $adminTree = function_exists('get_admin_nav_tree') ? get_admin_nav_tree() : [];
$adminNavItemIsCurrent = static function (?string $mmLink) use ($currentPath): bool { $gov = gov_portal_nav_context(false, $adminTree);
return menu_link_matches_request($mmLink, $currentPath, []);
};
/** 메뉴가 DB에서 안 쓰일 때만(폴백 상단바) 세그먼트 기반 활성 */ // 관리자 메뉴가 비어 있으면(지자체 미선택 등) 핵심 항목 폴백 노출
$isActive = static function (string $path) use ($uri, $seg3) { $navItems = $gov['navItems'];
if ($path === 'admin' || $path === '') return $uri === ''; if ($navItems === []) {
if ($path === 'users') return $uri === 'users'; $mk = static fn (string $name, string $path): array => [
if ($path === 'login-history') return $uri === 'access' && $seg3 === 'login-history'; 'idx' => 0, 'name' => $name, 'href' => $path, 'url' => base_url($path),
if ($path === 'approvals') return $uri === 'access' && $seg3 === 'approvals'; ];
if ($path === 'roles') return $uri === 'roles'; $navItems = [
if ($path === 'menus') return $uri === 'menus'; ['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
if ($path === 'local-governments') return $uri === 'local-governments'; ['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
if ($path === 'select-local-government') return $uri === 'select-local-government'; $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'),
if ($path === 'designated-shops') return $uri === 'designated-shops'; ]],
return false; ['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
}; $mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
]],
];
if ($isSuperAdmin) {
$navItems[] = ['idx' => 0, 'name' => '지자체', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
$mk('지자체 전환', 'admin/select-local-government'), $mk('지자체 관리', 'admin/local-governments'),
]];
}
}
$navJson = json_encode($navItems, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS);
$navPartial = [
'govNavItems' => $navItems,
'govNavJson' => $navJson,
'govActiveParentIdx' => $gov['activeParentIdx'],
'govCurrentPath' => $gov['currentPath'],
'govDashboardAliases' => $gov['dashboardAliases'],
'govActiveChildHref' => $gov['currentPath'],
];
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko" class="gov-portal-html">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - 종량제 시스템</title> <title><?= esc($title ?? '관리자') ?> - GBLS</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] }, fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: { colors: {
'system-header': '#ffffff', 'system-header': '#ffffff', 'title-bar': '#1a2b4b', 'control-panel': '#f8f9fa',
'title-bar': '#2c3e50', 'btn-search': '#243a5e', 'btn-excel-border': '#28a745', 'btn-excel-text': '#28a745',
'control-panel': '#f8f9fa', 'btn-print-border': '#ced4da', 'btn-exit': '#d9534f',
'btn-search': '#1c4e80',
'btn-excel-border': '#28a745',
'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da',
'btn-exit': '#d9534f',
}, },
fontSize: { 'xxs': '0.65rem' } fontSize: { 'xxs': '0.65rem' }
} }
} }
} }
</script> </script>
<style data-purpose="global-font-scale"> <style>
/* 전체 텍스트 +2px 확대 (요청). 로고(.app-brand)는 16px 로 유지. */ <?php include __DIR__ . '/../home/_dashboard_gov_portal_brand_css.php'; ?>
html { font-size: 18px; } <?php include __DIR__ . '/../home/_dashboard_gov_portal_topnav_css.php'; ?>
.app-brand, .app-brand * { font-size: 16px; } <?php include __DIR__ . '/../home/_dashboard_gov_portal_chrome_css.php'; ?>
</style> .data-table { width: 100%; border-collapse: collapse; font-family: 'Pretendard', 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
<style data-purpose="table-layout"> .data-table { font-size: 13px; }
.data-table { width: 100%; border-collapse: collapse; font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif; } .data-table th, .data-table td { text-align: left; padding: 0.55rem 0.5rem; white-space: nowrap; border: 0; border-bottom: 1px solid #e5e7eb; }
.data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; } .data-table thead th { font-size: 0.6875rem; font-weight: 600; color: #6b7280; background: transparent; vertical-align: middle; }
.data-table th { background-color: #e9ecef; text-align: center; vertical-align: middle; font-weight: bold; color: #333; } .data-table tbody td { color: #374151; }
.data-table tbody tr:nth-child(even) td { background-color: #f9f9f9; } .data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:nth-child(even) { background-color: #f9f9f9; } .data-table tbody tr:hover td { background-color: #f9fafb; }
.data-table tbody tr:hover td { background-color: #e6f7ff !important; }
.main-content-area { height: calc(100vh - 170px); overflow: auto; }
body { overflow: hidden; }
@media print { @media print {
header, footer, .no-print, nav { display: none !important; } .portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
.main-content-area { height: auto !important; overflow: visible !important; } body.gov-portal-shell { background: #fff; display: block; }
body { overflow: visible !important; } .gov-portal-shell .main.work-main { overflow: visible !important; padding: 0 !important; }
.bg-title-bar { display: none !important; }
.bg-control-panel { break-inside: avoid; }
.print-header { display: block !important; } .print-header { display: block !important; }
} }
</style> </style>
</head> </head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased select-none"> <body class="gov-portal-shell select-none">
<header class="relative bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0 z-50"> <header class="portal-header">
<div class="flex items-center gap-2 shrink-0"> <div class="portal-header-inner">
<?= view('components/header_brand', ['href' => base_url('admin')]) ?> <?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('/')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_click', $navPartial) ?>
<div class="portal-header-utils" style="display:flex;align-items:center;gap:.5rem;">
<div class="ws-fontctl" title="글씨 크기 조절" style="display:inline-flex;align-items:center;gap:2px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.25);border-radius:6px;padding:1px;">
<button type="button" id="wsFontMinus" title="글씨 작게" style="width:24px;height:22px;border:0;background:transparent;color:#fff;cursor:pointer;font-size:11px;line-height:1;border-radius:5px;">A</button>
<span id="wsFontPct" style="min-width:34px;text-align:center;color:#fff;font-size:.68rem;font-weight:600;">100%</span>
<button type="button" id="wsFontPlus" title="글씨 크게" style="width:24px;height:22px;border:0;background:transparent;color:#fff;cursor:pointer;font-size:14px;line-height:1;border-radius:5px;">A+</button>
</div> </div>
<nav class="hidden md:flex gap-5 text-sm font-medium text-gray-600"> <span class="user-line">
<?php if (! empty($adminNavTree)): ?> <?php if ($effectiveLgName !== ''): ?><strong><?= esc($effectiveLgName) ?></strong> · <?php endif; ?>
<?php foreach ($adminNavTree as $navItem): ?> <?= esc($levelName) ?> · <?= esc($mbName) ?>
<?php
$hasChildren = ! empty($navItem->children);
$parentLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath);
$activeChild = $hasChildren ? menu_active_child_for_parent($navItem, $currentPath, []) : null;
$parentIsCurrent = $adminNavItemIsCurrent($navItem->mm_link ?? null);
if (! $parentIsCurrent && $activeChild !== null) {
$parentIsCurrent = true;
}
?>
<div class="relative group">
<a class="<?= $parentIsCurrent ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>"
href="<?= $parentLink !== '' ? base_url($parentLink) : '#' ?>">
<?= esc($navItem->mm_name) ?>
</a>
<?php if ($hasChildren): ?>
<?php /* 사이트 메뉴와 동일: 호버 끊김 방지 pt-1, 키보드 포커스, z-index */ ?>
<div class="absolute left-0 top-full z-50 hidden pt-1 min-w-[12rem] group-hover:block group-focus-within:block">
<div class="bg-white border border-gray-200 rounded shadow-lg py-1">
<?php foreach ($navItem->children as $child): ?>
<?php
$childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
$childIsCurrent = $activeChild !== null
&& (int) ($child->mm_idx ?? 0) === (int) ($activeChild->mm_idx ?? -1);
?>
<?php if ($childLink !== ''): ?>
<a href="<?= base_url($childLink) ?>"
class="block px-3 py-1.5 text-sm hover:bg-blue-50 whitespace-nowrap <?= $childIsCurrent ? 'text-blue-700 font-semibold bg-blue-50' : 'text-gray-700' ?>">
<?= esc($child->mm_name) ?>
</a>
<?php else: ?>
<span class="block px-3 py-1.5 text-sm text-gray-400 cursor-default whitespace-nowrap" title="메뉴 관리에서 링크를 설정해 주세요">
<?= esc($child->mm_name) ?>
</span> </span>
<?php endif; ?> <a href="<?= base_url('/') ?>" title="사이트로"
<?php endforeach; ?> style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-solid fa-house"></i> 사이트
</a>
<a href="<?= base_url('logout') ?>" title="로그아웃"
style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.22);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
</a>
</div> </div>
</div> </div>
</header>
<div class="layout">
<?= view('home/_dashboard_gov_portal_sidebar', $navPartial) ?>
<main class="main work-main main-content-area">
<?php if (! empty($title)): ?>
<h1 class="work-titlebar"><i class="fa-solid fa-gear tb-ico"></i><?= esc($title) ?></h1>
<?php endif; ?> <?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="work-flash ok"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="work-flash err"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="work-flash err"><?php foreach (session()->getFlashdata('errors') as $err): ?><div><?= esc($err) ?></div><?php endforeach; ?></div>
<?php endif; ?>
<div class="work-surface">
<?= $content ?>
</div> </div>
<?php endforeach; ?> </main>
<?php else: ?> </div>
<a class="<?= $isActive('') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin') ?>">대시보드</a>
<a class="<?= $isActive('users') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/users') ?>">회원 관리</a> <footer class="portal-footer">
<a class="<?= $isActive('login-history') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/access/login-history') ?>">로그인 이력</a> <span>GBLS 관리자</span>
<a class="<?= $isActive('approvals') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/access/approvals') ?>">승인 대기</a> <span><?= date('Y.m.d (D) H:i') ?></span>
<a class="<?= $isActive('roles') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/roles') ?>">역할</a> </footer>
<a class="<?= $isActive('menus') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/menus') ?>">메뉴</a>
<?php if ($isSuperAdmin): ?> <?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<a class="<?= $isActive('select-local-government') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/select-local-government') ?>">지자체 전환</a> <script>
<a class="<?= $isActive('local-governments') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/local-governments') ?>">지자체</a> (function () {
<?php endif; ?> // bfcache 복원 시 열린 채 남은 모달/팝업으로 회색 레이어가 클릭을 막는 문제 방지
<a class="<?= $isActive('designated-shops') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('bag/designated-shops') ?>">지정판매소</a> function closeStuckOverlays() {
<?php endif; ?> document.querySelectorAll('.fixed.inset-0[id$="-modal"], .fixed.inset-0[id$="-popup"]').forEach(function (el) {
</nav> el.classList.add('hidden'); el.setAttribute('aria-hidden', 'true');
<?= view('components/header_user_tools', [ });
'userNav' => $userNav, document.body.style.overflow = '';
'effectiveLgName' => $effectiveLgName, }
'showSiteLink' => true, window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
'showAdminLink' => false, window.addEventListener('pagehide', closeStuckOverlays);
]) ?> })();
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0"> // 글씨 크기 조절(A/A+) — 본문 + 상단 대메뉴 + 좌측 사이드바에 zoom 적용. 사이트/워크스페이스와 배율 공유.
<?= esc($title ?? '관리자') ?> (function () {
</div> var FONT_KEY = 'jrj_font_scale';
<?php if (session()->getFlashdata('success')): ?> // 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다.
<div class="mx-4 mt-2 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div> var scaleSelectors = ['.sidebar', '.work-main'];
<?php endif; ?> function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
<?php if (session()->getFlashdata('error')): ?> function applyScale(s) {
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div> s = Math.min(150, Math.max(70, s));
<?php endif; ?> try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
<?php if (session()->getFlashdata('errors')): ?> var z = s / 100;
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert"> scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
<?php foreach (session()->getFlashdata('errors') as $err): ?><p><?= esc($err) ?></p><?php endforeach; ?> // 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
</div> var topnav = document.querySelector('.portal-top-nav');
<?php endif; ?> if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
<main class="main-content-area flex-grow bg-white p-4"> var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
<?= $content ?> }
</main> applyScale(curScale());
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 flex items-center justify-between shrink-0"> var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
<span>종량제 시스템 관리자</span> if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
<span><?= date('Y.m.d (D) g:i:sA') ?></span> if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
</footer> window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
})();
</script>
<script>
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
(function () {
function fixLang() {
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
})();
</script>
<?= view('components/feedback_widget') ?>
</body> </body>
</html> </html>

View File

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

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지자체 수정</span> <span class="text-sm font-bold text-gray-700">지자체 수정</span>
</section> </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/local-governments/update/' . (int) $item->lg_idx) ?>" method="POST" class="space-y-4"> <form action="<?= base_url('admin/local-governments/update/' . (int) $item->lg_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -8,18 +8,18 @@
</div> </div>
</div> </div>
</section> </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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>지자체명</th> <th class="text-left">지자체명</th>
<th>코드</th> <th class="text-left">코드</th>
<th>시/도</th> <th class="text-left">시/도</th>
<th>구/군</th> <th class="text-left">구/군</th>
<th>상태</th> <th class="text-center">상태</th>
<th>등록일</th> <th class="text-left">등록일</th>
<th class="w-28">작업</th> <th class="w-28 text-center">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">담당자 등록</span> <span class="text-sm font-bold text-gray-700">담당자 등록</span>
</section> </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('managers/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('managers/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">담당자 수정</span> <span class="text-sm font-bold text-gray-700">담당자 수정</span>
</section> </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('managers/update/' . (int) $item->mg_idx) ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('managers/update/' . (int) $item->mg_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -21,47 +21,53 @@
<a href="<?= mgmt_url('managers') ?>" class="text-sm text-gray-500 hover:underline">초기화</a> <a href="<?= mgmt_url('managers') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="w-16">번호</th> <th class="py-2.5 px-2 w-16 text-left">번호</th>
<th>담당자명</th> <th class="py-2.5 px-2">담당자명</th>
<th>카테고리</th> <th class="py-2.5 px-2">카테고리</th>
<th>전화</th> <th class="py-2.5 px-2">전화</th>
<th>휴대전화</th> <th class="py-2.5 px-2">휴대전화</th>
<th>이메일</th> <th class="py-2.5 px-2">이메일</th>
<th class="w-20">상태</th> <th class="py-2.5 px-2 w-20 text-left">상태</th>
<th class="w-36">작업</th> <th class="py-2.5 px-2 w-36 text-left">작업</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="text-center"><?= esc($row->mg_idx) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->mg_idx) ?></td>
<td class="text-center"><?= esc($row->mg_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->mg_name) ?></td>
<td class="text-center"> <td class="py-2.5 px-2">
<?php <?php
$cat = (string) ($row->mg_dept_code ?? ''); $cat = (string) ($row->mg_dept_code ?? '');
$catLabel = $categories[$cat] ?? $cat; $catLabel = $categories[$cat] ?? $cat;
echo esc($catLabel);
?> ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500"><?= esc($catLabel) ?></span>
</td> </td>
<td class="text-center"><?= esc($row->mg_tel) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_tel) ?></td>
<td class="text-center"><?= esc($row->mg_phone) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_phone) ?></td>
<td class="text-center"><?= esc($row->mg_email) ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_email) ?></td>
<td class="text-center"><?= (int) $row->mg_state === 1 ? '사용' : '미사용' ?></td> <td class="py-2.5 px-2 text-left">
<td class="text-center"> <?php if ((int) $row->mg_state === 1): ?>
<a href="<?= mgmt_url('managers/edit/' . (int) $row->mg_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a> <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('managers/edit/' . (int) $row->mg_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= mgmt_url('managers/delete/' . (int) $row->mg_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');"> <form action="<?= mgmt_url('managers/delete/' . (int) $row->mg_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?> <?= 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> </form>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr> <tr><td colspan="8" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

@@ -76,12 +76,12 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<thead> <thead>
<tr> <tr>
<th class="w-20 text-center text-xs font-medium text-gray-600">순서변경</th> <th class="w-20 text-center text-xs font-medium text-gray-600">순서변경</th>
<th class="w-10">#</th> <th class="w-10 text-center">#</th>
<th>메뉴명</th> <th class="text-left">메뉴명</th>
<th>링크</th> <th class="text-left">링크</th>
<th>노출 대상</th> <th class="text-left">노출 대상</th>
<th>사용</th> <th class="text-center">사용</th>
<th class="w-24">작업</th> <th class="w-24 text-center">작업</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">포장 단위 등록</span> <span class="text-sm font-bold text-gray-700">포장 단위 등록</span>
</section> </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('packaging-units/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('packaging-units/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">포장 단위 수정</span> <span class="text-sm font-bold text-gray-700">포장 단위 수정</span>
</section> </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('packaging-units/update/' . (int) $item->pu_idx) ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('packaging-units/update/' . (int) $item->pu_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -5,7 +5,7 @@
<span class="text-sm font-bold text-gray-700">변경 이력 <?= esc($item->pu_bag_name) ?> (<?= esc($item->pu_bag_code) ?>)</span> <span class="text-sm font-bold text-gray-700">변경 이력 <?= esc($item->pu_bag_name) ?> (<?= esc($item->pu_bag_code) ?>)</span>
</div> </div>
</section> </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">
<?php <?php
$fieldLabelMap = [ $fieldLabelMap = [
'pu_box_per_pack' => '박스당 팩 수', 'pu_box_per_pack' => '박스당 팩 수',
@@ -18,11 +18,11 @@
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>변경 내용</th> <th class="text-left">변경 내용</th>
<th>이전 값</th> <th class="text-right">이전 값</th>
<th>변경 값</th> <th class="text-right">변경 값</th>
<th>변경일시</th> <th class="text-center">변경일시</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -18,20 +18,20 @@
<a href="<?= mgmt_url('packaging-units') ?>" class="text-sm text-gray-500 hover:underline">초기화</a> <a href="<?= mgmt_url('packaging-units') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form> </form>
</section> </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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>봉투코드</th> <th class="text-center">봉투코드</th>
<th>봉투명</th> <th class="text-left">봉투명</th>
<th>박스당 팩 수</th> <th class="text-right">박스당 팩 수</th>
<th>팩당 낱장 수</th> <th class="text-right">팩당 낱장 수</th>
<th>1박스 총 낱장</th> <th class="text-right">1박스 총 낱장</th>
<th>적용시작</th> <th class="text-center">적용시작</th>
<th>적용종료</th> <th class="text-center">적용종료</th>
<th class="w-20">상태</th> <th class="w-20 text-center">상태</th>
<th class="w-36">작업</th> <th class="w-36 text-center">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">비밀번호 변경</span> <span class="text-sm font-bold text-gray-700">비밀번호 변경</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-md"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-md">
<form action="<?= mgmt_url('password-change') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('password-change') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -13,6 +13,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/>
<p class="w-full text-xs text-gray-500 pl-32">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">

View File

@@ -1,13 +1,13 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">역할 (mb_level)</span> <span class="text-sm font-bold text-gray-700">역할 (mb_level)</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2"> <div class="border border-gray-300 rounded-lg p-4 mt-2">
<p class="text-sm text-gray-600 mb-4">Config\Roles 기반 역할 목록입니다.</p> <p class="text-sm text-gray-600 mb-4">Config\Roles 기반 역할 목록입니다.</p>
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-24">코드</th> <th class="w-24 text-center">코드</th>
<th>이름</th> <th class="text-left">이름</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">대행소 등록</span> <span class="text-sm font-bold text-gray-700">대행소 등록</span>
</section> </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('sales-agencies/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('sales-agencies/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">대행소 수정</span> <span class="text-sm font-bold text-gray-700">대행소 수정</span>
</section> </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('sales-agencies/update/' . (int) $item->sa_idx) ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('sales-agencies/update/' . (int) $item->sa_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -8,7 +8,7 @@
</div> </div>
</div> </div>
</section> </section>
<section class="no-print border border-gray-300 bg-white p-2 mt-2"> <section class="no-print border border-gray-300 rounded-lg bg-white p-2 mt-2">
<form method="GET" action="<?= mgmt_url('sales-agencies') ?>" class="flex flex-wrap items-end gap-2"> <form method="GET" action="<?= mgmt_url('sales-agencies') ?>" class="flex flex-wrap items-end gap-2">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">번호</label> <label class="text-xs text-gray-500">번호</label>
@@ -32,15 +32,15 @@
</div> </div>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full text-[13px]">
<thead> <thead>
<tr> <tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
<th class="w-16">번호</th> <th class="py-2.5 px-2 w-16 text-left">번호</th>
<th>대행소 구분</th> <th class="py-2.5 px-2">대행소 구분</th>
<th>대행소 코드</th> <th class="py-2.5 px-2">대행소 코드</th>
<th>대행소 명</th> <th class="py-2.5 px-2">대행소 명</th>
<th class="w-36">작업</th> <th class="py-2.5 px-2 w-36 text-left">작업</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,22 +53,22 @@
} }
?> ?>
<?php foreach (($list ?? []) as $idx => $row): ?> <?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
<td class="text-center"><?= (int) $startNo + (int) $idx ?></td> <td class="py-2.5 px-2 text-left text-gray-500"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-left pl-2"><?= esc($row->sa_kind ?? '') ?></td> <td class="py-2.5 px-2 text-gray-600"><?= esc($row->sa_kind ?? '') ?></td>
<td class="text-center"><?= esc($row->sa_code ?? '') ?></td> <td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->sa_code ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->sa_name) ?></td> <td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->sa_name) ?></td>
<td class="text-center"> <td class="py-2.5 px-2 text-left text-xs">
<a href="<?= mgmt_url('sales-agencies/edit/' . (int) $row->sa_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a> <a href="<?= mgmt_url('sales-agencies/edit/' . (int) $row->sa_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= mgmt_url('sales-agencies/delete/' . (int) $row->sa_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');"> <form action="<?= mgmt_url('sales-agencies/delete/' . (int) $row->sa_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?> <?= 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> </form>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr> <tr><td colspan="5" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

@@ -80,12 +80,12 @@ $excelUrl = mgmt_url('reports/daily-summary?' . http_build_query($exportParams))
<p class="text-xs text-gray-500 m-0">누계(월): <?= esc(($monthStart ?? '') . ' ~ ' . ($date ?? '')) ?> · (단위: 매 / 원)</p> <p class="text-xs text-gray-500 m-0">누계(월): <?= esc(($monthStart ?? '') . ' ~ ' . ($date ?? '')) ?> · (단위: 매 / 원)</p>
</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" id="daily-summary-table"> <table class="w-full data-table text-sm" id="daily-summary-table">
<thead> <thead>
<tr> <tr>
<th rowspan="2" class="align-middle">구분</th> <th rowspan="2" class="align-middle text-left">구분</th>
<th rowspan="2" class="align-middle">봉투종류</th> <th rowspan="2" class="align-middle text-left">봉투종류</th>
<th colspan="4" class="text-center border-l border-gray-300">일계</th> <th colspan="4" class="text-center border-l border-gray-300">일계</th>
<th colspan="4" class="text-center border-l border-gray-300">누계(월)</th> <th colspan="4" class="text-center border-l border-gray-300">누계(월)</th>
</tr> </tr>

View File

@@ -254,7 +254,7 @@ $hometaxRenderTable = static function (
} }
</style> </style>
<div class="text-sm font-semibold text-gray-700 mb-2 no-print">조회결과</div> <div class="text-sm font-semibold text-gray-700 mb-2 no-print">조회결과</div>
<div class="hometax-screen-only hometax-scroll-wrap overflow-x-auto border border-gray-300" style="max-width: 100%;"> <div class="hometax-screen-only hometax-scroll-wrap overflow-x-auto border border-gray-300 rounded-lg" style="max-width: 100%;">
<?php <?php
$hometaxRenderTable( $hometaxRenderTable(
range(0, max(0, $colCount - 1)), range(0, max(0, $colCount - 1)),

View File

@@ -114,7 +114,7 @@ if ($bagName !== '' || $bagCode !== '') {
<div class="lot-flow-layout m-2 flex flex-col lg:flex-row gap-3 min-h-[320px]"> <div class="lot-flow-layout m-2 flex flex-col lg:flex-row gap-3 min-h-[320px]">
<!-- 좌: 품목·단위 요약 (레거시 BOX/PACK/낱장) --> <!-- 좌: 품목·단위 요약 (레거시 BOX/PACK/낱장) -->
<div class="lot-flow-summary border border-gray-300 bg-gray-50 p-3 lg:w-64 shrink-0"> <div class="lot-flow-summary border border-gray-300 rounded-lg bg-gray-50 p-3 lg:w-64 shrink-0">
<h3 class="text-sm font-bold text-gray-700 mb-2">봉투 정보</h3> <h3 class="text-sm font-bold text-gray-700 mb-2">봉투 정보</h3>
<?php if ($ok): ?> <?php if ($ok): ?>
<dl class="text-sm space-y-1.5"> <dl class="text-sm space-y-1.5">
@@ -157,7 +157,7 @@ if ($bagName !== '' || $bagCode !== '') {
</div> </div>
<!-- 우: LOT 수불 현황 --> <!-- 우: LOT 수불 현황 -->
<div class="lot-flow-table-wrap flex-1 border border-gray-300 flex flex-col min-w-0"> <div class="lot-flow-table-wrap flex-1 border border-gray-300 rounded-lg flex flex-col min-w-0">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5"> <div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">LOT 수불 현황</span> <span class="text-sm font-bold text-gray-700">LOT 수불 현황</span>
</div> </div>
@@ -165,7 +165,7 @@ if ($bagName !== '' || $bagCode !== '') {
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-28">일자</th> <th class="w-28 text-center">일자</th>
<th>입출고처</th> <th>입출고처</th>
<th class="w-24 text-center">구분</th> <th class="w-24 text-center">구분</th>
</tr> </tr>

View File

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

View File

@@ -116,12 +116,12 @@ $rowClass = static function (string $kind): string {
<p class="text-xs text-gray-500 m-0">집계: <?= $byDaily ? '일자별' : '기간별' ?> · (단위: 매 / 원)</p> <p class="text-xs text-gray-500 m-0">집계: <?= $byDaily ? '일자별' : '기간별' ?> · (단위: 매 / 원)</p>
</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" id="period-sales-table"> <table class="w-full data-table text-sm" id="period-sales-table">
<thead> <thead>
<tr> <tr>
<?php if ($byDaily): ?> <?php if ($byDaily): ?>
<th rowspan="2" class="align-middle whitespace-nowrap">일자</th> <th rowspan="2" class="align-middle whitespace-nowrap text-center">일자</th>
<?php endif; ?> <?php endif; ?>
<th rowspan="2" class="align-middle text-left pl-2">품목</th> <th rowspan="2" class="align-middle text-left pl-2">품목</th>
<th colspan="4" class="text-center border-l border-gray-300">판매</th> <th colspan="4" class="text-center border-l border-gray-300">판매</th>

View File

@@ -114,11 +114,11 @@ $tipPage = "지정판매소 반품·물류 입고분 파기 내역을 기간·
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="border border-gray-300 overflow-auto m-2 print:m-0"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto m-2 print:m-0">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-28">일자</th> <th class="w-28 text-center">일자</th>
<th>반품처</th> <th>반품처</th>
<th>종류</th> <th>종류</th>
<th class="w-24 text-right">수량</th> <th class="w-24 text-right">수량</th>

View File

@@ -115,6 +115,15 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
<?= esc($catLabels[$ck] ?? $ck) ?> <?= esc($catLabels[$ck] ?? $ck) ?>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
<span class="inline-flex items-center gap-1 ml-2 pl-3 border-l border-gray-200">
<span class="text-xs text-gray-600">크기</span>
<select name="size" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<?php foreach (($sizeOptions ?? []) as $sz): ?>
<option value="<?= esc($sz, 'attr') ?>" <?= ($size ?? '') === $sz ? 'selected' : '' ?>><?= esc($sz) ?></option>
<?php endforeach; ?>
</select>
</span>
</div> </div>
</fieldset> </fieldset>
<div> <div>
@@ -188,16 +197,16 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
<p class="text-xs text-gray-500 m-0">(단위: 매 / 원) · <?= esc($startDate) ?> ~ <?= esc($endDate) ?></p> <p class="text-xs text-gray-500 m-0">(단위: 매 / 원) · <?= esc($startDate) ?> ~ <?= esc($endDate) ?></p>
</div> </div>
<div class="sales-ledger-scroll-wrap border border-gray-300 overflow-auto"> <div class="sales-ledger-scroll-wrap border border-gray-300 rounded-lg p-4 overflow-auto">
<table class="w-full data-table text-sm <?= ($mode ?? 'daily') === 'period' ? 'sl-period' : '' ?>" id="sales-ledger-table"> <table class="w-full data-table text-sm <?= ($mode ?? 'daily') === 'period' ? 'sl-period' : '' ?>" id="sales-ledger-table">
<thead> <thead>
<tr> <tr>
<?php if (($mode ?? 'daily') === 'daily'): ?> <?php if (($mode ?? 'daily') === 'daily'): ?>
<th class="sl-col-date">일자</th> <th class="sl-col-date text-center">일자</th>
<?php endif; ?> <?php endif; ?>
<th class="sl-col-designation">지정번호</th> <th class="sl-col-designation text-center">지정번호</th>
<th class="sl-col-shop text-left">판매소명</th> <th class="sl-col-shop text-left">판매소명</th>
<th class="sl-col-rep">대표자</th> <th class="sl-col-rep text-center">대표자</th>
<th class="sl-col-addr text-left">소재지</th> <th class="sl-col-addr text-left">소재지</th>
<th class="sl-col-product text-left">품명</th> <th class="sl-col-product text-left">품명</th>
<th class="text-right sl-col-num">판매량</th> <th class="text-right sl-col-num">판매량</th>

View File

@@ -154,7 +154,7 @@ $colCount = 16;
<p class="text-xs text-gray-500 m-0"><?= $isAmt ? '(단위: 원)' : '(단위: 매)' ?></p> <p class="text-xs text-gray-500 m-0"><?= $isAmt ? '(단위: 원)' : '(단위: 매)' ?></p>
</div> </div>
<div class="shop-sales-scroll-wrap border border-gray-300 overflow-x-auto"> <div class="shop-sales-scroll-wrap border border-gray-300 rounded-lg p-4 overflow-x-auto">
<table class="w-full data-table text-xs" id="shop-sales-table"> <table class="w-full data-table text-xs" id="shop-sales-table">
<colgroup> <colgroup>
<col style="width: 14%;"/> <col style="width: 14%;"/>
@@ -168,11 +168,11 @@ $colCount = 16;
<thead> <thead>
<tr> <tr>
<th class="text-left pl-2">지정판매소</th> <th class="text-left pl-2">지정판매소</th>
<th>대표자명</th> <th class="text-center">대표자명</th>
<th class="text-left pl-1">주소</th> <th class="text-left pl-1">주소</th>
<th>합계</th> <th class="text-right">합계</th>
<?php for ($m = 1; $m <= 12; $m++): ?> <?php for ($m = 1; $m <= 12; $m++): ?>
<th><?= $m ?>월</th> <th class="text-right"><?= $m ?>월</th>
<?php endfor; ?> <?php endfor; ?>
</tr> </tr>
</thead> </thead>

View File

@@ -106,7 +106,7 @@ $printExtraLines = [
<?php endif; ?> <?php endif; ?>
<div class="supply-plan-print-sheet"> <div class="supply-plan-print-sheet">
<div class="supply-plan-print m-2 border border-gray-300 overflow-auto print:m-0"> <div class="supply-plan-print m-2 border border-gray-300 rounded-lg p-4 overflow-auto print:m-0">
<table class="w-full data-table text-sm supply-plan-table"> <table class="w-full data-table text-sm supply-plan-table">
<thead> <thead>
<tr class="bg-gray-100"> <tr class="bg-gray-100">

View File

@@ -144,7 +144,7 @@ $fmtMeasureCell = static function (array $cell, string $measureKey, bool $hasBsF
<p class="text-xs text-gray-500 m-0">(단위: 매 / 원)</p> <p class="text-xs text-gray-500 m-0">(단위: 매 / 원)</p>
</div> </div>
<div class="yearly-sales-scroll-wrap border border-gray-300 overflow-x-auto"> <div class="yearly-sales-scroll-wrap border border-gray-300 rounded-lg p-4 overflow-x-auto">
<table class="w-full data-table text-xs sm:text-sm" id="yearly-sales-table"> <table class="w-full data-table text-xs sm:text-sm" id="yearly-sales-table">
<colgroup> <colgroup>
<col style="width: 9%;"/> <col style="width: 9%;"/>
@@ -158,7 +158,7 @@ $fmtMeasureCell = static function (array $cell, string $measureKey, bool $hasBsF
<th class="align-middle min-w-0 sm:min-w-[7rem] max-w-[10rem] sm:max-w-[12rem] text-left pl-2">품목</th> <th class="align-middle min-w-0 sm:min-w-[7rem] max-w-[10rem] sm:max-w-[12rem] text-left pl-2">품목</th>
<th class="align-middle min-w-0 sm:min-w-[4.5rem]">구분</th> <th class="align-middle min-w-0 sm:min-w-[4.5rem]">구분</th>
<?php foreach ($colSpec ?? [] as $col): ?> <?php foreach ($colSpec ?? [] as $col): ?>
<th class="align-middle text-center min-w-0 sm:min-w-[4.5rem] border-l border-gray-200"><?= esc((string) ($col['label'] ?? '')) ?></th> <th class="align-middle text-right min-w-0 sm:min-w-[4.5rem] border-l border-gray-200"><?= esc((string) ($col['label'] ?? '')) ?></th>
<?php endforeach; ?> <?php endforeach; ?>
</tr> </tr>
</thead> </thead>

View File

@@ -1,17 +1,24 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<p class="text-sm text-gray-700 mb-2">관리자 페이지에서 사용할 지자체를 선택하세요. 선택한 지자체 기준으로 목록·등록이 표시됩니다.</p> <p class="text-sm text-gray-700 mb-2">관리자 페이지에서 사용할 지자체를 선택하세요. 선택한 지자체 기준으로 목록·등록이 표시됩니다.</p>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2 p-4"> <div class="border border-gray-300 rounded-lg overflow-auto mt-2 p-4">
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<p class="text-gray-600 py-4">등록된 지자체가 없습니다. <a href="<?= base_url('admin/local-governments') ?>" class="text-blue-600 hover:underline">지자체 관리</a>에서 먼저 등록하세요.</p> <p class="text-gray-600 py-4">등록된 지자체가 없습니다. <a href="<?= base_url('admin/local-governments') ?>" class="text-blue-600 hover:underline">지자체 관리</a>에서 먼저 등록하세요.</p>
<?php else: ?> <?php else: ?>
<form action="<?= base_url('admin/select-local-government') ?>" method="POST" class="space-y-3"> <form action="<?= base_url('admin/select-local-government') ?>" method="POST" class="space-y-3">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php $currentLgIdx = (int) ($currentLgIdx ?? 0); ?>
<ul class="space-y-2"> <ul class="space-y-2">
<?php foreach ($list as $lg): ?> <?php foreach ($list as $lg): ?>
<?php $isCurrent = ((int) $lg->lg_idx === $currentLgIdx); ?>
<li class="flex items-center gap-2"> <li class="flex items-center gap-2">
<input type="radio" name="lg_idx" id="lg_<?= $lg->lg_idx ?>" value="<?= (int) $lg->lg_idx ?>" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"/> <input type="radio" name="lg_idx" id="lg_<?= $lg->lg_idx ?>" value="<?= (int) $lg->lg_idx ?>" <?= $isCurrent ? 'checked' : '' ?> class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
<label for="lg_<?= $lg->lg_idx ?>" class="text-sm font-medium text-gray-800 cursor-pointer"><?= esc($lg->lg_name) ?> (<?= esc($lg->lg_code) ?>)</label> <label for="lg_<?= $lg->lg_idx ?>" class="text-sm font-medium text-gray-800 cursor-pointer"><?= esc($lg->lg_name) ?> (<?= esc($lg->lg_code) ?>)</label>
<?php if ($isCurrent): ?>
<span class="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5">
<i class="fa-solid fa-check"></i> 현재 선택
</span>
<?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">주문 접수</span> <span class="text-sm font-bold text-gray-700">주문 접수</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white">
<form action="<?= mgmt_url('shop-orders/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('shop-orders/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -38,7 +38,7 @@
</select> </select>
</div> </div>
</div> </div>
<div class="border border-gray-300 p-2 bg-gray-50"> <div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div> <div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
<table class="w-full text-sm"> <table class="w-full text-sm">
<tr><th class="text-left w-28 py-1">판매소 코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr> <tr><th class="text-left w-28 py-1">판매소 코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr>
@@ -73,19 +73,19 @@
<label class="block text-sm font-bold text-gray-700">전화 주문 접수표</label> <label class="block text-sm font-bold text-gray-700">전화 주문 접수표</label>
<button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button> <button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button>
</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"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-14">순번</th> <th class="w-14 text-center">순번</th>
<th class="w-48">품목</th> <th class="w-48 text-left">품목</th>
<th class="w-36">1박스(낱장/판매가)</th> <th class="w-36 text-right">1박스(낱장/판매가)</th>
<th class="w-36">1팩(낱장/판매가)</th> <th class="w-36 text-right">1팩(낱장/판매가)</th>
<th class="w-24">단가</th> <th class="w-24 text-right">단가</th>
<th class="w-28">주문수량</th> <th class="w-28 text-right">주문수량</th>
<th class="w-28">금액</th> <th class="w-28 text-right">금액</th>
<th class="w-32">포장(박스/팩/낱장)</th> <th class="w-32 text-right">포장(박스/팩/낱장)</th>
<th class="w-20">행삭제</th> <th class="w-20 text-center">행삭제</th>
</tr> </tr>
</thead> </thead>
<tbody id="order-rows"> <tbody id="order-rows">

View File

@@ -18,22 +18,22 @@
<a href="<?= mgmt_url('shop-orders') ?>" class="text-sm text-gray-500 hover:underline">초기화</a> <a href="<?= mgmt_url('shop-orders') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form> </form>
</section> </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"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>판매소</th> <th class="text-left">판매소</th>
<th>접수일</th> <th class="text-center">접수일</th>
<th>배달일</th> <th class="text-center">배달일</th>
<th>접수채널</th> <th class="text-center">접수채널</th>
<th>결제</th> <th class="text-center">결제</th>
<th>입금</th> <th class="text-center">입금</th>
<th>수령</th> <th class="text-center">수령</th>
<th>수량</th> <th class="text-right">수량</th>
<th>금액</th> <th class="text-right">금액</th>
<th class="w-20">상태</th> <th class="w-20 text-center">상태</th>
<th class="w-24">작업</th> <th class="w-24 text-center">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">회원 등록</span> <span class="text-sm font-bold text-gray-700">회원 등록</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-xl"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-xl">
<form action="<?= base_url('admin/users/store') ?>" method="POST" class="space-y-4"> <form action="<?= base_url('admin/users/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -11,6 +11,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-20">비밀번호 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" required/>
<p class="w-full text-xs text-gray-500 pl-20">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>

View File

@@ -14,7 +14,7 @@ $editLoginLocked = $editLockUntil !== null && $editLockUntil !== '' && strtotime
</form> </form>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-xl"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-xl">
<form action="<?= base_url('admin/users/update/' . $member->mb_idx) ?>" method="POST" class="space-y-4"> <form action="<?= base_url('admin/users/update/' . $member->mb_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -24,6 +24,7 @@ $editLoginLocked = $editLockUntil !== null && $editLockUntil !== '' && strtotime
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호</label> <label class="block text-sm font-bold text-gray-700 w-20">비밀번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" placeholder="변경 시에만 입력"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" placeholder="변경 시에만 입력"/>
<p class="w-full text-xs text-gray-500 pl-20">변경 시 8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>

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