30 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:25:16 +09:00
taekyoungc
9b3f5e4701 fix: 전화접수 포장명세 표 컬럼 간격·스캔 시 최신 항목 노출 개선(#14)
봉투종류 열을 줄이고 봉투코드 열을 넓혀 간격을 좁히고, 스캔 성공 시
포장명세 스크롤을 맨 위로 리셋해 8개 초과 시에도 방금 스캔한 봉투가
가려지지 않도록 한다.
2026-07-04 15:09:01 +09:00
62 changed files with 4711 additions and 547 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -142,6 +142,30 @@ class Feedback extends BaseController
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
}
/** 문의 글(본문) 수정 */
public function updateContent(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$content = trim((string) $this->request->getPost('fb_content'));
if ($content === '') {
return redirect()->to(site_url('admin/feedback/' . $id))->with('error', '내용을 입력해 주세요.');
}
$model->update($id, ['fb_content' => mb_substr($content, 0, 2000)]);
$returnTo = (string) $this->request->getPost('return_to');
if ($returnTo === 'all') {
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '문의 내용을 수정했습니다.');
}
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '문의 내용을 수정했습니다.');
}
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
public function image(int $id): ResponseInterface
{

View File

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

View File

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

View File

@@ -233,7 +233,7 @@ class Auth extends BaseController
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
}
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx), true);
}
public function showTotpSetup()
@@ -329,7 +329,7 @@ class Auth extends BaseController
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
}
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx));
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx), true);
}
public function logout()
@@ -573,7 +573,7 @@ class Auth extends BaseController
/**
* @param array<string, mixed> $logData
*/
private function completeLogin(object $member, array $logData): RedirectResponse
private function completeLogin(object $member, array $logData, bool $twofaVerified = false): RedirectResponse
{
$this->clearPending2faSession();
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
@@ -586,6 +586,8 @@ class Auth extends BaseController
'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true,
'session_token' => $sessionToken,
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
'auth_2fa_verified' => $twofaVerified,
];
session()->set($sessionData);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ class BagOrderModel extends Model
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_manager_idx',
'bo_regdate', 'bo_moddate',
];
}

View File

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

View File

@@ -28,13 +28,14 @@ class FeedbackModel extends Model
];
/** 허용 상태 값 */
public const STATUSES = ['new', 'in_progress', 'done', 'hold'];
public const STATUSES = ['new', 'in_progress', 'answered', 'done', 'hold'];
public static function statusLabel(string $s): string
{
return [
'new' => '접수',
'in_progress' => '처리중',
'answered' => '답변 완료',
'done' => '완료',
'hold' => '보류',
][$s] ?? $s;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
<?php
helper('admin');
helper(['admin', 'pii_encryption']);
$repDisp = static function ($row): string {
$v = (string) ($row->ds_rep_name ?? '');
return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v);
};
$currentPath = current_nav_request_path();
if ($currentPath === 'bag/designated-shops') {
$readOnly = false;
@@ -191,10 +195,8 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2">
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<?php endif; ?>
<?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
<?php endif; ?>
@@ -215,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">대표자명</label>
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
<label class="text-sm text-gray-600">전화</label>
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
<label class="text-sm text-gray-600">상태</label>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
@@ -245,6 +251,9 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<th class="ds-sort-th py-2.5 px-2 w-14 text-left cursor-pointer select-none" data-sort-key="no" data-sort-type="num">번호<span class="ds-sort-ind"></span></th>
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="rep" data-sort-type="str">대표자명<span class="ds-sort-ind"></span></th>
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="name" data-sort-type="str">상호명<span class="ds-sort-ind"></span></th>
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="dong" data-sort-type="str">구·군(동코드)<span class="ds-sort-ind"></span></th>
<th class="ds-sort-th py-2.5 px-2 w-24 text-left cursor-pointer select-none" data-sort-key="designated" data-sort-type="str">지정일<span class="ds-sort-ind"></span></th>
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="zone" data-sort-type="str">구역<span class="ds-sort-ind"></span></th>
<th class="ds-sort-th py-2.5 px-2 w-16 text-left cursor-pointer select-none" data-sort-key="state" data-sort-type="num">상태<span class="ds-sort-ind"></span></th>
</tr>
</thead>
@@ -275,16 +284,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
if ($addrCombinedList === '') {
$addrCombinedList = $addrMainList;
}
// 구·군(동코드) 표시: 동코드가 있으면 "동코드 (동명)", 없으면 구·군명
$dongDisp = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabel);
?>
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
data-sort-rep="<?= esc($row->ds_rep_name ?? '', 'attr') ?>"
data-sort-rep="<?= esc($repDisp($row), 'attr') ?>"
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
data-sort-zone="<?= esc($zone, 'attr') ?>"
data-sort-state="<?= (int) $st ?>">
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
<td class="py-2.5 px-2 text-gray-600" title="<?= esc($zone) ?>"><?= esc($zone) ?></td>
<td class="py-2.5 px-2 text-left">
<?php if ($st === 1): ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span>
@@ -303,6 +320,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
<div class="ds-detail-inner" id="ds-detail-box">
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
<div class="flex items-center justify-between gap-2">
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
</div>
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
<div class="flex gap-1">
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
</div>
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
</div>
</div>
<div id="ds-detail-fields" class="hidden">
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
@@ -469,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
fieldsWrap.classList.remove('hidden');
fillDetailInfoTable(d);
// 마스킹된 행이면 "원문 보기" 안내 노출
var revealBox = document.getElementById('ds-pii-reveal');
if (revealBox) {
revealBox.classList.toggle('hidden', !d.pii_masked);
var rf = document.getElementById('ds-pii-reveal-form');
if (rf) rf.classList.add('hidden');
}
if (!readOnly && editLink && delForm && delBtn) {
var id = d.ds_idx;
editLink.href = editBase + '/' + id;
@@ -560,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
delBtn.disabled = true;
delBtn.classList.add('pointer-events-none', 'opacity-40');
}
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
(function () {
var box = document.getElementById('ds-pii-reveal');
if (!box) return;
var btn = document.getElementById('ds-pii-reveal-btn');
var form = document.getElementById('ds-pii-reveal-form');
var otp = document.getElementById('ds-pii-otp');
var reason = document.getElementById('ds-pii-reason');
var submit = document.getElementById('ds-pii-reveal-submit');
var cancel = document.getElementById('ds-pii-reveal-cancel');
var msg = document.getElementById('ds-pii-reveal-msg');
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
var csrfName = '<?= csrf_token() ?>';
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
submit.addEventListener('click', function () {
msg.textContent = '';
var body = new URLSearchParams();
body.set('totp_code', (otp.value || '').trim());
body.set('reason', (reason.value || '').trim());
var t = document.querySelector('meta[name="' + csrfName + '"]');
body.set(csrfName, '<?= csrf_hash() ?>');
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) { location.reload(); }
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
})
.catch(function () { msg.textContent = '통신 오류'; });
});
})();
})();
</script>
@@ -570,7 +645,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr>
<th class="text-center">번호</th>
<th>지자체</th>
<th>구·군</th>
<th>구·군(동코드)</th>
<th class="text-center">지정일</th>
<th>구역</th>
<th>대표자명</th>
@@ -613,11 +688,15 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr>
<td class="text-center"><?= esc($shortNoP) ?></td>
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
<?php
$gCodeP = (string) ($row->ds_gugun_code ?? '');
$ggLabelP = (string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP);
$dongDispP = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabelP);
?>
<td class="text-left"><?= esc($dongDispP) ?></td>
<td class="text-center"><?= esc($daDispP) ?></td>
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-left"><?= esc($repDisp($row)) ?></td>
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-left"><?= esc($zipP) ?></td>
<td class="text-left"><?= esc($addrCombinedP) ?></td>

