6 Commits

Author SHA1 Message Date
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
17 changed files with 562 additions and 63 deletions

View File

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

View File

@@ -83,6 +83,7 @@ $routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
$routes->get('bag/order/change', 'Bag::orderChange'); $routes->get('bag/order/change', 'Bag::orderChange');
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1'); $routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed'); $routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
$routes->get('bag/order/lot-seed/preview', 'Bag::orderLotSeedPreview');
$routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate'); $routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate');
$routes->post('bag/order/store', 'Bag::orderStore'); $routes->post('bag/order/store', 'Bag::orderStore');
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1'); $routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');

View File

@@ -256,8 +256,31 @@ class Menu extends BaseController
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.'); ->with('error', '지자체를 선택하세요.');
} }
$ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_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)) { if (! is_array($ids) || empty($ids)) {
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.'); return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
} }

View File

@@ -3953,7 +3953,7 @@ SQL);
} }
/** /**
* LOT-No 디스켓 불출: 발주 건을 선택해 암호화 seed 파일 생성/다운로드. * 발주파일 생성(구 'LOT-No 디스켓 불출'): 발주 건을 선택해 암호화 seed 파일 생성/다운로드.
*/ */
public function orderLotSeed(): string|RedirectResponse public function orderLotSeed(): string|RedirectResponse
{ {
@@ -4031,7 +4031,7 @@ SQL);
} }
} }
return $this->render('LOT-No 디스켓 불출', 'bag/order_lot_seed', [ return $this->render('발주파일 생성', 'bag/order_lot_seed', [
'orders' => $orders, 'orders' => $orders,
'pager' => $pager, 'pager' => $pager,
'startMonth' => $startMonth, 'startMonth' => $startMonth,
@@ -4044,17 +4044,16 @@ SQL);
]); ]);
} }
public function orderLotSeedGenerate(): RedirectResponse|ResponseInterface /**
* 발주파일(seed) 생성·미리보기가 공용으로 쓰는 데이터 구성.
* 발주 메타(order_data)와 품목(items, 팩/박스 환산 포함), 무결성 해시를 만든다.
*
* @return array{ok: bool, error?: string, order?: object, order_data?: array<string,mixed>, items?: array<int,array<string,mixed>>, lot_no?: string, uuid?: string, version?: int, order_hash?: string}
*/
private function buildLotSeedData(int $lgIdx, int $boIdx): array
{ {
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$boIdx = (int) ($this->request->getPost('bo_idx') ?? 0);
if ($boIdx <= 0) { if ($boIdx <= 0) {
return redirect()->back()->with('error', '발주 건을 선택해 주세요.'); return ['ok' => false, 'error' => '발주 건을 선택해 주세요.'];
} }
$order = model(BagOrderModel::class) $order = model(BagOrderModel::class)
@@ -4062,14 +4061,14 @@ SQL);
->where('bo_idx', $boIdx) ->where('bo_idx', $boIdx)
->first(); ->first();
if (! $order) { if (! $order) {
return redirect()->back()->with('error', '발주 정보를 찾을 수 없습니다.'); return ['ok' => false, 'error' => '발주 정보를 찾을 수 없습니다.'];
} }
$lotNo = trim((string) ($order->bo_lot_no ?? '')); $lotNo = trim((string) ($order->bo_lot_no ?? ''));
$uuid = trim((string) ($order->bo_uuid ?? '')); $uuid = trim((string) ($order->bo_uuid ?? ''));
$version = max(1, (int) ($order->bo_version ?? 1)); $version = max(1, (int) ($order->bo_version ?? 1));
if ($lotNo === '' || $uuid === '') { if ($lotNo === '' || $uuid === '') {
return redirect()->back()->with('error', '발주의 LOT/UUID 정보가 없어 seed 파일을 생성할 수 없습니다.'); return ['ok' => false, 'error' => '발주의 LOT/UUID 정보가 없어 발주파일을 생성할 수 없습니다.'];
} }
$items = model(BagOrderItemModel::class) $items = model(BagOrderItemModel::class)
@@ -4077,7 +4076,7 @@ SQL);
->orderBy('boi_idx', 'ASC') ->orderBy('boi_idx', 'ASC')
->findAll(); ->findAll();
if ($items === []) { if ($items === []) {
return redirect()->back()->with('error', '발주 품목이 없어 seed 파일을 생성할 수 없습니다.'); return ['ok' => false, 'error' => '발주 품목이 없어 발주파일을 생성할 수 없습니다.'];
} }
$unitRows = model(PackagingUnitModel::class) $unitRows = model(PackagingUnitModel::class)
@@ -4142,10 +4141,100 @@ SQL);
$orderHash = hash('sha256', $payloadJson); $orderHash = hash('sha256', $payloadJson);
} }
$seedPath = $this->generateLotSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash); return [
'ok' => true,
'order' => $order,
'order_data' => $orderData,
'items' => $hashItems,
'lot_no' => $lotNo,
'uuid' => $uuid,
'version' => $version,
'order_hash' => $orderHash,
];
}
/**
* 발주파일 생성 전 '내용 확인'(미리보기): 암호화 전 평문 내용을 JSON 으로 반환.
*/
public function orderLotSeedPreview(): ResponseInterface
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return $this->response->setJSON(['ok' => false, 'error' => '지자체를 선택해 주세요.']);
}
$boIdx = (int) ($this->request->getGet('bo_idx') ?? 0);
$data = $this->buildLotSeedData($lgIdx, $boIdx);
if (! ($data['ok'] ?? false)) {
return $this->response->setJSON(['ok' => false, 'error' => (string) ($data['error'] ?? '내용을 확인할 수 없습니다.')]);
}
$order = $data['order'];
$companyIdx = (int) ($order->bo_company_idx ?? 0);
$companyName = '';
if ($companyIdx > 0) {
$company = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_idx', $companyIdx)
->first();
$companyName = (string) ($company->cp_name ?? '');
}
$items = array_map(static fn (array $it): array => [
'bag_code' => (string) $it['boi_bag_code'],
'bag_name' => (string) $it['boi_bag_name'],
'unit_price' => (float) $it['boi_unit_price'],
'qty_box' => (int) $it['boi_qty_box'],
'qty_pack' => (int) $it['boi_qty_pack'],
'qty_sheet' => (int) $it['boi_qty_sheet'],
'amount' => (float) $it['boi_amount'],
], $data['items']);
return $this->response->setJSON([
'ok' => true,
'bo_idx' => $boIdx,
'lot_no' => $data['lot_no'],
'uuid' => $data['uuid'],
'version' => $data['version'],
'order_date' => (string) ($order->bo_order_date ?? ''),
'company' => $companyName,
'order_hash' => $data['order_hash'],
'items' => $items,
'totals' => [
'line_count' => count($items),
'qty_box' => array_sum(array_map(static fn ($i) => (int) $i['qty_box'], $items)),
'qty_sheet' => array_sum(array_map(static fn ($i) => (int) $i['qty_sheet'], $items)),
'amount' => array_sum(array_map(static fn ($i) => (float) $i['amount'], $items)),
],
]);
}
public function orderLotSeedGenerate(): RedirectResponse|ResponseInterface
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$boIdx = (int) ($this->request->getPost('bo_idx') ?? 0);
$data = $this->buildLotSeedData($lgIdx, $boIdx);
if (! ($data['ok'] ?? false)) {
return redirect()->back()->with('error', (string) ($data['error'] ?? '발주파일을 생성할 수 없습니다.'));
}
$seedPath = $this->generateLotSeedFile(
$data['uuid'],
$data['version'],
$data['lot_no'],
$data['order_data'],
$data['items'],
$data['order_hash']
);
$seedBinary = @file_get_contents($seedPath); $seedBinary = @file_get_contents($seedPath);
if (! is_string($seedBinary) || $seedBinary === '') { if (! is_string($seedBinary) || $seedBinary === '') {
return redirect()->back()->with('error', 'seed 파일 생성에는 성공했으나 파일을 읽을 수 없습니다.'); return redirect()->back()->with('error', '발주파일 생성에는 성공했으나 파일을 읽을 수 없습니다.');
} }
return $this->response return $this->response

