Initial commit
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
* Appointment Calendar Styles
|
||||
* Supports week and month views with dark mode
|
||||
*/
|
||||
|
||||
/* ===== Day View ===== */
|
||||
.calendar-day-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.calendar-day-view-header {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.calendar-day-header-single {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-header-single.today {
|
||||
background: rgba(13, 110, 253, 0.08);
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-name-long {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-number-large {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-day-header-single.today .day-number-large {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-month-year {
|
||||
font-size: 1rem;
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-event-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.calendar-day-header-single .day-header-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-time-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 70px) 1fr;
|
||||
gap: 1px;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.calendar-day-time-row.has-events {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.calendar-day-time-cell {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day-time-cell:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* Day view event cards — wider with more detail */
|
||||
.calendar-event-day {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-event-day .event-location {
|
||||
font-size: 0.688rem;
|
||||
opacity: 0.85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Clickable day numbers in month and week views */
|
||||
.day-drill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-month-cell .cell-date.day-drill:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number.day-drill:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== Week View ===== */
|
||||
.calendar-week-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 600px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Smooth container transitions */
|
||||
#calendarContainer {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.calendar-week-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 70px) repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Spacer in week header that aligns with the time-label column below */
|
||||
.calendar-week-header-spacer {
|
||||
background: var(--bs-light);
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-day-header.today {
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header .day-header-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-day-header.today .day-number {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-day-header .day-header-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-time-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, 70px) repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
background: var(--bs-light);
|
||||
padding: 0.5rem 0.25rem;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary);
|
||||
font-weight: 500;
|
||||
word-break: keep-all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.calendar-time-cell:hover {
|
||||
background: var(--bs-light);
|
||||
}
|
||||
|
||||
/* ===== Month View ===== */
|
||||
.calendar-month-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-month-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-bottom: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.calendar-day-name {
|
||||
background: var(--bs-light);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-month-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--bs-border-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.calendar-week-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.calendar-month-cell {
|
||||
background: var(--bs-body-bg);
|
||||
min-height: 100px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-month-cell.other-month {
|
||||
background: var(--bs-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.calendar-month-cell.other-month .cell-date {
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
.calendar-month-cell.today {
|
||||
background: rgba(13, 110, 253, 0.05);
|
||||
border: 2px solid var(--bs-primary);
|
||||
}
|
||||
|
||||
.calendar-month-cell:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.calendar-month-cell .cell-date {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-month-cell.today .cell-date {
|
||||
color: var(--bs-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cell-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ===== Event Cards ===== */
|
||||
.calendar-event {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.calendar-event:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* All-day event modifier - black border */
|
||||
.all-day-event {
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .all-day-event {
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
/* Week view header events (compact style) */
|
||||
.calendar-event-header {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.688rem;
|
||||
line-height: 1.2;
|
||||
transition: transform 0.2s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event-header:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-event-header .event-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event .event-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.calendar-event .event-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event .event-customer {
|
||||
font-size: 0.688rem;
|
||||
opacity: 0.9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Event color classes - Appointments (all 20 colors) */
|
||||
.calendar-event-purple,
|
||||
.calendar-event-month.calendar-event-purple,
|
||||
.calendar-event-header.calendar-event-purple {
|
||||
background: #6f42c1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-green,
|
||||
.calendar-event-month.calendar-event-green,
|
||||
.calendar-event-header.calendar-event-green {
|
||||
background: #198754;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-blue,
|
||||
.calendar-event-month.calendar-event-blue,
|
||||
.calendar-event-header.calendar-event-blue {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-orange,
|
||||
.calendar-event-month.calendar-event-orange,
|
||||
.calendar-event-header.calendar-event-orange {
|
||||
background: #fd7e14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-red,
|
||||
.calendar-event-month.calendar-event-red,
|
||||
.calendar-event-header.calendar-event-red {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-yellow,
|
||||
.calendar-event-month.calendar-event-yellow,
|
||||
.calendar-event-header.calendar-event-yellow {
|
||||
background: #ffc107;
|
||||
color: #000 !important; /* Dark text for better contrast on yellow */
|
||||
}
|
||||
|
||||
.calendar-event-pink,
|
||||
.calendar-event-month.calendar-event-pink,
|
||||
.calendar-event-header.calendar-event-pink {
|
||||
background: #d63384;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-cyan,
|
||||
.calendar-event-month.calendar-event-cyan,
|
||||
.calendar-event-header.calendar-event-cyan {
|
||||
background: #0dcaf0;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-teal,
|
||||
.calendar-event-month.calendar-event-teal,
|
||||
.calendar-event-header.calendar-event-teal {
|
||||
background: #20c997;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-indigo,
|
||||
.calendar-event-month.calendar-event-indigo,
|
||||
.calendar-event-header.calendar-event-indigo {
|
||||
background: #6610f2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-lime,
|
||||
.calendar-event-month.calendar-event-lime,
|
||||
.calendar-event-header.calendar-event-lime {
|
||||
background: #84cc16;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-brown,
|
||||
.calendar-event-month.calendar-event-brown,
|
||||
.calendar-event-header.calendar-event-brown {
|
||||
background: #795548;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-gray,
|
||||
.calendar-event-month.calendar-event-gray,
|
||||
.calendar-event-header.calendar-event-gray {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Bootstrap utility color variants */
|
||||
.calendar-event-success,
|
||||
.calendar-event-month.calendar-event-success,
|
||||
.calendar-event-header.calendar-event-success {
|
||||
background: #198754;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-danger,
|
||||
.calendar-event-month.calendar-event-danger,
|
||||
.calendar-event-header.calendar-event-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-warning,
|
||||
.calendar-event-month.calendar-event-warning,
|
||||
.calendar-event-header.calendar-event-warning {
|
||||
background: #ffc107;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-info,
|
||||
.calendar-event-month.calendar-event-info,
|
||||
.calendar-event-header.calendar-event-info {
|
||||
background: #0dcaf0;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.calendar-event-primary,
|
||||
.calendar-event-month.calendar-event-primary,
|
||||
.calendar-event-header.calendar-event-primary {
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-secondary,
|
||||
.calendar-event-month.calendar-event-secondary,
|
||||
.calendar-event-header.calendar-event-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-event-dark,
|
||||
.calendar-event-month.calendar-event-dark,
|
||||
.calendar-event-header.calendar-event-dark {
|
||||
background: #212529;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Month view event compact style */
|
||||
.calendar-event-month {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.688rem;
|
||||
color: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.calendar-event-month:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-event-month .event-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-event-month .event-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-more {
|
||||
font-size: 0.688rem;
|
||||
color: var(--bs-secondary);
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-more:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* ===== Responsive Design ===== */
|
||||
|
||||
/* Large Desktop - Show everything with plenty of space */
|
||||
@media (min-width: 1400px) {
|
||||
.calendar-month-cell {
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
min-height: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard Desktop - Default styles work well */
|
||||
@media (min-width: 992px) and (max-width: 1399px) {
|
||||
.calendar-month-cell {
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet - Start compacting */
|
||||
@media (min-width: 768px) and (max-width: 991px) {
|
||||
.calendar-month-cell {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-name {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
font-size: 0.688rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-event .event-customer {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.688rem;
|
||||
padding: 0.375rem 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - Compact view with horizontal scroll */
|
||||
@media (max-width: 767px) {
|
||||
.calendar-week-view,
|
||||
.calendar-month-view {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.calendar-week-header,
|
||||
.calendar-month-header,
|
||||
.calendar-week-row {
|
||||
min-width: 650px;
|
||||
}
|
||||
|
||||
.calendar-time-row {
|
||||
min-width: 650px;
|
||||
}
|
||||
|
||||
.calendar-week-header {
|
||||
grid-template-columns: 45px repeat(7, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.calendar-time-row {
|
||||
grid-template-columns: 45px repeat(7, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.calendar-month-cell {
|
||||
min-height: 75px;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-time-cell {
|
||||
min-height: 45px;
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.calendar-day-header .day-name {
|
||||
font-size: 0.688rem;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
|
||||
.calendar-event .event-customer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-event-header {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.1875rem 0.25rem;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
}
|
||||
|
||||
.calendar-month-cell .cell-date {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Dark Mode Support ===== */
|
||||
[data-bs-theme="dark"] .calendar-day-header-single.today {
|
||||
background: rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-day-time-cell {
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-day-header.today {
|
||||
background: rgba(13, 110, 253, 0.2);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-month-cell.today {
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-month-cell.other-month {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .time-label {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-week-header-spacer {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .calendar-day-name {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* ===== Loading State ===== */
|
||||
#calendarContainer .spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
/* ===== Print Styles ===== */
|
||||
@media print {
|
||||
.calendar-time-cell:hover,
|
||||
.calendar-month-cell:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-event:hover,
|
||||
.calendar-event-month:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Schedule Sidebar ===== */
|
||||
.schedule-sidebar {
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.schedule-sidebar.sidebar-collapsed {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.schedule-sidebar-header {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.schedule-sidebar-body {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sidebar job cards */
|
||||
.sj-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.sj-card:hover {
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.sj-card.sj-dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sj-number {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.sj-customer {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sj-status {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.sj-due {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* Sidebar drop zone highlight */
|
||||
.sidebar-drag-over .card {
|
||||
border-color: var(--bs-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(13,110,253,.25);
|
||||
}
|
||||
|
||||
/* ===== Drag-over highlight for calendar cells ===== */
|
||||
.calendar-drag-over {
|
||||
background: rgba(13, 110, 253, 0.08) !important;
|
||||
outline: 2px dashed var(--bs-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ===== Dragging job event on calendar ===== */
|
||||
.event-dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Ensure calendar takes full remaining width */
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===== Collapsed sidebar — center the chevron button ===== */
|
||||
.schedule-sidebar.sidebar-collapsed .schedule-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.schedule-sidebar.sidebar-collapsed .schedule-sidebar-header > div {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== Job hover preview card ===== */
|
||||
.sjp-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.18);
|
||||
overflow: hidden;
|
||||
width: 260px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sjp-status-bar {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.sjp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.6rem 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
.sjp-job {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.sjp-customer {
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
padding: 0 0.6rem 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
.sjp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0 0.6rem;
|
||||
}
|
||||
|
||||
.sjp-overdue {
|
||||
color: #dc3545 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sjp-notes {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
margin: 0.25rem 0.6rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
padding-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sjp-items {
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
margin-top: 0.3rem;
|
||||
padding: 0.3rem 0.6rem 0.4rem;
|
||||
}
|
||||
|
||||
.sjp-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-body-color);
|
||||
padding: 0.05rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sjp-item-desc {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sjp-qty {
|
||||
font-weight: 700;
|
||||
color: var(--bs-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sjp-color-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--bs-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sjp-color {
|
||||
font-size: 0.68rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sjp-more {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Item row — flex on all sizes, stacks on mobile */
|
||||
.catalog-tree .item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item-row-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search inputs — fluid on mobile, capped on desktop */
|
||||
.catalog-search-group {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-category-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.catalog-search-group { max-width: 280px; }
|
||||
.catalog-category-select { max-width: 200px; }
|
||||
}
|
||||
|
||||
/* On very small screens, stack name above meta */
|
||||
@media (max-width: 480px) {
|
||||
.catalog-tree .item-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.item-row-name {
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-row-meta {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.catalog-tree .subcategory {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/* 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;
|
||||
background-color: transparent;
|
||||
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 !important;
|
||||
font-weight: 700;
|
||||
background-color: var(--bs-primary) !important;
|
||||
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 !important;
|
||||
background-color: var(--bs-primary) !important;
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* ── Dark mode explicit overrides for sub-tabs and PDF template tabs
|
||||
Bootstrap's nav-link uses `background: 0 0` (position only, not color) on
|
||||
<button> elements, which lets system button appearance bleed through in dark
|
||||
mode on some browsers. Force every state explicitly. */
|
||||
[data-bs-theme="dark"] #lookupSubTabs .nav-link {
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #lookupSubTabs .nav-link:hover:not(.active) {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-color: var(--bs-primary);
|
||||
color: var(--bs-emphasis-color);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #lookupSubTabs .nav-link.active {
|
||||
color: #fff !important;
|
||||
background-color: #0d6efd !important;
|
||||
border-color: #0d6efd !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #pdfTemplateTabs .nav-link {
|
||||
color: var(--bs-secondary-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #pdfTemplateTabs .nav-link:hover:not(.active) {
|
||||
color: var(--bs-emphasis-color);
|
||||
background-color: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #pdfTemplateTabs .nav-link.active {
|
||||
color: #fff !important;
|
||||
background-color: #0d6efd !important;
|
||||
border-color: #0d6efd #0d6efd var(--bs-body-bg) !important;
|
||||
}
|
||||
|
||||
/* Lookup tables */
|
||||
.lookup-table-wrapper {
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.lookup-table thead {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.lookup-table tbody tr:hover {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* System-defined badge */
|
||||
.badge.bg-secondary {
|
||||
background-color: var(--bs-secondary) !important;
|
||||
}
|
||||
|
||||
/* Color badge previews in table */
|
||||
.color-badge-preview {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grip handle for drag-and-drop (future feature) */
|
||||
.bi-grip-vertical {
|
||||
color: var(--bs-secondary);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.bi-grip-vertical:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.btn-action {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.lookup-loading {
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.lookup-empty-state {
|
||||
color: var(--bs-secondary);
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
/* Usage count */
|
||||
.usage-count {
|
||||
color: var(--bs-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Icon styling */
|
||||
.lookup-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Sortable.js drag-and-drop states */
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 1;
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.sortable-chosen {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* Make grip handle more visible on hover */
|
||||
.bi-grip-vertical {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
tr:hover .bi-grip-vertical {
|
||||
color: var(--bs-primary) !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
#lookupSubTabs .nav-link {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.lookup-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Job Photos Styles */
|
||||
.photo-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4/3;
|
||||
background: var(--bs-secondary-bg);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.photo-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-thumbnail {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.photo-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-type-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.photo-card:hover .photo-type-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
#viewPhotoImage {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#photoDetails {
|
||||
text-align: left;
|
||||
background: var(--bs-secondary-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-bs-theme="dark"] .photo-card {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .drop-zone {
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .drop-zone:hover,
|
||||
[data-bs-theme="dark"] .drop-zone.drag-over {
|
||||
border-color: var(--bs-primary);
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.photo-overlay {
|
||||
opacity: 1;
|
||||
padding: 0.5rem;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent);
|
||||
}
|
||||
|
||||
.photo-type-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* Mobile Card View Styles - Hidden on Desktop (≥992px) */
|
||||
.mobile-card-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Compact Stats Card for Mobile */
|
||||
.mobile-stats-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
/* Hide desktop table view on mobile */
|
||||
.table-responsive {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show mobile card view */
|
||||
.mobile-card-view {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Hide large stats cards on mobile */
|
||||
.stats-cards-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show compact stats card on mobile */
|
||||
.mobile-stats-compact {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-emphasis-color);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-stats-compact .stat-icon {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Card List */
|
||||
.mobile-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Individual Mobile Card */
|
||||
.mobile-data-card {
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-data-card:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Mobile Card Header */
|
||||
.mobile-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.mobile-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-card-title h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-emphasis-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-card-title small {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Mobile Card Body */
|
||||
.mobile-card-body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.mobile-card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-card-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-card-value {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--bs-body-color);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile Card Footer */
|
||||
.mobile-card-footer {
|
||||
padding: 1rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile-card-footer .btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.mobile-card-footer .btn-sm {
|
||||
min-height: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Badge overrides in mobile cards */
|
||||
.mobile-card-value .badge {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Icon spacing in mobile cards */
|
||||
.mobile-card-value i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Empty state in mobile */
|
||||
.mobile-card-view .text-center {
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.mobile-card-view .text-center i {
|
||||
font-size: 3rem;
|
||||
color: var(--bs-secondary-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
/* =============================================================
|
||||
PCL Design Tokens — v2 redesign
|
||||
============================================================= */
|
||||
:root {
|
||||
/* Neutrals — warm paper (light surface) */
|
||||
--pcl-ink: #0F0F10;
|
||||
--pcl-graphite: #1A1A1C;
|
||||
--pcl-slate: #3A3A3E;
|
||||
--pcl-steel: #6B6B70;
|
||||
--pcl-mute: #9A9A9F;
|
||||
--pcl-rule: #E4E2DC;
|
||||
--pcl-rule-soft: #EFEDE7;
|
||||
--pcl-paper: #FAFAF7;
|
||||
--pcl-paper-2: #F3F1EB;
|
||||
--pcl-card: #FFFFFF;
|
||||
|
||||
/* Signal — hue preserved across surfaces */
|
||||
--pcl-ember: oklch(0.68 0.17 50);
|
||||
--pcl-ok: oklch(0.62 0.11 155);
|
||||
--pcl-warn: oklch(0.76 0.13 80);
|
||||
--pcl-bad: oklch(0.60 0.19 25);
|
||||
--pcl-cool: oklch(0.58 0.09 240);
|
||||
|
||||
/* Chip tints — LIGHT surface */
|
||||
--pcl-ember-tint: oklch(0.95 0.04 55);
|
||||
--pcl-ember-ink: oklch(0.42 0.15 40);
|
||||
--pcl-ok-tint: oklch(0.95 0.04 155);
|
||||
--pcl-ok-ink: oklch(0.35 0.09 155);
|
||||
--pcl-warn-tint: oklch(0.96 0.05 85);
|
||||
--pcl-warn-ink: oklch(0.42 0.10 80);
|
||||
--pcl-bad-tint: oklch(0.95 0.04 25);
|
||||
--pcl-bad-ink: oklch(0.40 0.15 25);
|
||||
--pcl-cool-tint: oklch(0.95 0.03 240);
|
||||
--pcl-cool-ink: oklch(0.38 0.08 240);
|
||||
|
||||
/* Hairlines + focus */
|
||||
--pcl-swatch-edge: rgba(0, 0, 0, 0.14);
|
||||
--pcl-focus-offset: var(--pcl-paper);
|
||||
|
||||
/* Bootstrap binding */
|
||||
--bs-body-bg: var(--pcl-paper);
|
||||
--bs-body-color: var(--pcl-ink);
|
||||
--bs-border-color: var(--pcl-rule);
|
||||
--bs-primary: var(--pcl-ink);
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius: 6px;
|
||||
--radius-lg: 8px;
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Fraunces', Georgia, serif;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* DARK surface */
|
||||
[data-surface="ink"] {
|
||||
--pcl-paper: #0E0E10;
|
||||
--pcl-paper-2: #17171A;
|
||||
--pcl-card: #1C1C20;
|
||||
--pcl-ink: #F4F2EC;
|
||||
--pcl-graphite: #E8E6DF;
|
||||
--pcl-slate: #C2C0BA;
|
||||
--pcl-steel: #8A8883;
|
||||
--pcl-mute: #6A6864;
|
||||
|
||||
/* Rules — better contrast on --pcl-card */
|
||||
--pcl-rule: #34343A;
|
||||
--pcl-rule-soft: #26262B;
|
||||
|
||||
/* Chip tints — DARK surface */
|
||||
--pcl-ember-tint: oklch(0.26 0.06 55);
|
||||
--pcl-ember-ink: oklch(0.82 0.15 55);
|
||||
--pcl-ok-tint: oklch(0.24 0.05 155);
|
||||
--pcl-ok-ink: oklch(0.80 0.12 155);
|
||||
--pcl-warn-tint: oklch(0.26 0.05 85);
|
||||
--pcl-warn-ink: oklch(0.85 0.13 85);
|
||||
--pcl-bad-tint: oklch(0.26 0.07 25);
|
||||
--pcl-bad-ink: oklch(0.80 0.16 25);
|
||||
--pcl-cool-tint: oklch(0.26 0.04 240);
|
||||
--pcl-cool-ink: oklch(0.80 0.10 240);
|
||||
|
||||
/* Hairlines flip to light */
|
||||
--pcl-swatch-edge: rgba(255, 255, 255, 0.18);
|
||||
--pcl-focus-offset: var(--pcl-card);
|
||||
|
||||
/* Bootstrap binding */
|
||||
--bs-body-bg: var(--pcl-paper);
|
||||
--bs-body-color: var(--pcl-ink);
|
||||
--bs-border-color: var(--pcl-rule);
|
||||
--bs-primary: var(--pcl-ink);
|
||||
}
|
||||
|
||||
/* Mono utility */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "zero";
|
||||
}
|
||||
|
||||
/* Oven meter pulse — only permitted loop animation */
|
||||
@keyframes pcl-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; } /* 0.5 stays visible on dark surfaces */
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--pcl-ember);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px var(--pcl-focus-offset);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
PCL Status Chips
|
||||
============================================================= */
|
||||
.pcl-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10.5px/1.2 var(--font-mono);
|
||||
font-feature-settings: "zero";
|
||||
letter-spacing: 0.02em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pcl-chip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Foreground uses *-ink paired token — lightness-adjusted per surface */
|
||||
.pcl-chip-ok { background: var(--pcl-ok-tint); color: var(--pcl-ok-ink); }
|
||||
.pcl-chip-ok .pcl-chip-dot { background: var(--pcl-ok); }
|
||||
.pcl-chip-warn { background: var(--pcl-warn-tint); color: var(--pcl-warn-ink); }
|
||||
.pcl-chip-warn .pcl-chip-dot { background: var(--pcl-warn); }
|
||||
.pcl-chip-bad { background: var(--pcl-bad-tint); color: var(--pcl-bad-ink); }
|
||||
.pcl-chip-bad .pcl-chip-dot { background: var(--pcl-bad); }
|
||||
.pcl-chip-cool { background: var(--pcl-cool-tint); color: var(--pcl-cool-ink); }
|
||||
.pcl-chip-cool .pcl-chip-dot { background: var(--pcl-cool); }
|
||||
.pcl-chip-ember { background: var(--pcl-ember-tint); color: var(--pcl-ember-ink); }
|
||||
.pcl-chip-ember .pcl-chip-dot { background: var(--pcl-ember); }
|
||||
.pcl-chip-neutral { background: var(--pcl-paper-2); color: var(--pcl-slate); }
|
||||
.pcl-chip-neutral .pcl-chip-dot { background: var(--pcl-steel); }
|
||||
|
||||
/* =============================================================
|
||||
PCL Metric tile
|
||||
============================================================= */
|
||||
.pcl-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.pcl-metric-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--pcl-steel);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.pcl-metric-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--pcl-ink);
|
||||
}
|
||||
.pcl-metric-delta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.pcl-metric-delta.up { color: var(--pcl-ok); }
|
||||
.pcl-metric-delta.down { color: var(--pcl-bad); }
|
||||
|
||||
/* =============================================================
|
||||
PCL Powder swatch
|
||||
============================================================= */
|
||||
.pcl-swatch {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 0 1px var(--pcl-swatch-edge);
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
PCL Section Header
|
||||
============================================================= */
|
||||
.pcl-section-header {
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--pcl-rule);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.pcl-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--pcl-ink);
|
||||
}
|
||||
.pcl-section-meta {
|
||||
font-size: 12px;
|
||||
color: var(--pcl-steel);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
PCL Metric strip — replaces stats-cards-desktop + mobile-stats-compact
|
||||
============================================================= */
|
||||
.pcl-metric-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid var(--pcl-rule);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--pcl-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pcl-metric-strip-cell {
|
||||
flex: 1 1 120px;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-right: 1px solid var(--pcl-rule);
|
||||
}
|
||||
.pcl-metric-strip-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.pcl-metric-strip-cell {
|
||||
flex-basis: 50%;
|
||||
border-right: 1px solid var(--pcl-rule);
|
||||
border-bottom: 1px solid var(--pcl-rule);
|
||||
}
|
||||
.pcl-metric-strip-cell:nth-child(even) {
|
||||
border-right: none;
|
||||
}
|
||||
.pcl-metric-strip-cell:nth-last-child(-n+2):not(:nth-child(even) ~ *) {
|
||||
border-bottom: none;
|
||||
}
|
||||
.pcl-metric-strip-cell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Jobs list — hot-job 2px left edge on first cell
|
||||
============================================================= */
|
||||
.job-hot-cell {
|
||||
box-shadow: inset 2px 0 0 var(--pcl-bad);
|
||||
}
|
||||
|
||||
/* Table kicker headers — mono uppercase 10px */
|
||||
.table thead th.th-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pcl-steel);
|
||||
border-bottom: 1px solid var(--pcl-rule);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Quick-view pills row */
|
||||
.pcl-pill-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pcl-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--pcl-rule);
|
||||
background: var(--pcl-card);
|
||||
color: var(--pcl-steel);
|
||||
text-decoration: none;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pcl-pill:hover {
|
||||
background: var(--pcl-paper-2);
|
||||
color: var(--pcl-ink);
|
||||
border-color: var(--pcl-steel);
|
||||
}
|
||||
.pcl-pill.active {
|
||||
background: var(--pcl-ink);
|
||||
color: var(--pcl-paper);
|
||||
border-color: var(--pcl-ink);
|
||||
}
|
||||
.pcl-pill .pcl-pill-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Keyboard hint footer */
|
||||
.pcl-kbd-footer {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--pcl-mute);
|
||||
padding: 6px 16px;
|
||||
border-top: 1px solid var(--pcl-rule-soft);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Hover lift — list-item row hover in dashboard feeds
|
||||
============================================================= */
|
||||
.hover-lift {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.hover-lift:hover {
|
||||
background-color: var(--pcl-paper-2) !important;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Mono numerics — any td/th with class="num" renders in IBM Plex Mono
|
||||
============================================================= */
|
||||
.table td.num,
|
||||
.table th.num {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "zero";
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Toastr dark-surface overrides
|
||||
(Toastr doesn't follow data-bs-theme; override manually)
|
||||
============================================================= */
|
||||
[data-surface="ink"] #toast-container > .toast-success {
|
||||
background-color: var(--pcl-ok-tint);
|
||||
color: var(--pcl-ok-ink);
|
||||
}
|
||||
[data-surface="ink"] #toast-container > .toast-error {
|
||||
background-color: var(--pcl-bad-tint);
|
||||
color: var(--pcl-bad-ink);
|
||||
}
|
||||
[data-surface="ink"] #toast-container > .toast-warning {
|
||||
background-color: var(--pcl-warn-tint);
|
||||
color: var(--pcl-warn-ink);
|
||||
}
|
||||
[data-surface="ink"] #toast-container > .toast-info {
|
||||
background-color: var(--pcl-cool-tint);
|
||||
color: var(--pcl-cool-ink);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Theme toggle button
|
||||
============================================================= */
|
||||
.pcl-theme-toggle {
|
||||
color: var(--pcl-slate);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.pcl-theme-toggle:hover {
|
||||
color: var(--pcl-ink);
|
||||
background: var(--pcl-paper-2);
|
||||
border-color: var(--pcl-rule);
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Global focus ring — ember
|
||||
============================================================= */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--pcl-ember);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/* 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(--pcl-steel);
|
||||
background-color: var(--pcl-card);
|
||||
border-color: var(--pcl-rule);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--pcl-ink);
|
||||
border-color: var(--pcl-ink);
|
||||
color: var(--pcl-paper); /* flips correctly: dark bg+light text in light; light bg+dark text in dark */
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
color: var(--pcl-mute);
|
||||
pointer-events: none;
|
||||
background-color: var(--pcl-card);
|
||||
border-color: var(--pcl-rule);
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: var(--pcl-paper-2);
|
||||
border-color: var(--pcl-rule);
|
||||
color: var(--pcl-ink);
|
||||
}
|
||||
|
||||
.form-select-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness - Stats Cards (deprecated - now using compact card) */
|
||||
|
||||
/* Mobile Touch Optimization */
|
||||
@media (max-width: 768px) {
|
||||
/* Touch Target Sizes - Minimum 44px */
|
||||
.btn, .btn-sm {
|
||||
min-height: 44px;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
min-width: 44px;
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
min-height: 44px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Modal Optimization */
|
||||
.modal-dialog {
|
||||
margin: 0.5rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Form Layout Optimization */
|
||||
.row.g-3 > [class*="col-md-"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.row.g-3 > .col-6 {
|
||||
width: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Tab Selector for Settings Pages */
|
||||
#mobileTabSelector {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mobileTabSelector:focus {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Ensure tab content has proper spacing on mobile */
|
||||
.tab-content {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.tab-content .card {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Validation - Hide empty validation summary */
|
||||
.validation-summary-valid {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== Dark Mode Overrides ===== */
|
||||
|
||||
/* Equipment status badges */
|
||||
[data-bs-theme="dark"] .equipment-status-operational {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-needs-maintenance {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-under-maintenance {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-out-of-service {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .equipment-status-retired {
|
||||
background-color: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Maintenance status badges */
|
||||
[data-bs-theme="dark"] .maintenance-status-scheduled {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-in-progress {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-completed {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-overdue {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-status-cancelled {
|
||||
background-color: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Maintenance priority badges */
|
||||
[data-bs-theme="dark"] .maintenance-priority-low {
|
||||
background-color: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-priority-normal {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-priority-high {
|
||||
background-color: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .maintenance-priority-critical {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* bg-warning text-dark and bg-info text-dark badges:
|
||||
In dark mode Bootstrap changes --bs-dark to a light gray,
|
||||
which gives poor contrast on yellow/cyan backgrounds.
|
||||
Force dark text so contrast is maintained. */
|
||||
[data-bs-theme="dark"] .bg-warning {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bg-info {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tom Select — dark mode fixes
|
||||
Tom Select's Bootstrap 5 theme hardcodes color:#343a40 which
|
||||
is near-invisible on Bootstrap's dark body background. Using
|
||||
html prefix for maximum specificity; all colours hard-coded to
|
||||
avoid CSS variable resolution issues.
|
||||
============================================================ */
|
||||
|
||||
/* Control box */
|
||||
html[data-bs-theme="dark"] .ts-control {
|
||||
color: #dee2e6 !important;
|
||||
background-color: #212529 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
html[data-bs-theme="dark"] .ts-control input {
|
||||
color: #dee2e6 !important;
|
||||
}
|
||||
html[data-bs-theme="dark"] .ts-control input::placeholder {
|
||||
color: #6c757d !important;
|
||||
}
|
||||
|
||||
/* Selected item text */
|
||||
html[data-bs-theme="dark"] .ts-control .item {
|
||||
color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
/* Dropdown panel */
|
||||
html[data-bs-theme="dark"] .ts-dropdown {
|
||||
color: #dee2e6 !important;
|
||||
background-color: #2b3035 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
/* Every option row — override cascaded #343a40 from Tom Select theme */
|
||||
html[data-bs-theme="dark"] .ts-dropdown .option,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .no-results,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .optgroup-header,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .create {
|
||||
color: #dee2e6 !important;
|
||||
background-color: #2b3035 !important;
|
||||
}
|
||||
|
||||
/* Highlighted (hover/keyboard) option */
|
||||
html[data-bs-theme="dark"] .ts-dropdown .option.active,
|
||||
html[data-bs-theme="dark"] .ts-dropdown .active {
|
||||
background-color: #0d6efd !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Multi-select chips */
|
||||
html[data-bs-theme="dark"] .ts-wrapper.multi .ts-control > div {
|
||||
background: #343a40 !important;
|
||||
color: #dee2e6 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
/* Dropdown caret arrow — swap dark SVG for a light one */
|
||||
html[data-bs-theme="dark"] .ts-wrapper:not(.form-control, .form-select).single .ts-control {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-position: right .75rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px 12px;
|
||||
}
|
||||
|
||||
/* Search field inside open dropdown */
|
||||
html[data-bs-theme="dark"] .ts-dropdown .dropdown-input {
|
||||
color: #dee2e6 !important;
|
||||
background: #212529 !important;
|
||||
border-color: #495057 !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Item wizard (Quotes/Create, Jobs/Create, Jobs/Edit) — dark mode
|
||||
The inline styles in these views hardcode #fff/#fafafa/#dee2e6.
|
||||
============================================================ */
|
||||
[data-bs-theme="dark"] .item-type-card {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .item-type-card:hover {
|
||||
border-color: #86b7fe !important;
|
||||
background: rgba(13, 110, 253, 0.15) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .item-type-card.selected {
|
||||
border-color: #0d6efd !important;
|
||||
background: rgba(13, 110, 253, 0.2) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .quote-item-card {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .coat-row {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .wizard-step-dot {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .wizard-step-line {
|
||||
background: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tag chip input widget
|
||||
============================================================ */
|
||||
|
||||
/* Chips area sits below the input — input height never changes */
|
||||
.tag-chips-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Remove × inside each chip */
|
||||
.tag-chip .tag-remove {
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tag-chip .tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Clickable tag links in Index grids — hover darkens slightly */
|
||||
a.tag-index-badge:hover {
|
||||
filter: brightness(0.88);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* AI Help Widget
|
||||
* Floating chat assistant for Powder Coating Logix.
|
||||
*
|
||||
* Persistence: panel open/closed state, conversation history, rendered messages,
|
||||
* and panel height are all stored in sessionStorage so they survive page navigation.
|
||||
* Everything resets when the browser tab/session ends.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const CHAT_ENDPOINT = '/AiHelp/Chat';
|
||||
const STORAGE_KEY = 'aiHelpState';
|
||||
const MIN_HEIGHT = 300;
|
||||
const MAX_HEIGHT_VH = 0.85; // 85vh
|
||||
|
||||
// Conversation history for API: [{role:'user'|'assistant', content:'...'}]
|
||||
let history = [];
|
||||
|
||||
// Rendered message store for restoring across navigation: [{role, content, html}]
|
||||
let renderedMessages = [];
|
||||
|
||||
let isOpen = false;
|
||||
let isSending = false;
|
||||
let startersShown = true;
|
||||
let panelHeight = 520;
|
||||
|
||||
// DOM refs
|
||||
let btn, panel, messagesEl, inputEl, sendBtn, clearBtn, closeBtn,
|
||||
typingEl, startersEl, tokenEl, resizeHandle;
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
btn = document.getElementById('ai-help-btn');
|
||||
panel = document.getElementById('ai-help-panel');
|
||||
messagesEl = document.getElementById('ai-help-messages');
|
||||
inputEl = document.getElementById('ai-help-input');
|
||||
sendBtn = document.getElementById('ai-help-send');
|
||||
clearBtn = document.getElementById('ai-help-clear');
|
||||
closeBtn = document.getElementById('ai-help-close');
|
||||
typingEl = document.getElementById('ai-help-typing');
|
||||
startersEl = document.getElementById('ai-help-starters');
|
||||
tokenEl = document.getElementById('ai-help-token');
|
||||
resizeHandle = document.getElementById('ai-help-resize');
|
||||
|
||||
if (!btn || !panel) return;
|
||||
|
||||
btn.addEventListener('click', togglePanel);
|
||||
closeBtn.addEventListener('click', closePanel);
|
||||
clearBtn.addEventListener('click', clearChat);
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
inputEl.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.ai-help-starter-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
inputEl.value = b.dataset.q;
|
||||
sendMessage();
|
||||
});
|
||||
});
|
||||
|
||||
// Intercept help links inside the widget — navigate without closing
|
||||
messagesEl.addEventListener('click', function (e) {
|
||||
const link = e.target.closest('[data-help-link]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
const href = link.getAttribute('href');
|
||||
if (href) window.location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
// Save accurate open state right before any navigation so the new page
|
||||
// can restore correctly. This runs after any click handlers that might
|
||||
// have changed isOpen, giving us the true final state.
|
||||
window.addEventListener('beforeunload', saveState);
|
||||
|
||||
initResize();
|
||||
restoreState();
|
||||
}
|
||||
|
||||
// ── Resize ───────────────────────────────────────────────────────────────
|
||||
|
||||
function initResize() {
|
||||
if (!resizeHandle) return;
|
||||
|
||||
let startY = 0;
|
||||
let startH = 0;
|
||||
let dragging = false;
|
||||
|
||||
resizeHandle.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
startY = e.clientY;
|
||||
startH = panel.offsetHeight;
|
||||
resizeHandle.classList.add('dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (!dragging) return;
|
||||
// Panel grows upward: dragging mouse UP increases height
|
||||
const delta = startY - e.clientY;
|
||||
const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH);
|
||||
panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta));
|
||||
panel.style.height = panelHeight + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function () {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
resizeHandle.classList.remove('dragging');
|
||||
document.body.style.userSelect = '';
|
||||
saveState();
|
||||
});
|
||||
|
||||
// Touch support
|
||||
resizeHandle.addEventListener('touchstart', function (e) {
|
||||
const touch = e.touches[0];
|
||||
startY = touch.clientY;
|
||||
startH = panel.offsetHeight;
|
||||
dragging = true;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
if (!dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const delta = startY - touch.clientY;
|
||||
const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH);
|
||||
panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta));
|
||||
panel.style.height = panelHeight + 'px';
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function () {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
saveState();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Panel open/close ─────────────────────────────────────────────────────
|
||||
|
||||
function togglePanel() {
|
||||
if (isOpen) { closePanel(); } else { openPanel(); }
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panel.hidden = false;
|
||||
panel.style.height = panelHeight + 'px';
|
||||
isOpen = true;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
inputEl.focus();
|
||||
scrollToBottom();
|
||||
saveState();
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
isOpen = false;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ── Clear chat ───────────────────────────────────────────────────────────
|
||||
|
||||
function clearChat() {
|
||||
history = [];
|
||||
renderedMessages = [];
|
||||
startersShown = true;
|
||||
|
||||
// Remove all messages except the static welcome message (first child)
|
||||
const msgs = Array.from(messagesEl.children);
|
||||
msgs.slice(1).forEach(function (el) { el.remove(); });
|
||||
|
||||
startersEl.hidden = false;
|
||||
inputEl.value = '';
|
||||
inputEl.focus();
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ── Send message ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendMessage() {
|
||||
if (isSending) return;
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
startersEl.hidden = true;
|
||||
startersShown = false;
|
||||
inputEl.value = '';
|
||||
|
||||
appendMessage('user', text, escapeHtml(text));
|
||||
scrollToBottom();
|
||||
saveState();
|
||||
|
||||
isSending = true;
|
||||
sendBtn.disabled = true;
|
||||
typingEl.classList.remove('d-none');
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const token = tokenEl ? tokenEl.value : '';
|
||||
// Send history BEFORE appending the current user message
|
||||
// (appendMessage already pushed it, so slice off the last entry)
|
||||
const payload = {
|
||||
message: text,
|
||||
history: history.slice(0, -1)
|
||||
};
|
||||
|
||||
const res = await fetch(CHAT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
typingEl.classList.add('d-none');
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const msg = err.error || 'Something went wrong. Please try again.';
|
||||
appendMessage('assistant', msg, renderMarkdown(msg));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const reply = data.response || 'No response received.';
|
||||
appendMessage('assistant', reply, renderMarkdown(reply));
|
||||
}
|
||||
} catch (err) {
|
||||
typingEl.classList.add('d-none');
|
||||
const msg = 'Unable to connect. Please check your connection and try again.';
|
||||
appendMessage('assistant', msg, renderMarkdown(msg));
|
||||
} finally {
|
||||
isSending = false;
|
||||
sendBtn.disabled = false;
|
||||
scrollToBottom();
|
||||
saveState();
|
||||
inputEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Append a message bubble ───────────────────────────────────────────────
|
||||
|
||||
function appendMessage(role, content, html) {
|
||||
history.push({ role: role, content: content });
|
||||
renderedMessages.push({ role: role, content: content, html: html });
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ai-help-msg ai-help-msg--' + role;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'ai-help-msg-bubble';
|
||||
bubble.innerHTML = html;
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
messagesEl.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// ── Restore bubble from stored HTML (safe — came from our own renderer) ──
|
||||
|
||||
function restoreMessage(role, html) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ai-help-msg ai-help-msg--' + role;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'ai-help-msg-bubble';
|
||||
bubble.innerHTML = html;
|
||||
|
||||
wrapper.appendChild(bubble);
|
||||
messagesEl.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// ── sessionStorage persistence ────────────────────────────────────────────
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
open: isOpen,
|
||||
history: history,
|
||||
renderedMessages: renderedMessages,
|
||||
startersShown: startersShown,
|
||||
panelHeight: panelHeight
|
||||
}));
|
||||
} catch (_) { /* sessionStorage full or unavailable — ignore */ }
|
||||
}
|
||||
|
||||
function restoreState() {
|
||||
let saved;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
saved = JSON.parse(raw);
|
||||
} catch (_) { return; }
|
||||
|
||||
if (!saved) return;
|
||||
|
||||
// Restore height first (before showing panel to avoid flicker)
|
||||
if (saved.panelHeight && saved.panelHeight >= MIN_HEIGHT) {
|
||||
panelHeight = saved.panelHeight;
|
||||
panel.style.height = panelHeight + 'px';
|
||||
}
|
||||
|
||||
// Restore messages (skip if only the welcome message was ever shown)
|
||||
if (saved.renderedMessages && saved.renderedMessages.length > 0) {
|
||||
history = saved.history || [];
|
||||
renderedMessages = saved.renderedMessages || [];
|
||||
|
||||
saved.renderedMessages.forEach(function (m) {
|
||||
restoreMessage(m.role, m.html);
|
||||
});
|
||||
|
||||
if (!saved.startersShown) {
|
||||
startersEl.hidden = true;
|
||||
startersShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore open state last
|
||||
if (saved.open) {
|
||||
openPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minimal markdown renderer ─────────────────────────────────────────────
|
||||
|
||||
function renderMarkdown(text) {
|
||||
let html = escapeHtml(text);
|
||||
|
||||
// Fenced code blocks
|
||||
html = html.replace(/```[\s\S]*?```/g, function (match) {
|
||||
const code = match.slice(3, -3).replace(/^\n/, '');
|
||||
return '<pre style="font-size:0.8rem;overflow-x:auto;"><code>' + code + '</code></pre>';
|
||||
});
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
// Markdown links — only /relative or https:// allowed
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) {
|
||||
const safe = url.startsWith('/') || url.startsWith('https://') || url.startsWith('http://');
|
||||
if (!safe) return escapeHtml(linkText);
|
||||
return '<a href="' + escapeAttr(url) + '" data-help-link="true">' + linkText + '</a>';
|
||||
});
|
||||
|
||||
// Build line-by-line for lists
|
||||
const lines = html.split('\n');
|
||||
const result = [];
|
||||
let inUl = false, inOl = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const ulMatch = line.match(/^(\s*)[-*]\s+(.+)/);
|
||||
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
|
||||
|
||||
if (ulMatch) {
|
||||
if (!inUl) { if (inOl) { result.push('</ol>'); inOl = false; } result.push('<ul>'); inUl = true; }
|
||||
result.push('<li>' + ulMatch[2] + '</li>');
|
||||
} else if (olMatch) {
|
||||
if (!inOl) { if (inUl) { result.push('</ul>'); inUl = false; } result.push('<ol>'); inOl = true; }
|
||||
result.push('<li>' + olMatch[2] + '</li>');
|
||||
} else {
|
||||
if (inUl) { result.push('</ul>'); inUl = false; }
|
||||
if (inOl) { result.push('</ol>'); inOl = false; }
|
||||
result.push(line.trim() === '' ? '<br>' : '<p>' + line + '</p>');
|
||||
}
|
||||
}
|
||||
if (inUl) result.push('</ul>');
|
||||
if (inOl) result.push('</ol>');
|
||||
|
||||
return result.join('\n')
|
||||
.replace(/(<br>\s*){2,}/g, '<br>')
|
||||
.replace(/<p>\s*<\/p>/g, '');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(function () {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Appointment Calendar / Schedule Module
|
||||
* Handles day, week and month calendar views for appointments, jobs, and maintenance
|
||||
*/
|
||||
const appointmentCalendar = {
|
||||
currentView: 'week',
|
||||
currentDate: new Date(),
|
||||
events: [],
|
||||
unscheduledJobs: [],
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Init
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
init: function(view = 'week', date = new Date()) {
|
||||
this.currentView = view;
|
||||
this.currentDate = new Date(date);
|
||||
this.updateViewButtons();
|
||||
this.attachEventListeners();
|
||||
this.loadAndRender();
|
||||
this.loadUnscheduledJobs();
|
||||
this.setupSidebarToggle();
|
||||
this.setupSidebarDropZone();
|
||||
},
|
||||
|
||||
attachEventListeners: function() {
|
||||
document.getElementById('btnPrevious').addEventListener('click', () => this.goToPrevious());
|
||||
document.getElementById('btnNext').addEventListener('click', () => this.goToNext());
|
||||
document.getElementById('btnToday').addEventListener('click', () => this.goToToday());
|
||||
document.getElementById('btnDayView').addEventListener('click', () => this.switchView('day'));
|
||||
document.getElementById('btnWeekView').addEventListener('click', () => this.switchView('week'));
|
||||
document.getElementById('btnMonthView').addEventListener('click', () => this.switchView('month'));
|
||||
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => this.renderCalendar(), 250);
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Load & Render
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAndRender: async function() {
|
||||
const { start, end } = this.getDateRange();
|
||||
await this.loadEvents(start, end);
|
||||
this.renderCalendar();
|
||||
this.updateCurrentDateDisplay();
|
||||
this.updateURL();
|
||||
},
|
||||
|
||||
loadEvents: async function(startDate, endDate) {
|
||||
try {
|
||||
const url = `/Appointments/GetCalendarEvents?start=${startDate.toISOString()}&end=${endDate.toISOString()}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to load events');
|
||||
this.events = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error loading events:', error);
|
||||
this.events = [];
|
||||
}
|
||||
},
|
||||
|
||||
renderCalendar: function() {
|
||||
const container = document.getElementById('calendarContainer');
|
||||
if (this.currentView === 'day') {
|
||||
container.innerHTML = this.renderDayView();
|
||||
} else if (this.currentView === 'week') {
|
||||
container.innerHTML = this.renderWeekView();
|
||||
} else {
|
||||
container.innerHTML = this.renderMonthView();
|
||||
}
|
||||
this.attachEventClickHandlers();
|
||||
this.attachDropHandlers();
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Unscheduled Jobs Sidebar
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
loadUnscheduledJobs: async function() {
|
||||
try {
|
||||
const resp = await fetch('/Appointments/GetUnscheduledJobs');
|
||||
this.unscheduledJobs = await resp.json();
|
||||
} catch (e) {
|
||||
this.unscheduledJobs = [];
|
||||
}
|
||||
this.renderUnscheduledPanel();
|
||||
},
|
||||
|
||||
renderUnscheduledPanel: function() {
|
||||
const panel = document.getElementById('unscheduledJobsPanel');
|
||||
const countEl = document.getElementById('unscheduledCount');
|
||||
if (!panel) return;
|
||||
|
||||
const count = this.unscheduledJobs.length;
|
||||
if (countEl) countEl.textContent = count > 0 ? `${count} job${count === 1 ? '' : 's'}` : '';
|
||||
|
||||
if (count === 0) {
|
||||
panel.innerHTML = '<div class="text-muted small text-center py-3"><i class="bi bi-check-circle me-1"></i>All jobs scheduled</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
this.unscheduledJobs.forEach(job => {
|
||||
let dueLine = '';
|
||||
if (job.dueDate) {
|
||||
const cls = job.isOverdue ? 'text-danger fw-semibold' : 'text-muted';
|
||||
dueLine = `<div class="sj-due ${cls}"><i class="bi bi-calendar-event me-1"></i>${this.formatDateShort(job.dueDate)}${job.isOverdue ? ' ⚠' : ''}</div>`;
|
||||
}
|
||||
const previewData = JSON.stringify({
|
||||
jobNumber: job.jobNumber,
|
||||
customerName: job.customerName,
|
||||
statusName: job.statusName,
|
||||
color: job.color,
|
||||
dueDate: job.dueDate,
|
||||
isOverdue: job.isOverdue,
|
||||
quotedPrice: job.quotedPrice,
|
||||
specialInstructions: job.specialInstructions,
|
||||
items: job.items || [],
|
||||
itemCount: job.itemCount || 0
|
||||
});
|
||||
html += `
|
||||
<div class="sj-card" draggable="true"
|
||||
data-job-id="${job.id}"
|
||||
data-preview="${encodeURIComponent(previewData)}"
|
||||
style="border-left:3px solid ${this.escapeHtml(job.color)}">
|
||||
<div class="sj-number">${this.escapeHtml(job.jobNumber)}</div>
|
||||
<div class="sj-customer">${this.escapeHtml(job.customerName)}</div>
|
||||
<div class="sj-status" style="color:${this.escapeHtml(job.color)}">${this.escapeHtml(job.statusName)}</div>
|
||||
${dueLine}
|
||||
</div>`;
|
||||
});
|
||||
panel.innerHTML = html;
|
||||
this.attachSidebarDragHandlers();
|
||||
this.attachHoverPreviewHandlers();
|
||||
},
|
||||
|
||||
attachHoverPreviewHandlers: function() {
|
||||
const popout = document.getElementById('sjPreviewCard');
|
||||
if (!popout) return;
|
||||
|
||||
document.querySelectorAll('.sj-card').forEach(card => {
|
||||
card.addEventListener('mouseenter', (e) => {
|
||||
let data;
|
||||
try { data = JSON.parse(decodeURIComponent(card.dataset.preview)); } catch { return; }
|
||||
popout.innerHTML = this.buildPreviewHtml(data);
|
||||
popout.style.display = 'block';
|
||||
this.positionPreview(e);
|
||||
});
|
||||
card.addEventListener('mousemove', (e) => this.positionPreview(e));
|
||||
card.addEventListener('mouseleave', () => { popout.style.display = 'none'; });
|
||||
card.addEventListener('dragstart', () => { popout.style.display = 'none'; });
|
||||
});
|
||||
},
|
||||
|
||||
positionPreview: function(e) {
|
||||
const popout = document.getElementById('sjPreviewCard');
|
||||
if (!popout) return;
|
||||
const offset = 14;
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const pw = popout.offsetWidth || 240, ph = popout.offsetHeight || 160;
|
||||
let left = e.clientX + offset;
|
||||
let top = e.clientY + offset;
|
||||
if (left + pw > vw - 8) left = e.clientX - pw - offset;
|
||||
if (top + ph > vh - 8) top = e.clientY - ph - offset;
|
||||
popout.style.left = left + 'px';
|
||||
popout.style.top = top + 'px';
|
||||
},
|
||||
|
||||
buildPreviewHtml: function(d) {
|
||||
const dueLine = d.dueDate
|
||||
? `<div class="sjp-row ${d.isOverdue ? 'sjp-overdue' : ''}">
|
||||
<i class="bi bi-calendar-event"></i>
|
||||
Due ${this.formatDateShort(d.dueDate)}${d.isOverdue ? ' — Overdue' : ''}
|
||||
</div>` : '';
|
||||
const priceLine = d.quotedPrice > 0
|
||||
? `<div class="sjp-row"><i class="bi bi-currency-dollar"></i>Quoted $${Number(d.quotedPrice).toFixed(2)}</div>` : '';
|
||||
const notesLine = d.specialInstructions
|
||||
? `<div class="sjp-notes">${this.escapeHtml(d.specialInstructions)}</div>` : '';
|
||||
|
||||
let itemsHtml = '';
|
||||
if (d.items && d.items.length > 0) {
|
||||
itemsHtml = '<div class="sjp-items">';
|
||||
d.items.forEach(item => {
|
||||
const colorDot = item.colorName
|
||||
? `<span class="sjp-color-dot" title="${this.escapeHtml(item.colorName)}"></span><span class="sjp-color">${this.escapeHtml(item.colorName)}</span>`
|
||||
: '';
|
||||
itemsHtml += `<div class="sjp-item"><span class="sjp-qty">×${item.quantity}</span><span class="sjp-item-desc">${this.escapeHtml(item.description)}</span>${colorDot ? colorDot : ''}</div>`;
|
||||
});
|
||||
if (d.itemCount > d.items.length) {
|
||||
itemsHtml += `<div class="sjp-item sjp-more">+ ${d.itemCount - d.items.length} more item${d.itemCount - d.items.length > 1 ? 's' : ''}</div>`;
|
||||
}
|
||||
itemsHtml += '</div>';
|
||||
}
|
||||
|
||||
return `<div class="sjp-card">
|
||||
<div class="sjp-status-bar" style="background:${this.escapeHtml(d.color)}">${this.escapeHtml(d.statusName)}</div>
|
||||
<div class="sjp-header" style="border-left:3px solid ${this.escapeHtml(d.color)}">
|
||||
<span class="sjp-job">${this.escapeHtml(d.jobNumber)}</span>
|
||||
</div>
|
||||
<div class="sjp-customer">${this.escapeHtml(d.customerName)}</div>
|
||||
${dueLine}${priceLine}${notesLine}
|
||||
${itemsHtml}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
attachSidebarDragHandlers: function() {
|
||||
document.querySelectorAll('.sj-card').forEach(card => {
|
||||
card.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'job',
|
||||
jobId: card.dataset.jobId,
|
||||
fromDate: null
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
card.classList.add('sj-dragging');
|
||||
});
|
||||
card.addEventListener('dragend', () => card.classList.remove('sj-dragging'));
|
||||
});
|
||||
},
|
||||
|
||||
setupSidebarDropZone: function() {
|
||||
const panel = document.getElementById('unscheduledJobsPanel');
|
||||
if (!panel) return;
|
||||
const sidebar = document.getElementById('unscheduledSidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
[panel, sidebar].forEach(el => {
|
||||
el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
sidebar.classList.add('sidebar-drag-over');
|
||||
});
|
||||
el.addEventListener('dragleave', () => sidebar.classList.remove('sidebar-drag-over'));
|
||||
el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
sidebar.classList.remove('sidebar-drag-over');
|
||||
let data;
|
||||
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
|
||||
if (data.type === 'job' && data.fromDate) {
|
||||
this.scheduleJob(data.jobId, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setupSidebarToggle: function() {
|
||||
const btn = document.getElementById('btnCollapseSidebar');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
const sidebar = document.getElementById('unscheduledSidebar');
|
||||
const chevron = document.getElementById('sidebarChevron');
|
||||
const body = document.getElementById('unscheduledJobsPanel');
|
||||
const countEl = document.getElementById('unscheduledCount');
|
||||
const titleEl = document.getElementById('sidebarTitleText');
|
||||
if (this.sidebarCollapsed) {
|
||||
sidebar.classList.add('sidebar-collapsed');
|
||||
if (chevron) { chevron.classList.remove('bi-chevron-left'); chevron.classList.add('bi-chevron-right'); }
|
||||
if (body) body.style.display = 'none';
|
||||
if (countEl) countEl.style.display = 'none';
|
||||
if (titleEl) titleEl.style.display = 'none';
|
||||
} else {
|
||||
sidebar.classList.remove('sidebar-collapsed');
|
||||
if (chevron) { chevron.classList.remove('bi-chevron-right'); chevron.classList.add('bi-chevron-left'); }
|
||||
if (body) body.style.display = '';
|
||||
if (countEl) countEl.style.display = '';
|
||||
if (titleEl) titleEl.style.display = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Drag & Drop — Drop Handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
attachDropHandlers: function() {
|
||||
// Week view: day header columns
|
||||
document.querySelectorAll('.calendar-day-header[data-date]').forEach(cell => {
|
||||
this.makeDayDropTarget(cell);
|
||||
});
|
||||
// Month view: month cells
|
||||
document.querySelectorAll('.calendar-month-cell[data-date]').forEach(cell => {
|
||||
this.makeDayDropTarget(cell);
|
||||
});
|
||||
// Day view: single day header
|
||||
const dayHeader = document.querySelector('.calendar-day-header-single[data-date]');
|
||||
if (dayHeader) this.makeDayDropTarget(dayHeader);
|
||||
},
|
||||
|
||||
makeDayDropTarget: function(cell) {
|
||||
cell.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cell.classList.add('calendar-drag-over');
|
||||
});
|
||||
cell.addEventListener('dragleave', (e) => {
|
||||
if (!cell.contains(e.relatedTarget)) {
|
||||
cell.classList.remove('calendar-drag-over');
|
||||
}
|
||||
});
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cell.classList.remove('calendar-drag-over');
|
||||
let data;
|
||||
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
|
||||
if (data.type !== 'job') return;
|
||||
const dateStr = cell.dataset.date;
|
||||
if (!dateStr) return;
|
||||
this.scheduleJob(data.jobId, new Date(dateStr));
|
||||
});
|
||||
},
|
||||
|
||||
scheduleJob: async function(jobId, date) {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
const params = new URLSearchParams({ id: jobId });
|
||||
if (date) params.append('date', date.toISOString().split('T')[0]);
|
||||
if (token) params.append('__RequestVerificationToken', token);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/Appointments/ScheduleJob', { method: 'POST', body: params });
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.success) {
|
||||
await this.loadAndRender();
|
||||
await this.loadUnscheduledJobs();
|
||||
if (result.removedFromBatch) {
|
||||
this.showToast(`Job scheduled. Removed from oven batch ${this.escapeHtml(result.removedFromBatch)}.`, 'warning');
|
||||
} else if (date) {
|
||||
this.showToast('Job scheduled.', 'success');
|
||||
} else {
|
||||
this.showToast('Job unscheduled.', 'info');
|
||||
}
|
||||
} else {
|
||||
this.showToast(result.message || 'Failed to update job.', 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Failed to update job.', 'danger');
|
||||
}
|
||||
},
|
||||
|
||||
showToast: function(message, type = 'success') {
|
||||
const container = document.getElementById('scheduleToastContainer');
|
||||
if (!container) return;
|
||||
const id = 'toast-' + Date.now();
|
||||
const bgMap = { success: 'bg-success', warning: 'bg-warning text-dark', info: 'bg-info text-dark', danger: 'bg-danger' };
|
||||
const cls = bgMap[type] || 'bg-secondary';
|
||||
container.insertAdjacentHTML('beforeend', `
|
||||
<div id="${id}" class="toast align-items-center ${cls} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${message}</div>
|
||||
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>`);
|
||||
const el = document.getElementById(id);
|
||||
if (el && window.bootstrap) new bootstrap.Toast(el, { delay: 4000 }).show();
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Day View
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderDayView: function() {
|
||||
const day = this.currentDate;
|
||||
const isToday = this.isToday(day);
|
||||
const allDayEvents = this.getAllDayEventsForDay(day);
|
||||
const totalEvents = this.getEventsForDay(day).length;
|
||||
|
||||
let html = '<div class="calendar-day-view">';
|
||||
html += '<div class="calendar-day-view-header">';
|
||||
html += `<div class="calendar-day-header-single ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
|
||||
html += '<div class="day-header-top">';
|
||||
html += `<div class="day-name-long">${this.getDayName(day, 'long')}</div>`;
|
||||
html += `<div class="day-number-large">${day.getDate()}</div>`;
|
||||
html += `<div class="day-month-year">${this.getMonthName(day)} ${day.getFullYear()}</div>`;
|
||||
if (totalEvents > 0) {
|
||||
html += `<div class="day-event-count ms-auto"><span class="badge bg-primary">${totalEvents} event${totalEvents === 1 ? '' : 's'}</span></div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (allDayEvents.length > 0) {
|
||||
html += '<div class="day-header-events">';
|
||||
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="calendar-day-grid">';
|
||||
for (let hour = 6; hour < 21; hour++) {
|
||||
const hourEvents = this.getEventsForDayHour(day, hour);
|
||||
html += `<div class="calendar-day-time-row ${hourEvents.length > 0 ? 'has-events' : ''}">`;
|
||||
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
|
||||
html += '<div class="calendar-day-time-cell">';
|
||||
hourEvents.forEach(event => html += this.renderEventCard(event, 'day'));
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Week View
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderWeekView: function() {
|
||||
const { start } = this.getDateRange();
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
let html = '<div class="calendar-week-view">';
|
||||
html += '<div class="calendar-week-header">';
|
||||
html += '<div class="calendar-week-header-spacer"></div>';
|
||||
|
||||
days.forEach(day => {
|
||||
const isToday = this.isToday(day);
|
||||
const allDayEvents = this.getAllDayEventsForDay(day);
|
||||
|
||||
html += `<div class="calendar-day-header ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
|
||||
html += '<div class="day-header-top">';
|
||||
html += `<div class="day-name">${this.getDayName(day, 'short')}</div>`;
|
||||
html += `<div class="day-number day-drill" data-day-date="${day.toISOString()}" title="View ${this.getDayName(day, 'long')} ${day.getDate()}">${day.getDate()}</div>`;
|
||||
html += '</div>';
|
||||
|
||||
if (allDayEvents.length > 0) {
|
||||
html += '<div class="day-header-events">';
|
||||
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += '<div class="calendar-week-grid">';
|
||||
|
||||
for (let hour = 6; hour < 20; hour++) {
|
||||
html += '<div class="calendar-time-row">';
|
||||
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
|
||||
days.forEach(day => {
|
||||
const dayEvents = this.getEventsForDayHour(day, hour);
|
||||
html += `<div class="calendar-time-cell" data-date="${day.toISOString()}" data-hour="${hour}">`;
|
||||
dayEvents.forEach(event => html += this.renderEventCard(event, 'week'));
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Month View
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderMonthView: function() {
|
||||
const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
|
||||
const calendarStart = new Date(firstDay);
|
||||
calendarStart.setDate(calendarStart.getDate() - ((calendarStart.getDay() + 6) % 7));
|
||||
|
||||
let html = '<div class="calendar-month-view">';
|
||||
html += '<div class="calendar-month-header">';
|
||||
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(name => {
|
||||
html += `<div class="calendar-day-name">${name}</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div class="calendar-month-grid">';
|
||||
|
||||
let currentDay = new Date(calendarStart);
|
||||
for (let week = 0; week < 6; week++) {
|
||||
html += '<div class="calendar-week-row">';
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const isCurrentMonth = currentDay.getMonth() === this.currentDate.getMonth();
|
||||
const isToday = this.isToday(currentDay);
|
||||
const allEvents = this.getEventsForDay(currentDay);
|
||||
const dayIso = currentDay.toISOString();
|
||||
const allDayEvents = allEvents.filter(e => e.allDay);
|
||||
const timedEvents = allEvents.filter(e => !e.allDay);
|
||||
|
||||
html += `<div class="calendar-month-cell ${isCurrentMonth ? '' : 'other-month'} ${isToday ? 'today' : ''}" data-date="${dayIso}">`;
|
||||
html += `<div class="cell-date day-drill" data-day-date="${dayIso}" title="View day">${currentDay.getDate()}</div>`;
|
||||
html += '<div class="cell-events">';
|
||||
|
||||
allDayEvents.forEach(event => html += this.renderEventCard(event, 'month'));
|
||||
|
||||
const maxEvents = 3;
|
||||
const remainingSlots = maxEvents - allDayEvents.length;
|
||||
timedEvents.slice(0, remainingSlots).forEach(event => html += this.renderEventCard(event, 'month'));
|
||||
|
||||
const totalEvents = allEvents.length;
|
||||
if (totalEvents > maxEvents) {
|
||||
html += `<div class="event-more day-drill" data-day-date="${dayIso}">+${totalEvents - maxEvents} more</div>`;
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Event Card Rendering
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
renderEventCard: function(event, viewType) {
|
||||
const colorClass = this.getColorClass(event.backgroundColor);
|
||||
const time = event.allDay ? 'All Day' : new Date(event.start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
const eventType = event.eventType || 'Appointment';
|
||||
const allDayClass = event.allDay ? 'all-day-event' : '';
|
||||
const isJob = eventType === 'Job';
|
||||
const dragAttr = isJob ? `draggable="true" data-job-id="${event.id}"` : '';
|
||||
const fallbackAttr = isJob && event.isFallbackDate ? 'data-fallback="true"' : '';
|
||||
const fallbackStyle = isJob && event.isFallbackDate ? 'opacity:0.75;border-style:dashed;' : '';
|
||||
const jobIcon = isJob ? '<i class="bi bi-briefcase me-1" style="font-size:0.65rem"></i>' : '';
|
||||
const fallbackIcon = isJob && event.isFallbackDate ? ' <i class="bi bi-calendar-x" title="No scheduled date \u2014 showing on due date" style="font-size:0.65rem"></i>' : '';
|
||||
|
||||
if (viewType === 'week-header') {
|
||||
return `<div class="calendar-event-header ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
style="${fallbackStyle}"
|
||||
title="${this.escapeHtml(event.title)}">
|
||||
${jobIcon}<span class="event-text">${this.escapeHtml(isJob ? (event.jobNumber || event.title) : event.title)}</span>${fallbackIcon}
|
||||
</div>`;
|
||||
} else if (viewType === 'day') {
|
||||
const locationHtml = event.location
|
||||
? `<div class="event-location"><i class="bi bi-geo-alt me-1"></i>${this.escapeHtml(event.location)}</div>` : '';
|
||||
return `<div class="calendar-event calendar-event-day ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
style="${fallbackStyle}"
|
||||
title="${this.escapeHtml(event.title)}">
|
||||
<div class="event-time">${jobIcon}${time}</div>
|
||||
<div class="event-title">${this.escapeHtml(event.title)}</div>
|
||||
<div class="event-customer">${this.escapeHtml(event.customerName || '')}</div>
|
||||
${locationHtml}
|
||||
</div>`;
|
||||
} else if (viewType === 'week') {
|
||||
return `<div class="calendar-event ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
style="${fallbackStyle}"
|
||||
title="${this.escapeHtml(event.title)} \u2014 ${this.escapeHtml(event.customerName)}">
|
||||
<div class="event-time">${jobIcon}${time}</div>
|
||||
<div class="event-title">${this.escapeHtml(event.title)}</div>
|
||||
<div class="event-customer">${this.escapeHtml(event.customerName)}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
// Month view
|
||||
return `<div class="calendar-event-month ${colorClass} ${allDayClass}"
|
||||
data-event-id="${event.id}" data-event-type="${eventType}"
|
||||
${dragAttr} ${fallbackAttr}
|
||||
title="${time} \u2014 ${this.escapeHtml(event.title)}">
|
||||
<span class="event-dot"></span>
|
||||
<span class="event-text">${jobIcon}${isJob ? this.escapeHtml(event.jobNumber || event.title) : (time + ' ' + this.escapeHtml(event.title))}</span>
|
||||
</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Event Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getEventsForDay: function(day) {
|
||||
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
|
||||
return this.events.filter(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
return eventStart >= dayStart && eventStart <= dayEnd;
|
||||
});
|
||||
},
|
||||
|
||||
getAllDayEventsForDay: function(day) {
|
||||
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
|
||||
return this.events.filter(event => {
|
||||
if (!event.allDay) return false;
|
||||
const eventStart = new Date(event.start);
|
||||
return eventStart >= dayStart && eventStart <= dayEnd;
|
||||
});
|
||||
},
|
||||
|
||||
getEventsForDayHour: function(day, hour) {
|
||||
const hourStart = new Date(day); hourStart.setHours(hour, 0, 0, 0);
|
||||
const hourEnd = new Date(day); hourEnd.setHours(hour, 59, 59, 999);
|
||||
return this.events.filter(event => {
|
||||
if (event.allDay) return false;
|
||||
const eventStart = new Date(event.start);
|
||||
return eventStart >= hourStart && eventStart <= hourEnd;
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Click Handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
attachEventClickHandlers: function() {
|
||||
document.querySelectorAll('.calendar-event, .calendar-event-month, .calendar-event-header').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const eventId = el.dataset.eventId;
|
||||
const eventType = el.dataset.eventType || 'Appointment';
|
||||
if (eventType === 'Maintenance') {
|
||||
window.location.href = `/Maintenance/Details/${eventId}`;
|
||||
} else if (eventType === 'Job') {
|
||||
window.location.href = `/Jobs/Details/${eventId}`;
|
||||
} else {
|
||||
window.location.href = `/Appointments/Details/${eventId}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.day-drill').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const date = new Date(el.dataset.dayDate);
|
||||
this.switchToDay(date);
|
||||
});
|
||||
});
|
||||
|
||||
// Job event drag handlers (calendar → calendar reschedule)
|
||||
document.querySelectorAll('[data-event-type="Job"]').forEach(el => {
|
||||
el.setAttribute('draggable', 'true');
|
||||
el.addEventListener('dragstart', (e) => {
|
||||
e.stopPropagation();
|
||||
const cell = el.closest('[data-date]');
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'job',
|
||||
jobId: el.dataset.eventId,
|
||||
fromDate: cell?.dataset.date || null
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
el.classList.add('event-dragging');
|
||||
});
|
||||
el.addEventListener('dragend', () => el.classList.remove('event-dragging'));
|
||||
});
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Date Ranges & Navigation
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getDateRange: function() {
|
||||
if (this.currentView === 'day') return this.getDayRange(this.currentDate);
|
||||
if (this.currentView === 'week') return this.getWeekRange(this.currentDate);
|
||||
return this.getMonthRange(this.currentDate);
|
||||
},
|
||||
|
||||
getDayRange: function(date) {
|
||||
const start = new Date(date); start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(date); end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
getWeekRange: function(date) {
|
||||
const start = new Date(date);
|
||||
const day = start.getDay();
|
||||
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 7);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
getMonthRange: function(date) {
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
goToPrevious: function() {
|
||||
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() - 1);
|
||||
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() - 7);
|
||||
else this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
goToNext: function() {
|
||||
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() + 1);
|
||||
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() + 7);
|
||||
else this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
goToToday: function() {
|
||||
this.currentDate = new Date();
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
switchView: function(view) {
|
||||
this.currentView = view;
|
||||
this.updateViewButtons();
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
switchToDay: function(date) {
|
||||
this.currentDate = new Date(date);
|
||||
this.currentView = 'day';
|
||||
this.updateViewButtons();
|
||||
this.loadAndRender();
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// UI State
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
updateViewButtons: function() {
|
||||
document.getElementById('btnDayView').classList.toggle('active', this.currentView === 'day');
|
||||
document.getElementById('btnWeekView').classList.toggle('active', this.currentView === 'week');
|
||||
document.getElementById('btnMonthView').classList.toggle('active', this.currentView === 'month');
|
||||
},
|
||||
|
||||
updateCurrentDateDisplay: function() {
|
||||
const display = document.getElementById('currentDateDisplay');
|
||||
if (this.currentView === 'day') {
|
||||
display.textContent = `${this.getDayName(this.currentDate, 'long')}, ${this.getMonthName(this.currentDate)} ${this.currentDate.getDate()}, ${this.currentDate.getFullYear()}`;
|
||||
} else if (this.currentView === 'week') {
|
||||
const { start, end } = this.getWeekRange(this.currentDate);
|
||||
const endDisplay = new Date(end);
|
||||
endDisplay.setDate(endDisplay.getDate() - 1);
|
||||
if (start.getMonth() === endDisplay.getMonth()) {
|
||||
display.textContent = `${this.getMonthName(start)} ${start.getDate()}\u2013${endDisplay.getDate()}, ${start.getFullYear()}`;
|
||||
} else {
|
||||
display.textContent = `${this.getMonthName(start)} ${start.getDate()} \u2013 ${this.getMonthName(endDisplay)} ${endDisplay.getDate()}, ${start.getFullYear()}`;
|
||||
}
|
||||
} else {
|
||||
display.textContent = `${this.getMonthName(this.currentDate)} ${this.currentDate.getFullYear()}`;
|
||||
}
|
||||
},
|
||||
|
||||
updateURL: function() {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('view', this.currentView);
|
||||
url.searchParams.set('date', this.currentDate.toISOString().split('T')[0]);
|
||||
window.history.replaceState({}, '', url);
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
isToday: function(date) {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
},
|
||||
|
||||
getDayName: function(date, format = 'long') {
|
||||
return date.toLocaleDateString('en-US', { weekday: format });
|
||||
},
|
||||
|
||||
getMonthName: function(date) {
|
||||
return date.toLocaleDateString('en-US', { month: 'long' });
|
||||
},
|
||||
|
||||
formatHour: function(hour) {
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
|
||||
return `${displayHour} ${period}`;
|
||||
},
|
||||
|
||||
formatDateShort: function(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
},
|
||||
|
||||
getColorClass: function(bgColor) {
|
||||
if (bgColor && bgColor.startsWith('#')) {
|
||||
const colorMap = {
|
||||
'#6f42c1': 'calendar-event-purple',
|
||||
'#198754': 'calendar-event-green',
|
||||
'#0d6efd': 'calendar-event-blue',
|
||||
'#fd7e14': 'calendar-event-orange',
|
||||
'#d63384': 'calendar-event-pink',
|
||||
'#0dcaf0': 'calendar-event-cyan',
|
||||
'#20c997': 'calendar-event-teal',
|
||||
'#6610f2': 'calendar-event-indigo',
|
||||
'#84cc16': 'calendar-event-lime',
|
||||
'#795548': 'calendar-event-brown',
|
||||
'#dc3545': 'calendar-event-red',
|
||||
'#ffc107': 'calendar-event-yellow',
|
||||
'#6c757d': 'calendar-event-gray',
|
||||
'#adb5bd': 'calendar-event-gray',
|
||||
'#212529': 'calendar-event-dark'
|
||||
};
|
||||
return colorMap[bgColor] || 'calendar-event-blue';
|
||||
}
|
||||
return bgColor ? `calendar-event-${bgColor}` : 'calendar-event-blue';
|
||||
},
|
||||
|
||||
escapeHtml: function(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
window.appointmentCalendar = appointmentCalendar;
|
||||
window.loadCalendarEvents = () => appointmentCalendar.loadAndRender();
|
||||
@@ -0,0 +1,204 @@
|
||||
// CSV Bulk Import JavaScript
|
||||
// Handles file upload with AJAX, progress indicators, and result display
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setupCsvImportForm('csvImportCustomersForm', 'csvCustomersFile', 'csvImportCustomersBtn', '/Tools/CsvImportCustomers', 'csvCustomersResults');
|
||||
setupCsvImportForm('csvImportCatalogForm', 'csvCatalogFile', 'csvImportCatalogBtn', '/Tools/CsvImportCatalogItems', 'csvCatalogResults');
|
||||
setupCsvImportForm('csvImportInventoryForm', 'csvInventoryFile', 'csvImportInventoryBtn', '/Tools/CsvImportInventoryItems', 'csvInventoryResults');
|
||||
setupCsvImportForm('csvImportQuotesForm', 'csvQuotesFile', 'csvImportQuotesBtn', '/Tools/CsvImportQuotes', 'csvQuotesResults');
|
||||
setupCsvImportForm('csvImportJobsForm', 'csvJobsFile', 'csvImportJobsBtn', '/Tools/CsvImportJobs', 'csvJobsResults');
|
||||
setupCsvImportForm('csvImportAppointmentsForm', 'csvAppointmentsFile', 'csvImportAppointmentsBtn', '/Tools/CsvImportAppointments', 'csvAppointmentsResults');
|
||||
setupCsvImportForm('csvImportEquipmentForm', 'csvEquipmentFile', 'csvImportEquipmentBtn', '/Tools/CsvImportEquipment', 'csvEquipmentResults');
|
||||
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
|
||||
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
|
||||
setupCsvImportForm('csvImportVendorsForm', 'csvVendorsFile', 'csvImportVendorsBtn', '/Tools/CsvImportVendors', 'csvVendorsResults');
|
||||
setupCsvImportForm('csvImportShopWorkersForm', 'csvShopWorkersFile', 'csvImportShopWorkersBtn', '/Tools/CsvImportShopWorkers', 'csvShopWorkersResults');
|
||||
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
|
||||
});
|
||||
|
||||
function setupCsvImportForm(formId, fileInputId, submitBtnId, actionUrl, resultsId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
const submitBtn = document.getElementById(submitBtnId);
|
||||
const resultsDiv = document.getElementById(resultsId);
|
||||
const spinner = submitBtn.querySelector('.spinner-border');
|
||||
|
||||
// Validate file selection
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
showToast('Please select a CSV file to import.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
|
||||
// Validate file extension
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
showToast('Please select a valid CSV file.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
showToast('File size must be less than 10MB.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
spinner.classList.remove('d-none');
|
||||
submitBtn.disabled = true;
|
||||
resultsDiv.classList.add('d-none');
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
// Prepare form data (use the form element so all fields, including account selects, are included)
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Get anti-forgery token
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(actionUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'RequestVerificationToken': token
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Display results
|
||||
displayImportResults(result, resultsDiv);
|
||||
|
||||
// Show toast notification
|
||||
if (result.success) {
|
||||
showToast(`Import completed: ${result.successCount} records imported successfully!`, 'success');
|
||||
// Clear file input
|
||||
fileInput.value = '';
|
||||
} else {
|
||||
showToast('Import completed with errors. Please review the details below.', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
showToast('An error occurred during import: ' + error.message, 'error');
|
||||
displayImportResults({ success: false, message: error.message, errors: [error.toString()] }, resultsDiv);
|
||||
} finally {
|
||||
// Hide loading state
|
||||
spinner.classList.add('d-none');
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayImportResults(result, resultsDiv) {
|
||||
resultsDiv.classList.remove('d-none');
|
||||
|
||||
const cardClass = result.success ? 'border-success' : 'border-danger';
|
||||
const headerClass = result.success ? 'bg-success' : 'bg-danger';
|
||||
const icon = result.success ? 'check-circle' : 'exclamation-triangle';
|
||||
|
||||
const skipped = result.skippedCount || 0;
|
||||
const colSize = skipped > 0 ? 'col-md-3' : 'col-md-4';
|
||||
|
||||
let html = `
|
||||
<div class="card ${cardClass}">
|
||||
<div class="card-header ${headerClass} text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-${icon}"></i> Import Results</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-success">${result.successCount || 0}</div>
|
||||
<small class="text-muted">Imported</small>
|
||||
</div>
|
||||
</div>
|
||||
${skipped > 0 ? `
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-warning">${skipped}</div>
|
||||
<small class="text-muted">Skipped (already exist)</small>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-danger">${result.errorCount || 0}</div>
|
||||
<small class="text-muted">Errors</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${colSize}">
|
||||
<div class="text-center">
|
||||
<div class="display-6 text-info">${result.totalRows || 0}</div>
|
||||
<small class="text-muted">Total Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-2"><strong>${result.message || 'Import completed.'}</strong></p>
|
||||
`;
|
||||
|
||||
// Display errors
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
html += `
|
||||
<div class="alert alert-danger mt-3">
|
||||
<h6><i class="bi bi-exclamation-triangle"></i> Errors:</h6>
|
||||
<ul class="mb-0 small">
|
||||
`;
|
||||
result.errors.forEach(error => {
|
||||
html += `<li>${escapeHtml(error)}</li>`;
|
||||
});
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display warnings
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
html += `
|
||||
<div class="alert alert-warning mt-3">
|
||||
<h6><i class="bi bi-info-circle"></i> Warnings:</h6>
|
||||
<ul class="mb-0 small">
|
||||
`;
|
||||
result.warnings.forEach(warning => {
|
||||
html += `<li>${escapeHtml(warning)}</li>`;
|
||||
});
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultsDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toastId = type === 'success' ? 'successToast' : 'errorToast';
|
||||
const toastElement = document.getElementById(toastId);
|
||||
if (!toastElement) return;
|
||||
|
||||
const toastBody = toastElement.querySelector('.toast-body');
|
||||
toastBody.textContent = message;
|
||||
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
autohide: true,
|
||||
delay: 5000
|
||||
});
|
||||
toast.show();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Saves accordion open/closed state on toggle. The initial state restoration is
|
||||
* handled by an inline script in the view that runs synchronously before Bootstrap
|
||||
* initializes, preventing any visible flash.
|
||||
*
|
||||
* Key format: pcl_catalog_acc_{collapseId} → "1" (open) or "0" (closed)
|
||||
*/
|
||||
(function () {
|
||||
const PREFIX = 'pcl_catalog_acc_';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.catalog-tree .collapse').forEach(function (el) {
|
||||
el.addEventListener('show.bs.collapse', function () {
|
||||
localStorage.setItem(PREFIX + el.id, '1');
|
||||
});
|
||||
el.addEventListener('hide.bs.collapse', function () {
|
||||
localStorage.setItem(PREFIX + el.id, '0');
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,588 @@
|
||||
// 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);
|
||||
};
|
||||
|
||||
// ====================
|
||||
// AUTO-DERIVE CODE FROM DISPLAY NAME
|
||||
// When adding a new lookup item, auto-populate the Code field from the Display Name:
|
||||
// "In Progress" → "IN_PROGRESS". Also auto-uppercases direct code field edits.
|
||||
// Only fires when the code field is enabled (add mode — disabled in edit mode).
|
||||
// ====================
|
||||
|
||||
function deriveCode(displayName) {
|
||||
return displayName.trim().toUpperCase().replace(/\s+/g, '_').replace(/[^A-Z0-9_]/g, '');
|
||||
}
|
||||
|
||||
function wireCodeAutoFill(nameFieldId, codeFieldId) {
|
||||
const nameField = document.getElementById(nameFieldId);
|
||||
const codeField = document.getElementById(codeFieldId);
|
||||
if (!nameField || !codeField) return;
|
||||
|
||||
nameField.addEventListener('input', function() {
|
||||
if (!codeField.disabled) {
|
||||
codeField.value = deriveCode(this.value);
|
||||
}
|
||||
});
|
||||
|
||||
codeField.addEventListener('input', function() {
|
||||
const pos = this.selectionStart;
|
||||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
this.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
wireCodeAutoFill('jobStatusDisplayName', 'jobStatusCode');
|
||||
wireCodeAutoFill('jobPriorityDisplayName', 'jobPriorityCode');
|
||||
wireCodeAutoFill('quoteStatusDisplayName', 'quoteStatusCode');
|
||||
wireCodeAutoFill('inventoryCategoryDisplayName','inventoryCategoryCode');
|
||||
wireCodeAutoFill('appointmentTypeDisplayName', 'appointmentTypeCode');
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,179 @@
|
||||
// Equipment Management JavaScript
|
||||
|
||||
// File Upload with Drag & Drop
|
||||
function initializeFileUpload() {
|
||||
const fileInput = document.getElementById('manualFile');
|
||||
const uploadForm = document.getElementById('uploadManualForm');
|
||||
|
||||
if (!fileInput || !uploadForm) return;
|
||||
|
||||
// Drag and drop handlers
|
||||
const dropZone = uploadForm.querySelector('.file-upload-zone');
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
uploadForm.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side Cost Calculation
|
||||
function initializeCostCalculation() {
|
||||
const laborCostInput = document.querySelector('input[name="LaborCost"]');
|
||||
const partsCostInput = document.querySelector('input[name="PartsCost"]');
|
||||
const totalCostDisplay = document.getElementById('totalCostDisplay');
|
||||
|
||||
if (!laborCostInput || !partsCostInput) return;
|
||||
|
||||
function calculateTotal() {
|
||||
const laborCost = parseFloat(laborCostInput.value) || 0;
|
||||
const partsCost = parseFloat(partsCostInput.value) || 0;
|
||||
const totalCost = laborCost + partsCost;
|
||||
|
||||
if (totalCostDisplay) {
|
||||
totalCostDisplay.textContent = totalCost.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
laborCostInput.addEventListener('input', calculateTotal);
|
||||
partsCostInput.addEventListener('input', calculateTotal);
|
||||
|
||||
// Calculate on page load
|
||||
calculateTotal();
|
||||
}
|
||||
|
||||
// Equipment Status Change Handler
|
||||
function initializeStatusChangeHandler() {
|
||||
const statusSelect = document.querySelector('select[name="Status"]');
|
||||
|
||||
if (!statusSelect) return;
|
||||
|
||||
statusSelect.addEventListener('change', function() {
|
||||
const selectedStatus = this.value;
|
||||
|
||||
// Add visual feedback based on status
|
||||
const card = this.closest('.card');
|
||||
if (card) {
|
||||
card.classList.remove('border-success', 'border-warning', 'border-danger', 'border-info');
|
||||
|
||||
switch(selectedStatus) {
|
||||
case 'Operational':
|
||||
card.classList.add('border-success');
|
||||
break;
|
||||
case 'NeedsMaintenance':
|
||||
card.classList.add('border-warning');
|
||||
break;
|
||||
case 'UnderMaintenance':
|
||||
card.classList.add('border-info');
|
||||
break;
|
||||
case 'OutOfService':
|
||||
card.classList.add('border-danger');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Maintenance Due Reminder
|
||||
function checkMaintenanceDue() {
|
||||
const daysUntilMaintenance = document.querySelector('[data-days-until-maintenance]');
|
||||
|
||||
if (!daysUntilMaintenance) return;
|
||||
|
||||
const days = parseInt(daysUntilMaintenance.dataset.daysUntilMaintenance);
|
||||
|
||||
if (days !== null && days < 7 && days >= 0) {
|
||||
showToast(`Maintenance due in ${days} days!`, 'warning');
|
||||
} else if (days < 0) {
|
||||
showToast(`Maintenance is overdue by ${Math.abs(days)} days!`, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Show Toast Notification
|
||||
function showToast(message, type = 'info') {
|
||||
const toastHTML = `
|
||||
<div class="toast align-items-center text-white bg-${type} border-0 position-fixed bottom-0 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', toastHTML);
|
||||
const toastElement = document.querySelector('.toast:last-child');
|
||||
const toast = new bootstrap.Toast(toastElement);
|
||||
toast.show();
|
||||
|
||||
// Remove from DOM after hidden
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Confirm Delete with Modal
|
||||
function confirmDelete(event, itemName, itemType) {
|
||||
if (!confirm(`Are you sure you want to delete this ${itemType}: ${itemName}?\n\nThis action cannot be undone.`)) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search and Filter Tables
|
||||
function initializeTableSearch(inputId, tableId) {
|
||||
const searchInput = document.getElementById(inputId);
|
||||
const table = document.getElementById(tableId);
|
||||
|
||||
if (!searchInput || !table) return;
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
row.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize all features on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeFileUpload();
|
||||
initializeCostCalculation();
|
||||
initializeStatusChangeHandler();
|
||||
checkMaintenanceDue();
|
||||
|
||||
// Initialize table searches if present
|
||||
initializeTableSearch('searchInput', 'equipmentTable');
|
||||
initializeTableSearch('searchInput', 'maintenanceTable');
|
||||
});
|
||||
|
||||
// Export functions for use in other scripts
|
||||
window.EquipmentManagement = {
|
||||
showToast,
|
||||
confirmDelete,
|
||||
initializeFileUpload,
|
||||
initializeCostCalculation
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
// Job Photos JavaScript
|
||||
const jobPhotoModule = {
|
||||
jobId: null,
|
||||
allPhotos: [],
|
||||
currentPhotoIndex: 0,
|
||||
_tagApi: null,
|
||||
|
||||
init: function(jobId, tagSuggestions) {
|
||||
this.jobId = jobId;
|
||||
this._tagSuggestions = tagSuggestions || [];
|
||||
this.loadJobPhotos();
|
||||
this.setupDragDrop();
|
||||
this.setupFileInput();
|
||||
|
||||
// Initialise the tag widget each time the upload modal opens so it's always fresh
|
||||
const uploadModal = document.getElementById('uploadPhotoModal');
|
||||
if (uploadModal) {
|
||||
uploadModal.addEventListener('show.bs.modal', () => {
|
||||
this._tagApi = initTagInput('photoTagsHidden', 'photoTagsContainer', {
|
||||
suggestions: this._tagSuggestions
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadJobPhotos: function() {
|
||||
console.log('loadJobPhotos called');
|
||||
// Add cache-busting parameter to prevent browser from using cached data
|
||||
fetch(`/Jobs/GetJobPhotos?jobId=${this.jobId}&_t=${Date.now()}`, {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('GetJobPhotos response:', data);
|
||||
if (data.success) {
|
||||
this.allPhotos = data.photos;
|
||||
this.renderPhotoGallery(data.photos);
|
||||
document.getElementById('photoCount').textContent = data.photos.length;
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error loading photos:', error));
|
||||
},
|
||||
|
||||
renderPhotoGallery: function(photos) {
|
||||
const gallery = document.getElementById('photoGallery');
|
||||
|
||||
if (!gallery) {
|
||||
console.error('photoGallery element not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear gallery
|
||||
gallery.innerHTML = '';
|
||||
|
||||
if (photos.length === 0) {
|
||||
// Show "no photos" message
|
||||
gallery.innerHTML = `
|
||||
<div class="col-12 text-center py-5" id="noPhotosMessage">
|
||||
<i class="bi bi-camera" style="font-size: 3rem; opacity: 0.2;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No photos uploaded yet</p>
|
||||
<small class="text-muted">Click "Upload Photo" to add photos</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add photo cards
|
||||
photos.forEach((photo, index) => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-md-4 col-sm-6';
|
||||
col.innerHTML = `
|
||||
<div class="photo-card" onclick="jobPhotoModule.viewPhoto(${index})">
|
||||
<img src="/Jobs/GetPhoto/${photo.id}" alt="${this.escapeHtml(photo.caption || 'Job photo')}" class="photo-thumbnail">
|
||||
<div class="photo-overlay">
|
||||
<div class="photo-type-badge badge bg-${this.getPhotoTypeBadgeClass(photo.photoType)}">
|
||||
${photo.photoTypeDisplay}
|
||||
</div>
|
||||
${photo.caption ? `<p class="photo-caption">${this.escapeHtml(photo.caption)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
gallery.appendChild(col);
|
||||
});
|
||||
},
|
||||
|
||||
viewPhoto: function(index, isNavigating = false) {
|
||||
this.currentPhotoIndex = index;
|
||||
const photo = this.allPhotos[index];
|
||||
|
||||
document.getElementById('viewPhotoImage').src = `/Jobs/GetPhoto/${photo.id}`;
|
||||
document.getElementById('viewPhotoTitle').textContent = photo.photoTypeDisplay + ' Photo';
|
||||
document.getElementById('photoPosition').textContent = `Photo ${index + 1} of ${this.allPhotos.length}`;
|
||||
document.getElementById('photoDetailCaption').textContent = photo.caption || 'No caption';
|
||||
document.getElementById('photoDetailType').textContent = photo.photoTypeDisplay;
|
||||
document.getElementById('photoDetailDate').textContent = new Date(photo.uploadedDate).toLocaleDateString();
|
||||
document.getElementById('photoDetailUploader').textContent = photo.uploadedByName;
|
||||
|
||||
// Tags
|
||||
const tagsRow = document.getElementById('photoDetailTagsRow');
|
||||
const tagsSpan = document.getElementById('photoDetailTags');
|
||||
if (photo.tagsList && photo.tagsList.length > 0) {
|
||||
tagsSpan.innerHTML = photo.tagsList.map(t =>
|
||||
`<span class="badge bg-primary bg-opacity-10 text-primary me-1">${this.escapeHtml(t)}</span>`
|
||||
).join('');
|
||||
tagsRow.style.display = '';
|
||||
} else {
|
||||
tagsRow.style.display = 'none';
|
||||
}
|
||||
|
||||
// Only show modal if not navigating (prevents backdrop duplication)
|
||||
if (!isNavigating) {
|
||||
const modalElement = document.getElementById('viewPhotoModal');
|
||||
const modal = bootstrap.Modal.getInstance(modalElement) || new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
}
|
||||
},
|
||||
|
||||
navigatePhoto: function(direction) {
|
||||
this.currentPhotoIndex += direction;
|
||||
if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1;
|
||||
if (this.currentPhotoIndex >= this.allPhotos.length) this.currentPhotoIndex = 0;
|
||||
// Pass true to indicate we're navigating (don't re-show modal)
|
||||
this.viewPhoto(this.currentPhotoIndex, true);
|
||||
},
|
||||
|
||||
uploadPhoto: function() {
|
||||
const fileInput = document.getElementById('photoFile');
|
||||
const caption = document.getElementById('photoCaption').value;
|
||||
const photoType = document.getElementById('photoType').value;
|
||||
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
showWarning('Please select a photo to upload', 'No File Selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('jobId', this.jobId);
|
||||
formData.append('photo', fileInput.files[0]);
|
||||
formData.append('caption', caption);
|
||||
formData.append('photoType', photoType);
|
||||
formData.append('tags', document.getElementById('photoTagsHidden').value);
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
fetch('/Jobs/UploadPhoto', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Upload response:', data);
|
||||
if (data.success) {
|
||||
const modalElement = document.getElementById('uploadPhotoModal');
|
||||
const modal = bootstrap.Modal.getInstance(modalElement);
|
||||
modal.hide();
|
||||
|
||||
// Wait for modal animation to complete
|
||||
setTimeout(() => {
|
||||
console.log('Reloading photos after upload...');
|
||||
this.loadJobPhotos();
|
||||
this.clearPhotoSelection();
|
||||
this.showToast('Photo uploaded successfully', 'success');
|
||||
}, 400);
|
||||
} else {
|
||||
this.showToast(data.message || 'Error uploading photo', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
this.showToast('An error occurred while uploading', 'danger');
|
||||
});
|
||||
},
|
||||
|
||||
deletePhoto: function() {
|
||||
if (!confirm('Are you sure you want to delete this photo?')) return;
|
||||
|
||||
const photo = this.allPhotos[this.currentPhotoIndex];
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
fetch('/Jobs/DeletePhoto?id=' + photo.id, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Delete response:', data);
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('viewPhotoModal')).hide();
|
||||
|
||||
// Wait for modal animation to complete
|
||||
setTimeout(() => {
|
||||
console.log('Reloading photos after delete...');
|
||||
this.loadJobPhotos();
|
||||
this.showToast('Photo deleted successfully', 'success');
|
||||
}, 400);
|
||||
} else {
|
||||
this.showToast(data.message || 'Error deleting photo', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
this.showToast('An error occurred while deleting', 'danger');
|
||||
});
|
||||
},
|
||||
|
||||
setupDragDrop: function() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('photoFile');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.add('drag-over');
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
}, false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
fileInput.files = files;
|
||||
this.handleFileSelect({ target: fileInput });
|
||||
}, false);
|
||||
},
|
||||
|
||||
setupFileInput: function() {
|
||||
document.getElementById('photoFile').addEventListener('change', (e) => this.handleFileSelect(e));
|
||||
},
|
||||
|
||||
handleFileSelect: function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showError('Please select an image file', 'Invalid File Type');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
showError('File size must be less than 10 MB', 'File Too Large');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
document.getElementById('previewImage').src = e.target.result;
|
||||
document.getElementById('photoPreview').classList.remove('d-none');
|
||||
document.getElementById('dropZone').style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
clearPhotoSelection: function() {
|
||||
document.getElementById('photoFile').value = '';
|
||||
document.getElementById('photoCaption').value = '';
|
||||
document.getElementById('photoType').value = '1';
|
||||
document.getElementById('photoPreview').classList.add('d-none');
|
||||
document.getElementById('dropZone').style.display = 'block';
|
||||
if (this._tagApi) this._tagApi.clear();
|
||||
},
|
||||
|
||||
getPhotoTypeBadgeClass: function(type) {
|
||||
const classes = {
|
||||
0: 'info', // Before
|
||||
1: 'primary', // Progress
|
||||
2: 'success', // After
|
||||
3: 'warning', // Quality Check
|
||||
4: 'danger', // Issue
|
||||
5: 'success' // Completed
|
||||
};
|
||||
return classes[type] || 'secondary';
|
||||
},
|
||||
|
||||
escapeHtml: function(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
showToast: function(message, type) {
|
||||
const alertClass = `alert-${type}`;
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert ${alertClass} alert-dismissible fade show position-fixed top-0 end-0 m-3`;
|
||||
alert.style.zIndex = '9999';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
setTimeout(() => alert.remove(), 3000);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,781 @@
|
||||
/* oven-scheduler.js — Batch scheduler with mouse-event drag (no HTML5 DnD API) */
|
||||
|
||||
'use strict';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Drag state
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
let dragState = null;
|
||||
/* dragState shape:
|
||||
{
|
||||
type: 'queue' | 'batch',
|
||||
sourceEl: HTMLElement, // original row being dragged
|
||||
ghost: HTMLElement, // floating clone following the cursor
|
||||
offsetX, offsetY: number, // cursor offset within the source element
|
||||
// queue-specific:
|
||||
coatId, jobId, jobItemId, sqft, color, colorCode,
|
||||
pass, coatName, jobNumber, customer, description, priority, dueDate
|
||||
// batch-specific:
|
||||
batchItemId, sourceBatchId, sqft
|
||||
}
|
||||
*/
|
||||
|
||||
// Currently highlighted batch card id (for visual feedback)
|
||||
let dragOverBatchId = null;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Init — wire up mousedown on all draggable rows
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initDraggable();
|
||||
});
|
||||
|
||||
function initDraggable() {
|
||||
// Queue coat rows
|
||||
document.querySelectorAll('[data-coat-id]').forEach(el => {
|
||||
el.addEventListener('mousedown', onQueueMouseDown);
|
||||
});
|
||||
|
||||
// Batch item rows (only those with draggable=true, i.e. editable batches)
|
||||
document.querySelectorAll('[data-batch-item-id]').forEach(el => {
|
||||
if (el.dataset.draggable === 'true' || el.getAttribute('draggable') === 'true') {
|
||||
el.addEventListener('mousedown', onBatchItemMouseDown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Mousedown handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function onQueueMouseDown(e) {
|
||||
if (e.button !== 0) return; // left button only
|
||||
if (e.target.closest('button')) return; // don't intercept button clicks
|
||||
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
dragState = {
|
||||
type: 'queue',
|
||||
sourceEl: el,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
coatId: +el.dataset.coatId,
|
||||
jobId: +el.dataset.jobId,
|
||||
jobItemId: +el.dataset.jobItemId,
|
||||
sqft: +el.dataset.sqft,
|
||||
color: el.dataset.color,
|
||||
colorCode: el.dataset.colorCode,
|
||||
pass: +el.dataset.pass,
|
||||
coatName: el.dataset.coatName,
|
||||
jobNumber: el.dataset.jobNumber,
|
||||
customer: el.dataset.customer,
|
||||
description: el.dataset.description,
|
||||
priority: el.dataset.priority,
|
||||
dueDate: el.dataset.dueDate
|
||||
};
|
||||
|
||||
startDrag(el, e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onBatchItemMouseDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest('button')) return;
|
||||
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
dragState = {
|
||||
type: 'batch',
|
||||
sourceEl: el,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
batchItemId: +el.dataset.batchItemId,
|
||||
sourceBatchId: +el.dataset.batchId,
|
||||
sqft: +el.dataset.sqft
|
||||
};
|
||||
|
||||
startDrag(el, e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Ghost element
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function startDrag(sourceEl, e) {
|
||||
const rect = sourceEl.getBoundingClientRect();
|
||||
|
||||
const ghost = sourceEl.cloneNode(true);
|
||||
ghost.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${rect.left}px;
|
||||
top: ${rect.top}px;
|
||||
width: ${rect.width}px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.25);
|
||||
border-radius: 6px;
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
padding: .3rem .5rem;
|
||||
font-size: .82rem;
|
||||
transition: none;
|
||||
`;
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
dragState.ghost = ghost;
|
||||
|
||||
sourceEl.style.opacity = '0.35';
|
||||
}
|
||||
|
||||
function moveGhost(e) {
|
||||
if (!dragState?.ghost) return;
|
||||
dragState.ghost.style.left = (e.clientX - dragState.offsetX) + 'px';
|
||||
dragState.ghost.style.top = (e.clientY - dragState.offsetY) + 'px';
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Document mousemove — move ghost and highlight drop targets
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!dragState) return;
|
||||
moveGhost(e);
|
||||
|
||||
// Hide ghost temporarily so elementFromPoint sees what's underneath
|
||||
dragState.ghost.style.display = 'none';
|
||||
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
|
||||
dragState.ghost.style.display = '';
|
||||
|
||||
const batchCard = elUnder?.closest('.batch-card');
|
||||
const newId = batchCard ? +batchCard.dataset.batchId : null;
|
||||
|
||||
if (newId !== dragOverBatchId) {
|
||||
// Remove old highlight
|
||||
if (dragOverBatchId !== null) {
|
||||
document.getElementById(`batch-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
document.getElementById(`empty-items-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
}
|
||||
// Add new highlight
|
||||
if (newId !== null) {
|
||||
batchCard.classList.add('drag-over');
|
||||
document.getElementById(`empty-items-${newId}`)?.classList.add('drag-over');
|
||||
}
|
||||
dragOverBatchId = newId;
|
||||
}
|
||||
|
||||
// Highlight empty oven zone when a queue item hovers over it
|
||||
const ovenZone = elUnder?.closest('[data-oven-id]');
|
||||
document.querySelectorAll('.drop-zone-empty').forEach(z => z.classList.remove('drag-over'));
|
||||
if (dragState.type === 'queue' && !batchCard && ovenZone) {
|
||||
document.getElementById(`empty-${ovenZone.dataset.ovenId}`)?.classList.add('drag-over');
|
||||
}
|
||||
|
||||
// Highlight queue when a batch item is being dragged (indicates it can be dropped there)
|
||||
const queueContainer = document.getElementById('queueContainer');
|
||||
const overQueue = elUnder?.closest('#queueContainer') || elUnder?.closest('.scheduler-queue');
|
||||
if (dragState.type === 'batch') {
|
||||
queueContainer?.classList.toggle('drag-over', !!overQueue);
|
||||
} else {
|
||||
queueContainer?.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Document mouseup — commit the drop
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('mouseup', e => {
|
||||
if (!dragState) return;
|
||||
if (e.button !== 0) return;
|
||||
|
||||
|
||||
// Clear all highlights
|
||||
if (dragOverBatchId !== null) {
|
||||
document.getElementById(`batch-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
document.getElementById(`empty-items-${dragOverBatchId}`)?.classList.remove('drag-over');
|
||||
}
|
||||
document.getElementById('queueContainer')?.classList.remove('drag-over');
|
||||
document.querySelectorAll('.drop-zone-empty').forEach(z => z.classList.remove('drag-over'));
|
||||
|
||||
// Restore source element
|
||||
if (dragState.sourceEl) dragState.sourceEl.style.opacity = '';
|
||||
|
||||
// Remove ghost
|
||||
dragState.ghost?.remove();
|
||||
|
||||
// Find batch card under cursor
|
||||
dragState.ghost = null;
|
||||
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const batchCard = elUnder?.closest('.batch-card');
|
||||
|
||||
const state = dragState;
|
||||
dragState = null;
|
||||
dragOverBatchId = null;
|
||||
|
||||
if (!batchCard) {
|
||||
if (state.type === 'queue') {
|
||||
// Dropped on empty oven zone — auto-create batch and add
|
||||
const ovenZone = elUnder?.closest('[data-oven-id]');
|
||||
if (ovenZone) autoCreateBatchAndAdd(+ovenZone.dataset.ovenId, state);
|
||||
} else if (state.type === 'batch') {
|
||||
// Dropped outside any batch card — return item to queue
|
||||
removeFromBatch(state.batchItemId, state.sourceBatchId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const batchId = +batchCard.dataset.batchId;
|
||||
|
||||
if (state.type === 'queue') {
|
||||
addCoatToBatch(state, batchId);
|
||||
} else if (state.type === 'batch' && state.sourceBatchId !== batchId) {
|
||||
moveToBatch(state.batchItemId, batchId, state.sourceBatchId, state.sqft);
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Add coat from queue to batch
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function addCoatToBatch(drag, batchId) {
|
||||
const res = await apiPost(URLS.addToBatch, {
|
||||
batchId,
|
||||
jobItemCoatId: drag.coatId
|
||||
});
|
||||
|
||||
if (!res.success) { showToast(res.error || 'Failed to add to batch', 'danger'); return; }
|
||||
|
||||
// Remove from queue
|
||||
document.getElementById(`qcoat-${drag.coatId}`)?.remove();
|
||||
|
||||
// Add to batch items list
|
||||
appendBatchItemRow(batchId, res.item, true);
|
||||
|
||||
// Update capacity display
|
||||
updateBatchCapacity(batchId, res.totalSurfaceAreaSqFt, res.capacityPct);
|
||||
|
||||
// Update drop hint: switch from "Drag coats here" to "Drop more here"
|
||||
const hint = document.getElementById(`empty-items-${batchId}`);
|
||||
if (hint) {
|
||||
hint.classList.remove('batch-drop-hint-empty');
|
||||
hint.innerHTML = '<i class="bi bi-plus-circle me-1"></i><span>Drop more here</span>';
|
||||
}
|
||||
|
||||
updateQueueCount(-1);
|
||||
showToast('Added to batch', 'success');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Auto-create a batch then add the dragged coat (dropped on empty oven zone)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function autoCreateBatchAndAdd(ovenCostId, drag) {
|
||||
const createRes = await apiPost(URLS.createBatch, {
|
||||
ovenCostId,
|
||||
scheduledDate: SCHEDULED_DATE,
|
||||
scheduledStartTime: null
|
||||
});
|
||||
if (!createRes.success) { showToast(createRes.error || 'Failed to create batch', 'danger'); return; }
|
||||
|
||||
// Add the coat to the newly created batch before reloading
|
||||
const addRes = await apiPost(URLS.addToBatch, {
|
||||
batchId: createRes.batch.id,
|
||||
jobItemCoatId: drag.coatId
|
||||
});
|
||||
if (!addRes.success) { showToast(addRes.error || 'Batch created but could not add coat', 'warning'); }
|
||||
else { showToast('Batch created and item added!', 'success'); }
|
||||
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Move batch item between batches
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function moveToBatch(batchItemId, targetBatchId, sourceBatchId, sqft) {
|
||||
const res = await apiPost(URLS.moveToBatch, { batchItemId, targetBatchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to move item', 'danger'); return; }
|
||||
|
||||
// Move DOM element
|
||||
const itemEl = document.getElementById(`bitem-${batchItemId}`);
|
||||
const targetList = document.getElementById(`items-${targetBatchId}`);
|
||||
if (itemEl && targetList) {
|
||||
itemEl.dataset.batchId = targetBatchId;
|
||||
targetList.appendChild(itemEl);
|
||||
document.getElementById(`empty-items-${targetBatchId}`)?.remove();
|
||||
}
|
||||
|
||||
// Update capacity displays
|
||||
const sourceBatchCard = document.getElementById(`batch-${sourceBatchId}`);
|
||||
const maxSource = +(sourceBatchCard?.dataset.maxSqft || 0);
|
||||
updateBatchCapacity(sourceBatchId, res.sourceBatchTotal,
|
||||
maxSource > 0 ? Math.round(res.sourceBatchTotal / maxSource * 1000) / 10 : null);
|
||||
|
||||
const targetBatchCard = document.getElementById(`batch-${targetBatchId}`);
|
||||
const maxTarget = +(targetBatchCard?.dataset.maxSqft || 0);
|
||||
updateBatchCapacity(targetBatchId, res.targetBatchTotal,
|
||||
maxTarget > 0 ? Math.round(res.targetBatchTotal / maxTarget * 1000) / 10 : null);
|
||||
|
||||
showToast('Item moved', 'success');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Remove coat from batch back to queue (no page reload)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function removeFromBatch(batchItemId, batchId) {
|
||||
const res = await apiPost(URLS.removeFromBatch, { batchItemId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to remove', 'danger'); return; }
|
||||
|
||||
document.getElementById(`bitem-${batchItemId}`)?.remove();
|
||||
|
||||
const batchCard = document.getElementById(`batch-${batchId}`);
|
||||
const maxSqft = +(batchCard?.dataset.maxSqft || 0);
|
||||
const capPct = maxSqft > 0 ? Math.round(res.batchTotal / maxSqft * 1000) / 10 : null;
|
||||
updateBatchCapacity(batchId, res.batchTotal, capPct);
|
||||
|
||||
if (res.queueItem) returnCoatToQueue(res.queueItem);
|
||||
|
||||
showToast('Removed — returned to queue', 'info');
|
||||
}
|
||||
|
||||
function returnCoatToQueue(q) {
|
||||
const coatElId = `qcoat-${q.jobItemCoatId}`;
|
||||
if (document.getElementById(coatElId)) return;
|
||||
|
||||
const borderColors = { Rush: '#dc3545', Urgent: '#fd7e14', High: '#ffc107', Normal: '#0d6efd', Low: '#6c757d' };
|
||||
const border = borderColors[q.priority] || '#6c757d';
|
||||
|
||||
const coatEl = document.createElement('div');
|
||||
coatEl.className = 'batch-item-row d-flex align-items-center';
|
||||
coatEl.id = coatElId;
|
||||
coatEl.dataset.coatId = q.jobItemCoatId;
|
||||
coatEl.dataset.jobId = q.jobId;
|
||||
coatEl.dataset.jobItemId = q.jobItemId;
|
||||
coatEl.dataset.sqft = q.surfaceAreaSqFt;
|
||||
coatEl.dataset.color = q.colorName || '';
|
||||
coatEl.dataset.colorCode = q.colorCode || '';
|
||||
coatEl.dataset.pass = q.coatPassNumber;
|
||||
coatEl.dataset.coatName = q.coatName;
|
||||
coatEl.dataset.jobNumber = q.jobNumber;
|
||||
coatEl.dataset.customer = q.customerName;
|
||||
coatEl.dataset.description = q.itemDescription;
|
||||
coatEl.dataset.priority = q.priority;
|
||||
coatEl.dataset.dueDate = q.dueDate || '';
|
||||
|
||||
const colorDot = q.colorName
|
||||
? `<span class="color-dot" style="background:#aaaaaa;"></span>`
|
||||
: '';
|
||||
|
||||
coatEl.innerHTML = `
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="text-truncate">
|
||||
${colorDot}
|
||||
<span class="fw-medium">${escHtml(q.coatName)}</span>
|
||||
<span class="text-muted ms-1">— ${escHtml(q.itemDescription)}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">
|
||||
Pass ${q.coatPassNumber} · ${(+q.surfaceAreaSqFt).toFixed(1)} sqft
|
||||
${q.colorName ? `· ${escHtml(q.colorName)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<i class="bi bi-grip-vertical text-muted ms-1"></i>
|
||||
`;
|
||||
|
||||
// Wire drag via mousedown
|
||||
coatEl.addEventListener('mousedown', onQueueMouseDown);
|
||||
|
||||
// Find or create the job card in the queue
|
||||
const existingJobCard = document.getElementById(`qjob-${q.jobId}`);
|
||||
if (existingJobCard) {
|
||||
document.getElementById(`coats-job-${q.jobId}`)?.appendChild(coatEl);
|
||||
} else {
|
||||
const priorityBadgeClass = { Rush: 'bg-danger', Urgent: 'bg-warning text-dark', High: 'bg-primary', Normal: 'bg-secondary', Low: 'bg-light text-dark' }[q.priority] || 'bg-secondary';
|
||||
const dueDateHtml = q.dueDate
|
||||
? `<div class="small ${q.isOverdue ? 'text-danger fw-semibold' : 'text-muted'}">
|
||||
<i class="bi bi-calendar-event me-1"></i>Due ${formatDate(q.dueDate)}
|
||||
${q.isOverdue ? '<span class="badge bg-danger ms-1">Overdue</span>' : ''}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const jobCard = document.createElement('div');
|
||||
jobCard.className = 'queue-job-card card border-0 shadow-sm';
|
||||
jobCard.id = `qjob-${q.jobId}`;
|
||||
jobCard.dataset.priority = q.priority;
|
||||
jobCard.style.borderLeft = `4px solid ${border}`;
|
||||
jobCard.innerHTML = `
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="fw-semibold small me-auto">${escHtml(q.jobNumber)}</span>
|
||||
<span class="badge ${priorityBadgeClass} ms-1 small">${escHtml(q.priority)}</span>
|
||||
</div>
|
||||
<div class="text-muted small mb-1">${escHtml(q.customerName)}</div>
|
||||
${dueDateHtml}
|
||||
<div class="mt-2" id="coats-job-${q.jobId}"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const queueContainer = document.getElementById('queueContainer');
|
||||
const cards = [...queueContainer.querySelectorAll('.queue-job-card')];
|
||||
const insertBefore = cards.find(c => +(c.dataset.priorityId || 0) < +q.priorityId);
|
||||
insertBefore ? queueContainer.insertBefore(jobCard, insertBefore) : queueContainer.appendChild(jobCard);
|
||||
|
||||
document.getElementById(`coats-job-${q.jobId}`)?.appendChild(coatEl);
|
||||
}
|
||||
|
||||
updateQueueCount(1);
|
||||
}
|
||||
|
||||
function updateQueueCount(delta) {
|
||||
const badge = document.getElementById('queueCount');
|
||||
if (badge) badge.textContent = Math.max(0, (+badge.textContent || 0) + delta);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Create an empty batch
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function createBatch(ovenCostId, ovenName, date) {
|
||||
const res = await apiPost(URLS.createBatch, { ovenCostId, scheduledDate: date, scheduledStartTime: null });
|
||||
if (!res.success) { showToast(res.error || 'Failed to create batch', 'danger'); return; }
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Batch lifecycle actions
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function startBatch(batchId) {
|
||||
if (!await showConfirm('Start this batch? All linked jobs will be updated to "In Oven" status.', 'Start Batch')) return;
|
||||
const res = await apiPost(URLS.startBatch, { batchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to start batch', 'danger'); return; }
|
||||
showToast('Batch started!', 'success');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function completeBatch(batchId) {
|
||||
if (!await showConfirm('Mark this batch as complete? Job statuses will be updated to Curing or Coating.', 'Complete Batch')) return;
|
||||
const res = await apiPost(URLS.completeBatch, { batchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to complete batch', 'danger'); return; }
|
||||
showToast('Batch completed!', 'success');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function deleteBatch(batchId, batchNumber) {
|
||||
if (!await showConfirm(`Delete batch ${batchNumber}? Items will be returned to the queue.`, 'Delete', true)) return;
|
||||
const res = await apiPost(URLS.deleteBatch, { batchId });
|
||||
if (!res.success) { showToast(res.error || 'Failed to delete batch', 'danger'); return; }
|
||||
|
||||
const card = document.getElementById(`batch-${batchId}`);
|
||||
const ovenId = card?.dataset.ovenId;
|
||||
card?.remove();
|
||||
|
||||
// If the oven column is now empty, restore the drop zone hint
|
||||
const zone = document.getElementById(`zone-${ovenId}`);
|
||||
if (zone && !zone.querySelector('.batch-card')) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'drop-zone-empty';
|
||||
hint.id = `empty-${ovenId}`;
|
||||
hint.innerHTML = '<span><i class="bi bi-fire me-1"></i>Drop items here or click + to create a batch</span>';
|
||||
zone.appendChild(hint);
|
||||
}
|
||||
|
||||
showToast('Batch deleted', 'info');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// AI Suggestion Panel
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function openAiPanel() {
|
||||
document.getElementById('aiPanel').classList.add('open');
|
||||
document.getElementById('aiBackdrop').classList.add('open');
|
||||
const goalLabels = {
|
||||
maximize_throughput: 'Maximize Throughput',
|
||||
minimize_lateness: 'Minimize Lateness',
|
||||
minimize_color_changes: 'Minimize Color Changes'
|
||||
};
|
||||
document.getElementById('goalLabel').textContent = goalLabels[OPTIMIZATION_GOAL] || OPTIMIZATION_GOAL;
|
||||
}
|
||||
|
||||
function closeAiPanel() {
|
||||
document.getElementById('aiPanel').classList.remove('open');
|
||||
document.getElementById('aiBackdrop').classList.remove('open');
|
||||
}
|
||||
|
||||
async function runAiSuggest() {
|
||||
showAiState('loading');
|
||||
|
||||
const res = await apiPost(URLS.suggest, { optimizationGoal: OPTIMIZATION_GOAL });
|
||||
|
||||
if (!res.success) {
|
||||
document.getElementById('aiErrorText').textContent = res.error || 'AI error';
|
||||
showAiState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { suggestion } = res;
|
||||
AI_SUGGESTION_DATA.batches = suggestion.batches;
|
||||
|
||||
document.getElementById('aiSummary').textContent = suggestion.summary;
|
||||
|
||||
const warnEl = document.getElementById('aiWarnings');
|
||||
if (suggestion.warnings?.length > 0) {
|
||||
warnEl.innerHTML = suggestion.warnings.map(w =>
|
||||
`<div class="alert alert-warning py-1 px-2 mb-1 small"><i class="bi bi-exclamation-triangle me-1"></i>${escHtml(w)}</div>`
|
||||
).join('');
|
||||
warnEl.classList.remove('d-none');
|
||||
} else {
|
||||
warnEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
const listEl = document.getElementById('aiBatchList');
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (!suggestion.batches?.length) {
|
||||
listEl.innerHTML = '<div class="text-muted text-center py-3 small">No batches suggested. The queue may be empty or all coats are already scheduled.</div>';
|
||||
} else {
|
||||
suggestion.batches.forEach((batch, idx) => {
|
||||
const capPct = batch.capacityUtilization > 0 ? Math.round(batch.capacityUtilization * 100) : null;
|
||||
const capBar = capPct !== null
|
||||
? `<div class="mt-1">
|
||||
<div class="d-flex justify-content-between mb-1" style="font-size:.72rem;">
|
||||
<span class="text-muted">Utilization</span>
|
||||
<span class="fw-semibold">${capPct}%</span>
|
||||
</div>
|
||||
<div class="capacity-bar-wrap">
|
||||
<div class="capacity-bar-fill ${capPct >= 100 ? 'cap-over' : capPct >= 80 ? 'cap-warn' : 'cap-ok'}"
|
||||
style="width:${Math.min(capPct, 100)}%"></div>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const items = batch.items.map(i =>
|
||||
`<div class="d-flex align-items-center gap-1 py-1 border-bottom" style="font-size:.78rem;">
|
||||
<span class="badge bg-secondary" style="font-size:.68rem;">Pass ${i.coatPassNumber}</span>
|
||||
<div class="flex-grow-1 text-truncate">
|
||||
<span class="fw-medium">${escHtml(i.jobNumber)}</span>
|
||||
<span class="text-muted ms-1">${escHtml(i.description)}</span>
|
||||
</div>
|
||||
<span class="text-muted">${i.surfaceAreaSqFt.toFixed(1)} sqft</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
listEl.innerHTML +=
|
||||
`<div class="card mb-3 shadow-sm" id="aibatch-${idx}">
|
||||
<div class="card-header py-2 px-3 d-flex align-items-center gap-2"
|
||||
style="background:linear-gradient(90deg,#6f42c1 0%,#0d6efd 100%);color:white;">
|
||||
<i class="bi bi-fire"></i>
|
||||
<span class="fw-semibold small flex-grow-1">${escHtml(batch.batchName)}</span>
|
||||
<span class="badge bg-white text-dark" style="font-size:.7rem;">${escHtml(batch.ovenName)}</span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
${batch.suggestedStartTime ? `<span class="badge bg-light text-dark border"><i class="bi bi-clock me-1"></i>${batch.suggestedStartTime}</span>` : ''}
|
||||
${batch.primaryColorName ? `<span class="badge bg-secondary"><i class="bi bi-palette me-1"></i>${escHtml(batch.primaryColorName)}</span>` : ''}
|
||||
${batch.cureTemperatureF ? `<span class="badge bg-danger">${batch.cureTemperatureF}°F</span>` : ''}
|
||||
<span class="badge bg-light text-dark border">${batch.estimatedSqFt.toFixed(1)} sqft</span>
|
||||
<span class="badge bg-light text-dark border">${batch.estimatedCycleMinutes} min</span>
|
||||
</div>
|
||||
${capBar}
|
||||
<div class="mt-2">${items}</div>
|
||||
<div class="mt-2 text-muted small">
|
||||
<i class="bi bi-chat-left-text me-1" style="color:#6f42c1;"></i>
|
||||
${escHtml(batch.rationale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
showAiState('results');
|
||||
}
|
||||
|
||||
async function acceptAllBatches() {
|
||||
if (!AI_SUGGESTION_DATA.batches?.length) return;
|
||||
|
||||
document.getElementById('btnAcceptAll').disabled = true;
|
||||
document.getElementById('btnAcceptAll').innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...';
|
||||
|
||||
const res = await apiPost(URLS.acceptSuggestion, {
|
||||
scheduledDate: SCHEDULED_DATE,
|
||||
batches: AI_SUGGESTION_DATA.batches
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
showToast(res.error || 'Failed to save batches', 'danger');
|
||||
document.getElementById('btnAcceptAll').disabled = false;
|
||||
document.getElementById('btnAcceptAll').innerHTML = '<i class="bi bi-check-all me-1"></i>Accept All Batches';
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(`${res.batches?.length || 0} batch(es) created!`, 'success');
|
||||
closeAiPanel();
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
|
||||
function showAiState(state) {
|
||||
['loading', 'error', 'results', 'initial'].forEach(s => {
|
||||
const el = document.getElementById(`ai${s.charAt(0).toUpperCase() + s.slice(1)}`);
|
||||
if (el) {
|
||||
if (s === state) {
|
||||
el.classList.remove('d-none');
|
||||
if (s === 'results') el.classList.add('d-flex', 'flex-column');
|
||||
} else {
|
||||
el.classList.add('d-none');
|
||||
if (s === 'results') el.classList.remove('d-flex', 'flex-column');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// DOM helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
function appendBatchItemRow(batchId, item, withRemove) {
|
||||
const list = document.getElementById(`items-${batchId}`);
|
||||
if (!list) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'batch-item-row d-flex align-items-center';
|
||||
el.id = `bitem-${item.id}`;
|
||||
el.dataset.batchItemId = item.id;
|
||||
el.dataset.batchId = batchId;
|
||||
el.dataset.sqft = item.surfaceAreaContribution;
|
||||
el.setAttribute('draggable', 'true'); // kept for CSS selector compat
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="d-flex align-items-center gap-1 text-truncate">
|
||||
<span class="badge bg-light text-dark border" style="font-size:.7rem;">Pass ${item.coatPassNumber}</span>
|
||||
${item.colorName ? `<span class="text-truncate fw-medium small">${escHtml(item.colorName)}</span>` : ''}
|
||||
<span class="text-muted small text-truncate">${escHtml(item.itemDescription)}</span>
|
||||
</div>
|
||||
<div class="text-muted d-flex align-items-center gap-2" style="font-size:.73rem;">
|
||||
<span>${escHtml(item.jobNumber)}</span><span>·</span>
|
||||
<span>${(+item.surfaceAreaContribution).toFixed(1)} sqft</span>
|
||||
</div>
|
||||
</div>
|
||||
${withRemove
|
||||
? `<div class="d-flex align-items-center ms-1 gap-1">
|
||||
<i class="bi bi-grip-vertical text-muted"></i>
|
||||
<button class="btn btn-sm p-0 text-danger" style="line-height:1;"
|
||||
onclick="removeFromBatch(${item.id}, ${batchId})" title="Remove">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>`
|
||||
: '<i class="bi bi-grip-vertical text-muted ms-1"></i>'}
|
||||
`;
|
||||
|
||||
// Wire mouse-based drag
|
||||
el.addEventListener('mousedown', onBatchItemMouseDown);
|
||||
|
||||
list.appendChild(el);
|
||||
}
|
||||
|
||||
function updateBatchCapacity(batchId, totalSqFt, capPct) {
|
||||
document.querySelectorAll(`.batch-sqft[data-batch-id="${batchId}"]`).forEach(el => {
|
||||
const maxSqft = document.getElementById(`batch-${batchId}`)?.dataset.maxSqft;
|
||||
el.innerHTML = maxSqft
|
||||
? `${(+totalSqFt).toFixed(1)} / ${(+maxSqft).toFixed(0)} sqft`
|
||||
: `${(+totalSqFt).toFixed(1)} sqft`;
|
||||
});
|
||||
|
||||
document.querySelectorAll(`.batch-cap-bar[data-batch-id="${batchId}"]`).forEach(bar => {
|
||||
if (capPct !== null) {
|
||||
const pct = Math.min(+capPct, 100);
|
||||
bar.style.width = pct + '%';
|
||||
bar.className = `capacity-bar-fill ${pct >= 100 ? 'cap-over' : pct >= 80 ? 'cap-warn' : 'cap-ok'} batch-cap-bar`;
|
||||
bar.dataset.batchId = batchId;
|
||||
}
|
||||
});
|
||||
|
||||
const card = document.getElementById(`batch-${batchId}`);
|
||||
if (card) card.dataset.totalSqft = totalSqFt;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Utility
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
async function apiPost(url, data) {
|
||||
try {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||
|| document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'RequestVerificationToken': token } : {})
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function showConfirm(message, confirmLabel = 'Confirm', danger = false) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = `
|
||||
position:fixed; inset:0; z-index:10000;
|
||||
background:rgba(0,0,0,.45);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
`;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = `
|
||||
background:var(--bs-body-bg);
|
||||
border-radius:12px;
|
||||
padding:1.5rem 1.75rem;
|
||||
max-width:360px; width:90%;
|
||||
box-shadow:0 16px 48px rgba(0,0,0,.3);
|
||||
text-align:center;
|
||||
`;
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.style.cssText = 'margin:0 0 1.25rem; font-size:.95rem; line-height:1.5;';
|
||||
msg.textContent = message;
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.cssText = 'display:flex; gap:.75rem; justify-content:center;';
|
||||
|
||||
const btnCancel = document.createElement('button');
|
||||
btnCancel.className = 'btn btn-outline-secondary';
|
||||
btnCancel.textContent = 'Cancel';
|
||||
|
||||
const btnOk = document.createElement('button');
|
||||
btnOk.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
|
||||
btnOk.textContent = confirmLabel;
|
||||
|
||||
btnRow.appendChild(btnCancel);
|
||||
btnRow.appendChild(btnOk);
|
||||
box.appendChild(msg);
|
||||
box.appendChild(btnRow);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = result => { overlay.remove(); resolve(result); };
|
||||
btnOk.addEventListener('click', () => close(true));
|
||||
btnCancel.addEventListener('click', () => close(false));
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const colors = { success: '#198754', danger: '#dc3545', info: '#0dcaf0', warning: '#ffc107' };
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999;
|
||||
background:${colors[type] || '#333'}; color:white;
|
||||
padding:.65rem 1.1rem; border-radius:8px;
|
||||
font-size:.88rem; box-shadow:0 4px 16px rgba(0,0,0,.2);
|
||||
transition:opacity .3s; max-width:320px;
|
||||
`;
|
||||
if (type === 'warning') toast.style.color = '#212529';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2800);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* purchase-orders.js
|
||||
* Handles dynamic line-item management for Purchase Order Create/Edit forms.
|
||||
* A single searchable text input per row: type to filter inventory items or
|
||||
* enter a free-text description. Selecting an inventory item stores its ID in
|
||||
* a hidden field; typing something custom leaves the hidden field empty.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
let inventoryData = [];
|
||||
let itemIndex = 0;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dataEl = document.getElementById('inventoryItemsData');
|
||||
if (dataEl) {
|
||||
try { inventoryData = JSON.parse(dataEl.textContent); } catch (e) { inventoryData = []; }
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('lineItemsBody');
|
||||
if (tbody) {
|
||||
itemIndex = tbody.querySelectorAll('tr[data-index]').length;
|
||||
}
|
||||
|
||||
updateTotals();
|
||||
});
|
||||
|
||||
// ─── Add / Remove rows ────────────────────────────────────────────────────────
|
||||
|
||||
function addItem() {
|
||||
const tbody = document.getElementById('lineItemsBody');
|
||||
if (!tbody) return;
|
||||
|
||||
const i = itemIndex++;
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-index', i);
|
||||
tr.innerHTML = buildRowHtml(i, {});
|
||||
tbody.appendChild(tr);
|
||||
toggleEmptyMessage();
|
||||
updateTotals();
|
||||
|
||||
tr.querySelector('.item-search-input')?.focus();
|
||||
}
|
||||
|
||||
function removeItem(btn) {
|
||||
const tr = btn.closest('tr');
|
||||
if (tr) {
|
||||
tr.remove();
|
||||
reindexRows();
|
||||
updateTotals();
|
||||
toggleEmptyMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function reindexRows() {
|
||||
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
|
||||
rows.forEach((tr, newIndex) => {
|
||||
tr.setAttribute('data-index', newIndex);
|
||||
tr.querySelectorAll('[name]').forEach(el => {
|
||||
el.name = el.name.replace(/Items\[\d+\]/, `Items[${newIndex}]`);
|
||||
});
|
||||
const searchInput = tr.querySelector('.item-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.setAttribute('data-row-index', newIndex);
|
||||
searchInput.setAttribute('oninput', `onItemSearch(this, ${newIndex})`);
|
||||
}
|
||||
});
|
||||
itemIndex = rows.length;
|
||||
}
|
||||
|
||||
// ─── Row HTML builder ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* opts: { type: 'inventory'|'custom', selectedId, description, qty, cost, notes }
|
||||
*/
|
||||
function buildRowHtml(i, opts = {}) {
|
||||
const isInventory = opts.type === 'inventory' && opts.selectedId;
|
||||
const qty = opts.qty ?? 1;
|
||||
const cost = opts.cost ?? 0;
|
||||
const notes = opts.notes ?? '';
|
||||
const lineTotal = (qty * cost).toFixed(2);
|
||||
|
||||
let displayText = '';
|
||||
if (isInventory) {
|
||||
const inv = inventoryData.find(x => String(x.value) === String(opts.selectedId));
|
||||
displayText = inv ? inv.text : (opts.description || '');
|
||||
} else {
|
||||
displayText = opts.description || '';
|
||||
}
|
||||
|
||||
return `
|
||||
<td style="min-width:240px">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm item-search-input"
|
||||
name="Items[${i}].Description"
|
||||
value="${escHtml(displayText)}"
|
||||
placeholder="Search inventory or enter description…"
|
||||
autocomplete="off"
|
||||
oninput="onItemSearch(this, ${i})"
|
||||
onblur="hideAutocomplete()"
|
||||
data-row-index="${i}" />
|
||||
<input type="hidden"
|
||||
name="Items[${i}].InventoryItemId"
|
||||
class="inventory-id-field"
|
||||
value="${isInventory ? (opts.selectedId || '') : ''}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" name="Items[${i}].QuantityOrdered"
|
||||
class="form-control form-control-sm"
|
||||
value="${qty}" min="0.001" step="0.001" style="width:85px"
|
||||
oninput="updateLineTotals()" required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" name="Items[${i}].UnitCost"
|
||||
class="form-control form-control-sm"
|
||||
value="${cost}" min="0" step="0.01" style="width:105px"
|
||||
oninput="updateLineTotals()" />
|
||||
</td>
|
||||
<td class="item-line-total text-end fw-semibold align-middle">$${lineTotal}</td>
|
||||
<td>
|
||||
<input type="text" name="Items[${i}].Notes"
|
||||
class="form-control form-control-sm"
|
||||
value="${escHtml(notes)}" placeholder="Optional" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItem(this)" title="Remove">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
// ─── Autocomplete ─────────────────────────────────────────────────────────────
|
||||
// Uses a single body-level dropdown so it's never clipped by table overflow.
|
||||
|
||||
let _activeSearchInput = null;
|
||||
|
||||
function getAutocompleteDropdown() {
|
||||
let el = document.getElementById('po-autocomplete');
|
||||
if (!el) {
|
||||
el = document.createElement('ul');
|
||||
el.id = 'po-autocomplete';
|
||||
el.className = 'list-group shadow';
|
||||
el.style.cssText = 'position:fixed;z-index:9999;max-height:220px;overflow-y:auto;'
|
||||
+ 'margin:0;padding:0;border-radius:.35rem;min-width:180px;display:none;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function onItemSearch(input, rowIndex) {
|
||||
_activeSearchInput = input;
|
||||
|
||||
// Clear previously selected inventory item when user edits the field
|
||||
const tr = input.closest('tr');
|
||||
const idField = tr?.querySelector('.inventory-id-field');
|
||||
if (idField) idField.value = '';
|
||||
|
||||
const query = input.value.trim().toLowerCase();
|
||||
const dropdown = getAutocompleteDropdown();
|
||||
|
||||
if (!query) {
|
||||
dropdown.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = inventoryData
|
||||
.filter(item => item.text.toLowerCase().includes(query))
|
||||
.slice(0, 12);
|
||||
|
||||
if (matches.length === 0) {
|
||||
dropdown.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = matches.map(item => `
|
||||
<li class="list-group-item list-group-item-action py-1 px-2 small"
|
||||
style="cursor:pointer"
|
||||
data-value="${item.value}"
|
||||
data-cost="${item.cost}"
|
||||
data-text="${escHtml(item.text)}"
|
||||
onmousedown="selectInventoryItem(this, ${rowIndex})">
|
||||
${escHtml(item.text)}
|
||||
</li>`).join('');
|
||||
|
||||
// Position below the input
|
||||
const rect = input.getBoundingClientRect();
|
||||
dropdown.style.top = (rect.bottom + 2) + 'px';
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.width = rect.width + 'px';
|
||||
dropdown.style.display = '';
|
||||
}
|
||||
|
||||
function selectInventoryItem(li, rowIndex) {
|
||||
const tr = document.querySelector(`#lineItemsBody tr[data-index="${rowIndex}"]`);
|
||||
if (!tr) return;
|
||||
|
||||
const searchInput = tr.querySelector('.item-search-input');
|
||||
const idField = tr.querySelector('.inventory-id-field');
|
||||
const costInput = tr.querySelector(`[name="Items[${rowIndex}].UnitCost"]`);
|
||||
|
||||
if (searchInput) searchInput.value = li.dataset.text;
|
||||
if (idField) idField.value = li.dataset.value;
|
||||
|
||||
if (costInput) {
|
||||
const cost = parseFloat(li.dataset.cost);
|
||||
if (cost > 0) costInput.value = cost.toFixed(2);
|
||||
}
|
||||
|
||||
getAutocompleteDropdown().style.display = 'none';
|
||||
_activeSearchInput = null;
|
||||
|
||||
updateLineTotals();
|
||||
}
|
||||
|
||||
function hideAutocomplete() {
|
||||
// Delay so mousedown on a list item fires before blur hides the dropdown
|
||||
setTimeout(() => {
|
||||
getAutocompleteDropdown().style.display = 'none';
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Reposition on scroll/resize so the dropdown tracks the input
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!_activeSearchInput) return;
|
||||
const rect = _activeSearchInput.getBoundingClientRect();
|
||||
const dd = document.getElementById('po-autocomplete');
|
||||
if (dd && dd.style.display !== 'none') {
|
||||
dd.style.top = (rect.bottom + 2) + 'px';
|
||||
dd.style.left = rect.left + 'px';
|
||||
}
|
||||
}, true);
|
||||
|
||||
// ─── Totals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function updateLineTotals() {
|
||||
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
|
||||
let subTotal = 0;
|
||||
|
||||
rows.forEach(tr => {
|
||||
const i = tr.getAttribute('data-index');
|
||||
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
|
||||
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
|
||||
const lineTotal = qty * cost;
|
||||
subTotal += lineTotal;
|
||||
|
||||
const cell = tr.querySelector('.item-line-total');
|
||||
if (cell) cell.textContent = '$' + lineTotal.toFixed(2);
|
||||
});
|
||||
|
||||
updateTotals(subTotal);
|
||||
}
|
||||
|
||||
function updateTotals(subTotal) {
|
||||
if (subTotal === undefined) {
|
||||
subTotal = 0;
|
||||
document.querySelectorAll('#lineItemsBody tr[data-index]').forEach(tr => {
|
||||
const i = tr.getAttribute('data-index');
|
||||
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
|
||||
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
|
||||
subTotal += qty * cost;
|
||||
});
|
||||
}
|
||||
|
||||
const shipping = parseFloat(document.getElementById('shippingCostInput')?.value) || 0;
|
||||
const grandTotal = subTotal + shipping;
|
||||
|
||||
setText('subTotalDisplay', '$' + subTotal.toFixed(2));
|
||||
setText('grandTotalDisplay', '$' + grandTotal.toFixed(2));
|
||||
}
|
||||
|
||||
function setText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function toggleEmptyMessage() {
|
||||
const tbody = document.getElementById('lineItemsBody');
|
||||
const msg = document.getElementById('emptyItemsMessage');
|
||||
const header = document.getElementById('lineItemsHeader');
|
||||
if (!tbody || !msg) return;
|
||||
const hasRows = tbody.querySelectorAll('tr[data-index]').length > 0;
|
||||
msg.style.display = hasRows ? 'none' : 'block';
|
||||
if (header) header.style.display = hasRows ? '' : 'none';
|
||||
}
|
||||
|
||||
// ─── Receive form helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function receiveAll() {
|
||||
document.querySelectorAll('.receive-qty-input').forEach(input => {
|
||||
input.value = parseFloat(input.dataset.remaining) || 0;
|
||||
});
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
document.querySelectorAll('.receive-qty-input').forEach(input => {
|
||||
input.value = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* QuickBooks Migration Wizard — qb-migration-wizard.js
|
||||
* Embedded in Setup Wizard Step 2.
|
||||
* Manages a 9-step modal that walks the user through all QB Desktop data imports.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Step Definitions ──────────────────────────────────────────────────────
|
||||
const STEPS = [
|
||||
{
|
||||
id: 1, key: 'chartOfAccounts', title: 'Chart of Accounts',
|
||||
icon: 'bi-diagram-3', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportChartOfAccounts',
|
||||
deps: [],
|
||||
intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so vendor bills and other financial data can link to the correct accounts.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Lists → Chart of Accounts</strong>.',
|
||||
'Click the <strong>Account</strong> button at the bottom of the list.',
|
||||
'Choose <strong>Import/Export → Export Chart of Accounts</strong> (or use <em>File → Utilities → Export → Lists to IIF Files → Chart of Accounts</em>).',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2, key: 'customers', title: 'Customers',
|
||||
icon: 'bi-people', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportCustomers',
|
||||
deps: [],
|
||||
intro: 'Import your customer list so that historical invoices and payments can be matched to existing customer records.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
|
||||
'Check <strong>Customer List</strong> and click OK.',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3, key: 'vendors', title: 'Vendors',
|
||||
icon: 'bi-truck', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportVendors',
|
||||
deps: [],
|
||||
intro: 'Import your vendor list. Vendors are required before importing inventory stock levels and vendor bills.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
|
||||
'Check <strong>Vendor List</strong> and click OK.',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4, key: 'catalogItems', title: 'Catalog Items',
|
||||
icon: 'bi-grid', fileAccept: '.iif',
|
||||
endpoint: '/Tools/ImportCatalogItems',
|
||||
deps: [],
|
||||
intro: 'Import service items from QuickBooks to populate your catalog with pre-priced services.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
|
||||
'Check <strong>Item List</strong> and click OK.',
|
||||
'Save the <code>.iif</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5, key: 'inventory', title: 'Inventory',
|
||||
icon: 'bi-boxes', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbInventoryValuation',
|
||||
deps: [3],
|
||||
intro: 'Import current stock levels from an Inventory Valuation Summary report. Vendors must be imported first for preferred vendor matching.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Inventory → Inventory Valuation Summary</strong>.',
|
||||
'Click <strong>Customize Report</strong>, open the <strong>Display</strong> tab, and add the <strong>Preferred Vendor</strong> column.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6, key: 'invoices', title: 'Invoices',
|
||||
icon: 'bi-receipt', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbInvoices',
|
||||
deps: [2, 4],
|
||||
intro: 'Import historical invoices from a Customer Balance Detail report. Customers and catalog items should be imported first.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Customers & Receivables → Customer Balance Detail</strong>.',
|
||||
'Set the date range to <strong>All</strong> to cover your full history.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7, key: 'transactions', title: 'Customer Payments',
|
||||
icon: 'bi-cash-coin', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbTransactions',
|
||||
deps: [6],
|
||||
intro: 'Import payment records to update invoice balances. Invoices must be imported first.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Customers & Receivables → Transaction List by Customer</strong>.',
|
||||
'Set the date range to <strong>All</strong> to cover your full history.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8, key: 'bills', title: 'Vendor Bills & Payments',
|
||||
icon: 'bi-file-earmark-text', fileAccept: '.csv',
|
||||
endpoint: '/Tools/ImportQbBillsAndPayments',
|
||||
deps: [1, 3],
|
||||
intro: 'Import vendor bills and payment history in a single step. The same Vendor Balance Detail file contains both — bills are imported first, then payments are matched against them.',
|
||||
instructions: [
|
||||
'In QuickBooks Desktop, go to <strong>Reports → Vendors & Payables → Vendor Balance Detail</strong>.',
|
||||
'Set the date range to <strong>All</strong> to cover your full history.',
|
||||
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
|
||||
'Upload the CSV file here — bills and payments will both be processed automatically.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ── QuickBooks Online Step Definitions ────────────────────────────────────
|
||||
const QBO_STEPS = [
|
||||
{
|
||||
id: 1, key: 'qbo_coa', title: 'Chart of Accounts',
|
||||
icon: 'bi-diagram-3', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboChartOfAccounts',
|
||||
deps: [],
|
||||
intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so financial data can link to the correct accounts.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Accounting → Chart of Accounts</strong>.',
|
||||
'Click the <strong>Run Report</strong> button at the top right.',
|
||||
'Click the <strong>Export</strong> icon (spreadsheet icon) and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2, key: 'qbo_customers', title: 'Customers',
|
||||
icon: 'bi-people', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboCustomers',
|
||||
deps: [],
|
||||
intro: 'Import your customer list so invoices and payments can be matched to existing customer records.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Customer Contact List</strong>.',
|
||||
'Click <strong>Customize</strong> if you want to include additional fields (address, terms, balance).',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3, key: 'qbo_vendors', title: 'Vendors',
|
||||
icon: 'bi-truck', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboVendors',
|
||||
deps: [],
|
||||
intro: 'Import your vendor list before importing purchase orders or inventory.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Vendor Contact List</strong>.',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4, key: 'qbo_products', title: 'Products & Services',
|
||||
icon: 'bi-grid', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboCatalogItems',
|
||||
deps: [],
|
||||
intro: 'Import your products and services. Inventory-type items will create inventory records; all others become catalog items.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Sales → Products and Services</strong>.',
|
||||
'Click the <strong>More</strong> (⋮) button at the top right.',
|
||||
'Choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5, key: 'qbo_invoices', title: 'Invoices',
|
||||
icon: 'bi-receipt', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboInvoices',
|
||||
deps: [2],
|
||||
intro: 'Import historical invoices. Customers should be imported first for matching.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Invoice List</strong>.',
|
||||
'Set the date range to cover all of your history.',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6, key: 'qbo_transactions', title: 'Payments',
|
||||
icon: 'bi-cash-stack', fileAccept: '.xlsx,.xls',
|
||||
endpoint: '/Tools/ImportQboTransactions',
|
||||
deps: [5],
|
||||
intro: 'Import payment history to mark invoices as paid. Invoices must be imported first.',
|
||||
instructions: [
|
||||
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Transaction List by Date</strong>.',
|
||||
'Set the date range to cover all of your history.',
|
||||
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
|
||||
'Save the <code>.xlsx</code> file and upload it here.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ── Active step set (Desktop or Online) ───────────────────────────────────
|
||||
let activeSteps = STEPS; // default to Desktop
|
||||
let activeSource = 'desktop';
|
||||
|
||||
// Statuses: pending | complete | skipped | error | blocked
|
||||
let currentStepIdx = 0; // index into activeSteps
|
||||
let stepState = {}; // key -> { status, result }
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
// Default all steps to pending
|
||||
activeSteps.forEach(s => {
|
||||
stepState[s.key] = stepState[s.key] || { status: 'pending', result: null };
|
||||
});
|
||||
refreshBlocked();
|
||||
}
|
||||
|
||||
function refreshBlocked() {
|
||||
activeSteps.forEach(s => {
|
||||
if (s.deps.length === 0) return;
|
||||
const current = stepState[s.key].status;
|
||||
if (current === 'complete' || current === 'skipped') return;
|
||||
const anyDepUnresolved = s.deps.some(depId => {
|
||||
const depStep = activeSteps.find(x => x.id === depId);
|
||||
const depStatus = depStep ? stepState[depStep.key].status : 'pending';
|
||||
return depStatus === 'pending' || depStatus === 'blocked' || depStatus === 'error';
|
||||
});
|
||||
stepState[s.key].status = anyDepUnresolved ? 'blocked' : 'pending';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Open Modal ────────────────────────────────────────────────────────────
|
||||
window.openQbWizard = async function (source) {
|
||||
activeSteps = (source === 'online') ? QBO_STEPS : STEPS;
|
||||
activeSource = source;
|
||||
stepState = {}; // reset state when switching source
|
||||
init();
|
||||
// Load persisted state from server
|
||||
try {
|
||||
const resp = await fetch('/SetupWizard/GetQbMigrationState');
|
||||
const data = await resp.json();
|
||||
if (data.state) {
|
||||
const saved = JSON.parse(data.state);
|
||||
// Only restore state if same source
|
||||
if (saved && saved.source === source && saved.steps) {
|
||||
Object.assign(stepState, saved.steps);
|
||||
}
|
||||
// Resume at first incomplete step
|
||||
const firstIncomplete = activeSteps.findIndex(s => {
|
||||
const st = stepState[s.key]?.status;
|
||||
return st !== 'complete' && st !== 'skipped';
|
||||
});
|
||||
currentStepIdx = firstIncomplete >= 0 ? firstIncomplete : activeSteps.length - 1;
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal — start from beginning
|
||||
}
|
||||
refreshBlocked();
|
||||
// Update modal title to reflect source
|
||||
var titleEl = document.getElementById('qbMigrationWizardLabel');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = source === 'online'
|
||||
? 'QuickBooks Online Migration Wizard'
|
||||
: 'QuickBooks Desktop Migration Wizard';
|
||||
}
|
||||
renderWizard();
|
||||
const modal = new bootstrap.Modal(document.getElementById('qbMigrationWizard'));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
function renderWizard() {
|
||||
renderStepIndicator();
|
||||
renderProgressBar();
|
||||
renderStepContent();
|
||||
renderFooterButtons();
|
||||
}
|
||||
|
||||
function renderStepIndicator() {
|
||||
const container = document.getElementById('qbwStepIndicator');
|
||||
if (!container) return;
|
||||
container.innerHTML = activeSteps.map((s, idx) => {
|
||||
const st = stepState[s.key]?.status || 'pending';
|
||||
const isActive = idx === currentStepIdx;
|
||||
let statusClass = isActive ? 'active' : st;
|
||||
let icon = '';
|
||||
if (st === 'complete') icon = '<i class="bi bi-check-lg"></i>';
|
||||
else if (st === 'skipped') icon = '<i class="bi bi-skip-forward-fill"></i>';
|
||||
else if (st === 'error') icon = '<i class="bi bi-exclamation-triangle-fill"></i>';
|
||||
else if (st === 'blocked') icon = '<i class="bi bi-lock-fill"></i>';
|
||||
else icon = s.id;
|
||||
return `<div class="qbw-dot ${statusClass}" onclick="qbwGoTo(${idx})" title="${s.title}">
|
||||
<div class="qbw-dot-circle">${icon}</div>
|
||||
<div class="qbw-dot-label">${s.title}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Update badge
|
||||
const badge = document.getElementById('qbwStepBadge');
|
||||
if (badge) badge.textContent = `Step ${currentStepIdx + 1} of ${activeSteps.length}`;
|
||||
}
|
||||
|
||||
function renderProgressBar() {
|
||||
const completed = Object.values(stepState).filter(s => s.status === 'complete' || s.status === 'skipped').length;
|
||||
const pct = Math.round((completed / activeSteps.length) * 100);
|
||||
const bar = document.getElementById('qbwProgressBar');
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
}
|
||||
|
||||
function renderStepContent() {
|
||||
const container = document.getElementById('qbwStepContent');
|
||||
if (!container) return;
|
||||
const step = activeSteps[currentStepIdx];
|
||||
const st = stepState[step.key];
|
||||
container.innerHTML = buildStepHtml(step, st);
|
||||
}
|
||||
|
||||
function buildStepHtml(step, st) {
|
||||
const isBlocked = st.status === 'blocked';
|
||||
const blockedNames = step.deps.map(depId => {
|
||||
const depStep = activeSteps.find(x => x.id === depId);
|
||||
return depStep ? depStep.title : '';
|
||||
}).filter(Boolean).join(', ');
|
||||
|
||||
let resultHtml = '';
|
||||
if (st.result) {
|
||||
resultHtml = buildResultHtml(st.result, st.status);
|
||||
}
|
||||
|
||||
const instructionItems = step.instructions.map(i => `<li class="mb-1">${i}</li>`).join('');
|
||||
|
||||
return `
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-start gap-3 mb-3">
|
||||
<div class="rounded-3 p-3 d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="background:var(--bs-primary-bg-subtle); color:var(--bs-primary); width:60px; height:60px;">
|
||||
<i class="bi ${step.icon} fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1">${step.title}</h5>
|
||||
<p class="text-secondary mb-0">${step.intro}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${isBlocked ? `
|
||||
<div class="alert alert-warning d-flex gap-2 mb-3">
|
||||
<i class="bi bi-lock-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Step locked.</strong>
|
||||
This step requires the following to be completed first:
|
||||
<strong>${blockedNames}</strong>.
|
||||
Go back and complete those steps, then return here.
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="accordion mb-3" id="qbwInstructions-${step.id}">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button ${st.status === 'complete' ? 'collapsed' : ''}" type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#qbwInstructionsBody-${step.id}"
|
||||
aria-expanded="${st.status === 'complete' ? 'false' : 'true'}">
|
||||
<i class="bi bi-info-circle me-2"></i>How to export from QuickBooks Desktop
|
||||
</button>
|
||||
</h2>
|
||||
<div id="qbwInstructionsBody-${step.id}" class="accordion-collapse collapse ${st.status === 'complete' ? '' : 'show'}">
|
||||
<div class="accordion-body pb-2">
|
||||
<ol class="mb-0">${instructionItems}</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${!isBlocked ? `
|
||||
<div class="qbw-upload-zone mb-3">
|
||||
<label class="form-label fw-medium mb-2" for="qbwFile-${step.id}">
|
||||
<i class="bi bi-cloud-upload me-1"></i>Select file to import
|
||||
<span class="text-secondary fw-normal">(${step.fileAccept})</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" id="qbwFile-${step.id}" accept="${step.fileAccept}">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="qbwImportBtn-${step.id}"
|
||||
onclick="qbwRunImport(${currentStepIdx})"
|
||||
${isBlocked ? 'disabled' : ''}>
|
||||
<i class="bi bi-cloud-upload me-1"></i>Import Now
|
||||
</button>
|
||||
<div id="qbwImportSpinner-${step.id}" class="d-none d-inline-flex align-items-center gap-2 ms-3 text-secondary">
|
||||
<span class="spinner-border spinner-border-sm"></span> Importing…
|
||||
</div>` : ''}
|
||||
|
||||
${resultHtml ? `<div class="mt-3 qbw-result-box">${resultHtml}</div>` : ''}
|
||||
|
||||
<div id="qbwResultContainer-${step.id}" class="mt-3 qbw-result-box"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildResultHtml(result, status) {
|
||||
const alertClass = status === 'error' ? 'alert-danger' : status === 'complete' ? 'alert-success' : 'alert-warning';
|
||||
const icon = status === 'error' ? 'bi-x-circle-fill' : status === 'complete' ? 'bi-check-circle-fill' : 'bi-exclamation-triangle-fill';
|
||||
let html = `<div class="alert ${alertClass} mb-0">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi ${icon}"></i>
|
||||
<strong>${status === 'complete' ? 'Import successful' : status === 'error' ? 'Import failed' : 'Import completed with issues'}</strong>
|
||||
</div>
|
||||
<div class="row g-2 text-center mb-2">
|
||||
${(result.billsImported != null || result.paymentsImported != null) ? `
|
||||
<div class="col-auto"><span class="badge bg-success"><i class="bi bi-file-earmark-text me-1"></i>${result.billsImported ?? 0} Bills Imported</span></div>
|
||||
<div class="col-auto"><span class="badge bg-info"><i class="bi bi-credit-card me-1"></i>${result.paymentsImported ?? 0} Payments Applied</span></div>
|
||||
` : `
|
||||
<div class="col-auto"><span class="badge bg-secondary">${result.totalRecords ?? 0} Total</span></div>
|
||||
<div class="col-auto"><span class="badge bg-success">${result.importedCount ?? 0} Imported</span></div>
|
||||
<div class="col-auto"><span class="badge bg-info">${result.updatedCount ?? 0} Updated</span></div>
|
||||
`}
|
||||
${(result.skippedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.skippedCount} Skipped</span></div>` : ''}
|
||||
${(result.alreadyRecordedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.alreadyRecordedCount} Already Recorded</span></div>` : ''}
|
||||
<div class="col-auto"><span class="badge bg-danger">${result.errorCount ?? 0} Errors</span></div>
|
||||
</div>`;
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
html += `<details><summary class="small fw-medium">${result.errors.length} error message(s) — click to expand</summary>
|
||||
<ul class="small mt-1 mb-0">
|
||||
${result.errors.slice(0, 20).map(e => `<li>${escHtml(e)}</li>`).join('')}
|
||||
${result.errors.length > 20 ? `<li>… and ${result.errors.length - 20} more</li>` : ''}
|
||||
</ul></details>`;
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
html += `<details><summary class="small fw-medium">${result.warnings.length} warning(s) — click to expand</summary>
|
||||
<ul class="small mt-1 mb-0">
|
||||
${result.warnings.slice(0, 10).map(w => `<li>${escHtml(w)}</li>`).join('')}
|
||||
</ul></details>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderFooterButtons() {
|
||||
const btnBack = document.getElementById('qbwBtnBack');
|
||||
const btnSkip = document.getElementById('qbwBtnSkip');
|
||||
const btnNext = document.getElementById('qbwBtnNext');
|
||||
const btnFinish = document.getElementById('qbwBtnFinish');
|
||||
if (!btnBack) return;
|
||||
|
||||
const isLast = currentStepIdx === activeSteps.length - 1;
|
||||
const st = stepState[activeSteps[currentStepIdx].key];
|
||||
|
||||
btnBack.classList.toggle('d-none', currentStepIdx === 0);
|
||||
btnSkip.classList.toggle('d-none', isLast || st.status === 'complete');
|
||||
btnNext.classList.toggle('d-none', isLast);
|
||||
btnFinish.classList.toggle('d-none', !isLast);
|
||||
|
||||
// Disable Next if current step is still pending (not complete/skipped)
|
||||
const canAdvance = st.status === 'complete' || st.status === 'skipped' || st.status === 'blocked';
|
||||
btnNext.disabled = !canAdvance;
|
||||
if (isLast) {
|
||||
btnFinish.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
window.qbwGoTo = function (idx) {
|
||||
if (idx < 0 || idx >= activeSteps.length) return;
|
||||
currentStepIdx = idx;
|
||||
renderWizard();
|
||||
};
|
||||
|
||||
window.qbwBack = function () {
|
||||
if (currentStepIdx > 0) {
|
||||
currentStepIdx--;
|
||||
renderWizard();
|
||||
}
|
||||
};
|
||||
|
||||
window.qbwNext = function () {
|
||||
if (currentStepIdx < activeSteps.length - 1) {
|
||||
currentStepIdx++;
|
||||
renderWizard();
|
||||
}
|
||||
};
|
||||
|
||||
window.qbwSkip = function () {
|
||||
const step = activeSteps[currentStepIdx];
|
||||
stepState[step.key].status = 'skipped';
|
||||
refreshBlocked();
|
||||
saveState();
|
||||
window.qbwNext();
|
||||
};
|
||||
|
||||
window.qbwFinish = function () {
|
||||
saveState();
|
||||
// Show completion message on Step 2 page if present
|
||||
const completionMsg = document.getElementById('qbWizardCompletionAlert');
|
||||
if (completionMsg) completionMsg.classList.remove('d-none');
|
||||
};
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────
|
||||
window.qbwRunImport = async function (stepIdx) {
|
||||
const step = activeSteps[stepIdx];
|
||||
const fileInput = document.getElementById(`qbwFile-${step.id}`);
|
||||
const importBtn = document.getElementById(`qbwImportBtn-${step.id}`);
|
||||
const spinner = document.getElementById(`qbwImportSpinner-${step.id}`);
|
||||
const resultContainer = document.getElementById(`qbwResultContainer-${step.id}`);
|
||||
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
showToast('Please select a file to import.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
importBtn.disabled = true;
|
||||
spinner?.classList.remove('d-none');
|
||||
resultContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
// Include anti-forgery token if present on page
|
||||
const aft = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
if (aft) formData.append('__RequestVerificationToken', aft.value);
|
||||
|
||||
const resp = await fetch(step.endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Server returned ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const result = data.result || data;
|
||||
|
||||
// Errors array contains ImportErrorDto objects — split into hard errors vs warnings by severity
|
||||
const allErrors = result.errors ?? result.Errors ?? [];
|
||||
const toStr = e => (typeof e === 'string') ? e : (e.displayMessage ?? e.errorMessage ?? JSON.stringify(e));
|
||||
const hardErrors = allErrors.filter(e => !e.severity || e.severity === 'Error');
|
||||
const warnings = allErrors.filter(e => e.severity === 'Warning' || e.severity === 'Skipped');
|
||||
|
||||
const normalised = {
|
||||
totalRecords: result.totalRecords ?? result.TotalRecords ?? 0,
|
||||
importedCount: result.importedCount ?? result.ImportedCount ?? 0,
|
||||
updatedCount: result.updatedCount ?? result.UpdatedCount ?? 0,
|
||||
skippedCount: result.skippedCount ?? result.SkippedCount ?? 0,
|
||||
alreadyRecordedCount: result.alreadyRecordedCount ?? result.AlreadyRecordedCount ?? 0,
|
||||
billsImported: result.billsImported ?? result.BillsImported ?? null,
|
||||
paymentsImported: result.paymentsImported ?? result.PaymentsImported ?? null,
|
||||
errorCount: hardErrors.length,
|
||||
errors: hardErrors.map(toStr),
|
||||
warnings: warnings.map(toStr)
|
||||
};
|
||||
|
||||
// Hard failure = server returned success:false AND nothing was imported at all
|
||||
const hasHardError = result.success === false
|
||||
&& normalised.importedCount === 0
|
||||
&& normalised.updatedCount === 0;
|
||||
const tooManyErrors = false; // covered by hasHardError logic above
|
||||
|
||||
if (hasHardError || tooManyErrors) {
|
||||
stepState[step.key] = { status: 'error', result: normalised };
|
||||
// Prompt: continue anyway?
|
||||
const proceed = confirm(
|
||||
`Import completed with ${normalised.errorCount} error(s) out of ${normalised.totalRecords} records.\n\n` +
|
||||
'Do you want to mark this step as done and continue anyway?\n' +
|
||||
'Click OK to continue, or Cancel to stay on this step and review the errors.'
|
||||
);
|
||||
if (proceed) {
|
||||
stepState[step.key].status = 'complete';
|
||||
}
|
||||
} else if (normalised.errorCount > 0) {
|
||||
// Partial errors — still counts as complete but show warning
|
||||
stepState[step.key] = { status: 'complete', result: normalised };
|
||||
showToast(`${step.title} imported with ${normalised.errorCount} errors. Review the details below.`, 'warning');
|
||||
} else {
|
||||
stepState[step.key] = { status: 'complete', result: normalised };
|
||||
showToast(`${step.title} imported successfully!`, 'success');
|
||||
}
|
||||
|
||||
refreshBlocked();
|
||||
saveState();
|
||||
renderWizard();
|
||||
|
||||
} catch (err) {
|
||||
const errResult = { totalRecords: 0, importedCount: 0, updatedCount: 0, skippedCount: 0, errorCount: 1, errors: [err.message], warnings: [] };
|
||||
stepState[step.key] = { status: 'error', result: errResult };
|
||||
refreshBlocked();
|
||||
saveState();
|
||||
renderWizard();
|
||||
showToast('Import failed: ' + err.message, 'danger');
|
||||
} finally {
|
||||
// Re-enable button (in case re-render didn't)
|
||||
const btn2 = document.getElementById(`qbwImportBtn-${step.id}`);
|
||||
if (btn2) btn2.disabled = false;
|
||||
const sp2 = document.getElementById(`qbwImportSpinner-${step.id}`);
|
||||
if (sp2) sp2.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
async function saveState() {
|
||||
try {
|
||||
const payload = JSON.stringify({ source: activeSource, steps: stepState });
|
||||
await fetch('/SetupWizard/SaveQbMigrationState', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: payload })
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toast Helper ──────────────────────────────────────────────────────────
|
||||
function showToast(message, type) {
|
||||
// Use existing site toast infrastructure if available
|
||||
if (typeof window.showNotification === 'function') {
|
||||
window.showNotification(message, type);
|
||||
return;
|
||||
}
|
||||
// Fallback: simple Bootstrap toast
|
||||
const container = document.getElementById('toastContainer') || createToastContainer();
|
||||
const id = 'qbwToast-' + Date.now();
|
||||
const bgClass = type === 'success' ? 'bg-success' : type === 'warning' ? 'bg-warning' : type === 'danger' ? 'bg-danger' : 'bg-primary';
|
||||
const textClass = type === 'warning' ? 'text-dark' : 'text-white';
|
||||
const html = `<div id="${id}" class="toast align-items-center ${bgClass} ${textClass} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${escHtml(message)}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
const el = document.getElementById(id);
|
||||
if (el) new bootstrap.Toast(el, { delay: 5000 }).show();
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'toastContainer';
|
||||
div.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||
div.style.zIndex = '9999';
|
||||
document.body.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* quick-add.js — Generic inline-form quick-add modal.
|
||||
*
|
||||
* Usage: add these attributes to any <select>:
|
||||
* data-quick-add-url="/Controller/Create" — GET loads the inline partial; POST saves
|
||||
* data-quick-add-title="Add New Vendor" — optional modal title override
|
||||
*
|
||||
* Add a sentinel option at the top:
|
||||
* <option value="__new__">+ Add New Vendor…</option>
|
||||
*
|
||||
* When the user picks "__new__", the modal opens, loads the Create partial (GET ?inline=true),
|
||||
* intercepts the form submit (POST ?inline=true → JSON {success, id, name}), adds the new option
|
||||
* to the originating select, selects it, and closes the modal.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const modalEl = document.getElementById('quickAddModal');
|
||||
const modalBody = document.getElementById('quickAddModalBody');
|
||||
const modalErrors = document.getElementById('quickAddModalErrors');
|
||||
const modalErrorList = document.getElementById('quickAddModalErrorList');
|
||||
const modalTitle = document.getElementById('quickAddModalLabel');
|
||||
|
||||
if (!modalEl) return;
|
||||
|
||||
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
|
||||
|
||||
let _originSelect = null; // the <select> that triggered the modal
|
||||
let _submitBtn = null; // the submit button inside the loaded form
|
||||
let _saving = false;
|
||||
|
||||
// ── Wire every matching select ────────────────────────────────────────────
|
||||
|
||||
function wireSelect(sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
if (this.value !== '__new__') return;
|
||||
// Reset to blank so the select isn't stuck on __new__ if user cancels
|
||||
this.value = '';
|
||||
openQuickAdd(this);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('select[data-quick-add-url]').forEach(wireSelect);
|
||||
|
||||
// Support selects injected after DOMContentLoaded (e.g. in modals)
|
||||
window.quickAddWire = wireSelect;
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function openQuickAdd(selectEl) {
|
||||
_originSelect = selectEl;
|
||||
_saving = false;
|
||||
|
||||
const url = selectEl.dataset.quickAddUrl;
|
||||
const title = selectEl.dataset.quickAddTitle || 'Add New';
|
||||
|
||||
modalTitle.textContent = title;
|
||||
modalBody.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-center py-5">
|
||||
<div class="spinner-border text-primary me-3" role="status"></div>
|
||||
<span class="text-muted">Loading\u2026</span>
|
||||
</div>`;
|
||||
modalErrors.classList.add('d-none');
|
||||
modalErrorList.innerHTML = '';
|
||||
|
||||
bsModal.show();
|
||||
|
||||
const inlineUrl = url.includes('?') ? url + '&inline=true' : url + '?inline=true';
|
||||
fetch(inlineUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
modalBody.innerHTML = html;
|
||||
// Execute any <script> tags in the injected HTML — innerHTML assignment doesn't run them
|
||||
modalBody.querySelectorAll('script').forEach(old => {
|
||||
const s = document.createElement('script');
|
||||
Array.from(old.attributes).forEach(a => s.setAttribute(a.name, a.value));
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
// Re-initialise Bootstrap popovers and validation inside the loaded fragment
|
||||
modalBody.querySelectorAll('[data-bs-toggle="popover"]').forEach(el => {
|
||||
new bootstrap.Popover(el, { html: true, trigger: 'focus' });
|
||||
});
|
||||
if (window.jQuery && $.validator) {
|
||||
const form = modalBody.querySelector('form');
|
||||
if (form) $.validator.unobtrusive.parse(form);
|
||||
}
|
||||
// Hide the Back/Cancel navigation inside the partial — not needed in a modal
|
||||
modalBody.querySelectorAll('.d-flex.justify-content-end a.btn-outline-secondary').forEach(a => {
|
||||
if (a.textContent.trim().startsWith('Back') || a.textContent.trim() === 'Cancel') {
|
||||
a.style.display = 'none';
|
||||
}
|
||||
});
|
||||
wireFormSubmit();
|
||||
})
|
||||
.catch(() => {
|
||||
modalBody.innerHTML = '<div class="alert alert-danger alert-permanent m-3">Failed to load form. Please try again.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Intercept the form inside the loaded partial ──────────────────────────
|
||||
|
||||
function wireFormSubmit() {
|
||||
const form = modalBody.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
// Replace the form's action to include ?inline=true
|
||||
const action = form.getAttribute('action') || '';
|
||||
if (!action.includes('inline=true')) {
|
||||
const sep = action.includes('?') ? '&' : '?';
|
||||
form.setAttribute('action', action + sep + 'inline=true');
|
||||
}
|
||||
|
||||
// Add a footer Save button to the modal (keeps modal-footer pattern consistent)
|
||||
_submitBtn = document.createElement('button');
|
||||
_submitBtn.type = 'button';
|
||||
_submitBtn.className = 'btn btn-primary px-4';
|
||||
_submitBtn.innerHTML = '<i class="bi bi-check-circle me-2"></i>Save';
|
||||
_submitBtn.addEventListener('click', () => form.requestSubmit());
|
||||
|
||||
// Also hide the form's own submit button to avoid duplication
|
||||
form.querySelectorAll('[type="submit"]').forEach(b => b.style.display = 'none');
|
||||
|
||||
// Inject footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'modal-footer border-top';
|
||||
footer.innerHTML = '<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>';
|
||||
footer.appendChild(_submitBtn);
|
||||
modalEl.querySelector('.modal-content').appendChild(footer);
|
||||
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
}
|
||||
|
||||
// ── Submit handler ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (_saving) return;
|
||||
|
||||
const form = e.target;
|
||||
|
||||
// jQuery unobtrusive validation
|
||||
if (window.jQuery && $(form).valid && !$(form).valid()) return;
|
||||
|
||||
_saving = true;
|
||||
if (_submitBtn) {
|
||||
_submitBtn.disabled = true;
|
||||
_submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving\u2026';
|
||||
}
|
||||
modalErrors.classList.add('d-none');
|
||||
|
||||
try {
|
||||
const data = new FormData(form);
|
||||
const resp = await fetch(form.action, { method: 'POST', body: data });
|
||||
const json = await resp.json();
|
||||
|
||||
if (json.success) {
|
||||
addOptionAndSelect(json.id, json.name);
|
||||
// Remove the injected footer before hiding so it doesn't stack on re-open
|
||||
const injectedFooter = modalEl.querySelector('.modal-footer.border-top');
|
||||
if (injectedFooter) injectedFooter.remove();
|
||||
bsModal.hide();
|
||||
} else {
|
||||
const msgs = (json.errors || ['An unknown error occurred.']).join('<br>');
|
||||
modalErrorList.innerHTML = msgs;
|
||||
modalErrors.classList.remove('d-none');
|
||||
}
|
||||
} catch {
|
||||
modalErrorList.innerHTML = 'A network error occurred. Please try again.';
|
||||
modalErrors.classList.remove('d-none');
|
||||
} finally {
|
||||
_saving = false;
|
||||
if (_submitBtn) {
|
||||
_submitBtn.disabled = false;
|
||||
_submitBtn.innerHTML = '<i class="bi bi-check-circle me-2"></i>Save';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add the new option to the originating select and select it ────────────
|
||||
|
||||
function addOptionAndSelect(id, name) {
|
||||
if (!_originSelect) return;
|
||||
|
||||
// Remove sentinel option temporarily so we can check for duplicates
|
||||
const existing = _originSelect.querySelector(`option[value="${id}"]`);
|
||||
if (!existing) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = name;
|
||||
|
||||
// Insert in alphabetical order before the sentinel (value="__new__")
|
||||
const sentinel = _originSelect.querySelector('option[value="__new__"]');
|
||||
const options = Array.from(_originSelect.options).filter(o => o.value && o.value !== '__new__');
|
||||
const after = options.find(o => o.textContent.toLowerCase() > name.toLowerCase());
|
||||
if (after) {
|
||||
_originSelect.insertBefore(opt, after);
|
||||
} else if (sentinel) {
|
||||
_originSelect.insertBefore(opt, sentinel);
|
||||
} else {
|
||||
_originSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
_originSelect.value = id;
|
||||
_originSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// ── Clean up injected footer on modal close ───────────────────────────────
|
||||
|
||||
modalEl.addEventListener('hidden.bs.modal', function () {
|
||||
const injectedFooter = modalEl.querySelector('.modal-footer.border-top');
|
||||
if (injectedFooter) injectedFooter.remove();
|
||||
_saving = false;
|
||||
_submitBtn = null;
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
// Suppress the quoting calibration nudge banner for the rest of the browser session
|
||||
// once the user dismisses it, so it doesn't reappear on every page visit.
|
||||
(function () {
|
||||
var nudge = document.getElementById('quotingCalibrationNudge');
|
||||
if (!nudge) return;
|
||||
|
||||
if (sessionStorage.getItem('quotingCalibrationDismissed')) {
|
||||
nudge.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
nudge.addEventListener('close.bs.alert', function () {
|
||||
sessionStorage.setItem('quotingCalibrationDismissed', '1');
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,420 @@
|
||||
// Randomizer Wheel Easter Egg
|
||||
// A fun decision-making tool hidden in the Tools page
|
||||
|
||||
let wheel = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
spinning: false,
|
||||
rotation: 0,
|
||||
targetRotation: 0,
|
||||
spinSpeed: 0,
|
||||
spinStartTime: 0,
|
||||
options: [],
|
||||
colors: [],
|
||||
currentPreset: 'decisions'
|
||||
};
|
||||
|
||||
// Preset configurations
|
||||
const presets = {
|
||||
decisions: {
|
||||
name: 'Decisions',
|
||||
options: ['Yes!', 'No Way', 'Maybe', 'Ask Again', 'Definitely', 'Not Now', 'Go For It!', 'Wait'],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
|
||||
},
|
||||
lunch: {
|
||||
name: 'Lunch Ideas',
|
||||
options: ['Pizza 🍕', 'Burgers 🍔', 'Sushi 🍣', 'Tacos 🌮', 'Salad 🥗', 'Pasta 🍝', 'Chinese 🥡', 'Sandwiches 🥪'],
|
||||
colors: ['#E74C3C', '#F39C12', '#3498DB', '#2ECC71', '#9B59B6', '#1ABC9C', '#E67E22', '#34495E']
|
||||
},
|
||||
tasks: {
|
||||
name: 'Task Priority',
|
||||
options: ['Do It Now!', 'Schedule It', 'Delegate', 'Skip It', 'High Priority', 'Low Priority', 'Break Time!', 'Focus Time'],
|
||||
colors: ['#FF4757', '#FFA502', '#2ED573', '#1E90FF', '#5F27CD', '#00D2D3', '#FF6348', '#C23616']
|
||||
},
|
||||
colors: {
|
||||
name: 'Powder Colors',
|
||||
options: ['Gloss Black', 'Candy Red', 'Chrome Silver', 'Matte White', 'Metallic Blue', 'Neon Green', 'Rose Gold', 'Deep Purple'],
|
||||
colors: ['#000000', '#DC143C', '#C0C0C0', '#F5F5F5', '#4169E1', '#39FF14', '#B76E79', '#663399']
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the wheel when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
wheel.canvas = document.getElementById('wheelCanvas');
|
||||
if (wheel.canvas) {
|
||||
wheel.ctx = wheel.canvas.getContext('2d');
|
||||
setWheelPreset('decisions');
|
||||
|
||||
// Add click event to Tools header
|
||||
const header = document.getElementById('toolsHeader');
|
||||
if (header) {
|
||||
header.addEventListener('click', function() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('randomizerModal'));
|
||||
modal.show();
|
||||
|
||||
// Redraw wheel when modal opens (in case it was resized)
|
||||
setTimeout(() => drawWheel(), 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set wheel to a preset configuration
|
||||
function setWheelPreset(presetName) {
|
||||
const preset = presets[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
wheel.options = [...preset.options];
|
||||
wheel.colors = [...preset.colors];
|
||||
wheel.currentPreset = presetName;
|
||||
|
||||
// Update button states
|
||||
const buttons = document.querySelectorAll('.btn-group .btn');
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.remove('active');
|
||||
// Set active based on preset name
|
||||
const btnText = btn.textContent.toLowerCase();
|
||||
if (btnText.includes(presetName)) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear result
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '';
|
||||
}
|
||||
|
||||
// Redraw wheel
|
||||
drawWheel();
|
||||
}
|
||||
|
||||
// Load shop workers from the server
|
||||
async function loadShopWorkers() {
|
||||
try {
|
||||
// Show loading state
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="visually-hidden">Loading workers...</span></div>';
|
||||
}
|
||||
|
||||
const response = await fetch('/Tools/GetShopWorkers');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.workers && data.workers.length > 0) {
|
||||
wheel.options = data.workers;
|
||||
wheel.colors = generateRainbowColors(data.workers.length);
|
||||
wheel.currentPreset = 'workers';
|
||||
|
||||
// Update button states
|
||||
const buttons = document.querySelectorAll('.btn-group .btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.textContent.includes('Shop Workers')) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear result and redraw
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = `<small class="text-success"><i class="bi bi-check-circle me-1"></i>Loaded ${data.workers.length} active shop workers</small>`;
|
||||
}
|
||||
drawWheel();
|
||||
} else {
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<small class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>No active shop workers found</small>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading shop workers:', error);
|
||||
const resultDiv = document.getElementById('result');
|
||||
if (resultDiv) {
|
||||
resultDiv.innerHTML = '<small class="text-danger"><i class="bi bi-x-circle me-1"></i>Error loading shop workers</small>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set custom options from textarea
|
||||
function setCustomOptions() {
|
||||
const input = document.getElementById('customOptionsInput');
|
||||
const options = input.value
|
||||
.split('\n')
|
||||
.map(opt => opt.trim())
|
||||
.filter(opt => opt.length > 0);
|
||||
|
||||
if (options.length < 2) {
|
||||
showWarning('Please enter at least 2 options!', 'Invalid Input');
|
||||
return;
|
||||
}
|
||||
|
||||
wheel.options = options;
|
||||
|
||||
// Generate rainbow colors for custom options
|
||||
wheel.colors = generateRainbowColors(options.length);
|
||||
|
||||
// Collapse the custom options panel
|
||||
const collapse = bootstrap.Collapse.getInstance(document.getElementById('customOptions'));
|
||||
if (collapse) collapse.hide();
|
||||
|
||||
// Clear result and redraw
|
||||
document.getElementById('result').innerHTML = '';
|
||||
drawWheel();
|
||||
}
|
||||
|
||||
// Generate rainbow colors
|
||||
function generateRainbowColors(count) {
|
||||
const colors = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hue = (i * 360) / count;
|
||||
colors.push(`hsl(${hue}, 70%, 60%)`);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Draw the wheel
|
||||
function drawWheel() {
|
||||
if (!wheel.ctx) return;
|
||||
|
||||
const canvas = wheel.canvas;
|
||||
const ctx = wheel.ctx;
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const radius = Math.min(centerX, centerY) - 10;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Save context
|
||||
ctx.save();
|
||||
|
||||
// Rotate canvas
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(wheel.rotation);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
|
||||
const numOptions = wheel.options.length;
|
||||
const anglePerOption = (2 * Math.PI) / numOptions;
|
||||
|
||||
// Draw each segment
|
||||
for (let i = 0; i < numOptions; i++) {
|
||||
const startAngle = i * anglePerOption - Math.PI / 2;
|
||||
const endAngle = (i + 1) * anglePerOption - Math.PI / 2;
|
||||
|
||||
// Draw segment
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = wheel.colors[i % wheel.colors.length];
|
||||
ctx.fill();
|
||||
|
||||
// Draw border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.save();
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(startAngle + anglePerOption / 2);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px Arial';
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillText(wheel.options[i], radius - 20, 8);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw center circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 30, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#333333';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw center star
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('★', centerX, centerY);
|
||||
|
||||
// Restore context
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Spin the wheel
|
||||
function spinWheel() {
|
||||
if (wheel.spinning) return;
|
||||
|
||||
wheel.spinning = true;
|
||||
wheel.spinStartTime = Date.now();
|
||||
document.getElementById('spinBtn').disabled = true;
|
||||
document.getElementById('result').innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Spinning...</span></div>';
|
||||
|
||||
// Random spin: 5-8 full rotations + random position
|
||||
const fullRotations = 5 + Math.random() * 3;
|
||||
const randomAngle = Math.random() * 2 * Math.PI;
|
||||
wheel.targetRotation = wheel.rotation + (fullRotations * 2 * Math.PI) + randomAngle;
|
||||
wheel.spinSpeed = 0.3;
|
||||
|
||||
animateSpin();
|
||||
}
|
||||
|
||||
// Animate the spin
|
||||
function animateSpin() {
|
||||
if (!wheel.spinning) return;
|
||||
|
||||
// Safety timeout: stop after 10 seconds
|
||||
const elapsed = Date.now() - wheel.spinStartTime;
|
||||
if (elapsed > 10000) {
|
||||
console.warn('Spin animation timed out, forcing stop');
|
||||
wheel.rotation = wheel.targetRotation;
|
||||
finishSpin();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ease out
|
||||
const distance = wheel.targetRotation - wheel.rotation;
|
||||
|
||||
// Stop if we're very close OR if the speed is too slow to make progress
|
||||
if (Math.abs(distance) < 0.01 || wheel.spinSpeed < 0.001) {
|
||||
wheel.rotation = wheel.targetRotation;
|
||||
finishSpin();
|
||||
return;
|
||||
}
|
||||
|
||||
wheel.rotation += distance * wheel.spinSpeed;
|
||||
wheel.spinSpeed *= 0.97; // Deceleration
|
||||
|
||||
drawWheel();
|
||||
requestAnimationFrame(animateSpin);
|
||||
}
|
||||
|
||||
// Finish the spin and show result
|
||||
function finishSpin() {
|
||||
// Normalize rotation to 0-2π for next spin
|
||||
wheel.rotation = wheel.rotation % (2 * Math.PI);
|
||||
if (wheel.rotation < 0) wheel.rotation += 2 * Math.PI;
|
||||
|
||||
wheel.spinning = false;
|
||||
document.getElementById('spinBtn').disabled = false;
|
||||
showResult();
|
||||
}
|
||||
|
||||
// Show the result
|
||||
function showResult() {
|
||||
const numOptions = wheel.options.length;
|
||||
const anglePerOption = (2 * Math.PI) / numOptions;
|
||||
|
||||
// Normalize rotation to 0-2π
|
||||
let normalizedRotation = wheel.rotation % (2 * Math.PI);
|
||||
if (normalizedRotation < 0) normalizedRotation += 2 * Math.PI;
|
||||
|
||||
// Calculate which segment is at the pointer (top of wheel)
|
||||
// Canvas rotation is counterclockwise, so we need to reverse the calculation
|
||||
const rawIndex = (2 * Math.PI - normalizedRotation) / anglePerOption;
|
||||
const selectedIndex = Math.floor(rawIndex) % numOptions;
|
||||
|
||||
const winner = wheel.options[selectedIndex];
|
||||
const winnerColor = wheel.colors[selectedIndex % wheel.colors.length];
|
||||
|
||||
// Display result with animation
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.innerHTML = `
|
||||
<div class="alert alert-success border-3" style="border-color: ${winnerColor} !important; animation: fadeInScale 0.5s ease;">
|
||||
<h4 class="mb-2">
|
||||
<i class="bi bi-trophy-fill text-warning me-2"></i>Winner!
|
||||
</h4>
|
||||
<h3 class="mb-0" style="color: ${winnerColor}; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
|
||||
${winner}
|
||||
</h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add confetti effect
|
||||
createConfetti();
|
||||
}
|
||||
|
||||
// Create confetti effect
|
||||
function createConfetti() {
|
||||
const duration = 2000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F'];
|
||||
|
||||
(function frame() {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) return;
|
||||
|
||||
const particleCount = 2;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.style.position = 'fixed';
|
||||
particle.style.width = '10px';
|
||||
particle.style.height = '10px';
|
||||
particle.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
particle.style.borderRadius = '50%';
|
||||
particle.style.pointerEvents = 'none';
|
||||
particle.style.zIndex = '9999';
|
||||
|
||||
const startX = Math.random() * window.innerWidth;
|
||||
const startY = -20;
|
||||
|
||||
particle.style.left = startX + 'px';
|
||||
particle.style.top = startY + 'px';
|
||||
|
||||
document.body.appendChild(particle);
|
||||
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const velocity = 2 + Math.random() * 2;
|
||||
const vx = Math.cos(angle) * velocity;
|
||||
const vy = Math.sin(angle) * velocity + 3;
|
||||
|
||||
let x = startX;
|
||||
let y = startY;
|
||||
let opacity = 1;
|
||||
|
||||
const animate = () => {
|
||||
y += vy;
|
||||
x += vx;
|
||||
opacity -= 0.02;
|
||||
|
||||
particle.style.top = y + 'px';
|
||||
particle.style.left = x + 'px';
|
||||
particle.style.opacity = opacity;
|
||||
|
||||
if (opacity > 0 && y < window.innerHeight) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
particle.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
})();
|
||||
}
|
||||
|
||||
// Add CSS animation for result
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -0,0 +1,64 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
function getCsrfToken() {
|
||||
var el = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
var btn = target.closest ? target.closest('.btn-toggle-panel') : null;
|
||||
if (!btn) return;
|
||||
|
||||
e.stopPropagation();
|
||||
var itemId = btn.getAttribute('data-item-id');
|
||||
var hasPanel = btn.getAttribute('data-has-panel');
|
||||
var row = btn.closest ? btn.closest('tr') : null;
|
||||
var csrf = getCsrfToken();
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
|
||||
fetch('/Inventory/ToggleSamplePanel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'RequestVerificationToken': csrf
|
||||
},
|
||||
body: 'id=' + itemId + '&hasPanel=' + hasPanel + '&__RequestVerificationToken=' + encodeURIComponent(csrf)
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.success) {
|
||||
if (row) {
|
||||
row.style.transition = 'opacity .25s';
|
||||
row.style.opacity = '0';
|
||||
setTimeout(function () { location.reload(); }, 300);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
alert(data.message || 'An error occurred.');
|
||||
}
|
||||
})
|
||||
.catch(function () { btn.disabled = false; });
|
||||
});
|
||||
|
||||
var printBtn = document.getElementById('btnPrintList');
|
||||
if (printBtn) {
|
||||
printBtn.addEventListener('click', function () {
|
||||
var printArea = document.getElementById('printArea');
|
||||
var content = printArea ? printArea.innerHTML : '';
|
||||
var win = window.open('', '_blank', 'width=900,height=700');
|
||||
win.document.open();
|
||||
win.document.write('<!DOCTYPE html><html><head><title>Need to Order</title>');
|
||||
win.document.write('<style>body{font-family:sans-serif;padding:20px}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccc;padding:6px 10px;text-align:left}th{background:#f0f0f0}</style>');
|
||||
win.document.write('</head><body>');
|
||||
win.document.write(content);
|
||||
win.document.write('</body></html>');
|
||||
win.document.close();
|
||||
win.focus();
|
||||
setTimeout(function () { win.print(); }, 500);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
||||
|
||||
/**
|
||||
* Change page size for pagination
|
||||
* @param {number} newSize - The new page size
|
||||
*/
|
||||
function changePageSize(newSize) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('pageSize', newSize);
|
||||
url.searchParams.set('pageNumber', '1'); // Reset to page 1 when changing page size
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Tag chip input widget
|
||||
// Usage: call initTagInput('hiddenFieldId', 'containerId') after DOM ready
|
||||
// The hidden field stores comma-separated tags.
|
||||
// The container receives: a normal form-control input + a chips area below it.
|
||||
// Optional: pass { suggestions: ['tag1', 'tag2'] } as third argument to show clickable suggestion chips.
|
||||
// Returns: { addTag, clear } for external control.
|
||||
|
||||
function initTagInput(hiddenId, containerId, options) {
|
||||
const hidden = document.getElementById(hiddenId);
|
||||
const container = document.getElementById(containerId);
|
||||
if (!hidden || !container) return { addTag: function() {}, clear: function() {} };
|
||||
|
||||
// Clear any previous render
|
||||
container.innerHTML = '';
|
||||
|
||||
// Normal fixed-height text input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'form-control';
|
||||
input.placeholder = 'Add tag\u2026';
|
||||
input.autocomplete = 'off';
|
||||
|
||||
// Chips live below the input — does not affect input height
|
||||
const chipsArea = document.createElement('div');
|
||||
chipsArea.className = 'tag-chips-area';
|
||||
|
||||
container.appendChild(input);
|
||||
container.appendChild(chipsArea);
|
||||
|
||||
let tags = hidden.value
|
||||
? hidden.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
chipsArea.innerHTML = '';
|
||||
tags.forEach((tag, i) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge rounded-pill bg-info text-dark tag-chip';
|
||||
chip.innerHTML =
|
||||
`${escapeHtml(tag)}` +
|
||||
`<span class="tag-remove ms-1" data-index="${i}" aria-label="Remove" role="button">×</span>`;
|
||||
chipsArea.appendChild(chip);
|
||||
});
|
||||
hidden.value = tags.join(',');
|
||||
}
|
||||
|
||||
function addTag(val) {
|
||||
const trimmed = val.trim().replace(/,/g, '').slice(0, 50);
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags.push(trimmed);
|
||||
renderTags();
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag(input.value);
|
||||
input.value = '';
|
||||
} else if (e.key === 'Backspace' && input.value === '' && tags.length > 0) {
|
||||
tags.pop();
|
||||
renderTags();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value.trim()) {
|
||||
addTag(input.value);
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
chipsArea.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('tag-remove')) {
|
||||
const idx = parseInt(e.target.dataset.index, 10);
|
||||
tags.splice(idx, 1);
|
||||
renderTags();
|
||||
}
|
||||
});
|
||||
|
||||
// Render suggestion chips if provided
|
||||
if (options && options.suggestions && options.suggestions.length > 0) {
|
||||
const suggestionsRow = document.createElement('div');
|
||||
suggestionsRow.className = 'd-flex flex-wrap align-items-center gap-1 mt-2';
|
||||
suggestionsRow.innerHTML = '<small class="text-muted">Suggested:</small>';
|
||||
options.suggestions.forEach(s => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-secondary btn-sm py-0 px-2';
|
||||
btn.style.fontSize = '0.78rem';
|
||||
btn.textContent = s;
|
||||
btn.addEventListener('click', () => addTag(s));
|
||||
suggestionsRow.appendChild(btn);
|
||||
});
|
||||
container.appendChild(suggestionsRow);
|
||||
}
|
||||
|
||||
renderTags();
|
||||
|
||||
return {
|
||||
addTag: addTag,
|
||||
clear: function() {
|
||||
tags = [];
|
||||
input.value = '';
|
||||
renderTags();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function syncUI(surface) {
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
|
||||
btn.setAttribute('aria-pressed', surface === 'ink' ? 'true' : 'false');
|
||||
var icon = btn.querySelector('i');
|
||||
if (icon) icon.className = surface === 'ink' ? 'bi bi-sun' : 'bi bi-moon';
|
||||
});
|
||||
}
|
||||
|
||||
function persistServerSide(surface) {
|
||||
var body = new URLSearchParams({ surface: surface });
|
||||
fetch('/Theme/Set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString()
|
||||
})
|
||||
.then(function (r) {
|
||||
console.log('[theme] POST /Theme/Set → ' + r.status + ' (surface=' + surface + ')');
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('[theme] POST /Theme/Set failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
function apply(surface) {
|
||||
surface = (surface === 'ink') ? 'ink' : 'paper';
|
||||
console.log('[theme] apply(' + surface + ')');
|
||||
document.documentElement.setAttribute('data-surface', surface);
|
||||
document.documentElement.setAttribute('data-bs-theme', surface === 'ink' ? 'dark' : 'light');
|
||||
syncUI(surface);
|
||||
persistServerSide(surface);
|
||||
}
|
||||
|
||||
// Expose globally so Profile page radio and other pages can call it
|
||||
window.pclApplyTheme = apply;
|
||||
|
||||
function bindToggles() {
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
|
||||
// Remove any previously-bound handler to avoid doubles on re-bind
|
||||
btn.removeEventListener('click', btn._pclToggleHandler);
|
||||
btn._pclToggleHandler = function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var current = document.documentElement.getAttribute('data-surface') || 'paper';
|
||||
console.log('[theme] toggle clicked, current=' + current);
|
||||
apply(current === 'ink' ? 'paper' : 'ink');
|
||||
};
|
||||
btn.addEventListener('click', btn._pclToggleHandler);
|
||||
});
|
||||
}
|
||||
|
||||
// Follow OS preference only when no cookie is set yet
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
||||
var hasCookie = document.cookie.indexOf('pcl_surface=') !== -1;
|
||||
if (!hasCookie) apply(e.matches ? 'ink' : 'paper');
|
||||
});
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
var surface = document.documentElement.getAttribute('data-surface') || 'paper';
|
||||
console.log('[theme] onReady, data-surface=' + surface);
|
||||
syncUI(surface);
|
||||
bindToggles();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', onReady);
|
||||
} else {
|
||||
onReady();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Toast Notification System
|
||||
* Provides consistent, user-friendly notifications across the application
|
||||
* Uses Toastr library with Bootstrap 5 theming
|
||||
*/
|
||||
|
||||
// Configure Toastr global options
|
||||
toastr.options = {
|
||||
"closeButton": true,
|
||||
"debug": false,
|
||||
"newestOnTop": true,
|
||||
"progressBar": true,
|
||||
"positionClass": "toast-top-right",
|
||||
"preventDuplicates": true,
|
||||
"onclick": null,
|
||||
"showDuration": "300",
|
||||
"hideDuration": "1000",
|
||||
"timeOut": "5000",
|
||||
"extendedTimeOut": "1000",
|
||||
"showEasing": "swing",
|
||||
"hideEasing": "linear",
|
||||
"showMethod": "fadeIn",
|
||||
"hideMethod": "fadeOut"
|
||||
};
|
||||
|
||||
/**
|
||||
* Show success toast notification
|
||||
* @param {string} message - The success message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showSuccess(message, title = 'Success') {
|
||||
toastr.success(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast notification
|
||||
* @param {string} message - The error message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showError(message, title = 'Error') {
|
||||
toastr.error(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast notification
|
||||
* @param {string} message - The warning message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showWarning(message, title = 'Warning') {
|
||||
toastr.warning(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast notification
|
||||
* @param {string} message - The info message to display
|
||||
* @param {string} title - Optional title for the toast
|
||||
*/
|
||||
function showInfo(message, title = 'Info') {
|
||||
toastr.info(message, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show validation errors from ModelState
|
||||
* @param {Array<string>} errors - Array of validation error messages
|
||||
*/
|
||||
function showValidationErrors(errors) {
|
||||
if (!errors || errors.length === 0) return;
|
||||
|
||||
// If single error, show it directly
|
||||
if (errors.length === 1) {
|
||||
showError(errors[0], 'Validation Error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple errors - show as a list
|
||||
const errorList = '<ul class="mb-0 ps-3">' +
|
||||
errors.map(err => `<li>${escapeHtml(err)}</li>`).join('') +
|
||||
'</ul>';
|
||||
|
||||
toastr.error(errorList, `${errors.length} Validation Errors`, {
|
||||
timeOut: 8000,
|
||||
extendedTimeOut: 2000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display TempData messages automatically on page load
|
||||
* Expects TempData keys: Success, Error, Warning, Info
|
||||
*/
|
||||
function displayTempDataMessages() {
|
||||
// Success message
|
||||
const successMsg = document.getElementById('tempdata-success-message');
|
||||
if (successMsg && successMsg.textContent.trim()) {
|
||||
showSuccess(successMsg.textContent.trim());
|
||||
}
|
||||
|
||||
// Error message
|
||||
const errorMsg = document.getElementById('tempdata-error-message');
|
||||
if (errorMsg && errorMsg.textContent.trim()) {
|
||||
showError(errorMsg.textContent.trim());
|
||||
}
|
||||
|
||||
// Permanent success — no auto-dismiss
|
||||
const successPerm = document.getElementById('tempdata-success-permanent-message');
|
||||
if (successPerm && successPerm.textContent.trim()) {
|
||||
toastr.success(successPerm.textContent.trim(), 'Success', { timeOut: 0, extendedTimeOut: 0 });
|
||||
}
|
||||
|
||||
// Permanent error — no auto-dismiss
|
||||
const errorPerm = document.getElementById('tempdata-error-permanent-message');
|
||||
if (errorPerm && errorPerm.textContent.trim()) {
|
||||
toastr.error(errorPerm.textContent.trim(), 'Error', { timeOut: 0, extendedTimeOut: 0 });
|
||||
}
|
||||
|
||||
// Warning message
|
||||
const warningMsg = document.getElementById('tempdata-warning-message');
|
||||
if (warningMsg && warningMsg.textContent.trim()) {
|
||||
showWarning(warningMsg.textContent.trim());
|
||||
}
|
||||
|
||||
// Info message
|
||||
const infoMsg = document.getElementById('tempdata-info-message');
|
||||
if (infoMsg && infoMsg.textContent.trim()) {
|
||||
showInfo(infoMsg.textContent.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display ModelState validation errors on page load
|
||||
* Expects a hidden div with id 'modelstate-errors' containing JSON array of errors
|
||||
*/
|
||||
function displayModelStateErrors() {
|
||||
const errorContainer = document.getElementById('modelstate-errors');
|
||||
if (errorContainer && errorContainer.textContent.trim()) {
|
||||
try {
|
||||
const errors = JSON.parse(errorContainer.textContent);
|
||||
showValidationErrors(errors);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse ModelState errors:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} - Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all toasts
|
||||
*/
|
||||
function clearAllToasts() {
|
||||
toastr.clear();
|
||||
}
|
||||
|
||||
// Auto-initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
displayTempDataMessages();
|
||||
displayModelStateErrors();
|
||||
});
|
||||
|
||||
// Make functions globally available
|
||||
window.showSuccess = showSuccess;
|
||||
window.showError = showError;
|
||||
window.showWarning = showWarning;
|
||||
window.showInfo = showInfo;
|
||||
window.showValidationErrors = showValidationErrors;
|
||||
window.clearAllToasts = clearAllToasts;
|
||||
@@ -0,0 +1,885 @@
|
||||
// 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-invoices',
|
||||
label: 'Invoices', icon: 'bi-receipt', color: '#0891b2',
|
||||
desc: 'Invoice headers, amounts, and payment status',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportInvoices', accept: '.csv',
|
||||
template: '/Tools/DownloadInvoiceTemplate',
|
||||
tips: ['Download the CSV template to see the expected columns',
|
||||
'Customers must exist before importing — matched by CustomerEmail then Customer name',
|
||||
'Existing invoices matched by InvoiceNumber are updated; new ones are created',
|
||||
'Line items are not part of the CSV — this imports invoice headers and totals only'] },
|
||||
|
||||
{ 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-payments',
|
||||
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
|
||||
desc: 'Invoice payment records with method and reference',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportPayments', accept: '.csv',
|
||||
template: '/Tools/DownloadPaymentTemplate',
|
||||
tips: ['Download the CSV template to see the expected columns',
|
||||
'Invoices must exist before importing — matched by InvoiceNumber',
|
||||
'Duplicate payments (same invoice + date + amount) are automatically skipped',
|
||||
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment',
|
||||
'Invoice AmountPaid and status are updated automatically after each payment'] },
|
||||
|
||||
{ key: 'csv-purchaseorders',
|
||||
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
|
||||
desc: 'Purchase order headers with vendor, status, and totals',
|
||||
dir: ['import'], fmt: ['csv'],
|
||||
endpoint: '/Tools/CsvImportPurchaseOrders', accept: '.csv',
|
||||
template: '/Tools/DownloadPurchaseOrderTemplate',
|
||||
tips: ['Download the CSV template to see the expected columns',
|
||||
'Vendors must exist before importing — matched by company name',
|
||||
'Existing POs matched by PoNumber are updated; new ones are created',
|
||||
'Valid Status values: Draft, Submitted, PartiallyReceived, Received, Cancelled',
|
||||
'Line items are not part of the CSV — this imports PO headers and totals only'] },
|
||||
|
||||
{ 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-invoices',
|
||||
label: 'Invoices', icon: 'bi-receipt', color: '#0891b2',
|
||||
desc: 'Invoice headers, amounts, status, and customer info',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoicesCsv' },
|
||||
|
||||
{ key: 'exp-payments',
|
||||
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
|
||||
desc: 'Invoice payment records with method and reference',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPaymentsCsv' },
|
||||
|
||||
{ key: 'exp-appointments',
|
||||
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
|
||||
desc: 'Customer, type, status, and scheduling details',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportAppointmentsCsv' },
|
||||
|
||||
{ key: 'exp-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#6b7280',
|
||||
desc: 'SKU, pricing, categories, and descriptions',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCatalogCsv' },
|
||||
|
||||
{ key: 'exp-inventory',
|
||||
label: 'Inventory', icon: 'bi-boxes', color: '#1f2937',
|
||||
desc: 'Quantities, costs, and location details',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInventoryCsv' },
|
||||
|
||||
{ key: 'exp-equipment',
|
||||
label: 'Equipment', icon: 'bi-tools', color: '#dc2626',
|
||||
desc: 'Details, purchase info, and current status',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportEquipmentCsv' },
|
||||
|
||||
{ key: 'exp-maintenance',
|
||||
label: 'Maintenance Records', icon: 'bi-wrench', color: '#6b7280',
|
||||
desc: 'Scheduled and completed maintenance with equipment and costs',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportMaintenanceCsv' },
|
||||
|
||||
|
||||
{ key: 'exp-prepservices',
|
||||
label: 'Prep Services', icon: 'bi-tools', color: '#7c3aed',
|
||||
desc: 'Preparation service catalog (sandblasting, stripping, etc.)',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPrepServicesCsv' },
|
||||
|
||||
{ key: 'exp-purchaseorders',
|
||||
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
|
||||
desc: 'Vendor, status, dates, and totals',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPurchaseOrdersCsv' },
|
||||
|
||||
{ key: 'exp-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'Full GL account list with types, balances, and account numbers',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportChartOfAccountsCsv' },
|
||||
|
||||
{ key: 'exp-expenses',
|
||||
label: 'Expenses', icon: 'bi-receipt-cutoff', color: '#dc2626',
|
||||
desc: 'Direct expenses with dates, accounts, vendors, and amounts',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportExpensesCsv' },
|
||||
|
||||
{ key: 'exp-settings',
|
||||
label: 'Company Settings', icon: 'bi-gear-fill', color: '#d97706',
|
||||
desc: 'Company info, operating costs, and preferences',
|
||||
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCompanySettingsCsv' },
|
||||
|
||||
// ── QB Desktop Import ───────────────────────────────────────────────
|
||||
{ key: 'qbd-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'Account list — import this first (required for bills)',
|
||||
badge: 'Import first',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportChartOfAccounts', accept: '.iif,.txt',
|
||||
tips: ['Lists → Chart of Accounts → right-click → Export → save as .iif', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Customer list from QB Desktop',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportCustomers', accept: '.iif,.txt',
|
||||
tips: ['File → Utilities → Export → Lists to IIF Files → select Customers', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Vendor list from QB Desktop',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportVendors', accept: '.iif,.txt',
|
||||
tips: ['File → Utilities → Export → Lists to IIF Files → select Vendors', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Service items from QB Desktop',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportCatalogItems', accept: '.iif,.txt',
|
||||
tips: ['File → Utilities → Export → Lists to IIF Files → select Items', 'Upload the .iif file below'] },
|
||||
|
||||
{ key: 'qbd-inventory',
|
||||
label: 'Inventory Stock', icon: 'bi-boxes', color: '#0891b2',
|
||||
desc: 'Inventory Valuation Summary — include Pref Vendor column',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbInventoryValuation', accept: '.csv',
|
||||
tips: [
|
||||
'Reports → Inventory → Inventory Valuation Summary',
|
||||
'Customize Report → Display tab → check <strong>Pref Vendor</strong>',
|
||||
'Excel → Create New Worksheet → Comma Separated Values (.csv)',
|
||||
'Upload the saved .csv file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbd-invoices',
|
||||
label: 'Invoices', icon: 'bi-receipt', color: '#2563eb',
|
||||
desc: 'Customer Balance Detail report',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbInvoices', accept: '.csv',
|
||||
tips: ['Reports → Customers & Receivables → Customer Balance Detail', 'Set date range to <strong>All</strong>', 'Excel → Create New Worksheet → Comma Separated Values (.csv)', 'Upload the saved .csv file below'] },
|
||||
|
||||
{ key: 'qbd-transactions',
|
||||
label: 'Transactions', icon: 'bi-arrow-left-right', color: '#059669',
|
||||
desc: 'Transaction List by Customer report',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbTransactions', accept: '.csv',
|
||||
tips: ['Reports → Customers & Receivables → Transaction List by Customer', 'Set date range to <strong>All</strong>', 'Excel → Create New Worksheet → Comma Separated Values (.csv)', 'Upload the saved .csv file below'] },
|
||||
|
||||
{ key: 'qbd-bills',
|
||||
label: 'Bills & Payments', icon: 'bi-file-earmark-minus', color: '#dc2626',
|
||||
desc: 'Vendor Balance Detail report — imports bills and payments in one pass',
|
||||
dir: ['import'], fmt: ['qb-desktop'],
|
||||
endpoint: '/Tools/ImportQbBillsAndPayments', accept: '.csv',
|
||||
tips: [
|
||||
'Reports → Vendors & Payables → Vendor Balance Detail',
|
||||
'Set date range to <strong>All</strong>',
|
||||
'Click <strong>Customize Report</strong> → <strong>Display</strong> tab → check <strong>Memo</strong> to include bill and payment descriptions',
|
||||
'Excel → Create New Worksheet → Comma Separated Values (.csv)',
|
||||
'Upload the file — bills are imported first, then payments are matched against them automatically'
|
||||
] },
|
||||
|
||||
// ── QB Online Import ────────────────────────────────────────────────
|
||||
{ key: 'qbo-coa',
|
||||
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
|
||||
desc: 'Account List — import this first (required for invoices)',
|
||||
badge: 'Import first',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboChartOfAccounts', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Accounting (left nav) → Chart of Accounts → Download/Export button',
|
||||
'<strong>Or:</strong> Reports → search "Account List" → Export to Excel',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Customer Contact List from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboCustomers', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Customer Contact List" → Customize → add all desired columns → Export to Excel',
|
||||
'<strong>Tip:</strong> Add Billing Street, City, State, Zip columns via Customize → Rows/Columns for best results',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Vendor Contact List from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboVendors', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Vendor Contact List" → Customize → add all desired columns → Export to Excel',
|
||||
'<strong>Or:</strong> Expenses → Vendors → Export icon (box with arrow) → Export to Excel',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-products',
|
||||
label: 'Products & Services', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Product/service catalog from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboCatalogItems', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Sales → Products and Services → Export to Excel icon (top right)',
|
||||
'Inventory-type items are imported as Inventory; all others as Catalog Items',
|
||||
'Items named <em>Category:Item Name</em> will have the category prefix stripped automatically',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-invoices',
|
||||
label: 'Invoices', icon: 'bi-receipt', color: '#2563eb',
|
||||
desc: 'Invoice List report from QuickBooks Online',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboInvoices', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Invoice List" → set your date range → Export to Excel',
|
||||
'Customers must be imported first so invoices can be linked',
|
||||
'Invoice totals and open balances are imported; line item detail is not available in this report',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
{ key: 'qbo-transactions',
|
||||
label: 'Transactions', icon: 'bi-arrow-left-right', color: '#059669',
|
||||
desc: 'Transaction List — applies payments to imported invoices',
|
||||
dir: ['import'], fmt: ['qb-online'],
|
||||
endpoint: '/Tools/ImportQboTransactions', accept: '.xlsx,.xls',
|
||||
tips: [
|
||||
'Reports → search "Transaction List by Date" → set All Dates → Export to Excel',
|
||||
'Only Payment/Receipt rows are processed; Invoice rows are skipped',
|
||||
'Invoices must be imported first so payments can be matched by reference number',
|
||||
'Upload the .xlsx file below'
|
||||
] },
|
||||
|
||||
// ── QB Export ───────────────────────────────────────────────────────
|
||||
{ key: 'qb-exp-customers',
|
||||
label: 'Customers', icon: 'bi-people', color: '#2563eb',
|
||||
desc: 'Export all active customers',
|
||||
dir: ['export'], fmt: ['qb-desktop', 'qb-online'],
|
||||
exportUrlDesktop: '/Tools/ExportCustomers?format=desktop',
|
||||
exportUrlOnline: '/Tools/ExportCustomers?format=online' },
|
||||
|
||||
{ key: 'qb-exp-vendors',
|
||||
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
|
||||
desc: 'Export all active vendors to IIF format',
|
||||
dir: ['export'], fmt: ['qb-desktop'],
|
||||
exportUrl: '/Tools/ExportVendors' },
|
||||
|
||||
{ key: 'qb-exp-catalog',
|
||||
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
|
||||
desc: 'Export active catalog items as service items',
|
||||
dir: ['export'], fmt: ['qb-desktop', 'qb-online'],
|
||||
exportUrlDesktop: '/Tools/ExportCatalogItems?format=desktop',
|
||||
exportUrlOnline: '/Tools/ExportCatalogItems?format=online' },
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function getExportUrl(item) {
|
||||
if (item.exportUrl) return item.exportUrl;
|
||||
if (wFmt === 'qb-online' && item.exportUrlOnline) return item.exportUrlOnline;
|
||||
if (item.exportUrlDesktop) return item.exportUrlDesktop;
|
||||
return '#';
|
||||
}
|
||||
|
||||
function filteredItems() {
|
||||
return ITEMS.filter(it => it.dir.includes(wDir) && it.fmt.includes(wFmt));
|
||||
}
|
||||
|
||||
// ── Wizard Navigation ─────────────────────────────────────────────────────
|
||||
window.wizardSetDirection = function (dir) {
|
||||
wDir = dir;
|
||||
wStep = 2;
|
||||
setBreadcrumb(2, dir === 'import' ? 'Import' : 'Export');
|
||||
document.getElementById('step2-heading').textContent =
|
||||
dir === 'import' ? 'Import from which format?' : 'Export to which format?';
|
||||
showStep(2);
|
||||
document.getElementById('wizardBackBtn').classList.remove('d-none');
|
||||
};
|
||||
|
||||
window.wizardSetFormat = function (fmt) {
|
||||
wFmt = fmt;
|
||||
wStep = 3;
|
||||
const labels = { csv: 'CSV', 'qb-desktop': 'QB Desktop', 'qb-online': 'QB Online' };
|
||||
setBreadcrumb(3, labels[fmt] || fmt);
|
||||
renderStep3();
|
||||
showStep(3);
|
||||
};
|
||||
|
||||
window.wizardBack = function () {
|
||||
if (wStep === 4) {
|
||||
wItem = null;
|
||||
wStep = 3;
|
||||
resetBreadcrumb(4, wDir === 'import' ? 'Import' : 'Export');
|
||||
renderStep3();
|
||||
showStep(3);
|
||||
} else if (wStep === 3) {
|
||||
wFmt = null;
|
||||
wStep = 2;
|
||||
resetBreadcrumb(3, 'Select');
|
||||
showStep(2);
|
||||
} else if (wStep === 2) {
|
||||
wDir = null;
|
||||
wStep = 1;
|
||||
resetBreadcrumb(2, 'Format');
|
||||
resetBreadcrumb(1, 'Direction');
|
||||
showStep(1);
|
||||
document.getElementById('wizardBackBtn').classList.add('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
window.wizardSelectItem = function (key) {
|
||||
wItem = ITEMS.find(it => it.key === key);
|
||||
if (!wItem) return;
|
||||
wStep = 4;
|
||||
setBreadcrumb(4, wItem.label);
|
||||
renderStep4();
|
||||
showStep(4);
|
||||
};
|
||||
|
||||
function showStep(n) {
|
||||
[1, 2, 3, 4].forEach(function (s) {
|
||||
const el = document.getElementById('wizard-step-' + s);
|
||||
if (el) el.classList.toggle('d-none', s !== n);
|
||||
});
|
||||
updateBreadcrumbActive(n);
|
||||
}
|
||||
|
||||
function setBreadcrumb(step, label) {
|
||||
const lbl = document.getElementById('bc-' + step + '-label');
|
||||
const bdg = document.getElementById('bc-' + step + '-badge');
|
||||
if (lbl) { lbl.textContent = label; lbl.className = 'small fw-semibold'; }
|
||||
if (bdg) bdg.className = 'badge rounded-pill bg-primary';
|
||||
}
|
||||
|
||||
function resetBreadcrumb(step, label) {
|
||||
const lbl = document.getElementById('bc-' + step + '-label');
|
||||
const bdg = document.getElementById('bc-' + step + '-badge');
|
||||
if (lbl) { lbl.textContent = label; lbl.className = 'small text-muted'; }
|
||||
if (bdg) bdg.className = 'badge rounded-pill bg-secondary';
|
||||
}
|
||||
|
||||
function updateBreadcrumbActive(currentStep) {
|
||||
for (let s = 1; s <= 4; s++) {
|
||||
const badge = document.getElementById('bc-' + s + '-badge');
|
||||
if (badge && s < currentStep && badge.className.includes('primary')) {
|
||||
badge.className = 'badge rounded-pill bg-success';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: item selection grid ───────────────────────────────────────────
|
||||
function renderStep3() {
|
||||
const heading = document.getElementById('step3-heading');
|
||||
const grid = document.getElementById('step3-grid');
|
||||
if (!heading || !grid) return;
|
||||
|
||||
heading.textContent = wDir === 'import' ? 'What would you like to import?' : 'What would you like to export?';
|
||||
|
||||
let html = '';
|
||||
|
||||
// CSV Export: "Export All" shortcut
|
||||
if (wDir === 'export' && wFmt === 'csv') {
|
||||
html += `<div class="col-12 text-center mb-1">
|
||||
<a href="/Tools/ExportAllCsv" class="btn btn-primary px-4">
|
||||
<i class="bi bi-file-earmark-zip me-1"></i>Export All as ZIP
|
||||
</a>
|
||||
<p class="text-muted small mt-2 mb-0">Or pick a specific data set below.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// QB Desktop Import: recommended order callout
|
||||
if (wDir === 'import' && wFmt === 'qb-desktop') {
|
||||
html += `<div class="col-12 mb-1">
|
||||
<div class="alert alert-info py-2 mb-0 small">
|
||||
<i class="bi bi-list-ol me-1"></i>
|
||||
<strong>Recommended order:</strong>
|
||||
Chart of Accounts → Customers → Vendors → Catalog Items → Inventory → Invoices → Transactions → Bills & Payments
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
filteredItems().forEach(function (item) {
|
||||
const badgeHtml = item.badge
|
||||
? ` <span class="badge ms-1" style="background:#374151;font-size:0.68rem;vertical-align:middle">${item.badge}</span>`
|
||||
: '';
|
||||
html += `
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card h-100 border-2" role="button"
|
||||
onclick="wizardSelectItem('${item.key}')"
|
||||
style="cursor:pointer;border-color:${item.color}!important;transition:transform .1s"
|
||||
onmouseover="this.style.transform='scale(1.02)'"
|
||||
onmouseout="this.style.transform=''">
|
||||
<div class="card-body py-3 px-3">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div style="width:42px;height:42px;border-radius:10px;background:${item.color}18;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<i class="bi ${item.icon}" style="font-size:1.3rem;color:${item.color}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold" style="font-size:0.93rem">${item.label}${badgeHtml}</div>
|
||||
<div class="text-muted" style="font-size:0.8rem">${item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
if (!filteredItems().length) {
|
||||
html += `<div class="col-12 text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size:2.5rem"></i>
|
||||
<p class="mt-2 mb-0">No options available for this selection.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Step 4: upload form or download button ────────────────────────────────
|
||||
async function renderStep4() {
|
||||
const container = document.getElementById('step4-content');
|
||||
if (!container || !wItem) return;
|
||||
|
||||
const item = wItem;
|
||||
const isImport = wDir === 'import';
|
||||
|
||||
// Refresh account data from server if this card has account dropdowns,
|
||||
// so accounts imported earlier in the same page session are available.
|
||||
if (item.extraFields && item.extraFields.length) {
|
||||
try {
|
||||
const resp = await fetch('/Tools/GetImportAccounts');
|
||||
if (resp.ok) accountData = await resp.json();
|
||||
} catch (e) { /* keep whatever was loaded at page render */ }
|
||||
}
|
||||
|
||||
// Tips list
|
||||
const tipsHtml = (item.tips || []).length
|
||||
? `<ol class="small text-muted ps-3 mb-0">${item.tips.map(t => `<li>${t}</li>`).join('')}</ol>`
|
||||
: '';
|
||||
|
||||
// Warning
|
||||
const warningHtml = item.warning
|
||||
? `<div class="alert alert-warning py-2 mb-3 small"><i class="bi bi-exclamation-triangle-fill me-1"></i>${item.warning}</div>`
|
||||
: '';
|
||||
|
||||
// Template download button
|
||||
const templateHtml = item.template
|
||||
? `<div class="mb-4">
|
||||
<a href="${item.template}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-arrow-down me-1"></i>Download CSV Template
|
||||
</a>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Extra account dropdowns
|
||||
let extraFieldsHtml = '';
|
||||
(item.extraFields || []).forEach(function (f) {
|
||||
const opts = (accountData[f.accountKey] || [])
|
||||
.map(o => `<option value="${o.value}">${o.text}</option>`)
|
||||
.join('');
|
||||
extraFieldsHtml += `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold mb-1">${f.label}
|
||||
<span class="text-muted fw-normal">${f.hint ? '— ' + f.hint : ''}</span>
|
||||
</label>
|
||||
<select name="${f.name}" class="form-select form-select-sm">
|
||||
<option value="">(none)</option>
|
||||
${opts}
|
||||
</select>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
let actionHtml;
|
||||
|
||||
if (isImport) {
|
||||
actionHtml = `
|
||||
${warningHtml}
|
||||
${templateHtml}
|
||||
<form id="step4-form" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold mb-1">
|
||||
Select file <span class="text-muted fw-normal">${item.accept}</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" id="step4-file" name="file" accept="${item.accept}" required />
|
||||
</div>
|
||||
${extraFieldsHtml}
|
||||
<button type="submit" class="btn btn-primary" id="step4-btn">
|
||||
<span class="spinner-border spinner-border-sm d-none me-1" id="step4-spinner" role="status"></span>
|
||||
<i class="bi bi-upload me-1"></i>Import ${item.label}
|
||||
</button>
|
||||
</form>
|
||||
<div id="step4-results" class="mt-4 d-none">
|
||||
<hr>
|
||||
<div id="step4-summary" class="mb-2"></div>
|
||||
<div id="step4-errors"></div>
|
||||
</div>`;
|
||||
} else {
|
||||
const exportUrl = getExportUrl(item);
|
||||
const qbHelpHtml = (wFmt === 'qb-desktop' || wFmt === 'qb-online')
|
||||
? `<div class="alert alert-info small mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Importing into QuickBooks${wFmt === 'qb-online' ? ' Online' : ' Desktop'}:</strong>
|
||||
${wFmt === 'qb-desktop'
|
||||
? 'File → Utilities → Import → IIF Files → select the downloaded file.'
|
||||
: 'Settings → Import Data → select the data type, then upload the file.'}
|
||||
</div>`
|
||||
: '';
|
||||
actionHtml = `
|
||||
<a href="${exportUrl}" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-download me-2"></i>Download ${item.label}
|
||||
</a>
|
||||
${qbHelpHtml}`;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div style="width:40px;height:40px;border-radius:8px;background:${item.color}18;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
||||
<i class="bi ${item.icon}" style="color:${item.color};font-size:1.2rem"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">${item.label}</div>
|
||||
<div class="text-muted small">${item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
${tipsHtml}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
${actionHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (isImport) {
|
||||
document.getElementById('step4-form').addEventListener('submit', runImport);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Import runner ─────────────────────────────────────────────────────────
|
||||
async function runImport(e) {
|
||||
e.preventDefault();
|
||||
const item = wItem;
|
||||
const fileInput = document.getElementById('step4-file');
|
||||
const btn = document.getElementById('step4-btn');
|
||||
const spinner = document.getElementById('step4-spinner');
|
||||
const resultsDiv = document.getElementById('step4-results');
|
||||
const summaryDiv = document.getElementById('step4-summary');
|
||||
const errorsDiv = document.getElementById('step4-errors');
|
||||
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
if (typeof showWarning === 'function') showWarning('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
if (spinner) spinner.classList.remove('d-none');
|
||||
resultsDiv.classList.add('d-none');
|
||||
|
||||
const formData = new FormData(document.getElementById('step4-form'));
|
||||
formData.append('__RequestVerificationToken', token);
|
||||
|
||||
try {
|
||||
const response = await fetch(item.endpoint, { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const ct = response.headers.get('content-type') || '';
|
||||
if (!ct.includes('application/json')) throw new Error('Unexpected response from server — check the browser console');
|
||||
const result = await response.json();
|
||||
displaySuccess(summaryDiv, errorsDiv, resultsDiv, result);
|
||||
if (result.success) {
|
||||
if (typeof showSuccess === 'function') showSuccess(result.message || 'Import completed');
|
||||
} else {
|
||||
if (typeof showError === 'function') showError(result.message || 'Import completed with errors');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Tools] Import error:', err);
|
||||
displayError(summaryDiv, errorsDiv, resultsDiv, err.message, err.stack);
|
||||
if (typeof showError === 'function') showError(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
if (spinner) spinner.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result display ────────────────────────────────────────────────────────
|
||||
function displayError(summaryDiv, errorsDiv, resultsDiv, msg, detail) {
|
||||
if (summaryDiv) {
|
||||
summaryDiv.innerHTML = `<div class="alert alert-danger mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Import Failed:</strong> ${msg}
|
||||
</div>`;
|
||||
}
|
||||
if (errorsDiv) {
|
||||
errorsDiv.innerHTML = detail
|
||||
? `<details class="mt-2" open>
|
||||
<summary class="text-danger"><strong>Error Details</strong></summary>
|
||||
<pre class="mt-2 small bg-light p-2 rounded" style="max-height:300px;overflow-y:auto">${detail}</pre>
|
||||
</details>`
|
||||
: '';
|
||||
}
|
||||
if (resultsDiv) resultsDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function displaySuccess(summaryDiv, errorsDiv, resultsDiv, result) {
|
||||
// Normalise both QB-format (importedCount/totalRecords) and
|
||||
// CSV bulk-import format (successCount/totalRows) so this function
|
||||
// works regardless of which endpoint was called.
|
||||
const totalRecords = result.totalRecords ?? result.totalRows ?? 0;
|
||||
const importedCount = result.importedCount ?? result.successCount ?? 0;
|
||||
const updatedCount = result.updatedCount ?? 0;
|
||||
const skippedCount = result.skippedCount ?? 0;
|
||||
|
||||
// QB format puts everything in result.errors with a severity field.
|
||||
// CSV format puts error strings in result.errors and warning strings
|
||||
// in a separate result.warnings array. Normalise both into one list.
|
||||
const allMessages = [
|
||||
...(result.errors || []).map(function (e) {
|
||||
return typeof e === 'string' ? { severity: 'Error', displayMessage: e } : e;
|
||||
}),
|
||||
...(result.warnings || []).map(function (w) {
|
||||
return typeof w === 'string' ? { severity: 'Warning', displayMessage: w } : w;
|
||||
}),
|
||||
];
|
||||
|
||||
const errors = allMessages.filter(function (e) { return (e.severity || 'Error') === 'Error'; });
|
||||
const warnings = allMessages.filter(function (e) { return e.severity === 'Warning'; });
|
||||
const skipped = allMessages.filter(function (e) { return e.severity === 'Skipped'; });
|
||||
|
||||
if (summaryDiv) {
|
||||
const icon = errors.length > 0
|
||||
? '<i class="bi bi-exclamation-triangle text-warning me-1"></i>'
|
||||
: '<i class="bi bi-check-circle text-success me-1"></i>';
|
||||
summaryDiv.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
${icon}<strong>Results</strong>
|
||||
Total: <strong>${totalRecords}</strong>
|
||||
Imported: <span class="text-success fw-bold">${importedCount}</span>
|
||||
${updatedCount > 0 ? `Updated: <span class="text-info fw-bold">${updatedCount}</span> ` : ''}
|
||||
Skipped: <span class="text-warning fw-bold">${skippedCount}</span>
|
||||
${errors.length > 0 ? ` Errors: <span class="text-danger fw-bold">${errors.length}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm flex-shrink-0"
|
||||
onclick="downloadImportReport(this)"
|
||||
data-result='${JSON.stringify(result).replace(/'/g, ''')}'>
|
||||
<i class="bi bi-download me-1"></i>Download Report
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (errorsDiv) {
|
||||
let html = '';
|
||||
if (errors.length) {
|
||||
html += `<details class="mt-2" open>
|
||||
<summary class="text-danger"><strong>${errors.length} Error(s)</strong></summary>
|
||||
<ul class="mt-1 small mb-0">${errors.map(function (e) {
|
||||
return '<li>' + (e.displayMessage || e.errorMessage || JSON.stringify(e)) + '</li>';
|
||||
}).join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
if (warnings.length) {
|
||||
html += `<details class="mt-2">
|
||||
<summary class="text-warning"><strong>${warnings.length} Warning(s)</strong></summary>
|
||||
<ul class="mt-1 small mb-0">${warnings.map(function (e) {
|
||||
return '<li>' + (e.displayMessage || e.errorMessage) + '</li>';
|
||||
}).join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
if (skipped.length) {
|
||||
html += `<details class="mt-2">
|
||||
<summary class="text-secondary"><strong>${skipped.length} Skipped</strong></summary>
|
||||
<ul class="mt-1 small mb-0">${skipped.map(function (e) {
|
||||
return '<li>' + (e.recordName || '') + (e.errorMessage ? ' — ' + e.errorMessage : '') + '</li>';
|
||||
}).join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
errorsDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
if (resultsDiv) resultsDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// ── Download report (called from inline onclick) ──────────────────────────
|
||||
window.downloadImportReport = function (btn) {
|
||||
try {
|
||||
const result = JSON.parse(btn.getAttribute('data-result'));
|
||||
const importedCount = result.importedCount ?? result.successCount ?? 0;
|
||||
const updatedCount = result.updatedCount ?? 0;
|
||||
const skippedCount = result.skippedCount ?? 0;
|
||||
const rows = [['Status', 'Record', 'Field', 'Message']];
|
||||
rows.push(['Imported', importedCount, '', '']);
|
||||
if (updatedCount > 0) rows.push(['Updated', updatedCount, '', '']);
|
||||
rows.push(['Skipped', skippedCount, '', '']);
|
||||
// Normalise errors: QB format = objects, CSV format = plain strings
|
||||
const allErrors = [
|
||||
...(result.errors || []).map(function (e) {
|
||||
return typeof e === 'string' ? { severity: 'Error', recordName: '', fieldName: '', errorMessage: e } : e;
|
||||
}),
|
||||
...(result.warnings || []).map(function (w) {
|
||||
return typeof w === 'string' ? { severity: 'Warning', recordName: '', fieldName: '', errorMessage: w } : w;
|
||||
}),
|
||||
];
|
||||
allErrors.forEach(function (e) {
|
||||
rows.push([e.severity || 'Error', e.recordName || '', e.fieldName || '', e.errorMessage || '']);
|
||||
});
|
||||
const csv = rows.map(function (r) {
|
||||
return r.map(function (c) { return '"' + String(c).replace(/"/g, '""') + '"'; }).join(',');
|
||||
}).join('\r\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
||||
a.download = 'import-report-' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (err) {
|
||||
console.error('[Tools] Report error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @license
|
||||
* Unobtrusive validation support library for jQuery and jQuery Validate
|
||||
* Copyright (c) .NET Foundation. All rights reserved.
|
||||
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
* @version v4.0.0
|
||||
*/
|
||||
|
||||
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
|
||||
/*global document: false, jQuery: false */
|
||||
|
||||
(function (factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
|
||||
} else if (typeof module === 'object' && module.exports) {
|
||||
// CommonJS-like environments that support module.exports
|
||||
module.exports = factory(require('jquery-validation'));
|
||||
} else {
|
||||
// Browser global
|
||||
jQuery.validator.unobtrusive = factory(jQuery);
|
||||
}
|
||||
}(function ($) {
|
||||
var $jQval = $.validator,
|
||||
adapters,
|
||||
data_validation = "unobtrusiveValidation";
|
||||
|
||||
function setValidationValues(options, ruleName, value) {
|
||||
options.rules[ruleName] = value;
|
||||
if (options.message) {
|
||||
options.messages[ruleName] = options.message;
|
||||
}
|
||||
}
|
||||
|
||||
function splitAndTrim(value) {
|
||||
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
|
||||
}
|
||||
|
||||
function escapeAttributeValue(value) {
|
||||
// As mentioned on http://api.jquery.com/category/selectors/
|
||||
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function getModelPrefix(fieldName) {
|
||||
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
|
||||
}
|
||||
|
||||
function appendModelPrefix(value, prefix) {
|
||||
if (value.indexOf("*.") === 0) {
|
||||
value = value.replace("*.", prefix);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function onError(error, inputElement) { // 'this' is the form element
|
||||
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
|
||||
replaceAttrValue = container.attr("data-valmsg-replace"),
|
||||
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
|
||||
|
||||
container.removeClass("field-validation-valid").addClass("field-validation-error");
|
||||
error.data("unobtrusiveContainer", container);
|
||||
|
||||
if (replace) {
|
||||
container.empty();
|
||||
error.removeClass("input-validation-error").appendTo(container);
|
||||
}
|
||||
else {
|
||||
error.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function onErrors(event, validator) { // 'this' is the form element
|
||||
var container = $(this).find("[data-valmsg-summary=true]"),
|
||||
list = container.find("ul");
|
||||
|
||||
if (list && list.length && validator.errorList.length) {
|
||||
list.empty();
|
||||
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
|
||||
|
||||
$.each(validator.errorList, function () {
|
||||
$("<li />").html(this.message).appendTo(list);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSuccess(error) { // 'this' is the form element
|
||||
var container = error.data("unobtrusiveContainer");
|
||||
|
||||
if (container) {
|
||||
var replaceAttrValue = container.attr("data-valmsg-replace"),
|
||||
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
|
||||
|
||||
container.addClass("field-validation-valid").removeClass("field-validation-error");
|
||||
error.removeData("unobtrusiveContainer");
|
||||
|
||||
if (replace) {
|
||||
container.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onReset(event) { // 'this' is the form element
|
||||
var $form = $(this),
|
||||
key = '__jquery_unobtrusive_validation_form_reset';
|
||||
if ($form.data(key)) {
|
||||
return;
|
||||
}
|
||||
// Set a flag that indicates we're currently resetting the form.
|
||||
$form.data(key, true);
|
||||
try {
|
||||
$form.data("validator").resetForm();
|
||||
} finally {
|
||||
$form.removeData(key);
|
||||
}
|
||||
|
||||
$form.find(".validation-summary-errors")
|
||||
.addClass("validation-summary-valid")
|
||||
.removeClass("validation-summary-errors");
|
||||
$form.find(".field-validation-error")
|
||||
.addClass("field-validation-valid")
|
||||
.removeClass("field-validation-error")
|
||||
.removeData("unobtrusiveContainer")
|
||||
.find(">*") // If we were using valmsg-replace, get the underlying error
|
||||
.removeData("unobtrusiveContainer");
|
||||
}
|
||||
|
||||
function validationInfo(form) {
|
||||
var $form = $(form),
|
||||
result = $form.data(data_validation),
|
||||
onResetProxy = $.proxy(onReset, form),
|
||||
defaultOptions = $jQval.unobtrusive.options || {},
|
||||
execInContext = function (name, args) {
|
||||
var func = defaultOptions[name];
|
||||
func && $.isFunction(func) && func.apply(form, args);
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
result = {
|
||||
options: { // options structure passed to jQuery Validate's validate() method
|
||||
errorClass: defaultOptions.errorClass || "input-validation-error",
|
||||
errorElement: defaultOptions.errorElement || "span",
|
||||
errorPlacement: function () {
|
||||
onError.apply(form, arguments);
|
||||
execInContext("errorPlacement", arguments);
|
||||
},
|
||||
invalidHandler: function () {
|
||||
onErrors.apply(form, arguments);
|
||||
execInContext("invalidHandler", arguments);
|
||||
},
|
||||
messages: {},
|
||||
rules: {},
|
||||
success: function () {
|
||||
onSuccess.apply(form, arguments);
|
||||
execInContext("success", arguments);
|
||||
}
|
||||
},
|
||||
attachValidation: function () {
|
||||
$form
|
||||
.off("reset." + data_validation, onResetProxy)
|
||||
.on("reset." + data_validation, onResetProxy)
|
||||
.validate(this.options);
|
||||
},
|
||||
validate: function () { // a validation function that is called by unobtrusive Ajax
|
||||
$form.validate();
|
||||
return $form.valid();
|
||||
}
|
||||
};
|
||||
$form.data(data_validation, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
$jQval.unobtrusive = {
|
||||
adapters: [],
|
||||
|
||||
parseElement: function (element, skipAttach) {
|
||||
/// <summary>
|
||||
/// Parses a single HTML element for unobtrusive validation attributes.
|
||||
/// </summary>
|
||||
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
|
||||
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
|
||||
/// validation to the form. If parsing just this single element, you should specify true.
|
||||
/// If parsing several elements, you should specify false, and manually attach the validation
|
||||
/// to the form when you are finished. The default is false.</param>
|
||||
var $element = $(element),
|
||||
form = $element.parents("form")[0],
|
||||
valInfo, rules, messages;
|
||||
|
||||
if (!form) { // Cannot do client-side validation without a form
|
||||
return;
|
||||
}
|
||||
|
||||
valInfo = validationInfo(form);
|
||||
valInfo.options.rules[element.name] = rules = {};
|
||||
valInfo.options.messages[element.name] = messages = {};
|
||||
|
||||
$.each(this.adapters, function () {
|
||||
var prefix = "data-val-" + this.name,
|
||||
message = $element.attr(prefix),
|
||||
paramValues = {};
|
||||
|
||||
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
|
||||
prefix += "-";
|
||||
|
||||
$.each(this.params, function () {
|
||||
paramValues[this] = $element.attr(prefix + this);
|
||||
});
|
||||
|
||||
this.adapt({
|
||||
element: element,
|
||||
form: form,
|
||||
message: message,
|
||||
params: paramValues,
|
||||
rules: rules,
|
||||
messages: messages
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$.extend(rules, { "__dummy__": true });
|
||||
|
||||
if (!skipAttach) {
|
||||
valInfo.attachValidation();
|
||||
}
|
||||
},
|
||||
|
||||
parse: function (selector) {
|
||||
/// <summary>
|
||||
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
|
||||
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
|
||||
/// attribute values.
|
||||
/// </summary>
|
||||
/// <param name="selector" type="String">Any valid jQuery selector.</param>
|
||||
|
||||
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
|
||||
// element with data-val=true
|
||||
var $selector = $(selector),
|
||||
$forms = $selector.parents()
|
||||
.addBack()
|
||||
.filter("form")
|
||||
.add($selector.find("form"))
|
||||
.has("[data-val=true]");
|
||||
|
||||
$selector.find("[data-val=true]").each(function () {
|
||||
$jQval.unobtrusive.parseElement(this, true);
|
||||
});
|
||||
|
||||
$forms.each(function () {
|
||||
var info = validationInfo(this);
|
||||
if (info) {
|
||||
info.attachValidation();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
adapters = $jQval.unobtrusive.adapters;
|
||||
|
||||
adapters.add = function (adapterName, params, fn) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
|
||||
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
|
||||
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
|
||||
/// mmmm is the parameter name).</param>
|
||||
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
|
||||
/// attributes into jQuery Validate rules and/or messages.</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
if (!fn) { // Called with no params, just a function
|
||||
fn = params;
|
||||
params = [];
|
||||
}
|
||||
this.push({ name: adapterName, params: params, adapt: fn });
|
||||
return this;
|
||||
};
|
||||
|
||||
adapters.addBool = function (adapterName, ruleName) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
|
||||
/// the jQuery Validate validation rule has no parameter values.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
|
||||
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
|
||||
/// of adapterName will be used instead.</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
return this.add(adapterName, function (options) {
|
||||
setValidationValues(options, ruleName || adapterName, true);
|
||||
});
|
||||
};
|
||||
|
||||
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
|
||||
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
|
||||
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
|
||||
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
|
||||
/// have a minimum value.</param>
|
||||
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
|
||||
/// have a maximum value.</param>
|
||||
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
|
||||
/// have both a minimum and maximum value.</param>
|
||||
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
|
||||
/// contains the minimum value. The default is "min".</param>
|
||||
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
|
||||
/// contains the maximum value. The default is "max".</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
|
||||
var min = options.params.min,
|
||||
max = options.params.max;
|
||||
|
||||
if (min && max) {
|
||||
setValidationValues(options, minMaxRuleName, [min, max]);
|
||||
}
|
||||
else if (min) {
|
||||
setValidationValues(options, minRuleName, min);
|
||||
}
|
||||
else if (max) {
|
||||
setValidationValues(options, maxRuleName, max);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
|
||||
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
|
||||
/// the jQuery Validate validation rule has a single value.</summary>
|
||||
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
|
||||
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
|
||||
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
|
||||
/// The default is "val".</param>
|
||||
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
|
||||
/// of adapterName will be used instead.</param>
|
||||
/// <returns type="jQuery.validator.unobtrusive.adapters" />
|
||||
return this.add(adapterName, [attribute || "val"], function (options) {
|
||||
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
|
||||
});
|
||||
};
|
||||
|
||||
$jQval.addMethod("__dummy__", function (value, element, params) {
|
||||
return true;
|
||||
});
|
||||
|
||||
$jQval.addMethod("regex", function (value, element, params) {
|
||||
var match;
|
||||
if (this.optional(element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match = new RegExp(params).exec(value);
|
||||
return (match && (match.index === 0) && (match[0].length === value.length));
|
||||
});
|
||||
|
||||
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
|
||||
var match;
|
||||
if (nonalphamin) {
|
||||
match = value.match(/\W/g);
|
||||
match = match && match.length >= nonalphamin;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
if ($jQval.methods.extension) {
|
||||
adapters.addSingleVal("accept", "mimtype");
|
||||
adapters.addSingleVal("extension", "extension");
|
||||
} else {
|
||||
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
|
||||
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
|
||||
// validating the extension, and ignore mime-type validations as they are not supported.
|
||||
adapters.addSingleVal("extension", "extension", "accept");
|
||||
}
|
||||
|
||||
adapters.addSingleVal("regex", "pattern");
|
||||
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
|
||||
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
|
||||
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
|
||||
adapters.add("equalto", ["other"], function (options) {
|
||||
var prefix = getModelPrefix(options.element.name),
|
||||
other = options.params.other,
|
||||
fullOtherName = appendModelPrefix(other, prefix),
|
||||
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
|
||||
|
||||
setValidationValues(options, "equalTo", element);
|
||||
});
|
||||
adapters.add("required", function (options) {
|
||||
// jQuery Validate equates "required" with "mandatory" for checkbox elements
|
||||
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
|
||||
setValidationValues(options, "required", true);
|
||||
}
|
||||
});
|
||||
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
|
||||
var value = {
|
||||
url: options.params.url,
|
||||
type: options.params.type || "GET",
|
||||
data: {}
|
||||
},
|
||||
prefix = getModelPrefix(options.element.name);
|
||||
|
||||
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
|
||||
var paramName = appendModelPrefix(fieldName, prefix);
|
||||
value.data[paramName] = function () {
|
||||
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
|
||||
// For checkboxes and radio buttons, only pick up values from checked fields.
|
||||
if (field.is(":checkbox")) {
|
||||
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
|
||||
}
|
||||
else if (field.is(":radio")) {
|
||||
return field.filter(":checked").val() || '';
|
||||
}
|
||||
return field.val();
|
||||
};
|
||||
});
|
||||
|
||||
setValidationValues(options, "remote", value);
|
||||
});
|
||||
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
|
||||
if (options.params.min) {
|
||||
setValidationValues(options, "minlength", options.params.min);
|
||||
}
|
||||
if (options.params.nonalphamin) {
|
||||
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
|
||||
}
|
||||
if (options.params.regex) {
|
||||
setValidationValues(options, "regex", options.params.regex);
|
||||
}
|
||||
});
|
||||
adapters.add("fileextensions", ["extensions"], function (options) {
|
||||
setValidationValues(options, "extension", options.params.extensions);
|
||||
});
|
||||
|
||||
$(function () {
|
||||
$jQval.unobtrusive.parse(document);
|
||||
});
|
||||
|
||||
return $jQval.unobtrusive;
|
||||
}));
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Tom Select v2.3.1
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).TomSelect=t()}(this,(function(){"use strict"
|
||||
function e(e,t){e.split(/\s+/).forEach((e=>{t(e)}))}class t{constructor(){this._events=void 0,this._events={}}on(t,i){e(t,(e=>{const t=this._events[e]||[]
|
||||
t.push(i),this._events[e]=t}))}off(t,i){var s=arguments.length
|
||||
0!==s?e(t,(e=>{if(1===s)return void delete this._events[e]
|
||||
const t=this._events[e]
|
||||
void 0!==t&&(t.splice(t.indexOf(i),1),this._events[e]=t)})):this._events={}}trigger(t,...i){var s=this
|
||||
e(t,(e=>{const t=s._events[e]
|
||||
void 0!==t&&t.forEach((e=>{e.apply(s,i)}))}))}}const i=e=>(e=e.filter(Boolean)).length<2?e[0]||"":1==l(e)?"["+e.join("")+"]":"(?:"+e.join("|")+")",s=e=>{if(!o(e))return e.join("")
|
||||
let t="",i=0
|
||||
const s=()=>{i>1&&(t+="{"+i+"}")}
|
||||
return e.forEach(((n,o)=>{n!==e[o-1]?(s(),t+=n,i=1):i++})),s(),t},n=e=>{let t=c(e)
|
||||
return i(t)},o=e=>new Set(e).size!==e.length,r=e=>(e+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),l=e=>e.reduce(((e,t)=>Math.max(e,a(t))),0),a=e=>c(e).length,c=e=>Array.from(e),d=e=>{if(1===e.length)return[[e]]
|
||||
let t=[]
|
||||
const i=e.substring(1)
|
||||
return d(i).forEach((function(i){let s=i.slice(0)
|
||||
s[0]=e.charAt(0)+s[0],t.push(s),s=i.slice(0),s.unshift(e.charAt(0)),t.push(s)})),t},u=[[0,65535]]
|
||||
let p,h
|
||||
const g={},f={"/":"⁄∕",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"}
|
||||
for(let e in f){let t=f[e]||""
|
||||
for(let i=0;i<t.length;i++){let s=t.substring(i,i+1)
|
||||
g[s]=e}}const v=new RegExp(Object.keys(g).join("|")+"|[̀-ͯ·ʾʼ]","gu"),m=(e,t="NFKD")=>e.normalize(t),y=e=>c(e).reduce(((e,t)=>e+O(t)),""),O=e=>(e=m(e).toLowerCase().replace(v,(e=>g[e]||"")),m(e,"NFC"))
|
||||
const b=e=>{const t={},i=(e,i)=>{const s=t[e]||new Set,o=new RegExp("^"+n(s)+"$","iu")
|
||||
i.match(o)||(s.add(r(i)),t[e]=s)}
|
||||
for(let t of function*(e){for(const[t,i]of e)for(let e=t;e<=i;e++){let t=String.fromCharCode(e),i=y(t)
|
||||
i!=t.toLowerCase()&&(i.length>3||0!=i.length&&(yield{folded:i,composed:t,code_point:e}))}}(e))i(t.folded,t.folded),i(t.folded,t.composed)
|
||||
return t},w=e=>{const t=b(e),s={}
|
||||
let o=[]
|
||||
for(let e in t){let i=t[e]
|
||||
i&&(s[e]=n(i)),e.length>1&&o.push(r(e))}o.sort(((e,t)=>t.length-e.length))
|
||||
const l=i(o)
|
||||
return h=new RegExp("^"+l,"u"),s},_=(e,t=1)=>(t=Math.max(t,e.length-1),i(d(e).map((e=>((e,t=1)=>{let i=0
|
||||
return e=e.map((e=>(p[e]&&(i+=e.length),p[e]||e))),i>=t?s(e):""})(e,t))))),C=(e,t=!0)=>{let n=e.length>1?1:0
|
||||
return i(e.map((e=>{let i=[]
|
||||
const o=t?e.length():e.length()-1
|
||||
for(let t=0;t<o;t++)i.push(_(e.substrs[t]||"",n))
|
||||
return s(i)})))},S=(e,t)=>{for(const i of t){if(i.start!=e.start||i.end!=e.end)continue
|
||||
if(i.substrs.join("")!==e.substrs.join(""))continue
|
||||
let t=e.parts
|
||||
const s=e=>{for(const i of t){if(i.start===e.start&&i.substr===e.substr)return!1
|
||||
if(1!=e.length&&1!=i.length){if(e.start<i.start&&e.end>i.start)return!0
|
||||
if(i.start<e.start&&i.end>e.start)return!0}}return!1}
|
||||
if(!(i.parts.filter(s).length>0))return!0}return!1}
|
||||
class I{constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(e){e&&(this.parts.push(e),this.substrs.push(e.substr),this.start=Math.min(e.start,this.start),this.end=Math.max(e.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(e,t){let i=new I,s=JSON.parse(JSON.stringify(this.parts)),n=s.pop()
|
||||
for(const e of s)i.add(e)
|
||||
let o=t.substr.substring(0,e-n.start),r=o.length
|
||||
return i.add({start:n.start,end:n.start+r,length:r,substr:o}),i}}const A=e=>{var t
|
||||
void 0===p&&(p=w(t||u)),e=y(e)
|
||||
let i="",s=[new I]
|
||||
for(let t=0;t<e.length;t++){let n=e.substring(t).match(h)
|
||||
const o=e.substring(t,t+1),r=n?n[0]:null
|
||||
let l=[],a=new Set
|
||||
for(const e of s){const i=e.last()
|
||||
if(!i||1==i.length||i.end<=t)if(r){const i=r.length
|
||||
e.add({start:t,end:t+i,length:i,substr:r}),a.add("1")}else e.add({start:t,end:t+1,length:1,substr:o}),a.add("2")
|
||||
else if(r){let s=e.clone(t,i)
|
||||
const n=r.length
|
||||
s.add({start:t,end:t+n,length:n,substr:r}),l.push(s)}else a.add("3")}if(l.length>0){l=l.sort(((e,t)=>e.length()-t.length()))
|
||||
for(let e of l)S(e,s)||s.push(e)}else if(t>0&&1==a.size&&!a.has("3")){i+=C(s,!1)
|
||||
let e=new I
|
||||
const t=s[0]
|
||||
t&&e.add(t.last()),s=[e]}}return i+=C(s,!0),i},x=(e,t)=>{if(e)return e[t]},k=(e,t)=>{if(e){for(var i,s=t.split(".");(i=s.shift())&&(e=e[i]););return e}},F=(e,t,i)=>{var s,n
|
||||
return e?(e+="",null==t.regex||-1===(n=e.search(t.regex))?0:(s=t.string.length/e.length,0===n&&(s+=.5),s*i)):0},L=(e,t)=>{var i=e[t]
|
||||
if("function"==typeof i)return i
|
||||
i&&!Array.isArray(i)&&(e[t]=[i])},E=(e,t)=>{if(Array.isArray(e))e.forEach(t)
|
||||
else for(var i in e)e.hasOwnProperty(i)&&t(e[i],i)},T=(e,t)=>"number"==typeof e&&"number"==typeof t?e>t?1:e<t?-1:0:(e=y(e+"").toLowerCase())>(t=y(t+"").toLowerCase())?1:t>e?-1:0
|
||||
class P{constructor(e,t){this.items=void 0,this.settings=void 0,this.items=e,this.settings=t||{diacritics:!0}}tokenize(e,t,i){if(!e||!e.length)return[]
|
||||
const s=[],n=e.split(/\s+/)
|
||||
var o
|
||||
return i&&(o=new RegExp("^("+Object.keys(i).map(r).join("|")+"):(.*)$")),n.forEach((e=>{let i,n=null,l=null
|
||||
o&&(i=e.match(o))&&(n=i[1],e=i[2]),e.length>0&&(l=this.settings.diacritics?A(e)||null:r(e),l&&t&&(l="\\b"+l)),s.push({string:e,regex:l?new RegExp(l,"iu"):null,field:n})})),s}getScoreFunction(e,t){var i=this.prepareSearch(e,t)
|
||||
return this._getScoreFunction(i)}_getScoreFunction(e){const t=e.tokens,i=t.length
|
||||
if(!i)return function(){return 0}
|
||||
const s=e.options.fields,n=e.weights,o=s.length,r=e.getAttrFn
|
||||
if(!o)return function(){return 1}
|
||||
const l=1===o?function(e,t){const i=s[0].field
|
||||
return F(r(t,i),e,n[i]||1)}:function(e,t){var i=0
|
||||
if(e.field){const s=r(t,e.field)
|
||||
!e.regex&&s?i+=1/o:i+=F(s,e,1)}else E(n,((s,n)=>{i+=F(r(t,n),e,s)}))
|
||||
return i/o}
|
||||
return 1===i?function(e){return l(t[0],e)}:"and"===e.options.conjunction?function(e){var s,n=0
|
||||
for(let i of t){if((s=l(i,e))<=0)return 0
|
||||
n+=s}return n/i}:function(e){var s=0
|
||||
return E(t,(t=>{s+=l(t,e)})),s/i}}getSortFunction(e,t){var i=this.prepareSearch(e,t)
|
||||
return this._getSortFunction(i)}_getSortFunction(e){var t,i=[]
|
||||
const s=this,n=e.options,o=!e.query&&n.sort_empty?n.sort_empty:n.sort
|
||||
if("function"==typeof o)return o.bind(this)
|
||||
const r=function(t,i){return"$score"===t?i.score:e.getAttrFn(s.items[i.id],t)}
|
||||
if(o)for(let t of o)(e.query||"$score"!==t.field)&&i.push(t)
|
||||
if(e.query){t=!0
|
||||
for(let e of i)if("$score"===e.field){t=!1
|
||||
break}t&&i.unshift({field:"$score",direction:"desc"})}else i=i.filter((e=>"$score"!==e.field))
|
||||
return i.length?function(e,t){var s,n
|
||||
for(let o of i){if(n=o.field,s=("desc"===o.direction?-1:1)*T(r(n,e),r(n,t)))return s}return 0}:null}prepareSearch(e,t){const i={}
|
||||
var s=Object.assign({},t)
|
||||
if(L(s,"sort"),L(s,"sort_empty"),s.fields){L(s,"fields")
|
||||
const e=[]
|
||||
s.fields.forEach((t=>{"string"==typeof t&&(t={field:t,weight:1}),e.push(t),i[t.field]="weight"in t?t.weight:1})),s.fields=e}return{options:s,query:e.toLowerCase().trim(),tokens:this.tokenize(e,s.respect_word_boundaries,i),total:0,items:[],weights:i,getAttrFn:s.nesting?k:x}}search(e,t){var i,s,n=this
|
||||
s=this.prepareSearch(e,t),t=s.options,e=s.query
|
||||
const o=t.score||n._getScoreFunction(s)
|
||||
e.length?E(n.items,((e,n)=>{i=o(e),(!1===t.filter||i>0)&&s.items.push({score:i,id:n})})):E(n.items,((e,t)=>{s.items.push({score:1,id:t})}))
|
||||
const r=n._getSortFunction(s)
|
||||
return r&&s.items.sort(r),s.total=s.items.length,"number"==typeof t.limit&&(s.items=s.items.slice(0,t.limit)),s}}const N=(e,t)=>{if(Array.isArray(e))e.forEach(t)
|
||||
else for(var i in e)e.hasOwnProperty(i)&&t(e[i],i)},j=e=>{if(e.jquery)return e[0]
|
||||
if(e instanceof HTMLElement)return e
|
||||
if($(e)){var t=document.createElement("template")
|
||||
return t.innerHTML=e.trim(),t.content.firstChild}return document.querySelector(e)},$=e=>"string"==typeof e&&e.indexOf("<")>-1,V=(e,t)=>{var i=document.createEvent("HTMLEvents")
|
||||
i.initEvent(t,!0,!1),e.dispatchEvent(i)},q=(e,t)=>{Object.assign(e.style,t)},D=(e,...t)=>{var i=H(t);(e=M(e)).map((e=>{i.map((t=>{e.classList.add(t)}))}))},R=(e,...t)=>{var i=H(t);(e=M(e)).map((e=>{i.map((t=>{e.classList.remove(t)}))}))},H=e=>{var t=[]
|
||||
return N(e,(e=>{"string"==typeof e&&(e=e.trim().split(/[\11\12\14\15\40]/)),Array.isArray(e)&&(t=t.concat(e))})),t.filter(Boolean)},M=e=>(Array.isArray(e)||(e=[e]),e),z=(e,t,i)=>{if(!i||i.contains(e))for(;e&&e.matches;){if(e.matches(t))return e
|
||||
e=e.parentNode}},B=(e,t=0)=>t>0?e[e.length-1]:e[0],K=(e,t)=>{if(!e)return-1
|
||||
t=t||e.nodeName
|
||||
for(var i=0;e=e.previousElementSibling;)e.matches(t)&&i++
|
||||
return i},Q=(e,t)=>{N(t,((t,i)=>{null==t?e.removeAttribute(i):e.setAttribute(i,""+t)}))},G=(e,t)=>{e.parentNode&&e.parentNode.replaceChild(t,e)},U=(e,t)=>{if(null===t)return
|
||||
if("string"==typeof t){if(!t.length)return
|
||||
t=new RegExp(t,"i")}const i=e=>3===e.nodeType?(e=>{var i=e.data.match(t)
|
||||
if(i&&e.data.length>0){var s=document.createElement("span")
|
||||
s.className="highlight"
|
||||
var n=e.splitText(i.index)
|
||||
n.splitText(i[0].length)
|
||||
var o=n.cloneNode(!0)
|
||||
return s.appendChild(o),G(n,s),1}return 0})(e):((e=>{1!==e.nodeType||!e.childNodes||/(script|style)/i.test(e.tagName)||"highlight"===e.className&&"SPAN"===e.tagName||Array.from(e.childNodes).forEach((e=>{i(e)}))})(e),0)
|
||||
i(e)},J="undefined"!=typeof navigator&&/Mac/.test(navigator.userAgent)?"metaKey":"ctrlKey"
|
||||
var W={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'<input type="text" autocomplete="off" size="1" />',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(e){return e.length>0},render:{}}
|
||||
const X=e=>null==e?null:Y(e),Y=e=>"boolean"==typeof e?e?"1":"0":e+"",Z=e=>(e+"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),ee=(e,t)=>{var i
|
||||
return function(s,n){var o=this
|
||||
i&&(o.loading=Math.max(o.loading-1,0),clearTimeout(i)),i=setTimeout((function(){i=null,o.loadedSearches[s]=!0,e.call(o,s,n)}),t)}},te=(e,t,i)=>{var s,n=e.trigger,o={}
|
||||
for(s of(e.trigger=function(){var i=arguments[0]
|
||||
if(-1===t.indexOf(i))return n.apply(e,arguments)
|
||||
o[i]=arguments},i.apply(e,[]),e.trigger=n,t))s in o&&n.apply(e,o[s])},ie=(e,t=!1)=>{e&&(e.preventDefault(),t&&e.stopPropagation())},se=(e,t,i,s)=>{e.addEventListener(t,i,s)},ne=(e,t)=>!!t&&(!!t[e]&&1===(t.altKey?1:0)+(t.ctrlKey?1:0)+(t.shiftKey?1:0)+(t.metaKey?1:0)),oe=(e,t)=>{const i=e.getAttribute("id")
|
||||
return i||(e.setAttribute("id",t),t)},re=e=>e.replace(/[\\"']/g,"\\$&"),le=(e,t)=>{t&&e.append(t)}
|
||||
function ae(e,t){var i=Object.assign({},W,t),s=i.dataAttr,n=i.labelField,o=i.valueField,r=i.disabledField,l=i.optgroupField,a=i.optgroupLabelField,c=i.optgroupValueField,d=e.tagName.toLowerCase(),u=e.getAttribute("placeholder")||e.getAttribute("data-placeholder")
|
||||
if(!u&&!i.allowEmptyOption){let t=e.querySelector('option[value=""]')
|
||||
t&&(u=t.textContent)}var p={placeholder:u,options:[],optgroups:[],items:[],maxItems:null}
|
||||
return"select"===d?(()=>{var t,d=p.options,u={},h=1
|
||||
let g=0
|
||||
var f=e=>{var t=Object.assign({},e.dataset),i=s&&t[s]
|
||||
return"string"==typeof i&&i.length&&(t=Object.assign(t,JSON.parse(i))),t},v=(e,t)=>{var s=X(e.value)
|
||||
if(null!=s&&(s||i.allowEmptyOption)){if(u.hasOwnProperty(s)){if(t){var a=u[s][l]
|
||||
a?Array.isArray(a)?a.push(t):u[s][l]=[a,t]:u[s][l]=t}}else{var c=f(e)
|
||||
c[n]=c[n]||e.textContent,c[o]=c[o]||s,c[r]=c[r]||e.disabled,c[l]=c[l]||t,c.$option=e,c.$order=c.$order||++g,u[s]=c,d.push(c)}e.selected&&p.items.push(s)}}
|
||||
p.maxItems=e.hasAttribute("multiple")?null:1,N(e.children,(e=>{var i,s,n
|
||||
"optgroup"===(t=e.tagName.toLowerCase())?((n=f(i=e))[a]=n[a]||i.getAttribute("label")||"",n[c]=n[c]||h++,n[r]=n[r]||i.disabled,n.$order=n.$order||++g,p.optgroups.push(n),s=n[c],N(i.children,(e=>{v(e,s)}))):"option"===t&&v(e)}))})():(()=>{const t=e.getAttribute(s)
|
||||
if(t)p.options=JSON.parse(t),N(p.options,(e=>{p.items.push(e[o])}))
|
||||
else{var r=e.value.trim()||""
|
||||
if(!i.allowEmptyOption&&!r.length)return
|
||||
const t=r.split(i.delimiter)
|
||||
N(t,(e=>{const t={}
|
||||
t[n]=e,t[o]=e,p.options.push(t)})),p.items=t}})(),Object.assign({},W,p,t)}var ce=0
|
||||
class de extends(function(e){return e.plugins={},class extends e{constructor(...e){super(...e),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(t,i){e.plugins[t]={name:t,fn:i}}initializePlugins(e){var t,i
|
||||
const s=this,n=[]
|
||||
if(Array.isArray(e))e.forEach((e=>{"string"==typeof e?n.push(e):(s.plugins.settings[e.name]=e.options,n.push(e.name))}))
|
||||
else if(e)for(t in e)e.hasOwnProperty(t)&&(s.plugins.settings[t]=e[t],n.push(t))
|
||||
for(;i=n.shift();)s.require(i)}loadPlugin(t){var i=this,s=i.plugins,n=e.plugins[t]
|
||||
if(!e.plugins.hasOwnProperty(t))throw new Error('Unable to find "'+t+'" plugin')
|
||||
s.requested[t]=!0,s.loaded[t]=n.fn.apply(i,[i.plugins.settings[t]||{}]),s.names.push(t)}require(e){var t=this,i=t.plugins
|
||||
if(!t.plugins.loaded.hasOwnProperty(e)){if(i.requested[e])throw new Error('Plugin has circular dependency ("'+e+'")')
|
||||
t.loadPlugin(e)}return i.loaded[e]}}}(t)){constructor(e,t){var i
|
||||
super(),this.control_input=void 0,this.wrapper=void 0,this.dropdown=void 0,this.control=void 0,this.dropdown_content=void 0,this.focus_node=void 0,this.order=0,this.settings=void 0,this.input=void 0,this.tabIndex=void 0,this.is_select_tag=void 0,this.rtl=void 0,this.inputId=void 0,this._destroy=void 0,this.sifter=void 0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isRequired=void 0,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.currentResults=void 0,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,ce++
|
||||
var s=j(e)
|
||||
if(s.tomselect)throw new Error("Tom Select already initialized on this element")
|
||||
s.tomselect=this,i=(window.getComputedStyle&&window.getComputedStyle(s,null)).getPropertyValue("direction")
|
||||
const n=ae(s,t)
|
||||
this.settings=n,this.input=s,this.tabIndex=s.tabIndex||0,this.is_select_tag="select"===s.tagName.toLowerCase(),this.rtl=/rtl/i.test(i),this.inputId=oe(s,"tomselect-"+ce),this.isRequired=s.required,this.sifter=new P(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(1===n.maxItems?"single":"multi"),"boolean"!=typeof n.hideSelected&&(n.hideSelected="multi"===n.mode),"boolean"!=typeof n.hidePlaceholder&&(n.hidePlaceholder="multi"!==n.mode)
|
||||
var o=n.createFilter
|
||||
"function"!=typeof o&&("string"==typeof o&&(o=new RegExp(o)),o instanceof RegExp?n.createFilter=e=>o.test(e):n.createFilter=e=>this.settings.duplicates||!this.options[e]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates()
|
||||
const r=j("<div>"),l=j("<div>"),a=this._render("dropdown"),c=j('<div role="listbox" tabindex="-1">'),d=this.input.getAttribute("class")||"",u=n.mode
|
||||
var p
|
||||
if(D(r,n.wrapperClass,d,u),D(l,n.controlClass),le(r,l),D(a,n.dropdownClass,u),n.copyClassesToDropdown&&D(a,d),D(c,n.dropdownContentClass),le(a,c),j(n.dropdownParent||r).appendChild(a),$(n.controlInput)){p=j(n.controlInput)
|
||||
E(["autocorrect","autocapitalize","autocomplete","spellcheck"],(e=>{s.getAttribute(e)&&Q(p,{[e]:s.getAttribute(e)})})),p.tabIndex=-1,l.appendChild(p),this.focus_node=p}else n.controlInput?(p=j(n.controlInput),this.focus_node=p):(p=j("<input/>"),this.focus_node=l)
|
||||
this.wrapper=r,this.dropdown=a,this.dropdown_content=c,this.control=l,this.control_input=p,this.setup()}setup(){const e=this,t=e.settings,i=e.control_input,s=e.dropdown,n=e.dropdown_content,o=e.wrapper,l=e.control,a=e.input,c=e.focus_node,d={passive:!0},u=e.inputId+"-ts-dropdown"
|
||||
Q(n,{id:u}),Q(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":u})
|
||||
const p=oe(c,e.inputId+"-ts-control"),h="label[for='"+(e=>e.replace(/['"\\]/g,"\\$&"))(e.inputId)+"']",g=document.querySelector(h),f=e.focus.bind(e)
|
||||
if(g){se(g,"click",f),Q(g,{for:p})
|
||||
const t=oe(g,e.inputId+"-ts-label")
|
||||
Q(c,{"aria-labelledby":t}),Q(n,{"aria-labelledby":t})}if(o.style.width=a.style.width,e.plugins.names.length){const t="plugin-"+e.plugins.names.join(" plugin-")
|
||||
D([o,s],t)}(null===t.maxItems||t.maxItems>1)&&e.is_select_tag&&Q(a,{multiple:"multiple"}),t.placeholder&&Q(i,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+r(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=ee(t.load,t.loadThrottle)),se(s,"mousemove",(()=>{e.ignoreHover=!1})),se(s,"mouseenter",(t=>{var i=z(t.target,"[data-selectable]",s)
|
||||
i&&e.onOptionHover(t,i)}),{capture:!0}),se(s,"click",(t=>{const i=z(t.target,"[data-selectable]")
|
||||
i&&(e.onOptionSelect(t,i),ie(t,!0))})),se(l,"click",(t=>{var s=z(t.target,"[data-ts-item]",l)
|
||||
s&&e.onItemSelect(t,s)?ie(t,!0):""==i.value&&(e.onClick(),ie(t,!0))})),se(c,"keydown",(t=>e.onKeyDown(t))),se(i,"keypress",(t=>e.onKeyPress(t))),se(i,"input",(t=>e.onInput(t))),se(c,"blur",(t=>e.onBlur(t))),se(c,"focus",(t=>e.onFocus(t))),se(i,"paste",(t=>e.onPaste(t)))
|
||||
const v=t=>{const n=t.composedPath()[0]
|
||||
if(!o.contains(n)&&!s.contains(n))return e.isFocused&&e.blur(),void e.inputState()
|
||||
n==i&&e.isOpen?t.stopPropagation():ie(t,!0)},m=()=>{e.isOpen&&e.positionDropdown()}
|
||||
se(document,"mousedown",v),se(window,"scroll",m,d),se(window,"resize",m,d),this._destroy=()=>{document.removeEventListener("mousedown",v),window.removeEventListener("scroll",m),window.removeEventListener("resize",m),g&&g.removeEventListener("click",f)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,se(a,"invalid",(()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())})),e.updateOriginalInput(),e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,a.disabled?e.disable():a.readOnly?e.setReadOnly(!0):e.enable(),e.on("change",this.onChange),D(a,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),!0===t.preload&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),E(t,(e=>{this.registerOptionGroup(e)}))}setupTemplates(){var e=this,t=e.settings.labelField,i=e.settings.optgroupLabelField,s={optgroup:e=>{let t=document.createElement("div")
|
||||
return t.className="optgroup",t.appendChild(e.options),t},optgroup_header:(e,t)=>'<div class="optgroup-header">'+t(e[i])+"</div>",option:(e,i)=>"<div>"+i(e[t])+"</div>",item:(e,i)=>"<div>"+i(e[t])+"</div>",option_create:(e,t)=>'<div class="create">Add <strong>'+t(e.input)+"</strong>…</div>",no_results:()=>'<div class="no-results">No results found</div>',loading:()=>'<div class="spinner"></div>',not_loading:()=>{},dropdown:()=>"<div></div>"}
|
||||
e.settings.render=Object.assign({},s,e.settings.render)}setupCallbacks(){var e,t,i={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"}
|
||||
for(e in i)(t=this.settings[i[e]])&&this.on(e,t)}sync(e=!0){const t=this,i=e?ae(t.input,{delimiter:t.settings.delimiter}):t.settings
|
||||
t.setupOptions(i.options,i.optgroups),t.setValue(i.items||[],!0),t.lastQuery=null}onClick(){var e=this
|
||||
if(e.activeItems.length>0)return e.clearActiveItems(),void e.focus()
|
||||
e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){V(this.input,"input"),V(this.input,"change")}onPaste(e){var t=this
|
||||
t.isInputHidden||t.isLocked?ie(e):t.settings.splitOn&&setTimeout((()=>{var e=t.inputValue()
|
||||
if(e.match(t.settings.splitOn)){var i=e.trim().split(t.settings.splitOn)
|
||||
E(i,(e=>{X(e)&&(this.options[e]?t.addItem(e):t.createItem(e))}))}}),0)}onKeyPress(e){var t=this
|
||||
if(!t.isLocked){var i=String.fromCharCode(e.keyCode||e.which)
|
||||
return t.settings.create&&"multi"===t.settings.mode&&i===t.settings.delimiter?(t.createItem(),void ie(e)):void 0}ie(e)}onKeyDown(e){var t=this
|
||||
if(t.ignoreHover=!0,t.isLocked)9!==e.keyCode&&ie(e)
|
||||
else{switch(e.keyCode){case 65:if(ne(J,e)&&""==t.control_input.value)return ie(e),void t.selectAll()
|
||||
break
|
||||
case 27:return t.isOpen&&(ie(e,!0),t.close()),void t.clearActiveItems()
|
||||
case 40:if(!t.isOpen&&t.hasOptions)t.open()
|
||||
else if(t.activeOption){let e=t.getAdjacent(t.activeOption,1)
|
||||
e&&t.setActiveOption(e)}return void ie(e)
|
||||
case 38:if(t.activeOption){let e=t.getAdjacent(t.activeOption,-1)
|
||||
e&&t.setActiveOption(e)}return void ie(e)
|
||||
case 13:return void(t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),ie(e)):(t.settings.create&&t.createItem()||document.activeElement==t.control_input&&t.isOpen)&&ie(e))
|
||||
case 37:return void t.advanceSelection(-1,e)
|
||||
case 39:return void t.advanceSelection(1,e)
|
||||
case 9:return void(t.settings.selectOnTab&&(t.canSelect(t.activeOption)&&(t.onOptionSelect(e,t.activeOption),ie(e)),t.settings.create&&t.createItem()&&ie(e)))
|
||||
case 8:case 46:return void t.deleteSelection(e)}t.isInputHidden&&!ne(J,e)&&ie(e)}}onInput(e){if(this.isLocked)return
|
||||
const t=this.inputValue()
|
||||
this.lastValue!==t&&(this.lastValue=t,""!=t?(this.refreshTimeout&&clearTimeout(this.refreshTimeout),this.refreshTimeout=((e,t)=>t>0?setTimeout(e,t):(e.call(null),null))((()=>{this.refreshTimeout=null,this._onInput()}),this.settings.refreshThrottle)):this._onInput())}_onInput(){const e=this.lastValue
|
||||
this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,i=t.isFocused
|
||||
if(t.isDisabled||t.isReadOnly)return t.blur(),void ie(e)
|
||||
t.ignoreFocus||(t.isFocused=!0,"focus"===t.settings.preload&&t.preload(),i||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(!1!==document.hasFocus()){var t=this
|
||||
if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1
|
||||
var i=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")}
|
||||
t.settings.create&&t.settings.createOnBlur?t.createItem(null,i):i()}}}onOptionSelect(e,t){var i,s=this
|
||||
t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?s.createItem(null,(()=>{s.settings.closeAfterSelect&&s.close()})):void 0!==(i=t.dataset.value)&&(s.lastQuery=null,s.addItem(i),s.settings.closeAfterSelect&&s.close(),!s.settings.hideSelected&&e.type&&/click/.test(e.type)&&s.setActiveOption(t)))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var i=this
|
||||
return!i.isLocked&&"multi"===i.settings.mode&&(ie(e),i.setActiveItem(t,e),!0)}canLoad(e){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(e)}load(e){const t=this
|
||||
if(!t.canLoad(e))return
|
||||
D(t.wrapper,t.settings.loadingClass),t.loading++
|
||||
const i=t.loadCallback.bind(t)
|
||||
t.settings.load.call(t,e,i)}loadCallback(e,t){const i=this
|
||||
i.loading=Math.max(i.loading-1,0),i.lastQuery=null,i.clearActiveOption(),i.setupOptions(e,t),i.refreshOptions(i.isFocused&&!i.isInputHidden),i.loading||R(i.wrapper,i.settings.loadingClass),i.trigger("load",e,t)}preload(){var e=this.wrapper.classList
|
||||
e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input
|
||||
t.value!==e&&(t.value=e,V(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){te(this,t?[]:["change"],(()=>{this.clear(t),this.addItems(e,t)}))}setMaxItems(e){0===e&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var i,s,n,o,r,l,a=this
|
||||
if("single"!==a.settings.mode){if(!e)return a.clearActiveItems(),void(a.isFocused&&a.inputState())
|
||||
if("click"===(i=t&&t.type.toLowerCase())&&ne("shiftKey",t)&&a.activeItems.length){for(l=a.getLastActive(),(n=Array.prototype.indexOf.call(a.control.children,l))>(o=Array.prototype.indexOf.call(a.control.children,e))&&(r=n,n=o,o=r),s=n;s<=o;s++)e=a.control.children[s],-1===a.activeItems.indexOf(e)&&a.setActiveItemClass(e)
|
||||
ie(t)}else"click"===i&&ne(J,t)||"keydown"===i&&ne("shiftKey",t)?e.classList.contains("active")?a.removeActiveItem(e):a.setActiveItemClass(e):(a.clearActiveItems(),a.setActiveItemClass(e))
|
||||
a.inputState(),a.isFocused||a.focus()}}setActiveItemClass(e){const t=this,i=t.control.querySelector(".last-active")
|
||||
i&&R(i,"last-active"),D(e,"active last-active"),t.trigger("item_select",e),-1==t.activeItems.indexOf(e)&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e)
|
||||
this.activeItems.splice(t,1),R(e,"active")}clearActiveItems(){R(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,Q(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),Q(e,{"aria-selected":"true"}),D(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return
|
||||
const i=this.dropdown_content,s=i.clientHeight,n=i.scrollTop||0,o=e.offsetHeight,r=e.getBoundingClientRect().top-i.getBoundingClientRect().top+n
|
||||
r+o>s+n?this.scroll(r-s+o,t):r<n&&this.scroll(r,t)}scroll(e,t){const i=this.dropdown_content
|
||||
t&&(i.style.scrollBehavior=t),i.scrollTop=e,i.style.scrollBehavior=""}clearActiveOption(){this.activeOption&&(R(this.activeOption,"active"),Q(this.activeOption,{"aria-selected":null})),this.activeOption=null,Q(this.focus_node,{"aria-activedescendant":null})}selectAll(){const e=this
|
||||
if("single"===e.settings.mode)return
|
||||
const t=e.controlChildren()
|
||||
t.length&&(e.inputState(),e.close(),e.activeItems=t,E(t,(t=>{e.setActiveItemClass(t)})))}inputState(){var e=this
|
||||
e.control.contains(e.control_input)&&(Q(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&Q(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this
|
||||
e.isDisabled||e.isReadOnly||(e.ignoreFocus=!0,e.control_input.offsetWidth?e.control_input.focus():e.focus_node.focus(),setTimeout((()=>{e.ignoreFocus=!1,e.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField
|
||||
return"string"==typeof e.sortField&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,i,s=this,n=this.getSearchOptions()
|
||||
if(s.settings.score&&"function"!=typeof(i=s.settings.score.call(s,e)))throw new Error('Tom Select "score" setting must be a function that returns a function')
|
||||
return e!==s.lastQuery?(s.lastQuery=e,t=s.sifter.search(e,Object.assign(n,{score:i})),s.currentResults=t):t=Object.assign({},s.currentResults),s.settings.hideSelected&&(t.items=t.items.filter((e=>{let t=X(e.id)
|
||||
return!(t&&-1!==s.items.indexOf(t))}))),t}refreshOptions(e=!0){var t,i,s,n,o,r,l,a,c,d
|
||||
const u={},p=[]
|
||||
var h=this,g=h.inputValue()
|
||||
const f=g===h.lastQuery||""==g&&null==h.lastQuery
|
||||
var v=h.search(g),m=null,y=h.settings.shouldOpen||!1,O=h.dropdown_content
|
||||
f&&(m=h.activeOption)&&(c=m.closest("[data-group]")),n=v.items.length,"number"==typeof h.settings.maxOptions&&(n=Math.min(n,h.settings.maxOptions)),n>0&&(y=!0)
|
||||
const b=(e,t)=>{let i=u[e]
|
||||
if(void 0!==i){let e=p[i]
|
||||
if(void 0!==e)return[i,e.fragment]}let s=document.createDocumentFragment()
|
||||
return i=p.length,p.push({fragment:s,order:t,optgroup:e}),[i,s]}
|
||||
for(t=0;t<n;t++){let e=v.items[t]
|
||||
if(!e)continue
|
||||
let n=e.id,l=h.options[n]
|
||||
if(void 0===l)continue
|
||||
let a=Y(n),d=h.getOption(a,!0)
|
||||
for(h.settings.hideSelected||d.classList.toggle("selected",h.items.includes(a)),o=l[h.settings.optgroupField]||"",i=0,s=(r=Array.isArray(o)?o:[o])&&r.length;i<s;i++){o=r[i]
|
||||
let e=l.$order,t=h.optgroups[o]
|
||||
void 0===t?o="":e=t.$order
|
||||
const[s,a]=b(o,e)
|
||||
i>0&&(d=d.cloneNode(!0),Q(d,{id:l.$id+"-clone-"+i,"aria-selected":null}),d.classList.add("ts-cloned"),R(d,"active"),h.activeOption&&h.activeOption.dataset.value==n&&c&&c.dataset.group===o.toString()&&(m=d)),a.appendChild(d),""!=o&&(u[o]=s)}}var w
|
||||
h.settings.lockOptgroupOrder&&p.sort(((e,t)=>e.order-t.order)),l=document.createDocumentFragment(),E(p,(e=>{let t=e.fragment,i=e.optgroup
|
||||
if(!t||!t.children.length)return
|
||||
let s=h.optgroups[i]
|
||||
if(void 0!==s){let e=document.createDocumentFragment(),i=h.render("optgroup_header",s)
|
||||
le(e,i),le(e,t)
|
||||
let n=h.render("optgroup",{group:s,options:e})
|
||||
le(l,n)}else le(l,t)})),O.innerHTML="",le(O,l),h.settings.highlight&&(w=O.querySelectorAll("span.highlight"),Array.prototype.forEach.call(w,(function(e){var t=e.parentNode
|
||||
t.replaceChild(e.firstChild,e),t.normalize()})),v.query.length&&v.tokens.length&&E(v.tokens,(e=>{U(O,e.regex)})))
|
||||
var _=e=>{let t=h.render(e,{input:g})
|
||||
return t&&(y=!0,O.insertBefore(t,O.firstChild)),t}
|
||||
if(h.loading?_("loading"):h.settings.shouldLoad.call(h,g)?0===v.items.length&&_("no_results"):_("not_loading"),(a=h.canCreate(g))&&(d=_("option_create")),h.hasOptions=v.items.length>0||a,y){if(v.items.length>0){if(m||"single"!==h.settings.mode||null==h.items[0]||(m=h.getOption(h.items[0])),!O.contains(m)){let e=0
|
||||
d&&!h.settings.addPrecedence&&(e=1),m=h.selectable()[e]}}else d&&(m=d)
|
||||
e&&!h.isOpen&&(h.open(),h.scrollToOption(m,"auto")),h.setActiveOption(m)}else h.clearActiveOption(),e&&h.isOpen&&h.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){const i=this
|
||||
if(Array.isArray(e))return i.addOptions(e,t),!1
|
||||
const s=X(e[i.settings.valueField])
|
||||
return null!==s&&!i.options.hasOwnProperty(s)&&(e.$order=e.$order||++i.order,e.$id=i.inputId+"-opt-"+e.$order,i.options[s]=e,i.lastQuery=null,t&&(i.userOptions[s]=t,i.trigger("option_add",s,e)),s)}addOptions(e,t=!1){E(e,(e=>{this.addOption(e,t)}))}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=X(e[this.settings.optgroupValueField])
|
||||
return null!==t&&(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var i
|
||||
t[this.settings.optgroupValueField]=e,(i=this.registerOptionGroup(t))&&this.trigger("optgroup_add",i,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){const i=this
|
||||
var s,n
|
||||
const o=X(e),r=X(t[i.settings.valueField])
|
||||
if(null===o)return
|
||||
const l=i.options[o]
|
||||
if(null==l)return
|
||||
if("string"!=typeof r)throw new Error("Value must be set in option data")
|
||||
const a=i.getOption(o),c=i.getItem(o)
|
||||
if(t.$order=t.$order||l.$order,delete i.options[o],i.uncacheValue(r),i.options[r]=t,a){if(i.dropdown_content.contains(a)){const e=i._render("option",t)
|
||||
G(a,e),i.activeOption===a&&i.setActiveOption(e)}a.remove()}c&&(-1!==(n=i.items.indexOf(o))&&i.items.splice(n,1,r),s=i._render("item",t),c.classList.contains("active")&&D(s,"active"),G(c,s)),i.lastQuery=null}removeOption(e,t){const i=this
|
||||
e=Y(e),i.uncacheValue(e),delete i.userOptions[e],delete i.options[e],i.lastQuery=null,i.trigger("option_remove",e),i.removeItem(e,t)}clearOptions(e){const t=(e||this.clearFilter).bind(this)
|
||||
this.loadedSearches={},this.userOptions={},this.clearCache()
|
||||
const i={}
|
||||
E(this.options,((e,s)=>{t(e,s)&&(i[s]=e)})),this.options=this.sifter.items=i,this.lastQuery=null,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){const i=X(e)
|
||||
if(null===i)return null
|
||||
const s=this.options[i]
|
||||
if(null!=s){if(s.$div)return s.$div
|
||||
if(t)return this._render("option",s)}return null}getAdjacent(e,t,i="option"){var s
|
||||
if(!e)return null
|
||||
s="item"==i?this.controlChildren():this.dropdown_content.querySelectorAll("[data-selectable]")
|
||||
for(let i=0;i<s.length;i++)if(s[i]==e)return t>0?s[i+1]:s[i-1]
|
||||
return null}getItem(e){if("object"==typeof e)return e
|
||||
var t=X(e)
|
||||
return null!==t?this.control.querySelector(`[data-value="${re(t)}"]`):null}addItems(e,t){var i=this,s=Array.isArray(e)?e:[e]
|
||||
const n=(s=s.filter((e=>-1===i.items.indexOf(e))))[s.length-1]
|
||||
s.forEach((e=>{i.isPending=e!==n,i.addItem(e,t)}))}addItem(e,t){te(this,t?[]:["change","dropdown_close"],(()=>{var i,s
|
||||
const n=this,o=n.settings.mode,r=X(e)
|
||||
if((!r||-1===n.items.indexOf(r)||("single"===o&&n.close(),"single"!==o&&n.settings.duplicates))&&null!==r&&n.options.hasOwnProperty(r)&&("single"===o&&n.clear(t),"multi"!==o||!n.isFull())){if(i=n._render("item",n.options[r]),n.control.contains(i)&&(i=i.cloneNode(!0)),s=n.isFull(),n.items.splice(n.caretPos,0,r),n.insertAtCaret(i),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let e=n.getOption(r),t=n.getAdjacent(e,1)
|
||||
t&&n.setActiveOption(t)}n.isPending||n.settings.closeAfterSelect||n.refreshOptions(n.isFocused&&"single"!==o),0!=n.settings.closeAfterSelect&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",r,i),n.isPending||n.updateOriginalInput({silent:t})}(!n.isPending||!s&&n.isFull())&&(n.inputState(),n.refreshState())}}))}removeItem(e=null,t){const i=this
|
||||
if(!(e=i.getItem(e)))return
|
||||
var s,n
|
||||
const o=e.dataset.value
|
||||
s=K(e),e.remove(),e.classList.contains("active")&&(n=i.activeItems.indexOf(e),i.activeItems.splice(n,1),R(e,"active")),i.items.splice(s,1),i.lastQuery=null,!i.settings.persist&&i.userOptions.hasOwnProperty(o)&&i.removeOption(o,t),s<i.caretPos&&i.setCaret(i.caretPos-1),i.updateOriginalInput({silent:t}),i.refreshState(),i.positionDropdown(),i.trigger("item_remove",o,e)}createItem(e=null,t=(()=>{})){3===arguments.length&&(t=arguments[2]),"function"!=typeof t&&(t=()=>{})
|
||||
var i,s=this,n=s.caretPos
|
||||
if(e=e||s.inputValue(),!s.canCreate(e))return t(),!1
|
||||
s.lock()
|
||||
var o=!1,r=e=>{if(s.unlock(),!e||"object"!=typeof e)return t()
|
||||
var i=X(e[s.settings.valueField])
|
||||
if("string"!=typeof i)return t()
|
||||
s.setTextboxValue(),s.addOption(e,!0),s.setCaret(n),s.addItem(i),t(e),o=!0}
|
||||
return i="function"==typeof s.settings.create?s.settings.create.call(this,e,r):{[s.settings.labelField]:e,[s.settings.valueField]:e},o||r(i),!0}refreshItems(){var e=this
|
||||
e.lastQuery=null,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){const e=this
|
||||
e.refreshValidityState()
|
||||
const t=e.isFull(),i=e.isLocked
|
||||
e.wrapper.classList.toggle("rtl",e.rtl)
|
||||
const s=e.wrapper.classList
|
||||
var n
|
||||
s.toggle("focus",e.isFocused),s.toggle("disabled",e.isDisabled),s.toggle("readonly",e.isReadOnly),s.toggle("required",e.isRequired),s.toggle("invalid",!e.isValid),s.toggle("locked",i),s.toggle("full",t),s.toggle("input-active",e.isFocused&&!e.isInputHidden),s.toggle("dropdown-active",e.isOpen),s.toggle("has-options",(n=e.options,0===Object.keys(n).length)),s.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this
|
||||
e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){const t=this
|
||||
var i,s
|
||||
const n=t.input.querySelector('option[value=""]')
|
||||
if(t.is_select_tag){const o=[],r=t.input.querySelectorAll("option:checked").length
|
||||
function l(e,i,s){return e||(e=j('<option value="'+Z(i)+'">'+Z(s)+"</option>")),e!=n&&t.input.append(e),o.push(e),(e!=n||r>0)&&(e.selected=!0),e}t.input.querySelectorAll("option:checked").forEach((e=>{e.selected=!1})),0==t.items.length&&"single"==t.settings.mode?l(n,"",""):t.items.forEach((e=>{if(i=t.options[e],s=i[t.settings.labelField]||"",o.includes(i.$option)){l(t.input.querySelector(`option[value="${re(e)}"]:not(:checked)`),e,s)}else i.$option=l(i.$option,e,s)}))}else t.input.value=t.getValue()
|
||||
t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this
|
||||
e.isLocked||e.isOpen||"multi"===e.settings.mode&&e.isFull()||(e.isOpen=!0,Q(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),q(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),q(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,i=t.isOpen
|
||||
e&&(t.setTextboxValue(),"single"===t.settings.mode&&t.items.length&&t.inputState()),t.isOpen=!1,Q(t.focus_node,{"aria-expanded":"false"}),q(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),i&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var e=this.control,t=e.getBoundingClientRect(),i=e.offsetHeight+t.top+window.scrollY,s=t.left+window.scrollX
|
||||
q(this.dropdown,{width:t.width+"px",top:i+"px",left:s+"px"})}}clear(e){var t=this
|
||||
if(t.items.length){var i=t.controlChildren()
|
||||
E(i,(e=>{t.removeItem(e,!0)})),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){const t=this,i=t.caretPos,s=t.control
|
||||
s.insertBefore(e,s.children[i]||null),t.setCaret(i+1)}deleteSelection(e){var t,i,s,n,o,r=this
|
||||
t=e&&8===e.keyCode?-1:1,i={start:(o=r.control_input).selectionStart||0,length:(o.selectionEnd||0)-(o.selectionStart||0)}
|
||||
const l=[]
|
||||
if(r.activeItems.length)n=B(r.activeItems,t),s=K(n),t>0&&s++,E(r.activeItems,(e=>l.push(e)))
|
||||
else if((r.isFocused||"single"===r.settings.mode)&&r.items.length){const e=r.controlChildren()
|
||||
let s
|
||||
t<0&&0===i.start&&0===i.length?s=e[r.caretPos-1]:t>0&&i.start===r.inputValue().length&&(s=e[r.caretPos]),void 0!==s&&l.push(s)}if(!r.shouldDelete(l,e))return!1
|
||||
for(ie(e,!0),void 0!==s&&r.setCaret(s);l.length;)r.removeItem(l.pop())
|
||||
return r.inputState(),r.positionDropdown(),r.refreshOptions(!1),!0}shouldDelete(e,t){const i=e.map((e=>e.dataset.value))
|
||||
return!(!i.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(i,t))}advanceSelection(e,t){var i,s,n=this
|
||||
n.rtl&&(e*=-1),n.inputValue().length||(ne(J,t)||ne("shiftKey",t)?(s=(i=n.getLastActive(e))?i.classList.contains("active")?n.getAdjacent(i,e,"item"):i:e>0?n.control_input.nextElementSibling:n.control_input.previousElementSibling)&&(s.classList.contains("active")&&n.removeActiveItem(i),n.setActiveItemClass(s)):n.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active")
|
||||
if(t)return t
|
||||
var i=this.control.querySelectorAll(".active")
|
||||
return i?B(i,e):void 0}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings
|
||||
e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,R(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var i,s
|
||||
const n=this
|
||||
if("function"!=typeof this.settings.render[e])return null
|
||||
if(!(s=n.settings.render[e].call(this,t,Z)))return null
|
||||
if(s=j(s),"option"===e||"option_create"===e?t[n.settings.disabledField]?Q(s,{"aria-disabled":"true"}):Q(s,{"data-selectable":""}):"optgroup"===e&&(i=t.group[n.settings.optgroupValueField],Q(s,{"data-group":i}),t.group[n.settings.disabledField]&&Q(s,{"data-disabled":""})),"option"===e||"item"===e){const i=Y(t[n.settings.valueField])
|
||||
Q(s,{"data-value":i}),"item"===e?(D(s,n.settings.itemClass),Q(s,{"data-ts-item":""})):(D(s,n.settings.optionClass),Q(s,{role:"option",id:t.$id}),t.$div=s,n.options[i]=t)}return s}_render(e,t){const i=this.render(e,t)
|
||||
if(null==i)throw"HTMLElement expected"
|
||||
return i}clearCache(){E(this.options,(e=>{e.$div&&(e.$div.remove(),delete e.$div)}))}uncacheValue(e){const t=this.getOption(e)
|
||||
t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,i){var s=this,n=s[t]
|
||||
s[t]=function(){var t,o
|
||||
return"after"===e&&(t=n.apply(s,arguments)),o=i.apply(s,arguments),"instead"===e?o:("before"===e&&(t=n.apply(s,arguments)),t)}}}return de.define("change_listener",(function(){se(this.input,"change",(()=>{this.sync()}))})),de.define("checkbox_options",(function(e){var t=this,i=t.onOptionSelect
|
||||
t.settings.hideSelected=!1
|
||||
const s=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},e)
|
||||
var n=function(e,t){t?(e.checked=!0,s.uncheckedClassNames&&e.classList.remove(...s.uncheckedClassNames),s.checkedClassNames&&e.classList.add(...s.checkedClassNames)):(e.checked=!1,s.checkedClassNames&&e.classList.remove(...s.checkedClassNames),s.uncheckedClassNames&&e.classList.add(...s.uncheckedClassNames))},o=function(e){setTimeout((()=>{var t=e.querySelector("input."+s.className)
|
||||
t instanceof HTMLInputElement&&n(t,e.classList.contains("selected"))}),1)}
|
||||
t.hook("after","setupTemplates",(()=>{var e=t.settings.render.option
|
||||
t.settings.render.option=(i,o)=>{var r=j(e.call(t,i,o)),l=document.createElement("input")
|
||||
s.className&&l.classList.add(s.className),l.addEventListener("click",(function(e){ie(e)})),l.type="checkbox"
|
||||
const a=X(i[t.settings.valueField])
|
||||
return n(l,!!(a&&t.items.indexOf(a)>-1)),r.prepend(l),r}})),t.on("item_remove",(e=>{var i=t.getOption(e)
|
||||
i&&(i.classList.remove("selected"),o(i))})),t.on("item_add",(e=>{var i=t.getOption(e)
|
||||
i&&o(i)})),t.hook("instead","onOptionSelect",((e,s)=>{if(s.classList.contains("selected"))return s.classList.remove("selected"),t.removeItem(s.dataset.value),t.refreshOptions(),void ie(e,!0)
|
||||
i.call(t,e,s),o(s)}))})),de.define("clear_button",(function(e){const t=this,i=Object.assign({className:"clear-button",title:"Clear All",html:e=>`<div class="${e.className}" title="${e.title}">⨯</div>`},e)
|
||||
t.on("initialize",(()=>{var e=j(i.html(i))
|
||||
e.addEventListener("click",(e=>{t.isLocked||(t.clear(),"single"===t.settings.mode&&t.settings.allowEmptyOption&&t.addItem(""),e.preventDefault(),e.stopPropagation())})),t.control.appendChild(e)}))})),de.define("drag_drop",(function(){var e=this
|
||||
if("multi"!==e.settings.mode)return
|
||||
var t=e.lock,i=e.unlock
|
||||
let s,n=!0
|
||||
e.hook("after","setupTemplates",(()=>{var t=e.settings.render.item
|
||||
e.settings.render.item=(i,o)=>{const r=j(t.call(e,i,o))
|
||||
Q(r,{draggable:"true"})
|
||||
const l=e=>{e.preventDefault(),r.classList.add("ts-drag-over"),a(r,s)},a=(e,t)=>{var i,s,n
|
||||
void 0!==t&&(((e,t)=>{do{var i
|
||||
if(e==(t=null==(i=t)?void 0:i.previousElementSibling))return!0}while(t&&t.previousElementSibling)
|
||||
return!1})(t,r)?(s=t,null==(n=(i=e).parentNode)||n.insertBefore(s,i.nextSibling)):((e,t)=>{var i
|
||||
null==(i=e.parentNode)||i.insertBefore(t,e)})(e,t))}
|
||||
return se(r,"mousedown",(e=>{n||ie(e),e.stopPropagation()})),se(r,"dragstart",(e=>{s=r,setTimeout((()=>{r.classList.add("ts-dragging")}),0)})),se(r,"dragenter",l),se(r,"dragover",l),se(r,"dragleave",(()=>{r.classList.remove("ts-drag-over")})),se(r,"dragend",(()=>{var t
|
||||
document.querySelectorAll(".ts-drag-over").forEach((e=>e.classList.remove("ts-drag-over"))),null==(t=s)||t.classList.remove("ts-dragging"),s=void 0
|
||||
var i=[]
|
||||
e.control.querySelectorAll("[data-value]").forEach((e=>{if(e.dataset.value){let t=e.dataset.value
|
||||
t&&i.push(t)}})),e.setValue(i)})),r}})),e.hook("instead","lock",(()=>(n=!1,t.call(e)))),e.hook("instead","unlock",(()=>(n=!0,i.call(e))))})),de.define("dropdown_header",(function(e){const t=this,i=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:e=>'<div class="'+e.headerClass+'"><div class="'+e.titleRowClass+'"><span class="'+e.labelClass+'">'+e.title+'</span><a class="'+e.closeClass+'">×</a></div></div>'},e)
|
||||
t.on("initialize",(()=>{var e=j(i.html(i)),s=e.querySelector("."+i.closeClass)
|
||||
s&&s.addEventListener("click",(e=>{ie(e,!0),t.close()})),t.dropdown.insertBefore(e,t.dropdown.firstChild)}))})),de.define("caret_position",(function(){var e=this
|
||||
e.hook("instead","setCaret",(t=>{"single"!==e.settings.mode&&e.control.contains(e.control_input)?(t=Math.max(0,Math.min(e.items.length,t)))==e.caretPos||e.isPending||e.controlChildren().forEach(((i,s)=>{s<t?e.control_input.insertAdjacentElement("beforebegin",i):e.control.appendChild(i)})):t=e.items.length,e.caretPos=t})),e.hook("instead","moveCaret",(t=>{if(!e.isFocused)return
|
||||
const i=e.getLastActive(t)
|
||||
if(i){const s=K(i)
|
||||
e.setCaret(t>0?s+1:s),e.setActiveItem(),R(i,"last-active")}else e.setCaret(e.caretPos+t)}))})),de.define("dropdown_input",(function(){const e=this
|
||||
e.settings.shouldOpen=!0,e.hook("before","setup",(()=>{e.focus_node=e.control,D(e.control_input,"dropdown-input")
|
||||
const t=j('<div class="dropdown-input-wrap">')
|
||||
t.append(e.control_input),e.dropdown.insertBefore(t,e.dropdown.firstChild)
|
||||
const i=j('<input class="items-placeholder" tabindex="-1" />')
|
||||
i.placeholder=e.settings.placeholder||"",e.control.append(i)})),e.on("initialize",(()=>{e.control_input.addEventListener("keydown",(t=>{switch(t.keyCode){case 27:return e.isOpen&&(ie(t,!0),e.close()),void e.clearActiveItems()
|
||||
case 9:e.focus_node.tabIndex=-1}return e.onKeyDown.call(e,t)})),e.on("blur",(()=>{e.focus_node.tabIndex=e.isDisabled?-1:e.tabIndex})),e.on("dropdown_open",(()=>{e.control_input.focus()}))
|
||||
const t=e.onBlur
|
||||
e.hook("instead","onBlur",(i=>{if(!i||i.relatedTarget!=e.control_input)return t.call(e)})),se(e.control_input,"blur",(()=>e.onBlur())),e.hook("before","close",(()=>{e.isOpen&&e.focus_node.focus({preventScroll:!0})}))}))})),de.define("input_autogrow",(function(){var e=this
|
||||
e.on("initialize",(()=>{var t=document.createElement("span"),i=e.control_input
|
||||
t.style.cssText="position:absolute; top:-99999px; left:-99999px; width:auto; padding:0; white-space:pre; ",e.wrapper.appendChild(t)
|
||||
for(const e of["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"])t.style[e]=i.style[e]
|
||||
var s=()=>{t.textContent=i.value,i.style.width=t.clientWidth+"px"}
|
||||
s(),e.on("update item_add item_remove",s),se(i,"input",s),se(i,"keyup",s),se(i,"blur",s),se(i,"update",s)}))})),de.define("no_backspace_delete",(function(){var e=this,t=e.deleteSelection
|
||||
this.hook("instead","deleteSelection",(i=>!!e.activeItems.length&&t.call(e,i)))})),de.define("no_active_items",(function(){this.hook("instead","setActiveItem",(()=>{})),this.hook("instead","selectAll",(()=>{}))})),de.define("optgroup_columns",(function(){var e=this,t=e.onKeyDown
|
||||
e.hook("instead","onKeyDown",(i=>{var s,n,o,r
|
||||
if(!e.isOpen||37!==i.keyCode&&39!==i.keyCode)return t.call(e,i)
|
||||
e.ignoreHover=!0,r=z(e.activeOption,"[data-group]"),s=K(e.activeOption,"[data-selectable]"),r&&(r=37===i.keyCode?r.previousSibling:r.nextSibling)&&(n=(o=r.querySelectorAll("[data-selectable]"))[Math.min(o.length-1,s)])&&e.setActiveOption(n)}))})),de.define("remove_button",(function(e){const t=Object.assign({label:"×",title:"Remove",className:"remove",append:!0},e)
|
||||
var i=this
|
||||
if(t.append){var s='<a href="javascript:void(0)" class="'+t.className+'" tabindex="-1" title="'+Z(t.title)+'">'+t.label+"</a>"
|
||||
i.hook("after","setupTemplates",(()=>{var e=i.settings.render.item
|
||||
i.settings.render.item=(t,n)=>{var o=j(e.call(i,t,n)),r=j(s)
|
||||
return o.appendChild(r),se(r,"mousedown",(e=>{ie(e,!0)})),se(r,"click",(e=>{i.isLocked||(ie(e,!0),i.isLocked||i.shouldDelete([o],e)&&(i.removeItem(o),i.refreshOptions(!1),i.inputState()))})),o}}))}})),de.define("restore_on_backspace",(function(e){const t=this,i=Object.assign({text:e=>e[t.settings.labelField]},e)
|
||||
t.on("item_remove",(function(e){if(t.isFocused&&""===t.control_input.value.trim()){var s=t.options[e]
|
||||
s&&t.setTextboxValue(i.text.call(t,s))}}))})),de.define("virtual_scroll",(function(){const e=this,t=e.canLoad,i=e.clearActiveOption,s=e.loadCallback
|
||||
var n,o,r={},l=!1,a=[]
|
||||
if(e.settings.shouldLoadMore||(e.settings.shouldLoadMore=()=>{if(n.clientHeight/(n.scrollHeight-n.scrollTop)>.9)return!0
|
||||
if(e.activeOption){var t=e.selectable()
|
||||
if(Array.from(t).indexOf(e.activeOption)>=t.length-2)return!0}return!1}),!e.settings.firstUrl)throw"virtual_scroll plugin requires a firstUrl() method"
|
||||
e.settings.sortField=[{field:"$order"},{field:"$score"}]
|
||||
const c=t=>!("number"==typeof e.settings.maxOptions&&n.children.length>=e.settings.maxOptions)&&!(!(t in r)||!r[t]),d=(t,i)=>e.items.indexOf(i)>=0||a.indexOf(i)>=0
|
||||
e.setNextUrl=(e,t)=>{r[e]=t},e.getUrl=t=>{if(t in r){const e=r[t]
|
||||
return r[t]=!1,e}return e.clearPagination(),e.settings.firstUrl.call(e,t)},e.clearPagination=()=>{r={}},e.hook("instead","clearActiveOption",(()=>{if(!l)return i.call(e)})),e.hook("instead","canLoad",(i=>i in r?c(i):t.call(e,i))),e.hook("instead","loadCallback",((t,i)=>{if(l){if(o){const i=t[0]
|
||||
void 0!==i&&(o.dataset.value=i[e.settings.valueField])}}else e.clearOptions(d)
|
||||
s.call(e,t,i),l=!1})),e.hook("after","refreshOptions",(()=>{const t=e.lastValue
|
||||
var i
|
||||
c(t)?(i=e.render("loading_more",{query:t}))&&(i.setAttribute("data-selectable",""),o=i):t in r&&!n.querySelector(".no-results")&&(i=e.render("no_more_results",{query:t})),i&&(D(i,e.settings.optionClass),n.append(i))})),e.on("initialize",(()=>{a=Object.keys(e.options),n=e.dropdown_content,e.settings.render=Object.assign({},{loading_more:()=>'<div class="loading-more-results">Loading more results ... </div>',no_more_results:()=>'<div class="no-more-results">No more results</div>'},e.settings.render),n.addEventListener("scroll",(()=>{e.settings.shouldLoadMore.call(e)&&c(e.lastValue)&&(l||(l=!0,e.load.call(e,e.lastValue)))}))}))})),de}))
|
||||
var tomSelect=function(e,t){return new TomSelect(e,t)}
|
||||
//# sourceMappingURL=tom-select.complete.min.js.map
|
||||