Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -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;
}
Binary file not shown.

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.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 */
File diff suppressed because one or more lines are too long
@@ -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 */
File diff suppressed because one or more lines are too long
@@ -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 */
File diff suppressed because one or more lines are too long
@@ -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 */
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
}));
File diff suppressed because one or more lines are too long
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
}
+161
View File
@@ -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;
}
}
+127
View File
@@ -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;
}
}
+226
View File
@@ -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;
}
+600
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+412
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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();
+204
View File
@@ -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);
};
})();
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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
};
File diff suppressed because it is too large Load Diff
+312
View File
@@ -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);
}
};
+781
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
@@ -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 &amp; 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 &amp; 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 &amp; 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&hellip;
</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();
@@ -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);
+15
View File
@@ -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();
}
+116
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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">&times;</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;
+840
View File
@@ -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 &amp; 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 &amp; 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 &rarr; Customers &rarr; Vendors &rarr; Catalog Items &rarr; Inventory &rarr; Invoices &rarr; Transactions &rarr; Bills &amp; 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> &nbsp;
Total: <strong>${totalRecords}</strong> &nbsp;&nbsp;
Imported: <span class="text-success fw-bold">${importedCount}</span> &nbsp;&nbsp;
${updatedCount > 0 ? `Updated: <span class="text-info fw-bold">${updatedCount}</span> &nbsp;&nbsp;` : ''}
Skipped: <span class="text-warning fw-bold">${skippedCount}</span>
${errors.length > 0 ? ` &nbsp;&nbsp; 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, '&#39;')}'>
<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);
}
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More