View File

@@ -67,14 +67,17 @@
--- ---
## LOT-No 디스켓 불출 · *발주 입고 관리 LOT-No 디스켓 불출* ## 발주파일 생성 · *발주 입고 관리 발주파일 생성*
발주별 **LOT 번호 시드(seed) 데이터를 파일로 생성**하는 화면입니다. (바코드·LOT 부여에 쓰는 디스켓/파일 형태 데이터) 선택한 발주 건의 내용을 **봉투 생산기(바코드 인쇄) 전달용 파일**로 만드는 화면입니다. (예전 이름: *LOT-No 디스켓 불출*) 생성되는 파일은 발주 정보와 품목이 담긴 **암호화된 발주파일**(`.seed.json`)이며, 봉투 생산 장비가 이 파일을 읽어 그 발주의 바코드를 인쇄합니다.
**LOT-No란?** 발주를 등록할 때 그 건에 자동 부여되는 **6자리 배치(묶음) 번호**입니다. 이후 그 발주로 인쇄되는 모든 봉투 바코드의 앞자리(예: `ZLCH2M-000006-…`)가 되어, 어느 발주에서 나온 봉투인지 구분하는 기준이 됩니다.
**필터**: 시작월 · 종료월 · LOT-No · 제작업체. **필터**: 시작월 · 종료월 · LOT-No · 제작업체.
**순서** **순서**
1. 기간·조건으로 발주 목록을 조회합니다. 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

