최초 커밋

This commit is contained in:
root
2026-07-23 16:15:57 +09:00
commit c8f5efb6b7
14652 changed files with 4812531 additions and 0 deletions
+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

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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)})}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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
View File
+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
+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
+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
@@ -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(); ?>