5 Commits

Author SHA1 Message Date
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
16 changed files with 825 additions and 79 deletions

View File

@@ -74,6 +74,7 @@ $routes->post('bag/issue/store', 'Bag::issueStore');
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1'); $routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave'); $routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
$routes->get('bag/order/create', 'Bag::orderCreate'); $routes->get('bag/order/create', 'Bag::orderCreate');
$routes->get('bag/order/detail-json/(:num)', 'Bag::orderDetailJson/$1');
$routes->get('bag/order/phone', 'Bag::phoneOrderCreate'); $routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); $routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); $routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
@@ -155,6 +156,7 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse'); $routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
$routes->get('designated-shops', 'Admin\DesignatedShop::index'); $routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1'); $routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1'); $routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');

View File

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

View File

@@ -14,6 +14,7 @@ class DesignatedShop extends BaseController
private DesignatedShopModel $shopModel; private DesignatedShopModel $shopModel;
private LocalGovernmentModel $lgModel; private LocalGovernmentModel $lgModel;
private Roles $roles; private Roles $roles;
private ?bool $dongColumnExists = null;
public function __construct() public function __construct()
{ {
@@ -223,9 +224,10 @@ class DesignatedShop extends BaseController
/** /**
* @param list<object> $list * @param list<object> $list
* @param array<int, string> $lgMap * @param array<int, string> $lgMap
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
private function buildDesignatedShopDetailPayload(array $list, array $lgMap): array private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
{ {
helper('admin'); helper('admin');
$lgIdx = admin_effective_lg_idx() ?? 0; $lgIdx = admin_effective_lg_idx() ?? 0;
@@ -244,6 +246,18 @@ class DesignatedShop extends BaseController
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지']; $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
$da = $row->ds_designated_at ?? null; $da = $row->ds_designated_at ?? null;
$daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : ''; $daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : '';
$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;
}
$payload[] = [ $payload[] = [
'ds_idx' => (int) $row->ds_idx, 'ds_idx' => (int) $row->ds_idx,
'ds_shop_no' => $sn, 'ds_shop_no' => $sn,
@@ -266,7 +280,10 @@ class DesignatedShop extends BaseController
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''), 'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
'ds_email' => (string) ($row->ds_email ?? ''), 'ds_email' => (string) ($row->ds_email ?? ''),
'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''), 'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''),
'gugun_name' => $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? ''), 'gugun_name' => $gugunName,
'ds_dong_code' => $dongCode,
'dong_name' => $dongName,
'dong_display' => $dongDisplay,
'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'), 'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'),
'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'), 'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
'ds_designated_at' => $daOut, 'ds_designated_at' => $daOut,
@@ -300,8 +317,9 @@ class DesignatedShop extends BaseController
$dsState = $this->request->getGet('ds_state'); $dsState = $this->request->getGet('ds_state');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState); $this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState);
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->paginate(20); // 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
$pager = $this->shopModel->pager; $list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
$pager = null;
// 지자체 이름 매핑용 // 지자체 이름 매핑용
$lgMap = []; $lgMap = [];
@@ -311,7 +329,21 @@ class DesignatedShop extends BaseController
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState); $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState);
$gugunNameMap = $this->gugunCodeNameMap($lgIdx); $gugunNameMap = $this->gugunCodeNameMap($lgIdx);
$detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap);
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
$this->persistDesignatedShopDongCodes($list, $dongMap);
$detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap, $dongMap);
// 리스트(서버 렌더)에서 동코드 표시용: ds_idx => "동코드 (동명)"
$dongDisplayMap = [];
foreach ($dongMap as $idx => $d) {
$code = (string) ($d['code'] ?? '');
$name = (string) ($d['name'] ?? '');
if ($code !== '') {
$dongDisplayMap[$idx] = $code . ($name !== '' ? ' (' . $name . ')' : '');
}
}
// 구군코드 목록 (검색 필터용) // 구군코드 목록 (검색 필터용)
$db = \Config\Database::connect(); $db = \Config\Database::connect();
@@ -327,6 +359,7 @@ class DesignatedShop extends BaseController
'gugunCodes' => $gugunCodes, 'gugunCodes' => $gugunCodes,
'stateCounts' => $stateCounts, 'stateCounts' => $stateCounts,
'gugunNameMap' => $gugunNameMap, 'gugunNameMap' => $gugunNameMap,
'dongDisplayMap' => $dongDisplayMap,
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE), 'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
]; ];
@@ -477,14 +510,25 @@ class DesignatedShop extends BaseController
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll(); $list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
// 동코드(구·군 동코드) 산출
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
$rows = []; $rows = [];
foreach ($list as $row) { foreach ($list as $row) {
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지']; $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
$idxKey = (int) ($row->ds_idx ?? 0);
$dCode = (string) ($dongMap[$idxKey]['code'] ?? '');
$dName = (string) ($dongMap[$idxKey]['name'] ?? '');
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
$da = $row->ds_designated_at ?? null;
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
$rows[] = [ $rows[] = [
$row->ds_idx, $row->ds_idx,
$row->ds_shop_no, $row->ds_shop_no,
$row->ds_name, $row->ds_name,
$row->ds_rep_name, $row->ds_rep_name,
$dongDisp,
$daDisp,
$row->ds_biz_no, $row->ds_biz_no,
$this->designatedShopScalar($row, 'ds_biz_type'), $this->designatedShopScalar($row, 'ds_biz_type'),
$this->designatedShopScalar($row, 'ds_biz_kind'), $this->designatedShopScalar($row, 'ds_biz_kind'),
@@ -504,7 +548,7 @@ class DesignatedShop extends BaseController
export_csv( export_csv(
'지정판매소_' . date('Ymd') . '.csv', '지정판매소_' . date('Ymd') . '.csv',
[ [
'번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종', '번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호', '가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
'변경일자', '변경사유', '상태', '등록일', '변경일자', '변경사유', '상태', '등록일',
], ],
@@ -648,6 +692,8 @@ class DesignatedShop extends BaseController
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'), 'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
'ds_state' => 1, 'ds_state' => 1,
// 동코드(컬럼이 있을 때만 저장)
...($this->designatedShopHasDongColumn() ? ['ds_dong_code' => (string) ($resolvedNo['dong_code'] ?? '')] : []),
'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')), 'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'), 'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'),
'ds_regdate' => date('Y-m-d H:i:s'), 'ds_regdate' => date('Y-m-d H:i:s'),
@@ -770,6 +816,23 @@ class DesignatedShop extends BaseController
$va = $this->resolveVirtualAccountFromRequest(); $va = $this->resolveVirtualAccountFromRequest();
// 주소로 동코드 재산출(컬럼이 있을 때만 저장). 매칭 실패 시 동코드는 갱신하지 않음.
$dongCodeData = [];
if ($this->designatedShopHasDongColumn()) {
$resolvedDong = $this->resolveDesignatedShopNumberFromAddress(
$lgIdx,
$addrSido,
$addrSigungu,
$dsAddr,
$dsAddrJibun,
$dsZip,
$lg
);
if (($resolvedDong['ok'] ?? false) && ($resolvedDong['dong_code'] ?? '') !== '') {
$dongCodeData = ['ds_dong_code' => (string) $resolvedDong['dong_code']];
}
}
$data = [ $data = [
'ds_name' => (string) $this->request->getPost('ds_name'), 'ds_name' => (string) $this->request->getPost('ds_name'),
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'), 'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
@@ -786,6 +849,7 @@ class DesignatedShop extends BaseController
'ds_tel' => (string) $this->request->getPost('ds_tel'), 'ds_tel' => (string) $this->request->getPost('ds_tel'),
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'), 'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
'ds_email' => (string) $this->request->getPost('ds_email'), 'ds_email' => (string) $this->request->getPost('ds_email'),
...$dongCodeData,
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'), 'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'), 'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
@@ -1532,9 +1596,11 @@ class DesignatedShop extends BaseController
usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']); usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
$dCode = null; $dCode = null;
$dName = '';
foreach ($dCandidates as $cand) { foreach ($dCandidates as $cand) {
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) { if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
$dCode = $cand['cd']; $dCode = $cand['cd'];
$dName = $cand['nm'];
break; break;
} }
} }
@@ -1557,9 +1623,203 @@ class DesignatedShop extends BaseController
'ok' => true, 'ok' => true,
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1), 'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
'gugun_code' => $cCode, 'gugun_code' => $cCode,
'dong_code' => $dCode,
'dong_name' => $dName,
]; ];
} }
/**
* [AJAX] 주소 선택 시, 해당 주소의 동 기본코드(D)와 동명을 반환.
* 등록 폼에서 주소 검색 직후 「구·군(동코드)」 표시에 사용한다.
*/
public function resolveAddressCodes()
{
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '권한이 없습니다.']);
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return $this->response->setJSON(['ok' => false, 'error' => '작업할 지자체가 선택되지 않았습니다.']);
}
$lg = $this->lgModel->find($lgIdx);
if ($lg === null) {
return $this->response->setJSON(['ok' => false, 'error' => '지자체 정보를 찾을 수 없습니다.']);
}
$addrSido = (string) $this->request->getPost('addr_search_sido');
$addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
$dsAddr = (string) $this->request->getPost('ds_addr');
$dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
$dsZip = (string) $this->request->getPost('ds_zip');
$resolved = $this->resolveDesignatedShopNumberFromAddress(
$lgIdx,
$addrSido,
$addrSigungu,
$dsAddr,
$dsAddrJibun,
$dsZip,
$lg
);
if (! $resolved['ok']) {
return $this->response->setJSON(['ok' => false, 'error' => $resolved['error']]);
}
return $this->response->setJSON([
'ok' => true,
'dong_code' => $resolved['dong_code'] ?? '',
'dong_name' => $resolved['dong_name'] ?? '',
'gugun_code' => $resolved['gugun_code'] ?? '',
]);
}
/**
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
*/
private function designatedShopHasDongColumn(): bool
{
if ($this->dongColumnExists === null) {
$this->dongColumnExists = \Config\Database::connect()->fieldExists('ds_dong_code', 'designated_shop');
}
return $this->dongColumnExists;
}
/**
* 여러 지정판매소의 동 기본코드(D)를 주소로 일괄 산출한다.
* code_detail(B·C·D) 행을 한 번만 로드해 각 판매소를 매칭한다.
*
* @param list<object> $shops
* @return array<int, array{code: string, name: string}> ds_idx => [동코드, 동명]
*/
private function resolveDongCodesForShops(int $lgIdx, array $shops): array
{
$out = [];
if ($shops === []) {
return $out;
}
$bCk = $this->codeKindIdxByCkCode('B');
$cCk = $this->codeKindIdxByCkCode('C');
$dCk = $this->codeKindIdxByCkCode('D');
if ($bCk === null || $cCk === null || $dCk === null) {
return $out;
}
$detailModel = model(CodeDetailModel::class);
$bRows = $detailModel->getByKind($bCk, true, $lgIdx);
$cRows = $detailModel->getByKind($cCk, true, $lgIdx);
$dRows = $detailModel->getByKind($dCk, true, $lgIdx);
foreach ($shops as $row) {
$idx = (int) ($row->ds_idx ?? 0);
$road = (string) ($row->ds_addr ?? '');
$jibun = (string) ($row->ds_addr_jibun ?? '');
$zip = (string) ($row->ds_zip ?? '');
$blob = trim($road . ' ' . $jibun . ' ' . $zip);
if ($blob === '') {
$out[$idx] = ['code' => '', 'name' => ''];
continue;
}
// B: 시·도
$bCode = null;
foreach ($bRows as $r) {
$nm = trim((string) $r->cd_name);
$cd = trim((string) $r->cd_code);
if ($nm === '' || $cd === '') {
continue;
}
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
$bCode = $cd;
break;
}
}
if ($bCode === null || $bCode === '') {
$out[$idx] = ['code' => '', 'name' => ''];
continue;
}
// C: 구·군
$cCode = null;
foreach ($cRows as $r) {
$cd = trim((string) $r->cd_code);
if ($cd === '' || ! str_starts_with($cd, $bCode)) {
continue;
}
$nm = trim((string) $r->cd_name);
if ($nm === '') {
continue;
}
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
$cCode = $cd;
break;
}
}
if ($cCode === null || $cCode === '') {
$out[$idx] = ['code' => '', 'name' => ''];
continue;
}
// D: 동 — 긴 이름 우선 매칭(예: '노원동1가'가 '노원동'보다 먼저)
$cands = [];
foreach ($dRows as $r) {
$cd = trim((string) $r->cd_code);
if ($cd === '' || ! str_starts_with($cd, $cCode)) {
continue;
}
$nm = trim((string) $r->cd_name);
if ($nm === '') {
continue;
}
$cands[] = ['len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd];
}
usort($cands, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
$dCode = '';
$dName = '';
foreach ($cands as $cand) {
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
$dCode = $cand['cd'];
$dName = $cand['nm'];
break;
}
}
$out[$idx] = ['code' => $dCode, 'name' => $dName];
}
return $out;
}
/**
* 산출된 동코드를 ds_dong_code 컬럼에 저장(값이 다를 때만). 컬럼이 없으면 아무것도 하지 않음.
*
* @param list<object> $shops
* @param array<int, array{code: string, name: string}> $dongMap
*/
private function persistDesignatedShopDongCodes(array $shops, array $dongMap): void
{
if ($dongMap === [] || ! $this->designatedShopHasDongColumn()) {
return;
}
$db = \Config\Database::connect();
foreach ($shops as $row) {
$idx = (int) ($row->ds_idx ?? 0);
if ($idx <= 0 || ! isset($dongMap[$idx])) {
continue;
}
$code = $dongMap[$idx]['code'];
$current = (string) ($row->ds_dong_code ?? '');
if ($code !== '' && $code !== $current) {
$db->table('designated_shop')->where('ds_idx', $idx)->update(['ds_dong_code' => $code]);
$row->ds_dong_code = $code; // 뷰 페이로드에도 반영
}
}
}
/** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */ /** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
private function kakaoJavascriptKey(): string private function kakaoJavascriptKey(): string
{ {

View File

@@ -141,6 +141,10 @@ class SalesReport extends BaseController
} }
} }
// 하단 통계: 현재 필터(카테고리·크기)까지 적용된 결과 기준으로
// ① 용량별(리터별) ② 품목별(봉투/음식물/폐기물) 집계
[$sizeStats, $catStats] = $this->buildLedgerBottomStats($filtered, $sizeOf);
if ($mode === 'daily') { if ($mode === 'daily') {
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee); $built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
} else { } else {
@@ -235,6 +239,8 @@ class SalesReport extends BaseController
'cats' => $cats, 'cats' => $cats,
'sizeGroups' => $sizeGroups, 'sizeGroups' => $sizeGroups,
'size' => $size, 'size' => $size,
'sizeStats' => $sizeStats,
'catStats' => $catStats,
'shops' => $shops, 'shops' => $shops,
'agencies' => $agencies, 'agencies' => $agencies,
'lgName' => $lgName, 'lgName' => $lgName,
@@ -286,6 +292,68 @@ class SalesReport extends BaseController
return $builder->get()->getResult(); return $builder->get()->getResult();
} }
/**
* 판매대장 하단 통계: 용량별(리터별)·품목별(봉투/음식물/폐기물) 집계.
*
* @param list<object> $rows 필터가 적용된 상세 판매행
* @param callable $sizeOf 품목명 → 크기 토큰(예: '10L', '1000원', '')
* @return array{0: list<array{label:string,qty:int,amount:int}>, 1: list<array{label:string,qty:int,amount:int}>}
*/
private function buildLedgerBottomStats(array $rows, callable $sizeOf): array
{
$bySize = []; // 크기토큰 => [qty, amount]
$byCat = []; // '봉투'|'음식물'|'폐기물' => [qty, amount]
foreach ($rows as $row) {
$name = (string) ($row->bs_bag_name ?? '');
$qty = (int) ($row->line_qty ?? 0);
$amt = (float) ($row->line_amount ?? 0);
// ① 용량별(리터별) — 크기 토큰이 없으면 '기타'
$sz = (string) $sizeOf($name);
if ($sz === '') {
$sz = '기타';
}
$bySize[$sz]['qty'] = ($bySize[$sz]['qty'] ?? 0) + $qty;
$bySize[$sz]['amount'] = ($bySize[$sz]['amount'] ?? 0) + $amt;
// ② 품목별 — 봉투/음식물/폐기물 3분류
if (mb_strpos($name, '음식물') !== false) {
$cat = '음식물';
} elseif (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
$cat = '폐기물';
} else {
$cat = '봉투';
}
$byCat[$cat]['qty'] = ($byCat[$cat]['qty'] ?? 0) + $qty;
$byCat[$cat]['amount'] = ($byCat[$cat]['amount'] ?? 0) + $amt;
}
// 용량별: 숫자(리터/금액) 오름차순, '기타'는 맨 뒤
$sizeStats = [];
foreach ($bySize as $label => $v) {
$sizeStats[] = ['label' => (string) $label, 'qty' => (int) $v['qty'], 'amount' => (int) round($v['amount'])];
}
usort($sizeStats, static function (array $a, array $b): int {
$ao = $a['label'] === '기타' ? 1 : 0;
$bo = $b['label'] === '기타' ? 1 : 0;
if ($ao !== $bo) {
return $ao <=> $bo;
}
return (int) preg_replace('/\D/', '', $a['label']) <=> (int) preg_replace('/\D/', '', $b['label']);
});
// 품목별: 봉투 → 음식물 → 폐기물 고정 순서
$catStats = [];
foreach (['봉투', '음식물', '폐기물'] as $c) {
if (isset($byCat[$c])) {
$catStats[] = ['label' => $c, 'qty' => (int) $byCat[$c]['qty'], 'amount' => (int) round($byCat[$c]['amount'])];
}
}
return [$sizeStats, $catStats];
}
/** /**
* @param list<string> $cats 빈 배열이면 전체 * @param list<string> $cats 빈 배열이면 전체
*/ */