@@ -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 public static function statusLabel(string $s): string
{ {
return [ return [
'new' => '접수', 'new' => '접수',
'in_progress' => '처리중', 'in_progress' => '처리중',
'answered' => '답변 완료',
'done' => '완료', 'done' => '완료',
'hold' => '보류', 'hold' => '보류',
][$s] ?? $s; ][$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 결정 (동일 지자체·메뉴종류·부모·깊이 기준) * 추가 시 같은 레벨에서 mm_num 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
*/ */

View File

@@ -14,6 +14,7 @@ $badge = static function (string $s): string {
$map = [ $map = [
'new' => 'bg-blue-50 text-blue-700', 'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700', 'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-teal-50 text-teal-700',
'done' => 'bg-emerald-50 text-emerald-700', 'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500', 'hold' => 'bg-gray-100 text-gray-500',
]; ];

View File

@@ -12,6 +12,7 @@ $badge = static function (string $s): string {
$map = [ $map = [
'new' => 'bg-blue-50 text-blue-700', 'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700', 'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-teal-50 text-teal-700',
'done' => 'bg-emerald-50 text-emerald-700', 'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500', 'hold' => 'bg-gray-100 text-gray-500',
]; ];

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') ?>"> <form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/> <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"> <table class="data-table w-full">
<thead> <thead>
<tr> <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 ?>"> <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"> <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>
<td class="text-center"> <td class="text-center">
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/> <input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
@@ -444,11 +445,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() { const clearDragState = function() {
if (rafId) { if (rafId) {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
rafId = null; rafId = null;
} }
if (autoScrollRaf) {
window.cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
document.body.classList.remove('menu-row-dragging'); document.body.classList.remove('menu-row-dragging');
window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('mouseup', onMouseUp);
@@ -463,41 +496,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
draggingActive = false; 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() { var onMouseUp = function() {
if (!draggingActive || !draggingRow || !placeholderRow) { if (!draggingActive || !draggingRow || !placeholderRow) {
clearDragState(); clearDragState();
return; return;
} }
var prevRow = placeholderRow.previousElementSibling;
tbody.insertBefore(draggingRow, placeholderRow); tbody.insertBefore(draggingRow, placeholderRow);
refreshOrderNos(); refreshOrderNos();
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10); var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
if (draggedDep > 0) { if (draggedDep > 0) {
var valid = false; // 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
if (prevRow && prevRow.classList.contains('menu-row')) { var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10); if (newPidx <= 0) {
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10); alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
if (prevRow === draggingRow) {
valid = true;
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
valid = true;
}
}
if (!valid) {
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
restoreOrderByIds(originalOrderIds); restoreOrderByIds(originalOrderIds);
clearDragState(); clearDragState();
return; return;
} }
draggingRow.dataset.mmPidx = String(newPidx);
} }
const approved = window.confirm('변경한 순서를 적용할까요?'); const approved = window.confirm('변경한 순서를 적용할까요?');
if (!approved) { if (!approved) {
// 취소 시 원래 순서·상위로 되돌림
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
}
restoreOrderByIds(originalOrderIds); restoreOrderByIds(originalOrderIds);
} else { } else {
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
moveForm.submit(); moveForm.submit();
} }
clearDragState(); clearDragState();
@@ -511,6 +579,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
e.preventDefault(); e.preventDefault();
originalOrderIds = collectCurrentOrderIds(); originalOrderIds = collectCurrentOrderIds();
draggingRow = row; draggingRow = row;
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
placeholderRow = makePlaceholder(); placeholderRow = makePlaceholder();
draggingActive = true; draggingActive = true;
row.classList.add('dragging'); row.classList.add('dragging');
@@ -518,6 +588,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
tbody.insertBefore(placeholderRow, row.nextSibling); tbody.insertBefore(placeholderRow, row.nextSibling);
lastClientY = e.clientY; lastClientY = e.clientY;
updatePlaceholderPosition(); updatePlaceholderPosition();
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
window.addEventListener('mousemove', onMouseMove, { passive: true }); window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
}); });

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"> <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>
<section class="border border-gray-300 rounded-lg p-2 mt-2 bg-white"> <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> <tr>
<td class="text-center"> <td class="text-center">
<form method="post" action="<?= base_url('bag/order/lot-seed/generate') ?>" class="inline"> <button type="button"
<?= csrf_field() ?> class="lot-preview-btn text-blue-600 hover:underline text-xs"
<input type="hidden" name="bo_idx" value="<?= $boIdx ?>" /> data-bo-idx="<?= $boIdx ?>"
<button type="submit" class="text-blue-600 hover:underline text-xs">seed 생성</button> data-lot="<?= esc((string) ($order->bo_lot_no ?? ''), 'attr') ?>">
</form> 내용 확인
</button>
</td> </td>
<td class="text-center"><?= esc((string) ($order->bo_order_date ?? '')) ?></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> <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)): ?> <?php if (isset($pager)): ?>
<div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div> <div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?> <?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

