Initial commit
@@ -0,0 +1,95 @@
|
||||
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification\
|
||||
for details on configuring this project to bundle and minify static web assets. */
|
||||
|
||||
a.navbar-brand {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0077cc;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #0077cc;
|
||||
outline: black auto 1px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-check-input:focus {
|
||||
border-color: #0077cc;
|
||||
box-shadow: none;
|
||||
outline: black auto 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn-primary:focus {
|
||||
box-shadow: none;
|
||||
outline: black auto 1px;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn-link.nav-link:focus {
|
||||
outline: black auto 1px;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.pricing-header {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.box-shadow {
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
button.accept-policy {
|
||||
font-size: 1rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
html {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
overflow: scroll;
|
||||
white-space: nowrap;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,4 @@
|
||||
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2021 Twitter, Inc.
|
||||
Copyright (c) 2011-2021 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,427 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2021 The Bootstrap Authors
|
||||
* Copyright 2011-2021 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
background-color: currentColor;
|
||||
border: 0;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
hr:not([size]) {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title],
|
||||
abbr[data-bs-original-title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.2em;
|
||||
background-color: #fcf8e3;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0d6efd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #0a58ca;
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 1em;
|
||||
direction: ltr /* rtl:ignore */;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: #d63384;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.875em;
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: #6c757d;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
outline-offset: -2px;
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2021 The Bootstrap Authors
|
||||
* Copyright 2011-2021 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
|
||||
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
|
||||
@@ -0,0 +1,424 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2021 The Bootstrap Authors
|
||||
* Copyright 2011-2021 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
background-color: currentColor;
|
||||
border: 0;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
hr:not([size]) {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title],
|
||||
abbr[data-bs-original-title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.2em;
|
||||
background-color: #fcf8e3;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0d6efd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #0a58ca;
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 1em;
|
||||
direction: ltr ;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: #d63384;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.875em;
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: #6c757d;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
outline-offset: -2px;
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2021 The Bootstrap Authors
|
||||
* Copyright 2011-2021 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */
|
||||
@@ -0,0 +1,23 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) .NET Foundation and Contributors
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @license
|
||||
* Unobtrusive validation support library for jQuery and jQuery Validate
|
||||
* Copyright (c) .NET Foundation. All rights reserved.
|
||||
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
* @version v4.0.0
|
||||
*/
|
||||
|
||||
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
|
||||
/*global document: false, jQuery: false */
|
||||
|
||||
(function (factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
|
||||
} else if (typeof module === 'object' && module.exports) {
|
||||
// CommonJS-like environments that support module.exports
|
||||
module.exports = factory(require('jquery-validation'));
|
||||
} else {
|
||||
// Browser global
|
||||
jQuery.validator.unobtrusive = factory(jQuery);
|
||||
}
|
||||
}(function ($) {
|
||||
var $jQval = $.validator,
|
||||
adapters,
|
||||
data_validation = "unobtrusiveValidation";
|
||||
|
||||
function setValidationValues(options, ruleName, value) {
|
||||
options.rules[ruleName] = value;
|
||||
if (options.message) {
|
||||
options.messages[ruleName] = options.message;
|
||||
}
|
||||
}
|
||||
|
||||
function splitAndTrim(value) {
|
||||
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
|
||||
}
|
||||
|
||||
function escapeAttributeValue(value) {
|
||||
// As mentioned on http://api.jquery.com/category/selectors/
|
||||
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function getModelPrefix(fieldName) {
|
||||
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
|
||||
}
|
||||
|
||||
function appendModelPrefix(value, prefix) {
|
||||
if (value.indexOf("*.") === 0) {
|
||||
value = value.replace("*.", prefix);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function onError(error, inputElement) { // 'this' is the form element
|
||||
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
|
||||
replaceAttrValue = container.attr("data-valmsg-replace"),
|
||||
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
|
||||
|
||||
container.removeClass("field-validation-valid").addClass("field-validation-error");
|
||||
error.data("unobtrusiveContainer", container);
|
||||
|
||||
if (replace) {
|
||||
container.empty();
|
||||
error.removeClass("input-validation-error").appendTo(container);
|
||||
}
|
||||
else {
|
||||
error.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function onErrors(event, validator) { // 'this' is the form element
|
||||
var container = $(this).find("[data-valmsg-summary=true]"),
|
||||
list = container.find("ul");
|
||||
|
||||
if (list && list.length && validator.errorList.length) {
|
||||
list.empty();
|
||||
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
|
||||
|
||||
$.each(validator.errorList, function () {
|
||||
$("<li />").html(this.message).appendTo(list);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSuccess(error) { // 'this' is the form element
|
||||
var container = error.data("unobtrusiveContainer");
|
||||
|
||||
if (container) {
|
||||
var replaceAttrValue = container.attr("data-valmsg-replace"),
|
||||
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
|
||||
|
||||
container.addClass("field-validation-valid").removeClass("field-validation-error");
|
||||
error.removeData("unobtrusiveContainer");
|
||||
|
||||
if (replace) {
|
||||
container.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onReset(event) { // 'this' is the form element
|
||||
var $form = $(this),
|
||||
key = '__jquery_unobtrusive_validation_form_reset';
|
||||
if ($form.data(key)) {
|
||||
return;
|
||||
}
|
||||
// Set a flag that indicates we're currently resetting the form.
|
||||
$form.data(key, true);
|
||||
try {
|
||||
$form.data("validator").resetForm();
|
||||
} finally {
|
||||
$form.removeData(key);
|
||||
}
|
||||
|
||||
$form.find(".validation-summary-errors")
|
||||
.addClass("validation-summary-valid")
|
||||
.removeClass("validation-summary-errors");
|
||||
$form.find(".field-validation-error")
|
||||
.addClass("field-validation-valid")
|
||||
.removeClass("field-validation-error")
|
||||
.removeData("unobtrusiveContainer")
|
||||
.find(">*") // If we were using valmsg-replace, get the underlying error
|
||||
.removeData("unobtrusiveContainer");
|
||||
}
|
||||
|
||||
function validationInfo(form) {
|
||||
var $form = $(form),
|
||||
result = $form.data(data_validation),
|
||||
onResetProxy = $.proxy(onReset, form),
|
||||
defaultOptions = $jQval.unobtrusive.options || {},
|
||||
execInContext = function (name, args) {
|
||||
var func = defaultOptions[name];
|
||||
func && $.isFunction(func) && func.apply(form, args);
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
result = {
|
||||
options: { // options structure passed to jQuery Validate's validate() method
|
||||
errorClass: defaultOptions.errorClass || "input-validation-error",
|
||||
errorElement: defaultOptions.errorElement || "span",
|
||||
errorPlacement: function () {
|
||||
onError.apply(form, arguments);
|
||||
execInContext("errorPlacement", arguments);
|
||||
},
|
||||
invalidHandler: function () {
|
||||
onErrors.apply(form, arguments);
|
||||
execInContext("invalidHandler", arguments);
|
||||
},
|
||||
messages: {},
|
||||
rules: {},
|
||||
success: function () {
|
||||
onSuccess.apply(form, arguments);
|
||||
execInContext("success", arguments);
|
||||
}
|
||||
},
|
||||
attachValidation: function () {
|
||||
$form
|
||||
.off("reset." + data_validation, onResetProxy)
|
||||
.on("reset." + data_validation, onResetProxy)
|
||||
.validate(this.options);
|
||||
},
|
||||
validate: function () { // a validation function that is called by unobtrusive Ajax
|
||||
$form.validate();
|
||||
return $form.valid();
|
||||
}
|
||||
};
|
||||
$form.data(data_validation, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
$jQval.unobtrusive = {
|
||||
adapters: [],
|
||||
|
||||
parseElement: function (element, skipAttach) {
|
||||
/// <summary>
|
||||
/// Parses a single HTML element for unobtrusive validation attributes.
|
||||
/// </summary>
|
||||
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
|
||||
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
|
||||
/// validation to the form. If parsing just this single element, you should specify true.
|
||||
/// If parsing several elements, you should specify false, and manually attach the validation
|
||||
/// to the form when you are finished. The default is false.</param>
|
||||
var $element = $(element),
|
||||
form = $element.parents("form")[0],
|
||||
valInfo, rules, messages;
|
||||
|
||||
if (!form) { // Cannot do client-side validation without a form
|
||||
return;
|
||||
}
|
||||
|
||||
valInfo = validationInfo(form);
|
||||
valInfo.options.rules[element.name] = rules = {};
|
||||
valInfo.options.messages[element.name] = messages = {};
|
||||
|
||||
$.each(this.adapters, function () {
|
||||
var prefix = "data-val-" + this.name,
|
||||
message = $element.attr(prefix),
|
||||
paramValues = {};
|
||||
|
||||
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
|
||||
prefix += "-";
|
||||
|
||||
$.each(this.params, function () {
|
||||
paramValues[this] = $element.attr(prefix + this);
|
||||
});
|
||||
|
||||
this.adapt({
|
||||
element: element,
|
||||
form: form,
|
||||
message: message,
|
||||
params: paramValues,
|
||||
rules: rules,
|
||||
messages: messages
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$.extend(rules, { "__dummy__": true });
|
||||
|
||||
if (!skipAttach) {
|
||||
valInfo.attachValidation();
|
||||
}
|
||||
},
|
||||
|
||||
parse: function (selector) {
|
||||
/// <summary>
|
||||
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
|
||||
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
|
||||
/// attribute values.
|
||||
/// </summary>
|
||||
/// <param name="selector" type="String">Any valid jQuery selector.</param>
|
||||
|
||||
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
|
||||
// element with data-val=true
|
||||
var $selector = $(selector),
|
||||
$forms = $selector.parents()
|
||||
.addBack()
|
||||
.filter("form")
|
||||
.add($selector.find("form"))
|
||||
.has("[data-val=true]");
|
||||
|
||||
$selector.find("[data-val=true]").each(function () {
|
||||
$jQval.unobtrusive.parseElement(this, true);
|
||||
});
|
||||
|
||||
$forms.each(function () {
|
||||
var info = validationInfo(this);
|
||||
if (info) {
|
||||
info.attachValidation();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
adapters = $jQval.unobtrusive.adapters;
|
||||
|
||||
adapters.add = function (adapterName, params, fn) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
|
||||
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
|
||||
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
|
||||
/// mmmm is the parameter name).</param>
|
||||
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
|
||||
/// attributes into jQuery Validate rules and/or messages.</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
if (!fn) { // Called with no params, just a function
|
||||
fn = params;
|
||||
params = [];
|
||||
}
|
||||
this.push({ name: adapterName, params: params, adapt: fn });
|
||||
return this;
|
||||
};
|
||||
|
||||
adapters.addBool = function (adapterName, ruleName) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
|
||||
/// the jQuery Validate validation rule has no parameter values.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
|
||||
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
|
||||
/// of adapterName will be used instead.</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
return this.add(adapterName, function (options) {
|
||||
setValidationValues(options, ruleName || adapterName, true);
|
||||
});
|
||||
};
|
||||
|
||||
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
|
||||
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
|
||||
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
|
||||
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
|
||||
/// have a minimum value.</param>
|
||||
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
|
||||
/// have a maximum value.</param>
|
||||
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
|
||||
/// have both a minimum and maximum value.</param>
|
||||
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
|
||||
/// contains the minimum value. The default is "min".</param>
|
||||
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
|
||||
/// contains the maximum value. The default is "max".</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
|
||||
var min = options.params.min,
|
||||
max = options.params.max;
|
||||
|
||||
if (min && max) {
|
||||
setValidationValues(options, minMaxRuleName, [min, max]);
|
||||
}
|
||||
else if (min) {
|
||||
setValidationValues(options, minRuleName, min);
|
||||
}
|
||||
else if (max) {
|
||||
setValidationValues(options, maxRuleName, max);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
|
||||
/// the jQuery Validate validation rule has a single value.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
|
||||
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
|
||||
/// The default is "val".</param>
|
||||
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
|
||||
/// of adapterName will be used instead.</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
return this.add(adapterName, [attribute || "val"], function (options) {
|
||||
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
|
||||
});
|
||||
};
|
||||
|
||||
$jQval.addMethod("__dummy__", function (value, element, params) {
|
||||
return true;
|
||||
});
|
||||
|
||||
$jQval.addMethod("regex", function (value, element, params) {
|
||||
var match;
|
||||
if (this.optional(element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match = new RegExp(params).exec(value);
|
||||
return (match && (match.index === 0) && (match[0].length === value.length));
|
||||
});
|
||||
|
||||
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
|
||||
var match;
|
||||
if (nonalphamin) {
|
||||
match = value.match(/\W/g);
|
||||
match = match && match.length >= nonalphamin;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
if ($jQval.methods.extension) {
|
||||
adapters.addSingleVal("accept", "mimtype");
|
||||
adapters.addSingleVal("extension", "extension");
|
||||
} else {
|
||||
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
|
||||
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
|
||||
// validating the extension, and ignore mime-type validations as they are not supported.
|
||||
adapters.addSingleVal("extension", "extension", "accept");
|
||||
}
|
||||
|
||||
adapters.addSingleVal("regex", "pattern");
|
||||
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
|
||||
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
|
||||
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
|
||||
adapters.add("equalto", ["other"], function (options) {
|
||||
var prefix = getModelPrefix(options.element.name),
|
||||
other = options.params.other,
|
||||
fullOtherName = appendModelPrefix(other, prefix),
|
||||
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
|
||||
|
||||
setValidationValues(options, "equalTo", element);
|
||||
});
|
||||
adapters.add("required", function (options) {
|
||||
// jQuery Validate equates "required" with "mandatory" for checkbox elements
|
||||
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
|
||||
setValidationValues(options, "required", true);
|
||||
}
|
||||
});
|
||||
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
|
||||
var value = {
|
||||
url: options.params.url,
|
||||
type: options.params.type || "GET",
|
||||
data: {}
|
||||
},
|
||||
prefix = getModelPrefix(options.element.name);
|
||||
|
||||
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
|
||||
var paramName = appendModelPrefix(fieldName, prefix);
|
||||
value.data[paramName] = function () {
|
||||
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
|
||||
// For checkboxes and radio buttons, only pick up values from checked fields.
|
||||
if (field.is(":checkbox")) {
|
||||
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
|
||||
}
|
||||
else if (field.is(":radio")) {
|
||||
return field.filter(":checked").val() || '';
|
||||
}
|
||||
return field.val();
|
||||
};
|
||||
});
|
||||
|
||||
setValidationValues(options, "remote", value);
|
||||
});
|
||||
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
|
||||
if (options.params.min) {
|
||||
setValidationValues(options, "minlength", options.params.min);
|
||||
}
|
||||
if (options.params.nonalphamin) {
|
||||
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
|
||||
}
|
||||
if (options.params.regex) {
|
||||
setValidationValues(options, "regex", options.params.regex);
|
||||
}
|
||||
});
|
||||
adapters.add("fileextensions", ["extensions"], function (options) {
|
||||
setValidationValues(options, "extension", options.params.extensions);
|
||||
});
|
||||
|
||||
$(function () {
|
||||
$jQval.unobtrusive.parse(document);
|
||||
});
|
||||
|
||||
return $jQval.unobtrusive;
|
||||
}));
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
Copyright Jörn Zaefferer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
* Appointment Calendar Styles
|
||||
* Supports week and month views with dark mode
|
||||
*/
|
||||
|
||||
/* ===== Day View ===== */
|
||||
.calendar-day-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.calendar-day-view-header {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.calendar-day-header-single {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-header-single.today {
|
||||
background: rgba(13, 110, 253, 0.08);
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-name-long {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-number-large {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-day-header-single.today .day-number-large {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-month-year {
|
||||
font-size: 1rem;
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-event-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-header-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-time-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 70px) 1fr;
|
||||
gap: 1px;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.calendar-day-time-row.has-events {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.calendar-day-time-cell {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day-time-cell:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* Day view event cards — wider with more detail */
|
||||
.calendar-event-day {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-event-day .event-location {
|
||||
font-size: 0.688rem;
|
||||
opacity: 0.85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Clickable day numbers in month and week views */
|
||||
.day-drill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-month-cell .cell-date.day-drill:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number.day-drill:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== Week View ===== */
|
||||
.calendar-week-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 600px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Smooth container transitions */
|
||||
#calendarContainer {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.calendar-week-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 70px) repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Spacer in week header that aligns with the time-label column below */
|
||||
.calendar-week-header-spacer {
|
||||
background: var(--bs-light);
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-header.today {
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header .day-header-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-day-header.today .day-number {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header .day-header-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-time-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 70px) repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
background: var(--bs-light);
|
||||
padding: 0.5rem 0.25rem;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary);
|
||||
font-weight: 500;
|
||||
word-break: keep-all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.calendar-time-cell:hover {
|
||||
background: var(--bs-light);
|
||||
}
|
||||
|
||||
/* ===== Month View ===== */
|
||||
.calendar-month-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-month-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.calendar-day-name {
|
||||
background: var(--bs-light);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-month-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-week-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.calendar-month-cell {
|
||||
background: var(--bs-body-bg);
|
||||
min-height: 100px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-month-cell.other-month {
|
||||
background: var(--bs-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.calendar-month-cell.other-month .cell-date {
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.calendar-month-cell.today {
|
||||
background: rgba(13, 110, 253, 0.05);
|
||||
border: 2px solid var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-month-cell:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.calendar-month-cell .cell-date {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-month-cell.today .cell-date {
|
||||
color: var(--bs-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cell-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ===== Event Cards ===== */
|
||||
.calendar-event {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.calendar-event:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* All-day event modifier - black border */
|
||||
.all-day-event {
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .all-day-event {
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
/* Week view header events (compact style) */
|
||||
.calendar-event-header {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.688rem;
|
||||
line-height: 1.2;
|
||||
transition: transform 0.2s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event-header:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-event-header .event-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event .event-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.calendar-event .event-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event .event-customer {
|
||||
font-size: 0.688rem;
|
||||
opacity: 0.9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Event color classes - Appointments (all 20 colors) */
|
||||
.calendar-event-purple,
|
||||
.calendar-event-month.calendar-event-purple,
|
||||
.calendar-event-header.calendar-event-purple {
|
||||
background: #6f42c1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-green,
|
||||
.calendar-event-month.calendar-event-green,
|
||||
.calendar-event-header.calendar-event-green {
|
||||
background: #198754;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-blue,
|
||||
.calendar-event-month.calendar-event-blue,
|
||||
.calendar-event-header.calendar-event-blue {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-orange,
|
||||
.calendar-event-month.calendar-event-orange,
|
||||
.calendar-event-header.calendar-event-orange {
|
||||
background: #fd7e14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-red,
|
||||
.calendar-event-month.calendar-event-red,
|
||||
.calendar-event-header.calendar-event-red {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-yellow,
|
||||
.calendar-event-month.calendar-event-yellow,
|
||||
.calendar-event-header.calendar-event-yellow {
|
||||
background: #ffc107;
|
||||
color: #000 !important; /* Dark text for better contrast on yellow */
|
||||
}
|
||||
|
||||
.calendar-event-pink,
|
||||
.calendar-event-month.calendar-event-pink,
|
||||
.calendar-event-header.calendar-event-pink {
|
||||
background: #d63384;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-cyan,
|
||||
.calendar-event-month.calendar-event-cyan,
|
||||
.calendar-event-header.calendar-event-cyan {
|
||||
background: #0dcaf0;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-teal,
|
||||
.calendar-event-month.calendar-event-teal,
|
||||
.calendar-event-header.calendar-event-teal {
|
||||
background: #20c997;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-indigo,
|
||||
.calendar-event-month.calendar-event-indigo,
|
||||
.calendar-event-header.calendar-event-indigo {
|
||||
background: #6610f2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-lime,
|
||||
.calendar-event-month.calendar-event-lime,
|
||||
.calendar-event-header.calendar-event-lime {
|
||||
background: #84cc16;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-brown,
|
||||
.calendar-event-month.calendar-event-brown,
|
||||
.calendar-event-header.calendar-event-brown {
|
||||
background: #795548;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-gray,
|
||||
.calendar-event-month.calendar-event-gray,
|
||||
.calendar-event-header.calendar-event-gray {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Bootstrap utility color variants */
|
||||
.calendar-event-success,
|
||||
.calendar-event-month.calendar-event-success,
|
||||
.calendar-event-header.calendar-event-success {
|
||||
background: #198754;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-danger,
|
||||
.calendar-event-month.calendar-event-danger,
|
||||
.calendar-event-header.calendar-event-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-warning,
|
||||
.calendar-event-month.calendar-event-warning,
|
||||
.calendar-event-header.calendar-event-warning {
|
||||
background: #ffc107;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-info,
|
||||
.calendar-event-month.calendar-event-info,
|
||||
.calendar-event-header.calendar-event-info {
|
||||
background: #0dcaf0;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-primary,
|
||||
.calendar-event-month.calendar-event-primary,
|
||||
.calendar-event-header.calendar-event-primary {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-secondary,
|
||||
.calendar-event-month.calendar-event-secondary,
|
||||
.calendar-event-header.calendar-event-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-dark,
|
||||
.calendar-event-month.calendar-event-dark,
|
||||
.calendar-event-header.calendar-event-dark {
|
||||
background: #212529;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Month view event compact style */
|
||||
.calendar-event-month {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.688rem;
|
||||
color: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.calendar-event-month:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-event-month .event-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-event-month .event-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-more {
|
||||
font-size: 0.688rem;
|
||||
color: var(--bs-secondary);
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-more:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* ===== Responsive Design ===== */
|
||||
|
||||
/* Large Desktop - Show everything with plenty of space */
|
||||
@media (min-width: 1400px) {
|
||||
.calendar-month-cell {
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
min-height: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard Desktop - Default styles work well */
|
||||
@media (min-width: 992px) and (max-width: 1399px) {
|
||||
.calendar-month-cell {
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet - Start compacting */
|
||||
@media (min-width: 768px) and (max-width: 991px) {
|
||||
.calendar-month-cell {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-name {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
font-size: 0.688rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-event .event-customer {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.688rem;
|
||||
padding: 0.375rem 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - Compact view with horizontal scroll */
|
||||
@media (max-width: 767px) {
|
||||
.calendar-week-view,
|
||||
.calendar-month-view {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.calendar-week-header,
|
||||
.calendar-month-header,
|
||||
.calendar-week-row {
|
||||
min-width: 650px;
|
||||
}
|
||||
|
||||
.calendar-time-row {
|
||||
min-width: 650px;
|
||||
}
|
||||
|
||||
.calendar-week-header {
|
||||
grid-template-columns: 45px repeat(7, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.calendar-time-row {
|
||||
grid-template-columns: 45px repeat(7, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.calendar-month-cell {
|
||||
min-height: 75px;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
min-height: 45px;
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-name {
|
||||
font-size: 0.688rem;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-event .event-customer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-event-header {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.1875rem 0.25rem;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
}
|
||||
|
||||
.calendar-month-cell .cell-date {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Dark Mode Support ===== */
|
||||
[data-bs-theme="dark"] .calendar-day-header-single.today {
|
||||
background: rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-day-time-cell {
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-day-header.today {
|
||||
background: rgba(13, 110, 253, 0.2);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-month-cell.today {
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-month-cell.other-month {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .time-label {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-week-header-spacer {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-day-name {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* ===== Loading State ===== */
|
||||
#calendarContainer .spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
/* ===== Print Styles ===== */
|
||||
@media print {
|
||||
.calendar-time-cell:hover,
|
||||
.calendar-month-cell:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-event:hover,
|
||||
.calendar-event-month:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Schedule Sidebar ===== */
|
||||
.schedule-sidebar {
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.schedule-sidebar.sidebar-collapsed {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.schedule-sidebar-header {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.schedule-sidebar-body {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sidebar job cards */
|
||||
.sj-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.sj-card:hover {
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.sj-card.sj-dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sj-number {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.sj-customer {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sj-status {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.sj-due {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* Sidebar drop zone highlight */
|
||||
.sidebar-drag-over .card {
|
||||
border-color: var(--bs-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(13,110,253,.25);
|
||||
}
|
||||
|
||||
/* ===== Drag-over highlight for calendar cells ===== */
|
||||
.calendar-drag-over {
|
||||
background: rgba(13, 110, 253, 0.08) !important;
|
||||
outline: 2px dashed var(--bs-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ===== Dragging job event on calendar ===== */
|
||||
.event-dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Ensure calendar takes full remaining width */
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===== Collapsed sidebar — center the chevron button ===== */
|
||||
.schedule-sidebar.sidebar-collapsed .schedule-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.schedule-sidebar.sidebar-collapsed .schedule-sidebar-header > div {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== Job hover preview card ===== */
|
||||
.sjp-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.18);
|
||||
overflow: hidden;
|
||||
width: 260px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sjp-status-bar {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.sjp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.6rem 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
.sjp-job {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.sjp-customer {
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
padding: 0 0.6rem 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
.sjp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0 0.6rem;
|
||||
}
|
||||
|
||||
.sjp-overdue {
|
||||
color: #dc3545 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sjp-notes {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
margin: 0.25rem 0.6rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
padding-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sjp-items {
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
margin-top: 0.3rem;
|
||||
padding: 0.3rem 0.6rem 0.4rem;
|
||||
}
|
||||
|
||||
.sjp-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-body-color);
|
||||
padding: 0.05rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sjp-item-desc {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sjp-qty {
|
||||
font-weight: 700;
|
||||
color: var(--bs-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sjp-color-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sjp-color {
|
||||
font-size: 0.68rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sjp-more {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* Catalog Pages - Dark Mode Support */
|
||||
|
||||
/* Stats Cards */
|
||||
.catalog-stats-card {
|
||||
border: none !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.catalog-stats-icon {
|
||||
border-radius: 50%;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-stats-icon.blue {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.catalog-stats-icon.green {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.catalog-stats-icon.yellow {
|
||||
background-color: rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
.catalog-stats-icon.pink {
|
||||
background-color: rgba(236, 72, 153, 0.1);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-stats-icon.blue {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-stats-icon.green {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-stats-icon.yellow {
|
||||
background-color: rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-stats-icon.pink {
|
||||
background-color: rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
|
||||
/* Text colors that adapt to theme */
|
||||
.catalog-text-muted {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-text-muted {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.catalog-text-secondary {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-text-secondary {
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
/* Empty state icons */
|
||||
.catalog-empty-icon {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-empty-icon {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Category Tree Styles */
|
||||
.catalog-tree .category-header {
|
||||
background: var(--bs-light);
|
||||
border-left: 3px solid var(--bs-primary);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-tree .category-header {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.catalog-tree .category-header:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-tree .category-header:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.catalog-tree .category-header.collapsed {
|
||||
border-left-color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.catalog-tree .subcategory {
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.catalog-tree .item-row {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.catalog-tree .item-row:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.catalog-tree .item-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.catalog-tree .no-items {
|
||||
padding: 16px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Item row link color */
|
||||
.catalog-item-link {
|
||||
color: var(--bs-body-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.catalog-item-link:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Search box styling */
|
||||
.catalog-search-icon {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-right: 0 !important;
|
||||
}
|
||||
|
||||
.catalog-search-input {
|
||||
border-left: 0 !important;
|
||||
border-right: 0 !important;
|
||||
}
|
||||
|
||||
.catalog-card-header {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* Price text */
|
||||
.catalog-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .catalog-price {
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
/* Alert permanent (for filter info) */
|
||||
.alert-permanent {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* Company Settings - Data Lookups Tab Styling */
|
||||
/* Ensures proper visibility in both light and dark themes */
|
||||
|
||||
/* Custom color classes for appointment type badges */
|
||||
.bg-purple {
|
||||
background-color: #6f42c1 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-pink {
|
||||
background-color: #d63384 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-cyan {
|
||||
background-color: #0dcaf0 !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.bg-teal {
|
||||
background-color: #20c997 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-indigo {
|
||||
background-color: #6610f2 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-lime {
|
||||
background-color: #84cc16 !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.bg-brown {
|
||||
background-color: #795548 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-gray {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-orange {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-yellow {
|
||||
background-color: #ffc107 !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.bg-green {
|
||||
background-color: #198754 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-blue {
|
||||
background-color: #0d6efd !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bg-red {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* ── Main settings tabs ─────────────────────────────────────────────────── */
|
||||
#settingsTabs .nav-link {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 500;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
#settingsTabs .nav-link:hover:not(.active) {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-bottom-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
#settingsTabs .nav-link.active {
|
||||
color: var(--bs-primary);
|
||||
font-weight: 700;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-bottom: 3px solid var(--bs-primary);
|
||||
}
|
||||
|
||||
/* ── PDF Templates inner tabs (card-header-tabs) ────────────────────────── */
|
||||
#pdfTemplateTabs .nav-link {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
#pdfTemplateTabs .nav-link:hover:not(.active) {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
#pdfTemplateTabs .nav-link.active {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary) var(--bs-primary) var(--bs-card-bg, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
/* ── Sub-tab navigation pills */
|
||||
#lookupSubTabs .nav-link {
|
||||
color: var(--bs-body-color);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
margin-right: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#lookupSubTabs .nav-link:hover {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
#lookupSubTabs .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Lookup tables */
|
||||
.lookup-table-wrapper {
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.lookup-table thead {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.lookup-table tbody tr:hover {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* System-defined badge */
|
||||
.badge.bg-secondary {
|
||||
background-color: var(--bs-secondary) !important;
|
||||
}
|
||||
|
||||
/* Color badge previews in table */
|
||||
.color-badge-preview {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grip handle for drag-and-drop (future feature) */
|
||||
.bi-grip-vertical {
|
||||
color: var(--bs-secondary);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.bi-grip-vertical:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.btn-action {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.lookup-loading {
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.lookup-empty-state {
|
||||
color: var(--bs-secondary);
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
/* Usage count */
|
||||
.usage-count {
|
||||
color: var(--bs-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Icon styling */
|
||||
.lookup-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Sortable.js drag-and-drop states */
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 1;
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.sortable-chosen {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* Make grip handle more visible on hover */
|
||||
.bi-grip-vertical {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
tr:hover .bi-grip-vertical {
|
||||
color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
#lookupSubTabs .nav-link {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.lookup-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Job Photos Styles */
|
||||
.photo-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4/3;
|
||||
background: var(--bs-secondary-bg);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.photo-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-thumbnail {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.photo-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-type-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-type-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
#viewPhotoImage {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#photoDetails {
|
||||
text-align: left;
|
||||
background: var(--bs-secondary-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-bs-theme="dark"] .photo-card {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .drop-zone {
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .drop-zone:hover,
|
||||
[data-bs-theme="dark"] .drop-zone.drag-over {
|
||||
border-color: var(--bs-primary);
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.photo-overlay {
|
||||
opacity: 1;
|
||||
padding: 0.5rem;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
|
||||
}
|
||||
|
||||
.photo-type-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Mobile Card View Styles - Hidden on Desktop (≥992px) */
|
||||
.mobile-card-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact Stats Card for Mobile */
|
||||
.mobile-stats-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
/* Hide desktop table view on mobile */
|
||||
.table-responsive {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show mobile card view */
|
||||
.mobile-card-view {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Hide large stats cards on mobile */
|
||||
.stats-cards-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show compact stats card on mobile */
|
||||
.mobile-stats-compact {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-emphasis-color);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-icon {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Card List */
|
||||
.mobile-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Individual Mobile Card */
|
||||
.mobile-data-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-data-card:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Mobile Card Header */
|
||||
.mobile-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.mobile-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-card-title h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-emphasis-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-card-title small {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Mobile Card Body */
|
||||
.mobile-card-body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.mobile-card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-card-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-card-value {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--bs-body-color);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile Card Footer */
|
||||
.mobile-card-footer {
|
||||
padding: 1rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile-card-footer .btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.mobile-card-footer .btn-sm {
|
||||
min-height: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Badge overrides in mobile cards */
|
||||
.mobile-card-value .badge {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Icon spacing in mobile cards */
|
||||
.mobile-card-value i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Empty state in mobile */
|
||||
.mobile-card-view .text-center {
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.mobile-card-view .text-center i {
|
||||
font-size: 3rem;
|
||||
color: var(--bs-secondary-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
/* Equipment Status Colors */
|
||||
.equipment-status-operational {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.equipment-status-needs-maintenance {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.equipment-status-under-maintenance {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.equipment-status-out-of-service {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.equipment-status-retired {
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Maintenance Status Colors */
|
||||
.maintenance-status-scheduled {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.maintenance-status-in-progress {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.maintenance-status-completed {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.maintenance-status-overdue {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.maintenance-status-cancelled {
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Maintenance Priority Colors */
|
||||
.maintenance-priority-low {
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.maintenance-priority-normal {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.maintenance-priority-high {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.maintenance-priority-critical {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* File Upload Styles */
|
||||
.file-upload-zone {
|
||||
border: 2px dashed var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-upload-zone:hover {
|
||||
border-color: #4f46e5;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.file-upload-zone.dragover {
|
||||
border-color: #4f46e5;
|
||||
background-color: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
|
||||
/* Timeline Styles */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 17px;
|
||||
top: 40px;
|
||||
bottom: -20px;
|
||||
width: 2px;
|
||||
background-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Alert Permanent */
|
||||
.alert-permanent {
|
||||
/* Prevents auto-dismiss */
|
||||
}
|
||||
|
||||
/* Custom Badge Styles */
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive Table */
|
||||
@media (max-width: 768px) {
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Form Styles */
|
||||
.form-control,
|
||||
.form-select {
|
||||
border: 1.5px solid #9ca3af;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: #4f46e5;
|
||||
border-width: 1.5px;
|
||||
box-shadow: 0 0 0 0.2rem rgba(79, 70, 229, 0.25);
|
||||
}
|
||||
|
||||
/* Equipment Card Styles */
|
||||
.equipment-card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.equipment-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Maintenance Badge Glow */
|
||||
.badge-glow-critical {
|
||||
animation: glow-critical 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes glow-critical {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(220, 38, 38, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sortable Column Styles */
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
th.sortable:hover {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
th.sortable a {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
}
|
||||
|
||||
th.sortable a:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
th.sortable i {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
th.sortable:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.pagination {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
color: var(--bs-secondary-color);
|
||||
pointer-events: none;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.form-select-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness - Stats Cards (deprecated - now using compact card) */
|
||||
|
||||
/* Mobile Touch Optimization */
|
||||
@media (max-width: 768px) {
|
||||
/* Touch Target Sizes - Minimum 44px */
|
||||
.btn, .btn-sm {
|
||||
min-height: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
min-width: 44px;
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
min-height: 44px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Modal Optimization */
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Form Layout Optimization */
|
||||
.row.g-3 > [class*="col-md-"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.row.g-3 > .col-6 {
|
||||
width: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Tab Selector for Settings Pages */
|
||||
#mobileTabSelector {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mobileTabSelector:focus {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Ensure tab content has proper spacing on mobile */
|
||||
.tab-content {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.tab-content .card {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Validation - Hide empty validation summary */
|
||||
.validation-summary-valid {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== Dark Mode Overrides ===== */
|
||||
|
||||
/* Equipment status badges */
|
||||
[data-bs-theme="dark"] .equipment-status-operational {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-needs-maintenance {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-under-maintenance {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-out-of-service {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-retired {
|
||||
background-color: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Maintenance status badges */
|
||||
[data-bs-theme="dark"] .maintenance-status-scheduled {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-in-progress {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-completed {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-overdue {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-cancelled {
|
||||
background-color: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Maintenance priority badges */
|
||||
[data-bs-theme="dark"] .maintenance-priority-low {
|
||||
background-color: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-priority-normal {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-priority-high {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-priority-critical {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* bg-warning text-dark and bg-info text-dark badges:
|
||||
In dark mode Bootstrap changes --bs-dark to a light gray,
|
||||
which gives poor contrast on yellow/cyan backgrounds.
|
||||
Force dark text so contrast is maintained. */
|
||||
[data-bs-theme="dark"] .bg-warning {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bg-info {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tom Select — dark mode fixes
|
||||
Tom Select's Bootstrap 5 theme hardcodes color:#343a40 which
|
||||
is near-invisible on Bootstrap's dark body background. Using
|
||||
html prefix for maximum specificity; all colours hard-coded to
|
||||
avoid CSS variable resolution issues.
|
||||
============================================================ */
|
||||
|
||||
/* Control box */
|
||||
html[data-bs-theme="dark"] .ts-control {
|
||||
color: #dee2e6 !important;
|
||||
background-color: #212529 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
html[data-bs-theme="dark"] .ts-control input {
|
||||
color: #dee2e6 !important;
|
||||
}
|
||||
html[data-bs-theme="dark"] .ts-control input::placeholder {
|
||||
color: #6c757d !important;
|
||||
}
|
||||
|
||||
/* Selected item text */
|
||||
html[data-bs-theme="dark"] .ts-control .item {
|
||||
color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
/* Dropdown panel */
|
||||
html[data-bs-theme="dark"] .ts-dropdown {
|
||||
color: #dee2e6 !important;
|
||||
background-color: #2b3035 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
/* Every option row — override cascaded #343a40 from Tom Select theme */
|
||||
html[data-bs-theme="dark"] .ts-dropdown .option,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .no-results,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .optgroup-header,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .create {
|
||||
color: #dee2e6 !important;
|
||||
background-color: #2b3035 !important;
|
||||
}
|
||||
|
||||
/* Highlighted (hover/keyboard) option */
|
||||
html[data-bs-theme="dark"] .ts-dropdown .option.active,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .active {
|
||||
background-color: #0d6efd !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Multi-select chips */
|
||||
html[data-bs-theme="dark"] .ts-wrapper.multi .ts-control > div {
|
||||
background: #343a40 !important;
|
||||
color: #dee2e6 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
/* Dropdown caret arrow — swap dark SVG for a light one */
|
||||
html[data-bs-theme="dark"] .ts-wrapper:not(.form-control, .form-select).single .ts-control {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-position: right .75rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px 12px;
|
||||
}
|
||||
|
||||
/* Search field inside open dropdown */
|
||||
html[data-bs-theme="dark"] .ts-dropdown .dropdown-input {
|
||||
color: #dee2e6 !important;
|
||||
background: #212529 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Item wizard (Quotes/Create, Jobs/Create, Jobs/Edit) — dark mode
|
||||
The inline styles in these views hardcode #fff/#fafafa/#dee2e6.
|
||||
============================================================ */
|
||||
[data-bs-theme="dark"] .item-type-card {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .item-type-card:hover {
|
||||
border-color: #86b7fe !important;
|
||||
background: rgba(13, 110, 253, 0.15) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .item-type-card.selected {
|
||||
border-color: #0d6efd !important;
|
||||
background: rgba(13, 110, 253, 0.2) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .quote-item-card {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .coat-row {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .wizard-step-dot {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .wizard-step-line {
|
||||
background: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tag chip input widget
|
||||
============================================================ */
|
||||
|
||||
/* Chips area sits below the input — input height never changes */
|
||||
.tag-chips-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Remove × inside each chip */
|
||||
.tag-chip .tag-remove {
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tag-chip .tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Clickable tag links in Index grids — hover darkens slightly */
|
||||
a.tag-index-badge:hover {
|
||||
filter: brightness(0.88);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* AI Help Widget
|
||||
* Floating chat assistant for Powder Coating Logix.
|
||||
*
|
||||
* Persistence: panel open/closed state, conversation history, rendered messages,
|
||||
* and panel height are all stored in sessionStorage so they survive page navigation.
|
||||
* Everything resets when the browser tab/session ends.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const CHAT_ENDPOINT = '/AiHelp/Chat';
|
||||
const STORAGE_KEY = 'aiHelpState';
|
||||
const MIN_HEIGHT = 300;
|
||||
const MAX_HEIGHT_VH = 0.85; // 85vh
|
||||
|
||||
// Conversation history for API: [{role:'user'|'assistant', content:'...'}]
|
||||
let history = [];
|
||||
|
||||
// Rendered message store for restoring across navigation: [{role, content, html}]
|
||||
let renderedMessages = [];
|
||||
|
||||
let isOpen = false;
|
||||
let isSending = false;
|
||||
let startersShown = true;
|
||||
let panelHeight = 520;
|
||||
|
||||
// DOM refs
|
||||
let btn, panel, messagesEl, inputEl, sendBtn, clearBtn, closeBtn,
|
||||
typingEl, startersEl, tokenEl, resizeHandle;
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
btn = document.getElementById('ai-help-btn');
|
||||
panel = document.getElementById('ai-help-panel');
|
||||
messagesEl = document.getElementById('ai-help-messages');
|
||||
inputEl = document.getElementById('ai-help-input');
|
||||
sendBtn = document.getElementById('ai-help-send');
|
||||
clearBtn = document.getElementById('ai-help-clear');
|
||||
closeBtn = document.getElementById('ai-help-close');
|
||||
typingEl = document.getElementById('ai-help-typing');
|
||||
startersEl = document.getElementById('ai-help-starters');
|
||||
tokenEl = document.getElementById('ai-help-token');
|
||||
resizeHandle = document.getElementById('ai-help-resize');
|
||||
|
||||
if (!btn || !panel) return;
|
||||
|
||||
btn.addEventListener('click', togglePanel);
|
||||
closeBtn.addEventListener('click', closePanel);
|
||||
clearBtn.addEventListener('click', clearChat);
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
inputEl.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.ai-help-starter-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
inputEl.value = b.dataset.q;
|
||||
sendMessage();
|
||||
});
|
||||
});
|
||||
|
||||
// Intercept help links inside the widget — navigate without closing
|
||||
messagesEl.addEventListener('click', function (e) {
|
||||
const link = e.target.closest('[data-help-link]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
const href = link.getAttribute('href');
|
||||
if (href) window.location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
// Save accurate open state right before any navigation so the new page
|
||||
// can restore correctly. This runs after any click handlers that might
|
||||
// have changed isOpen, giving us the true final state.
|
||||
window.addEventListener('beforeunload', saveState);
|
||||
|
||||
initResize();
|
||||
restoreState();
|
||||
}
|
||||
|
||||
// ── Resize ───────────────────────────────────────────────────────────────
|
||||
|
||||
function initResize() {
|
||||
if (!resizeHandle) return;
|
||||
|
||||
let startY = 0;
|
||||
let startH = 0;
|
||||
let dragging = false;
|
||||
|
||||
resizeHandle.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
startY = e.clientY;
|
||||
startH = panel.offsetHeight;
|
||||
resizeHandle.classList.add('dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (!dragging) return;
|
||||
// Panel grows upward: dragging mouse UP increases height
|
||||
const delta = startY - e.clientY;
|
||||
const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH);
|
||||
panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta));
|
||||
panel.style.height = panelHeight + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function () {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
resizeHandle.classList.remove('dragging');
|
||||
document.body.style.userSelect = '';
|
||||
saveState();
|
||||
});
|
||||
|
||||
// Touch support
|
||||
resizeHandle.addEventListener('touchstart', function (e) {
|
||||
const touch = e.touches[0];
|
||||
startY = touch.clientY;
|
||||
startH = panel.offsetHeight;
|
||||
dragging = true;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
if (!dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const delta = startY - touch.clientY;
|
||||
const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH);
|
||||
panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta));
|
||||
panel.style.height = panelHeight + 'px';
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function () {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
saveState();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Panel open/close ─────────────────────────────────────────────────────
|
||||
|
||||
function togglePanel() {
|
||||
if (isOpen) { closePanel(); } else { openPanel(); }
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panel.hidden = false;
|
||||
panel.style.height = panelHeight + 'px';
|
||||
isOpen = true;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
inputEl.focus();
|
||||
scrollToBottom();
|
||||
saveState();
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
isOpen = false;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ── Clear chat ───────────────────────────────────────────────────────────
|
||||
|
||||
function clearChat() {
|
||||
history = [];
|
||||
renderedMessages = [];
|
||||
startersShown = true;
|
||||
|
||||
// Remove all messages except the static welcome message (first child)
|
||||
const msgs = Array.from(messagesEl.children);
|
||||
msgs.slice(1).forEach(function (el) { el.remove(); });
|
||||
|
||||
startersEl.hidden = false;
|
||||
inputEl.value = '';
|
||||
inputEl.focus();
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ── Send message ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendMessage() {
|
||||
if (isSending) return;
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
startersEl.hidden = true;
|
||||
startersShown = false;
|
||||
inputEl.value = '';
|
||||
|
||||
appendMessage('user', text, escapeHtml(text));
|
||||
scrollToBottom();
|
||||
saveState();
|
||||
|
||||
isSending = true;
|
||||
sendBtn.disabled = true;
|
||||
typingEl.classList.remove('d-none');
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const token = tokenEl ? tokenEl.value : '';
|
||||
// Send history BEFORE appending the current user message
|
||||
// (appendMessage already pushed it, so slice off the last entry)
|
||||
const payload = {
|
||||
message: text,
|
||||
history: history.slice(0, -1)
|
||||
};
|
||||
|
||||
const res = await fetch(CHAT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
typingEl.classList.add('d-none');
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const msg = err.error || 'Something went wrong. Please try again.';
|
||||
appendMessage('assistant', msg, renderMarkdown(msg));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const reply = data.response || 'No response received.';
|
||||
appendMessage('assistant', reply, renderMarkdown(reply));
|
||||
}
|
||||
} catch (err) {
|
||||
typingEl.classList.add('d-none');
|
||||
const msg = 'Unable to connect. Please check your connection and try again.';
|
||||
appendMessage('assistant', msg, renderMarkdown(msg));
|
||||
} finally {
|
||||
isSending = false;
|
||||
sendBtn.disabled = false;
|
||||
scrollToBottom();
|
||||
saveState();
|
||||
inputEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Append a message bubble ───────────────────────────────────────────────
|
||||
|
||||
function appendMessage(role, content, html) {
|
||||
history.push({ role: role, content: content });
|
||||
renderedMessages.push({ role: role, content: content, html: html });
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ai-help-msg ai-help-msg--' + role;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'ai-help-msg-bubble';
|
||||
bubble.innerHTML = html;
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
messagesEl.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// ── Restore bubble from stored HTML (safe — came from our own renderer) ──
|
||||
|
||||
function restoreMessage(role, html) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ai-help-msg ai-help-msg--' + role;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'ai-help-msg-bubble';
|
||||
bubble.innerHTML = html;
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
messagesEl.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// ── sessionStorage persistence ────────────────────────────────────────────
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
open: isOpen,
|
||||
history: history,
|
||||
renderedMessages: renderedMessages,
|
||||
startersShown: startersShown,
|
||||
panelHeight: panelHeight
|
||||
}));
|
||||
} catch (_) { /* sessionStorage full or unavailable — ignore */ }
|
||||
}
|
||||
|
||||
function restoreState() {
|
||||
let saved;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
saved = JSON.parse(raw);
|
||||
} catch (_) { return; }
|
||||
|
||||
if (!saved) return;
|
||||
|
||||
// Restore height first (before showing panel to avoid flicker)
|
||||
if (saved.panelHeight && saved.panelHeight >= MIN_HEIGHT) {
|
||||
panelHeight = saved.panelHeight;
|
||||
panel.style.height = panelHeight + 'px';
|
||||
}
|
||||
|
||||
// Restore messages (skip if only the welcome message was ever shown)
|
||||
if (saved.renderedMessages && saved.renderedMessages.length > 0) {
|
||||
history = saved.history || [];
|
||||
renderedMessages = saved.renderedMessages || [];
|
||||
|
||||
saved.renderedMessages.forEach(function (m) {
|
||||
restoreMessage(m.role, m.html);
|
||||
});
|
||||
|
||||
if (!saved.startersShown) {
|
||||
startersEl.hidden = true;
|
||||
startersShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore open state last
|
||||
if (saved.open) {
|
||||
openPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minimal markdown renderer ─────────────────────────────────────────────
|
||||
|
||||
function renderMarkdown(text) {
|
||||
let html = escapeHtml(text);
|
||||
|
||||
// Fenced code blocks
|
||||
html = html.replace(/```[\s\S]*?```/g, function (match) {
|
||||
const code = match.slice(3, -3).replace(/^\n/, '');
|
||||
return '<pre style="font-size:0.8rem;overflow-x:auto;"><code>' + code + '</code></pre>';
|
||||
});
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
// Markdown links — only /relative or https:// allowed
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) {
|
||||
const safe = url.startsWith('/') || url.startsWith('https://') || url.startsWith('http://');
|
||||
if (!safe) return escapeHtml(linkText);
|
||||
return '<a href="' + escapeAttr(url) + '" data-help-link="true">' + linkText + '</a>';
|
||||
});
|
||||
|
||||
// Build line-by-line for lists
|
||||
const lines = html.split('\n');
|
||||
const result = [];
|
||||
let inUl = false, inOl = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const ulMatch = line.match(/^(\s*)[-*]\s+(.+)/);
|
||||
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
|
||||
|
||||
if (ulMatch) {
|
||||
if (!inUl) { if (inOl) { result.push('</ol>'); inOl = false; } result.push('<ul>'); inUl = true; }
|
||||
result.push('<li>' + ulMatch[2] + '</li>');
|
||||
} else if (olMatch) {
|
||||
if (!inOl) { if (inUl) { result.push('</ul>'); inUl = false; } result.push('<ol>'); inOl = true; }
|
||||
result.push('<li>' + olMatch[2] + '</li>');
|
||||
} else {
|
||||
if (inUl) { result.push('</ul>'); inUl = false; }
|
||||
if (inOl) { result.push('</ol>'); inOl = false; }
|
||||
result.push(line.trim() === '' ? '<br>' : '<p>' + line + '</p>');
|
||||
}
|
||||
}
|
||||
if (inUl) result.push('</ul>');
|
||||
if (inOl) result.push('</ol>');
|
||||
|
||||
return result.join('\n')
|
||||
.replace(/(<br>\s*){2,}/g, '<br>')
|
||||
.replace(/<p>\s*<\/p>/g, '');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(function () {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Appointment Calendar / Schedule Module
|
||||
* Handles day, week and month calendar views for appointments, jobs, and maintenance
|
||||
*/
|
||||
const appointmentCalendar = {
|
||||
currentView: 'week',
|
||||
currentDate: new Date(),
|
||||
events: [],
|
||||
unscheduledJobs: [],
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Init
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
init: function(view = 'week', date = new Date()) {
|
||||
this.currentView = view;
|
||||
this.currentDate = new Date(date);
|
||||
this.updateViewButtons();
|
||||
this.attachEventListeners();
|
||||
this.loadAndRender();
|
||||
this.loadUnscheduledJobs();
|
||||
this.setupSidebarToggle();
|
||||
this.setupSidebarDropZone();
|
||||
},
|
||||
|
||||
attachEventListeners: function() {
|
||||
document.getElementById('btnPrevious').addEventListener('click', () => this.goToPrevious());
|
||||
document.getElementById('btnNext').addEventListener('click', () => this.goToNext());
|
||||
document.getElementById('btnToday').addEventListener('click', () => this.goToToday());
|
||||
document.getElementById('btnDayView').addEventListener('click', () => this.switchView('day'));
|
||||
document.getElementById('btnWeekView').addEventListener('click', () => this.switchView('week'));
|
||||
document.getElementById('btnMonthView').addEventListener('click', () => this.switchView('month'));
|
||||
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => this.renderCalendar(), 250);
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Load & Render
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAndRender: async function() {
|
||||
const { start, end } = this.getDateRange();
|
||||
await this.loadEvents(start, end);
|
||||
this.renderCalendar();
|
||||
this.updateCurrentDateDisplay();
|
||||
this.updateURL();
|
||||
},
|
||||
|
||||
loadEvents: async function(startDate, endDate) {
|
||||
try {
|
||||
const url = `/Appointments/GetCalendarEvents?start=${startDate.toISOString()}&end=${endDate.toISOString()}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to load events');
|
||||
this.events = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error loading events:', error);
|
||||
this.events = [];
|
||||
}
|
||||
},
|
||||
|
||||
renderCalendar: function() {
|
||||
const container = document.getElementById('calendarContainer');
|
||||
if (this.currentView === 'day') {
|
||||
container.innerHTML = this.renderDayView();
|
||||
} else if (this.currentView === 'week') {
|
||||
container.innerHTML = this.renderWeekView();
|
||||
} else {
|
||||
container.innerHTML = this.renderMonthView();
|
||||
}
|
||||
this.attachEventClickHandlers();
|
||||
this.attachDropHandlers();
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Unscheduled Jobs Sidebar
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadUnscheduledJobs: async function() {
|
||||
try {
|
||||
const resp = await fetch('/Appointments/GetUnscheduledJobs');
|
||||
this.unscheduledJobs = await resp.json();
|
||||
} catch (e) {
|
||||
this.unscheduledJobs = [];
|
||||
}
|
||||
this.renderUnscheduledPanel();
|
||||
},
|
||||
|
||||
renderUnscheduledPanel: function() {
|
||||
const panel = document.getElementById('unscheduledJobsPanel');
|
||||
const countEl = document.getElementById('unscheduledCount');
|
||||
if (!panel) return;
|
||||
|
||||
const count = this.unscheduledJobs.length;
|
||||
if (countEl) countEl.textContent = count > 0 ? `${count} job${count === 1 ? '' : 's'}` : '';
|
||||
|
||||
if (count === 0) {
|
||||
panel.innerHTML = '<div class="text-muted small text-center py-3"><i class="bi bi-check-circle me-1"></i>All jobs scheduled</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
this.unscheduledJobs.forEach(job => {
|
||||
let dueLine = '';
|
||||
if (job.dueDate) {
|
||||
const cls = job.isOverdue ? 'text-danger fw-semibold' : 'text-muted';
|
||||
dueLine = `<div class="sj-due ${cls}"><i class="bi bi-calendar-event me-1"></i>${this.formatDateShort(job.dueDate)}${job.isOverdue ? ' ⚠' : ''}</div>`;
|
||||
}
|
||||
const previewData = JSON.stringify({
|
||||
jobNumber: job.jobNumber,
|
||||
customerName: job.customerName,
|
||||
statusName: job.statusName,
|
||||
color: job.color,
|
||||
dueDate: job.dueDate,
|
||||
isOverdue: job.isOverdue,
|
||||
quotedPrice: job.quotedPrice,
|
||||
specialInstructions: job.specialInstructions,
|
||||
items: job.items || [],
|
||||
itemCount: job.itemCount || 0
|
||||
});
|
||||
html += `
|
||||
<div class="sj-card" draggable="true"
|
||||
data-job-id="${job.id}"
|
||||
data-preview="${encodeURIComponent(previewData)}"
|
||||
style="border-left:3px solid ${this.escapeHtml(job.color)}">
|
||||
<div class="sj-number">${this.escapeHtml(job.jobNumber)}</div>
|
||||
<div class="sj-customer">${this.escapeHtml(job.customerName)}</div>
|
||||
<div class="sj-status" style="color:${this.escapeHtml(job.color)}">${this.escapeHtml(job.statusName)}</div>
|
||||
${dueLine}
|
||||
</div>`;
|
||||
});
|
||||
panel.innerHTML = html;
|
||||
this.attachSidebarDragHandlers();
|
||||
this.attachHoverPreviewHandlers();
|
||||
},
|
||||
|
||||
attachHoverPreviewHandlers: function() {
|
||||
const popout = document.getElementById('sjPreviewCard');
|
||||
if (!popout) return;
|
||||
|
||||
document.querySelectorAll('.sj-card').forEach(card => {
|
||||
card.addEventListener('mouseenter', (e) => {
|
||||
let data;
|
||||
try { data = JSON.parse(decodeURIComponent(card.dataset.preview)); } catch { return; }
|
||||
popout.innerHTML = this.buildPreviewHtml(data);
|
||||
popout.style.display = 'block';
|
||||
this.positionPreview(e);
|
||||
});
|
||||
card.addEventListener('mousemove', (e) => this.positionPreview(e));
|
||||
card.addEventListener('mouseleave', () => { popout.style.display = 'none'; });
|
||||
card.addEventListener('dragstart', () => { popout.style.display = 'none'; });
|
||||
});
|
||||
},
|
||||
|
||||
positionPreview: function(e) {
|
||||
const popout = document.getElementById('sjPreviewCard');
|
||||
if (!popout) return;
|
||||
const offset = 14;
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const pw = popout.offsetWidth || 240, ph = popout.offsetHeight || 160;
|
||||
let left = e.clientX + offset;
|
||||
let top = e.clientY + offset;
|
||||
if (left + pw > vw - 8) left = e.clientX - pw - offset;
|
||||
if (top + ph > vh - 8) top = e.clientY - ph - offset;
|
||||
popout.style.left = left + 'px';
|
||||
popout.style.top = top + 'px';
|
||||
},
|
||||
|
||||
buildPreviewHtml: function(d) {
|
||||
const dueLine = d.dueDate
|
||||
? `<div class="sjp-row ${d.isOverdue ? 'sjp-overdue' : ''}">
|
||||
<i class="bi bi-calendar-event"></i>
|
||||
Due ${this.formatDateShort(d.dueDate)}${d.isOverdue ? ' — Overdue' : ''}
|
||||
</div>` : '';
|
||||
const priceLine = d.quotedPrice > 0
|
||||
? `<div class="sjp-row"><i class="bi bi-currency-dollar"></i>Quoted $${Number(d.quotedPrice).toFixed(2)}</div>` : '';
|
||||
const notesLine = d.specialInstructions
|
||||
? `<div class="sjp-notes">${this.escapeHtml(d.specialInstructions)}</div>` : '';
|
||||
|
||||
let itemsHtml = '';
|
||||
if (d.items && d.items.length > 0) {
|
||||
itemsHtml = '<div class="sjp-items">';
|
||||
d.items.forEach(item => {
|
||||
const colorDot = item.colorName
|
||||
? `<span class="sjp-color-dot" title="${this.escapeHtml(item.colorName)}"></span><span class="sjp-color">${this.escapeHtml(item.colorName)}</span>`
|
||||
: '';
|
||||
itemsHtml += `<div class="sjp-item"><span class="sjp-qty">×${item.quantity}</span><span class="sjp-item-desc">${this.escapeHtml(item.description)}</span>${colorDot ? colorDot : ''}</div>`;
|
||||
});
|
||||
if (d.itemCount > d.items.length) {
|
||||
itemsHtml += `<div class="sjp-item sjp-more">+ ${d.itemCount - d.items.length} more item${d.itemCount - d.items.length > 1 ? 's' : ''}</div>`;
|
||||
}
|
||||
itemsHtml += '</div>';
|
||||
}
|
||||
|
||||
return `<div class="sjp-card">
|
||||
<div class="sjp-status-bar" style="background:${this.escapeHtml(d.color)}">${this.escapeHtml(d.statusName)}</div>
|
||||
<div class="sjp-header" style="border-left:3px solid ${this.escapeHtml(d.color)}">
|
||||
<span class="sjp-job">${this.escapeHtml(d.jobNumber)}</span>
|
||||
</div>
|
||||
<div class="sjp-customer">${this.escapeHtml(d.customerName)}</div>
|
||||
${dueLine}${priceLine}${notesLine}
|
||||
${itemsHtml}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
attachSidebarDragHandlers: function() {
|
||||
document.querySelectorAll('.sj-card').forEach(card => {
|
||||
card.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'job',
|
||||
jobId: card.dataset.jobId,
|
||||
fromDate: null
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
card.classList.add('sj-dragging');
|
||||
});
|
||||
card.addEventListener('dragend', () => card.classList.remove('sj-dragging'));
|
||||
});
|
||||
},
|
||||
|
||||
setupSidebarDropZone: function() {
|
||||
const panel = document.getElementById('unscheduledJobsPanel');
|
||||
if (!panel) return;
|
||||
const sidebar = document.getElementById('unscheduledSidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
[panel, sidebar].forEach(el => {
|
||||
el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
sidebar.classList.add('sidebar-drag-over');
|
||||
});
|
||||
el.addEventListener('dragleave', () => sidebar.classList.remove('sidebar-drag-over'));
|
||||
el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
sidebar.classList.remove('sidebar-drag-over');
|
||||
let data;
|
||||
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
|
||||
if (data.type === 'job' && data.fromDate) {
|
||||
this.scheduleJob(data.jobId, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setupSidebarToggle: function() {
|
||||
const btn = document.getElementById('btnCollapseSidebar');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
const sidebar = document.getElementById('unscheduledSidebar');
|
||||
const chevron = document.getElementById('sidebarChevron');
|
||||
const body = document.getElementById('unscheduledJobsPanel');
|
||||
const countEl = document.getElementById('unscheduledCount');
|
||||
const titleEl = document.getElementById('sidebarTitleText');
|
||||
if (this.sidebarCollapsed) {
|
||||
sidebar.classList.add('sidebar-collapsed');
|
||||
if (chevron) { chevron.classList.remove('bi-chevron-left'); chevron.classList.add('bi-chevron-right'); }
|
||||
if (body) body.style.display = 'none';
|
||||
if (countEl) countEl.style.display = 'none';
|
||||
if (titleEl) titleEl.style.display = 'none';
|
||||
} else {
|
||||
sidebar.classList.remove('sidebar-collapsed');
|
||||
if (chevron) { chevron.classList.remove('bi-chevron-right'); chevron.classList.add('bi-chevron-left'); }
|
||||
if (body) body.style.display = '';
|
||||
if (countEl) countEl.style.display = '';
|
||||
if (titleEl) titleEl.style.display = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Drag & Drop — Drop Handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
attachDropHandlers: function() {
|
||||
// Week view: day header columns
|
||||
document.querySelectorAll('.calendar-day-header[data-date]').forEach(cell => {
|
||||
this.makeDayDropTarget(cell);
|
||||
});
|
||||
// Month view: month cells
|
||||
document.querySelectorAll('.calendar-month-cell[data-date]').forEach(cell => {
|
||||
this.makeDayDropTarget(cell);
|
||||
});
|
||||
// Day view: single day header
|
||||
const dayHeader = document.querySelector('.calendar-day-header-single[data-date]');
|
||||
if (dayHeader) this.makeDayDropTarget(dayHeader);
|
||||
},
|
||||
|
||||
makeDayDropTarget: function(cell) {
|
||||
cell.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cell.classList.add('calendar-drag-over');
|
||||
});
|
||||
cell.addEventListener('dragleave', (e) => {
|
||||
if (!cell.contains(e.relatedTarget)) {
|
||||
cell.classList.remove('calendar-drag-over');
|
||||
}
|
||||
});
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cell.classList.remove('calendar-drag-over');
|
||||
let data;
|
||||
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
|
||||
if (data.type !== 'job') return;
|
||||
const dateStr = cell.dataset.date;
|
||||
if (!dateStr) return;
|
||||
this.scheduleJob(data.jobId, new Date(dateStr));
|
||||
});
|
||||
},
|
||||
|
||||
scheduleJob: async function(jobId, date) {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
const params = new URLSearchParams({ id: jobId });
|
||||
if (date) params.append('date', date.toISOString().split('T')[0]);
|
||||
if (token) params.append('__RequestVerificationToken', token);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/Appointments/ScheduleJob', { method: 'POST', body: params });
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.success) {
|
||||
await this.loadAndRender();
|
||||
await this.loadUnscheduledJobs();
|
||||
if (result.removedFromBatch) {
|
||||
this.showToast(`Job scheduled. Removed from oven batch ${this.escapeHtml(result.removedFromBatch)}.`, 'warning');
|
||||
} else if (date) {
|
||||
this.showToast('Job scheduled.', 'success');
|
||||
} else {
|
||||
this.showToast('Job unscheduled.', 'info');
|
||||
}
|
||||
} else {
|
||||
this.showToast(result.message || 'Failed to update job.', 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Failed to update job.', 'danger');
|
||||
}
|
||||
},
|
||||
|
||||
showToast: function(message, type = 'success') {
|
||||
const container = document.getElementById('scheduleToastContainer');
|
||||
if (!container) return;
|
||||
const id = 'toast-' + Date.now();
|
||||
const bgMap = { success: 'bg-success', warning: 'bg-warning text-dark', info: 'bg-info text-dark', danger: 'bg-danger' };
|
||||
const cls = bgMap[type] || 'bg-secondary';
|
||||
container.insertAdjacentHTML('beforeend', `
|
||||
<div id="${id}" class="toast align-items-center ${cls} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${message}</div>
|
||||
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>`);
|
||||
const el = document.getElementById(id);
|
||||
if (el && window.bootstrap) new bootstrap.Toast(el, { delay: 4000 }).show();
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Day View
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderDayView: function() {
|
||||
const day = this.currentDate;
|
||||
const isToday = this.isToday(day);
|
||||
const allDayEvents = this.getAllDayEventsForDay(day);
|
||||
const totalEvents = this.getEventsForDay(day).length;
|
||||
|
||||
let html = '<div class="calendar-day-view">';
|
||||
html += '<div class="calendar-day-view-header">';
|
||||
html += `<div class="calendar-day-header-single ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
|
||||
html += '<div class="day-header-top">';
|
||||
html += `<div class="day-name-long">${this.getDayName(day, 'long')}</div>`;
|
||||
html += `<div class="day-number-large">${day.getDate()}</div>`;
|
||||
html += `<div class="day-month-year">${this.getMonthName(day)} ${day.getFullYear()}</div>`;
|
||||
if (totalEvents > 0) {
|
||||
html += `<div class="day-event-count ms-auto"><span class="badge bg-primary">${totalEvents} event${totalEvents === 1 ? '' : 's'}</span></div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (allDayEvents.length > 0) {
|
||||
html += '<div class="day-header-events">';
|
||||
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="calendar-day-grid">';
|
||||
for (let hour = 6; hour < 21; hour++) {
|
||||
const hourEvents = this.getEventsForDayHour(day, hour);
|
||||
html += `<div class="calendar-day-time-row ${hourEvents.length > 0 ? 'has-events' : ''}">`;
|
||||
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
|
||||
html += '<div class="calendar-day-time-cell">';
|
||||
hourEvents.forEach(event => html += this.renderEventCard(event, 'day'));
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Week View
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderWeekView: function() {
|
||||
const { start } = this.getDateRange();
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
let html = '<div class="calendar-week-view">';
|
||||
html += '<div class="calendar-week-header">';
|
||||
html += '<div class="calendar-week-header-spacer"></div>';
|
||||
|
||||
days.forEach(day => {
|
||||
const isToday = this.isToday(day);
|
||||
const allDayEvents = this.getAllDayEventsForDay(day);
|
||||
|
||||
html += `<div class="calendar-day-header ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
|
||||
html += '<div class="day-header-top">';
|
||||
html += `<div class="day-name">${this.getDayName(day, 'short')}</div>`;
|
||||
html += `<div class="day-number day-drill" data-day-date="${day.toISOString()}" title="View ${this.getDayName(day, 'long')} ${day.getDate()}">${day.getDate()}</div>`;
|
||||
html += '</div>';
|
||||
|
||||
if (allDayEvents.length > 0) {
|
||||
html += '<div class="day-header-events">';
|
||||
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += '<div class="calendar-week-grid">';
|
||||
|
||||
for (let hour = 6; hour < 20; hour++) {
|
||||
html += '<div class="calendar-time-row">';
|
||||
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
|
||||
days.forEach(day => {
|
||||
const dayEvents = this.getEventsForDayHour(day, hour);
|
||||
html += `<div class="calendar-time-cell" data-date="${day.toISOString()}" data-hour="${hour}">`;
|
||||
dayEvents.forEach(event => html += this.renderEventCard(event, 'week'));
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Month View
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderMonthView: function() {
|
||||
const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
|
||||
const calendarStart = new Date(firstDay);
|
||||
calendarStart.setDate(calendarStart.getDate() - ((calendarStart.getDay() + 6) % 7));
|
||||
|
||||
let html = '<div class="calendar-month-view">';
|
||||
html += '<div class="calendar-month-header">';
|
||||
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(name => {
|
||||
html += `<div class="calendar-day-name">${name}</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div class="calendar-month-grid">';
|
||||
|
||||
let currentDay = new Date(calendarStart);
|
||||
for (let week = 0; week < 6; week++) {
|
||||
html += '<div class="calendar-week-row">';
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const isCurrentMonth = currentDay.getMonth() === this.currentDate.getMonth();
|
||||
const isToday = this.isToday(currentDay);
|
||||
const allEvents = this.getEventsForDay(currentDay);
|
||||
const dayIso = currentDay.toISOString();
|
||||
const allDayEvents = allEvents.filter(e => e.allDay);
|
||||
const timedEvents = allEvents.filter(e => !e.allDay);
|
||||
|
||||
html += `<div class="calendar-month-cell ${isCurrentMonth ? '' : 'other-month'} ${isToday ? 'today' : ''}" data-date="${dayIso}">`;
|
||||
html += `<div class="cell-date day-drill" data-day-date="${dayIso}" title="View day">${currentDay.getDate()}</div>`;
|
||||
html += '<div class="cell-events">';
|
||||
|
||||
allDayEvents.forEach(event => html += this.renderEventCard(event, 'month'));
|
||||
|
||||
const maxEvents = 3;
|
||||
const remainingSlots = maxEvents - allDayEvents.length;
|
||||
timedEvents.slice(0, remainingSlots).forEach(event => html += this.renderEventCard(event, 'month'));
|
||||
|
||||
const totalEvents = allEvents.length;
|
||||
if (totalEvents > maxEvents) {
|
||||
html += `<div class="event-more day-drill" data-day-date="${dayIso}">+${totalEvents - maxEvents} more</div>`;
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Event Card Rendering
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderEventCard: function(event, viewType) {
|
||||
const colorClass = this.getColorClass(event.backgroundColor);
|
||||
const time = event.allDay ? 'All Day' : new Date(event.start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
const eventType = event.eventType || 'Appointment';
|
||||
const allDayClass = event.allDay ? 'all-day-event' : '';
|
||||
const isJob = eventType === 'Job';
|
||||
const dragAttr = isJob ? `draggable="true" data-job-id="${event.id}"` : '';
|
||||
const fallbackAttr = isJob && event.isFallbackDate ? 'data-fallback="true"' : '';
|
||||
const fallbackStyle = isJob && event.isFallbackDate ? 'opacity:0.75;border-style:dashed;' : '';
|
||||
const jobIcon = isJob ? '<i class="bi bi-briefcase me-1" style="font-size:0.65rem"></i>' : '';
|
||||
const fallbackIcon = isJob && event.isFallbackDate ? ' <i class="bi bi-calendar-x" title="No scheduled date \u2014 showing on due date" style="font-size:0.65rem"></i>' : '';
|
||||
|
||||
if (viewType === 'week-header') {
|
||||
return `<div class="calendar-event-header ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
style="${fallbackStyle}"
|
||||
title="${this.escapeHtml(event.title)}">
|
||||
${jobIcon}<span class="event-text">${this.escapeHtml(isJob ? (event.jobNumber || event.title) : event.title)}</span>${fallbackIcon}
|
||||
</div>`;
|
||||
} else if (viewType === 'day') {
|
||||
const locationHtml = event.location
|
||||
? `<div class="event-location"><i class="bi bi-geo-alt me-1"></i>${this.escapeHtml(event.location)}</div>` : '';
|
||||
return `<div class="calendar-event calendar-event-day ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
style="${fallbackStyle}"
|
||||
title="${this.escapeHtml(event.title)}">
|
||||
<div class="event-time">${jobIcon}${time}</div>
|
||||
<div class="event-title">${this.escapeHtml(event.title)}</div>
|
||||
<div class="event-customer">${this.escapeHtml(event.customerName || '')}</div>
|
||||
${locationHtml}
|
||||
</div>`;
|
||||
} else if (viewType === 'week') {
|
||||
return `<div class="calendar-event ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
style="${fallbackStyle}"
|
||||
title="${this.escapeHtml(event.title)} \u2014 ${this.escapeHtml(event.customerName)}">
|
||||
<div class="event-time">${jobIcon}${time}</div>
|
||||
<div class="event-title">${this.escapeHtml(event.title)}</div>
|
||||
<div class="event-customer">${this.escapeHtml(event.customerName)}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
// Month view
|
||||
return `<div class="calendar-event-month ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
title="${time} \u2014 ${this.escapeHtml(event.title)}">
|
||||
<span class="event-dot"></span>
|
||||
<span class="event-text">${jobIcon}${isJob ? this.escapeHtml(event.jobNumber || event.title) : (time + ' ' + this.escapeHtml(event.title))}</span>
|
||||
</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Event Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getEventsForDay: function(day) {
|
||||
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
|
||||
return this.events.filter(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
return eventStart >= dayStart && eventStart <= dayEnd;
|
||||
});
|
||||
},
|
||||
|
||||
getAllDayEventsForDay: function(day) {
|
||||
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
|
||||
return this.events.filter(event => {
|
||||
if (!event.allDay) return false;
|
||||
const eventStart = new Date(event.start);
|
||||
return eventStart >= dayStart && eventStart <= dayEnd;
|
||||
});
|
||||
},
|
||||
|
||||
getEventsForDayHour: function(day, hour) {
|
||||
const hourStart = new Date(day); hourStart.setHours(hour, 0, 0, 0);
|
||||
const hourEnd = new Date(day); hourEnd.setHours(hour, 59, 59, 999);
|
||||
return this.events.filter(event => {
|
||||
if (event.allDay) return false;
|
||||
const eventStart = new Date(event.start);
|
||||
return eventStart >= hourStart && eventStart <= hourEnd;
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Click Handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
attachEventClickHandlers: function() {
|
||||
document.querySelectorAll('.calendar-event, .calendar-event-month, .calendar-event-header').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const eventId = el.dataset.eventId;
|
||||
const eventType = el.dataset.eventType || 'Appointment';
|
||||
if (eventType === 'Maintenance') {
|
||||
window.location.href = `/Maintenance/Details/${eventId}`;
|
||||
} else if (eventType === 'Job') {
|
||||
window.location.href = `/Jobs/Details/${eventId}`;
|
||||
} else {
|
||||
window.location.href = `/Appointments/Details/${eventId}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.day-drill').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const date = new Date(el.dataset.dayDate);
|
||||
this.switchToDay(date);
|
||||
});
|
||||
});
|
||||
|
||||
// Job event drag handlers (calendar → calendar reschedule)
|
||||
document.querySelectorAll('[data-event-type="Job"]').forEach(el => {
|
||||
el.setAttribute('draggable', 'true');
|
||||
el.addEventListener('dragstart', (e) => {
|
||||
e.stopPropagation();
|
||||
const cell = el.closest('[data-date]');
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'job',
|
||||
jobId: el.dataset.eventId,
|
||||
fromDate: cell?.dataset.date || null
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
el.classList.add('event-dragging');
|
||||
});
|
||||
el.addEventListener('dragend', () => el.classList.remove('event-dragging'));
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Date Ranges & Navigation
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getDateRange: function() {
|
||||
if (this.currentView === 'day') return this.getDayRange(this.currentDate);
|
||||
if (this.currentView === 'week') return this.getWeekRange(this.currentDate);
|
||||
return this.getMonthRange(this.currentDate);
|
||||
},
|
||||
|
||||
getDayRange: function(date) {
|
||||
const start = new Date(date); start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(date); end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
getWeekRange: function(date) {
|
||||
const start = new Date(date);
|
||||
const day = start.getDay();
|
||||
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 7);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
getMonthRange: function(date) {
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
goToPrevious: function() {
|
||||
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() - 1);
|
||||
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() - 7);
|
||||
else this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
goToNext: function() {
|
||||
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() + 1);
|
||||
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() + 7);
|
||||
else this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
goToToday: function() {
|
||||
this.currentDate = new Date();
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
switchView: function(view) {
|
||||
this.currentView = view;
|
||||
this.updateViewButtons();
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
switchToDay: function(date) {
|
||||
this.currentDate = new Date(date);
|
||||
this.currentView = 'day';
|
||||
this.updateViewButtons();
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// UI State
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
updateViewButtons: function() {
|
||||
document.getElementById('btnDayView').classList.toggle('active', this.currentView === 'day');
|
||||
document.getElementById('btnWeekView').classList.toggle('active', this.currentView === 'week');
|
||||
document.getElementById('btnMonthView').classList.toggle('active', this.currentView === 'month');
|
||||
},
|
||||
|
||||
updateCurrentDateDisplay: function() {
|
||||
const display = document.getElementById('currentDateDisplay');
|
||||
if (this.currentView === 'day') {
|
||||
display.textContent = `${this.getDayName(this.currentDate, 'long')}, ${this.getMonthName(this.currentDate)} ${this.currentDate.getDate()}, ${this.currentDate.getFullYear()}`;
|
||||
} else if (this.currentView === 'week') {
|
||||
const { start, end } = this.getWeekRange(this.currentDate);
|
||||
const endDisplay = new Date(end);
|
||||
endDisplay.setDate(endDisplay.getDate() - 1);
|
||||
if (start.getMonth() === endDisplay.getMonth()) {
|
||||
display.textContent = `${this.getMonthName(start)} ${start.getDate()}\u2013${endDisplay.getDate()}, ${start.getFullYear()}`;
|
||||
} else {
|
||||
display.textContent = `${this.getMonthName(start)} ${start.getDate()} \u2013 ${this.getMonthName(endDisplay)} ${endDisplay.getDate()}, ${start.getFullYear()}`;
|
||||
}
|
||||
} else {
|
||||
display.textContent = `${this.getMonthName(this.currentDate)} ${this.currentDate.getFullYear()}`;
|
||||
}
|
||||
},
|
||||
|
||||
updateURL: function() {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('view', this.currentView);
|
||||
url.searchParams.set('date', this.currentDate.toISOString().split('T')[0]);
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
isToday: function(date) {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
},
|
||||
|
||||
getDayName: function(date, format = 'long') {
|
||||
return date.toLocaleDateString('en-US', { weekday: format });
|
||||
},
|
||||
|
||||
getMonthName: function(date) {
|
||||
return date.toLocaleDateString('en-US', { month: 'long' });
|
||||
},
|
||||
|
||||
formatHour: function(hour) {
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
|
||||
return `${displayHour} ${period}`;
|
||||
},
|
||||
|
||||
formatDateShort: function(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
},
|
||||
|
||||
getColorClass: function(bgColor) {
|
||||
if (bgColor && bgColor.startsWith('#')) {
|
||||
const colorMap = {
|
||||
'#6f42c1': 'calendar-event-purple',
|
||||
'#198754': 'calendar-event-green',
|
||||
'#0d6efd': 'calendar-event-blue',
|
||||
'#fd7e14': 'calendar-event-orange',
|
||||
'#d63384': 'calendar-event-pink',
|
||||
'#0dcaf0': 'calendar-event-cyan',
|
||||
'#20c997': 'calendar-event-teal',
|
||||
'#6610f2': 'calendar-event-indigo',
|
||||
'#84cc16': 'calendar-event-lime',
|
||||
'#795548': 'calendar-event-brown',
|
||||
'#dc3545': 'calendar-event-red',
|
||||
'#ffc107': 'calendar-event-yellow',
|
||||
'#6c757d': 'calendar-event-gray',
|
||||
'#adb5bd': 'calendar-event-gray',
|
||||
'#212529': 'calendar-event-dark'
|
||||
};
|
||||
return colorMap[bgColor] || 'calendar-event-blue';
|
||||
}
|
||||
return bgColor ? `calendar-event-${bgColor}` : 'calendar-event-blue';
|
||||
},
|
||||
|
||||
escapeHtml: function(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
window.appointmentCalendar = appointmentCalendar;
|
||||
window.loadCalendarEvents = () => appointmentCalendar.loadAndRender();
|
||||
@@ -0,0 +1,204 @@
|
||||
// CSV Bulk Import JavaScript
|
||||
// Handles file upload with AJAX, progress indicators, and result display
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setupCsvImportForm('csvImportCustomersForm', 'csvCustomersFile', 'csvImportCustomersBtn', '/Tools/CsvImportCustomers', 'csvCustomersResults');
|
||||
setupCsvImportForm('csvImportCatalogForm', 'csvCatalogFile', 'csvImportCatalogBtn', '/Tools/CsvImportCatalogItems', 'csvCatalogResults');
|
||||
setupCsvImportForm('csvImportInventoryForm', 'csvInventoryFile', 'csvImportInventoryBtn', '/Tools/CsvImportInventoryItems', 'csvInventoryResults');
|
||||
setupCsvImportForm('csvImportQuotesForm', 'csvQuotesFile', 'csvImportQuotesBtn', '/Tools/CsvImportQuotes', 'csvQuotesResults');
|
||||
setupCsvImportForm('csvImportJobsForm', 'csvJobsFile', 'csvImportJobsBtn', '/Tools/CsvImportJobs', 'csvJobsResults');
|
||||
setupCsvImportForm('csvImportAppointmentsForm', 'csvAppointmentsFile', 'csvImportAppointmentsBtn', '/Tools/CsvImportAppointments', 'csvAppointmentsResults');
|
||||
setupCsvImportForm('csvImportEquipmentForm', 'csvEquipmentFile', 'csvImportEquipmentBtn', '/Tools/CsvImportEquipment', 'csvEquipmentResults');
|
||||
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
|
||||
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
|
||||
setupCsvImportForm('csvImportVendorsForm', 'csvVendorsFile', 'csvImportVendorsBtn', '/Tools/CsvImportVendors', 'csvVendorsResults');
|
||||
setupCsvImportForm('csvImportShopWorkersForm', 'csvShopWorkersFile', 'csvImportShopWorkersBtn', '/Tools/CsvImportShopWorkers', 'csvShopWorkersResults');
|
||||
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
|
||||
});
|
||||
|
||||
function setupCsvImportForm(formId, fileInputId, submitBtnId, actionUrl, resultsId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
const submitBtn = document.getElementById(submitBtnId);
|
||||
const resultsDiv = document.getElementById(resultsId);
|
||||
const spinner = submitBtn.querySelector('.spinner-border');
|
||||
|
||||
// Validate file selection
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
showToast('Please select a CSV file to import.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
|
||||
// Validate file extension
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
showToast('Please select a valid CSV file.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
showToast('File size must be less than 10MB.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
spinner.classList.remove('d-none');
|
||||
submitBtn.disabled = true;
|
||||
resultsDiv.classList.add('d-none');
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
// Prepare form data (use the form element so all fields, including account selects, are included)
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Get anti-forgery token
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(actionUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'RequestVerificationToken': token
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Display results
|
||||
displayImportResults(result, resultsDiv);
|
||||
|
||||
// Show toast notification
|
||||
if (result.success) {
|
||||
showToast(`Import completed: ${result.successCount} records imported successfully!`, 'success');
|
||||
// Clear file input
|
||||
fileInput.value = '';
|
||||
} else {
|
||||
showToast('Import completed with errors. Please review the details below.', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
showToast('An error occurred during import: ' + error.message, 'error');
|
||||
displayImportResults({ success: false, message: error.message, errors: [error.toString()] }, resultsDiv);
|
||||
} finally {
|
||||
// Hide loading state
|
||||
spinner.classList.add('d-none');
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayImportResults(result, resultsDiv) {
|
||||
resultsDiv.classList.remove('d-none');
|
||||
|
||||
const cardClass = result.success ? 'border-success' : 'border-danger';
|
||||
const headerClass = result.success ? 'bg-success' : 'bg-danger';
|
||||
const icon = result.success ? 'check-circle' : 'exclamation-triangle';
|
||||
|
||||
const skipped = result.skippedCount || 0;
|
||||
const colSize = skipped > 0 ? 'col-md-3' : 'col-md-4';
|
||||
|
||||
let html = `
|
||||
<div class="card ${cardClass}">
|
||||
<div class="card-header ${headerClass} text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-${icon}"></i> Import Results</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-success">${result.successCount || 0}</div>
|
||||
<small class="text-muted">Imported</small>
|
||||
</div>
|
||||
</div>
|
||||
${skipped > 0 ? `
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-warning">${skipped}</div>
|
||||
<small class="text-muted">Skipped (already exist)</small>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-danger">${result.errorCount || 0}</div>
|
||||
<small class="text-muted">Errors</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-info">${result.totalRows || 0}</div>
|
||||
<small class="text-muted">Total Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-2"><strong>${result.message || 'Import completed.'}</strong></p>
|
||||
`;
|
||||
|
||||
// Display errors
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
html += `
|
||||
<div class="alert alert-danger mt-3">
|
||||
<h6><i class="bi bi-exclamation-triangle"></i> Errors:</h6>
|
||||
<ul class="mb-0 small">
|
||||
`;
|
||||
result.errors.forEach(error => {
|
||||
html += `<li>${escapeHtml(error)}</li>`;
|
||||
});
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display warnings
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
html += `
|
||||
<div class="alert alert-warning mt-3">
|
||||
<h6><i class="bi bi-info-circle"></i> Warnings:</h6>
|
||||
<ul class="mb-0 small">
|
||||
`;
|
||||
result.warnings.forEach(warning => {
|
||||
html += `<li>${escapeHtml(warning)}</li>`;
|
||||
});
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultsDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toastId = type === 'success' ? 'successToast' : 'errorToast';
|
||||
const toastElement = document.getElementById(toastId);
|
||||
if (!toastElement) return;
|
||||
|
||||
const toastBody = toastElement.querySelector('.toast-body');
|
||||
toastBody.textContent = message;
|
||||
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
autohide: true,
|
||||
delay: 5000
|
||||
});
|
||||
toast.show();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
// Modal Management for Lookup Tables
|
||||
// This file contains modal-specific functions that replace the old prompt() dialogs
|
||||
// Include this AFTER company-settings-lookups.js
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ====================
|
||||
// JOB STATUS MODAL
|
||||
// ====================
|
||||
|
||||
window.showJobStatusModal = function(item) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('jobStatusModal'));
|
||||
const form = document.getElementById('jobStatusForm');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
document.getElementById('jobStatusId').value = '';
|
||||
|
||||
if (item) {
|
||||
// Edit mode
|
||||
document.getElementById('jobStatusModalTitle').textContent = 'Edit Job Status';
|
||||
document.getElementById('jobStatusId').value = item.id;
|
||||
document.getElementById('jobStatusCode').value = item.statusCode;
|
||||
document.getElementById('jobStatusCode').disabled = true; // Cannot change code
|
||||
document.getElementById('jobStatusDisplayName').value = item.displayName;
|
||||
document.getElementById('jobStatusColorClass').value = item.colorClass;
|
||||
document.getElementById('jobStatusCategory').value = item.workflowCategory || '';
|
||||
document.getElementById('jobStatusIsTerminal').checked = item.isTerminalStatus;
|
||||
document.getElementById('jobStatusIsWIP').checked = item.isWorkInProgressStatus;
|
||||
document.getElementById('jobStatusDescription').value = item.description || '';
|
||||
} else {
|
||||
// Add mode
|
||||
document.getElementById('jobStatusModalTitle').textContent = 'Add Job Status';
|
||||
document.getElementById('jobStatusCode').disabled = false;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
};
|
||||
|
||||
document.getElementById('saveJobStatusBtn').addEventListener('click', function() {
|
||||
const form = document.getElementById('jobStatusForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = document.getElementById('jobStatusId').value;
|
||||
const data = {
|
||||
statusCode: document.getElementById('jobStatusCode').value.toUpperCase(),
|
||||
displayName: document.getElementById('jobStatusDisplayName').value,
|
||||
colorClass: document.getElementById('jobStatusColorClass').value,
|
||||
workflowCategory: document.getElementById('jobStatusCategory').value || null,
|
||||
isTerminalStatus: document.getElementById('jobStatusIsTerminal').checked,
|
||||
isWorkInProgressStatus: document.getElementById('jobStatusIsWIP').checked,
|
||||
description: document.getElementById('jobStatusDescription').value || null,
|
||||
displayOrder: 999 // Will be set by server
|
||||
};
|
||||
|
||||
if (id) {
|
||||
// Update
|
||||
data.id = parseInt(id);
|
||||
data.isActive = true;
|
||||
$.ajax({
|
||||
url: '/CompanySettings/UpdateJobStatus',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('jobStatusModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadJobStatuses();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to update job status');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
$.ajax({
|
||||
url: '/CompanySettings/CreateJobStatus',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('jobStatusModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadJobStatuses();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to create job status');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ====================
|
||||
// JOB PRIORITY MODAL
|
||||
// ====================
|
||||
|
||||
window.showJobPriorityModal = function(item) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('jobPriorityModal'));
|
||||
const form = document.getElementById('jobPriorityForm');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
document.getElementById('jobPriorityId').value = '';
|
||||
|
||||
if (item) {
|
||||
// Edit mode
|
||||
document.getElementById('jobPriorityModalTitle').textContent = 'Edit Job Priority';
|
||||
document.getElementById('jobPriorityId').value = item.id;
|
||||
document.getElementById('jobPriorityCode').value = item.priorityCode;
|
||||
document.getElementById('jobPriorityCode').disabled = true; // Cannot change code
|
||||
document.getElementById('jobPriorityDisplayName').value = item.displayName;
|
||||
document.getElementById('jobPriorityColorClass').value = item.colorClass;
|
||||
document.getElementById('jobPriorityDescription').value = item.description || '';
|
||||
} else {
|
||||
// Add mode
|
||||
document.getElementById('jobPriorityModalTitle').textContent = 'Add Job Priority';
|
||||
document.getElementById('jobPriorityCode').disabled = false;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
};
|
||||
|
||||
document.getElementById('saveJobPriorityBtn').addEventListener('click', function() {
|
||||
const form = document.getElementById('jobPriorityForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = document.getElementById('jobPriorityId').value;
|
||||
const data = {
|
||||
priorityCode: document.getElementById('jobPriorityCode').value.toUpperCase(),
|
||||
displayName: document.getElementById('jobPriorityDisplayName').value,
|
||||
colorClass: document.getElementById('jobPriorityColorClass').value,
|
||||
description: document.getElementById('jobPriorityDescription').value || null,
|
||||
displayOrder: 999 // Will be set by server
|
||||
};
|
||||
|
||||
if (id) {
|
||||
// Update
|
||||
data.id = parseInt(id);
|
||||
data.isActive = true;
|
||||
$.ajax({
|
||||
url: '/CompanySettings/UpdateJobPriority',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('jobPriorityModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadJobPriorities();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to update job priority');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
$.ajax({
|
||||
url: '/CompanySettings/CreateJobPriority',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('jobPriorityModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadJobPriorities();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to create job priority');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ====================
|
||||
// QUOTE STATUS MODAL
|
||||
// ====================
|
||||
|
||||
window.showQuoteStatusModal = function(item) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('quoteStatusModal'));
|
||||
const form = document.getElementById('quoteStatusForm');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
document.getElementById('quoteStatusId').value = '';
|
||||
|
||||
if (item) {
|
||||
// Edit mode
|
||||
document.getElementById('quoteStatusModalTitle').textContent = 'Edit Quote Status';
|
||||
document.getElementById('quoteStatusId').value = item.id;
|
||||
document.getElementById('quoteStatusCode').value = item.statusCode;
|
||||
document.getElementById('quoteStatusCode').disabled = true; // Cannot change code
|
||||
document.getElementById('quoteStatusDisplayName').value = item.displayName;
|
||||
document.getElementById('quoteStatusColorClass').value = item.colorClass;
|
||||
document.getElementById('quoteStatusIsApproved').checked = item.isApprovedStatus;
|
||||
document.getElementById('quoteStatusIsConverted').checked = item.isConvertedStatus;
|
||||
document.getElementById('quoteStatusIsDraft').checked = item.isDraftStatus;
|
||||
document.getElementById('quoteStatusDescription').value = item.description || '';
|
||||
} else {
|
||||
// Add mode
|
||||
document.getElementById('quoteStatusModalTitle').textContent = 'Add Quote Status';
|
||||
document.getElementById('quoteStatusCode').disabled = false;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
};
|
||||
|
||||
document.getElementById('saveQuoteStatusBtn').addEventListener('click', function() {
|
||||
const form = document.getElementById('quoteStatusForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = document.getElementById('quoteStatusId').value;
|
||||
const data = {
|
||||
statusCode: document.getElementById('quoteStatusCode').value.toUpperCase(),
|
||||
displayName: document.getElementById('quoteStatusDisplayName').value,
|
||||
colorClass: document.getElementById('quoteStatusColorClass').value,
|
||||
isApprovedStatus: document.getElementById('quoteStatusIsApproved').checked,
|
||||
isConvertedStatus: document.getElementById('quoteStatusIsConverted').checked,
|
||||
isDraftStatus: document.getElementById('quoteStatusIsDraft').checked,
|
||||
description: document.getElementById('quoteStatusDescription').value || null,
|
||||
displayOrder: 999 // Will be set by server
|
||||
};
|
||||
|
||||
if (id) {
|
||||
// Update
|
||||
data.id = parseInt(id);
|
||||
data.isActive = true;
|
||||
$.ajax({
|
||||
url: '/CompanySettings/UpdateQuoteStatus',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quoteStatusModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadQuoteStatuses();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to update quote status');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
$.ajax({
|
||||
url: '/CompanySettings/CreateQuoteStatus',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('quoteStatusModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadQuoteStatuses();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to create quote status');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ====================
|
||||
// INVENTORY CATEGORY MODAL
|
||||
// ====================
|
||||
|
||||
window.showInventoryCategoryModal = function(item) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('inventoryCategoryModal'));
|
||||
const form = document.getElementById('inventoryCategoryForm');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
document.getElementById('inventoryCategoryId').value = '';
|
||||
|
||||
if (item) {
|
||||
// Edit mode
|
||||
document.getElementById('inventoryCategoryModalTitle').textContent = 'Edit Inventory Category';
|
||||
document.getElementById('inventoryCategoryId').value = item.id;
|
||||
document.getElementById('inventoryCategoryCode').value = item.categoryCode;
|
||||
document.getElementById('inventoryCategoryCode').disabled = true; // Cannot change code
|
||||
document.getElementById('inventoryCategoryDisplayName').value = item.displayName;
|
||||
document.getElementById('inventoryCategoryIsCoating').checked = item.isCoating;
|
||||
document.getElementById('inventoryCategoryDescription').value = item.description || '';
|
||||
} else {
|
||||
// Add mode
|
||||
document.getElementById('inventoryCategoryModalTitle').textContent = 'Add Inventory Category';
|
||||
document.getElementById('inventoryCategoryCode').disabled = false;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
};
|
||||
|
||||
document.getElementById('saveInventoryCategoryBtn').addEventListener('click', function() {
|
||||
const form = document.getElementById('inventoryCategoryForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = document.getElementById('inventoryCategoryId').value;
|
||||
const data = {
|
||||
categoryCode: document.getElementById('inventoryCategoryCode').value.toUpperCase(),
|
||||
displayName: document.getElementById('inventoryCategoryDisplayName').value,
|
||||
isCoating: document.getElementById('inventoryCategoryIsCoating').checked,
|
||||
description: document.getElementById('inventoryCategoryDescription').value || null,
|
||||
displayOrder: 999 // Will be set by server
|
||||
};
|
||||
|
||||
if (id) {
|
||||
// Update
|
||||
data.id = parseInt(id);
|
||||
data.isActive = true;
|
||||
$.ajax({
|
||||
url: '/CompanySettings/UpdateInventoryCategory',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('inventoryCategoryModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadInventoryCategories();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to update inventory category');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
$.ajax({
|
||||
url: '/CompanySettings/CreateInventoryCategory',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('inventoryCategoryModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadInventoryCategories();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to create inventory category');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update the window-level edit functions to use the new modals
|
||||
const originalEditJobStatus = window.editJobStatus;
|
||||
window.editJobStatus = function(id) {
|
||||
// Find the item from the global arrays
|
||||
const item = window.jobStatuses ? window.jobStatuses.find(s => s.id === id) : null;
|
||||
if (item) window.showJobStatusModal(item);
|
||||
};
|
||||
|
||||
const originalEditJobPriority = window.editJobPriority;
|
||||
window.editJobPriority = function(id) {
|
||||
const item = window.jobPriorities ? window.jobPriorities.find(p => p.id === id) : null;
|
||||
if (item) window.showJobPriorityModal(item);
|
||||
};
|
||||
|
||||
const originalEditQuoteStatus = window.editQuoteStatus;
|
||||
window.editQuoteStatus = function(id) {
|
||||
const item = window.quoteStatuses ? window.quoteStatuses.find(s => s.id === id) : null;
|
||||
if (item) window.showQuoteStatusModal(item);
|
||||
};
|
||||
|
||||
const originalEditInventoryCategory = window.editInventoryCategory;
|
||||
window.editInventoryCategory = function(id) {
|
||||
const item = window.inventoryCategories ? window.inventoryCategories.find(c => c.id === id) : null;
|
||||
if (item) window.showInventoryCategoryModal(item);
|
||||
};
|
||||
|
||||
// ====================
|
||||
// APPOINTMENT TYPE MODAL
|
||||
// ====================
|
||||
|
||||
window.showAppointmentTypeModal = function(item) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('appointmentTypeModal'));
|
||||
const form = document.getElementById('appointmentTypeForm');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
document.getElementById('appointmentTypeId').value = '';
|
||||
document.getElementById('appointmentTypeActiveField').style.display = 'none';
|
||||
|
||||
if (item) {
|
||||
// Edit mode
|
||||
document.getElementById('appointmentTypeModalTitle').textContent = 'Edit Appointment Type';
|
||||
document.getElementById('appointmentTypeId').value = item.id;
|
||||
document.getElementById('appointmentTypeCode').value = item.typeCode;
|
||||
document.getElementById('appointmentTypeCode').disabled = true; // Cannot change code
|
||||
document.getElementById('appointmentTypeDisplayName').value = item.displayName;
|
||||
document.getElementById('appointmentTypeColorClass').value = item.colorClass;
|
||||
document.getElementById('appointmentTypeIconClass').value = item.iconClass || '';
|
||||
document.getElementById('appointmentTypeRequiresJob').checked = item.requiresJobLink;
|
||||
document.getElementById('appointmentTypeIsActive').checked = item.isActive;
|
||||
document.getElementById('appointmentTypeDescription').value = item.description || '';
|
||||
document.getElementById('appointmentTypeActiveField').style.display = 'block';
|
||||
} else {
|
||||
// Add mode
|
||||
document.getElementById('appointmentTypeModalTitle').textContent = 'Add Appointment Type';
|
||||
document.getElementById('appointmentTypeCode').disabled = false;
|
||||
document.getElementById('appointmentTypeIsActive').checked = true;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
|
||||
// Update color preview after modal is shown
|
||||
window.updateAppointmentTypeColorPreview();
|
||||
};
|
||||
|
||||
// Update the appointment type color preview badge
|
||||
window.updateAppointmentTypeColorPreview = function() {
|
||||
const colorSelect = document.getElementById('appointmentTypeColorClass');
|
||||
const previewBadge = document.getElementById('appointmentTypeColorPreview');
|
||||
|
||||
if (!colorSelect || !previewBadge) return;
|
||||
|
||||
const selectedColor = colorSelect.value;
|
||||
|
||||
// Remove all existing Bootstrap color classes
|
||||
const colorClasses = [
|
||||
'bg-purple', 'bg-green', 'bg-blue', 'bg-orange', 'bg-red', 'bg-yellow',
|
||||
'bg-pink', 'bg-cyan', 'bg-teal', 'bg-indigo', 'bg-lime', 'bg-brown', 'bg-gray',
|
||||
'bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'bg-primary', 'bg-secondary', 'bg-dark',
|
||||
'text-dark', 'text-white'
|
||||
];
|
||||
previewBadge.classList.remove(...colorClasses);
|
||||
|
||||
// Add the selected color class
|
||||
previewBadge.classList.add('bg-' + selectedColor);
|
||||
|
||||
// Add text-dark for warning (yellow) to ensure visibility
|
||||
if (selectedColor === 'warning' || selectedColor === 'yellow' || selectedColor === 'lime') {
|
||||
previewBadge.classList.add('text-dark');
|
||||
} else {
|
||||
previewBadge.classList.add('text-white');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('saveAppointmentTypeBtn').addEventListener('click', function() {
|
||||
const form = document.getElementById('appointmentTypeForm');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = document.getElementById('appointmentTypeId').value;
|
||||
const data = {
|
||||
typeCode: document.getElementById('appointmentTypeCode').value.toUpperCase(),
|
||||
displayName: document.getElementById('appointmentTypeDisplayName').value,
|
||||
colorClass: document.getElementById('appointmentTypeColorClass').value,
|
||||
iconClass: document.getElementById('appointmentTypeIconClass').value || null,
|
||||
requiresJobLink: document.getElementById('appointmentTypeRequiresJob').checked,
|
||||
description: document.getElementById('appointmentTypeDescription').value || null,
|
||||
displayOrder: 999 // Will be set by server
|
||||
};
|
||||
|
||||
if (id) {
|
||||
// Update
|
||||
data.id = parseInt(id);
|
||||
data.isActive = document.getElementById('appointmentTypeIsActive').checked;
|
||||
$.ajax({
|
||||
url: '/CompanySettings/UpdateAppointmentType',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('appointmentTypeModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadAppointmentTypes();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to update appointment type');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
$.ajax({
|
||||
url: '/CompanySettings/CreateAppointmentType',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
data: JSON.stringify(data),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('appointmentTypeModal')).hide();
|
||||
window.showToast('success', response.message);
|
||||
window.loadAppointmentTypes();
|
||||
} else {
|
||||
window.showToast('error', response.message);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
window.showToast('error', 'Failed to create appointment type');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Override the editAppointmentType function to use modal
|
||||
window.editAppointmentType = function(id) {
|
||||
const item = window.appointmentTypes ? window.appointmentTypes.find(t => t.id === id) : null;
|
||||
if (item) window.showAppointmentTypeModal(item);
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,179 @@
|
||||
// Equipment Management JavaScript
|
||||
|
||||
// File Upload with Drag & Drop
|
||||
function initializeFileUpload() {
|
||||
const fileInput = document.getElementById('manualFile');
|
||||
const uploadForm = document.getElementById('uploadManualForm');
|
||||
|
||||
if (!fileInput || !uploadForm) return;
|
||||
|
||||
// Drag and drop handlers
|
||||
const dropZone = uploadForm.querySelector('.file-upload-zone');
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
uploadForm.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side Cost Calculation
|
||||
function initializeCostCalculation() {
|
||||
const laborCostInput = document.querySelector('input[name="LaborCost"]');
|
||||
const partsCostInput = document.querySelector('input[name="PartsCost"]');
|
||||
const totalCostDisplay = document.getElementById('totalCostDisplay');
|
||||
|
||||
if (!laborCostInput || !partsCostInput) return;
|
||||
|
||||
function calculateTotal() {
|
||||
const laborCost = parseFloat(laborCostInput.value) || 0;
|
||||
const partsCost = parseFloat(partsCostInput.value) || 0;
|
||||
const totalCost = laborCost + partsCost;
|
||||
|
||||
if (totalCostDisplay) {
|
||||
totalCostDisplay.textContent = totalCost.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
laborCostInput.addEventListener('input', calculateTotal);
|
||||
partsCostInput.addEventListener('input', calculateTotal);
|
||||
|
||||
// Calculate on page load
|
||||
calculateTotal();
|
||||
}
|
||||
|
||||
// Equipment Status Change Handler
|
||||
function initializeStatusChangeHandler() {
|
||||
const statusSelect = document.querySelector('select[name="Status"]');
|
||||
|
||||
if (!statusSelect) return;
|
||||
|
||||
statusSelect.addEventListener('change', function() {
|
||||
const selectedStatus = this.value;
|
||||
|
||||
// Add visual feedback based on status
|
||||
const card = this.closest('.card');
|
||||
if (card) {
|
||||
card.classList.remove('border-success', 'border-warning', 'border-danger', 'border-info');
|
||||
|
||||
switch(selectedStatus) {
|
||||
case 'Operational':
|
||||
card.classList.add('border-success');
|
||||
break;
|
||||
case 'NeedsMaintenance':
|
||||
card.classList.add('border-warning');
|
||||
break;
|
||||
case 'UnderMaintenance':
|
||||
card.classList.add('border-info');
|
||||
break;
|
||||
case 'OutOfService':
|
||||
card.classList.add('border-danger');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Maintenance Due Reminder
|
||||
function checkMaintenanceDue() {
|
||||
const daysUntilMaintenance = document.querySelector('[data-days-until-maintenance]');
|
||||
|
||||
if (!daysUntilMaintenance) return;
|
||||
|
||||
const days = parseInt(daysUntilMaintenance.dataset.daysUntilMaintenance);
|
||||
|
||||
if (days !== null && days < 7 && days >= 0) {
|
||||
showToast(`Maintenance due in ${days} days!`, 'warning');
|
||||
} else if (days < 0) {
|
||||
showToast(`Maintenance is overdue by ${Math.abs(days)} days!`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Show Toast Notification
|
||||
function showToast(message, type = 'info') {
|
||||
const toastHTML = `
|
||||
<div class="toast align-items-center text-white bg-${type} border-0 position-fixed bottom-0 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', toastHTML);
|
||||
const toastElement = document.querySelector('.toast:last-child');
|
||||
const toast = new bootstrap.Toast(toastElement);
|
||||
toast.show();
|
||||
|
||||
// Remove from DOM after hidden
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Confirm Delete with Modal
|
||||
function confirmDelete(event, itemName, itemType) {
|
||||
if (!confirm(`Are you sure you want to delete this ${itemType}: ${itemName}?\n\nThis action cannot be undone.`)) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search and Filter Tables
|
||||
function initializeTableSearch(inputId, tableId) {
|
||||
const searchInput = document.getElementById(inputId);
|
||||
const table = document.getElementById(tableId);
|
||||
|
||||
if (!searchInput || !table) return;
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
row.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize all features on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeFileUpload();
|
||||
initializeCostCalculation();
|
||||
initializeStatusChangeHandler();
|
||||
checkMaintenanceDue();
|
||||
|
||||
// Initialize table searches if present
|
||||
initializeTableSearch('searchInput', 'equipmentTable');
|
||||
initializeTableSearch('searchInput', 'maintenanceTable');
|
||||
});
|
||||
|
||||
// Export functions for use in other scripts
|
||||
window.EquipmentManagement = {
|
||||
showToast,
|
||||
confirmDelete,
|
||||
initializeFileUpload,
|
||||
initializeCostCalculation
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
// Job Photos JavaScript
|
||||
const jobPhotoModule = {
|
||||
jobId: null,
|
||||
allPhotos: [],
|
||||
currentPhotoIndex: 0,
|
||||
_tagApi: null,
|
||||
|
||||
init: function(jobId, tagSuggestions) {
|
||||
this.jobId = jobId;
|
||||
this._tagSuggestions = tagSuggestions || [];
|
||||
this.loadJobPhotos();
|
||||
this.setupDragDrop();
|
||||
this.setupFileInput();
|
||||
|
||||
// Initialise the tag widget each time the upload modal opens so it's always fresh
|
||||
const uploadModal = document.getElementById('uploadPhotoModal');
|
||||
if (uploadModal) {
|
||||
uploadModal.addEventListener('show.bs.modal', () => {
|
||||
this._tagApi = initTagInput('photoTagsHidden', 'photoTagsContainer', {
|
||||
suggestions: this._tagSuggestions
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadJobPhotos: function() {
|
||||
console.log('loadJobPhotos called');
|
||||
// Add cache-busting parameter to prevent browser from using cached data
|
||||
fetch(`/Jobs/GetJobPhotos?jobId=${this.jobId}&_t=${Date.now()}`, {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('GetJobPhotos response:', data);
|
||||
if (data.success) {
|
||||
this.allPhotos = data.photos;
|
||||
this.renderPhotoGallery(data.photos);
|
||||
document.getElementById('photoCount').textContent = data.photos.length;
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error loading photos:', error));
|
||||
},
|
||||
|
||||
renderPhotoGallery: function(photos) {
|
||||
const gallery = document.getElementById('photoGallery');
|
||||
|
||||
if (!gallery) {
|
||||
console.error('photoGallery element not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear gallery
|
||||
gallery.innerHTML = '';
|
||||
|
||||
if (photos.length === 0) {
|
||||
// Show "no photos" message
|
||||
gallery.innerHTML = `
|
||||
<div class="col-12 text-center py-5" id="noPhotosMessage">
|
||||
<i class="bi bi-camera" style="font-size: 3rem; opacity: 0.2;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No photos uploaded yet</p>
|
||||
<small class="text-muted">Click "Upload Photo" to add photos</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add photo cards
|
||||
photos.forEach((photo, index) => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-md-4 col-sm-6';
|
||||
col.innerHTML = `
|
||||
<div class="photo-card" onclick="jobPhotoModule.viewPhoto(${index})">
|
||||
<img src="/Jobs/GetPhoto/${photo.id}" alt="${this.escapeHtml(photo.caption || 'Job photo')}" class="photo-thumbnail">
|
||||
<div class="photo-overlay">
|
||||
<div class="photo-type-badge badge bg-${this.getPhotoTypeBadgeClass(photo.photoType)}">
|
||||
${photo.photoTypeDisplay}
|
||||
</div>
|
||||
${photo.caption ? `<p class="photo-caption">${this.escapeHtml(photo.caption)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
gallery.appendChild(col);
|
||||
});
|
||||
},
|
||||
|
||||
viewPhoto: function(index, isNavigating = false) {
|
||||
this.currentPhotoIndex = index;
|
||||
const photo = this.allPhotos[index];
|
||||
|
||||
document.getElementById('viewPhotoImage').src = `/Jobs/GetPhoto/${photo.id}`;
|
||||
document.getElementById('viewPhotoTitle').textContent = photo.photoTypeDisplay + ' Photo';
|
||||
document.getElementById('photoPosition').textContent = `Photo ${index + 1} of ${this.allPhotos.length}`;
|
||||
document.getElementById('photoDetailCaption').textContent = photo.caption || 'No caption';
|
||||
document.getElementById('photoDetailType').textContent = photo.photoTypeDisplay;
|
||||
document.getElementById('photoDetailDate').textContent = new Date(photo.uploadedDate).toLocaleDateString();
|
||||
document.getElementById('photoDetailUploader').textContent = photo.uploadedByName;
|
||||
|
||||
// Tags
|
||||
const tagsRow = document.getElementById('photoDetailTagsRow');
|
||||
const tagsSpan = document.getElementById('photoDetailTags');
|
||||
if (photo.tagsList && photo.tagsList.length > 0) {
|
||||
tagsSpan.innerHTML = photo.tagsList.map(t =>
|
||||
`<span class="badge bg-primary bg-opacity-10 text-primary me-1">${this.escapeHtml(t)}</span>`
|
||||
).join('');
|
||||
tagsRow.style.display = '';
|
||||
} else {
|
||||
tagsRow.style.display = 'none';
|
||||
}
|
||||
|
||||
// Only show modal if not navigating (prevents backdrop duplication)
|
||||
if (!isNavigating) {
|
||||
const modalElement = document.getElementById('viewPhotoModal');
|
||||
const modal = bootstrap.Modal.getInstance(modalElement) || new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
}
|
||||
},
|
||||
|
||||
navigatePhoto: function(direction) {
|
||||
this.currentPhotoIndex += direction;
|
||||
if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1;
|
||||
if (this.currentPhotoIndex >= this.allPhotos.length) this.currentPhotoIndex = 0;
|
||||
// Pass true to indicate we're navigating (don't re-show modal)
|
||||
this.viewPhoto(this.currentPhotoIndex, true);
|
||||
},
|
||||
|
||||
uploadPhoto: function() {
|
||||
const fileInput = document.getElementById('photoFile');
|
||||
const caption = document.getElementById('photoCaption').value;
|
||||
const photoType = document.getElementById('photoType').value;
|
||||
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
showWarning('Please select a photo to upload', 'No File Selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('jobId', this.jobId);
|
||||
formData.append('photo', fileInput.files[0]);
|
||||
formData.append('caption', caption);
|
||||
formData.append('photoType', photoType);
|
||||
formData.append('tags', document.getElementById('photoTagsHidden').value);
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
fetch('/Jobs/UploadPhoto', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Upload response:', data);
|
||||
if (data.success) {
|
||||
const modalElement = document.getElementById('uploadPhotoModal');
|
||||
const modal = bootstrap.Modal.getInstance(modalElement);
|
||||
modal.hide();
|
||||
|
||||
// Wait for modal animation to complete
|
||||
setTimeout(() => {
|
||||
console.log('Reloading photos after upload...');
|
||||
this.loadJobPhotos();
|
||||
this.clearPhotoSelection();
|
||||
this.showToast('Photo uploaded successfully', 'success');
|
||||
}, 400);
|
||||
} else {
|
||||
this.showToast(data.message || 'Error uploading photo', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
this.showToast('An error occurred while uploading', 'danger');
|
||||
});
|
||||
},
|
||||
|
||||
deletePhoto: function() {
|
||||
if (!confirm('Are you sure you want to delete this photo?')) return;
|
||||
|
||||
const photo = this.allPhotos[this.currentPhotoIndex];
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
fetch('/Jobs/DeletePhoto?id=' + photo.id, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Delete response:', data);
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('viewPhotoModal')).hide();
|
||||
|
||||
// Wait for modal animation to complete
|
||||
setTimeout(() => {
|
||||
console.log('Reloading photos after delete...');
|
||||
this.loadJobPhotos();
|
||||
this.showToast('Photo deleted successfully', 'success');
|
||||
}, 400);
|
||||
} else {
|
||||
this.showToast(data.message || 'Error deleting photo', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
this.showToast('An error occurred while deleting', 'danger');
|
||||
});
|
||||
},
|
||||
|
||||
setupDragDrop: function() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('photoFile');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.add('drag-over');
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
fileInput.files = files;
|
||||
this.handleFileSelect({ target: fileInput });
|
||||
}, false);
|
||||
},
|
||||
|
||||
setupFileInput: function() {
|
||||
document.getElementById('photoFile').addEventListener('change', (e) => this.handleFileSelect(e));
|
||||
},
|
||||
|
||||
handleFileSelect: function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showError('Please select an image file', 'Invalid File Type');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
showError('File size must be less than 10 MB', 'File Too Large');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
document.getElementById('previewImage').src = e.target.result;
|
||||
document.getElementById('photoPreview').classList.remove('d-none');
|
||||
document.getElementById('dropZone').style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
clearPhotoSelection: function() {
|
||||
document.getElementById('photoFile').value = '';
|
||||
document.getElementById('photoCaption').value = '';
|
||||
document.getElementById('photoType').value = '1';
|
||||
document.getElementById('photoPreview').classList.add('d-none');
|
||||
document.getElementById('dropZone').style.display = 'block';
|
||||
if (this._tagApi) this._tagApi.clear();
|
||||
},
|
||||
|
||||
getPhotoTypeBadgeClass: function(type) {
|
||||
const classes = {
|
||||
0: 'info', // Before
|
||||
1: 'primary', // Progress
|
||||
2: 'success', // After
|
||||
3: 'warning', // Quality Check
|
||||
4: 'danger', // Issue
|
||||
5: 'success' // Completed
|
||||
};
|
||||
return classes[type] || 'secondary';
|
||||
},
|
||||
|
||||
escapeHtml: function(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
showToast: function(message, type) {
|
||||
const alertClass = `alert-${type}`;
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert ${alertClass} alert-dismissible fade show position-fixed top-0 end-0 m-3`;
|
||||
alert.style.zIndex = '9999';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
setTimeout(() => alert.remove(), 3000);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,781 @@
|
||||
/* oven-scheduler.js — Batch scheduler with mouse-event drag (no HTML5 DnD API) */
|
||||
|
||||
'use strict';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Drag state
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
let dragState = null;
|
||||
/* dragState shape:
|
||||
{
|
||||
type: 'queue' | 'batch',
|
||||
sourceEl: HTMLElement, // original row being dragged
|
||||
ghost: HTMLElement, // floating clone following the cursor
|
||||
offsetX, offsetY: number, // cursor offset within the source element
|
||||
// queue-specific:
|
||||
coatId, jobId, jobItemId, sqft, color, colorCode,
|
||||
pass, coatName, jobNumber, customer, description, priority, dueDate
|
||||
// batch-specific:
|
||||
batchItemId, sourceBatchId, sqft
|
||||
}
|
||||
*/
|
||||
|
||||
// Currently highlighted batch card id (for visual feedback)
|
||||
let dragOverBatchId = null;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Init — wire up mousedown on all draggable rows
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initDraggable();
|
||||
});
|
||||
|
||||
function initDraggable() {
|
||||
// Queue coat rows
|
||||
document.querySelectorAll('[data-coat-id]').forEach(el => {
|
||||
el.addEventListener('mousedown', onQueueMouseDown);
|
||||
});
|
||||
|
||||
// Batch item rows (only those with draggable=true, i.e. editable batches)
|
||||
document.querySelectorAll('[data-batch-item-id]').forEach(el => {
|
||||
if (el.dataset.draggable === 'true' || el.getAttribute('draggable') === 'true') {
|
||||
el.addEventListener('mousedown', onBatchItemMouseDown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Mousedown handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function onQueueMouseDown(e) {
|
||||
if (e.button !== 0) return; // left button only
|
||||
if (e.target.closest('button')) return; // don't intercept button clicks
|
||||
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
dragState = {
|
||||
type: 'queue',
|
||||
sourceEl: el,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
coatId: +el.dataset.coatId,
|
||||
jobId: +el.dataset.jobId,
|
||||
jobItemId: +el.dataset.jobItemId,
|
||||
sqft: +el.dataset.sqft,
|
||||
color: el.dataset.color,
|
||||
colorCode: el.dataset.colorCode,
|
||||
pass: +el.dataset.pass,
|
||||
coatName: el.dataset.coatName,
|
||||
jobNumber: el.dataset.jobNumber,
|
||||
customer: el.dataset.customer,
|
||||
description: el.dataset.description,
|
||||
priority: el.dataset.priority,
|
||||
dueDate: el.dataset.dueDate
|
||||
};
|
||||
|
||||
startDrag(el, e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onBatchItemMouseDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest('button')) return;
|
||||
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
dragState = {
|
||||
type: 'batch',
|
||||
sourceEl: el,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
batchItemId: +el.dataset.batchItemId,
|
||||
sourceBatchId: +el.dataset.batchId,
|
||||
sqft: +el.dataset.sqft
|
||||
};
|
||||
|
||||
startDrag(el, e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Ghost element
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function startDrag(sourceEl, e) {
|
||||
const rect = sourceEl.getBoundingClientRect();
|
||||
|
||||
const ghost = sourceEl.cloneNode(true);
|
||||
ghost.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${rect.left}px;
|
||||
top: ${rect.top}px;
|
||||
width: ${rect.width}px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.25);
|
||||
border-radius: 6px;
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
padding: .3rem .5rem;
|
||||
font-size: .82rem;
|
||||
transition: none;
|
||||
`;
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
dragState.ghost = ghost;
|
||||
|
||||
sourceEl.style.opacity = '0.35';
|
||||
}
|
||||
|
||||
function moveGhost(e) {
|
||||
if (!dragState?.ghost) return;
|
||||
dragState.ghost.style.left = (e.clientX - dragState.offsetX) + 'px';
|
||||
dragState.ghost.style.top = (e.clientY - dragState.offsetY) + 'px';
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Document mousemove — move ghost and highlight drop targets
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!dragState) return;
|
||||
moveGhost(e);
|
||||
|
||||
// Hide ghost temporarily so elementFromPoint sees what's underneath
|
||||
dragState.ghost.style.display = 'none';
|
||||
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
|
||||
dragState.ghost.style.display = '';
|
||||
|
||||
const batchCard = elUnder?.closest('.batch-card');
|
||||
const newId = batchCard ? +batchCard.dataset.batchId : null;
|
||||
|
||||
if (newId !== dragOverBatchId) {
|
||||
// Remove old highlight
|
||||
if (dragOverBatchId !== null) {
|
||||
document.getElementById(`batch-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
document.getElementById(`empty-items-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
}
|
||||
// Add new highlight
|
||||
if (newId !== null) {
|
||||
batchCard.classList.add('drag-over');
|
||||
document.getElementById(`empty-items-${newId}`)?.classList.add('drag-over');
|
||||
}
|
||||
dragOverBatchId = newId;
|
||||
}
|
||||
|
||||
// Highlight empty oven zone when a queue item hovers over it
|
||||
const ovenZone = elUnder?.closest('[data-oven-id]');
|
||||
document.querySelectorAll('.drop-zone-empty').forEach(z => z.classList.remove('drag-over'));
|
||||
if (dragState.type === 'queue' && !batchCard && ovenZone) {
|
||||
document.getElementById(`empty-${ovenZone.dataset.ovenId}`)?.classList.add('drag-over');
|
||||
}
|
||||
|
||||
// Highlight queue when a batch item is being dragged (indicates it can be dropped there)
|
||||
const queueContainer = document.getElementById('queueContainer');
|
||||
const overQueue = elUnder?.closest('#queueContainer') || elUnder?.closest('.scheduler-queue');
|
||||
if (dragState.type === 'batch') {
|
||||
queueContainer?.classList.toggle('drag-over', !!overQueue);
|
||||
} else {
|
||||
queueContainer?.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Document mouseup — commit the drop
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('mouseup', e => {
|
||||
if (!dragState) return;
|
||||
if (e.button !== 0) return;
|
||||
|
||||
|
||||
// Clear all highlights
|
||||
if (dragOverBatchId !== null) {
|
||||
document.getElementById(`batch-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
document.getElementById(`empty-items-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
}
|
||||
document.getElementById('queueContainer')?.classList.remove('drag-over');
|
||||
document.querySelectorAll('.drop-zone-empty').forEach(z => z.classList.remove('drag-over'));
|
||||
|
||||
// Restore source element
|
||||
if (dragState.sourceEl) dragState.sourceEl.style.opacity = '';
|
||||
|
||||
// Remove ghost
|
||||
dragState.ghost?.remove();
|
||||
|
||||
// Find batch card under cursor
|
||||
dragState.ghost = null;
|
||||
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const batchCard = elUnder?.closest('.batch-card');
|
||||
|
||||
const state = dragState;
|
||||
dragState = null;
|
||||
dragOverBatchId = null;
|
||||
|
||||
if (!batchCard) {
|
||||
if (state.type === 'queue') {
|
||||
// Dropped on empty oven zone — auto-create batch and add
|
||||
const ovenZone = elUnder?.closest('[data-oven-id]');
|
||||
if (ovenZone) autoCreateBatchAndAdd(+ovenZone.dataset.ovenId, state);
|
||||
} else if (state.type === 'batch') {
|
||||
// Dropped outside any batch card — return item to queue
|
||||
removeFromBatch(state.batchItemId, state.sourceBatchId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const batchId = +batchCard.dataset.batchId;
|
||||
|
||||
if (state.type === 'queue') {
|
||||
addCoatToBatch(state, batchId);
|
||||
} else if (state.type === 'batch' && state.sourceBatchId !== batchId) {
|
||||
moveToBatch(state.batchItemId, batchId, state.sourceBatchId, state.sqft);
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Add coat from queue to batch
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function addCoatToBatch(drag, batchId) {
|
||||
const res = await apiPost(URLS.addToBatch, {
|
||||
batchId,
|
||||
jobItemCoatId: drag.coatId
|
||||
});
|
||||
|
||||
if (!res.success) { showToast(res.error || 'Failed to add to batch', 'danger'); return; }
|
||||
|
||||
// Remove from queue
|
||||
document.getElementById(`qcoat-${drag.coatId}`)?.remove();
|
||||
|
||||
// Add to batch items list
|
||||
appendBatchItemRow(batchId, res.item, true);
|
||||
|
||||
// Update capacity display
|
||||
updateBatchCapacity(batchId, res.totalSurfaceAreaSqFt, res.capacityPct);
|
||||
|
||||
// Update drop hint: switch from "Drag coats here" to "Drop more here"
|
||||
const hint = document.getElementById(`empty-items-${batchId}`);
|
||||
if (hint) {
|
||||
hint.classList.remove('batch-drop-hint-empty');
|
||||
hint.innerHTML = '<i class="bi bi-plus-circle me-1"></i><span>Drop more here</span>';
|
||||
}
|
||||
|
||||
updateQueueCount(-1);
|
||||
showToast('Added to batch', 'success');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Auto-create a batch then add the dragged coat (dropped on empty oven zone)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function autoCreateBatchAndAdd(ovenCostId, drag) {
|
||||
const createRes = await apiPost(URLS.createBatch, {
|
||||
ovenCostId,
|
||||
scheduledDate: SCHEDULED_DATE,
|
||||
scheduledStartTime: null
|
||||
});
|
||||
if (!createRes.success) { showToast(createRes.error || 'Failed to create batch', 'danger'); return; }
|
||||
|
||||
// Add the coat to the newly created batch before reloading
|
||||
const addRes = await apiPost(URLS.addToBatch, {
|
||||
batchId: createRes.batch.id,
|
||||
jobItemCoatId: drag.coatId
|
||||
});
|
||||
if (!addRes.success) { showToast(addRes.error || 'Batch created but could not add coat', 'warning'); }
|
||||
else { showToast('Batch created and item added!', 'success'); }
|
||||
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Move batch item between batches
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function moveToBatch(batchItemId, targetBatchId, sourceBatchId, sqft) {
|
||||
const res = await apiPost(URLS.moveToBatch, { batchItemId, targetBatchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to move item', 'danger'); return; }
|
||||
|
||||
// Move DOM element
|
||||
const itemEl = document.getElementById(`bitem-${batchItemId}`);
|
||||
const targetList = document.getElementById(`items-${targetBatchId}`);
|
||||
if (itemEl && targetList) {
|
||||
itemEl.dataset.batchId = targetBatchId;
|
||||
targetList.appendChild(itemEl);
|
||||
document.getElementById(`empty-items-${targetBatchId}`)?.remove();
|
||||
}
|
||||
|
||||
// Update capacity displays
|
||||
const sourceBatchCard = document.getElementById(`batch-${sourceBatchId}`);
|
||||
const maxSource = +(sourceBatchCard?.dataset.maxSqft || 0);
|
||||
updateBatchCapacity(sourceBatchId, res.sourceBatchTotal,
|
||||
maxSource > 0 ? Math.round(res.sourceBatchTotal / maxSource * 1000) / 10 : null);
|
||||
|
||||
const targetBatchCard = document.getElementById(`batch-${targetBatchId}`);
|
||||
const maxTarget = +(targetBatchCard?.dataset.maxSqft || 0);
|
||||
updateBatchCapacity(targetBatchId, res.targetBatchTotal,
|
||||
maxTarget > 0 ? Math.round(res.targetBatchTotal / maxTarget * 1000) / 10 : null);
|
||||
|
||||
showToast('Item moved', 'success');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Remove coat from batch back to queue (no page reload)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function removeFromBatch(batchItemId, batchId) {
|
||||
const res = await apiPost(URLS.removeFromBatch, { batchItemId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to remove', 'danger'); return; }
|
||||
|
||||
document.getElementById(`bitem-${batchItemId}`)?.remove();
|
||||
|
||||
const batchCard = document.getElementById(`batch-${batchId}`);
|
||||
const maxSqft = +(batchCard?.dataset.maxSqft || 0);
|
||||
const capPct = maxSqft > 0 ? Math.round(res.batchTotal / maxSqft * 1000) / 10 : null;
|
||||
updateBatchCapacity(batchId, res.batchTotal, capPct);
|
||||
|
||||
if (res.queueItem) returnCoatToQueue(res.queueItem);
|
||||
|
||||
showToast('Removed — returned to queue', 'info');
|
||||
}
|
||||
|
||||
function returnCoatToQueue(q) {
|
||||
const coatElId = `qcoat-${q.jobItemCoatId}`;
|
||||
if (document.getElementById(coatElId)) return;
|
||||
|
||||
const borderColors = { Rush: '#dc3545', Urgent: '#fd7e14', High: '#ffc107', Normal: '#0d6efd', Low: '#6c757d' };
|
||||
const border = borderColors[q.priority] || '#6c757d';
|
||||
|
||||
const coatEl = document.createElement('div');
|
||||
coatEl.className = 'batch-item-row d-flex align-items-center';
|
||||
coatEl.id = coatElId;
|
||||
coatEl.dataset.coatId = q.jobItemCoatId;
|
||||
coatEl.dataset.jobId = q.jobId;
|
||||
coatEl.dataset.jobItemId = q.jobItemId;
|
||||
coatEl.dataset.sqft = q.surfaceAreaSqFt;
|
||||
coatEl.dataset.color = q.colorName || '';
|
||||
coatEl.dataset.colorCode = q.colorCode || '';
|
||||
coatEl.dataset.pass = q.coatPassNumber;
|
||||
coatEl.dataset.coatName = q.coatName;
|
||||
coatEl.dataset.jobNumber = q.jobNumber;
|
||||
coatEl.dataset.customer = q.customerName;
|
||||
coatEl.dataset.description = q.itemDescription;
|
||||
coatEl.dataset.priority = q.priority;
|
||||
coatEl.dataset.dueDate = q.dueDate || '';
|
||||
|
||||
const colorDot = q.colorName
|
||||
? `<span class="color-dot" style="background:#aaaaaa;"></span>`
|
||||
: '';
|
||||
|
||||
coatEl.innerHTML = `
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="text-truncate">
|
||||
${colorDot}
|
||||
<span class="fw-medium">${escHtml(q.coatName)}</span>
|
||||
<span class="text-muted ms-1">— ${escHtml(q.itemDescription)}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">
|
||||
Pass ${q.coatPassNumber} · ${(+q.surfaceAreaSqFt).toFixed(1)} sqft
|
||||
${q.colorName ? `· ${escHtml(q.colorName)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<i class="bi bi-grip-vertical text-muted ms-1"></i>
|
||||
`;
|
||||
|
||||
// Wire drag via mousedown
|
||||
coatEl.addEventListener('mousedown', onQueueMouseDown);
|
||||
|
||||
// Find or create the job card in the queue
|
||||
const existingJobCard = document.getElementById(`qjob-${q.jobId}`);
|
||||
if (existingJobCard) {
|
||||
document.getElementById(`coats-job-${q.jobId}`)?.appendChild(coatEl);
|
||||
} else {
|
||||
const priorityBadgeClass = { Rush: 'bg-danger', Urgent: 'bg-warning text-dark', High: 'bg-primary', Normal: 'bg-secondary', Low: 'bg-light text-dark' }[q.priority] || 'bg-secondary';
|
||||
const dueDateHtml = q.dueDate
|
||||
? `<div class="small ${q.isOverdue ? 'text-danger fw-semibold' : 'text-muted'}">
|
||||
<i class="bi bi-calendar-event me-1"></i>Due ${formatDate(q.dueDate)}
|
||||
${q.isOverdue ? '<span class="badge bg-danger ms-1">Overdue</span>' : ''}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const jobCard = document.createElement('div');
|
||||
jobCard.className = 'queue-job-card card border-0 shadow-sm';
|
||||
jobCard.id = `qjob-${q.jobId}`;
|
||||
jobCard.dataset.priority = q.priority;
|
||||
jobCard.style.borderLeft = `4px solid ${border}`;
|
||||
jobCard.innerHTML = `
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="fw-semibold small me-auto">${escHtml(q.jobNumber)}</span>
|
||||
<span class="badge ${priorityBadgeClass} ms-1 small">${escHtml(q.priority)}</span>
|
||||
</div>
|
||||
<div class="text-muted small mb-1">${escHtml(q.customerName)}</div>
|
||||
${dueDateHtml}
|
||||
<div class="mt-2" id="coats-job-${q.jobId}"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const queueContainer = document.getElementById('queueContainer');
|
||||
const cards = [...queueContainer.querySelectorAll('.queue-job-card')];
|
||||
const insertBefore = cards.find(c => +(c.dataset.priorityId || 0) < +q.priorityId);
|
||||
insertBefore ? queueContainer.insertBefore(jobCard, insertBefore) : queueContainer.appendChild(jobCard);
|
||||
|
||||
document.getElementById(`coats-job-${q.jobId}`)?.appendChild(coatEl);
|
||||
}
|
||||
|
||||
updateQueueCount(1);
|
||||
}
|
||||
|
||||
function updateQueueCount(delta) {
|
||||
const badge = document.getElementById('queueCount');
|
||||
if (badge) badge.textContent = Math.max(0, (+badge.textContent || 0) + delta);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Create an empty batch
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function createBatch(ovenCostId, ovenName, date) {
|
||||
const res = await apiPost(URLS.createBatch, { ovenCostId, scheduledDate: date, scheduledStartTime: null });
|
||||
if (!res.success) { showToast(res.error || 'Failed to create batch', 'danger'); return; }
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Batch lifecycle actions
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function startBatch(batchId) {
|
||||
if (!await showConfirm('Start this batch? All linked jobs will be updated to "In Oven" status.', 'Start Batch')) return;
|
||||
const res = await apiPost(URLS.startBatch, { batchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to start batch', 'danger'); return; }
|
||||
showToast('Batch started!', 'success');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function completeBatch(batchId) {
|
||||
if (!await showConfirm('Mark this batch as complete? Job statuses will be updated to Curing or Coating.', 'Complete Batch')) return;
|
||||
const res = await apiPost(URLS.completeBatch, { batchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to complete batch', 'danger'); return; }
|
||||
showToast('Batch completed!', 'success');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function deleteBatch(batchId, batchNumber) {
|
||||
if (!await showConfirm(`Delete batch ${batchNumber}? Items will be returned to the queue.`, 'Delete', true)) return;
|
||||
const res = await apiPost(URLS.deleteBatch, { batchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to delete batch', 'danger'); return; }
|
||||
|
||||
const card = document.getElementById(`batch-${batchId}`);
|
||||
const ovenId = card?.dataset.ovenId;
|
||||
card?.remove();
|
||||
|
||||
// If the oven column is now empty, restore the drop zone hint
|
||||
const zone = document.getElementById(`zone-${ovenId}`);
|
||||
if (zone && !zone.querySelector('.batch-card')) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'drop-zone-empty';
|
||||
hint.id = `empty-${ovenId}`;
|
||||
hint.innerHTML = '<span><i class="bi bi-fire me-1"></i>Drop items here or click + to create a batch</span>';
|
||||
zone.appendChild(hint);
|
||||
}
|
||||
|
||||
showToast('Batch deleted', 'info');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// AI Suggestion Panel
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function openAiPanel() {
|
||||
document.getElementById('aiPanel').classList.add('open');
|
||||
document.getElementById('aiBackdrop').classList.add('open');
|
||||
const goalLabels = {
|
||||
maximize_throughput: 'Maximize Throughput',
|
||||
minimize_lateness: 'Minimize Lateness',
|
||||
minimize_color_changes: 'Minimize Color Changes'
|
||||
};
|
||||
document.getElementById('goalLabel').textContent = goalLabels[OPTIMIZATION_GOAL] || OPTIMIZATION_GOAL;
|
||||
}
|
||||
|
||||
function closeAiPanel() {
|
||||
document.getElementById('aiPanel').classList.remove('open');
|
||||
document.getElementById('aiBackdrop').classList.remove('open');
|
||||
}
|
||||
|
||||
async function runAiSuggest() {
|
||||
showAiState('loading');
|
||||
|
||||
const res = await apiPost(URLS.suggest, { optimizationGoal: OPTIMIZATION_GOAL });
|
||||
|
||||
if (!res.success) {
|
||||
document.getElementById('aiErrorText').textContent = res.error || 'AI error';
|
||||
showAiState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { suggestion } = res;
|
||||
AI_SUGGESTION_DATA.batches = suggestion.batches;
|
||||
|
||||
document.getElementById('aiSummary').textContent = suggestion.summary;
|
||||
|
||||
const warnEl = document.getElementById('aiWarnings');
|
||||
if (suggestion.warnings?.length > 0) {
|
||||
warnEl.innerHTML = suggestion.warnings.map(w =>
|
||||
`<div class="alert alert-warning py-1 px-2 mb-1 small"><i class="bi bi-exclamation-triangle me-1"></i>${escHtml(w)}</div>`
|
||||
).join('');
|
||||
warnEl.classList.remove('d-none');
|
||||
} else {
|
||||
warnEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
const listEl = document.getElementById('aiBatchList');
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (!suggestion.batches?.length) {
|
||||
listEl.innerHTML = '<div class="text-muted text-center py-3 small">No batches suggested. The queue may be empty or all coats are already scheduled.</div>';
|
||||
} else {
|
||||
suggestion.batches.forEach((batch, idx) => {
|
||||
const capPct = batch.capacityUtilization > 0 ? Math.round(batch.capacityUtilization * 100) : null;
|
||||
const capBar = capPct !== null
|
||||
? `<div class="mt-1">
|
||||
<div class="d-flex justify-content-between mb-1" style="font-size:.72rem;">
|
||||
<span class="text-muted">Utilization</span>
|
||||
<span class="fw-semibold">${capPct}%</span>
|
||||
</div>
|
||||
<div class="capacity-bar-wrap">
|
||||
<div class="capacity-bar-fill ${capPct >= 100 ? 'cap-over' : capPct >= 80 ? 'cap-warn' : 'cap-ok'}"
|
||||
style="width:${Math.min(capPct, 100)}%"></div>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const items = batch.items.map(i =>
|
||||
`<div class="d-flex align-items-center gap-1 py-1 border-bottom" style="font-size:.78rem;">
|
||||
<span class="badge bg-secondary" style="font-size:.68rem;">Pass ${i.coatPassNumber}</span>
|
||||
<div class="flex-grow-1 text-truncate">
|
||||
<span class="fw-medium">${escHtml(i.jobNumber)}</span>
|
||||
<span class="text-muted ms-1">${escHtml(i.description)}</span>
|
||||
</div>
|
||||
<span class="text-muted">${i.surfaceAreaSqFt.toFixed(1)} sqft</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
listEl.innerHTML +=
|
||||
`<div class="card mb-3 shadow-sm" id="aibatch-${idx}">
|
||||
<div class="card-header py-2 px-3 d-flex align-items-center gap-2"
|
||||
style="background:linear-gradient(90deg,#6f42c1 0%,#0d6efd 100%);color:white;">
|
||||
<i class="bi bi-fire"></i>
|
||||
<span class="fw-semibold small flex-grow-1">${escHtml(batch.batchName)}</span>
|
||||
<span class="badge bg-white text-dark" style="font-size:.7rem;">${escHtml(batch.ovenName)}</span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
${batch.suggestedStartTime ? `<span class="badge bg-light text-dark border"><i class="bi bi-clock me-1"></i>${batch.suggestedStartTime}</span>` : ''}
|
||||
${batch.primaryColorName ? `<span class="badge bg-secondary"><i class="bi bi-palette me-1"></i>${escHtml(batch.primaryColorName)}</span>` : ''}
|
||||
${batch.cureTemperatureF ? `<span class="badge bg-danger">${batch.cureTemperatureF}°F</span>` : ''}
|
||||
<span class="badge bg-light text-dark border">${batch.estimatedSqFt.toFixed(1)} sqft</span>
|
||||
<span class="badge bg-light text-dark border">${batch.estimatedCycleMinutes} min</span>
|
||||
</div>
|
||||
${capBar}
|
||||
<div class="mt-2">${items}</div>
|
||||
<div class="mt-2 text-muted small">
|
||||
<i class="bi bi-chat-left-text me-1" style="color:#6f42c1;"></i>
|
||||
${escHtml(batch.rationale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
showAiState('results');
|
||||
}
|
||||
|
||||
async function acceptAllBatches() {
|
||||
if (!AI_SUGGESTION_DATA.batches?.length) return;
|
||||
|
||||
document.getElementById('btnAcceptAll').disabled = true;
|
||||
document.getElementById('btnAcceptAll').innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...';
|
||||
|
||||
const res = await apiPost(URLS.acceptSuggestion, {
|
||||
scheduledDate: SCHEDULED_DATE,
|
||||
batches: AI_SUGGESTION_DATA.batches
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
showToast(res.error || 'Failed to save batches', 'danger');
|
||||
document.getElementById('btnAcceptAll').disabled = false;
|
||||
document.getElementById('btnAcceptAll').innerHTML = '<i class="bi bi-check-all me-1"></i>Accept All Batches';
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(`${res.batches?.length || 0} batch(es) created!`, 'success');
|
||||
closeAiPanel();
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
|
||||
function showAiState(state) {
|
||||
['loading', 'error', 'results', 'initial'].forEach(s => {
|
||||
const el = document.getElementById(`ai${s.charAt(0).toUpperCase() + s.slice(1)}`);
|
||||
if (el) {
|
||||
if (s === state) {
|
||||
el.classList.remove('d-none');
|
||||
if (s === 'results') el.classList.add('d-flex', 'flex-column');
|
||||
} else {
|
||||
el.classList.add('d-none');
|
||||
if (s === 'results') el.classList.remove('d-flex', 'flex-column');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// DOM helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function appendBatchItemRow(batchId, item, withRemove) {
|
||||
const list = document.getElementById(`items-${batchId}`);
|
||||
if (!list) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'batch-item-row d-flex align-items-center';
|
||||
el.id = `bitem-${item.id}`;
|
||||
el.dataset.batchItemId = item.id;
|
||||
el.dataset.batchId = batchId;
|
||||
el.dataset.sqft = item.surfaceAreaContribution;
|
||||
el.setAttribute('draggable', 'true'); // kept for CSS selector compat
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="d-flex align-items-center gap-1 text-truncate">
|
||||
<span class="badge bg-light text-dark border" style="font-size:.7rem;">Pass ${item.coatPassNumber}</span>
|
||||
${item.colorName ? `<span class="text-truncate fw-medium small">${escHtml(item.colorName)}</span>` : ''}
|
||||
<span class="text-muted small text-truncate">${escHtml(item.itemDescription)}</span>
|
||||
</div>
|
||||
<div class="text-muted d-flex align-items-center gap-2" style="font-size:.73rem;">
|
||||
<span>${escHtml(item.jobNumber)}</span><span>·</span>
|
||||
<span>${(+item.surfaceAreaContribution).toFixed(1)} sqft</span>
|
||||
</div>
|
||||
</div>
|
||||
${withRemove
|
||||
? `<div class="d-flex align-items-center ms-1 gap-1">
|
||||
<i class="bi bi-grip-vertical text-muted"></i>
|
||||
<button class="btn btn-sm p-0 text-danger" style="line-height:1;"
|
||||
onclick="removeFromBatch(${item.id}, ${batchId})" title="Remove">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>`
|
||||
: '<i class="bi bi-grip-vertical text-muted ms-1"></i>'}
|
||||
`;
|
||||
|
||||
// Wire mouse-based drag
|
||||
el.addEventListener('mousedown', onBatchItemMouseDown);
|
||||
|
||||
list.appendChild(el);
|
||||
}
|
||||
|
||||
function updateBatchCapacity(batchId, totalSqFt, capPct) {
|
||||
document.querySelectorAll(`.batch-sqft[data-batch-id="${batchId}"]`).forEach(el => {
|
||||
const maxSqft = document.getElementById(`batch-${batchId}`)?.dataset.maxSqft;
|
||||
el.innerHTML = maxSqft
|
||||
? `${(+totalSqFt).toFixed(1)} / ${(+maxSqft).toFixed(0)} sqft`
|
||||
: `${(+totalSqFt).toFixed(1)} sqft`;
|
||||
});
|
||||
|
||||
document.querySelectorAll(`.batch-cap-bar[data-batch-id="${batchId}"]`).forEach(bar => {
|
||||
if (capPct !== null) {
|
||||
const pct = Math.min(+capPct, 100);
|
||||
bar.style.width = pct + '%';
|
||||
bar.className = `capacity-bar-fill ${pct >= 100 ? 'cap-over' : pct >= 80 ? 'cap-warn' : 'cap-ok'} batch-cap-bar`;
|
||||
bar.dataset.batchId = batchId;
|
||||
}
|
||||
});
|
||||
|
||||
const card = document.getElementById(`batch-${batchId}`);
|
||||
if (card) card.dataset.totalSqft = totalSqFt;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Utility
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function apiPost(url, data) {
|
||||
try {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||
|| document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'RequestVerificationToken': token } : {})
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function showConfirm(message, confirmLabel = 'Confirm', danger = false) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = `
|
||||
position:fixed; inset:0; z-index:10000;
|
||||
background:rgba(0,0,0,.45);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
`;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = `
|
||||
background:var(--bs-body-bg);
|
||||
border-radius:12px;
|
||||
padding:1.5rem 1.75rem;
|
||||
max-width:360px; width:90%;
|
||||
box-shadow:0 16px 48px rgba(0,0,0,.3);
|
||||
text-align:center;
|
||||
`;
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.style.cssText = 'margin:0 0 1.25rem; font-size:.95rem; line-height:1.5;';
|
||||
msg.textContent = message;
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.cssText = 'display:flex; gap:.75rem; justify-content:center;';
|
||||
|
||||
const btnCancel = document.createElement('button');
|
||||
btnCancel.className = 'btn btn-outline-secondary';
|
||||
btnCancel.textContent = 'Cancel';
|
||||
|
||||
const btnOk = document.createElement('button');
|
||||
btnOk.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
|
||||
btnOk.textContent = confirmLabel;
|
||||
|
||||
btnRow.appendChild(btnCancel);
|
||||
btnRow.appendChild(btnOk);
|
||||
box.appendChild(msg);
|
||||
box.appendChild(btnRow);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = result => { overlay.remove(); resolve(result); };
|
||||
btnOk.addEventListener('click', () => close(true));
|
||||
btnCancel.addEventListener('click', () => close(false));
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const colors = { success: '#198754', danger: '#dc3545', info: '#0dcaf0', warning: '#ffc107' };
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999;
|
||||
background:${colors[type] || '#333'}; color:white;
|
||||
padding:.65rem 1.1rem; border-radius:8px;
|
||||
font-size:.88rem; box-shadow:0 4px 16px rgba(0,0,0,.2);
|
||||
transition:opacity .3s; max-width:320px;
|
||||
`;
|
||||
if (type === 'warning') toast.style.color = '#212529';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2800);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* purchase-orders.js
|
||||
* Handles dynamic line-item management for Purchase Order Create/Edit forms.
|
||||
* A single searchable text input per row: type to filter inventory items or
|
||||
* enter a free-text description. Selecting an inventory item stores its ID in
|
||||
* a hidden field; typing something custom leaves the hidden field empty.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
let inventoryData = [];
|
||||
let itemIndex = 0;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dataEl = document.getElementById('inventoryItemsData');
|
||||
if (dataEl) {
|
||||
try { inventoryData = JSON.parse(dataEl.textContent); } catch (e) { inventoryData = []; }
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('lineItemsBody');
|
||||
if (tbody) {
|
||||
itemIndex = tbody.querySelectorAll('tr[data-index]').length;
|
||||
}
|
||||
|
||||
updateTotals();
|
||||
});
|
||||
|
||||
// ─── Add / Remove rows ────────────────────────────────────────────────────────
|
||||
|
||||
function addItem() {
|
||||
const tbody = document.getElementById('lineItemsBody');
|
||||
if (!tbody) return;
|
||||
|
||||
const i = itemIndex++;
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-index', i);
|
||||
tr.innerHTML = buildRowHtml(i, {});
|
||||
tbody.appendChild(tr);
|
||||
toggleEmptyMessage();
|
||||
updateTotals();
|
||||
|
||||
tr.querySelector('.item-search-input')?.focus();
|
||||
}
|
||||
|
||||
function removeItem(btn) {
|
||||
const tr = btn.closest('tr');
|
||||
if (tr) {
|
||||
tr.remove();
|
||||
reindexRows();
|
||||
updateTotals();
|
||||
toggleEmptyMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function reindexRows() {
|
||||
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
|
||||
rows.forEach((tr, newIndex) => {
|
||||
tr.setAttribute('data-index', newIndex);
|
||||
tr.querySelectorAll('[name]').forEach(el => {
|
||||
el.name = el.name.replace(/Items\[\d+\]/, `Items[${newIndex}]`);
|
||||
});
|
||||
const searchInput = tr.querySelector('.item-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.setAttribute('data-row-index', newIndex);
|
||||
searchInput.setAttribute('oninput', `onItemSearch(this, ${newIndex})`);
|
||||
}
|
||||
});
|
||||
itemIndex = rows.length;
|
||||
}
|
||||
|
||||
// ─── Row HTML builder ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* opts: { type: 'inventory'|'custom', selectedId, description, qty, cost, notes }
|
||||
*/
|
||||
function buildRowHtml(i, opts = {}) {
|
||||
const isInventory = opts.type === 'inventory' && opts.selectedId;
|
||||
const qty = opts.qty ?? 1;
|
||||
const cost = opts.cost ?? 0;
|
||||
const notes = opts.notes ?? '';
|
||||
const lineTotal = (qty * cost).toFixed(2);
|
||||
|
||||
let displayText = '';
|
||||
if (isInventory) {
|
||||
const inv = inventoryData.find(x => String(x.value) === String(opts.selectedId));
|
||||
displayText = inv ? inv.text : (opts.description || '');
|
||||
} else {
|
||||
displayText = opts.description || '';
|
||||
}
|
||||
|
||||
return `
|
||||
<td style="min-width:240px">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm item-search-input"
|
||||
name="Items[${i}].Description"
|
||||
value="${escHtml(displayText)}"
|
||||
placeholder="Search inventory or enter description…"
|
||||
autocomplete="off"
|
||||
oninput="onItemSearch(this, ${i})"
|
||||
onblur="hideAutocomplete()"
|
||||
data-row-index="${i}" />
|
||||
<input type="hidden"
|
||||
name="Items[${i}].InventoryItemId"
|
||||
class="inventory-id-field"
|
||||
value="${isInventory ? (opts.selectedId || '') : ''}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" name="Items[${i}].QuantityOrdered"
|
||||
class="form-control form-control-sm"
|
||||
value="${qty}" min="0.001" step="0.001" style="width:85px"
|
||||
oninput="updateLineTotals()" required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" name="Items[${i}].UnitCost"
|
||||
class="form-control form-control-sm"
|
||||
value="${cost}" min="0" step="0.01" style="width:105px"
|
||||
oninput="updateLineTotals()" />
|
||||
</td>
|
||||
<td class="item-line-total text-end fw-semibold align-middle">$${lineTotal}</td>
|
||||
<td>
|
||||
<input type="text" name="Items[${i}].Notes"
|
||||
class="form-control form-control-sm"
|
||||
value="${escHtml(notes)}" placeholder="Optional" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItem(this)" title="Remove">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
// ─── Autocomplete ─────────────────────────────────────────────────────────────
|
||||
// Uses a single body-level dropdown so it's never clipped by table overflow.
|
||||
|
||||
let _activeSearchInput = null;
|
||||
|
||||
function getAutocompleteDropdown() {
|
||||
let el = document.getElementById('po-autocomplete');
|
||||
if (!el) {
|
||||
el = document.createElement('ul');
|
||||
el.id = 'po-autocomplete';
|
||||
el.className = 'list-group shadow';
|
||||
el.style.cssText = 'position:fixed;z-index:9999;max-height:220px;overflow-y:auto;'
|
||||
+ 'margin:0;padding:0;border-radius:.35rem;min-width:180px;display:none;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function onItemSearch(input, rowIndex) {
|
||||
_activeSearchInput = input;
|
||||
|
||||
// Clear previously selected inventory item when user edits the field
|
||||
const tr = input.closest('tr');
|
||||
const idField = tr?.querySelector('.inventory-id-field');
|
||||
if (idField) idField.value = '';
|
||||
|
||||
const query = input.value.trim().toLowerCase();
|
||||
const dropdown = getAutocompleteDropdown();
|
||||
|
||||
if (!query) {
|
||||
dropdown.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = inventoryData
|
||||
.filter(item => item.text.toLowerCase().includes(query))
|
||||
.slice(0, 12);
|
||||
|
||||
if (matches.length === 0) {
|
||||
dropdown.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = matches.map(item => `
|
||||
<li class="list-group-item list-group-item-action py-1 px-2 small"
|
||||
style="cursor:pointer"
|
||||
data-value="${item.value}"
|
||||
data-cost="${item.cost}"
|
||||
data-text="${escHtml(item.text)}"
|
||||
onmousedown="selectInventoryItem(this, ${rowIndex})">
|
||||
${escHtml(item.text)}
|
||||
</li>`).join('');
|
||||
|
||||
// Position below the input
|
||||
const rect = input.getBoundingClientRect();
|
||||
dropdown.style.top = (rect.bottom + 2) + 'px';
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.width = rect.width + 'px';
|
||||
dropdown.style.display = '';
|
||||
}
|
||||
|
||||
function selectInventoryItem(li, rowIndex) {
|
||||
const tr = document.querySelector(`#lineItemsBody tr[data-index="${rowIndex}"]`);
|
||||
if (!tr) return;
|
||||
|
||||
const searchInput = tr.querySelector('.item-search-input');
|
||||
const idField = tr.querySelector('.inventory-id-field');
|
||||
const costInput = tr.querySelector(`[name="Items[${rowIndex}].UnitCost"]`);
|
||||
|
||||
if (searchInput) searchInput.value = li.dataset.text;
|
||||
if (idField) idField.value = li.dataset.value;
|
||||
|
||||
if (costInput) {
|
||||
const cost = parseFloat(li.dataset.cost);
|
||||
if (cost > 0) costInput.value = cost.toFixed(2);
|
||||
}
|
||||
|
||||
getAutocompleteDropdown().style.display = 'none';
|
||||
_activeSearchInput = null;
|
||||
|
||||
updateLineTotals();
|
||||
}
|
||||
|
||||
function hideAutocomplete() {
|
||||
// Delay so mousedown on a list item fires before blur hides the dropdown
|
||||
setTimeout(() => {
|
||||
getAutocompleteDropdown().style.display = 'none';
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Reposition on scroll/resize so the dropdown tracks the input
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!_activeSearchInput) return;
|
||||
const rect = _activeSearchInput.getBoundingClientRect();
|
||||
const dd = document.getElementById('po-autocomplete');
|
||||
if (dd && dd.style.display !== 'none') {
|
||||
dd.style.top = (rect.bottom + 2) + 'px';
|
||||
dd.style.left = rect.left + 'px';
|
||||
}
|
||||
}, true);
|
||||
|
||||
// ─── Totals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function updateLineTotals() {
|
||||
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
|
||||
let subTotal = 0;
|
||||
|
||||
rows.forEach(tr => {
|
||||
const i = tr.getAttribute('data-index');
|
||||
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
|
||||
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
|
||||
const lineTotal = qty * cost;
|
||||
subTotal += lineTotal;
|
||||
|
||||
const cell = tr.querySelector('.item-line-total');
|
||||
if (cell) cell.textContent = '$' + lineTotal.toFixed(2);
|
||||
});
|
||||
|
||||
updateTotals(subTotal);
|
||||
}
|
||||
|
||||
function updateTotals(subTotal) {
|
||||
if (subTotal === undefined) {
|
||||
subTotal = 0;
|
||||
document.querySelectorAll('#lineItemsBody tr[data-index]').forEach(tr => {
|
||||
const i = tr.getAttribute('data-index');
|
||||
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
|
||||
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
|
||||
subTotal += qty * cost;
|
||||
});
|
||||
}
|
||||
|
||||
const shipping = parseFloat(document.getElementById('shippingCostInput')?.value) || 0;
|
||||
const grandTotal = subTotal + shipping;
|
||||
|
||||
setText('subTotalDisplay', '$' + subTotal.toFixed(2));
|
||||
setText('grandTotalDisplay', '$' + grandTotal.toFixed(2));
|
||||
}
|
||||
|
||||
function setText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function toggleEmptyMessage() {
|
||||
const tbody = document.getElementById('lineItemsBody');
|
||||
const msg = document.getElementById('emptyItemsMessage');
|
||||
const header = document.getElementById('lineItemsHeader');
|
||||
if (!tbody || !msg) return;
|
||||
const hasRows = tbody.querySelectorAll('tr[data-index]').length > 0;
|
||||
msg.style.display = hasRows ? 'none' : 'block';
|
||||
if (header) header.style.display = hasRows ? '' : 'none';
|
||||
}
|
||||
|
||||
// ─── Receive form helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function receiveAll() {
|
||||
document.querySelectorAll('.receive-qty-input').forEach(input => {
|
||||
input.value = parseFloat(input.dataset.remaining) || 0;
|
||||
});
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
document.querySelectorAll('.receive-qty-input').forEach(input => {
|
||||
input.value = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* QuickBooks Migration Wizard — qb-migration-wizard.js
|
||||
* Embedded in Setup Wizard Step 2.
|
||||
* Manages a 9-step modal that walks the user through all QB Desktop data imports.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Step Definitions ──────────────────────────────────────────────────────
|
||||
const STEPS = [
|
||||
{
|
||||
id: 1, key: 'chartOfAccounts', title: 'Chart of Accounts',
|
||||
icon: 'bi-diagram-3', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportChartOfAccounts',
|
||||
deps: [],
|
||||
intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so vendor bills and other financial data can link to the correct accounts.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Lists → Chart of Accounts</strong>.',
|
||||
'Click the <strong>Account</strong> button at the bottom of the list.',
|
||||
'Choose <strong>Import/Export → Export Chart of Accounts</strong> (or use <em>File → Utilities → Export → Lists to IIF Files → Chart of Accounts</em>).',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2, key: 'customers', title: 'Customers',
|
||||
icon: 'bi-people', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportCustomers',
|
||||
deps: [],
|
||||
intro: 'Import your customer list so that historical invoices and payments can be matched to existing customer records.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
|
||||
'Check <strong>Customer List</strong> and click OK.',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3, key: 'vendors', title: 'Vendors',
|
||||
icon: 'bi-truck', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportVendors',
|
||||
deps: [],
|
||||
intro: 'Import your vendor list. Vendors are required before importing inventory stock levels and vendor bills.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
|
||||
'Check <strong>Vendor List</strong> and click OK.',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4, key: 'catalogItems', title: 'Catalog Items',
|
||||
icon: 'bi-grid', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportCatalogItems',
|
||||
deps: [],
|
||||
intro: 'Import service items from QuickBooks to populate your catalog with pre-priced services.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
|
||||
'Check <strong>Item List</strong> and click OK.',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5, key: 'inventory', title: 'Inventory',
|
||||
icon: 'bi-boxes', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbInventoryValuation',
|
||||
deps: [3],
|
||||
intro: 'Import current stock levels from an Inventory Valuation Summary report. Vendors must be imported first for preferred vendor matching.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Inventory → Inventory Valuation Summary</strong>.',
|
||||
'Click <strong>Customize Report</strong>, open the <strong>Display</strong> tab, and add the <strong>Preferred Vendor</strong> column.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6, key: 'invoices', title: 'Invoices',
|
||||
icon: 'bi-receipt', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbInvoices',
|
||||
deps: [2, 4],
|
||||
intro: 'Import historical invoices from a Customer Balance Detail report. Customers and catalog items should be imported first.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Customers & Receivables → Customer Balance Detail</strong>.',
|
||||
'Set the date range to <strong>All</strong> to cover your full history.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7, key: 'transactions', title: 'Customer Payments',
|
||||
icon: 'bi-cash-coin', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbTransactions',
|
||||
deps: [6],
|
||||
intro: 'Import payment records to update invoice balances. Invoices must be imported first.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Customers & Receivables → Transaction List by Customer</strong>.',
|
||||
'Set the date range to <strong>All</strong> to cover your full history.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8, key: 'bills', title: 'Vendor Bills & Payments',
|
||||
icon: 'bi-file-earmark-text', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbBillsAndPayments',
|
||||
deps: [1, 3],
|
||||
intro: 'Import vendor bills and payment history in a single step. The same Vendor Balance Detail file contains both — bills are imported first, then payments are matched against them.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Vendors & Payables → Vendor Balance Detail</strong>.',
|
||||
'Set the date range to <strong>All</strong> to cover your full history.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here — bills and payments will both be processed automatically.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ── QuickBooks Online Step Definitions ────────────────────────────────────
|
||||
const QBO_STEPS = [
|
||||
{
|
||||
id: 1, key: 'qbo_coa', title: 'Chart of Accounts',
|
||||
icon: 'bi-diagram-3', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboChartOfAccounts',
|
||||
deps: [],
|
||||
intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so financial data can link to the correct accounts.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Accounting → Chart of Accounts</strong>.',
|
||||
'Click the <strong>Run Report</strong> button at the top right.',
|
||||
'Click the <strong>Export</strong> icon (spreadsheet icon) and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2, key: 'qbo_customers', title: 'Customers',
|
||||
icon: 'bi-people', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboCustomers',
|
||||
deps: [],
|
||||
intro: 'Import your customer list so invoices and payments can be matched to existing customer records.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Customer Contact List</strong>.',
|
||||
'Click <strong>Customize</strong> if you want to include additional fields (address, terms, balance).',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3, key: 'qbo_vendors', title: 'Vendors',
|
||||
icon: 'bi-truck', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboVendors',
|
||||
deps: [],
|
||||
intro: 'Import your vendor list before importing purchase orders or inventory.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Vendor Contact List</strong>.',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4, key: 'qbo_products', title: 'Products & Services',
|
||||
icon: 'bi-grid', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboCatalogItems',
|
||||
deps: [],
|
||||
intro: 'Import your products and services. Inventory-type items will create inventory records; all others become catalog items.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Sales → Products and Services</strong>.',
|
||||
'Click the <strong>More</strong> (⋮) button at the top right.',
|
||||
'Choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5, key: 'qbo_invoices', title: 'Invoices',
|
||||
icon: 'bi-receipt', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboInvoices',
|
||||
deps: [2],
|
||||
intro: 'Import historical invoices. Customers should be imported first for matching.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Invoice List</strong>.',
|
||||
'Set the date range to cover all of your history.',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6, key: 'qbo_transactions', title: 'Payments',
|
||||
icon: 'bi-cash-stack', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboTransactions',
|
||||
deps: [5],
|
||||
intro: 'Import payment history to mark invoices as paid. Invoices must be imported first.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Transaction List by Date</strong>.',
|
||||
'Set the date range to cover all of your history.',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ── Active step set (Desktop or Online) ───────────────────────────────────
|
||||
let activeSteps = STEPS; // default to Desktop
|
||||
let activeSource = 'desktop';
|
||||
|
||||
// Statuses: pending | complete | skipped | error | blocked
|
||||
let currentStepIdx = 0; // index into activeSteps
|
||||
let stepState = {}; // key -> { status, result }
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
// Default all steps to pending
|
||||
activeSteps.forEach(s => {
|
||||
stepState[s.key] = stepState[s.key] || { status: 'pending', result: null };
|
||||
});
|
||||
refreshBlocked();
|
||||
}
|
||||
|
||||
function refreshBlocked() {
|
||||
activeSteps.forEach(s => {
|
||||
if (s.deps.length === 0) return;
|
||||
const current = stepState[s.key].status;
|
||||
if (current === 'complete' || current === 'skipped') return;
|
||||
const anyDepUnresolved = s.deps.some(depId => {
|
||||
const depStep = activeSteps.find(x => x.id === depId);
|
||||
const depStatus = depStep ? stepState[depStep.key].status : 'pending';
|
||||
return depStatus === 'pending' || depStatus === 'blocked' || depStatus === 'error';
|
||||
});
|
||||
stepState[s.key].status = anyDepUnresolved ? 'blocked' : 'pending';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Open Modal ────────────────────────────────────────────────────────────
|
||||
window.openQbWizard = async function (source) {
|
||||
activeSteps = (source === 'online') ? QBO_STEPS : STEPS;
|
||||
activeSource = source;
|
||||
stepState = {}; // reset state when switching source
|
||||
init();
|
||||
// Load persisted state from server
|
||||
try {
|
||||
const resp = await fetch('/SetupWizard/GetQbMigrationState');
|
||||
const data = await resp.json();
|
||||
if (data.state) {
|
||||
const saved = JSON.parse(data.state);
|
||||
// Only restore state if same source
|
||||
if (saved && saved.source === source && saved.steps) {
|
||||
Object.assign(stepState, saved.steps);
|
||||
}
|
||||
// Resume at first incomplete step
|
||||
const firstIncomplete = activeSteps.findIndex(s => {
|
||||
const st = stepState[s.key]?.status;
|
||||
return st !== 'complete' && st !== 'skipped';
|
||||
});
|
||||
currentStepIdx = firstIncomplete >= 0 ? firstIncomplete : activeSteps.length - 1;
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal — start from beginning
|
||||
}
|
||||
refreshBlocked();
|
||||
// Update modal title to reflect source
|
||||
var titleEl = document.getElementById('qbMigrationWizardLabel');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = source === 'online'
|
||||
? 'QuickBooks Online Migration Wizard'
|
||||
: 'QuickBooks Desktop Migration Wizard';
|
||||
}
|
||||
renderWizard();
|
||||
const modal = new bootstrap.Modal(document.getElementById('qbMigrationWizard'));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
function renderWizard() {
|
||||
renderStepIndicator();
|
||||
renderProgressBar();
|
||||
renderStepContent();
|
||||
renderFooterButtons();
|
||||
}
|
||||
|
||||
function renderStepIndicator() {
|
||||
const container = document.getElementById('qbwStepIndicator');
|
||||
if (!container) return;
|
||||
container.innerHTML = activeSteps.map((s, idx) => {
|
||||
const st = stepState[s.key]?.status || 'pending';
|
||||
const isActive = idx === currentStepIdx;
|
||||
let statusClass = isActive ? 'active' : st;
|
||||
let icon = '';
|
||||
if (st === 'complete') icon = '<i class="bi bi-check-lg"></i>';
|
||||
else if (st === 'skipped') icon = '<i class="bi bi-skip-forward-fill"></i>';
|
||||
else if (st === 'error') icon = '<i class="bi bi-exclamation-triangle-fill"></i>';
|
||||
else if (st === 'blocked') icon = '<i class="bi bi-lock-fill"></i>';
|
||||
else icon = s.id;
|
||||
return `<div class="qbw-dot ${statusClass}" onclick="qbwGoTo(${idx})" title="${s.title}">
|
||||
<div class="qbw-dot-circle">${icon}</div>
|
||||
<div class="qbw-dot-label">${s.title}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Update badge
|
||||
const badge = document.getElementById('qbwStepBadge');
|
||||
if (badge) badge.textContent = `Step ${currentStepIdx + 1} of ${activeSteps.length}`;
|
||||
}
|
||||
|
||||
function renderProgressBar() {
|
||||
const completed = Object.values(stepState).filter(s => s.status === 'complete' || s.status === 'skipped').length;
|
||||
const pct = Math.round((completed / activeSteps.length) * 100);
|
||||
const bar = document.getElementById('qbwProgressBar');
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
}
|
||||
|
||||
function renderStepContent() {
|
||||
const container = document.getElementById('qbwStepContent');
|
||||
if (!container) return;
|
||||
const step = activeSteps[currentStepIdx];
|
||||
const st = stepState[step.key];
|
||||
container.innerHTML = buildStepHtml(step, st);
|
||||
}
|
||||
|
||||
function buildStepHtml(step, st) {
|
||||
const isBlocked = st.status === 'blocked';
|
||||
const blockedNames = step.deps.map(depId => {
|
||||
const depStep = activeSteps.find(x => x.id === depId);
|
||||
return depStep ? depStep.title : '';
|
||||
}).filter(Boolean).join(', ');
|
||||
|
||||
let resultHtml = '';
|
||||
if (st.result) {
|
||||
resultHtml = buildResultHtml(st.result, st.status);
|
||||
}
|
||||
|
||||
const instructionItems = step.instructions.map(i => `<li class="mb-1">${i}</li>`).join('');
|
||||
|
||||
return `
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-start gap-3 mb-3">
|
||||
<div class="rounded-3 p-3 d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="background:var(--bs-primary-bg-subtle); color:var(--bs-primary); width:60px; height:60px;">
|
||||
<i class="bi ${step.icon} fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1">${step.title}</h5>
|
||||
<p class="text-secondary mb-0">${step.intro}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${isBlocked ? `
|
||||
<div class="alert alert-warning d-flex gap-2 mb-3">
|
||||
<i class="bi bi-lock-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Step locked.</strong>
|
||||
This step requires the following to be completed first:
|
||||
<strong>${blockedNames}</strong>.
|
||||
Go back and complete those steps, then return here.
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="accordion mb-3" id="qbwInstructions-${step.id}">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button ${st.status === 'complete' ? 'collapsed' : ''}" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#qbwInstructionsBody-${step.id}"
|
||||
aria-expanded="${st.status === 'complete' ? 'false' : 'true'}">
|
||||
<i class="bi bi-info-circle me-2"></i>How to export from QuickBooks Desktop
|
||||
</button>
|
||||
</h2>
|
||||
<div id="qbwInstructionsBody-${step.id}" class="accordion-collapse collapse ${st.status === 'complete' ? '' : 'show'}">
|
||||
<div class="accordion-body pb-2">
|
||||
<ol class="mb-0">${instructionItems}</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${!isBlocked ? `
|
||||
<div class="qbw-upload-zone mb-3">
|
||||
<label class="form-label fw-medium mb-2" for="qbwFile-${step.id}">
|
||||
<i class="bi bi-cloud-upload me-1"></i>Select file to import
|
||||
<span class="text-secondary fw-normal">(${step.fileAccept})</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" id="qbwFile-${step.id}" accept="${step.fileAccept}">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="qbwImportBtn-${step.id}"
|
||||
onclick="qbwRunImport(${currentStepIdx})"
|
||||
${isBlocked ? 'disabled' : ''}>
|
||||
<i class="bi bi-cloud-upload me-1"></i>Import Now
|
||||
</button>
|
||||
<div id="qbwImportSpinner-${step.id}" class="d-none d-inline-flex align-items-center gap-2 ms-3 text-secondary">
|
||||
<span class="spinner-border spinner-border-sm"></span> Importing…
|
||||
</div>` : ''}
|
||||
|
||||
${resultHtml ? `<div class="mt-3 qbw-result-box">${resultHtml}</div>` : ''}
|
||||
|
||||
<div id="qbwResultContainer-${step.id}" class="mt-3 qbw-result-box"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildResultHtml(result, status) {
|
||||
const alertClass = status === 'error' ? 'alert-danger' : status === 'complete' ? 'alert-success' : 'alert-warning';
|
||||
const icon = status === 'error' ? 'bi-x-circle-fill' : status === 'complete' ? 'bi-check-circle-fill' : 'bi-exclamation-triangle-fill';
|
||||
let html = `<div class="alert ${alertClass} mb-0">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi ${icon}"></i>
|
||||
<strong>${status === 'complete' ? 'Import successful' : status === 'error' ? 'Import failed' : 'Import completed with issues'}</strong>
|
||||
</div>
|
||||
<div class="row g-2 text-center mb-2">
|
||||
${(result.billsImported != null || result.paymentsImported != null) ? `
|
||||
<div class="col-auto"><span class="badge bg-success"><i class="bi bi-file-earmark-text me-1"></i>${result.billsImported ?? 0} Bills Imported</span></div>
|
||||
<div class="col-auto"><span class="badge bg-info"><i class="bi bi-credit-card me-1"></i>${result.paymentsImported ?? 0} Payments Applied</span></div>
|
||||
` : `
|
||||
<div class="col-auto"><span class="badge bg-secondary">${result.totalRecords ?? 0} Total</span></div>
|
||||
<div class="col-auto"><span class="badge bg-success">${result.importedCount ?? 0} Imported</span></div>
|
||||
<div class="col-auto"><span class="badge bg-info">${result.updatedCount ?? 0} Updated</span></div>
|
||||
`}
|
||||
${(result.skippedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.skippedCount} Skipped</span></div>` : ''}
|
||||
${(result.alreadyRecordedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.alreadyRecordedCount} Already Recorded</span></div>` : ''}
|
||||
<div class="col-auto"><span class="badge bg-danger">${result.errorCount ?? 0} Errors</span></div>
|
||||
</div>`;
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
html += `<details><summary class="small fw-medium">${result.errors.length} error message(s) — click to expand</summary>
|
||||
<ul class="small mt-1 mb-0">
|
||||
${result.errors.slice(0, 20).map(e => `<li>${escHtml(e)}</li>`).join('')}
|
||||
${result.errors.length > 20 ? `<li>… and ${result.errors.length - 20} more</li>` : ''}
|
||||
</ul></details>`;
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
html += `<details><summary class="small fw-medium">${result.warnings.length} warning(s) — click to expand</summary>
|
||||
<ul class="small mt-1 mb-0">
|
||||
${result.warnings.slice(0, 10).map(w => `<li>${escHtml(w)}</li>`).join('')}
|
||||
</ul></details>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderFooterButtons() {
|
||||
const btnBack = document.getElementById('qbwBtnBack');
|
||||
const btnSkip = document.getElementById('qbwBtnSkip');
|
||||
const btnNext = document.getElementById('qbwBtnNext');
|
||||
const btnFinish = document.getElementById('qbwBtnFinish');
|
||||
if (!btnBack) return;
|
||||
|
||||
const isLast = currentStepIdx === activeSteps.length - 1;
|
||||
const st = stepState[activeSteps[currentStepIdx].key];
|
||||
|
||||
btnBack.classList.toggle('d-none', currentStepIdx === 0);
|
||||
btnSkip.classList.toggle('d-none', isLast || st.status === 'complete');
|
||||
btnNext.classList.toggle('d-none', isLast);
|
||||
btnFinish.classList.toggle('d-none', !isLast);
|
||||
|
||||
// Disable Next if current step is still pending (not complete/skipped)
|
||||
const canAdvance = st.status === 'complete' || st.status === 'skipped' || st.status === 'blocked';
|
||||
btnNext.disabled = !canAdvance;
|
||||
if (isLast) {
|
||||
btnFinish.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
window.qbwGoTo = function (idx) {
|
||||
if (idx < 0 || idx >= activeSteps.length) return;
|
||||
currentStepIdx = idx;
|
||||
renderWizard();
|
||||
};
|
||||
|
||||
window.qbwBack = function () {
|
||||
if (currentStepIdx > 0) {
|
||||
currentStepIdx--;
|
||||
renderWizard();
|
||||
}
|
||||
};
|
||||
|
||||
window.qbwNext = function () {
|
||||
if (currentStepIdx < activeSteps.length - 1) {
|
||||
currentStepIdx++;
|
||||
renderWizard();
|
||||
}
|
||||
};
|
||||
|
||||
window.qbwSkip = function () {
|
||||
const step = activeSteps[currentStepIdx];
|
||||
stepState[step.key].status = 'skipped';
|
||||
refreshBlocked();
|
||||
saveState();
|
||||
window.qbwNext();
|
||||
};
|
||||
|
||||
window.qbwFinish = function () {
|
||||
saveState();
|
||||
// Show completion message on Step 2 page if present
|
||||
const completionMsg = document.getElementById('qbWizardCompletionAlert');
|
||||
if (completionMsg) completionMsg.classList.remove('d-none');
|
||||
};
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────
|
||||
window.qbwRunImport = async function (stepIdx) {
|
||||
const step = activeSteps[stepIdx];
|
||||
const fileInput = document.getElementById(`qbwFile-${step.id}`);
|
||||
const importBtn = document.getElementById(`qbwImportBtn-${step.id}`);
|
||||
const spinner = document.getElementById(`qbwImportSpinner-${step.id}`);
|
||||
const resultContainer = document.getElementById(`qbwResultContainer-${step.id}`);
|
||||
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
showToast('Please select a file to import.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
importBtn.disabled = true;
|
||||
spinner?.classList.remove('d-none');
|
||||
resultContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
// Include anti-forgery token if present on page
|
||||
const aft = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
if (aft) formData.append('__RequestVerificationToken', aft.value);
|
||||
|
||||
const resp = await fetch(step.endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Server returned ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const result = data.result || data;
|
||||
|
||||
// Errors array contains ImportErrorDto objects — split into hard errors vs warnings by severity
|
||||
const allErrors = result.errors ?? result.Errors ?? [];
|
||||
const toStr = e => (typeof e === 'string') ? e : (e.displayMessage ?? e.errorMessage ?? JSON.stringify(e));
|
||||
const hardErrors = allErrors.filter(e => !e.severity || e.severity === 'Error');
|
||||
const warnings = allErrors.filter(e => e.severity === 'Warning' || e.severity === 'Skipped');
|
||||
|
||||
const normalised = {
|
||||
totalRecords: result.totalRecords ?? result.TotalRecords ?? 0,
|
||||
importedCount: result.importedCount ?? result.ImportedCount ?? 0,
|
||||
updatedCount: result.updatedCount ?? result.UpdatedCount ?? 0,
|
||||
skippedCount: result.skippedCount ?? result.SkippedCount ?? 0,
|
||||
alreadyRecordedCount: result.alreadyRecordedCount ?? result.AlreadyRecordedCount ?? 0,
|
||||
billsImported: result.billsImported ?? result.BillsImported ?? null,
|
||||
paymentsImported: result.paymentsImported ?? result.PaymentsImported ?? null,
|
||||
errorCount: hardErrors.length,
|
||||
errors: hardErrors.map(toStr),
|
||||
warnings: warnings.map(toStr)
|
||||
};
|
||||
|
||||
// Hard failure = server returned success:false AND nothing was imported at all
|
||||
const hasHardError = result.success === false
|
||||
&& normalised.importedCount === 0
|
||||
&& normalised.updatedCount === 0;
|
||||
const tooManyErrors = false; // covered by hasHardError logic above
|
||||
|
||||
if (hasHardError || tooManyErrors) {
|
||||
stepState[step.key] = { status: 'error', result: normalised };
|
||||
// Prompt: continue anyway?
|
||||
const proceed = confirm(
|
||||
`Import completed with ${normalised.errorCount} error(s) out of ${normalised.totalRecords} records.\n\n` +
|
||||
'Do you want to mark this step as done and continue anyway?\n' +
|
||||
'Click OK to continue, or Cancel to stay on this step and review the errors.'
|
||||
);
|
||||
if (proceed) {
|
||||
stepState[step.key].status = 'complete';
|
||||
}
|
||||
} else if (normalised.errorCount > 0) {
|
||||
// Partial errors — still counts as complete but show warning
|
||||
stepState[step.key] = { status: 'complete', result: normalised };
|
||||
showToast(`${step.title} imported with ${normalised.errorCount} errors. Review the details below.`, 'warning');
|
||||
} else {
|
||||
stepState[step.key] = { status: 'complete', result: normalised };
|
||||
showToast(`${step.title} imported successfully!`, 'success');
|
||||
}
|
||||
|
||||
refreshBlocked();
|
||||
saveState();
|
||||
renderWizard();
|
||||
|
||||
} catch (err) {
|
||||
const errResult = { totalRecords: 0, importedCount: 0, updatedCount: 0, skippedCount: 0, errorCount: 1, errors: [err.message], warnings: [] };
|
||||
stepState[step.key] = { status: 'error', result: errResult };
|
||||
refreshBlocked();
|
||||
saveState();
|
||||
renderWizard();
|
||||
showToast('Import failed: ' + err.message, 'danger');
|
||||
} finally {
|
||||
// Re-enable button (in case re-render didn't)
|
||||
const btn2 = document.getElementById(`qbwImportBtn-${step.id}`);
|
||||
if (btn2) btn2.disabled = false;
|
||||
const sp2 = document.getElementById(`qbwImportSpinner-${step.id}`);
|
||||
if (sp2) sp2.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
async function saveState() {
|
||||
try {
|
||||
const payload = JSON.stringify({ source: activeSource, steps: stepState });
|
||||
await fetch('/SetupWizard/SaveQbMigrationState', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: payload })
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toast Helper ──────────────────────────────────────────────────────────
|
||||
function showToast(message, type) {
|
||||
// Use existing site toast infrastructure if available
|
||||
if (typeof window.showNotification === 'function') {
|
||||
window.showNotification(message, type);
|
||||
return;
|
||||
}
|
||||
// Fallback: simple Bootstrap toast
|
||||
const container = document.getElementById('toastContainer') || createToastContainer();
|
||||
const id = 'qbwToast-' + Date.now();
|
||||
const bgClass = type === 'success' ? 'bg-success' : type === 'warning' ? 'bg-warning' : type === 'danger' ? 'bg-danger' : 'bg-primary';
|
||||
const textClass = type === 'warning' ? 'text-dark' : 'text-white';
|
||||
const html = `<div id="${id}" class="toast align-items-center ${bgClass} ${textClass} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${escHtml(message)}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
const el = document.getElementById(id);
|
||||
if (el) new bootstrap.Toast(el, { delay: 5000 }).show();
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'toastContainer';
|
||||
div.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||
div.style.zIndex = '9999';
|
||||
document.body.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,420 @@
|
||||
// Randomizer Wheel Easter Egg
|
||||
// A fun decision-making tool hidden in the Tools page
|
||||
|
||||
let wheel = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
spinning: false,
|
||||
rotation: 0,
|
||||
targetRotation: 0,
|
||||
spinSpeed: 0,
|
||||
spinStartTime: 0,
|
||||
options: [],
|
||||
colors: [],
|
||||
currentPreset: 'decisions'
|
||||
};
|
||||
|
||||
// Preset configurations
|
||||
const presets = {
|
||||
decisions: {
|
||||
name: 'Decisions',
|
||||
options: ['Yes!', 'No Way', 'Maybe', 'Ask Again', 'Definitely', 'Not Now', 'Go For It!', 'Wait'],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
|
||||
},
|
||||
lunch: {
|
||||
name: 'Lunch Ideas',
|
||||
options: ['Pizza 🍕', 'Burgers 🍔', 'Sushi 🍣', 'Tacos 🌮', 'Salad 🥗', 'Pasta 🍝', 'Chinese 🥡', 'Sandwiches 🥪'],
|
||||
colors: ['#E74C3C', '#F39C12', '#3498DB', '#2ECC71', '#9B59B6', '#1ABC9C', '#E67E22', '#34495E']
|
||||
},
|
||||
tasks: {
|
||||
name: 'Task Priority',
|
||||
options: ['Do It Now!', 'Schedule It', 'Delegate', 'Skip It', 'High Priority', 'Low Priority', 'Break Time!', 'Focus Time'],
|
||||
colors: ['#FF4757', '#FFA502', '#2ED573', '#1E90FF', '#5F27CD', '#00D2D3', '#FF6348', '#C23616']
|
||||
},
|
||||
colors: {
|
||||
name: 'Powder Colors',
|
||||
options: ['Gloss Black', 'Candy Red', 'Chrome Silver', 'Matte White', 'Metallic Blue', 'Neon Green', 'Rose Gold', 'Deep Purple'],
|
||||
colors: ['#000000', '#DC143C', '#C0C0C0', '#F5F5F5', '#4169E1', '#39FF14', '#B76E79', '#663399']
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the wheel when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
wheel.canvas = document.getElementById('wheelCanvas');
|
||||
if (wheel.canvas) {
|
||||
wheel.ctx = wheel.canvas.getContext('2d');
|
||||
setWheelPreset('decisions');
|
||||
|
||||
// Add click event to Tools header
|
||||
const header = document.getElementById('toolsHeader');
|
||||
if (header) {
|
||||
header.addEventListener('click', function() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('randomizerModal'));
|
||||
modal.show();
|
||||
|
||||
// Redraw wheel when modal opens (in case it was resized)
|
||||
setTimeout(() => drawWheel(), 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set wheel to a preset configuration
|
||||
function setWheelPreset(presetName) {
|
||||
const preset = presets[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
wheel.options = [...preset.options];
|
||||
wheel.colors = [...preset.colors];
|
||||
wheel.currentPreset = presetName;
|
||||
|
||||
// Update button states
|
||||
const buttons = document.querySelectorAll('.btn-group .btn');
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.remove('active');
|
||||
// Set active based on preset name
|
||||
const btnText = btn.textContent.toLowerCase();
|
||||
if (btnText.includes(presetName)) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear result
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '';
|
||||
}
|
||||
|
||||
// Redraw wheel
|
||||
drawWheel();
|
||||
}
|
||||
|
||||
// Load shop workers from the server
|
||||
async function loadShopWorkers() {
|
||||
try {
|
||||
// Show loading state
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="visually-hidden">Loading workers...</span></div>';
|
||||
}
|
||||
|
||||
const response = await fetch('/Tools/GetShopWorkers');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.workers && data.workers.length > 0) {
|
||||
wheel.options = data.workers;
|
||||
wheel.colors = generateRainbowColors(data.workers.length);
|
||||
wheel.currentPreset = 'workers';
|
||||
|
||||
// Update button states
|
||||
const buttons = document.querySelectorAll('.btn-group .btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.textContent.includes('Shop Workers')) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear result and redraw
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = `<small class="text-success"><i class="bi bi-check-circle me-1"></i>Loaded ${data.workers.length} active shop workers</small>`;
|
||||
}
|
||||
drawWheel();
|
||||
} else {
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<small class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>No active shop workers found</small>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading shop workers:', error);
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<small class="text-danger"><i class="bi bi-x-circle me-1"></i>Error loading shop workers</small>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set custom options from textarea
|
||||
function setCustomOptions() {
|
||||
const input = document.getElementById('customOptionsInput');
|
||||
const options = input.value
|
||||
.split('\n')
|
||||
.map(opt => opt.trim())
|
||||
.filter(opt => opt.length > 0);
|
||||
|
||||
if (options.length < 2) {
|
||||
showWarning('Please enter at least 2 options!', 'Invalid Input');
|
||||
return;
|
||||
}
|
||||
|
||||
wheel.options = options;
|
||||
|
||||
// Generate rainbow colors for custom options
|
||||
wheel.colors = generateRainbowColors(options.length);
|
||||
|
||||
// Collapse the custom options panel
|
||||
const collapse = bootstrap.Collapse.getInstance(document.getElementById('customOptions'));
|
||||
if (collapse) collapse.hide();
|
||||
|
||||
// Clear result and redraw
|
||||
document.getElementById('result').innerHTML = '';
|
||||
drawWheel();
|
||||
}
|
||||
|
||||
// Generate rainbow colors
|
||||
function generateRainbowColors(count) {
|
||||
const colors = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hue = (i * 360) / count;
|
||||
colors.push(`hsl(${hue}, 70%, 60%)`);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Draw the wheel
|
||||
function drawWheel() {
|
||||
if (!wheel.ctx) return;
|
||||
|
||||
const canvas = wheel.canvas;
|
||||
const ctx = wheel.ctx;
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const radius = Math.min(centerX, centerY) - 10;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Save context
|
||||
ctx.save();
|
||||
|
||||
// Rotate canvas
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(wheel.rotation);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
|
||||
const numOptions = wheel.options.length;
|
||||
const anglePerOption = (2 * Math.PI) / numOptions;
|
||||
|
||||
// Draw each segment
|
||||
for (let i = 0; i < numOptions; i++) {
|
||||
const startAngle = i * anglePerOption - Math.PI / 2;
|
||||
const endAngle = (i + 1) * anglePerOption - Math.PI / 2;
|
||||
|
||||
// Draw segment
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = wheel.colors[i % wheel.colors.length];
|
||||
ctx.fill();
|
||||
|
||||
// Draw border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.save();
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(startAngle + anglePerOption / 2);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px Arial';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillText(wheel.options[i], radius - 20, 8);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw center circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 30, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#333333';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw center star
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('★', centerX, centerY);
|
||||
|
||||
// Restore context
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Spin the wheel
|
||||
function spinWheel() {
|
||||
if (wheel.spinning) return;
|
||||
|
||||
wheel.spinning = true;
|
||||
wheel.spinStartTime = Date.now();
|
||||
document.getElementById('spinBtn').disabled = true;
|
||||
document.getElementById('result').innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Spinning...</span></div>';
|
||||
|
||||
// Random spin: 5-8 full rotations + random position
|
||||
const fullRotations = 5 + Math.random() * 3;
|
||||
const randomAngle = Math.random() * 2 * Math.PI;
|
||||
wheel.targetRotation = wheel.rotation + (fullRotations * 2 * Math.PI) + randomAngle;
|
||||
wheel.spinSpeed = 0.3;
|
||||
|
||||
animateSpin();
|
||||
}
|
||||
|
||||
// Animate the spin
|
||||
function animateSpin() {
|
||||
if (!wheel.spinning) return;
|
||||
|
||||
// Safety timeout: stop after 10 seconds
|
||||
const elapsed = Date.now() - wheel.spinStartTime;
|
||||
if (elapsed > 10000) {
|
||||
console.warn('Spin animation timed out, forcing stop');
|
||||
wheel.rotation = wheel.targetRotation;
|
||||
finishSpin();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ease out
|
||||
const distance = wheel.targetRotation - wheel.rotation;
|
||||
|
||||
// Stop if we're very close OR if the speed is too slow to make progress
|
||||
if (Math.abs(distance) < 0.01 || wheel.spinSpeed < 0.001) {
|
||||
wheel.rotation = wheel.targetRotation;
|
||||
finishSpin();
|
||||
return;
|
||||
}
|
||||
|
||||
wheel.rotation += distance * wheel.spinSpeed;
|
||||
wheel.spinSpeed *= 0.97; // Deceleration
|
||||
|
||||
drawWheel();
|
||||
requestAnimationFrame(animateSpin);
|
||||
}
|
||||
|
||||
// Finish the spin and show result
|
||||
function finishSpin() {
|
||||
// Normalize rotation to 0-2π for next spin
|
||||
wheel.rotation = wheel.rotation % (2 * Math.PI);
|
||||
if (wheel.rotation < 0) wheel.rotation += 2 * Math.PI;
|
||||
|
||||
wheel.spinning = false;
|
||||
document.getElementById('spinBtn').disabled = false;
|
||||
showResult();
|
||||
}
|
||||
|
||||
// Show the result
|
||||
function showResult() {
|
||||
const numOptions = wheel.options.length;
|
||||
const anglePerOption = (2 * Math.PI) / numOptions;
|
||||
|
||||
// Normalize rotation to 0-2π
|
||||
let normalizedRotation = wheel.rotation % (2 * Math.PI);
|
||||
if (normalizedRotation < 0) normalizedRotation += 2 * Math.PI;
|
||||
|
||||
// Calculate which segment is at the pointer (top of wheel)
|
||||
// Canvas rotation is counterclockwise, so we need to reverse the calculation
|
||||
const rawIndex = (2 * Math.PI - normalizedRotation) / anglePerOption;
|
||||
const selectedIndex = Math.floor(rawIndex) % numOptions;
|
||||
|
||||
const winner = wheel.options[selectedIndex];
|
||||
const winnerColor = wheel.colors[selectedIndex % wheel.colors.length];
|
||||
|
||||
// Display result with animation
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.innerHTML = `
|
||||
<div class="alert alert-success border-3" style="border-color: ${winnerColor} !important; animation: fadeInScale 0.5s ease;">
|
||||
<h4 class="mb-2">
|
||||
<i class="bi bi-trophy-fill text-warning me-2"></i>Winner!
|
||||
</h4>
|
||||
<h3 class="mb-0" style="color: ${winnerColor}; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
|
||||
${winner}
|
||||
</h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add confetti effect
|
||||
createConfetti();
|
||||
}
|
||||
|
||||
// Create confetti effect
|
||||
function createConfetti() {
|
||||
const duration = 2000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F'];
|
||||
|
||||
(function frame() {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) return;
|
||||
|
||||
const particleCount = 2;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.style.position = 'fixed';
|
||||
particle.style.width = '10px';
|
||||
particle.style.height = '10px';
|
||||
particle.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
particle.style.borderRadius = '50%';
|
||||
particle.style.pointerEvents = 'none';
|
||||
particle.style.zIndex = '9999';
|
||||
|
||||
const startX = Math.random() * window.innerWidth;
|
||||
const startY = -20;
|
||||
|
||||
particle.style.left = startX + 'px';
|
||||
particle.style.top = startY + 'px';
|
||||
|
||||
document.body.appendChild(particle);
|
||||
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const velocity = 2 + Math.random() * 2;
|
||||
const vx = Math.cos(angle) * velocity;
|
||||
const vy = Math.sin(angle) * velocity + 3;
|
||||
|
||||
let x = startX;
|
||||
let y = startY;
|
||||
let opacity = 1;
|
||||
|
||||
const animate = () => {
|
||||
y += vy;
|
||||
x += vx;
|
||||
opacity -= 0.02;
|
||||
|
||||
particle.style.top = y + 'px';
|
||||
particle.style.left = x + 'px';
|
||||
particle.style.opacity = opacity;
|
||||
|
||||
if (opacity > 0 && y < window.innerHeight) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
particle.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
})();
|
||||
}
|
||||
|
||||
// Add CSS animation for result
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -0,0 +1,15 @@
|
||||
// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
||||
|
||||
/**
|
||||
* Change page size for pagination
|
||||
* @param {number} newSize - The new page size
|
||||
*/
|
||||
function changePageSize(newSize) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('pageSize', newSize);
|
||||
url.searchParams.set('pageNumber', '1'); // Reset to page 1 when changing page size
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Tag chip input widget
|
||||
// Usage: call initTagInput('hiddenFieldId', 'containerId') after DOM ready
|
||||
// The hidden field stores comma-separated tags.
|
||||
// The container receives: a normal form-control input + a chips area below it.
|
||||
// Optional: pass { suggestions: ['tag1', 'tag2'] } as third argument to show clickable suggestion chips.
|
||||
// Returns: { addTag, clear } for external control.
|
||||
|
||||
function initTagInput(hiddenId, containerId, options) {
|
||||
const hidden = document.getElementById(hiddenId);
|
||||
const container = document.getElementById(containerId);
|
||||
if (!hidden || !container) return { addTag: function() {}, clear: function() {} };
|
||||
|
||||
// Clear any previous render
|
||||
container.innerHTML = '';
|
||||
|
||||
// Normal fixed-height text input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'form-control';
|
||||
input.placeholder = 'Add tag\u2026';
|
||||
input.autocomplete = 'off';
|
||||
|
||||
// Chips live below the input — does not affect input height
|
||||
const chipsArea = document.createElement('div');
|
||||
chipsArea.className = 'tag-chips-area';
|
||||
|
||||
container.appendChild(input);
|
||||
container.appendChild(chipsArea);
|
||||
|
||||
let tags = hidden.value
|
||||
? hidden.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
chipsArea.innerHTML = '';
|
||||
tags.forEach((tag, i) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge rounded-pill bg-info text-dark tag-chip';
|
||||
chip.innerHTML =
|
||||
`${escapeHtml(tag)}` +
|
||||
`<span class="tag-remove ms-1" data-index="${i}" aria-label="Remove" role="button">×</span>`;
|
||||
chipsArea.appendChild(chip);
|
||||
});
|
||||
hidden.value = tags.join(',');
|
||||
}
|
||||
|
||||
function addTag(val) {
|
||||
const trimmed = val.trim().replace(/,/g, '').slice(0, 50);
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags.push(trimmed);
|
||||
renderTags();
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag(input.value);
|
||||
input.value = '';
|
||||
} else if (e.key === 'Backspace' && input.value === '' && tags.length > 0) {
|
||||
tags.pop();
|
||||
renderTags();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value.trim()) {
|
||||
addTag(input.value);
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
chipsArea.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('tag-remove')) {
|
||||
const idx = parseInt(e.target.dataset.index, 10);
|
||||
tags.splice(idx, 1);
|
||||
renderTags();
|
||||
}
|
||||
});
|
||||
|
||||
// Render suggestion chips if provided
|
||||
if (options && options.suggestions && options.suggestions.length > 0) {
|
||||
const suggestionsRow = document.createElement('div');
|
||||
suggestionsRow.className = 'd-flex flex-wrap align-items-center gap-1 mt-2';
|
||||
suggestionsRow.innerHTML = '<small class="text-muted">Suggested:</small>';
|
||||
options.suggestions.forEach(s => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-secondary btn-sm py-0 px-2';
|
||||
btn.style.fontSize = '0.78rem';
|
||||
btn.textContent = s;
|
||||
btn.addEventListener('click', () => addTag(s));
|
||||
suggestionsRow.appendChild(btn);
|
||||
});
|
||||
container.appendChild(suggestionsRow);
|
||||
}
|
||||
|
||||
renderTags();
|
||||
|
||||
return {
|
||||
addTag: addTag,
|
||||
clear: function() {
|
||||
tags = [];
|
||||
input.value = '';
|
||||
renderTags();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Toast Notification System
|
||||
* Provides consistent, user-friendly notifications across the application
|
||||
* Uses Toastr library with Bootstrap 5 theming
|
||||
*/
|
||||
|
||||
// Configure Toastr global options
|
||||
toastr.options = {
|
||||
"closeButton": true,
|
||||
"debug": false,
|
||||
"newestOnTop": true,
|
||||
"progressBar": true,
|
||||
"positionClass": "toast-top-right",
|
||||
"preventDuplicates": true,
|
||||
"onclick": null,
|
||||
"showDuration": "300",
|
||||
"hideDuration": "1000",
|
||||
"timeOut": "5000",
|
||||
"extendedTimeOut": "1000",
|
||||
"showEasing": "swing",
|
||||
"hideEasing": "linear",
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut"
|
||||
};
|
||||
|
||||
/**
|
||||
* Show success toast notification
|
||||
* @param {string} message - The success message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showSuccess(message, title = 'Success') {
|
||||
toastr.success(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast notification
|
||||
* @param {string} message - The error message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showError(message, title = 'Error') {
|
||||
toastr.error(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast notification
|
||||
* @param {string} message - The warning message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showWarning(message, title = 'Warning') {
|
||||
toastr.warning(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast notification
|
||||
* @param {string} message - The info message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showInfo(message, title = 'Info') {
|
||||
toastr.info(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show validation errors from ModelState
|
||||
* @param {Array<string>} errors - Array of validation error messages
|
||||
*/
|
||||
function showValidationErrors(errors) {
|
||||
if (!errors || errors.length === 0) return;
|
||||
|
||||
// If single error, show it directly
|
||||
if (errors.length === 1) {
|
||||
showError(errors[0], 'Validation Error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple errors - show as a list
|
||||
const errorList = '<ul class="mb-0 ps-3">' +
|
||||
errors.map(err => `<li>${escapeHtml(err)}</li>`).join('') +
|
||||
'</ul>';
|
||||
|
||||
toastr.error(errorList, `${errors.length} Validation Errors`, {
|
||||
timeOut: 8000,
|
||||
extendedTimeOut: 2000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display TempData messages automatically on page load
|
||||
* Expects TempData keys: Success, Error, Warning, Info
|
||||
*/
|
||||
function displayTempDataMessages() {
|
||||
// Success message
|
||||
const successMsg = document.getElementById('tempdata-success-message');
|
||||
if (successMsg && successMsg.textContent.trim()) {
|
||||
showSuccess(successMsg.textContent.trim());
|
||||
}
|
||||
|
||||
// Error message
|
||||
const errorMsg = document.getElementById('tempdata-error-message');
|
||||
if (errorMsg && errorMsg.textContent.trim()) {
|
||||
showError(errorMsg.textContent.trim());
|
||||
}
|
||||
|
||||
// Permanent success — no auto-dismiss
|
||||
const successPerm = document.getElementById('tempdata-success-permanent-message');
|
||||
if (successPerm && successPerm.textContent.trim()) {
|
||||
toastr.success(successPerm.textContent.trim(), 'Success', { timeOut: 0, extendedTimeOut: 0 });
|
||||
}
|
||||
|
||||
// Permanent error — no auto-dismiss
|
||||
const errorPerm = document.getElementById('tempdata-error-permanent-message');
|
||||
if (errorPerm && errorPerm.textContent.trim()) {
|
||||
toastr.error(errorPerm.textContent.trim(), 'Error', { timeOut: 0, extendedTimeOut: 0 });
|
||||
}
|
||||
|
||||
// Warning message
|
||||
const warningMsg = document.getElementById('tempdata-warning-message');
|
||||
if (warningMsg && warningMsg.textContent.trim()) {
|
||||
showWarning(warningMsg.textContent.trim());
|
||||
}
|
||||
|
||||
// Info message
|
||||
const infoMsg = document.getElementById('tempdata-info-message');
|
||||
if (infoMsg && infoMsg.textContent.trim()) {
|
||||
showInfo(infoMsg.textContent.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display ModelState validation errors on page load
|
||||
* Expects a hidden div with id 'modelstate-errors' containing JSON array of errors
|
||||
*/
|
||||
function displayModelStateErrors() {
|
||||
const errorContainer = document.getElementById('modelstate-errors');
|
||||
if (errorContainer && errorContainer.textContent.trim()) {
|
||||
try {
|
||||
const errors = JSON.parse(errorContainer.textContent);
|
||||
showValidationErrors(errors);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse ModelState errors:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} - Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all toasts
|
||||
*/
|
||||
function clearAllToasts() {
|
||||
toastr.clear();
|
||||
}
|
||||
|
||||
// Auto-initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
displayTempDataMessages();
|
||||
displayModelStateErrors();
|
||||
});
|
||||
|
||||
// Make functions globally available
|
||||
window.showSuccess = showSuccess;
|
||||
window.showError = showError;
|
||||
window.showWarning = showWarning;
|
||||
window.showInfo = showInfo;
|
||||
window.showValidationErrors = showValidationErrors;
|
||||
window.clearAllToasts = clearAllToasts;
|
||||
@@ -0,0 +1,840 @@
|
||||
// Tools Import/Export Wizard
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Anti-forgery token ────────────────────────────────────────────────────
|
||||
const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
if (!tokenInput) { console.error('[Tools] Anti-forgery token not found'); return; }
|
||||
const token = tokenInput.value;
|
||||
|
||||
// ── Account select data (embedded by Razor as JSON) ───────────────────────
|
||||
let accountData = { revenueAccounts: [], cogsAccounts: [], inventoryAccounts: [] };
|
||||
try {
|
||||
const el = document.getElementById('toolsAccountData');
|
||||
if (el) accountData = JSON.parse(el.textContent);
|
||||
} catch (e) { console.warn('[Tools] Could not parse account data'); }
|
||||
|
||||
// ── Wizard state ──────────────────────────────────────────────────────────
|
||||
let wDir = null; // 'import' | 'export'
|
||||
let wFmt = null; // 'csv' | 'qb-desktop' | 'qb-online'
|
||||
let wItem = null; // selected item object
|
||||
let wStep = 1;
|
||||
|
||||
// ── Item catalog ──────────────────────────────────────────────────────────
|
||||
const ITEMS = [
|
||||
// ── CSV Import ──────────────────────────────────────────────────────
|
||||
{ key: 'csv-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Contact info, addresses, and balances',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportCustomers', accept: '.csv',
|
||||
template: '/Tools/DownloadCustomerTemplate',
|
||||
tips: ['Download the CSV template to see the expected columns', 'One customer per row — existing records matched by company name are updated'] },
|
||||
|
||||
{ key: 'csv-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Supplier records and contact info',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportVendors', accept: '.csv',
|
||||
template: '/Tools/DownloadVendorTemplate',
|
||||
tips: ['Download the CSV template to see the expected columns', 'One vendor per row — existing records matched by company name are updated'] },
|
||||
|
||||
{ key: 'csv-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Pre-priced service catalog entries',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportCatalogItems', accept: '.csv',
|
||||
template: '/Tools/DownloadCatalogTemplate',
|
||||
extraFields: [
|
||||
{ name: 'revenueAccountId', label: 'Revenue Account', hint: 'Optional', accountKey: 'revenueAccounts' },
|
||||
{ name: 'cogsAccountId', label: 'COGS Account', hint: 'Optional', accountKey: 'cogsAccounts' },
|
||||
],
|
||||
tips: ['Download the CSV template', 'Optionally map to GL accounts before uploading'] },
|
||||
|
||||
{ key: 'csv-inventory',
|
||||
label: 'Inventory', icon: 'bi-boxes', color: '#0891b2',
|
||||
desc: 'Stock items, quantities, and unit costs',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportInventoryItems', accept: '.csv',
|
||||
template: '/Tools/DownloadInventoryTemplate',
|
||||
extraFields: [
|
||||
{ name: 'inventoryAccountId', label: 'Inventory Asset Account', hint: 'Optional', accountKey: 'inventoryAccounts' },
|
||||
{ name: 'cogsAccountId', label: 'COGS Account', hint: 'Optional', accountKey: 'cogsAccounts' },
|
||||
],
|
||||
tips: ['Download the CSV template', 'Optionally map to GL accounts before uploading'] },
|
||||
|
||||
{ key: 'csv-quotes',
|
||||
label: 'Quotes', icon: 'bi-file-earmark-text', color: '#d97706',
|
||||
desc: 'Quote records with statuses and totals',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportQuotes', accept: '.csv',
|
||||
template: '/Tools/DownloadQuoteTemplate',
|
||||
tips: ['Download the CSV template', 'One quote per row'] },
|
||||
|
||||
{ key: 'csv-jobs',
|
||||
label: 'Jobs', icon: 'bi-briefcase', color: '#059669',
|
||||
desc: 'Job records with statuses and priorities',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportJobs', accept: '.csv',
|
||||
template: '/Tools/DownloadJobTemplate',
|
||||
tips: ['Download the CSV template', 'One job per row'] },
|
||||
|
||||
{ key: 'csv-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#2563eb',
|
||||
desc: 'Scheduled customer appointments',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportAppointments', accept: '.csv',
|
||||
template: '/Tools/DownloadAppointmentTemplate',
|
||||
tips: ['Download the CSV template', 'One appointment per row'] },
|
||||
|
||||
{ key: 'csv-equipment',
|
||||
label: 'Equipment', icon: 'bi-tools', color: '#dc2626',
|
||||
desc: 'Equipment records and status',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportEquipment', accept: '.csv',
|
||||
template: '/Tools/DownloadEquipmentTemplate',
|
||||
tips: ['Download the CSV template', 'One equipment item per row'] },
|
||||
|
||||
{ key: 'csv-maintenance',
|
||||
label: 'Maintenance Records', icon: 'bi-wrench', color: '#6b7280',
|
||||
desc: 'Scheduled and completed maintenance',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportMaintenance', accept: '.csv',
|
||||
template: '/Tools/DownloadMaintenanceTemplate',
|
||||
tips: ['Download the CSV template', 'One maintenance record per row'] },
|
||||
|
||||
|
||||
{ key: 'csv-prepservices',
|
||||
label: 'Prep Services', icon: 'bi-hammer', color: '#7c3aed',
|
||||
desc: 'Sandblasting, masking, and prep options',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportPrepServices', accept: '.csv',
|
||||
template: '/Tools/DownloadPrepServiceTemplate',
|
||||
tips: ['Existing services matched by name are updated'] },
|
||||
|
||||
{ key: 'csv-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'GL accounts — import first (required for expenses and bills)',
|
||||
badge: 'Import first',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportChartOfAccounts', accept: '.csv',
|
||||
template: '/Tools/DownloadChartOfAccountsTemplate',
|
||||
tips: ['Download the CSV template to see required columns',
|
||||
'Valid AccountType values: Asset, Liability, Equity, Revenue, CostOfGoods, Expense',
|
||||
'Existing accounts matched by AccountNumber are updated; system accounts are never modified'] },
|
||||
|
||||
{ key: 'csv-expenses',
|
||||
label: 'Expenses', icon: 'bi-receipt-cutoff', color: '#dc2626',
|
||||
desc: 'Direct expenses with account, vendor, and job links',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportExpenses', accept: '.csv',
|
||||
template: '/Tools/DownloadExpenseTemplate',
|
||||
tips: ['Download the CSV template to see required columns',
|
||||
'ExpenseAccountNumber and PaymentAccountNumber must match account numbers in your Chart of Accounts',
|
||||
'VendorName and JobNumber are optional — leave blank if not applicable',
|
||||
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment'] },
|
||||
|
||||
{ key: 'csv-settings',
|
||||
label: 'Company Settings', icon: 'bi-gear', color: '#d97706',
|
||||
desc: 'Operating costs and configuration',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportCompanySettings', accept: '.csv',
|
||||
template: '/Tools/DownloadCompanySettingsTemplate',
|
||||
warning: 'This will overwrite your current company settings. Download a backup first.',
|
||||
tips: ['Download a backup of your current settings first', 'Modify the CSV, then upload it below'] },
|
||||
|
||||
// ── CSV Export ──────────────────────────────────────────────────────
|
||||
{ key: 'exp-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Contact info, addresses, and balances',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCustomersCsv' },
|
||||
|
||||
{ key: 'exp-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Supplier records, contact info, and payment terms',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportVendorsCsv' },
|
||||
|
||||
{ key: 'exp-quotes',
|
||||
label: 'Quotes', icon: 'bi-file-earmark-text', color: '#0891b2',
|
||||
desc: 'Status, dates, totals, and customer info',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportQuotesCsv' },
|
||||
|
||||
{ key: 'exp-jobs',
|
||||
label: 'Jobs', icon: 'bi-briefcase', color: '#059669',
|
||||
desc: 'Status, priority, dates, and completion data',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJobsCsv' },
|
||||
|
||||
{ key: 'exp-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
|
||||
desc: 'Customer, type, status, and scheduling details',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportAppointmentsCsv' },
|
||||
|
||||
{ key: 'exp-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#6b7280',
|
||||
desc: 'SKU, pricing, categories, and descriptions',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCatalogCsv' },
|
||||
|
||||
{ key: 'exp-inventory',
|
||||
label: 'Inventory', icon: 'bi-boxes', color: '#1f2937',
|
||||
desc: 'Quantities, costs, and location details',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInventoryCsv' },
|
||||
|
||||
{ key: 'exp-equipment',
|
||||
label: 'Equipment', icon: 'bi-tools', color: '#dc2626',
|
||||
desc: 'Details, purchase info, and current status',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportEquipmentCsv' },
|
||||
|
||||
{ key: 'exp-maintenance',
|
||||
label: 'Maintenance Records', icon: 'bi-wrench', color: '#6b7280',
|
||||
desc: 'Scheduled and completed maintenance with equipment and costs',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportMaintenanceCsv' },
|
||||
|
||||
|
||||
{ key: 'exp-prepservices',
|
||||
label: 'Prep Services', icon: 'bi-tools', color: '#7c3aed',
|
||||
desc: 'Preparation service catalog (sandblasting, stripping, etc.)',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPrepServicesCsv' },
|
||||
|
||||
{ key: 'exp-purchaseorders',
|
||||
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
|
||||
desc: 'Vendor, status, dates, and totals',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPurchaseOrdersCsv' },
|
||||
|
||||
{ key: 'exp-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'Full GL account list with types, balances, and account numbers',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportChartOfAccountsCsv' },
|
||||
|
||||
{ key: 'exp-expenses',
|
||||
label: 'Expenses', icon: 'bi-receipt-cutoff', color: '#dc2626',
|
||||
desc: 'Direct expenses with dates, accounts, vendors, and amounts',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportExpensesCsv' },
|
||||
|
||||
{ key: 'exp-settings',
|
||||
label: 'Company Settings', icon: 'bi-gear-fill', color: '#d97706',
|
||||
desc: 'Company info, operating costs, and preferences',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCompanySettingsCsv' },
|
||||
|
||||
// ── QB Desktop Import ───────────────────────────────────────────────
|
||||
{ key: 'qbd-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'Account list — import this first (required for bills)',
|
||||
badge: 'Import first',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportChartOfAccounts', accept: '.iif,.txt',
|
||||
tips: ['Lists → Chart of Accounts → right-click → Export → save as .iif', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Customer list from QB Desktop',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportCustomers', accept: '.iif,.txt',
|
||||
tips: ['File → Utilities → Export → Lists to IIF Files → select Customers', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Vendor list from QB Desktop',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportVendors', accept: '.iif,.txt',
|
||||
tips: ['File → Utilities → Export → Lists to IIF Files → select Vendors', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Service items from QB Desktop',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportCatalogItems', accept: '.iif,.txt',
|
||||
tips: ['File → Utilities → Export → Lists to IIF Files → select Items', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-inventory',
|
||||
label: 'Inventory Stock', icon: 'bi-boxes', color: '#0891b2',
|
||||
desc: 'Inventory Valuation Summary — include Pref Vendor column',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbInventoryValuation', accept: '.csv',
|
||||
tips: [
|
||||
'Reports → Inventory → Inventory Valuation Summary',
|
||||
'Customize Report → Display tab → check <strong>Pref Vendor</strong>',
|
||||
'Excel → Create New Worksheet → Comma Separated Values (.csv)',
|
||||
'Upload the saved .csv file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbd-invoices',
|
||||
label: 'Invoices', icon: 'bi-receipt', color: '#2563eb',
|
||||
desc: 'Customer Balance Detail report',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbInvoices', accept: '.csv',
|
||||
tips: ['Reports → Customers & Receivables → Customer Balance Detail', 'Set date range to <strong>All</strong>', 'Excel → Create New Worksheet → Comma Separated Values (.csv)', 'Upload the saved .csv file below'] },
|
||||
|
||||
{ key: 'qbd-transactions',
|
||||
label: 'Transactions', icon: 'bi-arrow-left-right', color: '#059669',
|
||||
desc: 'Transaction List by Customer report',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbTransactions', accept: '.csv',
|
||||
tips: ['Reports → Customers & Receivables → Transaction List by Customer', 'Set date range to <strong>All</strong>', 'Excel → Create New Worksheet → Comma Separated Values (.csv)', 'Upload the saved .csv file below'] },
|
||||
|
||||
{ key: 'qbd-bills',
|
||||
label: 'Bills & Payments', icon: 'bi-file-earmark-minus', color: '#dc2626',
|
||||
desc: 'Vendor Balance Detail report — imports bills and payments in one pass',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbBillsAndPayments', accept: '.csv',
|
||||
tips: [
|
||||
'Reports → Vendors & Payables → Vendor Balance Detail',
|
||||
'Set date range to <strong>All</strong>',
|
||||
'Click <strong>Customize Report</strong> → <strong>Display</strong> tab → check <strong>Memo</strong> to include bill and payment descriptions',
|
||||
'Excel → Create New Worksheet → Comma Separated Values (.csv)',
|
||||
'Upload the file — bills are imported first, then payments are matched against them automatically'
|
||||
] },
|
||||
|
||||
// ── QB Online Import ────────────────────────────────────────────────
|
||||
{ key: 'qbo-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'Account List — import this first (required for invoices)',
|
||||
badge: 'Import first',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboChartOfAccounts', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Accounting (left nav) → Chart of Accounts → Download/Export button',
|
||||
'<strong>Or:</strong> Reports → search "Account List" → Export to Excel',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Customer Contact List from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboCustomers', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Customer Contact List" → Customize → add all desired columns → Export to Excel',
|
||||
'<strong>Tip:</strong> Add Billing Street, City, State, Zip columns via Customize → Rows/Columns for best results',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Vendor Contact List from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboVendors', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Vendor Contact List" → Customize → add all desired columns → Export to Excel',
|
||||
'<strong>Or:</strong> Expenses → Vendors → Export icon (box with arrow) → Export to Excel',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-products',
|
||||
label: 'Products & Services', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Product/service catalog from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboCatalogItems', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Sales → Products and Services → Export to Excel icon (top right)',
|
||||
'Inventory-type items are imported as Inventory; all others as Catalog Items',
|
||||
'Items named <em>Category:Item Name</em> will have the category prefix stripped automatically',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-invoices',
|
||||
label: 'Invoices', icon: 'bi-receipt', color: '#2563eb',
|
||||
desc: 'Invoice List report from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboInvoices', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Invoice List" → set your date range → Export to Excel',
|
||||
'Customers must be imported first so invoices can be linked',
|
||||
'Invoice totals and open balances are imported; line item detail is not available in this report',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-transactions',
|
||||
label: 'Transactions', icon: 'bi-arrow-left-right', color: '#059669',
|
||||
desc: 'Transaction List — applies payments to imported invoices',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboTransactions', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Transaction List by Date" → set All Dates → Export to Excel',
|
||||
'Only Payment/Receipt rows are processed; Invoice rows are skipped',
|
||||
'Invoices must be imported first so payments can be matched by reference number',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
// ── QB Export ───────────────────────────────────────────────────────
|
||||
{ key: 'qb-exp-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Export all active customers',
|
||||
dir: ['export'], fmt: ['qb-desktop', 'qb-online'],
|
||||
exportUrlDesktop: '/Tools/ExportCustomers?format=desktop',
|
||||
exportUrlOnline: '/Tools/ExportCustomers?format=online' },
|
||||
|
||||
{ key: 'qb-exp-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Export all active vendors to IIF format',
|
||||
dir: ['export'], fmt: ['qb-desktop'],
|
||||
exportUrl: '/Tools/ExportVendors' },
|
||||
|
||||
{ key: 'qb-exp-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Export active catalog items as service items',
|
||||
dir: ['export'], fmt: ['qb-desktop', 'qb-online'],
|
||||
exportUrlDesktop: '/Tools/ExportCatalogItems?format=desktop',
|
||||
exportUrlOnline: '/Tools/ExportCatalogItems?format=online' },
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function getExportUrl(item) {
|
||||
if (item.exportUrl) return item.exportUrl;
|
||||
if (wFmt === 'qb-online' && item.exportUrlOnline) return item.exportUrlOnline;
|
||||
if (item.exportUrlDesktop) return item.exportUrlDesktop;
|
||||
return '#';
|
||||
}
|
||||
|
||||
function filteredItems() {
|
||||
return ITEMS.filter(it => it.dir.includes(wDir) && it.fmt.includes(wFmt));
|
||||
}
|
||||
|
||||
// ── Wizard Navigation ─────────────────────────────────────────────────────
|
||||
window.wizardSetDirection = function (dir) {
|
||||
wDir = dir;
|
||||
wStep = 2;
|
||||
setBreadcrumb(2, dir === 'import' ? 'Import' : 'Export');
|
||||
document.getElementById('step2-heading').textContent =
|
||||
dir === 'import' ? 'Import from which format?' : 'Export to which format?';
|
||||
showStep(2);
|
||||
document.getElementById('wizardBackBtn').classList.remove('d-none');
|
||||
};
|
||||
|
||||
window.wizardSetFormat = function (fmt) {
|
||||
wFmt = fmt;
|
||||
wStep = 3;
|
||||
const labels = { csv: 'CSV', 'qb-desktop': 'QB Desktop', 'qb-online': 'QB Online' };
|
||||
setBreadcrumb(3, labels[fmt] || fmt);
|
||||
renderStep3();
|
||||
showStep(3);
|
||||
};
|
||||
|
||||
window.wizardBack = function () {
|
||||
if (wStep === 4) {
|
||||
wItem = null;
|
||||
wStep = 3;
|
||||
resetBreadcrumb(4, wDir === 'import' ? 'Import' : 'Export');
|
||||
renderStep3();
|
||||
showStep(3);
|
||||
} else if (wStep === 3) {
|
||||
wFmt = null;
|
||||
wStep = 2;
|
||||
resetBreadcrumb(3, 'Select');
|
||||
showStep(2);
|
||||
} else if (wStep === 2) {
|
||||
wDir = null;
|
||||
wStep = 1;
|
||||
resetBreadcrumb(2, 'Format');
|
||||
resetBreadcrumb(1, 'Direction');
|
||||
showStep(1);
|
||||
document.getElementById('wizardBackBtn').classList.add('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
window.wizardSelectItem = function (key) {
|
||||
wItem = ITEMS.find(it => it.key === key);
|
||||
if (!wItem) return;
|
||||
wStep = 4;
|
||||
setBreadcrumb(4, wItem.label);
|
||||
renderStep4();
|
||||
showStep(4);
|
||||
};
|
||||
|
||||
function showStep(n) {
|
||||
[1, 2, 3, 4].forEach(function (s) {
|
||||
const el = document.getElementById('wizard-step-' + s);
|
||||
if (el) el.classList.toggle('d-none', s !== n);
|
||||
});
|
||||
updateBreadcrumbActive(n);
|
||||
}
|
||||
|
||||
function setBreadcrumb(step, label) {
|
||||
const lbl = document.getElementById('bc-' + step + '-label');
|
||||
const bdg = document.getElementById('bc-' + step + '-badge');
|
||||
if (lbl) { lbl.textContent = label; lbl.className = 'small fw-semibold'; }
|
||||
if (bdg) bdg.className = 'badge rounded-pill bg-primary';
|
||||
}
|
||||
|
||||
function resetBreadcrumb(step, label) {
|
||||
const lbl = document.getElementById('bc-' + step + '-label');
|
||||
const bdg = document.getElementById('bc-' + step + '-badge');
|
||||
if (lbl) { lbl.textContent = label; lbl.className = 'small text-muted'; }
|
||||
if (bdg) bdg.className = 'badge rounded-pill bg-secondary';
|
||||
}
|
||||
|
||||
function updateBreadcrumbActive(currentStep) {
|
||||
for (let s = 1; s <= 4; s++) {
|
||||
const badge = document.getElementById('bc-' + s + '-badge');
|
||||
if (badge && s < currentStep && badge.className.includes('primary')) {
|
||||
badge.className = 'badge rounded-pill bg-success';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: item selection grid ───────────────────────────────────────────
|
||||
function renderStep3() {
|
||||
const heading = document.getElementById('step3-heading');
|
||||
const grid = document.getElementById('step3-grid');
|
||||
if (!heading || !grid) return;
|
||||
|
||||
heading.textContent = wDir === 'import' ? 'What would you like to import?' : 'What would you like to export?';
|
||||
|
||||
let html = '';
|
||||
|
||||
// CSV Export: "Export All" shortcut
|
||||
if (wDir === 'export' && wFmt === 'csv') {
|
||||
html += `<div class="col-12 text-center mb-1">
|
||||
<a href="/Tools/ExportAllCsv" class="btn btn-primary px-4">
|
||||
<i class="bi bi-file-earmark-zip me-1"></i>Export All as ZIP
|
||||
</a>
|
||||
<p class="text-muted small mt-2 mb-0">Or pick a specific data set below.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// QB Desktop Import: recommended order callout
|
||||
if (wDir === 'import' && wFmt === 'qb-desktop') {
|
||||
html += `<div class="col-12 mb-1">
|
||||
<div class="alert alert-info py-2 mb-0 small">
|
||||
<i class="bi bi-list-ol me-1"></i>
|
||||
<strong>Recommended order:</strong>
|
||||
Chart of Accounts → Customers → Vendors → Catalog Items → Inventory → Invoices → Transactions → Bills & Payments
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
filteredItems().forEach(function (item) {
|
||||
const badgeHtml = item.badge
|
||||
? ` <span class="badge ms-1" style="background:#374151;font-size:0.68rem;vertical-align:middle">${item.badge}</span>`
|
||||
: '';
|
||||
html += `
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card h-100 border-2" role="button"
|
||||
onclick="wizardSelectItem('${item.key}')"
|
||||
style="cursor:pointer;border-color:${item.color}!important;transition:transform .1s"
|
||||
onmouseover="this.style.transform='scale(1.02)'"
|
||||
onmouseout="this.style.transform=''">
|
||||
<div class="card-body py-3 px-3">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div style="width:42px;height:42px;border-radius:10px;background:${item.color}18;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<i class="bi ${item.icon}" style="font-size:1.3rem;color:${item.color}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold" style="font-size:0.93rem">${item.label}${badgeHtml}</div>
|
||||
<div class="text-muted" style="font-size:0.8rem">${item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
if (!filteredItems().length) {
|
||||
html += `<div class="col-12 text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size:2.5rem"></i>
|
||||
<p class="mt-2 mb-0">No options available for this selection.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Step 4: upload form or download button ────────────────────────────────
|
||||
async function renderStep4() {
|
||||
const container = document.getElementById('step4-content');
|
||||
if (!container || !wItem) return;
|
||||
|
||||
const item = wItem;
|
||||
const isImport = wDir === 'import';
|
||||
|
||||
// Refresh account data from server if this card has account dropdowns,
|
||||
// so accounts imported earlier in the same page session are available.
|
||||
if (item.extraFields && item.extraFields.length) {
|
||||
try {
|
||||
const resp = await fetch('/Tools/GetImportAccounts');
|
||||
if (resp.ok) accountData = await resp.json();
|
||||
} catch (e) { /* keep whatever was loaded at page render */ }
|
||||
}
|
||||
|
||||
// Tips list
|
||||
const tipsHtml = (item.tips || []).length
|
||||
? `<ol class="small text-muted ps-3 mb-0">${item.tips.map(t => `<li>${t}</li>`).join('')}</ol>`
|
||||
: '';
|
||||
|
||||
// Warning
|
||||
const warningHtml = item.warning
|
||||
? `<div class="alert alert-warning py-2 mb-3 small"><i class="bi bi-exclamation-triangle-fill me-1"></i>${item.warning}</div>`
|
||||
: '';
|
||||
|
||||
// Template download button
|
||||
const templateHtml = item.template
|
||||
? `<div class="mb-4">
|
||||
<a href="${item.template}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-arrow-down me-1"></i>Download CSV Template
|
||||
</a>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Extra account dropdowns
|
||||
let extraFieldsHtml = '';
|
||||
(item.extraFields || []).forEach(function (f) {
|
||||
const opts = (accountData[f.accountKey] || [])
|
||||
.map(o => `<option value="${o.value}">${o.text}</option>`)
|
||||
.join('');
|
||||
extraFieldsHtml += `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold mb-1">${f.label}
|
||||
<span class="text-muted fw-normal">${f.hint ? '— ' + f.hint : ''}</span>
|
||||
</label>
|
||||
<select name="${f.name}" class="form-select form-select-sm">
|
||||
<option value="">(none)</option>
|
||||
${opts}
|
||||
</select>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
let actionHtml;
|
||||
|
||||
if (isImport) {
|
||||
actionHtml = `
|
||||
${warningHtml}
|
||||
${templateHtml}
|
||||
<form id="step4-form" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold mb-1">
|
||||
Select file <span class="text-muted fw-normal">${item.accept}</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" id="step4-file" name="file" accept="${item.accept}" required />
|
||||
</div>
|
||||
${extraFieldsHtml}
|
||||
<button type="submit" class="btn btn-primary" id="step4-btn">
|
||||
<span class="spinner-border spinner-border-sm d-none me-1" id="step4-spinner" role="status"></span>
|
||||
<i class="bi bi-upload me-1"></i>Import ${item.label}
|
||||
</button>
|
||||
</form>
|
||||
<div id="step4-results" class="mt-4 d-none">
|
||||
<hr>
|
||||
<div id="step4-summary" class="mb-2"></div>
|
||||
<div id="step4-errors"></div>
|
||||
</div>`;
|
||||
} else {
|
||||
const exportUrl = getExportUrl(item);
|
||||
const qbHelpHtml = (wFmt === 'qb-desktop' || wFmt === 'qb-online')
|
||||
? `<div class="alert alert-info small mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Importing into QuickBooks${wFmt === 'qb-online' ? ' Online' : ' Desktop'}:</strong>
|
||||
${wFmt === 'qb-desktop'
|
||||
? 'File → Utilities → Import → IIF Files → select the downloaded file.'
|
||||
: 'Settings → Import Data → select the data type, then upload the file.'}
|
||||
</div>`
|
||||
: '';
|
||||
actionHtml = `
|
||||
<a href="${exportUrl}" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-download me-2"></i>Download ${item.label}
|
||||
</a>
|
||||
${qbHelpHtml}`;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div style="width:40px;height:40px;border-radius:8px;background:${item.color}18;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<i class="bi ${item.icon}" style="color:${item.color};font-size:1.2rem"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">${item.label}</div>
|
||||
<div class="text-muted small">${item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
${tipsHtml}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
${actionHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (isImport) {
|
||||
document.getElementById('step4-form').addEventListener('submit', runImport);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Import runner ─────────────────────────────────────────────────────────
|
||||
async function runImport(e) {
|
||||
e.preventDefault();
|
||||
const item = wItem;
|
||||
const fileInput = document.getElementById('step4-file');
|
||||
const btn = document.getElementById('step4-btn');
|
||||
const spinner = document.getElementById('step4-spinner');
|
||||
const resultsDiv = document.getElementById('step4-results');
|
||||
const summaryDiv = document.getElementById('step4-summary');
|
||||
const errorsDiv = document.getElementById('step4-errors');
|
||||
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
if (typeof showWarning === 'function') showWarning('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
if (spinner) spinner.classList.remove('d-none');
|
||||
resultsDiv.classList.add('d-none');
|
||||
|
||||
const formData = new FormData(document.getElementById('step4-form'));
|
||||
formData.append('__RequestVerificationToken', token);
|
||||
|
||||
try {
|
||||
const response = await fetch(item.endpoint, { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const ct = response.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) throw new Error('Unexpected response from server — check the browser console');
|
||||
const result = await response.json();
|
||||
displaySuccess(summaryDiv, errorsDiv, resultsDiv, result);
|
||||
if (result.success) {
|
||||
if (typeof showSuccess === 'function') showSuccess(result.message || 'Import completed');
|
||||
} else {
|
||||
if (typeof showError === 'function') showError(result.message || 'Import completed with errors');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Tools] Import error:', err);
|
||||
displayError(summaryDiv, errorsDiv, resultsDiv, err.message, err.stack);
|
||||
if (typeof showError === 'function') showError(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (spinner) spinner.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result display ────────────────────────────────────────────────────────
|
||||
function displayError(summaryDiv, errorsDiv, resultsDiv, msg, detail) {
|
||||
if (summaryDiv) {
|
||||
summaryDiv.innerHTML = `<div class="alert alert-danger mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Import Failed:</strong> ${msg}
|
||||
</div>`;
|
||||
}
|
||||
if (errorsDiv) {
|
||||
errorsDiv.innerHTML = detail
|
||||
? `<details class="mt-2" open>
|
||||
<summary class="text-danger"><strong>Error Details</strong></summary>
|
||||
<pre class="mt-2 small bg-light p-2 rounded" style="max-height:300px;overflow-y:auto">${detail}</pre>
|
||||
</details>`
|
||||
: '';
|
||||
}
|
||||
if (resultsDiv) resultsDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function displaySuccess(summaryDiv, errorsDiv, resultsDiv, result) {
|
||||
// Normalise both QB-format (importedCount/totalRecords) and
|
||||
// CSV bulk-import format (successCount/totalRows) so this function
|
||||
// works regardless of which endpoint was called.
|
||||
const totalRecords = result.totalRecords ?? result.totalRows ?? 0;
|
||||
const importedCount = result.importedCount ?? result.successCount ?? 0;
|
||||
const updatedCount = result.updatedCount ?? 0;
|
||||
const skippedCount = result.skippedCount ?? 0;
|
||||
|
||||
// QB format puts everything in result.errors with a severity field.
|
||||
// CSV format puts error strings in result.errors and warning strings
|
||||
// in a separate result.warnings array. Normalise both into one list.
|
||||
const allMessages = [
|
||||
...(result.errors || []).map(function (e) {
|
||||
return typeof e === 'string' ? { severity: 'Error', displayMessage: e } : e;
|
||||
}),
|
||||
...(result.warnings || []).map(function (w) {
|
||||
return typeof w === 'string' ? { severity: 'Warning', displayMessage: w } : w;
|
||||
}),
|
||||
];
|
||||
|
||||
const errors = allMessages.filter(function (e) { return (e.severity || 'Error') === 'Error'; });
|
||||
const warnings = allMessages.filter(function (e) { return e.severity === 'Warning'; });
|
||||
const skipped = allMessages.filter(function (e) { return e.severity === 'Skipped'; });
|
||||
|
||||
if (summaryDiv) {
|
||||
const icon = errors.length > 0
|
||||
? '<i class="bi bi-exclamation-triangle text-warning me-1"></i>'
|
||||
: '<i class="bi bi-check-circle text-success me-1"></i>';
|
||||
summaryDiv.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
${icon}<strong>Results</strong>
|
||||
Total: <strong>${totalRecords}</strong>
|
||||
Imported: <span class="text-success fw-bold">${importedCount}</span>
|
||||
${updatedCount > 0 ? `Updated: <span class="text-info fw-bold">${updatedCount}</span> ` : ''}
|
||||
Skipped: <span class="text-warning fw-bold">${skippedCount}</span>
|
||||
${errors.length > 0 ? ` Errors: <span class="text-danger fw-bold">${errors.length}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm flex-shrink-0"
|
||||
onclick="downloadImportReport(this)"
|
||||
data-result='${JSON.stringify(result).replace(/'/g, ''')}'>
|
||||
<i class="bi bi-download me-1"></i>Download Report
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (errorsDiv) {
|
||||
let html = '';
|
||||
if (errors.length) {
|
||||
html += `<details class="mt-2" open>
|
||||
<summary class="text-danger"><strong>${errors.length} Error(s)</strong></summary>
|
||||
<ul class="mt-1 small mb-0">${errors.map(function (e) {
|
||||
return '<li>' + (e.displayMessage || e.errorMessage || JSON.stringify(e)) + '</li>';
|
||||
}).join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
if (warnings.length) {
|
||||
html += `<details class="mt-2">
|
||||
<summary class="text-warning"><strong>${warnings.length} Warning(s)</strong></summary>
|
||||
<ul class="mt-1 small mb-0">${warnings.map(function (e) {
|
||||
return '<li>' + (e.displayMessage || e.errorMessage) + '</li>';
|
||||
}).join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
if (skipped.length) {
|
||||
html += `<details class="mt-2">
|
||||
<summary class="text-secondary"><strong>${skipped.length} Skipped</strong></summary>
|
||||
<ul class="mt-1 small mb-0">${skipped.map(function (e) {
|
||||
return '<li>' + (e.recordName || '') + (e.errorMessage ? ' — ' + e.errorMessage : '') + '</li>';
|
||||
}).join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
errorsDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
if (resultsDiv) resultsDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// ── Download report (called from inline onclick) ──────────────────────────
|
||||
window.downloadImportReport = function (btn) {
|
||||
try {
|
||||
const result = JSON.parse(btn.getAttribute('data-result'));
|
||||
const importedCount = result.importedCount ?? result.successCount ?? 0;
|
||||
const updatedCount = result.updatedCount ?? 0;
|
||||
const skippedCount = result.skippedCount ?? 0;
|
||||
const rows = [['Status', 'Record', 'Field', 'Message']];
|
||||
rows.push(['Imported', importedCount, '', '']);
|
||||
if (updatedCount > 0) rows.push(['Updated', updatedCount, '', '']);
|
||||
rows.push(['Skipped', skippedCount, '', '']);
|
||||
// Normalise errors: QB format = objects, CSV format = plain strings
|
||||
const allErrors = [
|
||||
...(result.errors || []).map(function (e) {
|
||||
return typeof e === 'string' ? { severity: 'Error', recordName: '', fieldName: '', errorMessage: e } : e;
|
||||
}),
|
||||
...(result.warnings || []).map(function (w) {
|
||||
return typeof w === 'string' ? { severity: 'Warning', recordName: '', fieldName: '', errorMessage: w } : w;
|
||||
}),
|
||||
];
|
||||
allErrors.forEach(function (e) {
|
||||
rows.push([e.severity || 'Error', e.recordName || '', e.fieldName || '', e.errorMessage || '']);
|
||||
});
|
||||
const csv = rows.map(function (r) {
|
||||
return r.map(function (c) { return '"' + String(c).replace(/"/g, '""') + '"'; }).join(',');
|
||||
}).join('\r\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
||||
a.download = 'import-report-' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (err) {
|
||||
console.error('[Tools] Report error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||