github.com/shoshinnikita/budget-manager@v0.7.1-0.20220131195411-8c46ff1c6778/templates/month.html (about) 1 <!DOCTYPE html> 2 <html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 <title>{{ .Month.Month }} {{ .Year }} | Budget Manager</title> 8 9 <!-- Theme Switcher --> 10 <script src="{{ asStaticURL `/static/js/theme-switcher.js` }}"></script> 11 12 <link rel="stylesheet" href="{{ asStaticURL `/static/css/common.css` }}"> 13 14 <style> 15 /* | Header */ 16 17 #header { 18 position: relative; 19 } 20 21 #header__buttons { 22 column-gap: 5px; 23 display: grid; 24 grid-template-columns: repeat(2, 1fr); 25 height: 100%; 26 position: absolute; 27 right: 0px; 28 top: 0px; 29 } 30 31 #header__buttons svg { 32 height: 30px; 33 width: 30px; 34 } 35 36 /* | Content */ 37 38 #content { 39 column-gap: 30px; 40 display: grid; 41 height: 100%; 42 row-gap: 20px; 43 } 44 45 .card { 46 overflow-x: auto; 47 } 48 49 .card__body .copy-from-previous-month { 50 height: 30px; 51 margin-top: 30px; 52 text-align: center; 53 } 54 55 .card__body .copy-from-previous-month>input { 56 width: auto; 57 padding: 6px; 58 } 59 60 .table-shrink-cell { 61 width: 1px; 62 white-space: nowrap; 63 } 64 65 th.money, 66 td.money { 67 min-width: 80px; 68 text-align: right; 69 } 70 71 .card__title>.money { 72 font-size: 1rem; 73 } 74 75 /* || Income */ 76 77 #incomes { 78 grid-area: incomes; 79 } 80 81 /* || Monthly Payments */ 82 83 #monthly-payments { 84 grid-area: monthly-payments; 85 } 86 87 /* || Days */ 88 89 #days-info { 90 display: flex; 91 grid-area: days; 92 flex-direction: column; 93 height: 100%; 94 overflow-x: auto; 95 } 96 97 #calendar { 98 display: grid; 99 grid-template-columns: repeat(7, 1fr); 100 column-gap: 10px; 101 row-gap: 10px; 102 } 103 104 .calendar__week-day-name { 105 border-bottom: 1px solid var(--border-color); 106 font-size: 14px; 107 margin: auto; 108 padding: 0 10px; 109 text-align: center; 110 width: min-content; 111 } 112 113 .calendar__day { 114 border: 1px solid var(--border-color); 115 padding: 5px 7px; 116 } 117 118 .calendar__day.disabled { 119 cursor: default; 120 opacity: 0.2; 121 } 122 123 .calendar__day__date { 124 margin-bottom: 5px; 125 } 126 127 .calendar__day__info { 128 white-space: nowrap; 129 } 130 131 .day { 132 /* Hidden by default */ 133 display: none; 134 margin: 20px; 135 } 136 137 .day__result { 138 column-gap: 20px; 139 display: grid; 140 grid-template-columns: repeat(2, max-content); 141 font-size: 18px; 142 justify-content: right; 143 margin: 10px 10px 10px 0; 144 } 145 146 /* | Modal windows */ 147 148 #modal-window__background { 149 background-color: #00000040; 150 display: none; 151 height: 100%; 152 left: 0; 153 overflow-y: auto; 154 position: fixed; 155 top: 0; 156 width: 100%; 157 z-index: 2; 158 } 159 160 .modal-window { 161 box-shadow: 0px 0px 10px 10px #00000020; 162 display: none; 163 margin: 9vh auto 5vh; 164 max-width: 80%; 165 min-width: 700px; 166 overflow-x: auto; 167 width: max-content; 168 } 169 170 .modal-window>.card__title { 171 font-size: 1.4rem; 172 padding: 10px 20px; 173 } 174 175 .modal-window>.card__body { 176 padding: 10px 20px 20px; 177 } 178 179 .modal-window__edit-field { 180 display: grid; 181 grid-template-columns: 100px auto; 182 margin-bottom: 5px; 183 } 184 185 .modal-window__edit-field>select { 186 min-width: 150px; 187 width: 25%; 188 } 189 190 .modal-window__save-button { 191 margin-top: 20px; 192 text-align: center; 193 } 194 195 .modal-window__manage-types__spend-type { 196 column-gap: 10px; 197 display: grid; 198 grid-template-columns: max-content 5px 200px auto; 199 width: min-content; 200 margin: 0 auto 5px; 201 } 202 203 .modal-window__manage-types__spend-type:last-child { 204 margin-bottom: 20px; 205 } 206 207 .modal-window__manage-types__spend-type>span { 208 opacity: 0.5; 209 } 210 211 .modal-window__manage-types__spend-type>.feather-icon>svg { 212 height: 20px; 213 vertical-align: middle; 214 width: 20px; 215 } 216 217 #modal-window__manage-types__saved-msg { 218 display: inline-flex; 219 font-size: 20px; 220 justify-content: center; 221 margin-top: 5px; 222 opacity: 0; 223 transition: opacity 0.25s; 224 width: 100%; 225 } 226 227 .modal-window__manage-types__saved-msg-animation { 228 opacity: 1 !important; 229 } 230 231 #modal-window__manage-types__saved-msg>div.feather-icon { 232 margin-right: 3px; 233 position: relative; 234 width: 20px; 235 } 236 237 #modal-window__manage-types__saved-msg>div.feather-icon>svg { 238 height: 20px; 239 position: absolute; 240 stroke: green; 241 top: 50%; 242 transform: translateY(-43%); 243 width: 20px; 244 } 245 246 #modal-window__manage-types__saved-msg span { 247 color: green; 248 } 249 250 /* | Other */ 251 252 .actions-horizontal-list { 253 display: flex; 254 } 255 256 .add-button>svg { 257 transform: scale(1.2); 258 } 259 260 261 /* | Layouts */ 262 263 /* For medium and large screens (> 1350px) */ 264 @media (min-width: 1351px) { 265 #content { 266 grid-template-columns: 1fr 2fr; 267 grid-template-rows: 1fr 1fr; 268 grid-template-areas: 269 "incomes days" 270 "monthly-payments days"; 271 } 272 } 273 274 /* For medium screens (<= 1550px) */ 275 @media (max-width: 1550px) { 276 277 th.notes, 278 td.notes { 279 display: none; 280 } 281 } 282 283 /* For small screens (<= 1350px) */ 284 @media (max-width: 1350px) { 285 #content { 286 grid-template-columns: 1fr 1fr; 287 grid-template-rows: 2fr 5fr; 288 grid-template-areas: 289 "incomes monthly-payments" 290 "days days"; 291 row-gap: 30px; 292 } 293 294 .card__title, 295 .day__date { 296 font-size: 18px; 297 } 298 299 .calendar__day__date { 300 text-align: center; 301 } 302 303 .calendar__day__info { 304 display: none; 305 } 306 } 307 </style> 308 </head> 309 310 <body> 311 <div id="app"> 312 <div id="header"> 313 <div> 314 <span class="header__path__element"> <a href="/months">Months</a> </span> 315 <span class="header__path__element"> {{ .Year }} </span> 316 <span class="header__path__element"> {{ .Month.Month }} </span> 317 </div> 318 319 <div id="header__buttons"> 320 <!-- Manage Spend Types --> 321 <button class="feather-icon" title="Manage Types" onclick="showModalWindowToEditTypes()"> 322 {{ template "components/icon" "tag" }} 323 </button> 324 325 <!-- Search for Spends --> 326 {{ $after := printf `%d-%02d-01` .Year .Month.Month }} 327 {{ $before := printf `%d-%02d-%d` .Year .Month.Month (len .Month.Days) }} 328 <a href="/search/spends?after={{ $after }}&before={{ $before }}" class="feather-icon" title="Search & Statistics"> 329 {{ template "components/icon" "bar-chart-2" }} 330 </a> 331 </div> 332 </div> 333 334 <div id="content"> 335 <!-- Incomes --> 336 <div id="incomes" class="card"> 337 <div class="card__title noselect"> 338 <span>Incomes</span> 339 <span class="money money--gain">{{ .TotalIncome }}</span> 340 </div> 341 <div class="card__body"> 342 <table> 343 {{ if .Incomes }} 344 <thead> 345 <tr class="noselect"> 346 <th>Title</th> 347 <th class="notes">Notes</th> 348 <th class="money">Income</th> 349 <th></th> 350 </tr> 351 </thead> 352 {{ end }} 353 354 <tbody> 355 {{ range .Incomes }} 356 <tr> 357 <td>{{ .Title }}</td> 358 <td class="notes">{{ .Notes }}</td> 359 <td class="money table-shrink-cell">{{ .Income }}</td> 360 <td class="table-shrink-cell"> 361 <div class="actions-horizontal-list"> 362 <button class="feather-icon" title="Edit" 363 onclick="showModalWindowToEditIncome('{{ .ID }}', '{{ .Title }}', '{{ .Notes }}', '{{ printf `%f` .Income }}')"> 364 {{ template "components/icon" "edit-2" }} 365 </button> 366 <button class="feather-icon" title="Remove" onclick="removeIncome(Number('{{ .ID }}'))"> 367 {{ template "components/icon" "trash" }} 368 </button> 369 </div> 370 </td> 371 </tr> 372 {{ end }} 373 </tbody> 374 375 <tfoot> 376 <tr> 377 <form onsubmit="addIncome()" autocomplete="off"> 378 <td> 379 <input type="text" id="new-income-title" placeholder="Title"> 380 </td> 381 <td class="notes"> 382 <input type="text" id="new-income-notes" placeholder="Notes"> 383 </td> 384 <td class="money table-shrink-cell"> 385 <input type="text" id="new-income-income" placeholder="Income"> 386 </td> 387 <td class="table-shrink-cell"> 388 <div class="actions-horizontal-list"> 389 <button type="submit" class="feather-icon add-button" title="Add"> 390 {{ template "components/icon" "plus" }} 391 </button> 392 </div> 393 </td> 394 </form> 395 </tr> 396 </tfoot> 397 </table> 398 399 {{ if not .Incomes }} 400 <div class="copy-from-previous-month"> 401 <input type="button" value="Copy from previous Month" onclick="copyIncomesFromPreviousMonth()"> 402 </div> 403 {{ end }} 404 </div> 405 </div> 406 407 <!-- Monthly Payments --> 408 <div id="monthly-payments" class="card"> 409 <div class="card__title noselect"> 410 <span>Monthly Payments</span> 411 <span class="money money--lose">{{ .MonthlyPaymentsTotalCost }}</span> 412 </div> 413 <div class="card__body"> 414 <table> 415 {{ if .MonthlyPayments }} 416 <thead> 417 <tr class="noselect"> 418 <th>Title</th> 419 <th class="notes">Notes</th> 420 <th>Type</th> 421 <th class="money">Cost</th> 422 <th></th> 423 </tr> 424 </thead> 425 {{ end }} 426 427 <tbody> 428 {{ range .MonthlyPayments }} 429 <tr> 430 <td>{{ .Title }}</td> 431 <td class="notes">{{ .Notes }}</td> 432 <td class="table-shrink-cell"> 433 {{ if .Type }} 434 {{ .Type.Name }} 435 {{ else }} 436 - 437 {{ end }} 438 </td> 439 <td class="money table-shrink-cell">{{ .Cost }}</td> 440 <td class="table-shrink-cell"> 441 <div class="actions-horizontal-list"> 442 <button class="feather-icon" title="Edit" 443 onclick="showModalWindowToEditMonthlyPayment('{{ .ID }}', '{{ .Title }}', '{{ .Notes }}', 444 '{{ if .Type }}{{ .Type.ID }}{{ else }}0{{ end }}', '{{ printf `%f` .Cost }}')"> 445 {{ template "components/icon" "edit-2" }} 446 </button> 447 <button class="feather-icon" title="Remove" onclick="removeMonthlyPayment(Number('{{ .ID }}'))"> 448 {{ template "components/icon" "trash" }} 449 </button> 450 </div> 451 </td> 452 </tr> 453 {{ end }} 454 </tbody> 455 456 <tfoot> 457 <!-- Inputs for new Monthly Payment --> 458 <tr> 459 <form onsubmit="addMonthlyPayment()" autocomplete="off"> 460 <td> 461 <input type="text" id="new-monthly-payment-title" placeholder="Title"> 462 </td> 463 <td class="notes"> 464 <input type="text" id="new-monthly-payment-notes" placeholder="Notes"> 465 </td> 466 <td class="table-shrink-cell"> 467 <select id="new-monthly-payment-type"> 468 <option value="0">-</option> 469 {{ range $.SpendTypes }} 470 <option value="{{ .ID }}">{{ .FullName }}</option> 471 {{ end }} 472 </select> 473 </td> 474 <td class="money table-shrink-cell"> 475 <input type="text" id="new-monthly-payment-cost" placeholder="Cost"> 476 </td> 477 <td class="table-shrink-cell"> 478 <div class="actions-horizontal-list"> 479 <button type="submit" class="feather-icon add-button" title="Add"> 480 {{ template "components/icon" "plus" }} 481 </button> 482 </div> 483 </td> 484 </form> 485 </tr> 486 </tfoot> 487 </table> 488 489 {{ if not .MonthlyPayments }} 490 <div class="copy-from-previous-month"> 491 <input type="button" value="Copy from previous Month" onclick="copyMonthlyPaymentsFromPreviousMonth()"> 492 </div> 493 {{ end }} 494 </div> 495 </div> 496 497 <!-- Days --> 498 <div id="days-info"> 499 <!-- Calendar --> 500 <div id="calendar"> 501 {{ range .Days }} 502 <a href="#{{ .Day }}" id="id-calendar-day-{{ .Day }}" class="calendar__day card--hover" onclick="selectDay('{{ .Day }}');"> 503 <div class="calendar__day__date"> 504 <span>{{ .Day }} {{ call $.ToShortMonth $.Month.Month }}</span> 505 </div> 506 507 <div class="calendar__day__info"> 508 <span title="Saldo"> 509 {{ if ge .Saldo 0 }} 510 <span class="money--gain">{{ .Saldo }}</span> 511 {{ else }} 512 <span class="money--lose">{{ .Saldo }}</span> 513 {{ end }} 514 </span> 515 <span> · </span> 516 <span title="Spends">{{ len .Spends }}</span> 517 </div> 518 </a> 519 {{ end }} 520 </div> 521 522 <!-- Spends --> 523 {{ range .Days }} 524 <div id="id-day-{{ .Day }}" class="card day"> 525 <div class="card__title"> 526 <span>{{ .Day }} {{ $.Month.Month }}</span> 527 528 <!-- Spends must be always <= 0 --> 529 {{ $totalCost := call $.SumSpendCosts .Spends }} 530 {{ if eq $totalCost 0 }} 531 <span class="money money--gain">{{ $totalCost }}</span> 532 {{ else }} 533 <span class="money money--lose">{{ $totalCost }}</span> 534 {{ end }} 535 </div> 536 537 <div class="card__body"> 538 <table> 539 {{ if .Spends }} 540 <thead> 541 <tr class="noselect"> 542 <th>Title</th> 543 <th class="notes">Notes</th> 544 <th>Type</th> 545 <th class="money">Cost</th> 546 <th></th> 547 </tr> 548 </thead> 549 {{ end }} 550 551 <tbody> 552 {{ range .Spends }} 553 <tr> 554 <td>{{ .Title }}</td> 555 <td class="notes">{{ .Notes }}</td> 556 <td class="table-shrink-cell"> 557 {{ if .Type }} 558 {{ .Type.Name }} 559 {{ else }} 560 - 561 {{ end }} 562 </td> 563 <td class="money table-shrink-cell">{{ .Cost }}</td> 564 <td class="table-shrink-cell"> 565 <div class="actions-horizontal-list"> 566 <button class="feather-icon" title="Edit" 567 onclick="showModalWindowToEditSpend('{{ .ID }}', '{{ .Title }}', '{{ .Notes }}', 568 '{{ if .Type }}{{ .Type.ID }}{{ else }}0{{ end }}', '{{ printf `%f` .Cost }}')"> 569 {{ template "components/icon" "edit-2" }} 570 </button> 571 <button class="feather-icon" title="Remove" onclick="removeSpend(Number('{{ .ID }}'))"> 572 {{ template "components/icon" "trash" }} 573 </button> 574 </div> 575 </td> 576 </tr> 577 {{ end }} 578 </tbody> 579 580 <tfoot> 581 <!-- 582 Inputs for new Spend 583 584 We use '-{{ .ID }}' suffix because we render all days, just hide them. 585 So, ids without this suffix wouldn't be unique. We use day id instead of 586 day number because we pass into 'addSpend' function day id. 587 --> 588 <tr class="day__new-spend" data-day-id="{{ .ID }}"> 589 <form onsubmit="addSpend(Number('{{ .ID }}'))" autocomplete="off"> 590 <td> 591 <input type="text" id="new-spend-title-{{ .ID }}" placeholder="Title" list="spend-datalist-{{ .ID }}"> 592 <datalist id="spend-datalist-{{ .ID }}"></datalist> 593 </td> 594 <td class="notes"> 595 <input type="text" id="new-spend-notes-{{ .ID }}" placeholder="Notes"> 596 </td> 597 <td class="table-shrink-cell"> 598 <select id="new-spend-type-{{ .ID }}"> 599 <option value="0">-</option> 600 {{ range $.SpendTypes }} 601 <option value="{{ .ID }}">{{ .FullName }}</option> 602 {{ end }} 603 </select> 604 </td> 605 <td class="money table-shrink-cell"> 606 <input type="text" id="new-spend-cost-{{ .ID }}" placeholder="Cost"> 607 </td> 608 <td class="table-shrink-cell"> 609 <div class="actions-horizontal-list"> 610 <button type="submit" class="feather-icon add-button" title="Add"> 611 {{ template "components/icon" "plus" }} 612 </button> 613 </div> 614 </td> 615 </form> 616 </tr> 617 </tfoot> 618 </table> 619 </div> 620 </div> 621 {{ end }} 622 </div> 623 </div> 624 625 <!-- Modal windows (hidden by default) --> 626 <div id="modal-window__background"> 627 <!-- Edit Income --> 628 <form id="modal-window__edit-income" class="card modal-window"> 629 <div class="card__title noselect">Edit Income</div> 630 631 <div class="card__body"> 632 <div class="modal-window__edit-field"> 633 <span class="noselect">Title:</span> 634 <input id="modal-window__edit-income__title" type="text"> 635 </div> 636 637 <div class="modal-window__edit-field"> 638 <span class="noselect">Notes:</span> 639 <input id="modal-window__edit-income__notes" type="text"> 640 </div> 641 642 <div class="modal-window__edit-field"> 643 <span class="noselect">Income:</span> 644 <input id="modal-window__edit-income__income" type="text"> 645 </div> 646 647 <div class="modal-window__save-button"> 648 <input type="button" value="Cancel" onclick="hideAllModalWindows()"> 649 <input type="submit" id="modal-window__edit-income__save-button" value="Save"> 650 </div> 651 </div> 652 </form> 653 654 <!-- Edit Monthly Payment --> 655 <form id="modal-window__edit-monthly-payment" class="card modal-window"> 656 <div class="card__title noselect">Edit Monthly Payment</div> 657 658 <div class="card__body"> 659 <div class="modal-window__edit-field"> 660 <span class="noselect">Title:</span> 661 <input id="modal-window__edit-monthly-payment__title" type="text"> 662 </div> 663 664 <div class="modal-window__edit-field"> 665 <span class="noselect">Notes:</span> 666 <input id="modal-window__edit-monthly-payment__notes" type="text"> 667 </div> 668 669 <div class="modal-window__edit-field"> 670 <span class="noselect">Type:</span> 671 <select id="modal-window__edit-monthly-payment__type"> 672 <option value="0">-</option> 673 {{ range $.SpendTypes }} 674 <option value="{{ .ID }}">{{ .FullName }}</option> 675 {{ end }} 676 </select> 677 </div> 678 679 <div class="modal-window__edit-field"> 680 <span class="noselect">Cost:</span> 681 <input id="modal-window__edit-monthly-payment__cost" type="text"> 682 </div> 683 684 <div class="modal-window__save-button"> 685 <input type="button" value="Cancel" onclick="hideAllModalWindows()"> 686 <input type="submit" id="modal-window__edit-monthly-payment__save-button" value="Save"> 687 </div> 688 </div> 689 </form> 690 691 <!-- Edit Spend --> 692 <form id="modal-window__edit-spend" class="card modal-window"> 693 <div class="card__title noselect">Edit Spend</div> 694 695 <div class="card__body"> 696 <div class="modal-window__edit-field"> 697 <span class="noselect">Title:</span> 698 <input id="modal-window__edit-spend__title" type="text"> 699 </div> 700 701 <div class="modal-window__edit-field"> 702 <span class="noselect">Notes:</span> 703 <input id="modal-window__edit-spend__notes" type="text"> 704 </div> 705 706 <div class="modal-window__edit-field"> 707 <span class="noselect">Type:</span> 708 <select id="modal-window__edit-spend__type"> 709 <option value="0">-</option> 710 {{ range $.SpendTypes }} 711 <option value="{{ .ID }}">{{ .FullName }}</option> 712 {{ end }} 713 </select> 714 </div> 715 716 <div class="modal-window__edit-field"> 717 <span class="noselect">Cost:</span> 718 <input id="modal-window__edit-spend__cost" type="text"> 719 </div> 720 721 <div class="modal-window__save-button"> 722 <input type="button" value="Cancel" onclick="hideAllModalWindows()"> 723 <input type="submit" id="modal-window__edit-spend__save-button" value="Save"> 724 </div> 725 </div> 726 </form> 727 728 <!-- Manage types --> 729 <div id="modal-window__manage-types" class="card modal-window"> 730 <div class="card__title noselect">Manage Spend Types</div> 731 732 <div class="card__body"> 733 {{ range .SpendTypes }} 734 <div id="modal-window__manage-types__spend-type-{{ .ID }}" class="modal-window__manage-types__spend-type"> 735 {{ $currentType := . }} 736 <select id="edit-spend-type-parent-id-{{ .ID }}" class="reverse" autocomplete="off" title="Parent Spend Type"> 737 <option value="0"></option> 738 {{ range $.SpendTypes }} 739 740 {{ $attributes := "" }} 741 {{ if eq $currentType.ParentID .ID }} 742 {{ $attributes = (printf "%s %s" $attributes "selected") }} 743 {{ end }} 744 {{ if not (call $.ShouldSuggestSpendType $currentType .) }} 745 {{ $attributes = (printf "%s %s" $attributes "disabled style='color: var(--font-color--disabled)'") }} 746 {{ end }} 747 748 <option value="{{ .ID }}" {{ toHTMLAttr $attributes }}>{{ .FullName }}</option> 749 {{ end }} 750 </select> 751 752 <span class="noselect">/</span> 753 754 <input type="text" id="edit-spend-type-name-{{ .ID }}" value="{{ .Name }}" autocomplete="off" 755 placeholder="{{ .Name }}"> 756 757 <div class="actions-horizontal-list"> 758 <button class="feather-icon" title="Save" onclick="editSpendType('{{ .ID }}')"> 759 {{ template "components/icon" "check" }} 760 </button> 761 <button class="feather-icon" title="Remove" onclick="removeSpendType(Number('{{ .ID }}'))"> 762 {{ template "components/icon" "trash" }} 763 </button> 764 </div> 765 </div> 766 {{ end }} 767 768 <!-- New Spend Type --> 769 <form class="modal-window__manage-types__spend-type" onsubmit="addSpendType()"> 770 <select id="new-spend-type-parent-id" class="reverse" autocomplete="off" title="Parent Spend Type"> 771 <option value="0"></option> 772 {{ range $.SpendTypes }} 773 <option value="{{ .ID }}">{{ .FullName }}</option> 774 {{ end }} 775 </select> 776 777 <span class="noselect">/</span> 778 779 <input type="text" id="new-spend-type-name" placeholder="New Spend Type" autocomplete="off"> 780 781 <div class="actions-horizontal-list"> 782 <button type="submit" class="feather-icon" title="Add"> 783 {{ template "components/icon" "plus" }} 784 </button> 785 <!-- Use this disabled and hidden element to align 'Add' button --> 786 <button class="feather-icon" style="opacity: 0; pointer-events: none;"> 787 {{ template "components/icon" "plus" }} 788 </button> 789 </div> 790 </form> 791 </div> 792 793 <div id="modal-window__manage-types__saved-msg"> 794 <div class="feather-icon"> 795 {{ template "components/icon" "check" }} 796 </div> 797 798 <span>Saved</span> 799 </div> 800 </div> 801 </div> 802 803 {{ template "components/footer.html" .Footer }} 804 </div> 805 806 <script> 807 // ---------------------------------------------------- 808 // Global variables 809 // ---------------------------------------------------- 810 811 const Year = Number("{{ .Year }}") 812 const MonthID = Number("{{ .ID }}"); 813 const MonthNumber = Number("{{ printf `%d` .Month.Month }}") 814 815 // ---------------------------------------------------- 816 // Days 817 // ---------------------------------------------------- 818 819 // Fill weeks in calendar: add days of previous and next months if needed 820 window.addEventListener("load", () => { 821 const calendar = document.getElementById("calendar"); 822 if (calendar === null) { 823 console.error("couldn't get element with id 'calendar'"); 824 return; 825 } 826 827 const createCalendarDay = (dayNumber, monthName) => { 828 const div = document.createElement("div"); 829 div.classList.add("calendar__day", "disabled", "noselect"); 830 831 const info = document.createElement("div"); 832 info.classList.add("calendar__day__date"); 833 834 if (monthName.length > 4) { 835 monthName = monthName.slice(0, 3); 836 } 837 const date = `${dayNumber} ${monthName}`; 838 const dateElem = document.createElement("div"); 839 dateElem.textContent = date; 840 info.append(dateElem); 841 842 div.append(info); 843 844 return div; 845 } 846 847 // Add days of the previous month 848 849 const previousMonth = new Date(Year, MonthNumber - 1, 0); 850 const daysInPreviousMonth = previousMonth.getDate(); 851 const previousMonthName = previousMonth.toLocaleString("default", { month: "long" }); 852 853 // Weeks are started from Monday 854 const monthBeginning = new Date(Year, MonthNumber - 1); 855 const firstDayNumber = (monthBeginning.getDay() + 6) % 7; 856 857 for (let i = 0; i < firstDayNumber; i++) { 858 const div = createCalendarDay(daysInPreviousMonth - i, previousMonthName); 859 calendar.prepend(div); 860 } 861 862 // Add days of the next month 863 864 const nextMonth = new Date(Year, MonthNumber); 865 const nextMonthName = nextMonth.toLocaleString("default", { month: "long" }); 866 867 // Weeks are started from Monday 868 let lastDayNumber = (nextMonth.getDay() + 6) % 7; 869 if (lastDayNumber === 0) { 870 // Special case: month ends on Sunday 871 lastDayNumber = 7; 872 } 873 874 for (let i = 0; i < 7 - lastDayNumber; i++) { 875 const div = createCalendarDay(i + 1, nextMonthName); 876 calendar.append(div); 877 } 878 879 // Add name of week days 880 const weekDayNames = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; 881 const weekDayNamesShort = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; 882 for (let i = weekDayNames.length - 1; i >= 0; i--) { 883 let div = document.createElement("div"); 884 div.classList.add("calendar__week-day-name", "noselect") 885 div.textContent = weekDayNames[i]; 886 calendar.prepend(div); 887 } 888 }) 889 890 // Select day 891 window.addEventListener("load", () => { 892 // Check whether day is passed in URL 893 const passedDay = Number(location.hash.slice(1)); 894 if (passedDay) { 895 const daysInMonth = new Date(Year, MonthNumber, 0).getDate(); 896 if (0 < passedDay && passedDay <= daysInMonth) { 897 // Select passed day 898 selectDay(passedDay); 899 return; 900 } 901 } 902 903 const urlParams = new URLSearchParams(window.location.search); 904 const year = urlParams.get("year"); 905 const month = urlParams.get("month"); 906 907 // Check whether we display the current month 908 const now = new Date(); 909 if (year == now.getFullYear() && month == now.getMonth() + 1) { 910 // Select the current day 911 selectDay(now.getDate()); 912 return; 913 } 914 915 // Just choose the first day 916 selectDay(1); 917 }); 918 919 const calendarDayIDPrefix = "id-calendar-day-"; 920 const calendarDayClass = "calendar__day"; 921 // 922 const dayIDPrefix = "id-day-"; 923 const dayClass = "day"; 924 925 function selectDay(dayNumber) { 926 // Calendar 927 928 // Remove highlights 929 const calendarDays = document.getElementsByClassName(calendarDayClass); 930 for (let i = 0; i < calendarDays.length; i++) { 931 calendarDays[i].style.backgroundColor = ""; 932 } 933 934 // Highlight day in calendar 935 const calendarDayElemID = calendarDayIDPrefix + dayNumber; 936 const calendarDayElem = document.getElementById(calendarDayElemID); 937 if (calendarDayElem === undefined) { 938 console.error(`element with id '${calendarDayElem}' doesn't exist`); 939 } else { 940 calendarDayElem.style.backgroundColor = "var(--hover-color)"; 941 } 942 943 // Chosen day 944 945 // Hide all days at first 946 const days = document.getElementsByClassName(dayClass); 947 for (let i = 0; i < days.length; i++) { 948 days[i].style.display = "none"; 949 } 950 951 // Display chosen day 952 const dayElemID = dayIDPrefix + dayNumber; 953 const dayElem = document.getElementById(dayElemID); 954 if (dayElem === undefined) { 955 console.error(`element with id '${dayElemID}' doesn't exist`); 956 return; 957 } 958 959 dayElem.style.display = "block"; 960 // Set day number in URL 961 location.hash = dayNumber; 962 963 // Focus new Spend title input 964 const newSpendForm = dayElem.querySelector(".day__new-spend"); 965 const dayID = Number(newSpendForm.getAttribute("data-day-id")); 966 const newSpendTitleInput = newSpendForm.querySelector(`#new-spend-title-${dayID}`) 967 newSpendTitleInput.focus(); 968 969 // Scroll to bottom 970 dayElem.scrollTop = dayElem.scrollHeight; 971 } 972 973 // ---------------------------------------------------- 974 // Requests 975 // ---------------------------------------------------- 976 977 // Incomes 978 979 function addIncome() { 980 preventDefault(this); 981 982 const income = replaceCommas(getValue("new-income-income")); 983 if (isNaN(income)) { 984 processError("income must be a number"); 985 return; 986 } 987 988 const fields = { 989 "month_id": MonthID, 990 "title": getValue("new-income-title"), 991 "notes": getValue("new-income-notes"), 992 "income": Number(income), 993 } 994 995 sendRequest("POST", "/api/incomes", fields); 996 } 997 998 function editIncome(id) { 999 preventDefault(this); 1000 1001 const income = replaceCommas(getValue("modal-window__edit-income__income")); 1002 if (isNaN(income)) { 1003 processError("income must be a number"); 1004 return; 1005 } 1006 1007 const fields = { 1008 "id": Number(id), 1009 "title": getValue("modal-window__edit-income__title"), 1010 "notes": getValue("modal-window__edit-income__notes"), 1011 "income": Number(income), 1012 } 1013 1014 sendRequest("PUT", "/api/incomes", fields); 1015 } 1016 1017 function removeIncome(id) { 1018 preventDefault(this); 1019 1020 if (!confirm("Do you really want to delete the Income?")) { 1021 return; 1022 } 1023 1024 const fields = { "id": Number(id) }; 1025 sendRequest("DELETE", "/api/incomes", fields); 1026 } 1027 1028 async function copyIncomesFromPreviousMonth() { 1029 const previousMonth = await getPreviousMonth(); 1030 if (!previousMonth) return; 1031 const incomes = previousMonth.incomes; 1032 1033 for (const income of incomes) { 1034 const fields = { 1035 "month_id": MonthID, 1036 "title": income.title, 1037 "notes": income.notes || "", 1038 "income": income.income, 1039 }; 1040 await sendRequest("POST", "/api/incomes", fields, () => { }); 1041 } 1042 location.reload() 1043 } 1044 1045 // Monthly Payments 1046 1047 function addMonthlyPayment() { 1048 preventDefault(this); 1049 1050 // Skip check because user can't specify typeID as not a number 1051 const typeID = getValue("new-monthly-payment-type"); 1052 1053 const cost = replaceCommas(getValue("new-monthly-payment-cost")); 1054 if (isNaN(cost)) { 1055 processError("cost must be a number"); 1056 return; 1057 } 1058 1059 const fields = { 1060 "month_id": MonthID, 1061 "title": getValue("new-monthly-payment-title"), 1062 "notes": getValue("new-monthly-payment-notes"), 1063 "type_id": Number(typeID), 1064 "cost": Number(cost), 1065 }; 1066 1067 sendRequest("POST", "/api/monthly-payments", fields); 1068 } 1069 1070 function editMonthlyPayment(id) { 1071 preventDefault(this); 1072 1073 // Skip check because user can't specify typeID as not a number 1074 const typeID = getValue("modal-window__edit-monthly-payment__type"); 1075 1076 const cost = replaceCommas(getValue("modal-window__edit-monthly-payment__cost")); 1077 if (isNaN(cost)) { 1078 processError("cost must be a number"); 1079 return; 1080 } 1081 1082 const fields = { 1083 "id": Number(id), 1084 "title": getValue("modal-window__edit-monthly-payment__title"), 1085 "notes": getValue("modal-window__edit-monthly-payment__notes"), 1086 "type_id": Number(typeID), 1087 "cost": Number(cost), 1088 } 1089 1090 sendRequest("PUT", "/api/monthly-payments", fields); 1091 } 1092 1093 function removeMonthlyPayment(id) { 1094 preventDefault(this); 1095 1096 if (!confirm("Do you really want to delete the Monthly Payment?")) { 1097 return; 1098 } 1099 1100 const fields = { "id": Number(id) }; 1101 sendRequest("DELETE", "/api/monthly-payments", fields); 1102 } 1103 1104 async function copyMonthlyPaymentsFromPreviousMonth() { 1105 const previousMonth = await getPreviousMonth(); 1106 if (!previousMonth) return; 1107 const monthlyPayments = previousMonth.monthly_payments; 1108 1109 for (const payment of monthlyPayments) { 1110 const fields = { 1111 "month_id": MonthID, 1112 "title": payment.title, 1113 "notes": payment.notes || "", 1114 "type_id": payment.type ? payment.type.id : 0, 1115 "cost": payment.cost, 1116 }; 1117 await sendRequest("POST", "/api/monthly-payments", fields, () => { }); 1118 } 1119 location.reload() 1120 } 1121 1122 // Spends 1123 1124 function addSpend(dayID) { 1125 preventDefault(this); 1126 1127 // Skip check because user can't specify typeID as not a number 1128 const typeID = getValue(`new-spend-type-${dayID}`); 1129 1130 const cost = replaceCommas(getValue(`new-spend-cost-${dayID}`)); 1131 if (isNaN(cost)) { 1132 processError("cost must be a number"); 1133 return; 1134 } 1135 1136 const fields = { 1137 "day_id": Number(dayID), 1138 "title": getValue(`new-spend-title-${dayID}`), 1139 "notes": getValue(`new-spend-notes-${dayID}`), 1140 "type_id": Number(typeID), 1141 "cost": Number(cost), 1142 }; 1143 1144 sendRequest("POST", "/api/spends", fields); 1145 } 1146 1147 function editSpend(id) { 1148 preventDefault(this); 1149 1150 // Skip check because user can't specify typeID as not a number 1151 const typeID = getValue("modal-window__edit-spend__type"); 1152 1153 const cost = replaceCommas(getValue("modal-window__edit-spend__cost")); 1154 if (isNaN(cost)) { 1155 processError("cost must be a number"); 1156 return; 1157 } 1158 1159 const fields = { 1160 "id": Number(id), 1161 "title": getValue("modal-window__edit-spend__title"), 1162 "notes": getValue("modal-window__edit-spend__notes"), 1163 "type_id": Number(typeID), 1164 "cost": Number(cost), 1165 } 1166 1167 sendRequest("PUT", "/api/spends", fields); 1168 } 1169 1170 function removeSpend(id) { 1171 preventDefault(this); 1172 1173 if (!confirm("Do you really want to delete the Spend?")) { 1174 return; 1175 } 1176 1177 const fields = { "id": Number(id) }; 1178 sendRequest("DELETE", "/api/spends", fields); 1179 } 1180 1181 // Spend Types 1182 1183 // If spendTypeChanged is true, page will be reloaded after closing Modal Window 1184 var spendTypeChanged = false; 1185 1186 function addSpendType() { 1187 preventDefault(this); 1188 1189 const name = getValue("new-spend-type-name"); 1190 if (name === "") { 1191 processError("Name of Spend Type can't be empty"); 1192 return; 1193 } 1194 1195 // Skip check because user can't specify parent id as not a number 1196 const parentID = getValue("new-spend-type-parent-id"); 1197 1198 const fields = { 1199 "name": name, 1200 "parent_id": Number(parentID) 1201 } 1202 1203 // Reload on success 1204 sendRequest("POST", "/api/spend-types", fields); 1205 } 1206 1207 function editSpendType(id) { 1208 preventDefault(this); 1209 1210 const name = getValue(`edit-spend-type-name-${id}`); 1211 if (name === "") { 1212 processError("Name of Spend Type can't be empty"); 1213 return; 1214 } 1215 1216 // Skip check because user can't specify parent id as not a number 1217 const parentID = getValue(`edit-spend-type-parent-id-${id}`); 1218 1219 const fields = { 1220 "id": Number(id), 1221 "name": name, 1222 "parent_id": Number(parentID), 1223 } 1224 1225 const handler = () => { 1226 spendTypeChanged = true; 1227 showSpendTypeSavedMessage(); 1228 } 1229 sendRequest("PUT", "/api/spend-types", fields, handler); 1230 } 1231 1232 function removeSpendType(id) { 1233 if (!confirm("Do you really want to delete the Spend Type?")) { 1234 return; 1235 } 1236 1237 const fields = { 1238 "id": Number(id), 1239 } 1240 1241 const hideRemovedSpendType = () => { 1242 spendTypeChanged = true; 1243 1244 const elemID = `modal-window__manage-types__spend-type-${id}`; 1245 const elem = document.getElementById(elemID); 1246 if (!elem) { 1247 return; 1248 } 1249 elem.style.display = "none"; 1250 } 1251 sendRequest("DELETE", "/api/spend-types", fields, hideRemovedSpendType); 1252 } 1253 1254 async function getPreviousMonth() { 1255 let year = Year, month = MonthNumber - 1; 1256 if (month === 0) { 1257 year--; 1258 month = 12; 1259 } 1260 1261 const params = new URLSearchParams({ "year": year, "month": month }); 1262 return fetch("/api/months/date?" + params.toString()). 1263 then(rawResp => rawResp.json()). 1264 then(resp => { 1265 if (!resp.success) throw resp.error; 1266 return resp.month; 1267 }). 1268 catch(err => processError(err)) 1269 } 1270 1271 /** 1272 * @param {string} method - HTTP method (POST or PUT) 1273 * @param {string} url - request url 1274 * @param {Object} fields - additional json fields (like month id, day id and etc.) 1275 * @param {Function} successHandler - success handler (page are reloaded by default) 1276 */ 1277 async function sendRequest(method, url, fields, successHandler = () => { location.reload() }) { 1278 return fetch(url, { 1279 method: method, 1280 headers: { "Content-Type": "application/json" }, 1281 body: JSON.stringify(fields || null) 1282 }). 1283 then(rawResp => rawResp.json()). 1284 then(resp => { 1285 if (!resp.success) throw resp.error; 1286 1287 successHandler(); 1288 1289 }).catch(err => processError(err)); 1290 } 1291 1292 // ---------------------------------------------------- 1293 // Modal windows 1294 // ---------------------------------------------------- 1295 1296 // Add event listener to hide modal window on click outside of Modal Window 1297 window.addEventListener("load", () => { 1298 const elem = document.getElementById(modalWindowID); 1299 if (!elem) { 1300 console.error(`element with id '${modalWindowID}' doesn't exist`); 1301 return 1302 } 1303 1304 // Hide on click 1305 elem.addEventListener("click", (ev) => { 1306 if (ev.target.id !== modalWindowID) { 1307 return; 1308 } 1309 hideAllModalWindows(); 1310 }) 1311 }); 1312 1313 // Add event listener to hide modal window on Esc key pressing 1314 window.addEventListener("keydown", (ev) => { 1315 const escKeyCode = 27; 1316 1317 if (ev.keyCode === escKeyCode) { 1318 hideAllModalWindows(); 1319 } 1320 }) 1321 1322 const modalWindowID = "modal-window__background"; 1323 const editIncomeModalWindowID = "modal-window__edit-income"; 1324 const editMonthlyPaymentModalWindowID = "modal-window__edit-monthly-payment"; 1325 const editSpendModalWindowID = "modal-window__edit-spend"; 1326 const manageSpendTypesModalWindowID = "modal-window__manage-types"; 1327 1328 /** 1329 * @param {string} id - Income id 1330 * @param {string} title - current Income title 1331 * @param {string} notes - current Income notes 1332 * @param {string} income - current income 1333 */ 1334 function showModalWindowToEditIncome(id, title, notes, income) { 1335 hideAllModalWindows(); 1336 blurBackground(); 1337 1338 // Set current values 1339 setValue("modal-window__edit-income__title", title); 1340 setValue("modal-window__edit-income__notes", notes); 1341 setValue("modal-window__edit-income__income", income); 1342 1343 // Reset event listener 1344 const form = document.getElementById(editIncomeModalWindowID); 1345 form.onsubmit = () => { editIncome(id); } 1346 1347 // Show Modal Window 1348 showElement(editIncomeModalWindowID); 1349 showElement(modalWindowID); 1350 } 1351 1352 /** 1353 * @param {string} id - Monthly Payment id 1354 * @param {string} title - current Monthly Payment title 1355 * @param {string} notes - current Monthly Payment notes 1356 * @param {string} typeID - current Monthly Payment type id 1357 * @param {string} cost - current Monthly Payment cost 1358 */ 1359 function showModalWindowToEditMonthlyPayment(id, title, notes, typeID, cost) { 1360 hideAllModalWindows(); 1361 blurBackground(); 1362 1363 // Set current values 1364 setValue("modal-window__edit-monthly-payment__title", title); 1365 setValue("modal-window__edit-monthly-payment__notes", notes); 1366 setValue("modal-window__edit-monthly-payment__type", typeID); 1367 setValue("modal-window__edit-monthly-payment__cost", cost); 1368 1369 // Reset event listener 1370 const form = document.getElementById(editMonthlyPaymentModalWindowID); 1371 form.onsubmit = () => { editMonthlyPayment(id); } 1372 1373 // Show Modal Window 1374 showElement(editMonthlyPaymentModalWindowID); 1375 showElement(modalWindowID); 1376 } 1377 1378 /** 1379 * @param {string} id - Spend id 1380 * @param {string} title - current Spend title 1381 * @param {string} notes - current Spend notes 1382 * @param {string} typeID - current Spend type id 1383 * @param {string} cost - current Spend cost 1384 */ 1385 function showModalWindowToEditSpend(id, title, notes, typeID, cost) { 1386 hideAllModalWindows(); 1387 blurBackground(); 1388 1389 // Set current values 1390 setValue("modal-window__edit-spend__title", title); 1391 setValue("modal-window__edit-spend__notes", notes); 1392 setValue("modal-window__edit-spend__type", typeID); 1393 setValue("modal-window__edit-spend__cost", cost); 1394 1395 // Reset event listener 1396 const form = document.getElementById(editSpendModalWindowID); 1397 form.onsubmit = () => { editSpend(id); } 1398 1399 // Show Modal Window 1400 showElement(editSpendModalWindowID); 1401 showElement(modalWindowID); 1402 } 1403 1404 var spendTypeSaveMessageAnimationTimeoutID = 0; 1405 1406 function showSpendTypeSavedMessage() { 1407 const elemID = "modal-window__manage-types__saved-msg"; 1408 const animationClassName = elemID + "-animation"; 1409 1410 const elem = document.getElementById(elemID); 1411 if (!elem) return; 1412 1413 if (spendTypeSaveMessageAnimationTimeoutID) { 1414 // Reset timeout 1415 clearTimeout(spendTypeSaveMessageAnimationTimeoutID); 1416 } 1417 1418 elem.classList.add(animationClassName); 1419 1420 spendTypeSaveMessageAnimationTimeoutID = setTimeout(() => { 1421 elem.classList.remove(animationClassName); 1422 spendTypeSaveMessageAnimationTimeoutID = 0; 1423 }, 1000) 1424 } 1425 1426 function showModalWindowToEditTypes() { 1427 hideAllModalWindows(); 1428 blurBackground(); 1429 1430 // Show Modal Window 1431 showElement(manageSpendTypesModalWindowID); 1432 showElement(modalWindowID); 1433 } 1434 1435 /** 1436 * hideAllModalWindows hides all Modal Windows 1437 */ 1438 function hideAllModalWindows() { 1439 unblurBackground(); 1440 1441 hideElement(modalWindowID); 1442 hideElement(editIncomeModalWindowID); 1443 hideElement(editMonthlyPaymentModalWindowID); 1444 hideElement(editSpendModalWindowID); 1445 hideElement(manageSpendTypesModalWindowID); 1446 1447 if (spendTypeChanged) { 1448 // Reload page to display updated Spend Types 1449 location.reload(); 1450 } 1451 } 1452 1453 function blurBackground() { 1454 document.getElementById("header").classList.add("blurred"); 1455 document.getElementById("content").classList.add("blurred"); 1456 document.getElementById("footer").classList.add("blurred"); 1457 } 1458 1459 function unblurBackground() { 1460 document.getElementById("header").classList.remove("blurred"); 1461 document.getElementById("content").classList.remove("blurred"); 1462 document.getElementById("footer").classList.remove("blurred"); 1463 } 1464 1465 // ---------------------------------------------------- 1466 // Helpers 1467 // ---------------------------------------------------- 1468 1469 /** 1470 * preventDefault prevent the default action if it is possible 1471 */ 1472 function preventDefault(that) { 1473 if (that.event && that.event.preventDefault) { 1474 that.event.preventDefault(); 1475 } 1476 } 1477 1478 /** 1479 * @param {string} elemID - id of an element 1480 * @return {Object} value of an element or empty string 1481 */ 1482 function getValue(elemID) { 1483 const elem = document.getElementById(elemID); 1484 if (elem === undefined) { 1485 console.error(`element with id '${elemID}' doesn't exist`); 1486 return ""; 1487 } 1488 1489 return elem.value || ""; 1490 } 1491 1492 /** 1493 * @param {string} elemID - id of an element 1494 * @param {string} value - new value element 1495 */ 1496 function setValue(elemID, value) { 1497 const elem = document.getElementById(elemID); 1498 if (elem === undefined) { 1499 console.error(`element with id '${elemID}' doesn't exist`); 1500 return; 1501 } 1502 1503 elem.value = value; 1504 } 1505 1506 /** resetValues resets values of passed elements 1507 * 1508 * @param {...string} elemIDs - list of element ids 1509 */ 1510 function resetValues(...elemIDs) { 1511 for (let i = 0; i < elemIDs.length; i++) { 1512 /** 1513 * @type {HTMLInputElement} 1514 */ 1515 const elem = document.getElementById(elemIDs[i]); 1516 if (elem === undefined) { 1517 console.error(`element with id '${elemIDs[i]}' doesn't exist`) 1518 continue; 1519 } 1520 1521 elem.value = ""; 1522 } 1523 } 1524 1525 /** showElement sets 'display' to 'block' 1526 * 1527 * @param {string} elemID - element id 1528 */ 1529 function showElement(elemID) { 1530 const elem = document.getElementById(elemID); 1531 if (!elem) { 1532 console.error(`element with id '${elemID}' doesn't exist`); 1533 return 1534 } 1535 1536 elem.style.display = "block"; 1537 } 1538 1539 /** hideElement sets 'display' to 'none' 1540 * 1541 * @param {string} elemID - element id 1542 */ 1543 function hideElement(elemID) { 1544 const elem = document.getElementById(elemID); 1545 if (!elem) { 1546 console.error(`element with id '${elemID}' doesn't exist`); 1547 return 1548 } 1549 1550 elem.style.display = "none"; 1551 } 1552 1553 function processError(error) { 1554 console.error(error); 1555 alert("Error: " + error); 1556 } 1557 1558 /** replaceCommas replaces commas in passed string with points: 'qwe,rty,uio' -> 'qwe.rty,uio'. 1559 * It should be used to fix decimal separator: '15,5' -> '15.5' 1560 * 1561 * @param {string} s 1562 * @return {string} string with replaced commas 1563 */ 1564 function replaceCommas(s) { 1565 return s.replace(",", "."); 1566 } 1567 1568 </script> 1569 1570 <!-- Link Formatter --> 1571 <script src="{{ asStaticURL `/static/js/link-formatter.js` }}"></script> 1572 <script> 1573 window.addEventListener("load", () => { 1574 formatLinks("td.notes"); 1575 }) 1576 </script> 1577 1578 <!-- Spend Autocompletion --> 1579 <script> 1580 // A special character which is added to option values in <datalist>. 1581 // Use 'three-per-em space' - https://en.wikipedia.org/wiki/Whitespace_character 1582 const suggestionMark = " "; 1583 const suggestionRegexp = new RegExp(`${suggestionMark}•${suggestionMark}\\d+`); 1584 1585 /** 1586 * @param {string} title 1587 * @return {boolean} 1588 */ 1589 function isSuggestion(title) { 1590 return suggestionRegexp.test(title); 1591 } 1592 1593 /** 1594 * @param {string} title 1595 * @param {number} count 1596 * @return {string} 1597 */ 1598 function addSuggestionMark(title, count) { 1599 return title + `${suggestionMark}•${suggestionMark}${count}`; 1600 } 1601 1602 /** 1603 * @param {string} title 1604 * @return {string} 1605 */ 1606 function removeSuggestionMark(title) { 1607 return title.replace(suggestionRegexp, ""); 1608 } 1609 1610 class SpendAutocompleter { 1611 /** 1612 * @typedef {Object} Spend 1613 * @property {string} title 1614 * @property {Object} type 1615 * @property {number} type.id 1616 * @property {string} type.name 1617 * @property {number} cost 1618 */ 1619 1620 /** 1621 * @typedef {Object} Counter 1622 * @property {number} count 1623 * 1624 * @typedef {Spend & Counter} SpendWithCounter 1625 */ 1626 1627 /** 1628 * @param {number} dayID 1629 */ 1630 constructor(dayID) { 1631 this.dayID = dayID; 1632 /** @type {SpendWithCounter[]} */ 1633 this.cachedSpends = []; 1634 /** @type {?number} */ 1635 this.getSpendsTimeoutID = null; 1636 1637 this.titleInput = document.getElementById("new-spend-title-" + dayID); 1638 this.titleInput.addEventListener("input", this.oninputListener.bind(this)); 1639 1640 this.notesInput = document.getElementById("new-spend-notes-" + dayID); 1641 this.typeSelector = document.getElementById("new-spend-type-" + dayID); 1642 this.costInput = document.getElementById("new-spend-cost-" + dayID); 1643 1644 this.spendDatalist = document.getElementById("spend-datalist-" + this.dayID); 1645 } 1646 1647 /** 1648 * @param ev {Event} 1649 */ 1650 oninputListener(ev) { 1651 const title = ev.target.value; 1652 1653 if (isSuggestion(title)) { 1654 // User chose a suggestion. Remove the suggestion mark and complete it 1655 ev.target.value = removeSuggestionMark(title); 1656 this.completeSuggestion(ev.target.value) 1657 return; 1658 } 1659 1660 if (title.length < 3) { 1661 this.clearSuggestionList(); 1662 return; 1663 } 1664 1665 if (this.getSpendsTimeoutID) { 1666 clearTimeout(this.getSpendsTimeoutID); 1667 } 1668 this.getSpendsTimeoutID = setTimeout(async () => { 1669 this.cachedSpends = await this.getSpends(title); 1670 this.updateSuggestionList(); 1671 }, 100); 1672 } 1673 1674 /** 1675 * Get spends with passed title and unite duplicates (leave only newest). Spends are sorted by frequency 1676 * @param {string} title 1677 * @return {SpendWithCounter[]} 1678 */ 1679 async getSpends(title) { 1680 const spends = await this.searchSpendsViaAPI(title); 1681 1682 // Unite duplicates 1683 1684 /** @type {Object.<string, SpendWithCounter>} */ 1685 const uniqueSpends = {}; 1686 for (let spend of spends) { 1687 if (uniqueSpends[spend.title]) { 1688 uniqueSpends[spend.title].count++; 1689 continue; 1690 } 1691 uniqueSpends[spend.title] = spend; 1692 uniqueSpends[spend.title].count = 1; 1693 } 1694 1695 // Sort by frequency 1696 1697 /** @type {SpendWithCounter[]} */ 1698 const sortedSpends = []; 1699 for (let title in uniqueSpends) { 1700 sortedSpends.push(uniqueSpends[title]) 1701 } 1702 sortedSpends.sort((a, b) => { 1703 if (a.count !== b.count) { 1704 return a.count < b.count; 1705 } 1706 return a.title > b.title; 1707 }); 1708 1709 return sortedSpends; 1710 } 1711 1712 /** 1713 * Search spends with passed title via API. Spends are sorted by date from newest to oldest 1714 * @param {string} title 1715 * @return {Spend[]} 1716 */ 1717 async searchSpendsViaAPI(title) { 1718 const params = new URLSearchParams({ "title": title, "sort": "date", "order": "desc" }); 1719 1720 const spends = await fetch("/api/search/spends?" + params.toString()). 1721 then(rawResp => rawResp.json()). 1722 then(resp => { 1723 if (!resp.success) throw resp.error; 1724 return resp.spends; 1725 }). 1726 catch(err => console.error(err)); 1727 1728 return spends || []; 1729 } 1730 1731 /** 1732 * Update <datalist> with cached spends. Option values are marked with suggestion mark 1733 */ 1734 updateSuggestionList() { 1735 this.clearSuggestionList(); 1736 1737 for (let spend of this.cachedSpends) { 1738 const option = document.createElement("option"); 1739 option.value = addSuggestionMark(spend.title, spend.count); 1740 this.spendDatalist.appendChild(option); 1741 } 1742 } 1743 1744 clearSuggestionList() { 1745 this.spendDatalist.innerHTML = ""; 1746 } 1747 1748 /** 1749 * Complete the suggestion by setting spend type and cost 1750 * @param {string} title - Title without suggestion mark 1751 */ 1752 completeSuggestion(title) { 1753 for (let spend of this.cachedSpends) { 1754 if (spend.title != title) continue; 1755 1756 this.typeSelector.value = spend.type ? spend.type.id : 0; 1757 this.notesInput.value = spend.notes || ""; 1758 this.costInput.value = spend.cost; 1759 break; 1760 } 1761 } 1762 } 1763 1764 window.addEventListener("load", () => { 1765 const elements = document.getElementsByClassName("day__new-spend"); 1766 for (let elem of elements) { 1767 const dayID = Number(elem.getAttribute("data-day-id")); 1768 new SpendAutocompleter(dayID); 1769 } 1770 }) 1771 </script> 1772 </body> 1773 1774 </html>