@@ -15,8 +15,8 @@
</form> </form>
</section> </section>
<div class="grid grid-cols-1 xl:grid-cols-9 gap-3 mt-2"> <div id="order-split" class="flex flex-col xl:flex-row gap-3 mt-2 items-stretch">
<section class="xl:col-span-5 border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col"> <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 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"> <div id="list-scroll" class="overflow-auto">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
@@ -43,7 +43,12 @@
</div> </div>
</section> </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> <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"> <form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
@@ -119,15 +124,15 @@
<span class="font-semibold text-gray-700">포장 명세</span> <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="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
</div> </div>
<div class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]"> <div id="packing-scroll" class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
<table class="w-full data-table text-sm"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <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-10 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-40 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="text-left 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="w-16 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> <th class="w-16 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr> </tr>
</thead> </thead>
<tbody id="packing-body"> <tbody id="packing-body">
@@ -446,6 +451,9 @@
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신 // 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
if (String(selectedId) === String(data.so_idx)) { if (String(selectedId) === String(data.so_idx)) {
renderPacking(order); renderPacking(order);
// 새로 스캔한 봉투가 항상 맨 위(최신)에 보이도록 스크롤을 맨 위로 이동
const packingScrollEl = document.getElementById('packing-scroll');
if (packingScrollEl) packingScrollEl.scrollTop = 0;
const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]'); const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]');
if (cell) { if (cell) {
cell.textContent = nf(Number(data.packed_qty || 0)); cell.textContent = nf(Number(data.packed_qty || 0));
@@ -491,6 +499,74 @@
new ResizeObserver(function () { syncListHeight(); }).observe(detailCardEl); new ResizeObserver(function () { syncListHeight(); }).observe(detailCardEl);
} }
// ── 접수 리스트 ↔ 지정판매소 정보 사이 너비 조절(드래그 스플리터) ──────
// 손잡이를 접수 리스트 쪽(왼쪽)으로 끌면 리스트 폭이 줄고 지정판매소 정보 폭이 넓어진다.
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());
}
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ────────── // ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ──────────
const packingBody = document.getElementById('packing-body'); const packingBody = document.getElementById('packing-body');
const packingTotal = document.getElementById('packing-total'); const packingTotal = document.getElementById('packing-total');
@@ -508,10 +584,10 @@
const qty = parseInt(s.qty || 0, 10) || 0; const qty = parseInt(s.qty || 0, 10) || 0;
totalQty += qty; totalQty += qty;
return '<tr><td class="text-center">' + no + '</td>' return '<tr><td class="text-center">' + no + '</td>'
+ '<td class="text-left pl-2">' + packEsc(code) + ' ' + packEsc(name) + '</td>' + '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-center font-mono text-gray-600">' + packEsc(s.code || '') + '</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-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('') packingBody.innerHTML = rows.length ? rows.join('')
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>'; : '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';

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 -- 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 UPDATE `menu` m
INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site' 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' SET m.mm_link = 'bag/order/lot-seed'
WHERE m.mm_name = 'LOT-No 디스켓 불출'; WHERE m.mm_name IN ('발주파일 생성', 'LOT-No 디스켓 불출');

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/designated-shops/district-new-cancel'
WHEN '발주 등록' THEN 'bag/order/create' WHEN '발주 등록' THEN 'bag/order/create'
WHEN '발주 변경' THEN 'bag/order/change' 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/bag-orders'
WHEN '발주 입고[스캐너]' THEN 'bag/receiving/create' WHEN '발주 입고[스캐너]' THEN 'bag/receiving/create'
WHEN '입고 현황' THEN 'bag/bag-receivings' WHEN '입고 현황' THEN 'bag/bag-receivings'

