실적관리 추가
This commit is contained in:
534
app/Views/pages/results/m416/stats.php
Normal file
534
app/Views/pages/results/m416/stats.php
Normal file
@@ -0,0 +1,534 @@
|
||||
<?= $this->extend('layouts/main') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<style>
|
||||
th {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#resultList tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.blockUI {
|
||||
z-index: 1500 !important;
|
||||
}
|
||||
|
||||
.swal2-cancel {
|
||||
background-color: #ff0000 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>개인별이동거리</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 col-xl-12">
|
||||
<div class="main-card mb-3 card">
|
||||
<div class="card-body">
|
||||
<form class="row align-items-end" id="frm_srch_info" onsubmit="return false;">
|
||||
|
||||
<div class="row mb-3">
|
||||
<!-- 관할조직 -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label mb-1">관할조직</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<select class="form-control" name="bonbu" id="bonbu">
|
||||
<option value="">선택</option>
|
||||
<?php foreach ($bonbu as $d): ?>
|
||||
<option value="<?= $d['dept_sq'] ?>"><?= $d['dept_nm'] ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<select class="form-control" name="dept_sq" id="dept_sq">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기준일자 -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label mb-1">기준일자</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-5">
|
||||
<input type="date" class="form-control" id="sdate" name="sdate">
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-center justify-content-center">~</div>
|
||||
<div class="col-5">
|
||||
<input type="date" class="form-control" id="edate" name="edate">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1">
|
||||
<label class="form-label mb-1">검색유형</label>
|
||||
<select class="form-control" name="srchType">
|
||||
<option value="">선택</option>
|
||||
<option value="1">아이디</option>
|
||||
<option value="2">이름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색어 -->
|
||||
<div class="col-md-2">
|
||||
<label class="form-label mb-1">검색어</label>
|
||||
<input type="text" class="form-control" name="srchTxt" placeholder="검색어 입력">
|
||||
</div>
|
||||
|
||||
<!-- 검색버튼 -->
|
||||
<div class="col-md-1 d-grid align-items-end">
|
||||
<button type="button" class="btn btn-primary" id="btnSearch">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-12 col-xl-12">
|
||||
<div class="main-card mb-3 card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">사용자 목록</h3>
|
||||
<div class="ms-auto d-flex align-items-center gap-3">
|
||||
<button class="mb-2 me-2 border-0 btn-transition btn btn-shadow btn-outline-success"
|
||||
id="excel-download">엑셀다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="resultList" class="table table-hover table-striped table-bordered">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css" />
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
||||
<script defer src="/architectui/assets/js/datatable.kor.js"></script>
|
||||
<style>
|
||||
table.dataTable thead th {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
|
||||
// const tpl = document.querySelector('.my-loader-template');
|
||||
var teamArr = <?= json_encode($team, JSON_UNESCAPED_UNICODE); ?>;
|
||||
|
||||
let date = new Date();
|
||||
|
||||
|
||||
$(function () {
|
||||
|
||||
setTimeout(() => {
|
||||
const edate = $('#edate').val();
|
||||
const days = getDaysByEndDate(edate);
|
||||
initTable(days);
|
||||
}, 0);
|
||||
|
||||
|
||||
$("#bonbu").on("change", function (e) {
|
||||
|
||||
var dept_sq = e.target.value
|
||||
|
||||
$("#dept_sq").empty()
|
||||
|
||||
var str = "<option value=''>선택</option>"
|
||||
if (teamArr != null) {
|
||||
|
||||
for (var i = 0; i < teamArr.length; i++) {
|
||||
if (dept_sq === teamArr[i].pdept_sq) {
|
||||
str += "<option value='" + teamArr[i].dept_sq + "'>" + teamArr[i].dept_nm + "</option>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$("#dept_sq").append(str)
|
||||
|
||||
});
|
||||
|
||||
$("#srcSido, #srcGugun").on("change", function (e) {
|
||||
|
||||
const targetId = this.id;
|
||||
|
||||
$.ajax({
|
||||
url: "/manage/areas/getAreaList",
|
||||
method: "POST",
|
||||
dataType: "json",
|
||||
data: {
|
||||
'srcSido': $("#frm_srch_info [name=srcSido]").val(),
|
||||
'srcGugun': $("#frm_srch_info [name=srcGugun]").val(),
|
||||
},
|
||||
beforeSend: function () {
|
||||
blockUI.blockPage({
|
||||
message: tpl
|
||||
})
|
||||
},
|
||||
complete: function () {
|
||||
blockUI.unblockPage()
|
||||
},
|
||||
success: function (result) {
|
||||
|
||||
switch (targetId) {
|
||||
case "srcSido":
|
||||
$("#srcGugun").empty()
|
||||
var str = "";
|
||||
str += "<option value=''>시/군/구</option>";
|
||||
|
||||
if (result.length > 0) {
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
str += "<option value='" + result[i]['region_cd'] + "'>" + result[i].region_nm + "</option>";
|
||||
}
|
||||
}
|
||||
|
||||
$("#srcGugun").append(str);
|
||||
break;
|
||||
|
||||
case "srcGugun":
|
||||
$("#srcDong").empty()
|
||||
var str = "";
|
||||
str += "<option value=''>읍/면/동</option>";
|
||||
|
||||
if (result.length > 0) {
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
str += "<option value='" + result[i]['region_cd'] + "'>" + result[i].region_nm + "</option>";
|
||||
}
|
||||
}
|
||||
|
||||
$("#srcDong").append(str);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [검색] 버튼 눌렀을 때 다시 조회
|
||||
$('#btnSearch').on('click', function () {
|
||||
const srchDate = $('#edate').val();
|
||||
const srchDay = getDaysByEndDate(srchDate);
|
||||
initTable(srchDay)
|
||||
// table.ajax.reload()
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 엑셀다운 click
|
||||
$("#excel-download").on("click", function () {
|
||||
|
||||
$.ajax({
|
||||
url: "/m416/m416a/excel",
|
||||
method: "GET",
|
||||
dataType: "json",
|
||||
data: $("#frm_srch_info").serialize(),
|
||||
beforeSend: function () {
|
||||
blockUI.blockPage({
|
||||
message: tpl
|
||||
})
|
||||
},
|
||||
complete: function () {
|
||||
blockUI.unblockPage()
|
||||
},
|
||||
success: function (result) {
|
||||
const days = getDaysByEndDate($("#edate").val());
|
||||
downloadExcelWithHeader(result.data, days);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// datatable render
|
||||
function initTable(days) {
|
||||
// 1) thead 생성
|
||||
renderThead(days, days.length);
|
||||
|
||||
// 2) 기존 테이블 제거
|
||||
if ($.fn.DataTable.isDataTable('#resultList')) {
|
||||
table.destroy();
|
||||
$('#resultList tbody').empty(); // 잔여 DOM 정리
|
||||
}
|
||||
|
||||
// 3) columns 생성 후 초기화
|
||||
const columns = buildColumns(days);
|
||||
|
||||
table = $('#resultList').DataTable({
|
||||
language: lang_kor,
|
||||
processing: true,
|
||||
serverSide: true, // 필요 시
|
||||
searching: false,
|
||||
ordering: false,
|
||||
paging: false,
|
||||
ajax: {
|
||||
url: '/m416/m416a/getResultList',
|
||||
type: 'GET',
|
||||
data: function (d) {
|
||||
|
||||
d.sdate = $('#sdate').val();
|
||||
d.edate = $('#edate').val();
|
||||
|
||||
d.bonbu = $("#frm_srch_info [name=bonbu]").val();
|
||||
d.dep_sq = $("#frm_srch_info [name=dep_sq]").val();
|
||||
d.srchType = $("#frm_srch_info [name=srchType]").val();
|
||||
d.srchTxt = $("#frm_srch_info [name=srchTxt]").val();
|
||||
}
|
||||
},
|
||||
columns,
|
||||
columnDefs: [
|
||||
{ targets: '_all', defaultContent: '', className: 'text-center' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// table head render
|
||||
function renderThead(days, cnt) {
|
||||
// cnt는 days.length 라고 보면 됨
|
||||
const $thead = $('#resultList thead');
|
||||
const tr1 = `
|
||||
<tr>
|
||||
<th rowspan="2">순번</th>
|
||||
<th rowspan="2">아이디</th>
|
||||
<th rowspan="2">이름</th>
|
||||
<th rowspan="2">촬영</th>
|
||||
<th colspan="${cnt}">일자별 이동거리(km)</th>
|
||||
<th rowspan="2">합계</th>
|
||||
</tr>`;
|
||||
const tr2 = `
|
||||
<tr>
|
||||
${days.map(d => `<th>${parseInt(d, 10)}</th>`).join('')}
|
||||
</tr>`;
|
||||
|
||||
$thead.html(tr1 + tr2);
|
||||
}
|
||||
|
||||
// data column render
|
||||
function buildColumns(days) {
|
||||
const fixed = [
|
||||
{
|
||||
"data": null,
|
||||
"width": "30px",
|
||||
"render": function (data, type, row, meta) {
|
||||
return meta.row + meta.settings._iDisplayStart + 1;
|
||||
}
|
||||
},
|
||||
{ data: 'usr_id', "width": "80px" },
|
||||
{ data: 'usr_nm', "width": "80px" },
|
||||
{ data: 'shoot_cnt' },
|
||||
];
|
||||
|
||||
const dynamic = days.map(day => ({
|
||||
data: `d${day}`,
|
||||
name: `d${day}`,
|
||||
defaultContent: ''
|
||||
}));
|
||||
|
||||
const total = [
|
||||
{ data: 'tot_distance' },
|
||||
];
|
||||
|
||||
return [...fixed, ...dynamic, ...total];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 엑셀 다운로드
|
||||
function downloadExcelWithHeader(dataRows, days) {
|
||||
// days: ["01","02",...]
|
||||
const fixedCols = ["순번", "아이디", "이름", "촬영"];
|
||||
const totalCol = "합계(km)";
|
||||
|
||||
const COLS = fixedCols.length + (days?.length || 0) + 1; // +1 = 합계
|
||||
|
||||
const fitCols = (arr) => {
|
||||
const a = (arr || []).slice(0, COLS);
|
||||
while (a.length < COLS) a.push("");
|
||||
return a;
|
||||
};
|
||||
|
||||
const safe = (v) => (v === undefined || v === null) ? "" : v;
|
||||
|
||||
// 숫자형 안전 처리 (거리)
|
||||
const num = (v) => (v === undefined || v === null || v === "") ? 0 : Number(v);
|
||||
|
||||
/* =======================
|
||||
* 1) 헤더(2줄)
|
||||
* - 1줄: 고정 + 그룹(일자별) + 합계(rowspan)
|
||||
* - 2줄: 일자(1..말일)
|
||||
* ======================= */
|
||||
const header1 = fitCols([
|
||||
...fixedCols,
|
||||
"일자별 이동거리(km)",
|
||||
...Array(Math.max((days?.length || 0) - 1, 0)).fill(""),
|
||||
totalCol
|
||||
]);
|
||||
|
||||
const header2 = fitCols([
|
||||
...Array(fixedCols.length).fill(""),
|
||||
...(days || []).map(d => String(parseInt(d, 10))), // "01" -> "1"
|
||||
""
|
||||
]);
|
||||
|
||||
/* =======================
|
||||
* 2) 바디
|
||||
* - 서버가 d01,d02... 형태로 내려준다는 전제
|
||||
* - 합계는 tot_distance (없으면 일자 합으로 계산)
|
||||
* ======================= */
|
||||
const body = (dataRows || []).map((r, idx) => {
|
||||
const distByDay = (days || []).map(d => num(r[`d${d}`]));
|
||||
|
||||
// 서버 합계 컬럼이 있으면 그걸 우선 사용, 없으면 일자합
|
||||
const total = (r.tot_distance !== undefined && r.tot_distance !== null)
|
||||
? num(r.tot_distance)
|
||||
: distByDay.reduce((a, b) => a + b, 0);
|
||||
|
||||
return fitCols([
|
||||
idx + 1, // 순번(엑셀은 재계산)
|
||||
safe(r.usr_id),
|
||||
safe(r.usr_nm),
|
||||
safe(r.shoot_cnt),
|
||||
...distByDay,
|
||||
total
|
||||
]);
|
||||
});
|
||||
|
||||
const aoa = [header1, header2, ...body];
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
|
||||
/* =======================
|
||||
* 3) 병합(merge)
|
||||
* ======================= */
|
||||
const dayStart = fixedCols.length; // 일자 시작 컬럼 index
|
||||
const dayEnd = dayStart + (days.length - 1); // 일자 끝 컬럼 index
|
||||
const totalColIdx = fixedCols.length + days.length; // 합계 컬럼 index
|
||||
|
||||
const merges = [
|
||||
// 고정 4개 rowspan 2
|
||||
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } }, // 순번
|
||||
{ s: { r: 0, c: 1 }, e: { r: 1, c: 1 } }, // 아이디
|
||||
{ s: { r: 0, c: 2 }, e: { r: 1, c: 2 } }, // 이름
|
||||
{ s: { r: 0, c: 3 }, e: { r: 1, c: 3 } }, // 촬영
|
||||
// 합계 rowspan 2
|
||||
{ s: { r: 0, c: totalColIdx }, e: { r: 1, c: totalColIdx } },
|
||||
];
|
||||
|
||||
// 일자별 그룹 colspan (days가 1개 이상일 때만)
|
||||
if (days.length > 0) {
|
||||
merges.push({ s: { r: 0, c: dayStart }, e: { r: 0, c: dayEnd } });
|
||||
}
|
||||
|
||||
ws["!merges"] = merges;
|
||||
|
||||
/* =======================
|
||||
* 4) 컬럼 너비
|
||||
* ======================= */
|
||||
const cols = [];
|
||||
cols.push({ wpx: 50 }); // 순번
|
||||
cols.push({ wpx: 90 }); // 아이디
|
||||
cols.push({ wpx: 90 }); // 이름
|
||||
cols.push({ wpx: 60 }); // 촬영
|
||||
|
||||
// 일자 컬럼들
|
||||
for (let i = 0; i < days.length; i++) cols.push({ wpx: 55 });
|
||||
|
||||
cols.push({ wpx: 80 }); // 합계
|
||||
ws["!cols"] = cols;
|
||||
|
||||
/* =======================
|
||||
* 5) (선택) 헤더 스타일/테두리 (xlsx-js-style 사용 시)
|
||||
* ======================= */
|
||||
const lastRow = aoa.length - 1;
|
||||
const lastCol = COLS - 1;
|
||||
|
||||
const headerStyle = {
|
||||
font: { bold: true },
|
||||
alignment: { horizontal: "center", vertical: "center" },
|
||||
fill: { patternType: "solid", fgColor: { rgb: "F2F2F2" } },
|
||||
border: {
|
||||
top: { style: "thin", color: { rgb: "D9D9D9" } },
|
||||
bottom: { style: "thin", color: { rgb: "D9D9D9" } },
|
||||
left: { style: "thin", color: { rgb: "D9D9D9" } },
|
||||
right: { style: "thin", color: { rgb: "D9D9D9" } },
|
||||
}
|
||||
};
|
||||
|
||||
const cellBorder = {
|
||||
border: {
|
||||
top: { style: "thin", color: { rgb: "E0E0E0" } },
|
||||
bottom: { style: "thin", color: { rgb: "E0E0E0" } },
|
||||
left: { style: "thin", color: { rgb: "E0E0E0" } },
|
||||
right: { style: "thin", color: { rgb: "E0E0E0" } },
|
||||
}
|
||||
};
|
||||
|
||||
for (let r = 0; r <= lastRow; r++) {
|
||||
for (let c = 0; c <= lastCol; c++) {
|
||||
const addr = XLSX.utils.encode_cell({ r, c });
|
||||
if (!ws[addr]) ws[addr] = { t: "s", v: "" };
|
||||
ws[addr].s = Object.assign({}, ws[addr].s || {}, cellBorder);
|
||||
if (r <= 1) ws[addr].s = Object.assign({}, ws[addr].s, headerStyle);
|
||||
}
|
||||
}
|
||||
ws["!rows"] = [{ hpx: 24 }, { hpx: 24 }];
|
||||
|
||||
/* =======================
|
||||
* 6) 저장
|
||||
* ======================= */
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "sheet1");
|
||||
XLSX.writeFile(wb, "개인별_이동거리_" + getDateTimeString() + ".xlsx");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 말일 계산
|
||||
function getDaysByEndDate(edate) {
|
||||
if (!edate) return [];
|
||||
|
||||
const endDay = Number(edate.substr(8, 2)); // 말일 (ex: 31)
|
||||
const days = [];
|
||||
|
||||
for (let d = 1; d <= endDay; d++) {
|
||||
days.push(String(d).padStart(2, '0')); // "01" ~ "31"
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function getDateTimeString() {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${yyyy}${mm}${dd}${hh}${mi}${ss}`;
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", function () {
|
||||
// 오늘 날짜
|
||||
const today = new Date();
|
||||
|
||||
// 이번 달 1일
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
|
||||
// 이번 달 말일 (다음달 0일 = 이번달 말일)
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
// yyyy-mm-dd 형태로 변환
|
||||
const toDate = (d) => d.toISOString().split("T")[0];
|
||||
|
||||
document.getElementById("sdate").value = toDate(firstDay);
|
||||
document.getElementById("edate").value = toDate(lastDay);
|
||||
});
|
||||
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
Reference in New Issue
Block a user