View File

@@ -3873,6 +3873,66 @@ SQL);
return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.'); return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.');
} }
/**
* [AJAX] 발주 1건의 내역(헤더+품목)을 JSON으로 반환 — 발주 이력 "보기" 모달·삭제 화면 상세용.
*/
public function orderDetailJson(int $id)
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
}
$order = model(BagOrderModel::class)->find($id);
if ($order === null || (int) $order->bo_lg_idx !== (int) $lgIdx) {
return $this->response->setStatusCode(404)->setJSON(['ok' => false, 'error' => '발주를 찾을 수 없습니다.']);
}
$companyName = '';
if ($order->bo_company_idx) {
$c = model(CompanyModel::class)->find($order->bo_company_idx);
$companyName = $c ? (string) $c->cp_name : '';
}
$agencyName = '';
if ($order->bo_agency_idx) {
$a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
if ($a) {
$agencyName = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? '');
}
}
$managerName = '';
if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order') && ($order->bo_manager_idx ?? null)) {
$mg = model(ManagerModel::class)->find($order->bo_manager_idx);
$managerName = $mg ? (string) $mg->mg_name : '';
}
$items = [];
foreach (model(BagOrderItemModel::class)->where('boi_bo_idx', $id)->orderBy('boi_idx', 'ASC')->findAll() as $it) {
$items[] = [
'bag_code' => (string) ($it->boi_bag_code ?? ''),
'bag_name' => (string) ($it->boi_bag_name ?? ''),
'unit_price' => (float) ($it->boi_unit_price ?? 0),
'qty_box' => (int) ($it->boi_qty_box ?? 0),
'qty_sheet' => (int) ($it->boi_qty_sheet ?? 0),
'amount' => (float) ($it->boi_amount ?? 0),
];
}
return $this->response->setJSON([
'ok' => true,
'bo_idx' => (int) $order->bo_idx,
'bo_lot_no' => (string) ($order->bo_lot_no ?? ''),
'bo_version' => (int) ($order->bo_version ?? 1),
'order_date' => (string) ($order->bo_order_date ?? ''),
'fee_rate' => (float) ($order->bo_fee_rate ?? 0),
'status' => (string) ($order->bo_status ?? ''),
'company' => $companyName,
'agency' => $agencyName,
'manager' => $managerName,
'items' => $items,
]);
}
// --- 발주 등록 --- // --- 발주 등록 ---
public function orderCreate(): string public function orderCreate(): string
{ {
@@ -3885,6 +3945,7 @@ SQL);
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll() ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
: []; : [];
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : []; $agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
$managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : []; $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
@@ -3941,6 +4002,7 @@ SQL);
'companies', 'companies',
'associations', 'associations',
'agencies', 'agencies',
'managers',
'bagCodes', 'bagCodes',
'recentOrders', 'recentOrders',
'companyMap', 'companyMap',
@@ -4402,6 +4464,7 @@ SQL);
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll() ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
: []; : [];
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : []; $agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
$managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : []; $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
@@ -4492,6 +4555,7 @@ SQL);
'bo_association_idx' => (string) ($target->bo_association_idx ?? ''), 'bo_association_idx' => (string) ($target->bo_association_idx ?? ''),
'bo_company_idx' => (string) ($target->bo_company_idx ?? ''), 'bo_company_idx' => (string) ($target->bo_company_idx ?? ''),
'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''), 'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''),
'bo_manager_idx' => (string) ($target->bo_manager_idx ?? ''),
'item_bag_code' => $itemCodes, 'item_bag_code' => $itemCodes,
'item_qty_box' => $itemQtyBoxes, 'item_qty_box' => $itemQtyBoxes,
'item_qty_sheet' => $itemQtySheets, 'item_qty_sheet' => $itemQtySheets,
@@ -4504,6 +4568,7 @@ SQL);
'companies', 'companies',
'associations', 'associations',
'agencies', 'agencies',
'managers',
'bagCodes', 'bagCodes',
'recentOrders', 'recentOrders',
'companyMap', 'companyMap',
@@ -4589,16 +4654,22 @@ SQL);
} }
$month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7); $month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7);
// 삭제 후 돌아갈 화면: 발주 등록에서 삭제한 경우 등록 화면으로 복귀
$returnTo = (string) ($this->request->getPost('return_to') ?? '');
$redirectUrl = $returnTo === 'create'
? site_url('bag/order/create')
: site_url('bag/order/change?month=' . $month);
$admin = new \App\Controllers\Admin\BagOrder(); $admin = new \App\Controllers\Admin\BagOrder();
$admin->initController($this->request, $this->response, service('logger')); $admin->initController($this->request, $this->response, service('logger'));
$response = $admin->delete($id); $response = $admin->delete($id);
if ($response instanceof RedirectResponse) { if ($response instanceof RedirectResponse) {
$msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.'; $msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.';
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', $msg); return redirect()->to($redirectUrl)->with('success', $msg);
} }
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', '처리되었습니다.'); return redirect()->to($redirectUrl)->with('success', '처리되었습니다.');
} }
public function orderCancel(int $id) public function orderCancel(int $id)

