최초 커밋

This commit is contained in:
root committed 2026-07-23 16:15:57 +09:00
commit c8f5efb6b7
14652 files changed
+4812531

No files matched your search

+716
View File
@@ -0,0 +1,716 @@
@charset "UTF-8";
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap");
/**
* 반응형 폰트 크기 (responsive font)
* @param {number} $min-px - 최소 폰트 크기 (px)
* @param {number} $max-px - 최대 폰트 크기 (px)
* @param {number} $min-vw - 최소 뷰포트 너비 (기본값: 375px)
* @param {number} $max-vw - 최대 뷰포트 너비 (기본값: 1920px)
*/
/**
* 박스 그림자 (box-shadow)
* @param {number} $x - X 오프셋 (기본값: 0px)
* @param {number} $y - Y 오프셋 (기본값: 3px)
* @param {number} $blur - 블러 반경 (기본값: 6px)
* @param {number} $spread - 확산 반경 (기본값: 0px)
* @param {color} $color - 색상 (기본값: rgba(0, 0, 0, 0.25))
* @param {boolean} $important - !important 사용 여부 (기본값: false)
*/
/**
* 텍스트 그림자(text-shadow)
* @param {number} $x - X 오프셋 (기본값: 0px)
* @param {number} $y - Y 오프셋 (기본값: 1px)
* @param {number} $blur - 블러 반경 (기본값: 0px)
* @param {color} $color - 색상 (기본값: rgba(0, 0, 0, 0.25))
* @param {boolean} $important - !important 사용 여부 (기본값: false)
*/
/**
* 미디어 쿼리 mixin
* @param {string|number} $width - 브레이크포인트 이름 또는 픽셀 값
* @param {string} $type - 'min' 또는 'max'
* @example
* @include mq('desktop') { ... }
* @include mq(1920px) { ... }
* @include mq('tablet', 'max') { ... }
*/
html {
font-size: 10px;
}
html[lang=ko-KR], html[lang=ko] {
font-family: "Noto Sans KR", sans-serif;
}
html[lang=en],
html *[lang=en] {
font-family: "Noto Sans KR", Arial, sans-serif, serif;
}
html[lang=en] body,
html *[lang=en] body {
font-size: 18px;
font-weight: 400;
}
html body {
font-size: 16px;
font-weight: 400;
line-height: 1.4;
color: #000;
letter-spacing: -0.04em;
}
/* ====================================================================== */
/* [Web Font]
/* ====================================================================== */
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
html {
-webkit-text-size-adjust: 100%;
}
main {
display: block;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type=button]::-moz-focus-inner,
[type=reset]::-moz-focus-inner,
[type=submit]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type=button]:-moz-focusring,
[type=reset]:-moz-focusring,
[type=submit]:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type=checkbox],
[type=radio] {
box-sizing: border-box;
padding: 0;
}
[type=number]::-webkit-inner-spin-button,
[type=number]::-webkit-outer-spin-button {
height: auto;
-webkit-appearance: none;
appearance: none;
margin: 0;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}
/* reset */
html * {
box-sizing: border-box;
word-wrap: break-word;
}
body,
div,
dl,
dt,
dd,
ul,
ol,
li,
h1,
h2,
h3,
h4,
h5,
h6,
pre,
code,
form,
figure,
legend,
input,
textarea,
button,
p,
blockquote,
th,
td,
form,
fieldset,
blockquote,
iframe {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
article,
aside,
canvas,
details,
embed,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section,
summary {
display: block;
}
command,
datalist,
keygen,
mark,
meter,
progress,
rp,
rt,
ruby,
time,
wbr {
display: inline;
}
img {
display: inline-block;
vertical-align: top;
max-width: 100%;
border: 0;
image-rendering: -moz-crisp-edges; /* firefox */
image-rendering: -o-crisp-edges; /* opera */
image-rendering: -webkit-optimize-contrast; /* chrome(비표준) */
image-rendering: crisp-edges;
transform: translateZ(0);
}
fieldset {
border: 0;
}
ul,
ol,
li {
list-style: none;
}
pre {
white-space: pre-wrap;
}
legend,
caption {
position: relative;
clip: rect(0 0 0 0);
clip-path: inset(50%);
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
border: 0;
padding: 0;
white-space: nowrap;
clear: both;
}
a {
color: inherit;
cursor: pointer;
background-color: transparent;
}
a:link {
text-decoration: none;
}
a:hover, a:focus, a:active, a:visited {
text-decoration: none;
}
em,
i,
address,
cite {
font-style: normal;
font-weight: normal;
}
input,
textarea,
select,
button,
table {
font-size: inherit;
font-family: inherit;
border: 0;
background-color: transparent;
}
button,
select {
cursor: pointer;
}
textarea,
input,
select {
border-radius: 0;
border: 0;
outline-color: -moz-use-text-color;
outline-width: medium;
}
textarea {
resize: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
label {
cursor: pointer;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
table {
table-layout: fixed;
border-collapse: collapse;
border-spacing: 0;
}
th,
td {
border-collapse: collapse;
}
select::-ms-expand {
display: none;
}
/* 스크롤바 스타일 */
::-webkit-scrollbar {
height: 10px;
width: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #aaa;
border-radius: 8px;
background-clip: padding-box;
border: 2px solid transparent;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::before,
::after {
box-sizing: border-box;
}
/* clearfix */
.clearfix {
/* IE 지원 방식 : *zoom: 1;*/
}
.clearfix:before, .clearfix:after {
content: "";
display: block;
}
.clearfix:after {
clear: both;
}
/* hidden */
.blind {
position: absolute;
clip: rect(0 0 0 0);
clip-path: inset(50%);
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
border: 0;
padding: 0;
white-space: nowrap;
}
.d-none {
display: none !important;
}
[class*=gra] {
position: relative;
}
[class*=gra]::before, [class*=gra]::after {
content: "";
position: absolute;
border-radius: inherit;
pointer-events: none;
}
[class*=gra]::after {
inset: 0;
padding: 1px;
-webkit-mask: var(--mask-contents);
mask: var(--mask-contents);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.flex {
display: flex;
align-items: center;
justify-content: center;
}
/* toggle btn*/
.toggle-area {
position: relative;
display: flex;
padding: 6px;
width: 172px;
height: 48px;
background: #e5dfd3;
border: 1px solid #fff;
border-radius: 50px;
box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.2) inset;
}
.toggle-area input[type=radio] {
display: none;
}
.toggle-area input[type=radio]:checked + label {
color: white;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
text-shadow: -1px -1px 0 rgba(0, 0, 0, 0.5), 1px -1px 0 rgba(0, 0, 0, 0.5), -1px 1px 0 rgba(0, 0, 0, 0.5), 1px 1px 0 rgba(0, 0, 0, 0.5);
}
.toggle-area input[type=radio]:checked:nth-of-type(2) ~ .toggle-slider {
transform: translateX(100%);
}
.toggle-area label {
flex: 1;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
z-index: 2;
font-weight: 700;
font-size: 16px;
color: rgba(16, 16, 16, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
.toggle-slider {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: linear-gradient(179deg, #ff5c00 47.43%, #943600 99.56%);
border-radius: 50px;
transition: transform 0.3s ease;
z-index: 1;
box-shadow: 0 -1px 2px 0 rgba(0, 0, 0, 0.2), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
}
.toggle-slider::after {
content: " ";
display: block;
width: 100%;
height: 100%;
background: linear-gradient(180deg, #ffb68e 0%, #cd6f3b 24%, #ffdbc8 50%, #682500 83%, #a04411 100%);
inset: 0;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
border-radius: inherit;
padding: 1px;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
width: 100%;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
position: sticky;
top: 0;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
}
.modal-header h2 {
font-size: 24px;
color: #1f2937;
}
.modal-close {
background: none;
border: none;
font-size: 32px;
color: #000;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.modal-close:hover {
background: #f3f4f6;
color: #4b5563;
}
.modal-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 60px;
}
.modal .spinner {
border: 3px solid #e5e7eb;
border-top: 3px solid #2563eb;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* media query */
/*
@media all and (min-width: 3841px) {
html {
//font-size: 14px;
}
}
// 3200px 초과 ~ 3840px: 4K 디스플레이
@media all and (min-width: 3201px) and (max-width: 3840px) {
html {
//font-size: 13px;
}
}
// 2560px 초과 ~ 3200px: 매우 큰 데스크톱
@media all and (min-width: 2561px) and (max-width: 3200px) {
html {
//font-size: 12px;
}
}
// 1920px 초과 ~ 2560px: 큰 데스크톱
@media all and (min-width: 1921px) and (max-width: 2560px) {
html {
//font-size: 11px;
font-size: 10px;
}
}
*/
@media all and (min-width: 1024px) {
html {
font-size: 9px;
}
}
@media all and (min-width: 1024px) and (max-width: 1920px) {
html {
font-size: 9px;
}
}
/*
// 1023px 이하: 태블릿
@media all and (max-width: 1023px) {
html {
font-size: 14px;
}
}
// 767px 이하
@media all and (max-width: 767px) {
html {
font-size: 13px;
}
}
// 639px 이하
@media all and (max-width: 639px) {
html {
font-size: 12px;
}
}
// 479px 이하
@media all and (max-width: 479px) {
html {
font-size: 10px;
}
}
// 359px 이하
@media all and (max-width: 359px) {
html {
font-size: 9px;
}
}
// 319px 이하
@media all and (max-width: 319px) {
html {
font-size: 8px;
}
}
*/
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
<svg width="276" height="135" fill="none" xmlns="http://www.w3.org/2000/svg"><g opacity=".35" fill="#fff"><path d="M126.53 81.446c-8.872 3.213-13.546.174-15.62-2.074l-23.7 25.861c-20.014 21.67-44.027 30.023-62.261 22.833l94.655-102.422s9.409-11.67 20.237-19.243h-.009c.772-.56 1.067-1.096.814-2.12-.623-2.542-6.963-4.23-14.506-4.205h-.006l-18.839.046s-6.918-.28-11.502.009c-.95.06-13.033.576-17.116.917-4.17.347-14.935 2.055-17.951 2.804-19.212 4.766-37.584 13.223-51.85 28.224-18.766 19.727-28.538 43.07-27.516 65.73.751 16.687 7.433 31.356 18.812 41.299 10.673 9.328 24.061 14.213 38.528 14.21 3.708 0 7.49-.32 11.311-.969 18.899-3.21 37.371-14.093 53.448-31.503l34.266-37.384c-1.145-1.4-4.533-4.428-11.198-2.016l.003.003ZM3.648 96.758C2.914 80.456 10.38 63.053 24.874 47.96 42.026 30.1 56.046 24.79 69.238 24.018c6.21-.362 11.88.372 18.025 3.2L7.954 113.252c-2.545-4.766-4.032-10.375-4.306-16.493Z"/><path d="M275.378 54.144c-1.561-16.886-9.802-31.984-22.602-41.414-30.568-22.518-66.159-14.916-97.668 20.846l-35.901 38.842s.003.002.003.005l-3.006 3.261c.722.948 3.302 3.288 10.023.878 8.291-2.979 14.018.13 16.849 3.176l5.066-5.489 23.367-25.278.185-.206c23.991-27.283 46.853-33.238 67.942-17.701 7.607 5.603 12.522 14.788 13.486 25.196 1.443 15.58-5.413 33.39-20.661 48.817-21.345 21.591-41.852 28.048-62.603 21.388l30.783-33.94-16.451-15.34-45.999 50.718s-9.134 11.234-19.76 18.77c-1.155.693-1.627 1.22-1.326 2.437.606 2.449 6.593 4.102 13.845 4.177l.063.028 19.199-.009s16.314.06 19.76-.185c.656-.046 12.229-.96 14.51-1.148 2.037-.166 11.976-1.949 14.21-2.512 19.521-4.902 35.241-12.912 50.041-28.851 19.232-20.71 28.694-44.317 26.642-66.469l.003.003Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg" data-name="레이어_2"><g data-name="레이어_1" class="layer"><path d="M31.59 31.61c-1.81.72-2.76.04-3.19-.47l-4.83 5.85c-4.09 4.9-8.97 6.79-12.69 5.16l19.3-23.16s1.93-2.63 4.12-4.35c.16-.12.21-.25.18-.47-.14-.58-1.42-.97-2.96-.95l-3.83.02s-1.42-.07-2.35 0c-.19.02-2.67.12-3.48.21-.86.07-3.04.46-3.66.63-3.91 1.07-7.67 2.98-10.57 6.37-3.83 4.46-5.82 9.74-5.6 14.85.16 3.77 1.52 7.09 3.83 9.34 2.18 2.11 4.9 3.21 7.86 3.21.76 0 1.54-.07 2.32-.21 3.85-.72 7.61-3.18 10.9-7.13l6.98-8.44c-.23-.32-.91-1-2.28-.46h-.06zM6.55 35.07c-.16-3.69 1.36-7.62 4.32-11.03 3.5-4.04 6.34-5.23 9.05-5.41 1.26-.09 2.41.09 3.68.72L7.43 38.8c-.53-1.07-.82-2.35-.88-3.72v-.02z" style="fill:#ca2829"/><path d="M61.56 25.31c-.31-3.84-1.98-7.27-4.59-9.41-6.23-5.13-13.46-3.39-19.87 4.74l-7.3 8.83-.6.74c.14.21.66.75 2.04.19 1.69-.68 2.86.04 3.42.72l1.03-1.25 4.75-5.74.04-.05c4.88-6.2 9.53-7.57 13.83-4.02 1.56 1.28 2.55 3.37 2.74 5.72.29 3.55-1.11 7.58-4.2 11.1-4.34 4.92-8.52 6.37-12.72 4.86l6.27-7.72-3.35-3.49-9.36 11.53s-1.87 2.55-4.03 4.27c-.23.16-.33.28-.27.54.12.56 1.34.93 2.82.95h3.93s3.33.02 4.03-.04c.14-.02 2.49-.23 2.96-.26.41-.04 2.43-.44 2.9-.56 3.97-1.12 7.18-2.93 10.18-6.57 3.91-4.71 5.84-10.08 5.43-15.12l-.08.04z" style="fill:#263b7c"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#1f1f1f"><path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/></svg>

After

Width:  |  Height:  |  Size: 185 B

@@ -0,0 +1 @@
<svg width="100" height="100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h100C80.15 4.572 65.782 29.834 52.14 53.82 38.652 77.534 25.874 100 9.21 100H0V0Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -0,0 +1 @@
"use strict";function safeGrad(t,a,e){var r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(!a||void 0===a.left||void 0===a.right)return e[0].color;var i=a.left,n=a.right,o=a.top,l=a.bottom,s=r?t.createLinearGradient(0,o,0,l):t.createLinearGradient(i,0,n,0);return e.forEach(function(t){return s.addColorStop(t.stop,t.color)}),s}var hideNullPointsPlugin={id:"hideNullPoints",beforeDatasetsDraw:function(t){t.data.datasets.forEach(function(t){if("line"===t.type){var a=t.data;Array.isArray(a)&&(Array.isArray(t.pointRadius)||(t.pointRadius=a.map(function(){return t.pointRadius||6})),Array.isArray(t.pointHoverRadius)||(t.pointHoverRadius=a.map(function(){return t.pointHoverRadius||8})),t.pointRadius=a.map(function(a,e){return null===a?0:t.pointRadius[e]}),t.pointHoverRadius=a.map(function(a,e){return null===a?0:t.pointHoverRadius[e]}))}})}},highlightXFactory=function(t){var a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:cssVar("--bg-chart-highlight");return{id:"highlightX_".concat(t),beforeDatasetsDraw:function(e){if(e.chartArea){var r=e.ctx,i=e.chartArea,n=e.scales.x,o=e.data.labels.indexOf(t);if(-1!==o){var l=n.getPixelForValue(o+1)-n.getPixelForValue(o);isNaN(l)&&o>0&&(l=n.getPixelForValue(o)-n.getPixelForValue(o-1)),r.save(),r.fillStyle=a,r.fillRect(n.getPixelForValue(o)-l/2,i.top,l,i.bottom-i.top),r.restore()}}}}};function createMixedValuePlugin(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:10;return{id:"mixedValuePlugin",afterDatasetsDraw:function(a){var e=a.ctx,r=[],i=[],n=[];a.data.datasets.forEach(function(t,e){var r=a.getDatasetMeta(e);"line"===t.type&&r&&r.data&&r.data.forEach(function(a,e){var r,i,o=t.data[e];if(null!=o){var l=Array.isArray(t.pointRadius)?null!==(r=t.pointRadius[e])&&void 0!==r?r:3:null!==(i=t.pointRadius)&&void 0!==i?i:3;n.push({x:a.x,y:a.y,radius:l})}})}),a.data.datasets.forEach(function(n,o){var l=a.getDatasetMeta(o);l&&l.data&&l.data.forEach(function(a,l){var s=n.data[l];if(null!=s){var d=String(s);e.font="bold 1.2rem Noto Sans KR, sans-serif";var f,u=e.measureText(d).width;"bar"===n.type?f=a.y:"line"===n.type&&(f=a.y-t);var c={datasetIndex:o,elementIndex:l,element:a,value:s,type:n.type,x:a.x,y:f,originalY:f,textWidth:u,textHeight:20};"bar"===n.type?r.push(c):"line"===n.type&&i.push(c)}})});var o=function(t,a){var e=Math.abs(t.x-a.x)<10,r=Math.abs(t.y-a.y)<25;return e&&r};r.forEach(function(t){n.forEach(function(a){(function(t,a){if(!(Math.abs(t.x-a.x)<10))return!1;var e=t.textWidth/2,r=(t.y,t.y,a.radius+3),i=Math.abs(t.x-a.x),n=Math.abs(t.y-a.y);return i<e+r&&n<r+10})(t,a)&&(t.y=a.y-a.radius-5)})}),i.forEach(function(t){r.forEach(function(a){o(t,a)&&(t.y=a.y-20)})});for(var l=0;l<i.length;l++)for(var s=l+1;s<i.length;s++)o(i[l],i[s])&&(i[s].y=i[l].y-20);for(var d=0;d<r.length;d++)for(var f=d+1;f<r.length;f++)o(r[d],r[f])&&(r[f].y=r[d].y-20);[].concat(r,i).forEach(function(t){var r=a.data.datasets[t.datasetIndex];e.save(),e.font="bold 1.2rem Noto Sans KR, sans-serif",e.textAlign="center",e.textBaseline="bottom";var i=String(t.value);if("bar"===r.type)e.strokeStyle="white",e.lineWidth=3,e.fillStyle=cssVar("--chart-mixbar"),e.strokeText(i,t.x,t.y),e.fillText(i,t.x,t.y);else if("line"===r.type){var n=e.measureText(i).width,o=t.x-n/2-4,l=t.y-12-4,s=n+8;e.fillStyle="rgba(233, 192, 192, 0.5)",e.beginPath(),e.roundRect(o,l,s,20,4),e.fill(),e.strokeStyle="white",e.lineWidth=3,e.fillStyle=cssVar("--chart-mixline"),e.strokeText(i,t.x,t.y),e.fillText(i,t.x,t.y)}e.restore()})}}}function createMixedPointGradientPlugin(t){return{id:"mixedPointGradient",afterDatasetsDraw:function(a){var e=a.ctx;a.data.datasets.forEach(function(r,i){var n=a.getDatasetMeta(i);"line"===r.type&&n&&n.data&&n.data.forEach(function(a,i){var n,o,l=a.x,s=a.y,d=Array.isArray(r.pointRadius)?null!==(n=r.pointRadius[i])&&void 0!==n?n:3:null!==(o=r.pointRadius)&&void 0!==o?o:3;if(0!==d){var f=l-d+2*d*.3333,u=s-d+2*d*.3333,c=.7862*d,h=e.createRadialGradient(f,u,0,f,u,c);(t||[{stop:1,color:cssVar("--bg-chart-mixbullet")}]).forEach(function(t){return h.addColorStop(t.stop,t.color)}),e.save(),e.beginPath(),e.arc(l,s,d,0,2*Math.PI),e.fillStyle=h,e.fill(),e.shadowColor="rgba(0,0,0,0.4)",e.shadowBlur=4,e.shadowOffsetX=0,e.shadowOffsetY=0,e.strokeStyle="rgba(255,255,255)",e.lineWidth=2,e.stroke(),e.restore()}})})}}}function createPointGradientPluginWithBorder(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{id:"pointGradientWithBorder",afterDatasetsDraw:function(a){var e=a.ctx,r=[];a.data.datasets.forEach(function(i,n){var o=a.getDatasetMeta(n);o&&"line"===o.type&&!o.hidden&&o.data.forEach(function(a,o){var l,s;if(void 0!==a&&null!==i.data[o]){var d=a.x,f=a.y,u=i.data[o],c=Array.isArray(i.pointRadius)?null!==(l=i.pointRadius[o])&&void 0!==l?l:3:null!==(s=i.pointRadius)&&void 0!==s?s:3;if(0!==c){var h=d-c+2*c*.3333,v=f-c+2*c*.3333,x=.7862*c,g=e.createRadialGradient(h,v,0,h,v,x),y=t[i.label];if(y&&Array.isArray(y)?y.forEach(function(t){return g.addColorStop(t.stop,t.color)}):Line truncated
+1
View File
@@ -0,0 +1 @@
"use strict";function safeGrad(t,a,e){var r=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(!a||void 0===a.left||void 0===a.right)return e[0].color;var i=a.left,n=a.right,l=a.top,o=a.bottom,s=r?t.createLinearGradient(0,l,0,o):t.createLinearGradient(i,0,n,0);return e.forEach(function(t){return s.addColorStop(t.stop,t.color)}),s}var hideNullPointsPlugin={id:"hideNullPoints",beforeDatasetsDraw:function(t){t.data.datasets.forEach(function(t){if("line"===t.type){var a=t.data;Array.isArray(a)&&(Array.isArray(t.pointRadius)||(t.pointRadius=a.map(function(){return t.pointRadius||6})),Array.isArray(t.pointHoverRadius)||(t.pointHoverRadius=a.map(function(){return t.pointHoverRadius||8})),t.pointRadius=a.map(function(a,e){return null===a?0:t.pointRadius[e]}),t.pointHoverRadius=a.map(function(a,e){return null===a?0:t.pointHoverRadius[e]}))}})}},highlightXFactory=function(t){var a=arguments.length>1&&void 0!==arguments[1]?arguments[1]:cssVar("--bg-chart-highlight");return{id:"highlightX_".concat(t),beforeDatasetsDraw:function(e){if(e.chartArea){var r=e.ctx,i=e.chartArea,n=e.scales.x,l=e.data.labels.indexOf(t);if(-1!==l){var o=n.getPixelForValue(l+1)-n.getPixelForValue(l);isNaN(o)&&l>0&&(o=n.getPixelForValue(l)-n.getPixelForValue(l-1)),r.save(),r.fillStyle=a,r.fillRect(n.getPixelForValue(l)-o/2,i.top,o,i.bottom-i.top),r.restore()}}}}};function createMixedValuePlugin(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:10;return{id:"mixedValuePlugin",afterDatasetsDraw:function(a){var e=a.ctx,r=[],i=[],n=[];a.data.datasets.forEach(function(t,e){var r=a.getDatasetMeta(e);"line"===t.type&&r&&r.data&&r.data.forEach(function(a,e){var r,i,l=t.data[e];if(null!=l){var o=Array.isArray(t.pointRadius)?null!==(r=t.pointRadius[e])&&void 0!==r?r:3:null!==(i=t.pointRadius)&&void 0!==i?i:3;n.push({x:a.x,y:a.y,radius:o})}})}),a.data.datasets.forEach(function(n,l){var o=a.getDatasetMeta(l);o&&o.data&&o.data.forEach(function(a,o){var s=n.data[o];if(null!=s){var d=String(s);e.font="bold 1.2rem Noto Sans KR, sans-serif";var f,u=e.measureText(d).width;"bar"===n.type?f=a.y:"line"===n.type&&(f=a.y-t);var c={datasetIndex:l,elementIndex:o,element:a,value:s,type:n.type,x:a.x,y:f,originalY:f,textWidth:u,textHeight:20};"bar"===n.type?r.push(c):"line"===n.type&&i.push(c)}})});var l=function(t,a){var e=Math.abs(t.x-a.x)<10,r=Math.abs(t.y-a.y)<25;return e&&r};r.forEach(function(t){n.forEach(function(a){(function(t,a){if(!(Math.abs(t.x-a.x)<10))return!1;var e=t.textWidth/2,r=(t.y,t.y,a.radius+3),i=Math.abs(t.x-a.x),n=Math.abs(t.y-a.y);return i<e+r&&n<r+10})(t,a)&&(t.y=a.y-a.radius-5)})}),i.forEach(function(t){r.forEach(function(a){l(t,a)&&(t.y=a.y-20)})});for(var o=0;o<i.length;o++)for(var s=o+1;s<i.length;s++)l(i[o],i[s])&&(i[s].y=i[o].y-20);for(var d=0;d<r.length;d++)for(var f=d+1;f<r.length;f++)l(r[d],r[f])&&(r[f].y=r[d].y-20);[].concat(r,i).forEach(function(t){var r=a.data.datasets[t.datasetIndex];e.save(),e.font="bold 1.2rem Noto Sans KR, sans-serif",e.textAlign="center",e.textBaseline="bottom";var i=String(t.value);if("bar"===r.type)e.strokeStyle="white",e.lineWidth=3,e.fillStyle=cssVar("--chart-mixbar"),e.strokeText(i,t.x,t.y),e.fillText(i,t.x,t.y);else if("line"===r.type){var n=e.measureText(i).width,l=t.x-n/2-4,o=t.y-12-4,s=n+8;e.fillStyle="rgba(233, 192, 192, 0.5)",e.beginPath(),e.roundRect(l,o,s,20,4),e.fill(),e.strokeStyle="white",e.lineWidth=3,e.fillStyle=cssVar("--chart-mixline"),e.strokeText(i,t.x,t.y),e.fillText(i,t.x,t.y)}e.restore()})}}}function createMixedPointGradientPlugin(t){return{id:"mixedPointGradient",afterDatasetsDraw:function(a){var e=a.ctx;a.data.datasets.forEach(function(r,i){var n=a.getDatasetMeta(i);"line"===r.type&&n&&n.data&&n.data.forEach(function(a,i){var n,l,o=a.x,s=a.y,d=Array.isArray(r.pointRadius)?null!==(n=r.pointRadius[i])&&void 0!==n?n:3:null!==(l=r.pointRadius)&&void 0!==l?l:3;if(0!==d){var f=o-d+2*d*.3333,u=s-d+2*d*.3333,c=.7862*d,h=e.createRadialGradient(f,u,0,f,u,c);(t||[{stop:1,color:cssVar("--bg-chart-mixbullet")}]).forEach(function(t){return h.addColorStop(t.stop,t.color)}),e.save(),e.beginPath(),e.arc(o,s,d,0,2*Math.PI),e.fillStyle=h,e.fill(),e.shadowColor="rgba(0,0,0,0.4)",e.shadowBlur=4,e.shadowOffsetX=0,e.shadowOffsetY=0,e.strokeStyle="rgba(255,255,255)",e.lineWidth=2,e.stroke(),e.restore()}})})}}}function createPointGradientPluginWithBorder(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return{id:"pointGradientWithBorder",afterDatasetsDraw:function(a){var e=a.ctx,r=[];a.data.datasets.forEach(function(i,n){var l=a.getDatasetMeta(n);l&&"line"===l.type&&!l.hidden&&l.data.forEach(function(a,l){var o,s;if(void 0!==a&&null!==i.data[l]){var d=a.x,f=a.y,u=i.data[l],c=Array.isArray(i.pointRadius)?null!==(o=i.pointRadius[l])&&void 0!==o?o:3:null!==(s=i.pointRadius)&&void 0!==s?s:3;if(0!==c){var h=d-c+2*c*.3333,v=f-c+2*c*.3333,g=.7862*c,x=e.createRadialGradient(h,v,0,h,v,g),y=t[i.label];if(y&&Array.isArray(y)?y.forEach(function(t){return x.addColorStop(t.stop,t.color)}):Line truncated
+1
View File
@@ -0,0 +1 @@
"use strict";var scrollY,wrap,MOBILE_BREAKPOINT=1025;function syncHeight(){document.documentElement.style.setProperty("--window-inner-height","".concat(window.innerHeight,"px"))}function isMobile(){return window.innerWidth<MOBILE_BREAKPOINT}function bodyLock(){wrap?(scrollY=window.scrollY,document.documentElement.classList.add("is-locked"),document.documentElement.style.scrollBehavior="auto",wrap.style.top="-".concat(scrollY,"px")):console.warn("wrap element not found")}function bodyUnlock(){document.documentElement.classList.remove("is-locked"),void 0!==scrollY&&window.scrollTo(0,scrollY),wrap&&(wrap.style.top=""),document.documentElement.style.scrollBehavior=""}function popOpen(e){document.getElementById(e)?($("#"+e).fadeIn("fast"),bodyLock()):console.warn('Popup with id "'.concat(e,'" not found'))}function popClose(e){$(e).parents(".popup").fadeOut("fast"),bodyUnlock()}document.addEventListener("DOMContentLoaded",function(){(wrap=document.querySelector(".wrap"))?syncHeight():console.error("Wrap element not found")}),window.addEventListener("resize",function(){syncHeight()});var rootStyle=getComputedStyle(document.documentElement);function cssVar(e){return rootStyle.getPropertyValue(e).trim()}function fullScreen(){document.fullscreenElement?document.exitFullscreen().catch(function(e){console.log("전체화면 종료 실패:",e)}):document.documentElement.requestFullscreen().catch(function(e){console.log("전체화면 전환 실패:",e)})}
+1
View File
@@ -0,0 +1 @@
"use strict";var eventsData={"2025-10-31":[{time:"",title:"영업 활동",tags:["염"]}],"2025-11-03":[{time:"",title:"EG-BIM V1.2.0 업데이트 문의 대응",tags:["김"]},{time:"",title:"외부사용자 업데이트 안내 ( 개인설정 저장 )<br><em>※ 메일전송 후 유선안내</em>",tags:["김"]}],"2025-11-04":[{time:"",title:"더부엔지니어링",tags:["염"]},{time:"",title:"선인이엔지",tags:["염"]},{time:"",title:"제이알조경 ID&PW 생성(4)",tags:["김"]}],"2025-11-05":[{time:"12:00~14:00",title:"연세대 ( 한맥 구조부 부서장님 연계교 )",tags:["염"]},{time:"15:00~16:00",title:"대림대 ( 이진형교수 )",tags:["염"]},{time:"16:00~18:00",title:"한길아이티 ( 계약헙의 및 EG-BIM 소개 )",tags:["권"]},{time:"",title:"안양, 과천, 방문업체 선정",tags:["권"]},{time:"",title:"캐드제품군 가격정보 최신화",tags:["권"]},{time:"",title:"가족사 부서별 CAD 프로그램 사용현황 파악",tags:["김"]}],"2025-11-06":[{time:"09:00~10:00",title:"피컴스엔지니어링",tags:["염"]},{time:"11:00~12:00",title:"영업대시보드 회의",tags:["권","염","김"]},{time:"",title:"삼안 CM현장 방문협의 : 통복공공하수처리시설( KG, 갑인, 세일 : 9명 미팅가능)",tags:["권"]}],"2025-11-07":[{time:"14:00~15:00",title:"- 마이다스아이티 계약협의 (거여동)",tags:["권","염"]},{time:"",title:"삼안 & 한맥 CM현장 방문협의",tags:["권"]},{time:"",title:"구조기술사회 팝업사항 진도사항 점검",tags:["염"]},{time:"",title:"인하공전 교수님 연락처 확인",tags:["염"]}],"2025-11-10":[{time:"10:00~12:00",title:"큰길 개발자 최종리뷰",tags:["권","염"]},{time:"15:00~16:00",title:"사장님 영업보고",tags:["권","염"]},{time:"17:00~18:00",title:"영업대시보드 회의",tags:["염"]}],"2025-11-11":[{time:"10:30~11:30",title:"삼안 CM현장 : 통복공공하수처리시설, 평택 홍만기단장(공동도급관련 5개사)",tags:["권","염"]},{time:"13:00~14:00",title:"공단배수지 추가 증설공사 등 7개소 통합건설사업관리단 ( 동명기술공단 외 )김민관단장",tags:["권","염"]},{time:"15:00~16:00",title:"서림주택관리",tags:["권"]},{time:"",title:"영업대시보드 기획",tags:["김"]},{time:"",title:"DREAM PLUS",tags:["김"]}],"2025-11-12":[{time:"13:00~14:00",title:"대전대 유병재 교수",tags:["염"]},{time:"15:00~16:00",title:"우송대 안태봉교수",tags:["염"]},{time:"17:00~18:00",title:"CIVIL EXPO 준비참석",tags:["염"]},{time:"17:00~18:00",title:"한국종합엔지니어링 기획팀 방문",tags:["권"]},{time:"",title:"원건설(청주): 오전",tags:["권"]}],"2025-11-13":[{time:"14:00~15:00",title:"나모소프트 ( 연구소 팀장, 유선협의)",tags:["권"]},{time:"",title:"업무지원 ( 장헌산업, 당진공장 )",tags:["권"]},{time:"",title:"CIVIL EXPO 참석",tags:["염"]},{time:"",title:"영업 대시보드 기획 (9,10월 영업내역 전달)",tags:["김"]}],"2025-11-14":[{time:"10:00~11:00",title:"피컴스, 오토데스크 이슈 컨설팅 미팅",tags:["권"]},{time:"",title:"CIVIL EXPO 참석",tags:["염"]},{time:"",title:"각 부서 주사용 DREAM PLUS 명령어 취합",tags:["김"]}],"2025-11-17":[{time:"",title:"의왕도시공사 ( MOU 초안 작성 )",tags:["권","염"]},{time:"",title:" 한라대학교 ( 강의커리큐럼 초안 작성 )",tags:["권","염"]},{time:"",title:"인하공전 특강준비",tags:["염"]},{time:"",title:"영업대시보드 DB 정리",tags:["김"]}],"2025-11-18":[{time:"13:30",title:"인하대 김수전교수",tags:["염"]},{time:"14:00~15:00",title:"[ Smart Construction x OSC x SCCI ]( 오토데스크, 도로공사, 대한 경제, 정보수집 외)",tags:["권"]},{time:"17:00~18:00",title:"인하대 남진원교수",tags:["권"]},{time:"",title:"인천시청",tags:["염"]},{time:"",title:"회원관리시스템 flow작성(EG-BIM / KNGIL)",tags:["김"]}],"2025-11-19":[{time:"11:00~12:00",title:"해린엔지니어링 토목사업부 및 도시계획부 ( 청주 : 배창근 이사님,이동현 이사님) 청주시 청원구 1순환로 1896번길 20",tags:["권","염"]},{time:"13:00~14:00",title:"홍익기술단 안전진단부 방문협의(청주) 청주시 흥덕구 풍년로 150번길 28",tags:["권"]},{time:"13:00~14:00",title:"홍익기술단 총무팀 방문협의 (청주)",tags:["염"]},{time:"",title:"홍익기술단 감리부 방문협의",tags:["권"]},{time:"",title:"회원관리시스템 flow작성(EG-BIM / KNGIL)",tags:["김"]},{time:"",title:"대림대 계정 생성(100copy)",tags:["김"]},{time:"",title:"드림플러스 주사용 명령어 각부서 조사 시트 정리",tags:["김"]}],"2025-11-20":[{time:"11:00~12:00",title:"삼안 CM현장 : 대전산업단지완충저류시설...( 대전 / 박준성 전무님) 대전광역시 대덕구 대화로 150번길 147-10",tags:["권","염"]},{time:"15:00~16:00",title:"삼안 CM현장 : 대청댐광역Line truncated
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
"use strict";function ownKeys(t,e){var i=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),i.push.apply(i,n)}return i}function _objectSpread(t){for(var e=1;e<arguments.length;e++){var i=null!=arguments[e]?arguments[e]:{};e%2?ownKeys(Object(i),!0).forEach(function(e){_defineProperty(t,e,i[e])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):ownKeys(Object(i)).forEach(function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))})}return t}function _superPropGet(t,e,i,n){var r=_get2(_getPrototypeOf(1&n?t.prototype:t),e,i);return 2&n&&"function"==typeof r?function(t){return r.apply(i,t)}:r}function _get2(){return _get2="undefined"!=typeof Reflect&&Reflect.get?Reflect.get.bind():function(t,e,i){var n=_superPropBase(t,e);if(n){var r=Object.getOwnPropertyDescriptor(n,e);return r.get?r.get.call(arguments.length<3?t:i):r.value}},_get2.apply(null,arguments)}function _superPropBase(t,e){for(;!{}.hasOwnProperty.call(t,e)&&null!==(t=_getPrototypeOf(t)););return t}function _slicedToArray(t,e){return _arrayWithHoles(t)||_iterableToArrayLimit(t,e)||_unsupportedIterableToArray(t,e)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArrayLimit(t,e){var i=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=i){var n,r,a,o,s=[],l=!0,h=!1;try{if(a=(i=i.call(t)).next,0===e){if(Object(i)!==i)return;l=!1}else for(;!(l=(n=a.call(i)).done)&&(s.push(n.value),s.length!==e);l=!0);}catch(t){h=!0,r=t}finally{try{if(!l&&null!=i.return&&(o=i.return(),Object(o)!==o))return}finally{if(h)throw r}}return s}}function _arrayWithHoles(t){if(Array.isArray(t))return t}function _callSuper(t,e,i){return e=_getPrototypeOf(e),_possibleConstructorReturn(t,_isNativeReflectConstruct()?Reflect.construct(e,i||[],_getPrototypeOf(t).constructor):e.apply(t,i))}function _possibleConstructorReturn(t,e){if(e&&("object"==_typeof(e)||"function"==typeof e))return e;if(void 0!==e)throw new TypeError("Derived constructors may only return object or undefined");return _assertThisInitialized(t)}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function _isNativeReflectConstruct(){try{var t=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}))}catch(t){}return(_isNativeReflectConstruct=function(){return!!t})()}function _getPrototypeOf(t){return _getPrototypeOf=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},_getPrototypeOf(t)}function _inherits(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&_setPrototypeOf(t,e)}function _setPrototypeOf(t,e){return _setPrototypeOf=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},_setPrototypeOf(t,e)}function _defineProperty(t,e,i){return(e=_toPropertyKey(e))in t?Object.defineProperty(t,e,{value:i,enumerable:!0,configurable:!0,writable:!0}):t[e]=i,t}function _toConsumableArray(t){return _arrayWithoutHoles(t)||_iterableToArray(t)||_unsupportedIterableToArray(t)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}function _arrayWithoutHoles(t){if(Array.isArray(t))return _arrayLikeToArray(t)}function _classCallCheck(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function _defineProperties(t,e){for(var i=0;i<e.length;i++){var n=e[i];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(t,_toPropertyKey(n.key),n)}}function _createClass(t,e,i){return e&&_defineProperties(t.prototype,e),i&&_defineProperties(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t}function _toPropertyKey(t){var e=_toPrimitive(t,"string");return"symbol"==_typeof(e)?e:e+""}function _toPrimitive(t,e){if("object"!=_typeof(t)||!t)return t;var i=t[Symbol.toPrimitive];if(void 0!==i){var n=i.call(t,e||"default");if("object"!=_typeof(n))return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}function _createForOfIteratorHelper(t,e){var i="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!i){if(Array.isArray(t)||(i=_unsupportedILine truncated
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/}!function(t,e){"object"==("undefined"==typeof exports?"undefined":_typeof(exports))&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(void 0,function(){var t,e,i,n,r,a,o,s,l,h,u,c=Object.freeze({__proto__:null,get Colors(){return Xa},get Decimation(){return qa},get Filler(){return po},get Legend(){return bo},get SubTitle(){return wo},get Title(){return xo},get Tooltip(){return Vo}});function f(){}var d=function(){var t=0;return function(){return t++}}();function v(t){return null==t}function p(t){if(Array.isArray&&Array.isArray(t))return!0;var e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function g(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function y(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function m(t,e){return y(t)?t:e}function b(t,e){return void 0===t?e:t}var _=function(t,e){return"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e},x=function(t,e){return"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t};function k(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function w(t,e,i,n){var r,a,o;if(p(t))if(a=t.length,n)for(r=a-1;r>=0;r--)e.call(i,t[r],r);else for(r=0;r<a;r++)e.call(i,t[r],r);else if(g(t))for(a=(o=Object.keys(t)).length,r=0;r<a;r++)e.call(i,t[o[r]],o[r])}function M(t,e){var i,n,r,a;if(!t||!e||t.length!==e.length)return!1;for(i=0,n=t.length;i<n;++i)if(r=t[i],a=e[i],r.datasetIndex!==a.datasetIndex||r.index!==a.index)return!1;return!0}function P(t){if(p(t))return t.map(P);if(g(t)){for(var e=Object.create(null),i=Object.keys(t),n=i.length,r=0;r<n;++r)e[i[r]]=P(t[i[r]]);return e}return t}function S(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function C(t,e,i,n){if(S(t)){var r=e[t],a=i[t];g(r)&&g(a)?O(r,a,n):e[t]=P(a)}}function O(t,e,i){var n=p(e)?e:[e],r=n.length;if(!g(t))return t;for(var a,o=(i=i||{}).merger||C,s=0;s<r;++s)if(g(a=n[s]))for(var l=Object.keys(a),h=0,u=l.length;h<u;++h)o(l[h],t,a,i);return t}function A(t,e){return O(t,e,{merger:D})}function D(t,e,i){if(S(t)){var n=e[t],r=i[t];g(n)&&g(r)?A(n,r):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=P(r))}}var T={"":function(t){return t},x:function(t){return t.x},y:function(t){return t.y}};function I(t){var e,i=[],n="",r=_createForOfIteratorHelper(t.split("."));try{for(r.s();!(e=r.n()).done;){(n+=e.value).endsWith("\\")?n=n.slice(0,-1)+".":(i.push(n),n="")}}catch(t){r.e(t)}finally{r.f()}return i}function L(t,e){var i=T[e]||(T[e]=function(t){var e=I(t);return function(t){var i,n=_createForOfIteratorHelper(e);try{for(n.s();!(i=n.n()).done;){var r=i.value;if(""===r)break;t=t&&t[r]}}catch(t){n.e(t)}finally{n.f()}return t}}(e));return i(t)}function R(t){return t.charAt(0).toUpperCase()+t.slice(1)}var E=function(t){return void 0!==t},F=function(t){return"function"==typeof t},z=function(t,e){if(t.size!==e.size)return!1;var i,n=_createForOfIteratorHelper(t);try{for(n.s();!(i=n.n()).done;){var r=i.value;if(!e.has(r))return!1}}catch(t){n.e(t)}finally{n.f()}return!0};function V(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}var B=Math.PI,H=2*B,j=H+B,W=Number.POSITIVE_INFINITY,N=B/180,Y=B/2,U=B/4,$=2*B/3,X=Math.log10,G=Math.sign;function K(t,e,i){return Math.abs(t-e)<i}function q(t){var e=Math.round(t);t=K(t,e,t/1e3)?e:t;var i=Math.pow(10,Math.floor(X(t))),n=t/i;return(n<=1?1:n<=2?2:n<=5?5:10)*i}function J(t){var e,i=[],n=Math.sqrt(t);for(e=1;e<n;e++)t%e==0&&(i.push(e),i.push(t/e));return n===(0|n)&&i.push(n),i.sort(function(t,e){return t-e}).pop(),i}function Z(t){return!function(t){return"symbol"==_typeof(t)||"object"==_typeof(t)&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function Q(t,e){var i=Math.round(t);return i-e<=t&&i+e>=t}function tt(t,e,i){var n,r,a;for(n=0,r=t.length;n<r;n++)a=t[n][i],isNaN(a)||(e.min=Math.min(e.min,a),e.max=Math.max(e.max,a))}function et(t){return t*(B/180)}function it(t){return t*(180/B)}function nt(t){if(y(t)){for(var e=1,i=0;Math.round(t*e)/e!==t;)e*=10,i++;return i}}function rt(t,e){var i=e.x-t.x,n=e.y-t.y,r=Math.sqrt(i*i+n*n),a=Math.atan2(n,i);return a<-.5*B&&(a+=H),{angle:a,distance:r}}function at(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function ot(t,e){return(t-e+j)%H-B}function st(t){return(t%H+H)%H}function lt(t,e,i,n){var r=st(t),a=st(e),o=st(i),s=st(a-r),l=st(o-r),h=st(r-a),u=st(r-o);return r===a||r===o||n&&a===o||s>l&&h<u}function ht(t,e,i){return Math.max(e,Math.min(i,t))}function ut(t){return ht(t,-32768,32767)}function ct(t,e,i){var n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1e-6;return t>=Math.min(e,i)-n&&t<=Math.max(e,i)+n}function ft(t,e,i){i=i||function(i){return t[i]<e};for(var n,r=t.length-1,a=0;r-a>1;)i(n=a+r>>1)?a=n:r=n;return{lo:a,hi:r}}var dt=function(t,e,i,n){return ft(t,i,n?function(n){var r=t[n][e];return r<i||r==Line truncated
/*!
* @kurkle/color v0.3.2
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* Released under the MIT License
*/
function Dt(t){return t+.5|0}var Tt=function(t,e,i){return Math.max(Math.min(t,i),e)};function It(t){return Tt(Dt(2.55*t),0,255)}function Lt(t){return Tt(Dt(255*t),0,255)}function Rt(t){return Tt(Dt(t/2.55)/100,0,1)}function Et(t){return Tt(Dt(100*t),0,100)}var Ft={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},zt=_toConsumableArray("0123456789ABCDEF"),Vt=function(t){return zt[15&t]},Bt=function(t){return zt[(240&t)>>4]+zt[15&t]},Ht=function(t){return(240&t)>>4==(15&t)};var jt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Wt(t,e,i){var n=e*Math.min(i,1-i),r=function(e){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:(e+t/30)%12;return i-n*Math.max(Math.min(r-3,9-r,1),-1)};return[r(0),r(8),r(4)]}function Nt(t,e,i){var n=function(n){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:(n+t/60)%6;return i-i*e*Math.max(Math.min(r,4-r,1),0)};return[n(5),n(3),n(1)]}function Yt(t,e,i){var n,r=Wt(t,1,.5);for(e+i>1&&(e*=n=1/(e+i),i*=n),n=0;n<3;n++)r[n]*=1-e-i,r[n]+=e;return r}function Ut(t){var e,i,n,r=t.r/255,a=t.g/255,o=t.b/255,s=Math.max(r,a,o),l=Math.min(r,a,o),h=(s+l)/2;return s!==l&&(n=s-l,i=h>.5?n/(2-s-l):n/(s+l),e=function(t,e,i,n,r){return t===r?(e-i)/n+(e<i?6:0):e===r?(i-t)/n+2:(t-e)/n+4}(r,a,o,n,s),e=60*e+.5),[0|e,i||0,h]}function $t(t,e,i,n){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,n)).map(Lt)}function Xt(t,e,i){return $t(Wt,t,e,i)}function Gt(t){return(t%360+360)%360}var Kt,qt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Jt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};var Zt=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/,Qt=function(t){return t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055},te=function(t){return t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)};function ee(t,e,i){if(t){var n=Ut(t);n[e]=Math.max(0,Math.min(n[e]+n[e]*i,0===e?360:1)),n=Xt(n),t.r=n[0],t.g=n[1],t.b=n[2]}}function ie(t,e){return t?Object.assign(e||{},t):t}function ne(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Lt(t[3]))):(e=ie(t,{r:0,g:0,b:0,a:1})).a=Lt(e.a),e}function re(t){return"r"===t.charAt(0)?function(t){var e,i,n,r=Zt.exec(t),a=255;if(r){if(r[7]!==e){var o=+r[7];a=r[8]?It(o):Tt(255*o,0,255)}return e=+r[1],i=+r[3],n=+r[5],{r:e=255&(r[2]?It(e):Tt(e,0,255)),g:i=255&(r[4]?It(i):Tt(i,0,255)),b:n=255&(r[6]?It(n):Tt(n,0,255)),a:a}}}(t):function(t){var e,i=jt.exec(tLine truncated
+2
View File
@@ -0,0 +1,2 @@
/*! jQuery v3.6.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */
!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0<t&&t-1 in e)}S.fn=S.prototype={jquery:f,constructor:S,length:0,toArray:function(){return s.call(this)},get:function(e){return null==e?s.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=S.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return S.each(this,e)},map:function(n){return this.pushStack(S.map(this,function(e,t){return n.call(e,t,e)}))},slice:function(){return this.pushStack(s.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(S.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(S.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(0<=n&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:u,sort:t.sort,splice:t.splice},S.extend=S.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==typeof a||m(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(S.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||S.isPlainObject(n)?n:{},i=!1,a[t]=S.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},S.extend({expando:"jQuery"+(f+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==o.call(e))&&(!(t=r(e))||"function"==typeof(n=y.call(t,"constructor")&&t.constructor)&&a.call(n)===l)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t,n){b(e,{nonce:t&&t.nonce},n)},each:function(e,t){var n,r=0;if(p(e)){for(n=e.length;r<n;r++)if(!1===t.call(e[r],r,e[r]))break}else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},makeArray:function(e,t){var n=t||[];return null!=e&&(p(Object(e))?S.merge(n,"string"==typeof e?[e]:e):u.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:i.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;i<o;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,o=0,a=[];if(p(e))for(r=e.length;o<r;o++)null!=(i=t(e[o],o,n))&&a.push(i);else for(o in e)null!=(i=t(e[o],o,n))&&a.push(i);return g(a)},guid:1,support:v}),"function"==typeof Symbol&&(S.fn[Symbol.iterator]=t[Symbol.iterator]),S.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){n["[object "+t+"]"]=t.toLowerCase()});var d=function(n){var e,d,b,o,i,h,f,g,w,u,l,T,C,a,E,y,s,c,v,S="sizzle"+1*new Date,p=n.document,k=0,r=0,m=ue(),x=ue(),A=ue(),N=ue(),j=function(e,t){return e===t&&(l=!0),0},D={}.hasOwnProperty,t=[],q=t.pop,L=t.push,H=t.push,O=t.slice,P=function(e,t){for(var n=0,r=e.length;n<r;n++)if(e[n]===t)return n;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",I="(?:\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+",W="\\["+M+"*("+I+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+I+"))|)"+M+"*\\]",F=":("+I+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+W+")*)|.*)\\)|)",$=new RegExp(M+"+","g"),B=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=new RegExp("^"+M+"*,"+M+"*"),z=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^Line truncated
@@ -0,0 +1,2 @@
"use strict";function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)
/*! jQuery v3.6.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */}!function(e,t){"object"==("undefined"==typeof module?"undefined":_typeof(module))&&"object"==_typeof(module.exports)?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:void 0,function(e,t){var n=[],r=Object.getPrototypeOf,i=n.slice,o=n.flat?function(e){return n.flat.call(e)}:function(e){return n.concat.apply([],e)},a=n.push,s=n.indexOf,u={},l=u.toString,c=u.hasOwnProperty,f=c.toString,p=f.call(Object),d={},h=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},g=function(e){return null!=e&&e===e.window},v=e.document,y={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||v).createElement("script");if(o.text=e,t)for(r in y)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==_typeof(e)||"function"==typeof e?u[l.call(e)]||"object":_typeof(e)}var b="3.6.1",w=function(e,t){return new w.fn.init(e,t)};function T(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!h(e)&&!g(e)&&("array"===n||0===t||"number"==typeof t&&0<t&&t-1 in e)}w.fn=w.prototype={jquery:b,constructor:w,length:0,toArray:function(){return i.call(this)},get:function(e){return null==e?i.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=w.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return w.each(this,e)},map:function(e){return this.pushStack(w.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(i.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(w.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(w.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(0<=n&&n<t?[this[n]]:[])},end:function(){return this.prevObject||this.constructor()},push:a,sort:n.sort,splice:n.splice},w.extend=w.fn.extend=function(){var e,t,n,r,i,o,a=arguments[0]||{},s=1,u=arguments.length,l=!1;for("boolean"==typeof a&&(l=a,a=arguments[s]||{},s++),"object"==_typeof(a)||h(a)||(a={}),s===u&&(a=this,s--);s<u;s++)if(null!=(e=arguments[s]))for(t in e)r=e[t],"__proto__"!==t&&a!==r&&(l&&r&&(w.isPlainObject(r)||(i=Array.isArray(r)))?(n=a[t],o=i&&!Array.isArray(n)?[]:i||w.isPlainObject(n)?n:{},i=!1,a[t]=w.extend(l,o,r)):void 0!==r&&(a[t]=r));return a},w.extend({expando:"jQuery"+(b+Math.random()).replace(/\D/g,""),isReady:!0,error:function(e){throw new Error(e)},noop:function(){},isPlainObject:function(e){var t,n;return!(!e||"[object Object]"!==l.call(e)||(t=r(e))&&("function"!=typeof(n=c.call(t,"constructor")&&t.constructor)||f.call(n)!==p))},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},globalEval:function(e,t,n){m(e,{nonce:t&&t.nonce},n)},each:function(e,t){var n,r=0;if(T(e))for(n=e.length;r<n&&!1!==t.call(e[r],r,e[r]);r++);else for(r in e)if(!1===t.call(e[r],r,e[r]))break;return e},makeArray:function(e,t){var n=t||[];return null!=e&&(T(Object(e))?w.merge(n,"string"==typeof e?[e]:e):a.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:s.call(t,e,n)},merge:function(e,t){for(var n=+t.length,r=0,i=e.length;r<n;r++)e[i++]=t[r];return e.length=i,e},grep:function(e,t,n){for(var r=[],i=0,o=e.length,a=!n;i<o;i++)!t(e[i],i)!==a&&r.push(e[i]);return r},map:function(e,t,n){var r,i,a=0,s=[];if(T(e))for(r=e.length;a<r;a++)null!=(i=t(e[a],a,n))&&s.push(i);else for(a in e)null!=(i=t(e[a],a,n))&&s.push(i);return o(s)},guid:1,support:d}),"function"==typeof Symbol&&(w.fn[Symbol.iterator]=n[Symbol.iterator]),w.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(e,t){u["[object "+t+"]"]=t.toLowerCase()});var C=function(e){var t,n,r,i,o,a,s,u,l,c,f,p,d,h,g,v,y,m,x,b="sizzle"+1*new Date,w=e.document,T=0,C=0,E=ue(),S=ue(),k=ue(),A=ue(),N=function(e,t){return e===t&&(f=!0),0},j={}.hasOwnProperty,D=[],q=D.pop,L=D.push,H=D.push,O=D.slice,P=function(e,t){for(var n=0,r=e.length;n<r;n++)if(e[n]===t)return n;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",I="(?:\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+",_="\\["+M+"*("+I+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+I+"))|)"+M+"*\\]",W=":("+I+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+_+")*)|.*)\\)|)",F=new RegExp(M+"+","g"),$=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),B=new RegExp("^"+M+"*,"+M+"*"),z=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(W),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.(Line truncated
+1
View File
@@ -0,0 +1 @@
"use strict";function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function _iterableToArrayLimit(e,t){var r=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=r){var n,a,o,i,l=[],u=!0,s=!1;try{if(o=(r=r.call(e)).next,0===t){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=o.call(r)).done)&&(l.push(n.value),l.length!==t);u=!0);}catch(e){s=!0,a=e}finally{try{if(!u&&null!=r.return&&(i=r.return(),Object(i)!==i))return}finally{if(s)throw a}}return l}}function _arrayWithHoles(e){if(Array.isArray(e))return e}function _classCallCheck(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function _defineProperties(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,_toPropertyKey(n.key),n)}}function _createClass(e,t,r){return t&&_defineProperties(e.prototype,t),r&&_defineProperties(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function _toPropertyKey(e){var t=_toPrimitive(e,"string");return"symbol"==_typeof(t)?t:t+""}function _toPrimitive(e,t){if("object"!=_typeof(e)||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=_typeof(n))return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}var ScheduleManager=function(){return _createClass(function e(){_classCallCheck(this,e),this.lastActiveItem=null,this.updateInterval=6e4},[{key:"getCurrentTimeInMinutes",value:function(){var e=new Date,t=60*e.getHours()+e.getMinutes();return{now:e,minutes:t}}},{key:"parseTimeToMinutes",value:function(e){var t=_slicedToArray(e.split(":").map(Number),2);return 60*t[0]+t[1]}},{key:"getItemTimeRange",value:function(e){var t=e.dataset,r=t.start,n=t.end;return r&&n?{start:this.parseTimeToMinutes(r),end:this.parseTimeToMinutes(n)}:null}},{key:"determineItemStatus",value:function(e,t){return t>=e.start&&t<=e.end?"active":t>e.end?"completed":"upcoming"}},{key:"updateBadge",value:function(e,t){var r=e.querySelector(".current-time-badge");if(t&&!r){var n=document.createElement("span");n.className="current-time-badge",n.textContent="진행중",e.appendChild(n)}else!t&&r&&r.remove()}},{key:"updateItemClasses",value:function(e,t){var r,n,a={active:{add:["active"],remove:["completed"]},completed:{add:["completed"],remove:["active"]},upcoming:{add:[],remove:["active","completed"]}}[t],o=a.add,i=a.remove;(r=e.classList).remove.apply(r,_toConsumableArray(i)),(n=e.classList).add.apply(n,_toConsumableArray(o))}},{key:"updateItemState",value:function(e,t){var r=this.getItemTimeRange(e);if(!r)return!1;var n=this.determineItemStatus(r,t),a="active"===n;return this.updateItemClasses(e,n),a}},{key:"scrollToElement",value:function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:20;t&&e.scrollTo({top:t.offsetTop-r,behavior:"smooth"})}},{key:"getTodayDateString",value:function(e){return e.getFullYear()+"-"+String(e.getMonth()+1).padStart(2,"0")+"-"+String(e.getDate()).padStart(2,"0")}},{key:"updateCardItems",value:function(e,t){var r=this,n=null;return e.querySelectorAll(".day-list li").forEach(function(e){r.updateItemState(e,t)&&!n&&(n=e)}),n}},{key:"processAllCards",value:function(e,t){var r=this,n=null,a=null;return document.querySelectorAll(".day-card").forEach(function(o){var i=o.dataset.date===e;o.classList.toggle("today",i),i?(a=o,n=r.updateCardItems(o,t)):r.updateCardItems(o,t)}),{firstActive:n,todayCarLine truncated
+22
View File
@@ -0,0 +1,22 @@
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, viewport-fit=cover"
/>
<meta http-equiv="X-UA-compatible" content="IE=edge,chrome=1" />
<meta name="format-detection" content="telephone=no" />
<!-- <meta http-equiv="pragma" content="no-cache"> -->
<link
rel="shortcut icon"
type="image/svg+xml"
href="./assets/images/favicon.svg"
/>
<link rel="stylesheet" type="text/css" href="./assets/css/common.css" />
<link rel="stylesheet" type="text/css" href="./assets/css/style.css" />
<script src="./assets/js/lib/jquery-3.6.1.min.js"></script>
<script src="./assets/js/lib/chart.js"></script>
<script src="./assets/js/chart-plugin.js"></script>
<script src="./assets/js/common.min.js"></script>
<title>EG-BIM 판매현황</title>
+178
View File
@@ -0,0 +1,178 @@
<!-- header -->
<!-- <header class="header">
<button class="logo" onclick="fullScreen();" title="">EG-BIM</button>
<h1 class="title" data-text="2025년 판매 상세 현황">2025년 판매 상세 현황</h1>
<div class="date">업데이트 : 2025.11.21(금) 18:00</div>
</header> -->
<?php
$DB_HOST = "localhost";
$DB_NAME = "egbim";
$DB_USER = "egbim";
$DB_PASS = "baron3840!!";
try {
$pdo = new PDO(
"mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4",
$DB_USER,
$DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
} catch (Exception $e) {
echo json_encode([
"status" => "fail",
"message" => "DB 연결 실패: " . $e->getMessage()
]);
exit;
}
// 기준 연도
$START_YEAR = 2025;
// 현재 연도 (URL 우선)
$year = isset($_GET['year']) ? (int)$_GET['year'] : date('Y');
if ($year < $START_YEAR) $year = $START_YEAR;
// 누적 기간
$startDate = $START_YEAR . '-01-01';
$endDate = $year . '-12-31';
// 누적 판매 copy 계산
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(quantity), 0) AS total
FROM sales_results
WHERE sales_date BETWEEN ? AND ?
");
$stmt->execute([$startDate, $endDate]);
$cumulativeCopy = (int)$stmt->fetchColumn();
?>
<header class="header">
<div class="header-left">
<button class="logo" onclick="fullScreen()">EG-BIM</button>
<div class="cumulative-copy">
<span class="label">[총 판매 실적 :</span>
<span><?= number_format($cumulativeCopy) ?></span>
<span>copy]</span>
</div>
</div>
<!-- 연도 이동 영역 -->
<div class="title-wrap yearly_button" id="yearly_button">
<button class="year-nav-btn" id="btn-prev-year" title="작년">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#424242"
stroke-width="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<h1 class="title" data-text="">
<span id="current-year"></span>년 판매 상세 현황
</h1>
<button class="year-nav-btn" id="btn-next-year" title="내년">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#424242"
stroke-width="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
<div class="date"></div>
</header>
<!--// header -->
<script>
document.addEventListener("DOMContentLoaded", () => {
/* -------------------------------
1) 연도 처리 (URL 우선)
-------------------------------- */
const params = new URLSearchParams(location.search);
// let currentYear = Number(params.get("year")) || new Date().getFullYear();
const START_YEAR = 2025;
const CURRENT_YEAR = new Date().getFullYear();
let currentYear = Number(params.get("year")) || CURRENT_YEAR;
if (currentYear < START_YEAR) currentYear = START_YEAR;
const yearSpan = document.getElementById("current-year");
const titleEl = document.querySelector(".header .title");
function updateTitle() {
const text = `${currentYear}년 판매 상세 현황`;
yearSpan.textContent = currentYear;
titleEl.dataset.text = text;
}
updateTitle();
document.getElementById("btn-prev-year")?.addEventListener("click", () => {
if (currentYear <= START_YEAR) return; // 🔒 2025 이하 차단
currentYear--;
moveYear();
});
document.getElementById("btn-next-year")?.addEventListener("click", () => {
currentYear++;
moveYear();
});
function moveYear() {
location.href = `?year=${currentYear}`;
}
/* -------------------------------
2) 업데이트 날짜 자동 반영
-------------------------------- */
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const weekday = ["일", "월", "화", "수", "목", "금", "토"][now.getDay()];
const dateText = `업데이트 : ${y}.${m}.${d}(${weekday}) ${hh}:${mm}`;
const dateEl = document.querySelector(".header .date");
if (dateEl) dateEl.textContent = dateText;
});
function updateTitle() {
const text = `${currentYear}년 판매 상세 현황`;
yearSpan.textContent = currentYear;
titleEl.dataset.text = text;
const prevBtn = document.getElementById("btn-prev-year");
if (prevBtn) {
prevBtn.classList.toggle("disabled", currentYear <= START_YEAR);
}
}
// 전역 연도 (모든 include에서 사용)
window.APP_YEAR = (() => {
const params = new URLSearchParams(location.search);
const y = parseInt(params.get("year"), 10);
const nowY = new Date().getFullYear();
if (!isNaN(y) && y >= 2025) return y;
return nowY;
})();
</script>
+56
View File
@@ -0,0 +1,56 @@
<?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
?>
<!doctype html>
<html lang="ko">
<head>
<?php include __DIR__ . '/html/_include/_head.php'; ?>
</head>
<body>
<div class="wrap">
<?php include __DIR__ . '/html/_include/_header.php'; ?>
<!-- container -->
<div class="container">
<div class="main-contents">
<div class="main-grid">
<!-- 연간 총 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/yearly_chart.php'; ?>
<!-- //연간 총 판매 실적 -->
<!-- 연간 개별 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/individual_chart.php'; ?>
<!-- //연간 개별 판매 실적 -->
<!-- 연간 영업 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/sales_chart.php'; ?>
<!-- //연간 영업 판매 실적 -->
<!-- 월 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/month_chart.php'; ?>
<!-- //월 판매 실적 -->
</div>
<!-- 주간 영업 계획 -->
<?php include __DIR__ . '/html/main_sub/weekly_plan.php'; ?>
<!-- //주간 영업 계획 -->
</div>
</div>
</div>
<script>
(() => {
// 리사이즈 이벤트 리스너 (디바운스 적용)
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
createYearlyChart();
createIndividualChart();
createMonthChart();
createIndustryChart();
}, 250); // 250ms 디바운스
});
})();
</script>
</body>
</html>
+545
View File
@@ -0,0 +1,545 @@
<?php
$pdo = new PDO("mysql:host=localhost;dbname=egbim;charset=utf8mb4","egbim","baron3840!!");
$rows = $pdo->query("
SELECT
s.schedule_date,
s.time_period,
s.content,
s.emp_no,
m.emp_name,
c.client_name
FROM sales_schedules s
LEFT JOIN sales_members m ON s.emp_no = m.emp_no
LEFT JOIN sales_clients c ON s.client_code = c.client_code
")->fetchAll(PDO::FETCH_ASSOC);
// 모든 emp_no는 소문자로 통일!
$tagMap = [
"j01201" => "권",
"223070" => "염",
"m21430" => "김",
"b21367" => "윤"
];
$eventsData = [];
foreach ($rows as $r) {
$date = $r["schedule_date"];
$empNo = strtolower($r["emp_no"]); // ★ emp_no를 소문자로 통일
// 날짜 키가 없으면 배열 초기화
if (!isset($eventsData[$date])) {
$eventsData[$date] = [];
}
// 여러 개가 정상적으로 PUSH됨
$eventsData[$date][] = [
"time" => $r["time_period"],
"title" => $r["content"],
"tags" => [ $tagMap[$empNo] ?? "" ]
];
}
echo "<script>var eventsData = " . json_encode($eventsData, JSON_UNESCAPED_UNICODE) . ";</script>";
?>
<div id="scheduleModal" class="modal calendar">
<div class="modal-content">
<div class="modal-header">
<div class="filter-options">
<div class="checkbox-group green">
<input type="checkbox" id="check1" value="green" checked />
<label for="check1">권혁진 수석</label>
</div>
<div class="checkbox-group purple">
<input type="checkbox" id="check2" value="purple" checked />
<label for="check2">염승호 수석</label>
</div>
<div class="checkbox-group teal">
<input type="checkbox" id="check3" value="teal" checked />
<label for="check3">김지영 선임</label>
</div>
<div class="checkbox-group black">
<input type="checkbox" id="check4" value="black" checked />
<label for="check4">윤준수 선임</label>
</div>
</div>
<div class="date-navigation">
<button class="prev-btn" id="prevMonth">‹</button>
<div class="current-date" id="currentDate">2025.11</div>
<button class="next-btn" id="nextMonth">›</button>
</div>
<div class="flex">
<div class="toggle-area">
<input type="radio" id="option1" name="toggle" value="접기" checked />
<label for="option1">접기</label>
<input type="radio" id="option2" name="toggle" value="펼치기" />
<label for="option2">펼치기</label>
<div class="toggle-slider"></div>
</div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
</div>
<div class="modal-body" id="modalBody">
<div class="weekdays">
<div class="weekday">일</div>
<div class="weekday">월</div>
<div class="weekday">화</div>
<div class="weekday">수</div>
<div class="weekday">목</div>
<div class="weekday">금</div>
<div class="weekday">토</div>
</div>
<div class="calendar-days">
<div class="days collapsed" id="calendarDays"></div>
</div>
</div>
</div>
<script>
// 모달 배경 클릭시 닫기
const modal = document.getElementById("scheduleModal");
modal.addEventListener("click", function (e) {
if (e.target === modal) {
closeModal();
}
});
</script>
<script>
function initializeCalendar() {
const tagMapping = {
"권": "green",
"염": "purple",
"김": "teal",
"윤": "black"
};
// 사람 고정 순서 (우선순위 낮을수록 뒤로)
const PERSON_ORDER = {
"권": 1, // 권혁진
"염": 2, // 염승호
"김": 3, // 김지영
"윤": 4 // 윤준수
};
function sortEventsByPerson(events) {
return events.slice().sort((a, b) => {
const aTag = a.tags[0] || "";
const bTag = b.tags[0] || "";
return (PERSON_ORDER[aTag] || 99) - (PERSON_ORDER[bTag] || 99);
});
}
/* ===============================
오전 / 오후 판별 (핵심)
→ title 문자열 기준
=============================== */
function isMorning(event) {
return event.time === "오전";
}
function isAfternoon(event) {
return event.time === "오후";
}
const today = new Date();
let currentYear = today.getFullYear(); // 2025 대신
let currentMonth = today.getMonth(); // 11 대신
let selectedFilters = ["green", "purple", "teal", "black"];
function renderCalendar() {
const calendarDays = document.getElementById("calendarDays");
const currentDate = document.getElementById("currentDate");
currentDate.textContent = `${currentYear}.${String(currentMonth + 1).padStart(2, "0")}`;
const firstDay = new Date(currentYear, currentMonth, 1);
const lastDay = new Date(currentYear, currentMonth + 1, 0);
const prevLastDay = new Date(currentYear, currentMonth, 0);
const firstDayOfWeek = firstDay.getDay();
const lastDate = lastDay.getDate();
const prevLastDate = prevLastDay.getDate();
// 오늘 날짜의 주차 계산
const today = new Date();
let todayWeekStart = -1;
let todayWeekEnd = -1;
// 현재 달력에 오늘이 포함되어 있는지 확인
if (
currentMonth === today.getMonth() &&
currentYear === today.getFullYear()
) {
const todayDate = today.getDate();
const currentDate = new Date(currentYear, currentMonth, todayDate);
const dayOfWeek = currentDate.getDay();
// 이번 주의 시작(일요일)과 끝(토요일) 날짜 계산
todayWeekStart = todayDate - dayOfWeek;
todayWeekEnd = todayDate + (6 - dayOfWeek);
}
let weeks = "";
let currentWeekDays = "";
let dayCount = 0;
let weekNumber = 0;
// Previous month days
const prevMonth = currentMonth === 0 ? 11 : currentMonth - 1;
const prevYear = currentMonth === 0 ? currentYear - 1 : currentYear;
for (let i = firstDayOfWeek - 1; i >= 0; i--) {
const dayNum = prevLastDate - i;
const dateKey = `${prevYear}-${String(prevMonth + 1).padStart(2, "0")}-${String(dayNum).padStart(2, "0")}`;
const dayEvents = eventsData[dateKey] || [];
const filteredEvents = sortEventsByPerson(
dayEvents.filter((event) =>
event.tags.some((tag) => selectedFilters.includes(tagMapping[tag]))
)
);
let morningHtml = "";
let afternoonHtml = "";
filteredEvents.forEach((event) => {
const tagsHtml = event.tags
.filter((tag) => selectedFilters.includes(tagMapping[tag]))
.map(
(tag) =>
`<span class="event-tag badge-${tagMapping[tag]}">${tag}</span>`
)
.join("");
const html = `
<div class="event-item">
<span class="event-title">${tagsHtml}${event.title}</span>
</div>
`;
if (isMorning(event)) {
morningHtml += html;
}
if (isAfternoon(event)) {
afternoonHtml += html;
}
});
currentWeekDays += `<div class="day other-month">
<div class="day-number">${dayNum}</div>
<dl>
<dt>오전</dt>
<dd class="day-content">${morningHtml}</dd>
</dl>
<dl>
<dt>오후</dt>
<dd class="day-content">${afternoonHtml}</dd>
</dl>
</div>`;
dayCount++;
}
// Current month days
for (let i = 1; i <= lastDate; i++) {
const isToday =
i === today.getDate() &&
currentMonth === today.getMonth() &&
currentYear === today.getFullYear();
const isCurrentWeek =
todayWeekStart >= 0 && i >= todayWeekStart && i <= todayWeekEnd;
const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(i).padStart(2, "0")}`;
const dayEvents = eventsData[dateKey] || [];
const filteredEvents = sortEventsByPerson(
dayEvents.filter((event) =>
event.tags.some((tag) => selectedFilters.includes(tagMapping[tag]))
)
);
filteredEvents.forEach((event) => {
console.log(event.title);
});
let morningHtml = "";
let afternoonHtml = "";
filteredEvents.forEach((event) => {
const tagsHtml = event.tags
.filter((tag) => selectedFilters.includes(tagMapping[tag]))
.map(
(tag) =>
`<span class="event-tag badge-${tagMapping[tag]}">${tag}</span>`
)
.join("");
const html = `
<div class="event-item">
<span class="event-title">${tagsHtml}${event.title}</span>
</div>
`;
if (isMorning(event)) {
morningHtml += html;
}
if (isAfternoon(event)) {
afternoonHtml += html;
}
});
currentWeekDays += `<div class="day ${isToday ? "today" : ""} ${isCurrentWeek ? "current-week" : ""}">
<div class="day-number">${i}</div>
<dl>
<dt>오전</dt>
<dd class="day-content">${morningHtml}</dd>
</dl>
<dl>
<dt>오후</dt>
<dd class="day-content">${afternoonHtml}</dd>
</dl>
</div>`;
dayCount++;
// 7일마다 한 주 완성
if (dayCount === 7) {
const hasCurrentWeek =
currentWeekDays.includes('class="day today"') ||
currentWeekDays.includes("current-week");
weeks += `<div class="week ${hasCurrentWeek ? "current-week-row" : ""}">${currentWeekDays}</div>`;
currentWeekDays = "";
dayCount = 0;
weekNumber++;
}
}
// Next month days
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
const remainingDays = 42 - (firstDayOfWeek + lastDate);
for (let i = 1; i <= remainingDays; i++) {
const dateKey = `${nextYear}-${String(nextMonth + 1).padStart(2, "0")}-${String(i).padStart(2, "0")}`;
const dayEvents = eventsData[dateKey] || [];
const filteredEvents = sortEventsByPerson(
dayEvents.filter((event) =>
event.tags.some((tag) => selectedFilters.includes(tagMapping[tag]))
)
);
let morningHtml = "";
let afternoonHtml = "";
filteredEvents.forEach((event) => {
const tagsHtml = event.tags
.filter((tag) => selectedFilters.includes(tagMapping[tag]))
.map(
(tag) =>
`<span class="event-tag badge-${tagMapping[tag]}">${tag}</span>`
)
.join("");
const html = `
<div class="event-item">
<span class="event-title">${tagsHtml}${event.title}</span>
</div>
`;
if (isMorning(event)) morningHtml += html;
if (isAfternoon(event)) afternoonHtml += html;
});
currentWeekDays += `<div class="day other-month">
<div class="day-number">${i}</div>
<dl>
<dt>오전</dt>
<dd class="day-content">${morningHtml}</dd>
</dl>
<dl>
<dt>오후</dt>
<dd class="day-content">${afternoonHtml}</dd>
</dl>
</div>`;
dayCount++;
// 7일마다 한 주 완성
if (dayCount === 7) {
weeks += `<div class="week">${currentWeekDays}</div>`;
currentWeekDays = "";
dayCount = 0;
}
}
calendarDays.innerHTML = weeks;
document.querySelectorAll(".day:not(.other-month)").forEach((day) => {
day.addEventListener("click", function () {
document
.querySelectorAll(".day")
.forEach((d) => d.classList.remove("selected"));
this.classList.add("selected");
});
});
}
function checkOverflow() {
const calendarDaysEl = document.getElementById("calendarDays");
if (!calendarDaysEl.classList.contains("collapsed")) return;
document.querySelectorAll(".day-content").forEach((content) => {
// 스크롤 높이가 실제 높이보다 크면 overflow 발생
if (content.scrollHeight > content.clientHeight) {
content.classList.add("has-overflow");
} else {
content.classList.remove("has-overflow");
}
});
}
// document.getElementById("prevMonth").addEventListener("click", () => {
// currentMonth--;
// if (currentMonth < 0) {
// currentMonth = 11;
// currentYear--;
// }
// renderCalendar();
// checkOverflow();
// });
// document.getElementById("nextMonth").addEventListener("click", () => {
// currentMonth++;
// if (currentMonth > 11) {
// currentMonth = 0;
// currentYear++;
// }
// renderCalendar();
// checkOverflow();
// });
// 필터 체크박스
const filterCheckboxes = document.querySelectorAll(
'.filter-options input[type="checkbox"]',
);
filterCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", () => {
selectedFilters = Array.from(filterCheckboxes)
.filter((cb) => cb.checked)
.map((cb) => cb.value);
renderCalendar();
checkOverflow();
});
});
// 접기/펼치기 토글
const calendarDaysEl = document.getElementById("calendarDays");
const toggleRadios = document.querySelectorAll('input[name="toggle"]');
toggleRadios.forEach((radio) => {
radio.addEventListener("change", (e) => {
if (e.target.value === "접기") {
calendarDaysEl.classList.add("collapsed");
} else {
calendarDaysEl.classList.remove("collapsed");
}
});
});
calendarDaysEl.classList.add("collapsed");
renderCalendar();
checkOverflow();
bindCalendarEvents();
//바인드 함수 컨트롤
function bindCalendarEvents() {
// 이전 월
const prevBtn = document.getElementById("prevMonth");
if (prevBtn) {
prevBtn.onclick = () => {
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
renderCalendar();
checkOverflow();
};
}
// 다음 월
const nextBtn = document.getElementById("nextMonth");
if (nextBtn) {
nextBtn.onclick = () => {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
renderCalendar();
checkOverflow();
};
}
// 필터 체크박스
const filterCheckboxes = document.querySelectorAll('.filter-options input[type="checkbox"]');
filterCheckboxes.forEach((checkbox) => {
checkbox.onclick = () => {
selectedFilters = Array.from(filterCheckboxes)
.filter((cb) => cb.checked)
.map((cb) => cb.value);
renderCalendar();
checkOverflow();
};
});
// 접기&펼치기 토글
const radios = document.querySelectorAll('input[name="toggle"]');
radios.forEach((radio) => {
radio.addEventListener("change", (e) => {
const calendarDaysEl = document.getElementById("calendarDays");
if (e.target.value === "접기") {
calendarDaysEl.classList.add("collapsed");
} else {
calendarDaysEl.classList.remove("collapsed");
// 스크롤을 맨 아래로 이동
setTimeout(() => {
const scheduleList = document.querySelector(".calendar-days");
const scheduleItem = document.querySelector(".current-week-row");
if (scheduleList && scheduleItem) {
// 현재 주가 보이도록 + 살짝 아래로 여유 있게
scheduleList.scrollTo({
top:
scheduleItem.offsetTop -
scheduleList.offsetHeight / 2 +
scheduleItem.offsetHeight / 2,
// 또는 그냥 맨 아래로 내리고 싶으면:
// top: scheduleList.scrollHeight,
behavior: "smooth",
});
}
}, 100);
}
});
});
}
}
// 모달이 로드되면 자동 실행
initializeCalendar();
</script>
</div>
@@ -0,0 +1,275 @@
<section class="card individual">
<div class="chart-wrap">
<div class="card-header">
<h3>연간 개인별 판매 실적<span class="tail"></span></h3>
<div class="legend">
<span>
<span class="legend-dot line-area01"></span>
권혁진 수석
<span class="badge-individual01"></span>
</span>
<span>
<span class="legend-dot line-area02"></span>
염승호 수석
<span class="badge-individual02"></span>
</span>
<span class="unit">(단위:copy) </span>
</div>
</div>
<div class="chart-body">
<!-- <div class="badge-box">
</div> -->
<canvas id="individual_chart"></canvas>
</div>
</div>
<div class="card-body">
<div class="person-box">
<div class="person-name"><span>권혁진</span> <small>수석</small></div>
<div class="stat-row individual01">
<div class="label-box">
<span class="label">누적 판매수</span>
<!-- <div class="change-down">전월대비 <em>27</em></div> -->
</div>
<div>
<span class="big-num">162</span><span class="unit copy">copy</span>
</div>
</div>
<div class="stat-row">
<div class="label-box">
<span class="label">목표 달성률</span>
<!-- <div class="change-up">전월대비 <em>15p</em></div> -->
</div>
<div><span class="big-num">81</span><span class="unit">%</span></div>
</div>
</div>
<div class="person-box">
<div class="person-name">
<span>염승호</span>
<small>수석</small>
</div>
<div class="stat-row individual02">
<div class="label-box">
<span class="label">누적 판매수</span>
<!-- <div class="change-up">전월대비 <em>105</em></div> -->
</div>
<div>
<span class="big-num">508</span><span class="unit copy">copy</span>
</div>
</div>
<div class="stat-row">
<div class="label-box">
<span class="label">목표 달성률</span>
<!-- <div class="change-up">전월대비 <em>63p</em></div> -->
</div>
<div><span class="big-num">169.3</span><span class="unit">%</span></div>
</div>
</div>
</div>
</section>
<script>
(() => {
let INDIVIDUAL_DATA = null;
// -----------------------------
// 1) UI 숫자 반영
// -----------------------------
function updateIndividualUI() {
if (!INDIVIDUAL_DATA || INDIVIDUAL_DATA.length < 2) return;
const p1 = INDIVIDUAL_DATA[0];
const p2 = INDIVIDUAL_DATA[1];
// 이름
document.querySelector('.person-box:nth-child(1) .person-name span').textContent = p1.name;
document.querySelector('.person-box:nth-child(2) .person-name span').textContent = p2.name;
// 누적 판매수
document.querySelector('.individual01 .big-num').textContent = p1.total;
document.querySelector('.individual02 .big-num').textContent = p2.total;
// 달성률
document.querySelector('.person-box:nth-child(1) .stat-row:nth-child(3) .big-num').textContent = p1.rate;
document.querySelector('.person-box:nth-child(2) .stat-row:nth-child(3) .big-num').textContent = p2.rate;
// 개인별 목표(배지)
document.querySelector('.badge-individual01').textContent = `목표 ${p1.target_qty}`;
document.querySelector('.badge-individual02').textContent = `목표 ${p2.target_qty}`;
}
// -----------------------------
// 2) 차트 생성
// -----------------------------
const labels12 = Array.from({ length: 12 }, (_, i) => String(i + 1));
const currentMonth = new Date().getMonth() + 1;
const currentMonthStr = String(currentMonth);
let individualInstance = null;
function createIndividualChart() {
const ctx = document.getElementById("individual_chart");
if (!ctx || !INDIVIDUAL_DATA || INDIVIDUAL_DATA.length < 2) return;
if (individualInstance) individualInstance.destroy();
individualInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels12,
datasets: [
{
label: "권혁진 수석",
data: [...INDIVIDUAL_DATA[0].monthly, null],
fill: true,
backgroundColor: (ctx) => {
const a = ctx.chart.chartArea;
return !a
? cssVar("--bg-chart-area01")
: safeGrad(
ctx.chart.ctx,
a,
[
{ stop: 0, color: cssVar("--bg-chart-area01") },
{ stop: 1, color: "rgba(255, 255, 255, 0.3)" },
],
true
);
},
borderColor: cssVar("--bg-chart-linearea01"),
borderWidth: 1,
pointRadius: 4,
tension: 0,
},
{
label: "염승호 수석",
data: [...INDIVIDUAL_DATA[1].monthly, null],
fill: true,
backgroundColor: (ctx) => {
const a = ctx.chart.chartArea;
return !a
? cssVar("--bg-chart-area02")
: safeGrad(
ctx.chart.ctx,
a,
[
{ stop: 0, color: cssVar("--bg-chart-area02") },
{ stop: 1, color: "rgba(255, 255, 255, 0.3)" },
],
true
);
},
borderColor: cssVar("--bg-chart-linearea02"),
borderWidth: 1,
pointRadius: 4,
tension: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 16 } },
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
y: {
afterBuildTicks: (scale) => {
const maxValue = scale.chart.data.datasets
.flatMap((d) => d.data)
.filter((n) => typeof n === "number");
const realMax = Math.max(...maxValue);
const rawMax = realMax + 50;
const targetMax = Math.ceil(rawMax / 100) * 100;
const mid = Math.round(targetMax / 2);
scale.max = targetMax;
scale.min = 0;
scale.ticks = [
{ value: 0 },
{ value: mid },
{ value: targetMax },
];
scale._midIndex = 1;
},
grid: {
drawTicks: false,
color: (ctx) =>
ctx.index === ctx.scale._midIndex
? cssVar("--grid-border-dashed")
: "rgba(0,0,0,0)",
lineWidth: (ctx) =>
ctx.index === ctx.scale._midIndex ? 1 : 0,
},
border: { display: false },
ticks: {
padding: 4,
color: cssVar("--chart-base"),
},
},
x: {
offset: true,
grid: {
drawTicks: false,
display: true,
color: cssVar("--grid-border") || "#e0e0e0",
drawOnChartArea: true,
z: -1,
},
ticks: {
color: (ctx) =>
ctx.tick.label === currentMonthStr
? cssVar("--chart-point")
: cssVar("--chart-base"),
font: (ctx) =>
ctx.tick.label === currentMonthStr
? { weight: "bold", size: 18 }
: { weight: "normal", size: 18 },
padding: 10,
callback: (_, index) => labels12[index],
},
border: {
display: true,
dash: [3, 3],
color: cssVar("--grid-border-dashed"),
width: 1,
},
},
},
},
plugins: [
highlightXFactory(currentMonthStr),
hideNullPointsPlugin,
createPointGradientPluginWithBorder(),
],
});
}
// -----------------------------
// 3) 개인별 데이터 로딩 함수 (🔥 핵심)
// -----------------------------
function loadIndividualDashboardData() {
return fetch(`/egbim/bbs/sales_dashboard.php?year=${window.APP_YEAR}&_=${Date.now()}`)
.then(r => r.json())
.then(json => {
INDIVIDUAL_DATA = json.individual || [];
updateIndividualUI();
createIndividualChart();
});
}
// -----------------------------
// 4) 전역 등록 + 최초 실행
// -----------------------------
window.loadIndividualDashboardData = loadIndividualDashboardData;
loadIndividualDashboardData();
})();
</script>
@@ -0,0 +1,182 @@
<section class="card month">
<div class="card-header">
<h3>11월 판매 실적<span class="tail"></span></h3>
</div>
<div class="card-body">
<div class="stat-row month">
<div class="label-box">
<span class="label">당월 판매수</span>
<!-- <span class="change-down">전주대비 <em>114</em></span> -->
</div>
<div>
<span class="big-num" id="month_sales_value">0</span><span class="unit copy">copy</span>
</div>
</div>
<div class="stat-row">
<div class="label-box">
<span class="label">당월 매출액</span>
<!-- <span class="change">전주대비 <em>0</em></span> -->
</div>
<div><span class="big-num" id="month_amount_value">0</span><span class="unit">만원</span></div>
</div>
<div class="stat-row">
<div class="label-box">
<span class="label">목표 달성률</span>
<!-- <span class="change-up">전주대비 <em>3p</em></span> -->
</div>
<div><span class="big-num" id="month_rate_value">0</span><span class="unit">%</span></div>
</div>
</div>
<div class="chart-wrap">
<div class="chart-header">
<!-- <span class="badge-month">목표 125</span> -->
<div class="legend">
<span class="unit">(단위:copy) </span>
</div>
</div>
<div class="chart-body">
<canvas id="weekly_chart"></canvas>
<div id="weekly_legend"></div>
</div>
</div>
</section>
<script>
(() => {
let weeklyInstance = null;
// ------------------------------
// 1) 초기값 (0으로 먼저 렌더)
// ------------------------------
let cumulative = [0, 0, 0, 0, 0];
let goal = 0;
// ------------------------------
// 📌 현재 월 텍스트로 변경
// ------------------------------
const now = new Date();
const month = now.getMonth() + 1;
const monthText = `${month}월 판매 실적`;
document.querySelector(".card.month .card-header h3").innerHTML =
monthText + '<span class="tail"></span>';
// ------------------------------
// 📌 오늘 날짜 기준 몇 주차인지 계산
// ------------------------------
function getWeekOfMonth(date = new Date()) {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
// 월요일 기준 (월=1, 일=7)
const firstDayOfWeek = firstDay.getDay() === 0 ? 7 : firstDay.getDay();
const offset = firstDayOfWeek - 1;
return Math.floor((date.getDate() + offset) / 7);
}
const CURRENT_WEEK = getWeekOfMonth(); // 1~5
// ------------------------------
// 차트 생성 함수
// ------------------------------
function createMonthChart() {
const ctx = document.getElementById("weekly_chart");
if (!ctx) return;
if (weeklyInstance) weeklyInstance.destroy();
const total = cumulative.reduce((a, b) => a + b, 0);
const remaining = Math.max(0, goal - total);
weeklyInstance = new Chart(ctx, {
type: "doughnut",
data: {
labels: ["1주차", "2주차", "3주차", "4주차", "5주차", "남은 목표"],
datasets: [{
data: [...cumulative, remaining],
backgroundColor: [
"#FFD311", "#FFA808", "#F58C03",
"#E76C00", "#B65500", "#e1e1e1"
],
borderWidth: 2,
borderColor: "#fff",
circumference: 180,
rotation: 270,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
htmlLegend: {
containerID: "weekly_legend",
activeWeek: CURRENT_WEEK,
},
},
cutout: "45%",
layout: {
padding: { top: 20, bottom: 40, left: 40, right: 40 }
}
},
plugins: [htmlLegendPlugin, gaugeValuePlugin],
});
}
// ------------------------------
// 2) 초기 0 데이터로 차트 1회 렌더
// ------------------------------
createMonthChart();
// ------------------------------
// 3) 월간 데이터 로딩 함수 (🔥 핵심)
// ------------------------------
function loadMonthDashboardData() {
return fetch(`/egbim/bbs/sales_dashboard.php?year=${window.APP_YEAR}&_=${Date.now()}`)
.then(res => res.json())
.then(data => {
// (1) 숫자 UI 반영
document.getElementById("month_sales_value").textContent =
data.month_sales ?? 0;
document.getElementById("month_amount_value").textContent =
Math.round(data.month_sales_amount / 10000);
document.getElementById("month_rate_value").textContent =
data.target_rate ?? 0;
// (2) 주차별 수량
const week = data.weekly || {};
cumulative = [
week[1] ?? 0,
week[2] ?? 0,
week[3] ?? 0,
week[4] ?? 0,
week[5] ?? 0,
];
// (3) 목표 수량
goal = data.target_qty ?? 0;
// (4) 차트 재렌더
createMonthChart();
});
console.log('월데이터 성공');
}
// ------------------------------
// 4) 전역 등록 + 최초 실행
// ------------------------------
window.loadMonthDashboardData = loadMonthDashboardData;
loadMonthDashboardData();
})();
</script>
@@ -0,0 +1,314 @@
<section class="card sales">
<div class="card-header">
<h3>영업 업체 현황<span class="tail"></span></h3>
</div>
<div class="card-body">
<div class="stat-row sales">
<div class="label-box">
<span class="label">영업 업체수</span>
<!-- <span class="change-up">전월대비 <em>53</em></span> -->
</div>
<div><span class="big-num">148</span><span class="unit">개</span></div>
</div>
</div>
<div class="chart-wrap">
<div class="legend">
<span class="unit">(단위:업체수)</span>
</div>
<div class="chart-body">
<h4 class="chart-tit">산업/업종별</h4>
<canvas id="industry_chart"></canvas>
</div>
<div class="chart-body">
<h4 class="chart-tit">기관별</h4>
<canvas id="institution_chart"></canvas>
</div>
</div>
</section>
<script>
(() => {
let industryInstance = null;
let institutionInstance = null;
// ⭐ 업종: 설계, 교육, 제작, 감리, 시공, 기타 → 총 6개
let industryData = [0, 0, 0, 0, 0, 0];
// ⭐ 기관: 기존 그대로 4개
let institutionData = [0, 0, 0, 0];
/* --------------------------------------------------------
1) API 로딩
-------------------------------------------------------- */
function loadSalesDashboardData() {
fetch("/egbim/bbs/sales_dashboard.php")
.then((res) => res.json())
.then((data) => {
const clientCountEl = document.querySelector(".stat-row.sales .big-num");
if (clientCountEl) clientCountEl.textContent = data.client_count ?? 0;
// 업종 6개
industryData = [
data.industry?.설계 ?? 0,
data.industry?.교육 ?? 0,
data.industry?.제작 ?? 0,
data.industry?.감리 ?? 0,
data.industry?.시공 ?? 0,
data.industry?.기타 ?? 0,
];
// 기관 4개
institutionData = [
data.institution?.일반기업 ?? 0,
data.institution?.교육기관 ?? 0,
data.institution?.공공기관 ?? 0,
data.institution?.기타 ?? 0,
];
createIndustryChart();
});
}
window.loadSalesDashboardData = loadSalesDashboardData;
loadSalesDashboardData();
/* --------------------------------------------------------
2) 차트 생성 (+ 0 데이터 제거)
-------------------------------------------------------- */
function createIndustryChart() {
const industry_chart = document.getElementById("industry_chart");
const institution_chart = document.getElementById("institution_chart");
// 기존 차트 제거
if (industryInstance) industryInstance.destroy();
if (institutionInstance) institutionInstance.destroy();
/* ----------------------------------------------------
🔵 업종 차트 (6개)
→ 0 이상 항목만 표시
---------------------------------------------------- */
const industryLabelsAll = ["설계", "교육", "제작", "감리", "시공", "기타"];
const industryColorsAll = ["#EBA170", "#E08042", "#BB5E21", "#8A471B", "#603213", "#3F1A01"];
const industryFiltered = industryData
.map((v, i) => ({ value: v, label: industryLabelsAll[i], color: industryColorsAll[i] }))
.filter(item => item.value > 0);
const industryLabels = industryFiltered.map(i => i.label);
const industryValues = industryFiltered.map(i => i.value);
const industryColors = industryFiltered.map(i => i.color);
industryInstance = new Chart(industry_chart, {
type: "bar",
data: {
labels: industryLabels,
datasets: [
{
data: industryValues,
backgroundColor: industryColors,
borderWidth: 0,
},
],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
cutout: "50%",
layout: {
padding: { top: 40, bottom: 16, left: 0, right: 10 },
},
plugins: {
legend: { display: false },
tooltip: { enabled: false },
datalabels: {
anchor: "end",
align: "end",
color: function (context) {
return industryColors[context.dataIndex];
},
font: {
size: 16,
weight: "500",
},
formatter: function (value, context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return value + " (" + percentage + "%)";
},
},
},
scales: {
x: {
afterBuildTicks: (scale) => {
const maxValue = scale.chart.data.datasets
.flatMap((d) => d.data)
.filter((n) => typeof n === "number");
const realMax = Math.max(...maxValue);
// +100 후 백단위 올림
const rawMax = realMax + 100;
const targetMax = Math.ceil(rawMax / 100) * 100;
const mid = Math.round(targetMax / 2);
scale.max = targetMax;
scale.min = 0;
// tick 3개
scale.ticks = [
{ value: 0 },
{ value: mid },
{ value: targetMax },
];
scale._midIndex = 1;
},
beginAtZero: true,
border: { display: false },
ticks: { padding: -4 },
grid: {
display: false,
},
},
y: {
grid: {
display: false,
},
ticks: {
color: function (context) {
return industryColors[context.index];
},
font: {
size: 16,
weight: "500",
},
padding: 6,
},
},
},
},
plugins: [createCustomGridPlugin(),createCustomLabelPlugin(industryColors)],
});
/* ----------------------------------------------------
🟤 기관 차트 (4개)
→ 0 이상 항목만 표시
---------------------------------------------------- */
const instLabelsAll = ["일반기업", "교육기관", "공공기관", "기타"];
const instColorsAll = ["#EBBA69", "#D59C3D", "#B1802C", "#855B17"];
const instFiltered = institutionData
.map((v, i) => ({ value: v, label: instLabelsAll[i], color: instColorsAll[i] }))
.filter(item => item.value > 0);
const instLabels = instFiltered.map(i => i.label);
const instValues = instFiltered.map(i => i.value);
const instColors = instFiltered.map(i => i.color);
institutionInstance = new Chart(institution_chart, {
type: "bar",
data: {
labels: instLabels,
datasets: [
{
data: instValues,
backgroundColor: instColors,
},
],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
cutout: "50%",
layout: {
padding: { top: 40, bottom: 16, left: 0, right: 10 },
},
plugins: {
legend: { display: false },
tooltip: { enabled: false },
datalabels: {
anchor: "end",
align: "end",
color: function (context) {
return instColors[context.dataIndex];
},
font: {
size: 16,
weight: "500",
},
formatter: function (value, context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return value + " (" + percentage + "%)";
},
},
},
scales: {
x: {
afterBuildTicks: (scale) => {
const maxValue = scale.chart.data.datasets
.flatMap((d) => d.data)
.filter((n) => typeof n === "number");
const realMax = Math.max(...maxValue);
// +100 후 백단위 올림
const rawMax = realMax + 100;
const targetMax = Math.ceil(rawMax / 100) * 100;
const mid = Math.round(targetMax / 2);
scale.max = targetMax;
scale.min = 0;
// tick 3개
scale.ticks = [
{ value: 0 },
{ value: mid },
{ value: targetMax },
];
scale._midIndex = 1;
},
beginAtZero: true,
border: { display: false },
ticks: { padding: -4 },
grid: {
display: false,
},
},
y: {
grid: {
display: false,
},
ticks: {
color: function (context) {
return instColors[context.index];
},
font: {
size: 16,
weight: "500",
},
padding: 6,
},
},
},
},
plugins: [createCustomGridPlugin(),createCustomLabelPlugin(instColors)],
});
} // function 끝
})(); // 즉시실행 함수 끝
</script>
@@ -0,0 +1,477 @@
<?php
$pdo = new PDO("mysql:host=localhost;dbname=egbim;charset=utf8mb4","egbim","baron3840!!");
$rows = $pdo->query("
SELECT
s.schedule_date,
s.time_period,
s.content,
s.emp_no,
m.emp_name,
c.client_name
FROM sales_schedules s
LEFT JOIN sales_members m ON s.emp_no = m.emp_no
LEFT JOIN sales_clients c ON s.client_code = c.client_code
")->fetchAll(PDO::FETCH_ASSOC);
// 사번 매핑
$tagMap = [
"j01201" => "권",
"223070" => "염",
"m21430" => "김",
"b21367" => "윤",
];
$eventsData = [];
foreach ($rows as $r) {
$date = $r["schedule_date"];
$empNo = strtolower($r["emp_no"]);
if (!isset($eventsData[$date])) {
$eventsData[$date] = [];
}
$time = trim($r["time_period"]); // 공백 제거
$time = str_replace(["\r", "\n", "\t"], "", $time); // 숨은 문자 제거
$eventsData[$date][] = [
"time" => $time, // ⭐ 핵심
"title" => $r["content"],
"tags" => [ $tagMap[$empNo] ?? "" ]
];
}
// JS로 전달
echo "<script>var eventsData = " . json_encode($eventsData, JSON_UNESCAPED_UNICODE) . ";</script>";
?>
<div class="main-weekly weekly" id="weekly">
<div class="weekly-grid">
<div class="weekly-header">
<div class="weekly-title">
<p>주간 영업 계획</p>
<div class="week-navigation">
<button class="week-nav-btn" id="prevWeek" title="이전 주">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#424242"
stroke-width="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<h3 id="weekTitle">11월 5주차</h3>
<button class="week-nav-btn" id="nextWeek" title="다음 주">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#424242"
stroke-width="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
</div>
<ul class="person-list">
<li>
<span class="badge-green">권</span><span class="name">권혁진 <small>수석</small></span>
</li>
<li>
<span class="badge-purple">염</span><span class="name">염승호 <small>수석</small></span>
</li>
<li>
<span class="badge-teal">김</span><span class="name">김지영 <small>선임</small></span>
</li>
<li>
<span class="badge-black">윤</span><span class="name">윤준수 <small>선임</small></span>
</li>
</ul>
</div>
<div class="schedule" id="scheduleContainer"></div>
</div>
<div class="main-issue">
<div class="issue-header">
<h3>주요 이슈 사항</h3>
</div>
<div class="issue">
<ul class="issue-list">
<li>주요 이슈 A</li>
<li>주요 이슈 B</li>
</ul>
</div>
</div>
</div>
<script src="./assets/js/schedule-manager.min.js"></script>
<!-- <script src="./assets/js/events-data.min.js"></script> -->
<script>
const personOrder = {
"권": 1,
"염": 2,
"김": 3,
"윤": 4,
};
function isMorning(event) {
return event.time === "오전";
}
function isAfternoon(event) {
return event.time === "오후";
}
// 태그 매핑
const tagMapping = {
권: "green",
염: "purple",
김: "teal",
윤: "black",
};
(function() {
'use strict';
// ⭐ scheduleManager를 상단에 선언 (초기화는 나중에)
let scheduleManager;
async function loadIssueText() {
// ⭐ 현재 주간 달 기준으로 변경
const year = currentWeekStart.getFullYear();
const month = String(currentWeekStart.getMonth() + 1).padStart(2, "0");
const ym = `${year}-${month}`;
let res = await fetch(`/egbim/bbs/sales_issue.php?action=get&issue_month=${ym}`);
let json = await res.json();
const issue = json.issue_text ?? "";
const ul = document.querySelector(".issue-list");
ul.innerHTML = "";
if (issue.trim() === "") {
ul.innerHTML = "<li>이번 달 주요 이슈가 없습니다.</li>";
return;
}
issue.split("\n").forEach(line => {
if (line.trim() !== "") {
ul.innerHTML += `<li>${line}</li>`;
}
});
}
// 현재 날짜에서 이번 주 월요일 구하기
function getThisMonday() {
const today = new Date();
const day = today.getDay(); // 0(일) ~ 6(토)
const diff = day === 0 ? -6 : 1 - day; // 일요일이면 -6, 아니면 1-day
const monday = new Date(today);
monday.setDate(today.getDate() + diff);
monday.setHours(0, 0, 0, 0); // 시간 초기화
return monday;
}
// 현재 주차 상태
let currentWeekStart = getThisMonday();
// 주차 타이틀 업데이트
function updateWeekTitle() {
const weekStart = new Date(currentWeekStart);
const month = weekStart.getMonth() + 1;
const startDate = weekStart.getDate();
// 해당 월의 몇 번째 주인지 계산
const firstDayOfMonth = new Date(
weekStart.getFullYear(),
weekStart.getMonth(),
1,
);
const firstDayOfWeek = firstDayOfMonth.getDay() < 2 ? 7 : firstDayOfMonth.getDay();
const offset = firstDayOfWeek - 1;
//const weekOfMonth = Math.ceil((startDate + firstDayOfMonth.getDay()) / 7);
const weekOfMonth = Math.floor((startDate + offset) / 7);
document.getElementById("weekTitle").textContent =
`${month}월 ${weekOfMonth}주차`;
}
// 요일 이름 가져오기
function getDayName(dayIndex) {
const days = ["일", "월", "화", "수", "목", "금", "토"];
return days[dayIndex];
}
// 주간 일정 렌더링
function renderWeeklySchedule() {
const container = document.getElementById("scheduleContainer");
container.innerHTML = "";
// 월요일부터 금요일까지 (5일)
for (let i = 0; i < 5; i++) {
const currentDate = new Date(currentWeekStart);
currentDate.setDate(currentWeekStart.getDate() + i);
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
const day = String(currentDate.getDate()).padStart(2, "0");
const dateKey = `${year}-${month}-${day}`;
const dayOfWeek = getDayName(currentDate.getDay());
const dayEvents = eventsData[dateKey] || [];
dayEvents.sort((a, b) => {
const aTag = a.tags[0] ?? "";
const bTag = b.tags[0] ?? "";
return (personOrder[aTag] || 99) - (personOrder[bTag] || 99);
});
// 카드 생성
const dayCard = document.createElement("div");
dayCard.className = "day-card";
dayCard.dataset.date = dateKey;
let morningHTML = "";
let afternoonHTML = "";
dayEvents.forEach((event) => {
console.log(
dateKey,
event.time,
JSON.stringify(event.time),
isMorning(event),
isAfternoon(event)
);
});
dayEvents.forEach((event) => {
const tagsHTML = event.tags
.map(tag => `<span class="badge-${tagMapping[tag]}">${tag}</span>`)
.join("");
const itemHTML = `
<li>
<a href="#" class="schedule-item">
${tagsHTML}
${event.title}
</a>
</li>
`;
if (isMorning(event)) {
morningHTML += itemHTML;
}
if (isAfternoon(event)) {
afternoonHTML += itemHTML;
}
});
let dayHTML = `
<div class="day-title">${day}일 <span>(${dayOfWeek})</span></div>
<div class="day">
<dl>
<dt>오전</dt>
<dd class="day-content">
${morningHTML}
</dd>
</dl>
<dl>
<dt>오후</dt>
<dd class="day-content">
${afternoonHTML}
</dd>
</dl>
</div>
`;
dayCard.innerHTML = dayHTML;
container.appendChild(dayCard);
}
updateWeekTitle();
if (scheduleManager && typeof scheduleManager.initialize === "function") {
scheduleManager.initialize();
}
}
// 이벤트 리스너 등록 함수
function setupEventListeners() {
const prevWeekBtn = document.getElementById("prevWeek");
const nextWeekBtn = document.getElementById("nextWeek");
// 이전 주로 이동
if (prevWeekBtn) {
prevWeekBtn.addEventListener("click", () => {
currentWeekStart.setDate(currentWeekStart.getDate() - 7);
renderWeeklySchedule();
loadIssueText();
});
}
// 다음 주로 이동
if (nextWeekBtn) {
nextWeekBtn.addEventListener("click", () => {
currentWeekStart.setDate(currentWeekStart.getDate() + 7);
renderWeeklySchedule();
loadIssueText();
});
}
}
// ⭐ 초기화 함수
function init() {
// ScheduleManager가 존재하는지 확인
if (typeof ScheduleManager !== 'undefined') {
scheduleManager = new ScheduleManager();
scheduleManager.initialize();
}
// 이벤트 리스너 등록
setupEventListeners();
// 초기 렌더링
renderWeeklySchedule();
loadIssueText();
}
// ⭐ DOM 로드 완료 후 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
<script>
let isCalendarLoaded = false;
// 모달 열기
function openModal() {
const existingModal = document.getElementById("scheduleModal");
// 기존 모달이 있으면 제거
if (existingModal) {
existingModal.remove();
}
// 항상 새로 로드 (데이터 최신화)
fetchScheduleDetail();
}
// 모달 닫기
function closeModal() {
const modal = document.getElementById("scheduleModal");
modal.classList.add("closing");
isCalendarLoaded = false;
setTimeout(() => {
modal.remove();
}, 400);
}
// AJAX로 calendar.html 페이지 가져오기
let calendarScriptsLoaded = false;
function fetchScheduleDetail() {
const weekly = document.getElementById("weekly");
fetch(`./html/main_sub/calendar.php`)
.then((response) => response.text())
.then((html) => {
// 기존 모달 제거
const existingModal = document.getElementById("scheduleModal");
if (existingModal) existingModal.remove();
// 새로운 모달 삽입
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// ⭐ body의 자식 노드들을 추가
doc.body.childNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
weekly.appendChild(node.cloneNode(true));
}
});
// ⭐ 스크립트 처리 개선
const scripts = doc.querySelectorAll("script");
if (!calendarScriptsLoaded) {
// 첫 번째 로딩: 모든 스크립트 실행
scripts.forEach(script => {
const newScript = document.createElement("script");
if (script.src) {
newScript.src = script.src;
} else {
newScript.textContent = script.textContent;
}
document.body.appendChild(newScript);
});
calendarScriptsLoaded = true;
} else {
// ⭐ 두 번째 이후: 인라인 스크립트만 재실행
scripts.forEach(script => {
if (!script.src) {
// src가 없는 인라인 스크립트만 실행
try {
eval(script.textContent);
} catch (error) {
console.error('Script execution error:', error);
}
}
});
}
// ⭐ 모달이 추가된 후 초기화 함수 명시적 호출
setTimeout(() => {
if (typeof initializeCalendar === "function") {
initializeCalendar();
}
if (typeof renderCalendar === "function") {
renderCalendar();
}
if (typeof checkOverflow === "function") {
checkOverflow();
}
if (typeof bindToggleEvents === "function") {
bindToggleEvents();
}
}, 100);
})
.catch(error => {
console.error('Calendar loading error:', error);
});
}
// 일정 항목 클릭 이벤트
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener('click', function(e) {
if (e.target.closest('.schedule-item')) {
e.preventDefault();
const scheduleItem = e.target.closest('.schedule-item');
const scheduleId = scheduleItem.getAttribute("data-id");
openModal(scheduleId);
}
});
});
</script>
@@ -0,0 +1,245 @@
<section class="card yearly">
<div class="card-header">
<h3>연간 총 판매 실적<span class="tail"></span></h3>
</div>
<div class="card-body">
<!-- 누적 판매수 -->
<div class="stat-row accrue">
<div class="label-box">
<span class="label">누적 판매수</span>
<span class="change-up">전월대비 <em id="year_compare_copy">0</em></span>
</div>
<div>
<span class="big-num" id="year_total_copy">0</span>
<span class="unit copy">copy</span>
</div>
</div>
<!-- 누적 판매액 -->
<div class="stat-row">
<div class="label-box">
<span class="label">누적 판매액</span>
<span class="change">전월대비 <em id="year_compare_amount">0</em></span>
</div>
<div>
<span class="big-num" id="year_total_amount">0.0</span>
<span class="unit">만원</span>
</div>
</div>
<!-- 목표 달성률 -->
<div class="stat-row">
<div class="label-box">
<span class="label">목표 달성률</span>
<span class="change-up">전월대비 <em id="year_compare_rate">0</em></span>
</div>
<div>
<span class="big-num" id="year_target_rate">0</span>
<span class="unit">%</span>
</div>
</div>
</div>
<div class="chart-wrap">
<div class="chart-header">
<span class="badge-year">목표 <span id="year_target_qty">0</span></span>
<div class="legend">
<span><span class="legend-dot mix-bar"></span>월별 판매수</span>
<span><span class="legend-dot mix-line"></span>누적 판매수</span>
<span class="unit">(단위:copy) </span>
</div>
</div>
<div class="chart-body">
<canvas id="yearly_chart"></canvas>
</div>
</div>
</section>
<script>
(() => {
// -------------------------------
// 1) 기본값 초기화 (시작 시 반드시 0으로 렌더)
// -------------------------------
const currentMonth = String(new Date().getMonth() + 1);
window.yearlyMonthly = Array(12).fill(0);
window.yearlyCumulative = Array(12).fill(0);
const labels12 = Array.from({ length: 12 }, (_, i) => String(i + 1));
let yearlyInstance = null;
// -------------------------------
// 4) API 로딩 → 데이터 반영
// -------------------------------
function loadYearlyDashboardData() {
return fetch(`/egbim/bbs/sales_dashboard.php?year=${window.APP_YEAR}&_=${Date.now()}`)
.then(res => res.json())
.then(data => {
const monthlyArr = [];
for (let i = 1; i <= 12; i++) {
monthlyArr.push(data.monthly[i] ?? 0);
}
const cumulativeArr = [];
monthlyArr.reduce((acc, cur) => {
cumulativeArr.push(acc + cur);
return acc + cur;
}, 0);
const lastIdx = monthlyArr.reduce((l, v, i) => v > 0 ? i : l, -1);
window.yearlyMonthly = monthlyArr;
window.yearlyCumulative = cumulativeArr.map((v, i) => i <= lastIdx ? v : null);
// 수치 업데이트
year_target_rate.textContent = data.yearly_rate;
year_target_qty.textContent = data.yearly_target_qty;
year_total_copy.textContent = data.total_copy;
year_total_amount.textContent = Math.round(data.total_amount / 10000);
updateDiff("year_compare_copy", data.diff_copy);
updateDiff("year_compare_amount", Math.floor(data.diff_amount / 10000));
updateDiff("year_compare_rate", Math.round(data.diff_rate * 10) / 10);
// 🔥 차트 갱신
createYearlyChart();
});
}
window.loadYearlyDashboardData = loadYearlyDashboardData;
loadYearlyDashboardData();
// -------------------------------
// 2) 차트 생성 / 갱신 함수
// -------------------------------
function createYearlyChart() {
const canvas = document.getElementById("yearly_chart");
if (!canvas) return;
// ✅ 최초 1회만 생성
if (!yearlyInstance) {
yearlyInstance = new Chart(canvas, {
type: "bar",
data: {
labels: labels12,
datasets: [
{
type: "bar",
order: 2,
data: window.yearlyMonthly,
backgroundColor: cssVar("--chart-mixbar"),
borderRadius: { topLeft: 2, topRight: 2 },
},
{
type: "line",
order: 1,
data: window.yearlyCumulative,
borderColor: cssVar("--chart-mixline"),
borderWidth: 1,
pointRadius: 4,
fill: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: null },
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
layout: {
padding: { top: 16 },
},
scales: {
y: {
afterBuildTicks: (scale) => {
const allValues = scale.chart.data.datasets
.flatMap((d) => d.data)
.filter((v) => typeof v === "number");
const maxVal = Math.max(...allValues, 0);
const rawMax = maxVal + 50;
const targetMax = Math.ceil(rawMax / 100) * 100;
const midVal = Math.round(targetMax / 2);
scale.max = targetMax;
scale.min = 0;
scale.ticks = [
{ value: 0 },
{ value: midVal },
{ value: targetMax },
];
scale._midIndex = 1;
},
grid: {
drawTicks: false,
color: (ctx) =>
ctx.index === ctx.scale._midIndex
? cssVar("--grid-border-dashed")
: "rgba(0,0,0,0)",
lineWidth: (ctx) =>
ctx.index === ctx.scale._midIndex ? 1 : 0,
},
border: { display: false },
ticks: {
padding: 4,
color: cssVar("--chart-base"),
},
},
x: {
grid: {
drawTicks: false,
display: true,
drawOnChartArea: true,
color: (ctx) =>
ctx.index === 0 || ctx.index == null
? "rgba(0,0,0,0)"
: cssVar("--grid-border-dashed"),
lineWidth: (ctx) =>
ctx.index === 0 || ctx.index == null ? 0 : 1,
},
ticks: {
padding: 10,
color: (ctx) =>
ctx.tick.label === currentMonth
? cssVar("--chart-point")
: cssVar("--chart-base"),
font: (ctx) =>
ctx.tick.label === currentMonth
? { weight: "bold", size: 18 }
: { weight: "normal", size: 18 },
},
border: {
display: true,
dash: [3, 3],
color: cssVar("--grid-border-dashed"),
width: 1,
},
},
},
},
plugins: [
createMixedValuePlugin(12),
createMixedPointGradientPlugin(),
highlightXFactory(currentMonth),
hideNullPointsPlugin,
],
});
}
// 🔥 여기만 추가됨 (기존 destroy 제거)
else {
yearlyInstance.data.datasets[0].data = window.yearlyMonthly;
yearlyInstance.data.datasets[1].data = window.yearlyCumulative;
yearlyInstance.update();
}
}
// -------------------------------
// 3) 초기 렌더
// -------------------------------
createYearlyChart();
loadYearlyDashboardData(); // 최초 데이터
})();
</script>
+170
View File
@@ -0,0 +1,170 @@
<?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
?>
<!doctype html>
<html lang="ko">
<head>
<?php include __DIR__ . '/html/_include/_head.php'; ?>
</head>
<body>
<div class="wrap">
<?php include __DIR__ . '/html/_include/_header.php'; ?>
<!-- container -->
<div class="container">
<div class="main-contents">
<div class="main-grid">
<!-- 연간 총 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/yearly_chart.php'; ?>
<!-- //연간 총 판매 실적 -->
<!-- 연간 개별 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/individual_chart.php'; ?>
<!-- //연간 개별 판매 실적 -->
<!-- 연간 영업 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/sales_chart.php'; ?>
<!-- //연간 영업 판매 실적 -->
<!-- 월 판매 실적 -->
<?php include __DIR__ . '/html/main_sub/month_chart.php'; ?>
<!-- //월 판매 실적 -->
</div>
<!-- 주간 영업 계획 -->
<?php include __DIR__ . '/html/main_sub/weekly_plan.php'; ?>
<!-- //주간 영업 계획 -->
</div>
</div>
</div>
<script>
(() => {
// 리사이즈 이벤트 리스너 (디바운스 적용)
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
createYearlyChart();
createIndividualChart();
createMonthChart();
createIndustryChart();
}, 250); // 250ms 디바운스
});
})();
/* =========================================================
공통: 헤더 업데이트 시간
========================================================= */
function updateHeaderDate() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const weekday = ["일","월","화","수","목","금","토"][now.getDay()];
const dateEl = document.querySelector(".header .date");
if (dateEl) {
dateEl.textContent = `업데이트 : ${y}.${m}.${d}(${weekday}) ${hh}:${mm}`;
}
}
/* =========================================================
공통: 증감 표시 UI
========================================================= */
function updateDiff(id, value) {
const el = document.getElementById(id);
if (!el) return;
const wrap = el.parentElement;
wrap.classList.remove("change", "change-up", "change-down");
if (value > 0) {
wrap.classList.add("change-up");
} else if (value < 0) {
wrap.classList.add("change-down");
} else {
wrap.classList.add("change");
}
el.textContent = Math.abs(value);
}
/* =========================================================
🔥 대시보드 전체 갱신 컨트롤 타워
========================================================= */
function refreshDashboard() {
Promise.all([
// 연간 영업 판매 현황
typeof loadSalesDashboardData === "function"
? loadSalesDashboardData()
: Promise.resolve(),
// 연간 총 판매 실적
typeof loadYearlyDashboardData === "function"
? loadYearlyDashboardData()
: Promise.resolve(),
// 월 판매 실적
typeof loadMonthDashboardData === "function"
? loadMonthDashboardData()
: Promise.resolve(),
// 개인별 연간 실적
typeof loadIndividualDashboardData === "function"
? loadIndividualDashboardData()
: Promise.resolve(),
// 주간 영업계획 (있을 때만)
typeof loadWeeklyPlanData === "function"
? loadWeeklyPlanData()
: Promise.resolve(),
]).then(() => {
updateHeaderDate();
});
}
/* =========================================================
🔄 자동 갱신 제어
========================================================= */
let autoRefreshTimer = null;
function startAutoRefresh() {
if (autoRefreshTimer) return;
// 5분(300초)
autoRefreshTimer = setInterval(refreshDashboard, 60000);
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
/* =========================================================
탭 비활성화 시 갱신 중단
========================================================= */
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopAutoRefresh();
} else {
refreshDashboard();
startAutoRefresh();
}
});
/* =========================================================
초기 실행
========================================================= */
document.addEventListener("DOMContentLoaded", () => {
refreshDashboard(); // 최초 1회
startAutoRefresh(); // 이후 자동 갱신
});
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<?php
function sales_layout_start($title = "영업 관리") {
$current = basename($_SERVER['PHP_SELF']); // ← 현재 페이지 파일명
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title><?= $title ?></title>
<link rel="stylesheet" href="/egbim/css/style_sales.css">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- <link rel="stylesheet" href="https://rawgit.com/vitmalina/w2ui/master/dist/w2ui.min.css"> -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.min.css"/>
<link rel="stylesheet" href="https://rawcdn.githack.com/vitmalina/w2ui/master/dist/w2ui.min.css">
<!-- <script src="https://rawgit.com/vitmalina/w2ui/master/dist/w2ui.min.js"></script> -->
<!-- <link rel="stylesheet" href="https://rawcdn.githack.com/sweetalert2/sweetalert2/master/dist/sweetalert2.min.css"> -->
<!-- <script src="https://rawcdn.githack.com/sweetalert2/sweetalert2/master/dist/sweetalert2.all.min.js"></script> -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="sales-container">
<!-- 🔵 왼쪽 메뉴 -->
<aside class="sales-sidebar">
<div class="sales-logo">
EGBIM<br>관리 페이지
</div>
<ul class="sales-menu">
<li class="<?= $current == 'sales_schedules.skin.php' ? 'active' : '' ?>">
<a href="sales_schedules.skin.php">스케줄</a>
</li>
<li class="<?= $current == 'sales_results.skin.php' ? 'active' : '' ?>">
<a href="sales_results.skin.php">영업실적</a>
</li>
<li class="<?= $current == 'sales_index.skin.php' ? 'active' : '' ?>">
<a href="https://eg-bim.co.kr/egbim/skin/sales/index.php#" target="_blank" rel="noopener noreferrer">DASH board</a>
</li>
</ul>
<div class="sidebar-title"> 관리영역
<ul class="sales-menu">
<li class="<?= $current == 'sales_clients.skin.php' ? 'active' : '' ?>">
<a href="sales_clients.skin.php">거래처 관리</a>
</li>
<li class="<?= $current == 'sales_members.skin.php' ? 'active' : '' ?>">
<a href="sales_members.skin.php">영업담당자 관리</a>
</li>
<li class="<?= $current == 'sales_targets.skin.php' ? 'active' : '' ?>">
<a href="sales_targets.skin.php">영업 목표 설정</a>
</li>
</ul>
</div>
</aside>
<!-- 🔵 오른쪽 페이지 영역 시작 -->
<main class="sales-main">
<?php } ?>
<?php
function sales_layout_end() { ?>
</main>
</div>
</body>
</html>
<?php } ?>
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+394
View File
@@ -0,0 +1,394 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("거래처 관리");
?>
<h2 class="text-2xl font-bold mb-4">거래처 관리</h2>
<div id="grid_clients" style="width: 100%; height: 720px;"></div>
<script type="module">
import { w2grid } from "https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js";
/* ------------------------------------------------------
⭐ 전화번호 자동 포맷 함수
------------------------------------------------------- */
function formatPhone(number) {
if (!number) return "";
number = number.replace(/\D/g, ""); // 숫자만 유지
// 휴대폰 010/011/016/017/018/019
if (/^01[016789]\d{7,8}$/.test(number)) {
if (number.length === 10)
return number.replace(/(01[016789])(\d{3})(\d{4})/, "$1-$2-$3");
return number.replace(/(01[016789])(\d{4})(\d{4})/, "$1-$2-$3");
}
// 070 인터넷전화
if (/^070\d{7,8}$/.test(number)) {
return number.replace(/(070)(\d{4})(\d{4})/, "$1-$2-$3");
}
// 서울 02
if (number.startsWith("02")) {
if (number.length === 9)
return number.replace(/(02)(\d{3})(\d{4})/, "$1-$2-$3");
if (number.length === 10)
return number.replace(/(02)(\d{4})(\d{4})/, "$1-$2-$3");
}
// 기타 지역번호 031~064
if (/^0[3-6]\d/.test(number)) {
return number.replace(/(0\d{2})(\d{3,4})(\d{4})/, "$1-$2-$3");
}
return number;
}
/* ------------------------------------------------------
1) 기관구분 고정 배열
------------------------------------------------------- */
const orgTypes = [
{ id: '공공기관', text: '공공기관' },
{ id: '일반기업', text: '일반기업' },
{ id: '교육기관', text: '교육기관' },
{ id: '기타', text: '기타' },
];
const businessTypes = [
{ id: '설계', text: '설계' },
{ id: '제작', text: '제작' },
{ id: '교육', text: '교육' },
{ id: '감리', text: '감리' },
{ id: '시공', text: '시공' },
{ id: '기타', text: '기타' },
];
/* ------------------------------------------------------
2) 영업담당자 리스트 로딩
------------------------------------------------------- */
let employeeList = [];
async function loadEmployees() {
let res = await fetch('/egbim/bbs/sales_members.php?action=list');
let json = await res.json();
if (json.status === "ok") {
employeeList = json.records.map(m => ({
id: m.emp_no,
text: `${m.emp_name} (${m.emp_no})`
}));
}
}
/* ------------------------------------------------------
3) 직원 먼저 불러와야 grid가 정상 동작
------------------------------------------------------- */
await loadEmployees();
/* ------------------------------------------------------
4) GRID 생성
------------------------------------------------------- */
let grid = new w2grid({
name: 'grid_clients',
box: '#grid_clients',
show: {
toolbar: true,
footer: true,
lineNumbers: true,
toolbarSave: true,
toolbarReload: true,
toolbarSearch: true,
toolbarColumns: true
},
multiSearch: true,
searches: [
{ field: 'client_code', label: '거래처코드', type: 'text' },
{ field: 'client_name', label: '거래처명', type: 'text', operator: 'contains' },
{ field: 'business_type', label: '업종', type: 'text' },
{ field: 'org_type', label: '기관구분', type: 'text' },
{ field: 'manager_emp_no', label: '영업담당자', type: 'text' },
{ field: 'biz_number', label: '사업자등록번호', size: '120px', editable: { type: 'text' }},
{ field: 'address', label: '주소', type: 'text' },
{ field: 'phone', label: '전화번호', type: 'text' },
{ field: 'remarks', label: '비고', type: 'text' },
{ field: 'created_at', label: '등록일', type: 'date' },
],
columns: [
{ field: 'client_code', text: '거래처코드', size: '120px',
editable: false, resizable: true, sortable: true },
{ field: 'client_name', text: '거래처명', size: '180px',
editable: { type: 'text' }, resizable: true, sortable: true },
/* -----------------------------------------------------
업종 — 콤보박스 적용
------------------------------------------------------ */
{
field: 'business_type',
text: '업종',
size: '120px',
sortable: true,
resizable: true,
editable: { type: 'combo', items: businessTypes, showAll: true },
},
/* -----------------------------------------------------
기관구분 — 콤보박스 적용
------------------------------------------------------ */
{
field: 'org_type',
text: '기관구분',
size: '140px',
sortable: true,
resizable: true,
editable: { type: 'combo', items: orgTypes, showAll: true },
},
/* -----------------------------------------------------
영업담당자 — 직원 목록 기반 콤보박스
------------------------------------------------------ */
{
field: 'manager_emp_no',
text: '영업담당자',
size: '160px',
sortable: true,
resizable: true,
editable: { type: 'combo', items: employeeList, filter: true },
},
{ field: 'biz_number', text: '사업자등록번호', size: '120px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'address', text: '주소', size: '220px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'phone', text: '전화번호', size: '120px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'remarks', text: '비고', size: '200px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'created_at', text: '등록일', size: '160px', resizable: true, sortable: true },
],
toolbar: {
items: [
{ id: 'add', type: 'button', text: '추가', icon: 'w2ui-icon-plus' },
{ id: 'btn_del', type: 'button', text: '삭제', icon: 'w2ui-icon-cross' }
],
onClick(event) {
const g = this.owner;
if (event.target === 'w2ui-reload') {
loadClients();
return;
}
if (event.target === 'add') {
$.post("/egbim/bbs/sales_clients.php", {
action: "insert"
}, function (res) {
const code = res.client_code;
g.add({
recid: code,
client_code: code,
client_name: "",
business_type: "",
org_type: "",
manager_emp_no: ""
});
g.refresh();
alert("새 거래처가 추가되었습니다. (코드: " + code + ")");
//alert(grid.records.length + 1);
g.scrollIntoView(grid.records.length + 1);
//g.editField(grid.records.length + 1, 2);
}, "json");
}
if (event.target === 'btn_del') {
console.log("삭제 버튼 클릭됨!");
const g = this.owner;
let sel = g.getSelection();
if (!sel.length) return;
if (!confirm("정말 삭제하시겠습니까?")) return;
sel.forEach(code => {
$.post("/egbim/bbs/sales_clients.php", {
action: "delete",
client_code: code
}, function(res) {
console.log("삭제 응답:", res);
}, "json");
g.remove(code);
});
}
},
onChange(event) {
if (event.column === 'manager_emp_no') {
let newValue = event.value_new?.text || event.value_new || '';
event.record.manager_emp_no = newValue;
this.set(event.recid, { manager_emp_no: newValue });
this.refresh();
}
}
},
/* ------------------------------------------------------
onSave() — DB 업데이트 전송
------------------------------------------------------ */
onSave(event) {
let changes = this.getChanges();
if (!changes.length) {
alert("변경된 내용이 없습니다.");
return;
}
Promise.all(
changes.map(ch => {
// 전화번호 자동 포맷
if (ch.phone !== undefined) {
let raw = (ch.phone + "").replace(/[^0-9]/g, "");
ch.phone = formatPhone(raw);
}
/* ------------------------------
🔵 1) 기관구분(org_type) 유효성 확인
------------------------------ */
if (ch.org_type !== undefined) {
let validOrg = orgTypes.map(o => o.id);
if (!validOrg.includes(ch.org_type)) {
alert("리스트에 있는 기관구분만 선택해주세요.");
throw "invalid org_type";
}
}
/* ------------------------------
🔵 2) 업종(business_type) 유효성 확인
------------------------------ */
if (ch.business_type !== undefined) {
let validBiz = businessTypes.map(b => b.id);
if (!validBiz.includes(ch.business_type)) {
alert("리스트에 있는 업종만 선택해주세요.");
throw "invalid business_type";
}
}
// ------------------------------
// 🔵 3) 영업담당자(manager_emp_no) 유효성 검사 + 원본 저장
// ------------------------------
if (ch.manager_emp_no !== undefined) {
let rawValue = ch.manager_emp_no;
// 콤보 선택 시 object 형태 → 텍스트로 변환하여 DB 저장
if (typeof rawValue === "object" && rawValue !== null) {
// 예: { id: "01201", text: "홍길동 (01201)" }
rawValue = rawValue.text;
}
// "" 허용
if (rawValue !== "") {
// rawValue 안에서 "(사번)" 부분만 추출
let match = rawValue.match(/\((.*?)\)/); // 괄호 안의 문자 추출
if (!match) {
alert("영업담당자 형식이 올바르지 않습니다. 예: 홍길동 (01201)");
throw "invalid manager_emp_no";
}
let extractedEmpNo = match[1]; // 괄호 안의 사번
let validEmployees = employeeList.map(e => e.id);
if (!validEmployees.includes(extractedEmpNo)) {
alert("영업담당자는 직원 목록에 있는 사람만 선택할 수 있습니다.");
throw "invalid manager_emp_no";
}
}
// 🔥 DB에는 사용자가 선택한 그대로 저장 (rawValue)
ch.manager_emp_no = rawValue;
}
if (ch.biz_number !== undefined) {
let raw = (ch.biz_number + "").replace(/[^0-9]/g, "");
if (raw.length !== 10) {
alert("사업자등록번호는 숫자 10자리여야 합니다. 예: 1234567890 또는 123-45-67890");
throw "invalid biz number";
}
// 10자리 → 하이픈 추가
let fmt = raw.replace(/(\d{3})(\d{2})(\d{5})/, "$1-$2-$3");
ch.biz_number = fmt; // 🔥 저장할 값 수정
}
let orgValue = (typeof ch.org_type === 'object' && ch.org_type?.id)
? ch.org_type.id
: ch.org_type;
let empValue = (typeof ch.manager_emp_no === 'object' && ch.manager_emp_no?.id)
? ch.manager_emp_no.id
: ch.manager_emp_no;
return $.post("/egbim/bbs/sales_clients.php", {
action: "update",
client_code: ch.recid,
client_name: ch.client_name,
business_type: ch.business_type,
org_type: orgValue,
manager_emp_no: empValue,
biz_number: ch.biz_number,
address: ch.address,
phone: ch.phone,
remarks: ch.remarks
});
})
).then(() => {
loadClients();
alert("저장되었습니다.");
});
}
});
/* -------------------------------------------------
🔵 loadClients() — DB 조회
-------------------------------------------------- */
function loadClients() {
fetch("/egbim/bbs/sales_clients.php?action=list")
.then(r => r.json())
.then(res => {
if (res.status === "ok") {
grid.records = res.records.map(row => ({
recid: row.client_code,
...row
}));
grid.refresh();
}
});
}
loadClients();
</script>
<?php
sales_layout_end();
?>
View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("DASHBOARD");
?>
<h2 class="text-2xl font-bold mb-4">DASH BOARD</h2>
<div id="grid_clients" style="width: 100%; height: 720px;"></div>
<?php
sales_layout_end();
?>
View File
Whitespace-only changes.
+187
View File
@@ -0,0 +1,187 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("영업담당자 관리");
?>
<h2 class="text-2xl font-bold mb-4">영업담당자 관리</h2>
<div id="grid_members" style="width: 100%; height: 720px;"></div>
<!-- =====================================================
🔥 JS 직접 포함 (CDN 문제 방지 / 로드 순서 문제 해결)
====================================================== -->
<link rel="stylesheet" type="text/css"
href="https://rawgit.com/vitmalina/w2ui/master/dist/w2ui.min.css">
<script type="module">
import { w2grid } from "https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js";
function clean(v) {
return (v === undefined || v === null) ? '' : v;
}
let grid = new w2grid({
name: 'grid_members',
box: '#grid_members',
show: {
toolbar: true,
footer: true,
lineNumbers: true,
toolbarSave: true,
toolbarReload: true,
toolbarSearch: true,
toolbarColumns: true
},
multiSearch: true,
searches: [
{ field: 'emp_no', label: '사번', type: 'text' },
{ field: 'emp_name', label: '이름', type: 'text' },
{ field: 'position', label: '직위', type: 'text' },
{ field: 'department', label: '부서', type: 'text' },
{ field: 'created_at', label: '등록일', type: 'date' }
],
columns: [
{ field: 'emp_no', text: '사번', size: '120px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'emp_name', text: '이름', size: '150px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'position', text: '직위', size: '120px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'department', text: '부서', size: '180px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'created_at', text: '등록일', size: '160px', resizable: true, sortable: true }
],
toolbar: {
items: [
{ id: 'add', type: 'button', text: '추가', icon: 'w2ui-icon-plus' },
// { id: 'delete', type: 'button', text: '삭제', icon: 'w2ui-icon-cross' }
],
onClick(event) {
const g = this.owner;
// 🔄 reload
if (event.target === 'w2ui-reload') {
loadMembers();
return;
}
// ➕ 추가 (DB insert 아직 X)
if (event.target === 'add') {
// recid를 숫자가 아닌 'new-' 접두어를 붙여서 생성합니다.
const newId = "new-" + Math.random().toString(36).substr(2, 9);
g.add({
recid: newId,
emp_no: "",
emp_name: "",
position: "",
department: "",
created_at: ""
});
g.scrollIntoView(newId);
g.editField(newId, 0); // 사번 컬럼부터 바로 편집 모드
}
// ❌ 삭제
if (event.target === 'delete') {
const sel = g.getSelection(); // ✅ sel 변수를 반드시 선언
if (!sel.length) return;
if (!confirm("정말 삭제하시겠습니까?")) return;
sel.forEach(id => {
// 신규행(new-*)는 DB 삭제 안 함
if (String(id).startsWith("new-")) {
g.remove(id);
return;
}
fetch("/egbim/bbs/sales_members.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "delete",
emp_no: id
})
})
.then(r => r.json())
.then(res => {
console.log("삭제 응답:", res);
})
.catch(err => console.error("삭제 오류:", err));
g.remove(id);
});
}
}
},
// 💾 onSave — INSERT + UPDATE
onSave(event) {
const changes = this.getChanges();
if (!changes.length) {
alert("변경된 내용이 없습니다.");
return;
}
Promise.all(
changes.map(ch => {
const isNew = String(ch.recid).startsWith("new-");
// 신규 데이터일 경우 사용자가 grid에 입력한 emp_no를 가져와야 함
// 만약 ch.emp_no가 없다면 grid.get(ch.recid).emp_no 확인
const targetData = this.get(ch.recid);
const empNo = isNew ? (ch.emp_no || targetData.emp_no) : ch.recid;
return fetch("/egbim/bbs/sales_members.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: isNew ? "insert" : "update",
emp_no: clean(empNo),
emp_name: clean(ch.emp_name || targetData.emp_name),
position: clean(ch.position || targetData.position),
department: clean(ch.department || targetData.department)
})
});
})
).then(() => {
loadMembers();
alert("저장되었습니다.");
}).catch(err => {
console.error("저장 오류:", err);
alert("저장 중 오류가 발생했습니다.");
});
}
});
// --------------------------------------------
// 🔵 loadMembers() — 데이터 불러오기
// --------------------------------------------
function loadMembers() {
fetch("/egbim/bbs/sales_members.php?action=list")
.then(r => r.json())
.then(res => {
if (res.status === "ok") {
grid.records = res.records.map(row => ({
recid: row.emp_no,
emp_no: clean(row.emp_no),
emp_name: clean(row.emp_name),
position: clean(row.position),
department: clean(row.department),
created_at: clean(row.created_at)
}));
grid.reload(); // ★★★ refresh 말고 reload 사용
}
});
}
loadMembers();
</script>
<?php
sales_layout_end();
?>
View File
Whitespace-only changes.
@@ -0,0 +1,247 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("영업실적");
?>
<h2 class="text-2xl font-bold mb-4">영업실적</h2>
<button class="bg-blue-600 text-white px-4 py-2 rounded mb-3" id="btn-add-result">
신규 실적 등록
</button>
<div id="grid_results" style="width:100%;height:720px;"></div>
<script type="module">
import { w2grid } from "https://rawgit.com/vitmalina/w2ui/master/dist/w2ui.es6.min.js";
let grid = new w2grid({
name: 'grid_results',
box: '#grid_results',
show: {
toolbar: true,
footer: true,
toolbarSave: true,
toolbarReload: true,
toolbarSearch: true,
toolbarColumns: true,
lineNumbers: true
},
multiSearch: true,
searches: [
{ field: 'sales_date', label: '실적일', type: 'date' },
{ field: 'emp_no', label: '사번', type: 'text' },
{ field: 'client_code', label: '거래처코드', type: 'text' },
{ field: 'product_code', label: '제품코드', type: 'text' },
{ field: 'remarks', label: '비고', type: 'text' },
],
columns: [
// { field: 'seq_no', text: '번호', size: '70px' },
{ field: 'sales_date', text: '실적일', size: '110px', editable: { type: 'date' }, sortable: true },
{ field: 'emp_no', text: '사번', size: '110px', editable: { type: 'text' }, sortable: true },
{ field: 'client_code', text: '거래처코드', size: '120px', editable: { type: 'text' }, sortable: true },
{ field: 'product_code', text: '제품코드', size: '120px', editable: { type: 'text' }, sortable: true },
{ field: 'quantity', text: '수량', size: '80px',
editable: { type: 'int' }, sortable: true,
style: 'text-align:right' },
{ field: 'unit_price', text: '단가', size: '100px',
editable: { type: 'float' }, sortable: true,
style: 'text-align:right' },
{ field: 'discount', text: '할인액', size: '100px',
editable: { type: 'float' }, sortable: true,
style: 'text-align:right' },
{ field: 'total_amount', text: '총금액', size: '120px',
editable: { type: 'float' }, sortable: true,
style: 'text-align:right' },
{ field: 'remarks', text: '비고', size: '200px', editable: { type: 'text' }, sortable: true },
{ field: 'created_at', text: '등록일', size: '160px', sortable: true }
],
toolbar: {
items: [
{ id: 'add', type: 'button', text: '추가', icon: 'w2ui-icon-plus' },
{ id: 'delete', type: 'button', text: '삭제', icon: 'w2ui-icon-cross' }
],
onClick(event) {
const g = this.owner;
// reload
if (event.target === 'w2ui-reload') {
loadResults();
return;
}
// ADD
if (event.target === 'add') {
const newId = "new-" + Math.random().toString(36).substr(2, 9);
g.add({
recid: newId,
sales_date: "",
emp_no: "",
client_code: "",
product_code: "",
quantity: 0,
unit_price: 0,
discount: 0,
total_amount: 0,
remarks: "",
created_at: ""
});
g.refresh();
updateSummary();
return;
}
// DELETE
if (event.target === 'delete') {
const sel = g.getSelection();
if (!sel.length) return;
w2confirm("정말 삭제하시겠습니까?").yes(() => {
sel.forEach(id => {
// 신규행 삭제
if (String(id).startsWith("new-")) {
g.remove(id);
return;
}
fetch("/egbim/bbs/sales_results.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "delete",
seq_no: id
})
});
g.remove(id);
});
updateSummary();
});
}
}
},
// SAVE (insert + update)
onSave(event) {
const changes = this.getChanges();
if (!changes.length) return;
Promise.all(
changes.map(ch => {
if (String(ch.recid).startsWith("new-")) {
// INSERT
return fetch("/egbim/bbs/sales_results.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "insert",
sales_date: ch.sales_date,
emp_no: ch.emp_no,
client_code: ch.client_code,
product_code: ch.product_code,
quantity: ch.quantity,
unit_price: ch.unit_price,
discount: ch.discount,
total_amount: ch.total_amount,
remarks: ch.remarks
})
});
}
// UPDATE
return fetch("/egbim/bbs/sales_results.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "update",
seq_no: ch.recid,
sales_date: ch.sales_date,
emp_no: ch.emp_no,
client_code: ch.client_code,
product_code: ch.product_code,
quantity: ch.quantity,
unit_price: ch.unit_price,
discount: ch.discount,
total_amount: ch.total_amount,
remarks: ch.remarks
})
});
})
).then(() => {
loadResults();
w2alert("저장되었습니다!");
});
}
});
// LOAD
function loadResults() {
fetch("/egbim/bbs/sales_results.php?action=list")
.then(r => r.json())
.then(res => {
if (res.status === "ok") {
grid.records = res.records.map(r => ({
recid: r.seq_no,
...r
}));
updateSummary();
grid.refresh();
}
});
}
loadResults();
//합계
function updateSummary() {
let sumQty = 0;
let sumUnit = 0;
let sumDiscount = 0;
let sumTotal = 0;
grid.records.forEach(r => {
sumQty += Number(r.quantity || 0);
sumUnit += Number(r.unit_price || 0);
sumDiscount += Number(r.discount || 0);
sumTotal += Number(r.total_amount || 0);
});
grid.summary = [
{
recid: 'summary',
sales_date: '<span style="float:right;font-weight:bold;">합계</span>',
quantity: sumQty,
unit_price: sumUnit,
discount: sumDiscount,
total_amount: sumTotal,
w2ui: { summary: true }
}
];
grid.refresh();
}
//천단위
function numberFormat(num) {
if (num === null || num === undefined || num === "") return "-";
return Number(num).toLocaleString('ko-KR');
}
</script>
<?php sales_layout_end(); ?>
+469
View File
@@ -0,0 +1,469 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("영업실적");
?>
<h2 class="text-2xl font-bold mb-4">영업실적</h2>
<!-- <button class="bg-blue-600 text-white px-4 py-2 rounded mb-3" id="btn-add-result">
신규 실적 등록
</button> -->
<div id="grid_results" style="width:100%;height:720px;"></div>
<script type="module">
import { w2grid } from "https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js";
/* ------------------------------------------------------
공통 유틸
------------------------------------------------------ */
function clean(v) {
return (v === undefined || v === null) ? '' : v;
}
function cleanNumber(v) {
if (v === undefined || v === null || v === '' || isNaN(v)) return 0;
return Number(v);
}
function normalizeListValue(v) {
if (typeof v === "object" && v !== null && v.id) return v.id;
if (typeof v === "string") return v;
return '';
}
/* ------------------------------------------------------
🔵 직원 목록 / 거래처 목록 로드
------------------------------------------------------ */
let employeeList = [];
let clientList = [];
let productList = [];
async function loadEmployees() {
let res = await fetch('/egbim/bbs/sales_members.php?action=list');
let json = await res.json();
if (json.status === "ok") {
employeeList = json.records.map(m => ({
id: m.emp_no,
text: `${m.emp_name} (${m.emp_no})`
}));
}
}
async function loadClients() {
let res = await fetch('/egbim/bbs/sales_clients.php?action=list');
let json = await res.json();
if (json.status === "ok") {
clientList = json.records.map(c => ({
id: c.client_code,
text: `${c.client_name} (${c.client_code})`
}));
}
}
async function loadProducts() {
let res = await fetch('/egbim/bbs/sales_products.php?action=list');
let json = await res.json();
if (json.status === 'ok') {
productList = json.records.map(p => ({
id: p.code, // 저장용
text: `${p.name} (${p.code})` // 화면 표시용
}));
}
}
/* ------------------------------------------------------
🔵 직원 + 거래처 모두 로드 후 GRID 생성
------------------------------------------------------ */
await loadEmployees();
await loadClients();
await loadProducts();
/* ------------------------------------------------------
🔵 GRID
------------------------------------------------------ */
let grid = new w2grid({
name: 'grid_results',
box: '#grid_results',
show: {
toolbar: true,
footer: true,
toolbarSave: true,
toolbarReload: true,
toolbarSearch: true,
toolbarColumns: true,
lineNumbers: true
},
multiSearch: true,
searches: [
{ field: 'sales_date', label: '실적일', type: 'date' },
{ field: 'emp_no', label: '영업담당자', type: 'text' },
{ field: 'client_code', label: '거래처명', type: 'text', operator: 'contains' },
{ field: 'product_code', label: '제품명', type: 'text' },
{ field: 'remarks', label: '비고', type: 'text' },
],
columns: [
{ field: 'sales_date', text: '실적일', size: '110px', editable: { type: 'date' }, sortable: true },
/* 🔵 영업담당자 콤보 */
{
field: 'emp_no',
text: '영업담당자',
size: '150px',
editable: { type: 'combo', items: employeeList, showAll: true, filter: true},
resizable: true,
// render(record) {
// // 1) combo 선택 직후: record.emp_no = { id, text }
// if (typeof record.emp_no === "object" && record.emp_no !== null) {
// return record.emp_no.text; // ← 즉시 표시됨
// }
// // 2) 서버에서 불러온 값은 string → employeeList에서 매칭
// const item = employeeList.find(e => e.id == record.emp_no);
// return item ? item.text : record.emp_no;
// },
sortable: true
},
/* 🔵 거래처명 콤보 — DB는 client_code 저장 */
{
field: 'client_code',
text: '거래처명',
size: '180px',
editable: { type: 'combo', items: clientList, showAll: true, filter: true, match: 'contains', openOnFocus: true },
// render(record, extra) {
// let id = (typeof record.client_code === "object") ? record.client_code.id : record.client_code;
// let item = clientList.find(c => c.id == id);
// return item ? item.text : id;
// },
resizable: true,
sortable: true
},
{
field: 'product_code',
text: '제품명',
size: '200px',
editable: { type: 'combo', items: productList, filter: true, showAll: true },
// combo는 render 제거해야 정상표시됨
// render(record) {
// let item = productList.find(p => p.id === record.product_code);
// return item ? item.text : record.product_code;
// },
resizable: true,
sortable: true
},
{ field: 'quantity', text: '수량', size: '80px',
editable: { type: 'int' }, sortable: true, render: 'int', resizable: true, style: 'text-align:right' },
{ field: 'unit_price', text: '단가', size: '100px',
editable: { type: 'int' }, sortable: true, render: 'int', resizable: true, style: 'text-align:right' },
{ field: 'discount', text: '할인액', size: '100px',
editable: { type: 'float' }, sortable: true, render: 'int', resizable: true, style: 'text-align:right' },
{ field: 'total_amount', text: '총금액', size: '120px',
editable: { type: 'float' }, sortable: true, render: 'int', resizable: true, style: 'text-align:right' },
{ field: 'remarks', text: '비고', size: '200px', editable: { type: 'text' }, resizable: true, sortable: true },
{ field: 'created_at', text: '등록일', size: '160px', resizable: true, sortable: true }
],
toolbar: {
items: [
{ id: 'add', type: 'button', text: '추가', icon: 'w2ui-icon-plus' },
{ id: 'delete', type: 'button', text: '삭제', icon: 'w2ui-icon-cross' }
],
onClick(event) {
const g = this.owner;
if (event.target === 'w2ui-reload') {
loadResults();
return;
}
if (event.target === 'add') {
const newId = "new-" + Math.random().toString(36).substr(2, 9);
g.add({
recid: newId,
sales_date: "",
emp_no: "",
client_code: "",
product_code: "",
quantity: 0,
unit_price: 0,
discount: 0,
total_amount: 0,
remarks: "",
created_at: ""
});
g.refresh();
updateSummary();
return;
}
if (event.target === 'delete') {
const sel = g.getSelection();
if (!sel.length) return;
if (!confirm("정말 삭제하시겠습니까?")) return;
sel.forEach(id => {
// 신규(new-xxx)는 DB 삭제 X
if (String(id).startsWith("new-")) {
g.remove(id);
return;
}
fetch("/egbim/bbs/sales_results.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "delete",
seq_no: id
})
})
.then(res => res.json())
.then(json => {
console.log("삭제 응답:", json);
})
.catch(err => console.error("삭제 오류:", err));
g.remove(id);
});
updateSummary();
}
}
},
onChange(event) {
event.onComplete = function(ev) {
let rec = grid.get(ev.recid);
if (!rec) return;
let field = grid.columns[ev.column].field;
let val = ev.value_new;
// combo object → text 강제 변환
if (typeof val === "object" && val !== null) {
val = val.text;
}
grid.set(ev.recid, { [field]: val });
// 계산 필드 자동 업데이트
if (field === 'quantity' || field === 'unit_price' || field === 'discount') {
let qty = parseInt(rec.quantity) || 0;
let unit = parseInt(rec.unit_price) || 0;
let dc = parseInt(rec.discount) || 0;
let total = Math.max(0, qty * unit - dc);
grid.set(ev.recid, { total_amount: total });
}
grid.refresh();
};
},
/* ------------------------------------------------------
🔥 저장 처리 (insert + update)
------------------------------------------------------ */
onSave(event) {
const changes = this.getChanges();
if (!changes.length) return;
Promise.all(
changes.map(ch => {
let rec = this.get(ch.recid); // 기존 record 전체 값
/* -------------------------------------------
🔵 1) 영업담당자(emp_no) 유효성 검사 및 변환
------------------------------------------- */
// raw 값 가져오기 (object, string 모두 처리)
let empRaw = ch.emp_no ?? rec.emp_no;
console.log('1111333');
// 콤보일 경우 object → text 로 변환 ("홍길동 (01201)")
if (typeof empRaw === "object" && empRaw !== null) {
empRaw = empRaw.text;
console.log('1111222');
}
// 빈 값은 허용
if (empRaw !== "") {
console.log('1111444');
// "(사번)" 추출
let match = empRaw.match(/\((.*?)\)/);
if (!match) {
console.log('11115');
alert("영업담당자 형식이 잘못되었습니다. 예: 홍길동 (01201)");
throw "invalid emp_no format";
}
let extractedEmpNo = match[1];
// 사번 유효성 체크
let validEmpList = employeeList.map(e => e.id);
if (!validEmpList.includes(extractedEmpNo)) {
console.log('11116');
alert("영업담당자는 목록에 있는 사람만 선택할 수 있습니다.");
throw "invalid emp_no";
}
}
// ✔ 최종 DB에는 "홍길동 (01201)" 그대로 저장
ch.emp_no = empRaw;
/* -------------------------------------------
🔵 2) 거래처(client_code) 유효성 검사
------------------------------------------- */
let client = normalizeListValue(ch.client_code ?? rec.client_code);
let validClientList = clientList.map(c => c.id);
let clientRaw = ch.client_code ?? rec.client_code;
if (typeof clientRaw === "object" && clientRaw !== null) {
clientRaw = clientRaw.text;
}
if (clientRaw !== "") {
let match = clientRaw.match(/\(([^()]*)\)(?!.*\([^()]*\))/);
if (!match) {
alert("거래처명 형식이 잘못되었습니다. 예: 현대건설 (C002)");
throw "invalid_client_code_format";
}
let code = match[1];
let validList = clientList.map(c => c.id);
if (!validList.includes(code)) {
alert("거래처명은 목록에서 선택해야 합니다.");
throw "invalid_client_code";
}
}
ch.client_code = clientRaw;
/* -------------------------------------------
🔵 3) 제품명(product_code) 유효성 검사
------------------------------------------- */
let product = normalizeListValue(ch.product_code ?? rec.product_code);
let validProductList = productList.map(p => p.id);
let productRaw = ch.product_code ?? rec.product_code;
if (typeof productRaw === "object" && productRaw !== null) {
productRaw = productRaw.text;
}
if (productRaw !== "") {
let match = productRaw.match(/\((.*?)\)$/);
if (!match) {
alert("제품명 형식이 잘못되었습니다. 예: EG-BIM (P001)");
throw "invalid_product_code_format";
}
let code = match[1];
let validList = productList.map(p => p.id);
if (!validList.includes(code)) {
alert("제품명은 목록에서 선택해야 합니다.");
throw "invalid_product_code";
}
}
ch.product_code = productRaw;
let body = {
action: String(ch.recid).startsWith("new-") ? "insert" : "update",
seq_no: ch.recid,
sales_date: clean(ch.sales_date ?? rec.sales_date),
emp_no: ch.emp_no,
client_code: ch.client_code,
product_code: ch.product_code,
quantity: cleanNumber(ch.quantity ?? rec.quantity),
unit_price: cleanNumber(ch.unit_price ?? rec.unit_price),
discount: cleanNumber(ch.discount ?? rec.discount),
total_amount: cleanNumber(ch.total_amount ?? rec.total_amount),
remarks: clean(ch.remarks ?? rec.remarks)
};
return fetch("/egbim/bbs/sales_results.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body)
});
})
).then(() => {
loadResults();
alert("저장되었습니다!");
});
}
});
/* ------------------------------------------------------
🔵 데이터 로드 + 합계
------------------------------------------------------ */
function loadResults() {
fetch("/egbim/bbs/sales_results.php?action=list")
.then(r => r.json())
.then(res => {
if (res.status === "ok") {
grid.records = res.records.map(r => ({
recid: r.seq_no,
...r
}));
updateSummary();
grid.refresh();
}
});
}
loadResults();
function updateSummary() {
let sumQty = 0, sumUnit = 0, sumDiscount = 0, sumTotal = 0;
grid.records.forEach(r => {
sumQty += Number(r.quantity || 0);
sumUnit += Number(r.unit_price || 0);
sumDiscount += Number(r.discount || 0);
sumTotal += Number(r.total_amount || 0);
});
grid.summary = [
{
recid: 'summary',
sales_date: '<span style="float:right;font-weight:bold;">합계</span>',
quantity: sumQty,
unit_price: sumUnit,
discount: sumDiscount,
total_amount: sumTotal,
w2ui: { summary: true }
}
];
grid.refresh();
}
</script>
<?php sales_layout_end(); ?>
@@ -0,0 +1,350 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("영업 스케줄표");
?>
<h2 class="text-2xl font-bold mb-3">영업 스케줄표</h2>
<!-- 월 선택 -->
<div class="flex gap-2 mb-4">
<select id="sel-year" class="border p-1">
<?php
for ($y=date("Y")-1; $y<=date("Y")+2; $y++) {
echo "<option value='$y' ".($y==date("Y")?"selected":"").">$y 년</option>";
}
?>
</select>
<select id="sel-month" class="border p-1">
<?php
for ($m=1;$m<=12;$m++) {
$mm = sprintf("%02d",$m);
echo "<option value='$mm' ".($mm==date("m")?"selected":"").">$mm 월</option>";
}
?>
</select>
<button id="btn-load" class="bg-blue-600 text-white px-3 py-1 rounded">불러오기</button>
</div>
<div id="month-container"></div>
<script type="module">
import { w2grid,w2popup } from "https://cdn.jsdeliver.net/npm/w2ui@2.0.0/dist/w2ui.es6.min.js";
let allSchedules = [];
let employees = [];
let weekGroups = [];
/* =========================================================
평일(월~금) 기준 주차 생성
========================================================= */
function generateWeeks(year, month) {
let lastDay = new Date(year, month, 0).getDate();
let businessDays = [];
for (let d=1; d<=lastDay; d++) {
let date = new Date(`${year}-${month}-${String(d).padStart(2,'0')}`);
let dow = date.getDay();
if (dow >= 1 && dow <= 5) {
businessDays.push({
day: d,
dow: ["","월","화","수","목","금"][dow],
full: `${year}-${month}-${String(d).padStart(2,'0')}`
});
}
}
let weeks = [];
for (let i=0; i<businessDays.length; i+=5) {
weeks.push(businessDays.slice(i, i+5));
}
return weeks;
}
/* =========================================================
월 전체 로드
========================================================= */
async function loadMonth() {
let year = document.getElementById("sel-year").value;
let month = document.getElementById("sel-month").value;
let res = await fetch(`/egbim/bbs/sales_schedules.php?action=month&year=${year}&month=${month}`);
let json = await res.json();
if (json.status !== "ok") return;
employees = json.employees;
allSchedules = json.schedules;
weekGroups = generateWeeks(year, month);
renderMonth();
}
/* =========================================================
월 → 주차 렌더링
========================================================= */
function renderMonth() {
let container = document.getElementById("month-container");
container.innerHTML = "";
weekGroups.forEach((week, idx) => {
container.insertAdjacentHTML("beforeend", `
<div class="font-bold text-lg mt-6 mb-2">
${idx+1}주차 : ${week[0].day}일 ~ ${week.at(-1).day}일
</div>
<div id="week-grid-${idx}" style="width:100%;height:360px;"></div>
`);
renderWeekGrid(week, idx);
});
}
/* =========================================================
주차 Grid
========================================================= */
function renderWeekGrid(week, idx) {
let columns = [
{ field: "emp_name", text: "담당자", size: "120px", frozen: true }
];
week.forEach(d => {
columns.push({
field: "day_" + d.day,
text: `${d.day}일(${d.dow})`,
size: "260px",
render: rec => rec["day_" + d.day] || ""
});
});
let records = employees.map(emp => {
let row = {
recid: emp.emp_no + "_" + idx,
emp_name: emp.emp_name,
emp_no: emp.emp_no
};
week.forEach(d => {
let items = allSchedules.filter(s =>
s.emp_no === emp.emp_no && s.schedule_date === d.full
);
row["day_" + d.day] = items.map(it => `
<div style="margin-bottom:4px;">
• ${it.content}
<div style="font-size:11px;color:#777;">${it.client_code}</div>
<div style="font-size:11px;color:#0a0;">${it.status}</div>
</div>
`).join("");
});
return row;
});
new w2grid({
name: `grid_week_${idx}`,
box: `#week-grid-${idx}`,
show: { toolbar: false, footer: false },
columns,
records,
/* 셀 클릭 → 일정 팝업 */
onClick(event) {
let rec = this.get(event.recid);
if (!rec) return;
let col = this.columns[event.column];
if (!col || !col.field) return; // ← 헤더 클릭 오류 방지
let field = col.field;
if (!field.startsWith("day_")) return;
let dayNum = field.replace("day_", "");
let fullDate = week.find(w => w.day == dayNum).full;
openSchedulePopup(rec.emp_no, rec.emp_name, fullDate);
}
});
}
/* =========================================================
일정 리스트 팝업
========================================================= */
function openSchedulePopup(emp_no, emp_name, date) {
let items = allSchedules.filter(s => s.emp_no == emp_no && s.schedule_date == date);
let html = `
<div class="p-3">
<div class="mb-2 font-bold">${date} - ${emp_name}</div>
<button class="bg-blue-600 text-white px-3 py-1 rounded mb-3"
onclick="window.addSchedule('${emp_no}','${date}')">+ 신규 일정 추가</button>
${items.map(it => `
<div class="border rounded p-2 mb-2">
<div><b>${it.content}</b></div>
<div class="text-xs text-gray-600">${it.client_code}</div>
<div class="text-xs mt-1">상태: ${it.status}</div>
<button onclick="window.editSchedule(${it.seq_no})"
class="bg-yellow-500 text-white px-2 py-1 rounded mt-2">수정</button>
<button onclick="window.deleteSchedule(${it.seq_no})"
class="bg-red-600 text-white px-2 py-1 rounded mt-2 ml-2">삭제</button>
</div>
`).join("")}
</div>
`;
w2popup.open({
title: `${date} - 일정`,
body: html,
width: 420,
height: 500
});
}
/* =========================================================
신규 일정 추가
========================================================= */
window.addSchedule = function(emp_no, date) {
let html = `
<div class="p-3">
<label>내용</label>
<input id="s-content" class="border p-2 w-full mb-2">
<label>거래처 코드</label>
<input id="s-client" class="border p-2 w-full mb-2">
<label>상태</label>
<select id="s-status" class="border p-2 w-full mb-3">
<option value="예정">예정</option>
<option value="완료">완료</option>
</select>
<button class="bg-blue-600 text-white px-3 py-2 rounded w-full"
onclick="window.saveNew('${emp_no}','${date}')">등록</button>
</div>
`;
w2popup.open({
title: "신규 일정 추가",
body: html,
width: 400,
height: 350
});
};
window.saveNew = async function(emp_no, date) {
let res = await fetch("/egbim/bbs/sales_schedules.php", {
method: "POST",
headers: {"Content-Type":"application/x-www-form-urlencoded"},
body: new URLSearchParams({
action: "insert",
emp_no,
schedule_date: date,
client_code: document.getElementById("s-client").value,
content: document.getElementById("s-content").value,
status: document.getElementById("s-status").value
})
});
let json = await res.json();
if (json.status === "ok") {
w2popup.close();
loadMonth();
}
};
/* =========================================================
일정 수정
========================================================= */
window.editSchedule = async function(seq_no) {
let item = allSchedules.find(s => s.seq_no == seq_no);
let html = `
<div class="p-3">
<label>내용</label>
<input id="e-content" class="border p-2 w-full mb-2" value="${item.content}">
<label>거래처 코드</label>
<input id="e-client" class="border p-2 w-full mb-2" value="${item.client_code}">
<label>상태</label>
<select id="e-status" class="border p-2 w-full mb-3">
<option value="예정" ${item.status=="예정"?"selected":""}>예정</option>
<option value="완료" ${item.status=="완료"?"selected":""}>완료</option>
</select>
<button class="bg-blue-600 text-white px-3 py-2 rounded w-full"
onclick="window.saveEdit(${seq_no})">저장</button>
</div>
`;
w2popup.open({
title: "일정 수정",
body: html,
width: 400,
height: 350
});
};
window.saveEdit = async function(seq_no) {
let res = await fetch("/egbim/bbs/sales_schedules.php", {
method: "POST",
headers: {"Content-Type":"application/x-www-form-urlencoded"},
body: new URLSearchParams({
action: "update",
seq_no,
content: document.getElementById("e-content").value,
client_code: document.getElementById("e-client").value,
status: document.getElementById("e-status").value
})
});
let json = await res.json();
if (json.status === "ok") {
w2popup.close();
loadMonth();
}
};
/* =========================================================
일정 삭제
========================================================= */
window.deleteSchedule = async function(seq_no) {
if (!confirm("삭제하시겠습니까?")) return;
let res = await fetch("/egbim/bbs/sales_schedules.php", {
method: "POST",
headers: {"Content-Type":"application/x-www-form-urlencoded"},
body: new URLSearchParams({
action: "delete",
seq_no
})
});
let json = await res.json();
if (json.status === "ok") {
w2popup.close();
loadMonth();
}
};
/* 초기 로드 */
document.getElementById("btn-load").onclick = loadMonth;
loadMonth();
</script>
<?php sales_layout_end(); ?>
+602
View File
@@ -0,0 +1,602 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("영업 스케줄표");
?>
<!-- SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<h2 class="text-2xl font-bold mb-3">영업 스케줄표</h2>
<!-- 월 선택 -->
<div class="flex gap-2 mb-4">
<select id="sel-year" class="border p-1">
<?php
for ($y=date("Y")-1; $y<=date("Y")+2; $y++) {
echo "<option value='$y' ".($y==date("Y")?"selected":"").">$y 년</option>";
}
?>
</select>
<select id="sel-month" class="border p-1">
<?php
for ($m=1;$m<=12;$m++) {
$mm = sprintf("%02d",$m);
echo "<option value='$mm' ".($mm==date("m")?"selected":"").">$mm 월</option>";
}
?>
</select>
<!-- <button id="btn-load" class="bg-blue-600 text-white px-3 py-1 rounded">
불러오기
</button> -->
</div>
<div id="month-container"></div>
<!-- ⭐ 주요 이슈 사항 영역 -->
<div id="issue-box" class="mt-10 hidden">
<div class="flex justify-between items-center mb-2">
<h3 id="issue-title" class="text-xl font-bold">📌 주요 이슈 사항</h3>
<button id="btn-save-issue"
class="px-3 py-1 bg-amber-600 text-white rounded hover:bg-amber-700">
저장
</button>
</div>
<textarea id="issue-textarea"
class="w-full border border-gray-300 rounded p-3"
placeholder="이번 달 주요 이슈를 입력하세요."
style="height:150px;"></textarea>
</div>
<script>
let allSchedules = [];
let employees = [];
let weekGroups = [];
let clientList = [];
/* ===============================
거래처 로드
=============================== */
async function loadClients() {
let res = await fetch('/egbim/bbs/sales_clients.php?action=list');
let json = await res.json();
if (json.status === "ok") {
clientList = json.records.map(c => ({
id: c.client_code,
text: `${c.client_name} (${c.client_code})`
}));
}
}
/* ===============================
초기 로드
=============================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadClients();
loadMonth();
});
/* ===============================
주요 이슈 로드
=============================== */
async function loadIssue(year, month) {
const ym = `${year}-${month}`;
try {
let res = await fetch(`/egbim/bbs/sales_issue.php?action=get&issue_month=${ym}`);
let json = await res.json();
document.getElementById("issue-title").innerText = `📌 ${month}월 주요 이슈 사항`;
document.getElementById("issue-textarea").value = json.issue_text ?? "";
} catch (e) {
document.getElementById("issue-textarea").value = "";
}
document.getElementById("issue-box").classList.remove("hidden");
}
/* ===============================
주요 이슈 저장
=============================== */
document.getElementById("btn-save-issue").onclick = async function () {
const year = document.getElementById("sel-year").value;
const month = document.getElementById("sel-month").value;
let res = await fetch("/egbim/bbs/sales_issue.php", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: new URLSearchParams({
action: "save",
issue_month: `${year}-${month}`,
issue_text: document.getElementById("issue-textarea").value
})
});
let json = await res.json();
if (json.success) Swal.fire("저장 완료!", "", "success");
};
/* ===============================
평일 기준 주차 생성
=============================== */
function generateWeeks(year, month) {
const weeks = [];
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
let currentWeek = [];
let weekNo = 1;
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month - 1, d);
const dow = date.getDay(); // 0=일, 1=월, ... 6=토
// 월요일이고, 이전 주차에 데이터 있으면 새 주차 시작
if (dow === 1 && currentWeek.length) {
weeks.push({
weekNo: weekNo++,
days: currentWeek
});
currentWeek = [];
}
// 평일만 포함
if (dow >= 1 && dow <= 5) {
currentWeek.push({
day: d,
dow: ["","월","화","수","목","금"][dow],
full: `${year}-${String(month).padStart(2,"0")}-${String(d).padStart(2,"0")}`
});
}
}
// 마지막 주차 push
if (currentWeek.length) {
weeks.push({
weekNo: weekNo,
days: currentWeek
});
}
return weeks;
}
/* ===============================
현재 주차 index 계산
=============================== */
function getCurrentWeekIndex(year, month, weeks) {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,"0")}-${String(today.getDate()).padStart(2,"0")}`;
if (`${year}-${month}` !== todayStr.slice(0,7)) return null;
for (let i = 0; i < weeks.length; i++) {
if (weeks[i].days.some(d => d.full === todayStr)) {
return i;
}
}
return null;
}
/* ===============================
주차 재정렬
=============================== */
function reorderWeeks(weeks, currentIdx) {
if (currentIdx === null) return weeks;
const current = weeks[currentIdx];
const before = weeks.slice(0, currentIdx);
const after = weeks.slice(currentIdx + 1);
return [current, ...before, ...after];
}
/* ===============================
월 전체 로드
=============================== */
async function loadMonth() {
let year = document.getElementById("sel-year").value;
let month = document.getElementById("sel-month").value;
let res = await fetch(`/egbim/bbs/sales_schedules.php?action=month&year=${year}&month=${month}`);
let json = await res.json();
employees = json.employees;
allSchedules = json.schedules;
weekGroups = generateWeeks(year, month);
const idx = getCurrentWeekIndex(year, month, weekGroups);
const currentWeekNo = getCurrentWeekNo(year, month, weekGroups);
window.__CURRENT_WEEK_NO__ = currentWeekNo;
weekGroups = reorderWeeks(weekGroups, idx);
renderMonth();
await loadIssue(year, month);
}
/* ===============================
월 → 주차 렌더링
=============================== */
function renderMonth() {
let container = document.getElementById("month-container");
container.innerHTML = "";
weekGroups.forEach(week => {
let html = `
<div class="mt-10 ${week.weekNo === window.__CURRENT_WEEK_NO__ ? 'week-current' : ''}">
<div class="font-bold text-xl mb-3 week-title">
${week.weekNo}주차 : ${week.days[0].day}일 ~ ${week.days.at(-1).day}일
</div>
<div class="bg-white rounded-xl shadow border overflow-hidden">
<table class="w-full text-sm sales-schedule-table">
<thead class="bg-gray-100">
<tr>
<th class="border-r px-3 py-2 w-28 text-center">담당자</th>
${week.days.map(d => `
<th class="border-r px-3 py-2 text-center">
${d.day}일(${d.dow})
</th>
`).join("")}
</tr>
</thead>
<tbody>
${employees.map(emp => `
<tr>
<td class="border px-3 py-2 text-center bg-gray-50">${emp.emp_name}</td>
${week.days.map(d => {
let items = allSchedules.filter(s =>
s.emp_no == emp.emp_no && s.schedule_date == d.full
);
return `
<td class="border px-2 py-2 align-top cursor-pointer"
onclick="openSchedulePopup('${emp.emp_no}','${emp.emp_name}','${d.full}')">
${
items.length
? items.map(it => `
<div class="mb-1 p-2 border rounded bg-gray-50">
<div class="font-medium">${it.content}</div>
<div class="text-xs text-gray-500">${it.client_code}</div>
<span class="text-xs px-2 py-0.5 rounded ${
it.status === '완료'
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}">${it.status}</span>
</div>
`).join("")
: `<div class="text-xs text-gray-300">(없음)</div>`
}
</td>
`;
}).join("")}
</tr>
`).join("")}
</tbody>
</table>
</div>
</div>`;
container.insertAdjacentHTML("beforeend", html);
});
}
//현재 주차 번호 구하기
function getCurrentWeekNo(year, month, weeks) {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,"0")}-${String(today.getDate()).padStart(2,"0")}`;
if (`${year}-${month}` !== todayStr.slice(0,7)) return null;
for (let w of weeks) {
if (w.days.some(d => d.full === todayStr)) {
return w.weekNo;
}
}
return null;
}
/* =========================================================
일정 리스트 팝업 (SweetAlert2)
========================================================= */
function openSchedulePopup(emp_no, emp_name, date) {
let items = allSchedules.filter(
s => s.emp_no == emp_no && s.schedule_date == date
);
const listHtml = items.length
? items.map(it => `
<div class="swal-schedule-card">
<div class="swal-schedule-content">
${it.content}
</div>
<div class="swal-schedule-meta">
${it.client_code} · ${it.time_period}
<span class="ml-2 px-2 py-0.5 rounded text-xs ${
it.status === '완료'
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}">${it.status}</span>
</div>
<div class="swal-schedule-actions">
<button onclick="editSchedule(${it.seq_no})"
class="px-2 py-1 text-xs rounded bg-amber-400 text-white">
수정
</button>
<button onclick="deleteSchedule(${it.seq_no})"
class="px-2 py-1 text-xs rounded bg-red-500 text-white">
삭제
</button>
</div>
</div>
`).join("")
: `<div style="color:#9ca3af;">등록된 일정이 없습니다.</div>`;
Swal.fire({
width: 480,
showConfirmButton: false,
html: `
<div class="swal-schedule-header">
<div>
<div class="swal-schedule-title">${date}</div>
<div class="swal-schedule-sub">${emp_name}</div>
</div>
<button onclick="addSchedule('${emp_no}','${date}')"
class="px-3 py-1 text-sm rounded bg-indigo-500 text-white">
+ 일정 추가
</button>
</div>
<div class="swal-schedule-list">
${listHtml}
</div>
`
});
}
/* =========================================================
신규 일정 추가
========================================================= */
window.addSchedule = function(emp_no, date) {
let clientOptions = clientList
.map(c => `<option value="${c.id}">${c.text}</option>`)
.join("");
Swal.fire({
title: "신규 일정 추가",
html: `
<div class="swal-form">
<div class="swal-group">
<label class="title">시간대</label>
<div class="swal-radio">
<label><input type="radio" name="s_period" value="오전" checked> 오전</label>
<label><input type="radio" name="s_period" value="오후"> 오후</label>
</div>
</div>
<div class="swal-group">
<label class="title">상태</label>
<div class="swal-radio">
<label><input type="radio" name="s_status" value="예정" checked> 예정</label>
<label><input type="radio" name="s_status" value="완료"> 완료</label>
</div>
</div>
<div class="swal-group">
<label class="title">거래처</label>
<select id="s-client" class="swal-select">
<option value="">선택하세요</option>
${clientOptions}
</select>
</div>
<div class="swal-group">
<label class="title">내용</label>
<textarea id="s-content"
class="swal-textarea"
placeholder="내용을 입력하세요"></textarea>
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: "등록",
cancelButtonText: "취소",
width: 420
}).then(async result => {
if (!result.isConfirmed) return;
let status = document.querySelector('input[name="s_status"]:checked').value;
let period = document.querySelector('input[name="s_period"]:checked').value;
let client_code = document.getElementById("s-client").value;
let content = document.getElementById("s-content").value;
let res = await fetch("/egbim/bbs/sales_schedules.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "insert",
emp_no,
schedule_date: date,
time_period: period,
client_code,
content,
status
})
});
let j = await res.json();
if (j.status === "ok") {
Swal.close();
loadMonth();
}
});
};
/* =========================================================
일정 수정
========================================================= */
window.editSchedule = function(seq_no) {
let item = allSchedules.find(s => s.seq_no == seq_no);
let clientOptions = clientList
.map(c => `
<option value="${c.id}" ${c.id == item.client_code ? "selected" : ""}>
${c.text}
</option>
`)
.join("");
Swal.fire({
title: "일정 수정",
html: `
<div class="swal-form">
<div class="swal-group">
<label class="title">시간대</label>
<div class="swal-radio">
<label>
<input type="radio" name="e_period" value="오전"
${item.time_period === "오전" ? "checked" : ""}>
오전
</label>
<label>
<input type="radio" name="e_period" value="오후"
${item.time_period === "오후" ? "checked" : ""}>
오후
</label>
</div>
</div>
<div class="swal-group">
<label class="title">상태</label>
<div class="swal-radio">
<label>
<input type="radio" name="e_status" value="예정"
${item.status === "예정" ? "checked" : ""}>
예정
</label>
<label>
<input type="radio" name="e_status" value="완료"
${item.status === "완료" ? "checked" : ""}>
완료
</label>
</div>
</div>
<div class="swal-group">
<label class="title">거래처</label>
<select id="e-client" class="swal-select">
<option value="">선택하세요</option>
${clientOptions}
</select>
</div>
<div class="swal-group">
<label class="title">내용</label>
<textarea id="e-content"
class="swal-textarea"
placeholder="내용을 입력하세요">${item.content}</textarea>
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: "저장",
cancelButtonText: "취소",
width: 420
}).then(async result => {
if (!result.isConfirmed) return;
let status = document.querySelector('input[name="e_status"]:checked').value;
let period = document.querySelector('input[name="e_period"]:checked').value;
let client_code = document.getElementById("e-client").value;
let content = document.getElementById("e-content").value;
let res = await fetch("/egbim/bbs/sales_schedules.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "update",
seq_no,
emp_no: item.emp_no,
schedule_date: item.schedule_date,
time_period: period,
client_code,
content,
status
})
});
let j = await res.json();
if (j.status === "ok") {
Swal.close();
loadMonth();
}
});
};
/* =========================================================
일정 삭제
========================================================= */
window.deleteSchedule = function(seq_no) {
Swal.fire({
title:"삭제하시겠습니까?",
icon:"warning",
showCancelButton:true,
confirmButtonText:"삭제"
}).then(async r=>{
if(!r.isConfirmed) return;
let res = await fetch("/egbim/bbs/sales_schedules.php",{
method:"POST",
headers:{"Content-Type":"application/x-www-form-urlencoded"},
body:new URLSearchParams({
action:"delete",
seq_no
})
});
let j = await res.json();
if(j.status==="ok"){
Swal.close();
loadMonth();
}
});
};
//document.getElementById("btn-load").onclick = loadMonth;
loadMonth();
/* ===============================
연도 / 월 변경 시 자동 로드
=============================== */
document.getElementById("sel-year").addEventListener("change", () => {
loadMonth();
});
document.getElementById("sel-month").addEventListener("change", () => {
loadMonth();
});
</script>
<?php sales_layout_end(); ?>
+297
View File
@@ -0,0 +1,297 @@
<?php
include __DIR__ . '/layout_sales.php';
sales_layout_start("영업 목표 설정");
?>
<h2 class="text-2xl font-bold mb-4">영업 목표 설정</h2>
<div id="grid_targets" style="width:100%;height:720px;"></div>
<script type="module">
import { w2grid } from "https://cdn.jsdelivr.net/gh/vitmalina/w2ui@master/dist/w2ui.es6.min.js";
/* ------------------------------------------------------
공통 유틸
------------------------------------------------------ */
function clean(v) {
return (v === undefined || v === null) ? '' : v;
}
function cleanNumber(v) {
if (v === undefined || v === null || v === '' || isNaN(v)) return 0;
return Number(v);
}
function normalizeListValue(v) {
if (typeof v === "object" && v !== null && v.id) return v.id;
return v ?? '';
}
/* ------------------------------------------------------
직원 목록 로드
------------------------------------------------------ */
let employeeList = [];
async function loadEmployees() {
let res = await fetch('/egbim/bbs/sales_members.php?action=list');
let json = await res.json();
if (json.status === "ok") {
employeeList = json.records.map(m => ({
id: m.emp_no,
text: `${m.emp_name} (${m.emp_no})`
}));
}
}
await loadEmployees();
/* ------------------------------------------------------
GRID 정의
------------------------------------------------------ */
let grid = new w2grid({
name: 'grid_targets',
box: '#grid_targets',
show: {
toolbar: true,
footer: true,
toolbarSave: true,
toolbarReload: true,
toolbarSearch: true,
toolbarColumns: true,
lineNumbers: false
},
multiSearch: true,
searches: [
{ field: 'target_month', label: '목표월', type: 'text' },
{ field: 'emp_no', label: '영업담당자', type: 'text' }
],
columns: [
{ field: 'target_month', text: '목표월', size: '120px',
editable: { type: 'text' }, sortable: true },
{
field: 'emp_no',
text: '영업담당자',
size: '160px',
editable: { type: 'combo', items: employeeList, showAll: true, filter: true },
sortable: true,
// render(record) {
// if (typeof record.emp_no === "object" && record.emp_no !== null)
// return record.emp_no.text;
// const item = employeeList.find(e => e.id == record.emp_no);
// return item ? item.text : record.emp_no;
// }
},
{ field: 'target_qty', text: '목표수량', size: '120px',
editable: { type: 'int' }, sortable: true, render: 'int', style: 'text-align:right' },
{ field: 'target_amount', text: '목표금액', size: '150px',
editable: { type: 'float' }, sortable: true, render: 'int', style: 'text-align:right' },
{ field: 'created_at', text: '등록일', size: '160px', sortable: true }
],
toolbar: {
items: [
{ id: 'add', type: 'button', text: '추가', icon: 'w2ui-icon-plus' },
{ id: 'delete', type: 'button', text: '삭제', icon: 'w2ui-icon-cross' }
],
onClick(event) {
const g = this.owner;
if (event.target === 'w2ui-reload') {
loadTargets();
return;
}
if (event.target === 'add') {
let recid = grid.records.length + 2;
g.add({recid:recid,target_month:'',emp_no:'',target_qty:0,target_amount:0,created_at:''});
g.scrollIntoView(recid);
g.editField(recid, 0);
/*
const newId = "new-" + Math.random().toString(36).substr(2, 9);
g.add({
recid: newId,
target_month: "",
emp_no: "",
target_qty: 0,
target_amount: 0,
created_at: ""
});
g.refresh();
return;
*/
}
if (event.target === 'delete') {
const sel = g.getSelection();
if (!sel.length) return;
if (!confirm("정말 삭제하시겠습니까?")) return;
sel.forEach(id => {
if (String(id).startsWith("new-")) {
g.remove(id);
return;
}
fetch("/egbim/bbs/sales_targets.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
action: "delete",
id: id
})
});
g.remove(id);
});
}
}
},
/* ------------------------------------------------------
저장 처리
------------------------------------------------------ */
onSave(event) {
const changes = this.getChanges();
if (!changes.length) return;
Promise.all(
changes.map(ch => {
let rec = this.get(ch.recid);
let empRaw = ch.emp_no ?? rec.emp_no;
let empText = ""; // 저장될 값: 이름(사번)
let empId = ""; // 검증용 사번
/* ------------------------------------------------------
1) combo 선택한 경우 (object)
------------------------------------------------------ */
if (typeof empRaw === "object" && empRaw !== null) {
empId = String(empRaw.id);
// combo text를 그대로 믿지 말고 employeeList 기준으로 재확정
const found = employeeList.find(e => String(e.id) === empId);
if (found) {
empText = found.text;
} else {
empText = empRaw.text; // fallback
}
}
/* ------------------------------------------------------
2) 직접 입력한 경우
- 이름(사번)
- 이름 (사번)
- 사번만 입력 → 허용 X (반드시 이름(사번)으로 변환)
------------------------------------------------------ */
else {
let typed = String(empRaw ?? "").trim();
// (1) 이름(사번) 패턴
let match = typed.match(/(.+)\(([A-Za-z0-9]+)\)$/);
if (match) {
empText = typed;
empId = match[2];
}
else {
// (2) 사번만 입력 → 리스트에서 검색하여 이름(사번) 변환
let foundById = employeeList.find(e => e.id === typed);
if (foundById) {
empId = foundById.id;
empText = foundById.text; // 이름(사번)
}
else {
// (3) 이름만 입력 → 리스트에서 검색하여 이름(사번) 변환
let foundByName = employeeList.find(e =>
e.text.startsWith(typed + "(") // "이름(" 패턴 확인
);
if (foundByName) {
empId = foundByName.id;
empText = foundByName.text;
} else {
alert("⚠ 올바른 이름 또는 이름(사번)을 입력해주세요.");
throw 'invalid-format';
}
}
}
}
/* ------------------------------------------------------
3) employeeList 존재 여부 체크
------------------------------------------------------ */
const normalize = v => String(v).replace(/\s+/g, '').trim();
const exists = employeeList.some(e =>
normalize(e.text) === normalize(empText)
);
if (!exists) {
alert(`영업담당자 '${empText}' 은(는) 등록된 직원이 아닙니다.`);
throw 'invalid-emp';
}
let body = {
target_month: clean(ch.target_month ?? rec.target_month),
emp_no: empText,
target_qty: cleanNumber(ch.target_qty ?? rec.target_qty),
target_amount: cleanNumber(ch.target_amount ?? rec.target_amount)
};
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(body.target_month)) {
alert("목표월은 YYYY-MM 형식이어야 합니다.");
throw 'invalid-month';
}
if (String(ch.recid).startsWith("new-")) {
body.action = "insert";
} else {
body.action = "update";
body.id = ch.recid;
}
return fetch("/egbim/bbs/sales_targets.php", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body)
});
})
).then(() => {
loadTargets();
w2alert("저장되었습니다!");
});
}
});
/* ------------------------------------------------------
데이터 로드
------------------------------------------------------ */
function loadTargets() {
fetch("/egbim/bbs/sales_targets.php?action=list")
.then(r => r.json())
.then(res => {
if (res.status === "ok") {
grid.records = res.records.map(r => ({
recid: r.id,
...r
}));
grid.refresh();
}
});
}
loadTargets();
</script>
<?php sales_layout_end(); ?>