View File

@@ -14,6 +14,7 @@ $badge = static function (string $s): string {
$map = [
'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-gray-100 text-gray-500',
'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500',
];
@@ -21,6 +22,7 @@ $badge = static function (string $s): string {
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
};
?>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견 · 전체보기 (<?= count($list) ?>건)</span>
<div class="flex items-center gap-2">
@@ -62,9 +64,20 @@ $badge = static function (string $s): string {
<span class="text-gray-400 text-[12px]">·</span>
<span class="text-gray-600 text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></span>
</div>
<?php
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
$fbUrlOpen = $fbUrlRaw;
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
}
?>
<div class="text-[12px] text-gray-500">
<?= esc((string) ($row->fb_page_title ?: '-')) ?>
<span class="text-gray-400"> · <?= esc((string) ($row->fb_page_url ?: '')) ?></span>
<?php if ($fbUrlRaw !== ''): ?>
<span class="text-gray-400"> · </span><a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
<?php endif; ?>
</div>
</div>
@@ -103,3 +116,4 @@ $badge = static function (string $s): string {
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><!-- /.fb-selectable -->

View File

@@ -12,6 +12,7 @@ $badge = static function (string $s): string {
$map = [
'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-gray-100 text-gray-500',
'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500',
];
@@ -19,6 +20,7 @@ $badge = static function (string $s): string {
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
};
?>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견</span>
<div class="flex items-center gap-2">
@@ -52,7 +54,7 @@ $badge = static function (string $s): string {
<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) ?>'">
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="if(!window.getSelection().toString()){window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>';}">
<td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td>
<td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td>
<td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td>
@@ -67,3 +69,4 @@ $badge = static function (string $s): string {
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
</div><!-- /.fb-selectable -->

View File

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

View File

@@ -62,6 +62,9 @@ $navPartial = [
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - GBLS</title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
@@ -91,6 +94,13 @@ tailwind.config = {
.data-table tbody td { color: #374151; }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover td { background-color: #f9fafb; }
/* body의 select-none 상속을 무효화해 본문 텍스트를 드래그·복사할 수 있게 하는 opt-in 클래스 */
.fb-selectable, .fb-selectable * {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
@media print {
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
body.gov-portal-shell { background: #fff; display: block; }
@@ -167,27 +177,40 @@ tailwind.config = {
window.addEventListener('pagehide', closeStuckOverlays);
})();
// 글씨 크기 조절(A/A+) — 본문 + 상단 대메뉴 + 좌측 사이드바에 zoom 적용. 사이트/워크스페이스와 배율 공유.
// 글씨 크기 조절(A/A+) — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
(function () {
var FONT_KEY = 'jrj_font_scale';
// 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다.
var MENU_KEY = <?= json_encode($__fmk) ?>;
var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main'];
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
function applyScale(s) {
var saveTimer = null;
function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s));
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
scale = s;
var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
}
applyScale(curScale());
applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
})();
</script>
<script>

View File

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

View File

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

View File

@@ -250,6 +250,80 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
</table>
</div>
<p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p>
<?php
$sizeStats = $sizeStats ?? [];
$catStats = $catStats ?? [];
$sizeTotQty = array_sum(array_map(static fn ($r) => (int) $r['qty'], $sizeStats));
$sizeTotAmt = array_sum(array_map(static fn ($r) => (int) $r['amount'], $sizeStats));
$catTotQty = array_sum(array_map(static fn ($r) => (int) $r['qty'], $catStats));
$catTotAmt = array_sum(array_map(static fn ($r) => (int) $r['amount'], $catStats));
?>
<div class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 용량별(리터별) 통계 -->
<div>
<div class="text-sm font-bold text-gray-700 mb-1">용량별(리터별) 통계</div>
<table class="w-full data-table text-sm">
<thead>
<tr class="bg-gray-50">
<th class="text-left px-2 py-1">용량</th>
<th class="text-right px-2 py-1">판매량(매)</th>
<th class="text-right px-2 py-1">판매금액(원)</th>
</tr>
</thead>
<tbody>
<?php if ($sizeStats === []): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터 없음</td></tr>
<?php else: ?>
<?php foreach ($sizeStats as $r): ?>
<tr>
<td class="text-left px-2 py-1"><?= esc($r['label']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['qty']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['amount']) ?></td>
</tr>
<?php endforeach; ?>
<tr class="bg-amber-50 font-bold">
<td class="text-left px-2 py-1">합계</td>
<td class="text-right px-2 py-1"><?= number_format((int) $sizeTotQty) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $sizeTotAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 품목별(봉투/음식물/폐기물) 통계 -->
<div>
<div class="text-sm font-bold text-gray-700 mb-1">품목별(봉투·음식물·폐기물) 통계</div>
<table class="w-full data-table text-sm">
<thead>
<tr class="bg-gray-50">
<th class="text-left px-2 py-1">품목</th>
<th class="text-right px-2 py-1">판매량(매)</th>
<th class="text-right px-2 py-1">판매금액(원)</th>
</tr>
</thead>
<tbody>
<?php if ($catStats === []): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터 없음</td></tr>
<?php else: ?>
<?php foreach ($catStats as $r): ?>
<tr>
<td class="text-left px-2 py-1"><?= esc($r['label']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['qty']) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $r['amount']) ?></td>
</tr>
<?php endforeach; ?>
<tr class="bg-amber-50 font-bold">
<td class="text-left px-2 py-1">합계</td>
<td class="text-right px-2 py-1"><?= number_format((int) $catTotQty) ?></td>
<td class="text-right px-2 py-1"><?= number_format((int) $catTotAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</section>
<script>

View File

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

View File

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

View File

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

View File

@@ -175,12 +175,13 @@ tailwind.config = {
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
})();
// 글씨 크기(zoom) — 상단바에서 조절한 값을 적용. localStorage 공유 + storage 이벤트로 실시간 반영.
function applyFontScale() {
try { var s = parseInt(localStorage.getItem('jrj_font_scale') || '100', 10); if (!(s >= 70 && s <= 150)) s = 100; document.documentElement.style.zoom = (s / 100); } catch (e) {}
}
applyFontScale();
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') applyFontScale(); });
// 글씨 크기(zoom) — 계정별·메뉴별 저장값(서버 주입)을 이 화면에 적용.
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
(function () {
var s = <?= (int) $__fsc ?>;
if (!(s >= 70 && s <= 150)) s = 100;
try { document.documentElement.style.zoom = (s / 100); } catch (e) {}
})();
document.addEventListener('click', function (e) {
var a = e.target.closest ? e.target.closest('a[href]') : null;

View File

@@ -264,25 +264,39 @@ tailwind.config = {
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
// 글씨 크기 조절 — 본문 + 좌측 사이드바에 zoom 적용.
// 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다.
var FONT_KEY = 'jrj_font_scale';
// 글씨 크기 조절 — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
var MENU_KEY = <?= json_encode($__fmk) ?>;
var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main'];
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
function applyScale(s) {
var saveTimer = null;
function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s));
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
scale = s;
var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
}
applyScale(curScale());
applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
})();
</script>
<script>

View File

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

View File

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

View File

@@ -187,7 +187,7 @@ unset($g);
<th class="text-right">금액</th>
<th class="text-right">수량</th>
<th class="text-right">금액</th>
<th class="text-right">판매수량</th>
<th class="text-right">판매수량(팩)</th>
<th class="text-right">금액</th>
<th class="text-center">B</th>
<th class="text-center">P</th>
@@ -212,7 +212,7 @@ unset($g);
<td class="text-right px-2"><?= number_format($r['box']) ?></td>
<td class="text-right px-2"><?= number_format($boxPrice) ?></td>
<td class="text-right px-2"><?= number_format($r['price']) ?></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input item-pack-input" type="number" min="0" value="0"/><input type="hidden" name="item_qty[]" class="item-qty-hidden" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td>
<td class="text-center item-box-cell">0</td>
<td class="text-center item-pack-cell">0</td>
@@ -330,12 +330,18 @@ unset($g);
}
function calcRow(row) {
const qtyInput = row.querySelector('.item-qty-input');
const qty = parseInt(qtyInput.value || '0', 10) || 0;
const qtyInput = row.querySelector('.item-pack-input');
const hiddenQty = row.querySelector('.item-qty-hidden');
const packQty = parseInt(qtyInput.value || '0', 10) || 0; // 입력값 = 팩 수
const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0;
const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0;
// 팩당 낱장 수(팩 매수 미설정 봉투는 1:1로 처리)
const mult = packSheets > 0 ? packSheets : 1;
const qty = packQty * mult; // 실제 낱장 수 → 서버로 전송
if (hiddenQty) hiddenQty.value = String(qty);
let box = 0;
let pack = 0;
let sheet = qty;
@@ -359,14 +365,14 @@ unset($g);
row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : '';
row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : '';
return { qty, amount, box, pack, sheet };
return { packQty, amount, box, pack, sheet };
}
function recalcAllRows() {
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
document.querySelectorAll('.order-row').forEach((row) => {
const r = calcRow(row);
sumQty += r.qty;
sumQty += r.packQty;
sumAmount += r.amount;
sumBox += r.box;
sumPack += r.pack;

View File

@@ -15,8 +15,8 @@
</form>
</section>
<div class="grid grid-cols-1 xl:grid-cols-9 gap-3 mt-2">
<section class="xl:col-span-5 border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col">
<div id="order-split" class="flex flex-col xl:flex-row gap-3 mt-2 items-stretch">
<section id="list-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col min-w-0 xl:shrink-0">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div>
<div id="list-scroll" class="overflow-auto">
<table class="w-full data-table text-sm">
@@ -43,7 +43,12 @@
</div>
</section>
<section id="detail-card" class="xl:col-span-4 border border-gray-300 rounded-lg bg-white overflow-hidden">
<!-- 드래그 가능한 너비 조절 손잡이 (xl 이상 좌우 배치일 때만 표시) -->
<div id="split-handle" class="hidden xl:flex items-center justify-center cursor-col-resize select-none shrink-0" style="width:8px;touch-action:none;" role="separator" aria-orientation="vertical" title="드래그하여 표 너비를 조절합니다">
<div class="w-1.5 h-16 rounded-full bg-gray-300 hover:bg-blue-400 transition-colors"></div>
</div>
<section id="detail-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden min-w-0 xl:flex-1">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
@@ -64,12 +69,14 @@
<input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/>
</div>
<div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">입금상태</span> <span id="detail-paid-status" class="font-semibold">-</span></div>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 px-1">
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
<div class="flex items-center gap-2">
<button type="button" id="btn-toggle-paid" class="border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50" disabled>입금확인</button>
<button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
<button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
<button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
@@ -116,26 +123,25 @@
<div class="mt-3">
<div class="flex items-center justify-between px-1 mb-1 text-sm">
<span class="font-semibold text-gray-700">포장 명세</span>
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
<span class="font-semibold text-gray-700">스캔 현황</span>
<span class="flex items-center gap-2">
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
<button type="button" id="btn-packing-modal" class="border border-gray-300 text-gray-600 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 disabled:opacity-50" disabled>포장 명세 보기</button>
</span>
</div>
<div class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="w-64 text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="w-28 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-20 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<!-- 최근 스캔 2건 -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
<div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</div>
</div>
<!-- 앞으로 찍을 목록(품목별 남은 수량) -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
</div>
</div>
</div><!-- /포장 명세 -->
</div><!-- /스캔 현황 -->
</form>
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
@@ -145,6 +151,36 @@
</section>
</div>
<!-- 포장 명세(전체 봉투코드) 팝업 -->
<div id="packing-modal-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<span class="text-sm font-bold text-gray-800">포장 명세 — <span id="packing-modal-title">-</span></span>
<button type="button" id="packing-modal-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">&times;</button>
</div>
<div class="p-3 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="w-40 text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="w-16 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-modal-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-between">
<span class="text-xs text-gray-500">총 <span id="packing-modal-count">0</span>건 · 포장량 <span id="packing-modal-total">0</span></span>
<button type="button" id="packing-modal-close2" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
</div>
</div>
</div>
<!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) -->
<div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm">
<p class="text-[11px] text-amber-900 mb-1 leading-relaxed">
@@ -174,6 +210,19 @@
(() => {
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
// 표시용 접수번호: 접수일(so_order_date)별로 매일 1번부터 순차 부여(그 날의 가장 오래된 건 = 1).
// 정렬과 무관하게 고정.
const dateKey = (o) => String(o && o.so_order_date ? o.so_order_date : '').slice(0, 10);
const displayNoMap = new Map();
(() => {
const perDate = {};
orders.slice().sort((a, b) => Number(a.so_idx || 0) - Number(b.so_idx || 0)).forEach((o) => {
const k = dateKey(o);
perDate[k] = (perDate[k] || 0) + 1;
displayNoMap.set(String(o.so_idx), perDate[k]);
});
})();
const displayNo = (o) => displayNoMap.get(String(o && o.so_idx)) || '';
// 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신)
// base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전
@@ -191,6 +240,8 @@
const btnSave = document.getElementById('btn-save');
const btnCancel = document.getElementById('btn-cancel-order');
const btnReceipt = document.getElementById('btn-receipt');
const btnTogglePaid = document.getElementById('btn-toggle-paid');
const paidApi = new URL('<?= base_url('bag/order/phone/manage/paid') ?>', window.location.href).pathname;
const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>';
btnReceipt?.addEventListener('click', () => {
const id = inputSoIdx.value;
@@ -205,9 +256,46 @@
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
function paintPaidButton(order) {
if (!btnTogglePaid) return;
const paid = order && Number(order.so_paid) === 1;
btnTogglePaid.textContent = paid ? '입금확인 취소' : '입금확인';
btnTogglePaid.className = paid
? 'border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50'
: 'border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50';
}
btnTogglePaid?.addEventListener('click', async () => {
const id = inputSoIdx.value;
if (!id) return;
const o = orderMap.get(String(id));
if (!o) return;
const nextPaid = Number(o.so_paid) === 1 ? 0 : 1;
if (nextPaid === 0 && !confirm('입금확인을 취소하면 이 주문은 포장(스캔)이 차단됩니다.\n계속할까요?')) return;
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('so_idx', String(id));
body.set('paid', String(nextPaid));
btnTogglePaid.disabled = true;
try {
const res = await fetch(paidApi, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body });
const data = await res.json();
if (data && data.csrf) csrfHash = data.csrf;
if (!data || !data.ok) { alert((data && data.message) || '처리에 실패했습니다.'); btnTogglePaid.disabled = false; return; }
o.so_paid = nextPaid;
setHeader(o);
paintPaidButton(o);
btnTogglePaid.disabled = false;
renderList(); // 리스트의 입금 표시 갱신
} catch (e) {
alert('네트워크 오류로 처리하지 못했습니다.');
btnTogglePaid.disabled = false;
}
});
function setHeader(order) {
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
t('detail-so-no', order ? order.so_idx : '');
t('detail-so-no', order ? displayNo(order) : '');
t('detail-shop-code', order ? order.ds_shop_no : '');
t('detail-shop-name', order ? order.so_ds_name : '');
t('detail-shop-rep', order ? order.ds_rep_name : '');
@@ -216,6 +304,12 @@
t('detail-shop-addr', order ? order.ds_addr : '');
t('detail-order-date', order ? order.so_order_date : '');
t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '');
var ps = document.getElementById('detail-paid-status');
if (ps) {
var paid = order && Number(order.so_paid) === 1;
ps.textContent = order ? (paid ? '입금완료' : '미입금') : '-';
ps.style.color = order ? (paid ? '#059669' : '#e11d48') : '';
}
// 배달일 — 수정 가능한 date 입력
var dd = document.getElementById('detail-delivery-date-input');
if (dd) {
@@ -299,6 +393,7 @@
btnSave.disabled = isCancelled;
btnCancel.disabled = isCancelled;
if (btnReceipt) btnReceipt.disabled = false;
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
renderPacking(order); // 포장 명세 탭 동기화
@@ -491,32 +586,161 @@
new ResizeObserver(function () { syncListHeight(); }).observe(detailCardEl);
}
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ──────────
const packingBody = document.getElementById('packing-body');
// ── 접수 리스트 ↔ 지정판매소 정보 사이 너비 조절(드래그 스플리터) ──────
// 손잡이를 접수 리스트 쪽(왼쪽)으로 끌면 리스트 폭이 줄고 지정판매소 정보 폭이 넓어진다.
const splitEl = document.getElementById('order-split');
const listCardEl = document.getElementById('list-card');
const splitHandleEl = document.getElementById('split-handle');
const SPLIT_KEY = 'jrj_order_split_pct';
const SPLIT_MIN = 25, SPLIT_MAX = 75, SPLIT_DEFAULT = 55.5; // 기본값 ≈ 기존 5:4 비율
const xlMedia = window.matchMedia('(min-width: 1280px)');
function clampPct(p) { return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, p)); }
function currentPct() {
const saved = parseFloat(localStorage.getItem(SPLIT_KEY) || '');
return clampPct(isFinite(saved) ? saved : SPLIT_DEFAULT);
}
function applySplit(pct) {
if (!listCardEl) return;
if (xlMedia.matches) {
// 좌우 배치일 때만 폭 지정(세로로 쌓이는 좁은 화면에서는 전체폭 사용)
listCardEl.style.flex = '0 0 ' + pct + '%';
listCardEl.style.maxWidth = pct + '%';
} else {
listCardEl.style.flex = '';
listCardEl.style.maxWidth = '';
}
syncListHeight();
}
if (splitHandleEl && splitEl && listCardEl) {
let dragging = false, rafId = 0, pendingPct = null;
function onMove(clientX) {
const rect = splitEl.getBoundingClientRect();
if (rect.width <= 0) return;
pendingPct = clampPct((clientX - rect.left) / rect.width * 100);
if (!rafId) {
rafId = requestAnimationFrame(function () {
rafId = 0;
if (pendingPct != null) applySplit(pendingPct);
});
}
}
function pointerMove(e) { if (dragging) { e.preventDefault(); onMove(e.clientX); } }
function pointerUp() {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
window.removeEventListener('pointermove', pointerMove);
window.removeEventListener('pointerup', pointerUp);
if (pendingPct != null) localStorage.setItem(SPLIT_KEY, String(Math.round(pendingPct * 10) / 10));
}
splitHandleEl.addEventListener('pointerdown', function (e) {
if (!xlMedia.matches) return; // 좁은 화면에서는 비활성
e.preventDefault();
dragging = true;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
window.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp);
});
// 더블클릭 시 기본 비율로 초기화
splitHandleEl.addEventListener('dblclick', function () {
localStorage.removeItem(SPLIT_KEY);
applySplit(SPLIT_DEFAULT);
});
// 브레이크포인트 전환 시 폭 지정 적용/해제
(xlMedia.addEventListener ? xlMedia.addEventListener('change', function () { applySplit(currentPct()); })
: xlMedia.addListener(function () { applySplit(currentPct()); }));
applySplit(currentPct());
}
// ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
const packingTotal = document.getElementById('packing-total');
const recentScansEl = document.getElementById('recent-scans');
const remainingListEl = document.getElementById('remaining-list');
const btnPackingModal = document.getElementById('btn-packing-modal');
const packingModalOverlay = document.getElementById('packing-modal-overlay');
const packingModalBody = document.getElementById('packing-modal-body');
const packingModalTitle = document.getElementById('packing-modal-title');
const packingModalCount = document.getElementById('packing-modal-count');
const packingModalTotal = document.getElementById('packing-modal-total');
let packingModalOrderId = null;
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
// 포장 명세 = 실제 스캔(=판매/포장)된 봉투코드 내역. 최근 스캔이 위로 오도록 역순 표시.
function renderPacking(order) {
if (!packingBody) return;
// 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
function scanFullRows(order) {
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
let no = 0;
let totalQty = 0;
let no = 0, total = 0;
const rows = scans.map((s) => {
no++;
const code = s.bag_code || '';
const name = s.bag_name || '';
const qty = parseInt(s.qty || 0, 10) || 0;
totalQty += qty;
const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
total += qty;
return '<tr><td class="text-center">' + no + '</td>'
+ '<td class="text-left pl-2">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-center font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
+ '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-left pl-2">' + packEsc(s.unit || '') + '</td></tr>';
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
});
packingBody.innerHTML = rows.length ? rows.join('')
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingTotal) packingTotal.textContent = nf(totalQty);
return { html: rows.join(''), count: no, total: total };
}
function fillPackingModal(order) {
const full = scanFullRows(order);
if (packingModalBody) packingModalBody.innerHTML = full.count ? full.html
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingModalCount) packingModalCount.textContent = nf(full.count);
if (packingModalTotal) packingModalTotal.textContent = nf(full.total);
}
// 앞으로 찍을 목록 = 품목별 남은(접수량 포장량)
function renderRemaining(order) {
if (!remainingListEl) return;
const items = Array.isArray(order.items) ? order.items : [];
const rows = items.map((it) => {
const qty = parseInt(it.soi_qty || 0, 10) || 0;
const packed = parseInt(it.soi_packed_qty || 0, 10) || 0;
if (qty <= 0) return '';
const remain = Math.max(0, qty - packed);
const name = (String(it.soi_bag_code || '') + ' ' + String(it.soi_bag_name || '')).trim();
if (remain <= 0) {
return '<div class="flex items-center justify-between text-emerald-600 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap">✓ 완료</span></div>';
}
return '<div class="flex items-center justify-between text-gray-700 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap font-semibold text-amber-700">' + nf(remain) + ' 매</span></div>';
}).filter(Boolean);
remainingListEl.innerHTML = rows.length ? rows.join('') : '<span class="text-gray-400">남은 수량이 없습니다.</span>';
}
// 스캔 현황 갱신(최근 2건 + 남은 목록 + 포장량 + 열린 팝업)
function renderPacking(order) {
const scans = Array.isArray(order.scans) ? order.scans : [];
const full = scanFullRows(order);
if (packingTotal) packingTotal.textContent = nf(full.total);
if (btnPackingModal) btnPackingModal.disabled = false;
if (recentScansEl) {
const last = scans.slice(-2).reverse();
recentScansEl.innerHTML = last.length
? last.map((s, i) => '<div class="flex items-center justify-between py-0.5 ' + (i === 0 ? 'font-semibold text-emerald-700' : 'text-gray-500') + '">'
+ '<span class="truncate mr-2 font-mono">' + (i === 0 ? '▶ ' : '') + packEsc(s.code || '') + '</span>'
+ '<span class="whitespace-nowrap text-gray-500">' + packEsc(s.bag_name || '') + ' · ' + nf(parseInt(s.qty || 0, 10) || 0) + '</span></div>').join('')
: '<span class="text-gray-400">스캔 내역이 없습니다.</span>';
}
renderRemaining(order);
if (packingModalOverlay && !packingModalOverlay.classList.contains('hidden') && String(packingModalOrderId) === String(order.so_idx)) {
fillPackingModal(order);
}
}
// 포장 명세 팝업 열기/닫기
function openPackingModal() {
const order = orderMap.get(String(selectedId));
if (!order) return;
packingModalOrderId = selectedId;
if (packingModalTitle) packingModalTitle.textContent = '접수 #' + displayNo(order) + ' · ' + (order.so_ds_name || '');
fillPackingModal(order);
packingModalOverlay.classList.remove('hidden');
packingModalOverlay.classList.add('flex');
}
function closePackingModal() { packingModalOverlay.classList.add('hidden'); packingModalOverlay.classList.remove('flex'); }
if (btnPackingModal) btnPackingModal.addEventListener('click', openPackingModal);
document.getElementById('packing-modal-close')?.addEventListener('click', closePackingModal);
document.getElementById('packing-modal-close2')?.addEventListener('click', closePackingModal);
if (packingModalOverlay) packingModalOverlay.addEventListener('click', (e) => { if (e.target === packingModalOverlay) closePackingModal(); });
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
@@ -528,7 +752,7 @@
const channel = (o.so_channel && o.so_channel !== 'phone') ? o.so_channel : '전화';
const packed = Number(o.so_received) === 1;
return `<tr class="order-list-row cursor-pointer hover:bg-blue-50 ${cancelled ? 'bg-gray-50 text-gray-400' : ''} ${String(o.so_idx) === String(selectedId) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
<td class="text-center">${o.so_idx}</td>
<td class="text-center">${displayNo(o)}</td>
<td class="text-center">${esc(o.so_order_date)}</td>
<td class="text-center">${esc(dDate)}</td>
<td class="text-center">${esc(channel)}</td>
@@ -579,6 +803,7 @@
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100');
renderDetail(selectedId);
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
});
detailBody?.addEventListener('input', (e) => {

View File

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

View File

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

View File

@@ -112,6 +112,11 @@ $roadBaseOnly = ! empty($roadBaseOnly);
var detEl = field(detailFieldName);
if (detEl) detEl.value = data.buildingName || '';
}
// 주소가 채워진 뒤, 폼에서 후속 처리(예: 동코드 조회)를 할 수 있도록 이벤트 발행
try {
form.dispatchEvent(new CustomEvent('kakao-address-selected', { detail: data }));
} catch (e) { /* 구형 브라우저 무시 */ }
}
}).open();
});

View File

@@ -1,38 +1,109 @@
<?php
/**
* Custom Tailwind CSS Pagination View for CodeIgniter 4
* 공용 리스트 페이저 → Lazy Loading(무한 스크롤) 표준 컴포넌트.
*
* CI4 Pager 템플릿(Config\Pager: default_full/default_simple)으로 등록돼 있어,
* 모든 리스트 화면의 `<?= $pager->links() ?>` 호출이 이 컴포넌트를 렌더한다.
* 페이지 번호 대신 센티넬 + '더보기' 버튼을 두고, 화면 하단 도달 시(IntersectionObserver)
* 다음 페이지 HTML을 받아 바로 위 리스트 표(tbody)에 행을 이어붙인다.
*
* @var \CodeIgniter\Pager\PagerRenderer $pager
*/
$pager->setSurroundCount(2);
$hasNext = $pager->hasNextPage();
$nextUrl = $hasNext ? (string) $pager->getNextPage() : '';
$curPage = (int) $pager->getCurrent();
$pageCount = (int) $pager->getPageCount();
?>
<?php if ($pageCount > 1): ?>
<div class="lazy-pager no-print mt-3 mb-2 flex flex-col items-center gap-1"
data-next-url="<?= esc($nextUrl, 'attr') ?>"
data-has-next="<?= $hasNext ? '1' : '0' ?>">
<button type="button" class="lazy-more px-4 py-1.5 text-xs border border-gray-300 rounded-full hover:bg-gray-50 text-gray-600<?= $hasNext ? '' : ' hidden' ?>">더보기</button>
<span class="lazy-status text-[11px] text-gray-400"></span>
</div>
<script>
(function () {
if (window.__lazyPagerInit) { window.__lazyPagerInit(); return; }
<?php if ($pager->hasPreviousPage() || $pager->hasNextPage()): ?>
<nav aria-label="Page navigation" class="flex items-center justify-center gap-1 mt-3 mb-2 no-print">
<?php if ($pager->hasPreviousPage()): ?>
<a href="<?= $pager->getFirst() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="처음">&laquo;</a>
<a href="<?= $pager->getPreviousPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="이전">&lsaquo;</a>
<?php else: ?>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&laquo;</span>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&lsaquo;</span>
<?php endif; ?>
function lastTableBefore(el, root) {
var tables = root.querySelectorAll('table');
var found = null;
for (var i = 0; i < tables.length; i++) {
if (el.compareDocumentPosition(tables[i]) & Node.DOCUMENT_POSITION_PRECEDING) found = tables[i];
}
return found;
}
function bodyOf(table) { return table ? (table.tBodies[0] || table.querySelector('tbody')) : null; }
<?php foreach ($pager->links() as $link): ?>
<?php if ($link['active']): ?>
<span class="px-3 py-1 text-xs border border-btn-search rounded bg-btn-search text-white font-bold"><?= $link['title'] ?></span>
<?php else: ?>
<a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a>
<?php endif; ?>
<?php endforeach; ?>
function initOne(sentinel) {
if (sentinel.getAttribute('data-lazy-init')) return;
sentinel.setAttribute('data-lazy-init', '1');
var tbody = bodyOf(lastTableBefore(sentinel, document));
if (!tbody) return;
var moreBtn = sentinel.querySelector('.lazy-more');
var statusEl = sentinel.querySelector('.lazy-status');
var loading = false;
var io = null;
var setStatus = function (t) { if (statusEl) statusEl.textContent = t || ''; };
<?php if ($pager->hasNextPage()): ?>
<a href="<?= $pager->getNextPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="다음">&rsaquo;</a>
<a href="<?= $pager->getLast() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="마지막">&raquo;</a>
<?php else: ?>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&rsaquo;</span>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&raquo;</span>
<?php endif; ?>
</nav>
async function loadNext() {
if (loading) return;
if (sentinel.getAttribute('data-has-next') !== '1') return;
var url = sentinel.getAttribute('data-next-url');
if (!url) return;
loading = true; setStatus('불러오는 중…');
try {
var res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } });
var html = await res.text();
var doc = new DOMParser().parseFromString(html, 'text/html');
var fSentinel = doc.querySelector('.lazy-pager');
var fBody = bodyOf(fSentinel ? lastTableBefore(fSentinel, doc) : null);
var added = 0;
if (fBody) {
fBody.querySelectorAll('tr').forEach(function (tr) {
if (tr.children.length === 1 && tr.children[0].hasAttribute('colspan')) return; // 빈 목록 placeholder 제외
tbody.appendChild(document.importNode(tr, true));
added++;
});
}
if (fSentinel && fSentinel.getAttribute('data-has-next') === '1') {
sentinel.setAttribute('data-next-url', fSentinel.getAttribute('data-next-url'));
setStatus('');
} else {
sentinel.setAttribute('data-has-next', '0');
if (moreBtn) moreBtn.classList.add('hidden');
setStatus('모두 불러왔습니다');
if (io) io.disconnect();
}
} catch (e) {
setStatus('불러오기 실패 — [더보기]로 다시 시도해 주세요.');
}
loading = false;
}
if (moreBtn) moreBtn.addEventListener('click', loadNext);
// 사용자가 실제로 스크롤한 뒤에만 자동 로드(내부 스크롤 리스트에서 로드 즉시 전체 로딩 방지).
// 스크롤 전에는 [더보기] 버튼으로 불러온다.
if (!window.__lazyUserScrolled) {
var mark = function () { window.__lazyUserScrolled = true; };
window.addEventListener('scroll', mark, { passive: true, capture: true, once: true });
window.addEventListener('wheel', mark, { passive: true, once: true });
window.addEventListener('touchmove', mark, { passive: true, once: true });
}
if ('IntersectionObserver' in window) {
io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) { if (en.isIntersecting && window.__lazyUserScrolled) loadNext(); });
}, { root: null, rootMargin: '0px 0px 320px 0px', threshold: 0 });
io.observe(sentinel);
}
}
window.__lazyPagerInit = function () {
document.querySelectorAll('.lazy-pager:not([data-lazy-init])').forEach(initOne);
};
window.__lazyPagerInit();
})();
</script>
<?php endif; ?>

View File

@@ -9,7 +9,7 @@ $brandHref = $brandHref ?? base_url('dashboard/gov-portal');
?>
<a href="<?= esc($brandHref) ?>" class="gov-portal-brand">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg>
<span style="display:inline-flex;flex-direction:column;line-height:1.02;">
<strong style="font-size:1.02rem;font-weight:800;letter-spacing:.5px;">GBLS</strong>

View File

@@ -1,3 +1,6 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>GBLS</title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>

View File

@@ -6,6 +6,42 @@
var listEl = document.getElementById('portalSidebarList');
var titleEl = document.getElementById('portalSidebarTitle');
// 모든 상위메뉴의 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저).
// 서버 렌더(_dashboard_gov_portal_sidebar.php)의 매핑과 동일하게 유지할 것.
function sidebarIcon(name) {
var n = String(name || '');
var rules = [
['바코드', 'fa-barcode'], ['스캐너', 'fa-barcode'], ['번호', 'fa-hashtag'],
['판매대장', 'fa-book'], ['대장', 'fa-book'],
['집계표', 'fa-table-list'], ['일계표', 'fa-table-list'],
['수불', 'fa-right-left'], ['입출고', 'fa-right-left'],
['무료', 'fa-gift'], ['반품', 'fa-rotate-left'], ['파기', 'fa-trash-can'],
['신규', 'fa-chart-column'], ['취소', 'fa-ban'],
['발주파일', 'fa-file-arrow-down'], ['발주', 'fa-cart-shopping'],
['입고', 'fa-dolly'], ['재고', 'fa-warehouse'], ['불출', 'fa-truck-ramp-box'],
['전화', 'fa-phone'], ['접수', 'fa-clipboard-list'],
['판매현황', 'fa-chart-column'], ['판매소', 'fa-store'], ['대행소', 'fa-handshake'],
['담당자', 'fa-user-tie'], ['대상자', 'fa-user-check'], ['업체', 'fa-building'],
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'], ['포장', 'fa-box'],
['계획', 'fa-clipboard-check'], ['실사', 'fa-clipboard-check'],
['분석', 'fa-chart-line'], ['추이', 'fa-chart-line'], ['통계', 'fa-chart-line'],
['홈텍스', 'fa-file-invoice-dollar'], ['판매', 'fa-cash-register'],
['현황', 'fa-chart-column'], ['조회', 'fa-magnifying-glass'],
['대시보드', 'fa-gauge-high'], ['회원', 'fa-users'], ['승인', 'fa-user-check'],
['로그', 'fa-clock-rotate-left'], ['이력', 'fa-clock-rotate-left'],
['메뉴', 'fa-bars'], ['지자체', 'fa-city'], ['문의', 'fa-comment-dots'],
['비밀번호', 'fa-key'], ['설정', 'fa-gear'], ['관리', 'fa-folder-open']
];
for (var i = 0; i < rules.length; i++) {
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
}
if (n.toLowerCase().indexOf('password') !== -1) return 'fa-key';
return 'fa-angle-right';
}
function iconHtml(parentName, childName) {
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
}
if (listEl && navData.length) {
function renderSidebar(idx, overrideHref) {
var parent = navData[idx];
@@ -29,10 +65,11 @@
var li = document.createElement('li');
var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0);
var ic = iconHtml(parent.name, child.name);
if (child.href) {
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + child.name + '</a>';
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + ic + child.name + '</a>';
} else {
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + child.name + '</span>';
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + ic + child.name + '</span>';
}
listEl.appendChild(li);
});

View File

@@ -6,6 +6,72 @@ declare(strict_types=1);
$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
$sidebarTitle = $activeParent['name'] ?? 'MY MENU';
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
// 모든 상위메뉴의 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저).
$showSidebarIcons = true;
$sidebarIconFor = static function (string $name): string {
$rules = [
'바코드' => 'fa-barcode',
'스캐너' => 'fa-barcode',
'번호' => 'fa-hashtag',
'판매대장' => 'fa-book',
'대장' => 'fa-book',
'집계표' => 'fa-table-list',
'일계표' => 'fa-table-list',
'수불' => 'fa-right-left',
'입출고' => 'fa-right-left',
'무료' => 'fa-gift',
'반품' => 'fa-rotate-left',
'파기' => 'fa-trash-can',
'신규' => 'fa-chart-column',
'취소' => 'fa-ban',
'발주파일' => 'fa-file-arrow-down',
'발주' => 'fa-cart-shopping',
'입고' => 'fa-dolly',
'재고' => 'fa-warehouse',
'불출' => 'fa-truck-ramp-box',
'전화' => 'fa-phone',
'접수' => 'fa-clipboard-list',
'판매현황' => 'fa-chart-column',
'판매소' => 'fa-store',
'대행소' => 'fa-handshake',
'담당자' => 'fa-user-tie',
'대상자' => 'fa-user-check',
'업체' => 'fa-building',
'단가' => 'fa-won-sign',
'코드' => 'fa-list-ol',
'포장' => 'fa-box',
'계획' => 'fa-clipboard-check',
'실사' => 'fa-clipboard-check',
'분석' => 'fa-chart-line',
'추이' => 'fa-chart-line',
'통계' => 'fa-chart-line',
'홈텍스' => 'fa-file-invoice-dollar',
'판매' => 'fa-cash-register',
'현황' => 'fa-chart-column',
'조회' => 'fa-magnifying-glass',
'대시보드' => 'fa-gauge-high',
'회원' => 'fa-users',
'승인' => 'fa-user-check',
'로그' => 'fa-clock-rotate-left',
'이력' => 'fa-clock-rotate-left',
'메뉴' => 'fa-bars',
'지자체' => 'fa-city',
'문의' => 'fa-comment-dots',
'비밀번호' => 'fa-key',
'설정' => 'fa-gear',
'관리' => 'fa-folder-open',
];
foreach ($rules as $kw => $icon) {
if (mb_stripos($name, $kw) !== false) {
return $icon;
}
}
if (stripos($name, 'password') !== false) {
return 'fa-key';
}
return 'fa-angle-right';
};
?>
<aside class="sidebar">
<div class="my-menu-hd" id="portalSidebarTitle"><?= esc($sidebarTitle) ?></div>
@@ -22,11 +88,11 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<li>
<?php if ($child['href'] !== ''): ?>
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
<?= esc($child['name']) ?>
<?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
</a>
<?php else: ?>
<span class="menu-sub" style="opacity:.65;cursor:default;">
<?= esc($child['name']) ?>
<?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
</span>
<?php endif; ?>
</li>

View File

@@ -28,7 +28,7 @@ tailwind.config = {
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
<a href="<?= base_url() ?>" class="flex items-center gap-2 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg>
<span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
@@ -46,7 +46,7 @@ tailwind.config = {
<div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-8 py-8 text-center">
<div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/15 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 text-white" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg>
</div>
<h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1>

20
public/favicon.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="GBLS">
<!-- 브랜드 배경(네이비) -->
<rect x="0" y="0" width="64" height="64" rx="14" fill="#243a5e"/>
<!-- 쓰레기통(흰색) : 종량제 쓰레기봉투 물류 -->
<g fill="none" stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
<!-- 뚜껑 -->
<path d="M16 22 H48"/>
<!-- 손잡이 -->
<path d="M27 22 V18.5 a2 2 0 0 1 2-2 h6 a2 2 0 0 1 2 2 V22"/>
<!-- 통 몸체 -->
<path d="M19 22 L21.5 47 a3 3 0 0 0 3 2.8 h15 a3 3 0 0 0 3-2.8 L45 22 Z"/>
</g>
<!-- 세로 줄무늬 -->
<g stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9">
<path d="M27 28 V44"/>
<path d="M37 28 V44"/>
</g>
<!-- 에코 잎사귀(그린 포인트) -->
<path d="M32 27 C33 22 37 20 41 20 C41 25 38 28 32 27 Z" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1,16 @@
-- 발주에 '발주담당'(담당자 manager.mg_idx) 저장 컬럼 추가
-- bo_orderer_idx(발주 작성자 mb_idx) 다음에 bo_manager_idx(발주담당 mg_idx)를 둔다.
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/bag_order_add_manager.sql
-- 멱등 실행(이미 있으면 건너뜀).
SET @db = DATABASE();
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'bag_order' AND COLUMN_NAME = 'bo_manager_idx') > 0,
'SELECT ''bo_manager_idx already exists'' AS msg',
'ALTER TABLE `bag_order` ADD COLUMN `bo_manager_idx` INT UNSIGNED NULL COMMENT ''발주담당 manager.mg_idx'' AFTER `bo_orderer_idx`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,16 @@
-- 지정판매소에 동코드(code_detail 종류 D) 저장 컬럼 추가
-- ds_gugun_code(구코드) 다음에 ds_dong_code(동코드)를 둔다.
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/designated_shop_add_dong_code.sql
-- 멱등 실행(이미 있으면 건너뜀).
SET @db = DATABASE();
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_dong_code') > 0,
'SELECT ''ds_dong_code already exists'' AS msg',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_dong_code` VARCHAR(20) NOT NULL DEFAULT '''' COMMENT ''동코드 code_detail(D)'' AFTER `ds_gugun_code`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,23 @@
-- 지정판매소 PII 블라인드 인덱스 컬럼(정확일치 검색용) 추가 — HMAC-SHA256 값 저장(64 hex)
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/designated_shop_add_pii_bidx.sql
-- 멱등. 컬럼 추가 후, 암호화/백필은 spark 명령(pii:protect-designated-shops)으로 수행.
SET @db = DATABASE();
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='designated_shop' AND COLUMN_NAME='ds_tel_bidx')>0,
'SELECT ''ds_tel_bidx exists''',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_tel_bidx` CHAR(64) NOT NULL DEFAULT '''' COMMENT ''전화 블라인드인덱스(HMAC)'' , ADD INDEX `idx_ds_tel_bidx` (`ds_tel_bidx`)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='designated_shop' AND COLUMN_NAME='ds_rep_name_bidx')>0,
'SELECT ''ds_rep_name_bidx exists''',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_rep_name_bidx` CHAR(64) NOT NULL DEFAULT '''' COMMENT ''대표자명 블라인드인덱스(HMAC)'' , ADD INDEX `idx_ds_rep_name_bidx` (`ds_rep_name_bidx`)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 참고: 암호문(ENC:...)은 평문보다 길어짐. 아래 PII 컬럼이 짧으면 길이 확대 필요할 수 있음(확인 후 조정).
-- ALTER TABLE `designated_shop`
-- MODIFY `ds_rep_name` VARCHAR(255) NULL, MODIFY `ds_tel` VARCHAR(255) NULL,
-- MODIFY `ds_rep_phone` VARCHAR(255) NULL, MODIFY `ds_email` VARCHAR(255) NULL,
-- MODIFY `ds_va_account` VARCHAR(255) NULL;

View File

@@ -0,0 +1,15 @@
-- 지자체별 허용 IP 대역(CIDR/단일IP, 쉼표·공백 구분) — 지정판매소 PII 자동 원문 열람 조건에 사용
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/local_government_add_allow_ips.sql
-- 멱등(이미 있으면 건너뜀).
--
-- 예시 값(운영 반영 시 각 지자체 실제 사무실 공인 IP로 UPDATE):
-- UPDATE local_government SET lg_allow_ips = '203.0.113.10, 203.0.113.0/24' WHERE lg_idx = 1;
SET @db = DATABASE();
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'local_government' AND COLUMN_NAME = 'lg_allow_ips') > 0,
'SELECT ''lg_allow_ips already exists'' AS msg',
'ALTER TABLE `local_government` ADD COLUMN `lg_allow_ips` VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''허용 IP 대역(CIDR/단일IP, 쉼표구분) — PII 자동 원문 열람'''
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,15 @@
-- 계정별 · 메뉴별 글자크기(zoom %) 저장
-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/member_menu_font_scale.sql
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `member_menu_font_scale` (
`mmf_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`mmf_mb_idx` INT UNSIGNED NOT NULL,
`mmf_menu_key` VARCHAR(191) NOT NULL,
`mmf_scale` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
`mmf_updated` DATETIME NOT NULL,
PRIMARY KEY (`mmf_idx`),
UNIQUE KEY `uk_mb_menu` (`mmf_mb_idx`, `mmf_menu_key`),
KEY `idx_mb` (`mmf_mb_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,21 @@
-- 판매 현황 카테고리에 '맞춤 집계표' 소메뉴 추가 (사이트 메뉴)
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_add_custom_report.sql
-- 멱등: 이미 있으면 건너뜀.
SET @mt_site := (SELECT mt_idx FROM menu_type WHERE mt_code = 'site' LIMIT 1);
SET @parent_sales := (
SELECT mm_idx FROM menu
WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = 0 AND mm_name = '판매 현황'
LIMIT 1
);
SET @next_num := (
SELECT IFNULL(MAX(mm_num), -1) + 1 FROM menu WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = @parent_sales
);
INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_cnode, mm_level, mm_is_view)
SELECT @mt_site, 1, '맞춤 집계표', 'bag/reports/custom', @parent_sales, 1, @next_num, 0, '', 'Y'
WHERE @parent_sales IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM menu m
WHERE m.mt_idx = @mt_site AND m.lg_idx = 1 AND m.mm_pidx = @parent_sales AND m.mm_name = '맞춤 집계표'
);

View File

@@ -1,4 +1,4 @@
-- LOT-No 디스켓 불출 소메뉴 링크 보정
-- 발주파일 생성(구 LOT-No 디스켓 불출) 소메뉴 링크 보정
-- 실행:
-- mysql --default-character-set=utf8mb4 -u ... -p DBNAME < writable/database/menu_link_lot_seed_issue.sql
@@ -8,4 +8,4 @@ SET CHARACTER_SET_CLIENT = utf8mb4;
UPDATE `menu` m
INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site'
SET m.mm_link = 'bag/order/lot-seed'
WHERE m.mm_name = 'LOT-No 디스켓 불출';
WHERE m.mm_name IN ('발주파일 생성', 'LOT-No 디스켓 불출');

View File

@@ -0,0 +1,5 @@
-- 사용자 의견 #35: 메뉴 '전화 접수 관리' → '주문접수관리' 개명
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_rename_phone_to_order.sql
-- 멱등(이미 바뀌었으면 0건).
UPDATE `menu` SET `mm_name` = '주문접수관리' WHERE `mm_name` = '전화 접수 관리';

View File

@@ -27,7 +27,7 @@ SET m.mm_link = CASE m.mm_name
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '발주 등록' THEN 'bag/order/create'
WHEN '발주 변경' THEN 'bag/order/change'
WHEN 'LOT-No 디스켓 불출' THEN 'bag/order/lot-seed'
WHEN '발주파일 생성' THEN 'bag/order/lot-seed'
WHEN '발주 현황' THEN 'bag/bag-orders'
WHEN '발주 입고[스캐너]' THEN 'bag/receiving/create'
WHEN '입고 현황' THEN 'bag/bag-receivings'

View File

@@ -83,7 +83,7 @@ SELECT @mt_site, 1, t.mm_name,
CASE t.mm_name
WHEN '발주 등록' THEN 'bag/order/create'
WHEN '발주 변경' THEN 'bag/order/change'
WHEN 'LOT-No 디스켓 불출' THEN 'bag/order/lot-seed'
WHEN '발주파일 생성' THEN 'bag/order/lot-seed'
WHEN '발주 현황' THEN 'bag/bag-orders'
WHEN '발주 입고[스캐너]' THEN 'bag/receiving/create'
WHEN '입고 현황' THEN 'bag/bag-receivings'
@@ -93,7 +93,7 @@ SELECT @mt_site, 1, t.mm_name,
FROM (
SELECT 0 AS mm_num, '발주 등록' AS mm_name UNION ALL
SELECT 1, '발주 변경' UNION ALL
SELECT 2, 'LOT-No 디스켓 불출' UNION ALL
SELECT 2, '발주파일 생성' UNION ALL
SELECT 3, '발주 현황' UNION ALL
SELECT 4, '발주 입고[스캐너]' UNION ALL
SELECT 5, '일괄입고' UNION ALL
@@ -177,7 +177,7 @@ INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_
SELECT @mt_site, 1, t.mm_name,
CASE t.mm_name
WHEN '전화 접수' THEN 'bag/shop-orders'
WHEN '전화 접수 관리' THEN 'bag/shop-orders'
WHEN '주문접수관리' THEN 'bag/shop-orders'
WHEN '지정 판매소 판매' THEN 'bag/sale/create'
WHEN '지정 판매소 반품' THEN 'bag/bag-sales'
WHEN '지정 판매소 판매 취소' THEN 'bag/bag-sales'
@@ -187,7 +187,7 @@ SELECT @mt_site, 1, t.mm_name,
@parent_sales, 1, t.mm_num, 0, '', 'Y'
FROM (
SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL
SELECT 1, '전화 접수 관리' UNION ALL
SELECT 1, '주문접수관리' UNION ALL
SELECT 2, '지정 판매소 판매' UNION ALL
SELECT 3, '지정 판매소 반품' UNION ALL
SELECT 4, '지정 판매소 판매 취소' UNION ALL

View File

@@ -0,0 +1,11 @@
-- 메뉴명 변경: '일괄입고' → '일괄입고(음식물,폐기물)'
-- 링크(mm_link='bag/receiving/batch') 기준, 기본 이름을 쓰는 항목만 변경.
-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/rename_batch_receiving_menu.sql
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET CHARACTER_SET_CLIENT = utf8mb4;
UPDATE `menu`
SET `mm_name` = '일괄입고(음식물,폐기물)'
WHERE `mm_link` = 'bag/receiving/batch'
AND `mm_name` = '일괄입고';

View File

@@ -0,0 +1,13 @@
-- 메뉴명 변경: 'LOT-No 디스켓 불출' → '발주파일 생성'
-- 링크(mm_link='bag/order/lot-seed') 기준으로, 기본 이름을 쓰는 항목만 안전하게 변경한다.
-- (지자체가 직접 다른 이름으로 바꿔 둔 항목은 건드리지 않음)
-- 실행:
-- mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/rename_lot_seed_menu_to_order_file.sql
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET CHARACTER_SET_CLIENT = utf8mb4;
UPDATE `menu`
SET `mm_name` = '발주파일 생성'
WHERE `mm_link` = 'bag/order/lot-seed'
AND `mm_name` = 'LOT-No 디스켓 불출';

View File

@@ -0,0 +1,18 @@
-- 맞춤 집계표: 계정별 리포트 프리셋(제목·품목구분·컬럼구성) 저장
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/report_preset_table.sql
-- 멱등(IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS `report_preset` (
`rp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`rp_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체',
`rp_mb_idx` INT UNSIGNED NOT NULL COMMENT '소유 계정(mb_idx) — 계정별 저장',
`rp_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '프리셋 이름',
`rp_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '리포트(표) 제목',
`rp_category` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT 'all|envelope|food|waste',
`rp_mode` VARCHAR(10) NOT NULL DEFAULT 'detail' COMMENT 'detail|summary',
`rp_columns` TEXT NULL COMMENT '선택 컬럼 키 JSON 배열',
`rp_regdate` DATETIME NULL,
`rp_updated` DATETIME NULL,
PRIMARY KEY (`rp_idx`),
KEY `idx_rp_owner` (`rp_lg_idx`, `rp_mb_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;