View File

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

View File

@@ -33,6 +33,7 @@ class DesignatedShopModel extends Model
'ds_rep_phone', 'ds_rep_phone',
'ds_email', 'ds_email',
'ds_gugun_code', 'ds_gugun_code',
'ds_dong_code',
'ds_zone_code', 'ds_zone_code',
'ds_branch_no', 'ds_branch_no',
'ds_designated_at', 'ds_designated_at',

View File

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

View File

@@ -191,10 +191,8 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span> <span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a> <a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<?php endif; ?>
<?php if (! $readOnly): ?> <?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a> <a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
<?php endif; ?> <?php endif; ?>
@@ -245,6 +243,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 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="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="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> <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> </tr>
</thead> </thead>
@@ -275,16 +276,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
if ($addrCombinedList === '') { if ($addrCombinedList === '') {
$addrCombinedList = $addrMainList; $addrCombinedList = $addrMainList;
} }
// 구·군(동코드) 표시: 동코드가 있으면 "동코드 (동명)", 없으면 구·군명
$dongDisp = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabel);
?> ?>
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60" <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-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-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($row->ds_rep_name ?? '', 'attr') ?>"
data-sort-name="<?= esc($row->ds_name ?? '', '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 ?>"> data-sort-state="<?= (int) $st ?>">
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td> <td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td> <td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="py-2.5 px-2 text-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"> <td class="py-2.5 px-2 text-left">
<?php if ($st === 1): ?> <?php if ($st === 1): ?>
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span> <span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span>
@@ -570,7 +579,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr> <tr>
<th class="text-center">번호</th> <th class="text-center">번호</th>
<th>지자체</th> <th>지자체</th>
<th>구·군</th> <th>구·군(동코드)</th>
<th class="text-center">지정일</th> <th class="text-center">지정일</th>
<th>구역</th> <th>구역</th>
<th>대표자명</th> <th>대표자명</th>
@@ -613,8 +622,12 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr> <tr>
<td class="text-center"><?= esc($shortNoP) ?></td> <td class="text-center"><?= esc($shortNoP) ?></td>
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td> <td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?> <?php
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td> $gCodeP = (string) ($row->ds_gugun_code ?? '');
$ggLabelP = (string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP);
$dongDispP = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabelP);
?>
<td class="text-left"><?= esc($dongDispP) ?></td>
<td class="text-center"><?= esc($daDispP) ?></td> <td class="text-center"><?= esc($daDispP) ?></td>
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td> <td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td> <td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>

