Compare commits
5 Commits
03c2bbc05c
...
794d34635b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
794d34635b | ||
|
|
e9f1cf2b49 | ||
|
|
3a611972c1 | ||
|
|
9365848f9d | ||
|
|
65582c3439 |
@@ -211,6 +211,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
||||
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
|
||||
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
|
||||
|
||||
$routes->get('reports/custom', 'Admin\SalesReport::customReport');
|
||||
$routes->post('reports/custom/preset/save', 'Admin\SalesReport::customReportPresetSave');
|
||||
$routes->post('reports/custom/preset/delete', 'Admin\SalesReport::customReportPresetDelete');
|
||||
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
|
||||
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
|
||||
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
|
||||
@@ -255,6 +258,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
||||
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
|
||||
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');
|
||||
$routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1');
|
||||
$routes->post('feedback/(:num)/content', 'Admin\Feedback::updateContent/$1');
|
||||
$routes->get('/', 'Admin\Dashboard::index');
|
||||
$routes->get('users', 'Admin\User::index');
|
||||
$routes->get('users/create', 'Admin\User::create');
|
||||
|
||||
@@ -142,6 +142,30 @@ class Feedback extends BaseController
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
|
||||
}
|
||||
|
||||
/** 문의 글(본문) 수정 */
|
||||
public function updateContent(int $id): ResponseInterface
|
||||
{
|
||||
$model = model(FeedbackModel::class);
|
||||
$row = $model->find($id);
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
|
||||
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$content = trim((string) $this->request->getPost('fb_content'));
|
||||
if ($content === '') {
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('error', '내용을 입력해 주세요.');
|
||||
}
|
||||
$model->update($id, ['fb_content' => mb_substr($content, 0, 2000)]);
|
||||
|
||||
$returnTo = (string) $this->request->getPost('return_to');
|
||||
if ($returnTo === 'all') {
|
||||
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '문의 내용을 수정했습니다.');
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '문의 내용을 수정했습니다.');
|
||||
}
|
||||
|
||||
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
|
||||
public function image(int $id): ResponseInterface
|
||||
{
|
||||
|
||||
@@ -210,7 +210,41 @@ class Menu extends BaseController
|
||||
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
|
||||
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
|
||||
];
|
||||
|
||||
// 위치(상위↔하위) 변경 지원: mm_pidx 가 바뀌면 상위/하위로 이동
|
||||
$oldPidx = (int) $row->mm_pidx;
|
||||
$newPidx = (int) $this->request->getPost('mm_pidx');
|
||||
$newDep = $newPidx > 0 ? 1 : 0;
|
||||
$positionChanged = $newPidx !== $oldPidx;
|
||||
if ($positionChanged) {
|
||||
if ($newPidx === $id) {
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('error', '자기 자신을 상위 메뉴로 지정할 수 없습니다.');
|
||||
}
|
||||
// 하위 메뉴를 보유한 상위 메뉴는 하위로 변경 불가(2단 구조 유지)
|
||||
$childCnt = $this->menuModel->where('mt_idx', (int) $row->mt_idx)->where('lg_idx', $lgIdx)->where('mm_pidx', $id)->countAllResults();
|
||||
if ($newPidx > 0 && $childCnt > 0) {
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('error', '하위 메뉴가 있는 상위 메뉴는 하위로 변경할 수 없습니다. 먼저 하위 메뉴를 옮겨 주세요.');
|
||||
}
|
||||
if ($newPidx > 0) {
|
||||
$parent = $this->menuModel->find($newPidx);
|
||||
if (! $parent || (int) $parent->lg_idx !== $lgIdx || (int) $parent->mt_idx !== (int) $row->mt_idx || (int) $parent->mm_dep !== 0) {
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('error', '올바른 상위 메뉴를 선택해 주세요.');
|
||||
}
|
||||
}
|
||||
$data['mm_pidx'] = $newPidx;
|
||||
$data['mm_dep'] = $newDep;
|
||||
$data['mm_num'] = $this->menuModel->getNextNum((int) $row->mt_idx, $lgIdx, $newPidx, $newDep);
|
||||
}
|
||||
|
||||
$this->menuModel->update($id, $data);
|
||||
if ($positionChanged) {
|
||||
if ($oldPidx > 0) {
|
||||
$this->menuModel->updateCnode($oldPidx, -1);
|
||||
}
|
||||
if ($newPidx > 0) {
|
||||
$this->menuModel->updateCnode($newPidx, 1);
|
||||
}
|
||||
}
|
||||
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
|
||||
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
|
||||
|
||||
@@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel;
|
||||
use App\Models\DesignatedShopModel;
|
||||
use App\Models\LocalGovernmentModel;
|
||||
use App\Models\SalesAgencyModel;
|
||||
use App\Models\ReportPresetModel;
|
||||
|
||||
class SalesReport extends BaseController
|
||||
{
|
||||
/** 맞춤 집계표에서 선택 가능한 컬럼 카탈로그 (표시 순서 = 정의 순서) */
|
||||
private const CUSTOM_REPORT_COLUMNS = [
|
||||
'date' => ['label' => '일자', 'type' => 'str'],
|
||||
'shop_no' => ['label' => '지정번호', 'type' => 'str'],
|
||||
'shop_name' => ['label' => '판매소명', 'type' => 'str'],
|
||||
'rep_name' => ['label' => '대표자', 'type' => 'str'],
|
||||
'address' => ['label' => '소재지', 'type' => 'str'],
|
||||
'zone' => ['label' => '구역', 'type' => 'str'],
|
||||
'category' => ['label' => '품목구분', 'type' => 'str'],
|
||||
'product' => ['label' => '품목', 'type' => 'str'],
|
||||
'size' => ['label' => '용량', 'type' => 'str'],
|
||||
'qty' => ['label' => '판매량', 'type' => 'num'],
|
||||
'amount' => ['label' => '판매금액', 'type' => 'num'],
|
||||
'fee' => ['label' => '수수료', 'type' => 'num'],
|
||||
'total' => ['label' => '총액', 'type' => 'num'],
|
||||
];
|
||||
|
||||
private const CUSTOM_REPORT_DEFAULT_COLUMNS = ['date', 'shop_no', 'shop_name', 'product', 'qty', 'amount'];
|
||||
|
||||
/** 품목명 → 3분류(봉투/음식물/폐기물) */
|
||||
private function customReportCategory3(string $name): string
|
||||
{
|
||||
if (mb_strpos($name, '음식물') !== false) {
|
||||
return 'food';
|
||||
}
|
||||
if (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
|
||||
return 'waste';
|
||||
}
|
||||
return 'envelope';
|
||||
}
|
||||
|
||||
private function customReportCategoryLabel(string $cat): string
|
||||
{
|
||||
return ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'][$cat] ?? '전체';
|
||||
}
|
||||
|
||||
/** 품목명에서 용량 토큰 추출(예: 10L / 1000원) */
|
||||
private function customReportSize(string $name): string
|
||||
{
|
||||
if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
|
||||
return $m[1] . 'L';
|
||||
}
|
||||
if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
|
||||
return str_replace(',', '', $m[1]) . '원';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
/**
|
||||
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
|
||||
*/
|
||||
@@ -3378,4 +3426,224 @@ class SalesReport extends BaseController
|
||||
'salesLabel' => $scopeLabel($salesScope),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 맞춤 집계표 — 기간·품목구분·컬럼을 골라 제목을 붙여 조회/인쇄, 계정별 프리셋 저장.
|
||||
*/
|
||||
public function customReport()
|
||||
{
|
||||
helper(['admin', 'export']);
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
$mbIdx = (int) session()->get('mb_idx');
|
||||
|
||||
// report_preset 테이블이 있을 때만 프리셋 기능 활성(없으면 조회·인쇄는 그대로 동작)
|
||||
$presetsEnabled = \Config\Database::connect()->tableExists('report_preset');
|
||||
$presetModel = model(ReportPresetModel::class);
|
||||
$presets = [];
|
||||
if ($presetsEnabled) {
|
||||
$presets = $presetModel->where('rp_lg_idx', $lgIdx)->where('rp_mb_idx', $mbIdx)
|
||||
->orderBy('rp_name', 'ASC')->findAll();
|
||||
}
|
||||
|
||||
// 활성 설정 결정: 폼 적용(applied) > 프리셋 로드(preset) > 기본값
|
||||
$presetIdx = (int) ($this->request->getGet('preset') ?? 0);
|
||||
$loaded = null;
|
||||
if ($presetsEnabled && $presetIdx > 0) {
|
||||
$loaded = $presetModel->find($presetIdx);
|
||||
if ($loaded === null || (int) $loaded->rp_lg_idx !== (int) $lgIdx || (int) $loaded->rp_mb_idx !== $mbIdx) {
|
||||
$loaded = null;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogKeys = array_keys(self::CUSTOM_REPORT_COLUMNS);
|
||||
if ((string) ($this->request->getGet('applied') ?? '') === '1') {
|
||||
$cols = (array) ($this->request->getGet('cols') ?? []);
|
||||
$title = (string) ($this->request->getGet('title') ?? '');
|
||||
$category = (string) ($this->request->getGet('category') ?? 'all');
|
||||
$mode = (string) ($this->request->getGet('mode') ?? 'detail');
|
||||
} elseif ($loaded !== null) {
|
||||
$cols = json_decode((string) ($loaded->rp_columns ?? '[]'), true) ?: [];
|
||||
$title = (string) $loaded->rp_title;
|
||||
$category = (string) $loaded->rp_category;
|
||||
$mode = (string) $loaded->rp_mode;
|
||||
} else {
|
||||
$cols = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||
$title = '';
|
||||
$category = 'all';
|
||||
$mode = 'detail';
|
||||
}
|
||||
// 정규화
|
||||
$selected = array_values(array_filter($catalogKeys, static fn ($k) => in_array($k, $cols, true)));
|
||||
if ($selected === []) {
|
||||
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||
}
|
||||
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
|
||||
$category = 'all';
|
||||
}
|
||||
$mode = $mode === 'summary' ? 'summary' : 'detail';
|
||||
|
||||
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
|
||||
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
|
||||
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
||||
if (! $isYmd($startDate)) {
|
||||
$startDate = date('Y-m-01');
|
||||
}
|
||||
if (! $isYmd($endDate)) {
|
||||
$endDate = date('Y-m-d');
|
||||
}
|
||||
if ($startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
|
||||
$detail = $this->fetchSalesLedgerDetailRows($db, (int) $lgIdx, $startDate, $endDate, 0, 0, $hasBsFee);
|
||||
|
||||
// 카테고리 필터 + 행 매핑
|
||||
$rows = [];
|
||||
foreach ($detail as $r) {
|
||||
$name = (string) ($r->bs_bag_name ?? '');
|
||||
$cat3 = $this->customReportCategory3($name);
|
||||
if ($category !== 'all' && $cat3 !== $category) {
|
||||
continue;
|
||||
}
|
||||
$qty = (int) ($r->line_qty ?? 0);
|
||||
$amount = (float) ($r->line_amount ?? 0);
|
||||
$fee = (float) ($r->line_fee ?? 0);
|
||||
$rows[] = [
|
||||
'date' => substr((string) ($r->bs_sale_date ?? ''), 0, 10),
|
||||
'shop_no' => (string) ($r->ds_shop_no ?? ''),
|
||||
'shop_name' => (string) ($r->ds_name ?? ''),
|
||||
'rep_name' => (string) ($r->ds_rep_name ?? ''),
|
||||
'address' => (string) ($r->ds_addr ?? ''),
|
||||
'zone' => (string) ($r->ds_zone_code ?? ''),
|
||||
'category' => $this->customReportCategoryLabel($cat3),
|
||||
'product' => $name,
|
||||
'size' => $this->customReportSize($name),
|
||||
'qty' => $qty,
|
||||
'amount' => (int) round($amount),
|
||||
'fee' => (int) round($fee),
|
||||
'total' => (int) round($amount + $fee),
|
||||
];
|
||||
}
|
||||
|
||||
// 집계 모드: 품목(구분+품명+용량) 기준 합산
|
||||
if ($mode === 'summary') {
|
||||
$agg = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['category'] . '|' . $row['product'] . '|' . $row['size'];
|
||||
if (! isset($agg[$key])) {
|
||||
$agg[$key] = ['category' => $row['category'], 'product' => $row['product'], 'size' => $row['size'],
|
||||
'date' => '', 'shop_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'zone' => '',
|
||||
'qty' => 0, 'amount' => 0, 'fee' => 0, 'total' => 0];
|
||||
}
|
||||
$agg[$key]['qty'] += $row['qty'];
|
||||
$agg[$key]['amount'] += $row['amount'];
|
||||
$agg[$key]['fee'] += $row['fee'];
|
||||
$agg[$key]['total'] += $row['total'];
|
||||
}
|
||||
$rows = array_values($agg);
|
||||
}
|
||||
|
||||
// 숫자 컬럼 합계
|
||||
$totals = [];
|
||||
foreach ($selected as $key) {
|
||||
if ((self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num') {
|
||||
$totals[$key] = array_sum(array_map(static fn ($r) => (int) $r[$key], $rows));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
|
||||
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
|
||||
'selected' => $selected,
|
||||
'title' => $title,
|
||||
'category' => $category,
|
||||
'mode' => $mode,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'rows' => $rows,
|
||||
'totals' => $totals,
|
||||
'presets' => $presets,
|
||||
'activePresetIdx' => $loaded !== null ? (int) $loaded->rp_idx : 0,
|
||||
'presetsEnabled' => $presetsEnabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 맞춤 집계표 프리셋 저장(신규/수정) — 계정 소유 */
|
||||
public function customReportPresetSave()
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
$mbIdx = (int) session()->get('mb_idx');
|
||||
|
||||
if (! \Config\Database::connect()->tableExists('report_preset')) {
|
||||
return redirect()->to(site_url('bag/reports/custom'))
|
||||
->with('error', '프리셋 저장 테이블이 아직 생성되지 않았습니다. (report_preset_table.sql 실행 필요)');
|
||||
}
|
||||
$name = trim((string) $this->request->getPost('rp_name'));
|
||||
if ($name === '') {
|
||||
return redirect()->to(site_url('bag/reports/custom'))->with('error', '프리셋 이름을 입력해 주세요.');
|
||||
}
|
||||
$cols = (array) ($this->request->getPost('cols') ?? []);
|
||||
$catalog = array_keys(self::CUSTOM_REPORT_COLUMNS);
|
||||
$selected = array_values(array_filter($catalog, static fn ($k) => in_array($k, $cols, true)));
|
||||
if ($selected === []) {
|
||||
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||
}
|
||||
$category = (string) $this->request->getPost('rp_category');
|
||||
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
|
||||
$category = 'all';
|
||||
}
|
||||
$mode = (string) $this->request->getPost('rp_mode') === 'summary' ? 'summary' : 'detail';
|
||||
|
||||
$data = [
|
||||
'rp_lg_idx' => $lgIdx,
|
||||
'rp_mb_idx' => $mbIdx,
|
||||
'rp_name' => mb_substr($name, 0, 100),
|
||||
'rp_title' => mb_substr((string) $this->request->getPost('rp_title'), 0, 200),
|
||||
'rp_category' => $category,
|
||||
'rp_mode' => $mode,
|
||||
'rp_columns' => json_encode($selected, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
|
||||
$presetModel = model(ReportPresetModel::class);
|
||||
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
|
||||
if ($rpIdx > 0) {
|
||||
$existing = $presetModel->find($rpIdx);
|
||||
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
|
||||
$presetModel->update($rpIdx, $data);
|
||||
}
|
||||
} else {
|
||||
$presetModel->insert($data);
|
||||
$rpIdx = (int) $presetModel->getInsertID();
|
||||
}
|
||||
|
||||
$q = http_build_query(['preset' => $rpIdx, 'start_date' => $this->request->getPost('start_date'), 'end_date' => $this->request->getPost('end_date')]);
|
||||
|
||||
return redirect()->to(site_url('bag/reports/custom') . '?' . $q)->with('success', '프리셋을 저장했습니다.');
|
||||
}
|
||||
|
||||
/** 맞춤 집계표 프리셋 삭제 — 계정 소유 */
|
||||
public function customReportPresetDelete()
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
$mbIdx = (int) session()->get('mb_idx');
|
||||
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
|
||||
$presetModel = model(ReportPresetModel::class);
|
||||
$existing = $rpIdx > 0 ? $presetModel->find($rpIdx) : null;
|
||||
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
|
||||
$presetModel->delete($rpIdx);
|
||||
|
||||
return redirect()->to(site_url('bag/reports/custom'))->with('success', '프리셋을 삭제했습니다.');
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('bag/reports/custom'))->with('error', '삭제할 프리셋을 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,13 @@ class MenuModel extends Model
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑
|
||||
$byDep = [];
|
||||
foreach ($source as $row) {
|
||||
$byDep[(int) ($row->mm_dep ?? 0)][] = $row;
|
||||
}
|
||||
ksort($byDep);
|
||||
|
||||
foreach ($lgRows as $lgRow) {
|
||||
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
|
||||
if ($destLg <= 0 || $destLg === $sourceLg) {
|
||||
@@ -281,28 +288,41 @@ class MenuModel extends Model
|
||||
->where('lg_idx', $destLg)
|
||||
->delete();
|
||||
|
||||
// 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑
|
||||
$idMap = [];
|
||||
foreach ($source as $row) {
|
||||
$oldId = (int) ($row->mm_idx ?? 0);
|
||||
foreach ($byDep as $dep => $levelRows) {
|
||||
$batch = [];
|
||||
foreach ($levelRows as $row) {
|
||||
$oldP = (int) ($row->mm_pidx ?? 0);
|
||||
$newPidx = 0;
|
||||
if ($oldP > 0 && isset($idMap[$oldP])) {
|
||||
$newPidx = (int) $idMap[$oldP];
|
||||
}
|
||||
|
||||
$this->insert([
|
||||
$newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0;
|
||||
$batch[] = [
|
||||
'mt_idx' => $mtIdx,
|
||||
'lg_idx' => $destLg,
|
||||
'mm_name' => (string) ($row->mm_name ?? ''),
|
||||
'mm_link' => (string) ($row->mm_link ?? ''),
|
||||
'mm_pidx' => $newPidx,
|
||||
'mm_dep' => (int) ($row->mm_dep ?? 0),
|
||||
'mm_dep' => (int) $dep,
|
||||
'mm_num' => (int) ($row->mm_num ?? 0),
|
||||
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
|
||||
'mm_level' => (string) ($row->mm_level ?? ''),
|
||||
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
|
||||
]);
|
||||
$idMap[$oldId] = (int) $this->getInsertID();
|
||||
];
|
||||
}
|
||||
if ($batch === []) {
|
||||
continue;
|
||||
}
|
||||
$this->insertBatch($batch);
|
||||
// 방금 삽입한 이 레벨의 행을 삽입 순서(mm_idx ASC)대로 조회해 원본과 1:1 매핑
|
||||
$inserted = $this->where('mt_idx', $mtIdx)
|
||||
->where('lg_idx', $destLg)
|
||||
->where('mm_dep', (int) $dep)
|
||||
->orderBy('mm_idx', 'ASC')
|
||||
->findAll();
|
||||
foreach ($levelRows as $i => $row) {
|
||||
if (isset($inserted[$i])) {
|
||||
$idMap[(int) ($row->mm_idx ?? 0)] = (int) $inserted[$i]->mm_idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->transComplete();
|
||||
}
|
||||
|
||||
21
app/Models/ReportPresetModel.php
Normal file
21
app/Models/ReportPresetModel.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ReportPresetModel extends Model
|
||||
{
|
||||
protected $table = 'report_preset';
|
||||
protected $primaryKey = 'rp_idx';
|
||||
protected $returnType = 'object';
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'rp_regdate';
|
||||
protected $updatedField = 'rp_updated';
|
||||
protected $allowedFields = [
|
||||
'rp_lg_idx', 'rp_mb_idx', 'rp_name', 'rp_title',
|
||||
'rp_category', 'rp_mode', 'rp_columns',
|
||||
];
|
||||
}
|
||||
@@ -20,9 +20,24 @@ use App\Models\FeedbackModel;
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">의견 내용</div>
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
|
||||
<span>의견 내용</span>
|
||||
<button type="button" id="fb-content-edit-btn" class="text-xs font-medium text-blue-600 hover:underline">수정</button>
|
||||
</div>
|
||||
<div class="p-3 space-y-2 text-sm">
|
||||
<!-- 보기 모드 -->
|
||||
<div id="fb-content-view">
|
||||
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
|
||||
</div>
|
||||
<!-- 수정 모드 -->
|
||||
<form id="fb-content-edit" action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/content') ?>" method="post" class="hidden">
|
||||
<?= csrf_field() ?>
|
||||
<textarea name="fb_content" rows="6" maxlength="2000" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"><?= esc((string) $row->fb_content) ?></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">수정 저장</button>
|
||||
<button type="button" id="fb-content-cancel" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded-sm text-sm hover:bg-gray-300">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr class="my-2"/>
|
||||
<?php
|
||||
// 화면 URL — embed 파라미터를 떼고 전체 화면 절대 URL로 변환(클릭 시 해당 화면으로 바로 이동).
|
||||
@@ -91,3 +106,21 @@ use App\Models\FeedbackModel;
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div><!-- /.fb-selectable -->
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('fb-content-edit-btn');
|
||||
var view = document.getElementById('fb-content-view');
|
||||
var edit = document.getElementById('fb-content-edit');
|
||||
var cancel = document.getElementById('fb-content-cancel');
|
||||
if (!btn || !view || !edit) return;
|
||||
var show = function (editing) {
|
||||
view.classList.toggle('hidden', editing);
|
||||
edit.classList.toggle('hidden', !editing);
|
||||
btn.classList.toggle('hidden', editing);
|
||||
if (editing) { var ta = edit.querySelector('textarea'); if (ta) ta.focus(); }
|
||||
};
|
||||
btn.addEventListener('click', function () { show(true); });
|
||||
if (cancel) cancel.addEventListener('click', function () { show(false); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -145,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
data-link="<?= esc($row->mm_link) ?>"
|
||||
data-level="<?= esc($row->mm_level) ?>"
|
||||
data-view="<?= (string) $row->mm_is_view ?>"
|
||||
data-dep="<?= (int) $row->mm_dep ?>">
|
||||
data-dep="<?= (int) $row->mm_dep ?>"
|
||||
data-pidx="<?= (int) $row->mm_pidx ?>">
|
||||
수정
|
||||
</button>
|
||||
<?php if ($dep === 0): ?>
|
||||
@@ -190,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
<label class="block text-sm font-medium text-gray-700">링크</label>
|
||||
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
|
||||
</div>
|
||||
<div class="space-y-2 mb-3" id="menu-position-wrap">
|
||||
<label class="block text-sm font-medium text-gray-700">메뉴 위치</label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="pos_type" value="top" id="pos-top" checked/>
|
||||
<span class="text-sm">상위 메뉴로 등록</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="pos_type" value="child" id="pos-child"/>
|
||||
<span class="text-sm">하위 메뉴로 등록</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="mm-parent-select" class="border border-gray-300 rounded px-2 py-1 w-full text-sm mt-1 hidden">
|
||||
<option value="">— 상위 메뉴 선택 —</option>
|
||||
<?php foreach ($list as $prow): ?>
|
||||
<?php if ((int) $prow->mm_dep !== 0) { continue; } ?>
|
||||
<option value="<?= (int) $prow->mm_idx ?>" data-dep="<?= (int) $prow->mm_dep ?>"><?= esc($prow->mm_name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">노출 대상</label>
|
||||
<?php if ($mtCode === 'admin'): ?>
|
||||
@@ -259,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
const editIdInput = document.getElementById('mm_idx_edit');
|
||||
const mmPidxInput = form.querySelector('[name="mm_pidx"]');
|
||||
const mmDepInput = form.querySelector('[name="mm_dep"]');
|
||||
const posWrap = document.getElementById('menu-position-wrap');
|
||||
const posTop = document.getElementById('pos-top');
|
||||
const posChild = document.getElementById('pos-child');
|
||||
const parentSelect = document.getElementById('mm-parent-select');
|
||||
|
||||
// 상위/하위 위치 선택 → 숨은 mm_pidx / mm_dep 동기화
|
||||
function applyParentFromSelect() {
|
||||
const opt = parentSelect.options[parentSelect.selectedIndex];
|
||||
if (opt && opt.value) {
|
||||
mmPidxInput.value = opt.value;
|
||||
mmDepInput.value = String((parseInt(opt.dataset.dep || '0', 10)) + 1);
|
||||
} else {
|
||||
mmPidxInput.value = '0';
|
||||
mmDepInput.value = '1';
|
||||
}
|
||||
}
|
||||
function setPosition(type, parentId) {
|
||||
if (type === 'child') {
|
||||
if (posChild) posChild.checked = true;
|
||||
parentSelect.classList.remove('hidden');
|
||||
if (parentId != null) parentSelect.value = String(parentId);
|
||||
applyParentFromSelect();
|
||||
} else {
|
||||
if (posTop) posTop.checked = true;
|
||||
parentSelect.classList.add('hidden');
|
||||
mmPidxInput.value = '0';
|
||||
mmDepInput.value = '0';
|
||||
}
|
||||
}
|
||||
if (posTop) posTop.addEventListener('change', function () { if (posTop.checked) setPosition('top'); });
|
||||
if (posChild) posChild.addEventListener('change', function () { if (posChild.checked) setPosition('child', parentSelect.value); });
|
||||
if (parentSelect) parentSelect.addEventListener('change', applyParentFromSelect);
|
||||
// 하위 선택인데 상위 미지정이면 등록 차단
|
||||
function enableAllParentOptions() {
|
||||
if (!parentSelect) return;
|
||||
Array.prototype.forEach.call(parentSelect.options, function (o) { o.disabled = false; });
|
||||
}
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (posChild && posChild.checked && !parentSelect.value) {
|
||||
e.preventDefault();
|
||||
alert('하위 메뉴로 지정하려면 상위 메뉴를 선택해 주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
const levelAll = document.getElementById('mm_level_all');
|
||||
const levelCbs = document.querySelectorAll('.mm-level-cb');
|
||||
const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
|
||||
@@ -298,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
const level = (this.dataset.level || '').toString().trim();
|
||||
const view = this.dataset.view || 'Y';
|
||||
const dep = parseInt(this.dataset.dep || '0', 10);
|
||||
const pidx = parseInt(this.dataset.pidx || '0', 10);
|
||||
form.action = '<?= base_url('admin/menus/update/') ?>' + id;
|
||||
form.querySelector('[name="mm_name"]').value = name;
|
||||
form.querySelector('[name="mm_link"]').value = link;
|
||||
mmPidxInput.value = '0';
|
||||
mmDepInput.value = String(dep);
|
||||
// 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능
|
||||
if (posWrap) posWrap.style.display = '';
|
||||
// 자기 자신은 상위 후보에서 제외
|
||||
if (parentSelect) {
|
||||
Array.prototype.forEach.call(parentSelect.options, function (o) {
|
||||
o.disabled = (o.value === String(id));
|
||||
});
|
||||
}
|
||||
if (pidx > 0) {
|
||||
setPosition('child', pidx);
|
||||
} else {
|
||||
setPosition('top');
|
||||
}
|
||||
if (!isAdminType && levelAll) {
|
||||
levelAll.checked = (level === '');
|
||||
levelCbs.forEach(function(cb){
|
||||
@@ -340,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
formTitle.textContent = '하위 메뉴 등록';
|
||||
btnSubmit.textContent = '등록';
|
||||
btnCancel.style.display = 'inline-block';
|
||||
// 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시)
|
||||
if (posWrap) posWrap.style.display = '';
|
||||
enableAllParentOptions();
|
||||
setPosition('child', parentId);
|
||||
// 노출 기본값 재설정
|
||||
form.querySelector('[name="mm_is_view"]').checked = true;
|
||||
if (!isAdminType && levelAll) {
|
||||
@@ -368,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
formTitle.textContent = '메뉴 등록';
|
||||
btnSubmit.textContent = '등록';
|
||||
btnCancel.style.display = 'none';
|
||||
// 위치 선택 UI 복원 → 기본 '상위'
|
||||
if (posWrap) posWrap.style.display = '';
|
||||
enableAllParentOptions();
|
||||
setPosition('top');
|
||||
});
|
||||
|
||||
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
|
||||
|
||||
188
app/Views/admin/sales_report/custom_report.php
Normal file
188
app/Views/admin/sales_report/custom_report.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @var array<string, array{label:string,type:string}> $columnCatalog
|
||||
* @var list<string> $selected
|
||||
* @var string $title
|
||||
* @var string $category
|
||||
* @var string $mode
|
||||
* @var string $startDate
|
||||
* @var string $endDate
|
||||
* @var list<array<string,mixed>> $rows
|
||||
* @var array<string,int> $totals
|
||||
* @var list<object> $presets
|
||||
* @var int $activePresetIdx
|
||||
*/
|
||||
$catLabels = ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'];
|
||||
$printTitle = $title !== '' ? $title : '맞춤 집계표';
|
||||
$nf = static fn ($n): string => number_format((int) $n);
|
||||
?>
|
||||
<?= view('components/print_header', [
|
||||
'printTitle' => $printTitle,
|
||||
'printDate' => date('Y-m-d'),
|
||||
'printExtraLines' => ['조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 품목구분: ' . ($catLabels[$category] ?? '전체') . ' · (단위: 매 / 원)'],
|
||||
]) ?>
|
||||
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">맞춤 집계표</span>
|
||||
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||
</section>
|
||||
|
||||
<!-- 조회/구성 폼 -->
|
||||
<section class="p-3 bg-white border-b border-gray-200 no-print">
|
||||
<form method="get" action="<?= mgmt_url('reports/custom') ?>" id="custom-report-form" class="space-y-3">
|
||||
<input type="hidden" name="applied" value="1"/>
|
||||
<div class="flex flex-wrap items-end gap-3 text-sm">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
|
||||
<input type="text" name="title" value="<?= esc($title, 'attr') ?>" maxlength="200" placeholder="예: 2026년 음식물 봉투 판매 집계" class="border border-gray-300 rounded px-2 py-1 w-72"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">시작일</label>
|
||||
<input type="date" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">종료일</label>
|
||||
<input type="date" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">품목구분(종류)</label>
|
||||
<select name="category" class="border border-gray-300 rounded px-2 py-1 w-32">
|
||||
<?php foreach ($catLabels as $ck => $cl): ?>
|
||||
<option value="<?= esc($ck, 'attr') ?>" <?= $category === $ck ? 'selected' : '' ?>><?= esc($cl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">집계 방식</label>
|
||||
<select name="mode" class="border border-gray-300 rounded px-2 py-1 w-40">
|
||||
<option value="detail" <?= $mode === 'detail' ? 'selected' : '' ?>>상세(건별)</option>
|
||||
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">표시할 컬럼 선택</label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<?php foreach ($columnCatalog as $key => $meta): ?>
|
||||
<label class="inline-flex items-center gap-1 text-sm cursor-pointer">
|
||||
<input type="checkbox" name="cols[]" value="<?= esc($key, 'attr') ?>" <?= in_array($key, $selected, true) ? 'checked' : '' ?>/>
|
||||
<span><?= esc($meta['label']) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="text-[11px] text-gray-500 mt-1">집계 방식이 「품목별 집계」이면 일자·판매소 등 건별 컬럼은 빈 값으로 표시됩니다.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- 프리셋 (계정별 저장) -->
|
||||
<?php if (! empty($presetsEnabled)): ?>
|
||||
<section class="p-3 bg-gray-50 border-b border-gray-200 no-print">
|
||||
<div class="flex flex-wrap items-end gap-3 text-sm">
|
||||
<form method="get" action="<?= mgmt_url('reports/custom') ?>" class="flex items-end gap-2">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">저장된 프리셋 불러오기</label>
|
||||
<select name="preset" class="border border-gray-300 rounded px-2 py-1 min-w-[14rem]" onchange="this.form.submit()">
|
||||
<option value="">— 프리셋 선택 —</option>
|
||||
<?php foreach ($presets as $p): ?>
|
||||
<option value="<?= (int) $p->rp_idx ?>" <?= $activePresetIdx === (int) $p->rp_idx ? 'selected' : '' ?>><?= esc((string) $p->rp_name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
|
||||
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
|
||||
</form>
|
||||
|
||||
<?php if ($activePresetIdx > 0): ?>
|
||||
<form method="post" action="<?= mgmt_url('reports/custom/preset/delete') ?>" onsubmit="return confirm('이 프리셋을 삭제할까요?');" class="flex items-end">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
|
||||
<button type="submit" class="text-red-600 hover:underline text-xs pb-1">현재 프리셋 삭제</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 현재 화면 구성 그대로 저장 (JS로 현재 폼 값 복사) -->
|
||||
<form method="post" action="<?= mgmt_url('reports/custom/preset/save') ?>" id="preset-save-form" class="flex items-end gap-2">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
|
||||
<input type="hidden" name="rp_title" id="ps-title"/>
|
||||
<input type="hidden" name="rp_category" id="ps-category"/>
|
||||
<input type="hidden" name="rp_mode" id="ps-mode"/>
|
||||
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
|
||||
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
|
||||
<div id="ps-cols-holder"></div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">프리셋 이름</label>
|
||||
<input type="text" name="rp_name" maxlength="100" placeholder="예: 음식물 담당 월집계" class="border border-gray-300 rounded px-2 py-1 w-56" required/>
|
||||
</div>
|
||||
<button type="submit" class="bg-[#243a5e] text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">현재 구성 저장</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 결과 표 -->
|
||||
<div class="p-3">
|
||||
<div class="mb-2 text-center no-print">
|
||||
<h1 class="text-lg font-bold m-0"><?= esc($printTitle) ?></h1>
|
||||
<p class="text-xs text-gray-500 mt-0.5">조회기간 <?= esc($startDate) ?> ~ <?= esc($endDate) ?> · 품목구분 <?= esc($catLabels[$category] ?? '전체') ?> · 건수 <?= $nf(count($rows)) ?></p>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12 text-center">No</th>
|
||||
<?php foreach ($selected as $key): ?>
|
||||
<th class="<?= ($columnCatalog[$key]['type'] ?? '') === 'num' ? 'text-right' : 'text-left' ?> px-2"><?= esc($columnCatalog[$key]['label']) ?></th>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($rows === []): ?>
|
||||
<tr><td colspan="<?= count($selected) + 1 ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $i => $row): ?>
|
||||
<tr>
|
||||
<td class="text-center text-gray-500"><?= $i + 1 ?></td>
|
||||
<?php foreach ($selected as $key): ?>
|
||||
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
|
||||
<td class="<?= $isNum ? 'text-right' : 'text-left' ?> px-2"><?= $isNum ? $nf($row[$key] ?? 0) : esc((string) ($row[$key] ?? '')) ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr class="bg-amber-50 font-bold">
|
||||
<td class="text-center">합계</td>
|
||||
<?php foreach ($selected as $key): ?>
|
||||
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
|
||||
<td class="<?= $isNum ? 'text-right' : '' ?> px-2"><?= $isNum ? $nf($totals[$key] ?? 0) : '' ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// "현재 구성 저장" 시, 조회 폼의 현재 값(제목/종류/방식/컬럼)을 프리셋 저장 폼으로 복사
|
||||
var mainForm = document.getElementById('custom-report-form');
|
||||
var saveForm = document.getElementById('preset-save-form');
|
||||
if (!mainForm || !saveForm) return;
|
||||
saveForm.addEventListener('submit', function () {
|
||||
document.getElementById('ps-title').value = mainForm.querySelector('[name="title"]').value;
|
||||
document.getElementById('ps-category').value = mainForm.querySelector('[name="category"]').value;
|
||||
document.getElementById('ps-mode').value = mainForm.querySelector('[name="mode"]').value;
|
||||
var holder = document.getElementById('ps-cols-holder');
|
||||
holder.innerHTML = '';
|
||||
mainForm.querySelectorAll('input[name="cols[]"]:checked').forEach(function (cb) {
|
||||
var h = document.createElement('input');
|
||||
h.type = 'hidden'; h.name = 'cols[]'; h.value = cb.value;
|
||||
holder.appendChild(h);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -15,6 +15,9 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title><?= esc($pageTitle ?? 'GBLS') ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>GBLS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||
|
||||
@@ -6,6 +6,29 @@
|
||||
var listEl = document.getElementById('portalSidebarList');
|
||||
var titleEl = document.getElementById('portalSidebarTitle');
|
||||
|
||||
// 기본정보관리 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저)
|
||||
function sidebarIcon(name) {
|
||||
var n = String(name || '');
|
||||
var rules = [
|
||||
['바코드', 'fa-barcode'], ['조회', 'fa-magnifying-glass'],
|
||||
['신규', 'fa-chart-column'], ['현황', 'fa-chart-column'],
|
||||
['판매소', 'fa-store'], ['대행소', 'fa-handshake'],
|
||||
['담당자', 'fa-user-tie'], ['업체', 'fa-building'],
|
||||
['무료', 'fa-gift'], ['포장', 'fa-box'],
|
||||
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'],
|
||||
['비밀번호', 'fa-key'], ['설정', 'fa-gear']
|
||||
];
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
|
||||
}
|
||||
if (n.toLowerCase().indexOf('password') !== -1) return 'fa-key';
|
||||
return 'fa-angle-right';
|
||||
}
|
||||
function iconHtml(parentName, childName) {
|
||||
if (String(parentName || '').replace(/\s/g, '') !== '기본정보관리') return '';
|
||||
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
|
||||
}
|
||||
|
||||
if (listEl && navData.length) {
|
||||
function renderSidebar(idx, overrideHref) {
|
||||
var parent = navData[idx];
|
||||
@@ -29,10 +52,11 @@
|
||||
var li = document.createElement('li');
|
||||
var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
|
||||
var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0);
|
||||
var ic = iconHtml(parent.name, child.name);
|
||||
if (child.href) {
|
||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + child.name + '</a>';
|
||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + ic + child.name + '</a>';
|
||||
} else {
|
||||
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + child.name + '</span>';
|
||||
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + ic + child.name + '</span>';
|
||||
}
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,36 @@ declare(strict_types=1);
|
||||
$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
|
||||
$sidebarTitle = $activeParent['name'] ?? 'MY MENU';
|
||||
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||
|
||||
// 기본정보관리 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저)
|
||||
$showSidebarIcons = (str_replace(' ', '', (string) $sidebarTitle) === '기본정보관리');
|
||||
$sidebarIconFor = static function (string $name): string {
|
||||
$rules = [
|
||||
'바코드' => 'fa-barcode', // 지정판매소 바코드 출력
|
||||
'조회' => 'fa-magnifying-glass', // 지정 판매소 조회
|
||||
'신규' => 'fa-chart-column', // 지정 판매소 신규/취소 현황
|
||||
'현황' => 'fa-chart-column',
|
||||
'판매소' => 'fa-store', // 지정 판매소 관리
|
||||
'대행소' => 'fa-handshake',
|
||||
'담당자' => 'fa-user-tie',
|
||||
'업체' => 'fa-building',
|
||||
'무료' => 'fa-gift',
|
||||
'포장' => 'fa-box',
|
||||
'단가' => 'fa-won-sign',
|
||||
'코드' => 'fa-list-ol',
|
||||
'비밀번호' => 'fa-key',
|
||||
'설정' => 'fa-gear',
|
||||
];
|
||||
foreach ($rules as $kw => $icon) {
|
||||
if (mb_stripos($name, $kw) !== false) {
|
||||
return $icon;
|
||||
}
|
||||
}
|
||||
if (stripos($name, 'password') !== false) {
|
||||
return 'fa-key';
|
||||
}
|
||||
return 'fa-angle-right';
|
||||
};
|
||||
?>
|
||||
<aside class="sidebar">
|
||||
<div class="my-menu-hd" id="portalSidebarTitle"><?= esc($sidebarTitle) ?></div>
|
||||
@@ -22,11 +52,11 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||
<li>
|
||||
<?php if ($child['href'] !== ''): ?>
|
||||
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
|
||||
<?= esc($child['name']) ?>
|
||||
<?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="menu-sub" style="opacity:.65;cursor:default;">
|
||||
<?= esc($child['name']) ?>
|
||||
<?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
|
||||
20
public/favicon.svg
Normal file
20
public/favicon.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="GBLS">
|
||||
<!-- 브랜드 배경(네이비) -->
|
||||
<rect x="0" y="0" width="64" height="64" rx="14" fill="#243a5e"/>
|
||||
<!-- 쓰레기통(흰색) : 종량제 쓰레기봉투 물류 -->
|
||||
<g fill="none" stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- 뚜껑 -->
|
||||
<path d="M16 22 H48"/>
|
||||
<!-- 손잡이 -->
|
||||
<path d="M27 22 V18.5 a2 2 0 0 1 2-2 h6 a2 2 0 0 1 2 2 V22"/>
|
||||
<!-- 통 몸체 -->
|
||||
<path d="M19 22 L21.5 47 a3 3 0 0 0 3 2.8 h15 a3 3 0 0 0 3-2.8 L45 22 Z"/>
|
||||
</g>
|
||||
<!-- 세로 줄무늬 -->
|
||||
<g stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9">
|
||||
<path d="M27 28 V44"/>
|
||||
<path d="M37 28 V44"/>
|
||||
</g>
|
||||
<!-- 에코 잎사귀(그린 포인트) -->
|
||||
<path d="M32 27 C33 22 37 20 41 20 C41 25 38 28 32 27 Z" fill="#34d399"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 931 B |
21
writable/database/menu_add_custom_report.sql
Normal file
21
writable/database/menu_add_custom_report.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- 판매 현황 카테고리에 '맞춤 집계표' 소메뉴 추가 (사이트 메뉴)
|
||||
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_add_custom_report.sql
|
||||
-- 멱등: 이미 있으면 건너뜀.
|
||||
|
||||
SET @mt_site := (SELECT mt_idx FROM menu_type WHERE mt_code = 'site' LIMIT 1);
|
||||
SET @parent_sales := (
|
||||
SELECT mm_idx FROM menu
|
||||
WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = 0 AND mm_name = '판매 현황'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @next_num := (
|
||||
SELECT IFNULL(MAX(mm_num), -1) + 1 FROM menu WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = @parent_sales
|
||||
);
|
||||
|
||||
INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_cnode, mm_level, mm_is_view)
|
||||
SELECT @mt_site, 1, '맞춤 집계표', 'bag/reports/custom', @parent_sales, 1, @next_num, 0, '', 'Y'
|
||||
WHERE @parent_sales IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM menu m
|
||||
WHERE m.mt_idx = @mt_site AND m.lg_idx = 1 AND m.mm_pidx = @parent_sales AND m.mm_name = '맞춤 집계표'
|
||||
);
|
||||
18
writable/database/report_preset_table.sql
Normal file
18
writable/database/report_preset_table.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 맞춤 집계표: 계정별 리포트 프리셋(제목·품목구분·컬럼구성) 저장
|
||||
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/report_preset_table.sql
|
||||
-- 멱등(IF NOT EXISTS).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `report_preset` (
|
||||
`rp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`rp_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체',
|
||||
`rp_mb_idx` INT UNSIGNED NOT NULL COMMENT '소유 계정(mb_idx) — 계정별 저장',
|
||||
`rp_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '프리셋 이름',
|
||||
`rp_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '리포트(표) 제목',
|
||||
`rp_category` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT 'all|envelope|food|waste',
|
||||
`rp_mode` VARCHAR(10) NOT NULL DEFAULT 'detail' COMMENT 'detail|summary',
|
||||
`rp_columns` TEXT NULL COMMENT '선택 컬럼 키 JSON 배열',
|
||||
`rp_regdate` DATETIME NULL,
|
||||
`rp_updated` DATETIME NULL,
|
||||
PRIMARY KEY (`rp_idx`),
|
||||
KEY `idx_rp_owner` (`rp_lg_idx`, `rp_mb_idx`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Reference in New Issue
Block a user