View File

@@ -83,7 +83,7 @@ SELECT @mt_site, 1, t.mm_name,
CASE t.mm_name CASE t.mm_name
WHEN '발주 등록' THEN 'bag/order/create' WHEN '발주 등록' THEN 'bag/order/create'
WHEN '발주 변경' THEN 'bag/order/change' 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/bag-orders'
WHEN '발주 입고[스캐너]' THEN 'bag/receiving/create' WHEN '발주 입고[스캐너]' THEN 'bag/receiving/create'
WHEN '입고 현황' THEN 'bag/bag-receivings' WHEN '입고 현황' THEN 'bag/bag-receivings'
@@ -93,7 +93,7 @@ SELECT @mt_site, 1, t.mm_name,
FROM ( FROM (
SELECT 0 AS mm_num, '발주 등록' AS mm_name UNION ALL SELECT 0 AS mm_num, '발주 등록' AS mm_name UNION ALL
SELECT 1, '발주 변경' UNION ALL SELECT 1, '발주 변경' UNION ALL
SELECT 2, 'LOT-No 디스켓 불출' UNION ALL SELECT 2, '발주파일 생성' UNION ALL
SELECT 3, '발주 현황' UNION ALL SELECT 3, '발주 현황' UNION ALL
SELECT 4, '발주 입고[스캐너]' UNION ALL SELECT 4, '발주 입고[스캐너]' UNION ALL
SELECT 5, '일괄입고' UNION ALL SELECT 5, '일괄입고' UNION ALL

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 디스켓 불출';