View File

@@ -3,6 +3,16 @@ declare(strict_types=1);
/** @var object $row */ /** @var object $row */
use App\Models\FeedbackModel; 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"> <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> <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> <a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline">목록으로</a>
@@ -62,3 +72,4 @@ use App\Models\FeedbackModel;
</div> </div>
</section> </section>
<?php endif; ?> <?php endif; ?>
</div><!-- /.fb-selectable -->

View File

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

View File

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

View File

@@ -179,6 +179,19 @@
(() => { (() => {
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>; const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o])); const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
// 표시용 접수번호: 접수일(so_order_date)별로 매일 1번부터 순차 부여(그 날의 가장 오래된 건 = 1).
// 정렬과 무관하게 고정.
const dateKey = (o) => String(o && o.so_order_date ? o.so_order_date : '').slice(0, 10);
const displayNoMap = new Map();
(() => {
const perDate = {};
orders.slice().sort((a, b) => Number(a.so_idx || 0) - Number(b.so_idx || 0)).forEach((o) => {
const k = dateKey(o);
perDate[k] = (perDate[k] || 0) + 1;
displayNoMap.set(String(o.so_idx), perDate[k]);
});
})();
const displayNo = (o) => displayNoMap.get(String(o && o.so_idx)) || '';
// 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신) // 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신)
// base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전 // base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전
@@ -212,7 +225,7 @@
function setHeader(order) { function setHeader(order) {
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; }; 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-code', order ? order.ds_shop_no : '');
t('detail-shop-name', order ? order.so_ds_name : ''); t('detail-shop-name', order ? order.so_ds_name : '');
t('detail-shop-rep', order ? order.ds_rep_name : ''); t('detail-shop-rep', order ? order.ds_rep_name : '');
@@ -604,7 +617,7 @@
const channel = (o.so_channel && o.so_channel !== 'phone') ? o.so_channel : '전화'; const channel = (o.so_channel && o.so_channel !== 'phone') ? o.so_channel : '전화';
const packed = Number(o.so_received) === 1; 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}"> 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(o.so_order_date)}</td>
<td class="text-center">${esc(dDate)}</td> <td class="text-center">${esc(dDate)}</td>
<td class="text-center">${esc(channel)}</td> <td class="text-center">${esc(channel)}</td>

View File

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

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;