first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Copy the real .env here manually - it is NEVER included in the build.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
Binary file not shown.
BIN
View File
Binary file not shown.
+342
View File
@@ -0,0 +1,342 @@
StartFontMetrics 4.1
Comment Copyright (c) 1989, 1990, 1991, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
Comment Creation Date: Mon Jun 23 16:28:00 1997
Comment UniqueID 43048
Comment VMusage 41139 52164
FontName Courier-Bold
FullName Courier Bold
FamilyName Courier
Weight Bold
ItalicAngle 0
IsFixedPitch true
CharacterSet ExtendedRoman
FontBBox -113 -250 749 801
UnderlinePosition -100
UnderlineThickness 50
Version 003.000
Notice Copyright (c) 1989, 1990, 1991, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
EncodingScheme AdobeStandardEncoding
CapHeight 562
XHeight 439
Ascender 629
Descender -157
StdHW 84
StdVW 106
StartCharMetrics 315
C 32 ; WX 600 ; N space ; B 0 0 0 0 ;
C 33 ; WX 600 ; N exclam ; B 202 -15 398 572 ;
C 34 ; WX 600 ; N quotedbl ; B 135 277 465 562 ;
C 35 ; WX 600 ; N numbersign ; B 56 -45 544 651 ;
C 36 ; WX 600 ; N dollar ; B 82 -126 519 666 ;
C 37 ; WX 600 ; N percent ; B 5 -15 595 616 ;
C 38 ; WX 600 ; N ampersand ; B 36 -15 546 543 ;
C 39 ; WX 600 ; N quoteright ; B 171 277 423 562 ;
C 40 ; WX 600 ; N parenleft ; B 219 -102 461 616 ;
C 41 ; WX 600 ; N parenright ; B 139 -102 381 616 ;
C 42 ; WX 600 ; N asterisk ; B 91 219 509 601 ;
C 43 ; WX 600 ; N plus ; B 71 39 529 478 ;
C 44 ; WX 600 ; N comma ; B 123 -111 393 174 ;
C 45 ; WX 600 ; N hyphen ; B 100 203 500 313 ;
C 46 ; WX 600 ; N period ; B 192 -15 408 171 ;
C 47 ; WX 600 ; N slash ; B 98 -77 502 626 ;
C 48 ; WX 600 ; N zero ; B 87 -15 513 616 ;
C 49 ; WX 600 ; N one ; B 81 0 539 616 ;
C 50 ; WX 600 ; N two ; B 61 0 499 616 ;
C 51 ; WX 600 ; N three ; B 63 -15 501 616 ;
C 52 ; WX 600 ; N four ; B 53 0 507 616 ;
C 53 ; WX 600 ; N five ; B 70 -15 521 601 ;
C 54 ; WX 600 ; N six ; B 90 -15 521 616 ;
C 55 ; WX 600 ; N seven ; B 55 0 494 601 ;
C 56 ; WX 600 ; N eight ; B 83 -15 517 616 ;
C 57 ; WX 600 ; N nine ; B 79 -15 510 616 ;
C 58 ; WX 600 ; N colon ; B 191 -15 407 425 ;
C 59 ; WX 600 ; N semicolon ; B 123 -111 408 425 ;
C 60 ; WX 600 ; N less ; B 66 15 523 501 ;
C 61 ; WX 600 ; N equal ; B 71 118 529 398 ;
C 62 ; WX 600 ; N greater ; B 77 15 534 501 ;
C 63 ; WX 600 ; N question ; B 98 -14 501 580 ;
C 64 ; WX 600 ; N at ; B 16 -15 584 616 ;
C 65 ; WX 600 ; N A ; B -9 0 609 562 ;
C 66 ; WX 600 ; N B ; B 30 0 573 562 ;
C 67 ; WX 600 ; N C ; B 22 -18 560 580 ;
C 68 ; WX 600 ; N D ; B 30 0 594 562 ;
C 69 ; WX 600 ; N E ; B 25 0 560 562 ;
C 70 ; WX 600 ; N F ; B 39 0 570 562 ;
C 71 ; WX 600 ; N G ; B 22 -18 594 580 ;
C 72 ; WX 600 ; N H ; B 20 0 580 562 ;
C 73 ; WX 600 ; N I ; B 77 0 523 562 ;
C 74 ; WX 600 ; N J ; B 37 -18 601 562 ;
C 75 ; WX 600 ; N K ; B 21 0 599 562 ;
C 76 ; WX 600 ; N L ; B 39 0 578 562 ;
C 77 ; WX 600 ; N M ; B -2 0 602 562 ;
C 78 ; WX 600 ; N N ; B 8 -12 610 562 ;
C 79 ; WX 600 ; N O ; B 22 -18 578 580 ;
C 80 ; WX 600 ; N P ; B 48 0 559 562 ;
C 81 ; WX 600 ; N Q ; B 32 -138 578 580 ;
C 82 ; WX 600 ; N R ; B 24 0 599 562 ;
C 83 ; WX 600 ; N S ; B 47 -22 553 582 ;
C 84 ; WX 600 ; N T ; B 21 0 579 562 ;
C 85 ; WX 600 ; N U ; B 4 -18 596 562 ;
C 86 ; WX 600 ; N V ; B -13 0 613 562 ;
C 87 ; WX 600 ; N W ; B -18 0 618 562 ;
C 88 ; WX 600 ; N X ; B 12 0 588 562 ;
C 89 ; WX 600 ; N Y ; B 12 0 589 562 ;
C 90 ; WX 600 ; N Z ; B 62 0 539 562 ;
C 91 ; WX 600 ; N bracketleft ; B 245 -102 475 616 ;
C 92 ; WX 600 ; N backslash ; B 99 -77 503 626 ;
C 93 ; WX 600 ; N bracketright ; B 125 -102 355 616 ;
C 94 ; WX 600 ; N asciicircum ; B 108 250 492 616 ;
C 95 ; WX 600 ; N underscore ; B 0 -125 600 -75 ;
C 96 ; WX 600 ; N quoteleft ; B 178 277 428 562 ;
C 97 ; WX 600 ; N a ; B 35 -15 570 454 ;
C 98 ; WX 600 ; N b ; B 0 -15 584 626 ;
C 99 ; WX 600 ; N c ; B 40 -15 545 459 ;
C 100 ; WX 600 ; N d ; B 20 -15 591 626 ;
C 101 ; WX 600 ; N e ; B 40 -15 563 454 ;
C 102 ; WX 600 ; N f ; B 83 0 547 626 ; L i fi ; L l fl ;
C 103 ; WX 600 ; N g ; B 30 -146 580 454 ;
C 104 ; WX 600 ; N h ; B 5 0 592 626 ;
C 105 ; WX 600 ; N i ; B 77 0 523 658 ;
C 106 ; WX 600 ; N j ; B 63 -146 440 658 ;
C 107 ; WX 600 ; N k ; B 20 0 585 626 ;
C 108 ; WX 600 ; N l ; B 77 0 523 626 ;
C 109 ; WX 600 ; N m ; B -22 0 626 454 ;
C 110 ; WX 600 ; N n ; B 18 0 592 454 ;
C 111 ; WX 600 ; N o ; B 30 -15 570 454 ;
C 112 ; WX 600 ; N p ; B -1 -142 570 454 ;
C 113 ; WX 600 ; N q ; B 20 -142 591 454 ;
C 114 ; WX 600 ; N r ; B 47 0 580 454 ;
C 115 ; WX 600 ; N s ; B 68 -17 535 459 ;
C 116 ; WX 600 ; N t ; B 47 -15 532 562 ;
C 117 ; WX 600 ; N u ; B -1 -15 569 439 ;
C 118 ; WX 600 ; N v ; B -1 0 601 439 ;
C 119 ; WX 600 ; N w ; B -18 0 618 439 ;
C 120 ; WX 600 ; N x ; B 6 0 594 439 ;
C 121 ; WX 600 ; N y ; B -4 -142 601 439 ;
C 122 ; WX 600 ; N z ; B 81 0 520 439 ;
C 123 ; WX 600 ; N braceleft ; B 160 -102 464 616 ;
C 124 ; WX 600 ; N bar ; B 255 -250 345 750 ;
C 125 ; WX 600 ; N braceright ; B 136 -102 440 616 ;
C 126 ; WX 600 ; N asciitilde ; B 71 153 530 356 ;
C 161 ; WX 600 ; N exclamdown ; B 202 -146 398 449 ;
C 162 ; WX 600 ; N cent ; B 66 -49 518 614 ;
C 163 ; WX 600 ; N sterling ; B 72 -28 558 611 ;
C 164 ; WX 600 ; N fraction ; B 25 -60 576 661 ;
C 165 ; WX 600 ; N yen ; B 10 0 590 562 ;
C 166 ; WX 600 ; N florin ; B -30 -131 572 616 ;
C 167 ; WX 600 ; N section ; B 83 -70 517 580 ;
C 168 ; WX 600 ; N currency ; B 54 49 546 517 ;
C 169 ; WX 600 ; N quotesingle ; B 227 277 373 562 ;
C 170 ; WX 600 ; N quotedblleft ; B 71 277 535 562 ;
C 171 ; WX 600 ; N guillemotleft ; B 8 70 553 446 ;
C 172 ; WX 600 ; N guilsinglleft ; B 141 70 459 446 ;
C 173 ; WX 600 ; N guilsinglright ; B 141 70 459 446 ;
C 174 ; WX 600 ; N fi ; B 12 0 593 626 ;
C 175 ; WX 600 ; N fl ; B 12 0 593 626 ;
C 177 ; WX 600 ; N endash ; B 65 203 535 313 ;
C 178 ; WX 600 ; N dagger ; B 106 -70 494 580 ;
C 179 ; WX 600 ; N daggerdbl ; B 106 -70 494 580 ;
C 180 ; WX 600 ; N periodcentered ; B 196 165 404 351 ;
C 182 ; WX 600 ; N paragraph ; B 6 -70 576 580 ;
C 183 ; WX 600 ; N bullet ; B 140 132 460 430 ;
C 184 ; WX 600 ; N quotesinglbase ; B 175 -142 427 143 ;
C 185 ; WX 600 ; N quotedblbase ; B 65 -142 529 143 ;
C 186 ; WX 600 ; N quotedblright ; B 61 277 525 562 ;
C 187 ; WX 600 ; N guillemotright ; B 47 70 592 446 ;
C 188 ; WX 600 ; N ellipsis ; B 26 -15 574 116 ;
C 189 ; WX 600 ; N perthousand ; B -113 -15 713 616 ;
C 191 ; WX 600 ; N questiondown ; B 99 -146 502 449 ;
C 193 ; WX 600 ; N grave ; B 132 508 395 661 ;
C 194 ; WX 600 ; N acute ; B 205 508 468 661 ;
C 195 ; WX 600 ; N circumflex ; B 103 483 497 657 ;
C 196 ; WX 600 ; N tilde ; B 89 493 512 636 ;
C 197 ; WX 600 ; N macron ; B 88 505 512 585 ;
C 198 ; WX 600 ; N breve ; B 83 468 517 631 ;
C 199 ; WX 600 ; N dotaccent ; B 230 498 370 638 ;
C 200 ; WX 600 ; N dieresis ; B 128 498 472 638 ;
C 202 ; WX 600 ; N ring ; B 198 481 402 678 ;
C 203 ; WX 600 ; N cedilla ; B 205 -206 387 0 ;
C 205 ; WX 600 ; N hungarumlaut ; B 68 488 588 661 ;
C 206 ; WX 600 ; N ogonek ; B 169 -199 400 0 ;
C 207 ; WX 600 ; N caron ; B 103 493 497 667 ;
C 208 ; WX 600 ; N emdash ; B -10 203 610 313 ;
C 225 ; WX 600 ; N AE ; B -29 0 602 562 ;
C 227 ; WX 600 ; N ordfeminine ; B 147 196 453 580 ;
C 232 ; WX 600 ; N Lslash ; B 39 0 578 562 ;
C 233 ; WX 600 ; N Oslash ; B 22 -22 578 584 ;
C 234 ; WX 600 ; N OE ; B -25 0 595 562 ;
C 235 ; WX 600 ; N ordmasculine ; B 147 196 453 580 ;
C 241 ; WX 600 ; N ae ; B -4 -15 601 454 ;
C 245 ; WX 600 ; N dotlessi ; B 77 0 523 439 ;
C 248 ; WX 600 ; N lslash ; B 77 0 523 626 ;
C 249 ; WX 600 ; N oslash ; B 30 -24 570 463 ;
C 250 ; WX 600 ; N oe ; B -18 -15 611 454 ;
C 251 ; WX 600 ; N germandbls ; B 22 -15 596 626 ;
C -1 ; WX 600 ; N Idieresis ; B 77 0 523 761 ;
C -1 ; WX 600 ; N eacute ; B 40 -15 563 661 ;
C -1 ; WX 600 ; N abreve ; B 35 -15 570 661 ;
C -1 ; WX 600 ; N uhungarumlaut ; B -1 -15 628 661 ;
C -1 ; WX 600 ; N ecaron ; B 40 -15 563 667 ;
C -1 ; WX 600 ; N Ydieresis ; B 12 0 589 761 ;
C -1 ; WX 600 ; N divide ; B 71 16 529 500 ;
C -1 ; WX 600 ; N Yacute ; B 12 0 589 784 ;
C -1 ; WX 600 ; N Acircumflex ; B -9 0 609 780 ;
C -1 ; WX 600 ; N aacute ; B 35 -15 570 661 ;
C -1 ; WX 600 ; N Ucircumflex ; B 4 -18 596 780 ;
C -1 ; WX 600 ; N yacute ; B -4 -142 601 661 ;
C -1 ; WX 600 ; N scommaaccent ; B 68 -250 535 459 ;
C -1 ; WX 600 ; N ecircumflex ; B 40 -15 563 657 ;
C -1 ; WX 600 ; N Uring ; B 4 -18 596 801 ;
C -1 ; WX 600 ; N Udieresis ; B 4 -18 596 761 ;
C -1 ; WX 600 ; N aogonek ; B 35 -199 586 454 ;
C -1 ; WX 600 ; N Uacute ; B 4 -18 596 784 ;
C -1 ; WX 600 ; N uogonek ; B -1 -199 585 439 ;
C -1 ; WX 600 ; N Edieresis ; B 25 0 560 761 ;
C -1 ; WX 600 ; N Dcroat ; B 30 0 594 562 ;
C -1 ; WX 600 ; N commaaccent ; B 205 -250 397 -57 ;
C -1 ; WX 600 ; N copyright ; B 0 -18 600 580 ;
C -1 ; WX 600 ; N Emacron ; B 25 0 560 708 ;
C -1 ; WX 600 ; N ccaron ; B 40 -15 545 667 ;
C -1 ; WX 600 ; N aring ; B 35 -15 570 678 ;
C -1 ; WX 600 ; N Ncommaaccent ; B 8 -250 610 562 ;
C -1 ; WX 600 ; N lacute ; B 77 0 523 801 ;
C -1 ; WX 600 ; N agrave ; B 35 -15 570 661 ;
C -1 ; WX 600 ; N Tcommaaccent ; B 21 -250 579 562 ;
C -1 ; WX 600 ; N Cacute ; B 22 -18 560 784 ;
C -1 ; WX 600 ; N atilde ; B 35 -15 570 636 ;
C -1 ; WX 600 ; N Edotaccent ; B 25 0 560 761 ;
C -1 ; WX 600 ; N scaron ; B 68 -17 535 667 ;
C -1 ; WX 600 ; N scedilla ; B 68 -206 535 459 ;
C -1 ; WX 600 ; N iacute ; B 77 0 523 661 ;
C -1 ; WX 600 ; N lozenge ; B 66 0 534 740 ;
C -1 ; WX 600 ; N Rcaron ; B 24 0 599 790 ;
C -1 ; WX 600 ; N Gcommaaccent ; B 22 -250 594 580 ;
C -1 ; WX 600 ; N ucircumflex ; B -1 -15 569 657 ;
C -1 ; WX 600 ; N acircumflex ; B 35 -15 570 657 ;
C -1 ; WX 600 ; N Amacron ; B -9 0 609 708 ;
C -1 ; WX 600 ; N rcaron ; B 47 0 580 667 ;
C -1 ; WX 600 ; N ccedilla ; B 40 -206 545 459 ;
C -1 ; WX 600 ; N Zdotaccent ; B 62 0 539 761 ;
C -1 ; WX 600 ; N Thorn ; B 48 0 557 562 ;
C -1 ; WX 600 ; N Omacron ; B 22 -18 578 708 ;
C -1 ; WX 600 ; N Racute ; B 24 0 599 784 ;
C -1 ; WX 600 ; N Sacute ; B 47 -22 553 784 ;
C -1 ; WX 600 ; N dcaron ; B 20 -15 727 626 ;
C -1 ; WX 600 ; N Umacron ; B 4 -18 596 708 ;
C -1 ; WX 600 ; N uring ; B -1 -15 569 678 ;
C -1 ; WX 600 ; N threesuperior ; B 138 222 433 616 ;
C -1 ; WX 600 ; N Ograve ; B 22 -18 578 784 ;
C -1 ; WX 600 ; N Agrave ; B -9 0 609 784 ;
C -1 ; WX 600 ; N Abreve ; B -9 0 609 784 ;
C -1 ; WX 600 ; N multiply ; B 81 39 520 478 ;
C -1 ; WX 600 ; N uacute ; B -1 -15 569 661 ;
C -1 ; WX 600 ; N Tcaron ; B 21 0 579 790 ;
C -1 ; WX 600 ; N partialdiff ; B 63 -38 537 728 ;
C -1 ; WX 600 ; N ydieresis ; B -4 -142 601 638 ;
C -1 ; WX 600 ; N Nacute ; B 8 -12 610 784 ;
C -1 ; WX 600 ; N icircumflex ; B 73 0 523 657 ;
C -1 ; WX 600 ; N Ecircumflex ; B 25 0 560 780 ;
C -1 ; WX 600 ; N adieresis ; B 35 -15 570 638 ;
C -1 ; WX 600 ; N edieresis ; B 40 -15 563 638 ;
C -1 ; WX 600 ; N cacute ; B 40 -15 545 661 ;
C -1 ; WX 600 ; N nacute ; B 18 0 592 661 ;
C -1 ; WX 600 ; N umacron ; B -1 -15 569 585 ;
C -1 ; WX 600 ; N Ncaron ; B 8 -12 610 790 ;
C -1 ; WX 600 ; N Iacute ; B 77 0 523 784 ;
C -1 ; WX 600 ; N plusminus ; B 71 24 529 515 ;
C -1 ; WX 600 ; N brokenbar ; B 255 -175 345 675 ;
C -1 ; WX 600 ; N registered ; B 0 -18 600 580 ;
C -1 ; WX 600 ; N Gbreve ; B 22 -18 594 784 ;
C -1 ; WX 600 ; N Idotaccent ; B 77 0 523 761 ;
C -1 ; WX 600 ; N summation ; B 15 -10 586 706 ;
C -1 ; WX 600 ; N Egrave ; B 25 0 560 784 ;
C -1 ; WX 600 ; N racute ; B 47 0 580 661 ;
C -1 ; WX 600 ; N omacron ; B 30 -15 570 585 ;
C -1 ; WX 600 ; N Zacute ; B 62 0 539 784 ;
C -1 ; WX 600 ; N Zcaron ; B 62 0 539 790 ;
C -1 ; WX 600 ; N greaterequal ; B 26 0 523 696 ;
C -1 ; WX 600 ; N Eth ; B 30 0 594 562 ;
C -1 ; WX 600 ; N Ccedilla ; B 22 -206 560 580 ;
C -1 ; WX 600 ; N lcommaaccent ; B 77 -250 523 626 ;
C -1 ; WX 600 ; N tcaron ; B 47 -15 532 703 ;
C -1 ; WX 600 ; N eogonek ; B 40 -199 563 454 ;
C -1 ; WX 600 ; N Uogonek ; B 4 -199 596 562 ;
C -1 ; WX 600 ; N Aacute ; B -9 0 609 784 ;
C -1 ; WX 600 ; N Adieresis ; B -9 0 609 761 ;
C -1 ; WX 600 ; N egrave ; B 40 -15 563 661 ;
C -1 ; WX 600 ; N zacute ; B 81 0 520 661 ;
C -1 ; WX 600 ; N iogonek ; B 77 -199 523 658 ;
C -1 ; WX 600 ; N Oacute ; B 22 -18 578 784 ;
C -1 ; WX 600 ; N oacute ; B 30 -15 570 661 ;
C -1 ; WX 600 ; N amacron ; B 35 -15 570 585 ;
C -1 ; WX 600 ; N sacute ; B 68 -17 535 661 ;
C -1 ; WX 600 ; N idieresis ; B 77 0 523 618 ;
C -1 ; WX 600 ; N Ocircumflex ; B 22 -18 578 780 ;
C -1 ; WX 600 ; N Ugrave ; B 4 -18 596 784 ;
C -1 ; WX 600 ; N Delta ; B 6 0 594 688 ;
C -1 ; WX 600 ; N thorn ; B -14 -142 570 626 ;
C -1 ; WX 600 ; N twosuperior ; B 143 230 436 616 ;
C -1 ; WX 600 ; N Odieresis ; B 22 -18 578 761 ;
C -1 ; WX 600 ; N mu ; B -1 -142 569 439 ;
C -1 ; WX 600 ; N igrave ; B 77 0 523 661 ;
C -1 ; WX 600 ; N ohungarumlaut ; B 30 -15 668 661 ;
C -1 ; WX 600 ; N Eogonek ; B 25 -199 576 562 ;
C -1 ; WX 600 ; N dcroat ; B 20 -15 591 626 ;
C -1 ; WX 600 ; N threequarters ; B -47 -60 648 661 ;
C -1 ; WX 600 ; N Scedilla ; B 47 -206 553 582 ;
C -1 ; WX 600 ; N lcaron ; B 77 0 597 626 ;
C -1 ; WX 600 ; N Kcommaaccent ; B 21 -250 599 562 ;
C -1 ; WX 600 ; N Lacute ; B 39 0 578 784 ;
C -1 ; WX 600 ; N trademark ; B -9 230 749 562 ;
C -1 ; WX 600 ; N edotaccent ; B 40 -15 563 638 ;
C -1 ; WX 600 ; N Igrave ; B 77 0 523 784 ;
C -1 ; WX 600 ; N Imacron ; B 77 0 523 708 ;
C -1 ; WX 600 ; N Lcaron ; B 39 0 637 562 ;
C -1 ; WX 600 ; N onehalf ; B -47 -60 648 661 ;
C -1 ; WX 600 ; N lessequal ; B 26 0 523 696 ;
C -1 ; WX 600 ; N ocircumflex ; B 30 -15 570 657 ;
C -1 ; WX 600 ; N ntilde ; B 18 0 592 636 ;
C -1 ; WX 600 ; N Uhungarumlaut ; B 4 -18 638 784 ;
C -1 ; WX 600 ; N Eacute ; B 25 0 560 784 ;
C -1 ; WX 600 ; N emacron ; B 40 -15 563 585 ;
C -1 ; WX 600 ; N gbreve ; B 30 -146 580 661 ;
C -1 ; WX 600 ; N onequarter ; B -56 -60 656 661 ;
C -1 ; WX 600 ; N Scaron ; B 47 -22 553 790 ;
C -1 ; WX 600 ; N Scommaaccent ; B 47 -250 553 582 ;
C -1 ; WX 600 ; N Ohungarumlaut ; B 22 -18 628 784 ;
C -1 ; WX 600 ; N degree ; B 86 243 474 616 ;
C -1 ; WX 600 ; N ograve ; B 30 -15 570 661 ;
C -1 ; WX 600 ; N Ccaron ; B 22 -18 560 790 ;
C -1 ; WX 600 ; N ugrave ; B -1 -15 569 661 ;
C -1 ; WX 600 ; N radical ; B -19 -104 473 778 ;
C -1 ; WX 600 ; N Dcaron ; B 30 0 594 790 ;
C -1 ; WX 600 ; N rcommaaccent ; B 47 -250 580 454 ;
C -1 ; WX 600 ; N Ntilde ; B 8 -12 610 759 ;
C -1 ; WX 600 ; N otilde ; B 30 -15 570 636 ;
C -1 ; WX 600 ; N Rcommaaccent ; B 24 -250 599 562 ;
C -1 ; WX 600 ; N Lcommaaccent ; B 39 -250 578 562 ;
C -1 ; WX 600 ; N Atilde ; B -9 0 609 759 ;
C -1 ; WX 600 ; N Aogonek ; B -9 -199 625 562 ;
C -1 ; WX 600 ; N Aring ; B -9 0 609 801 ;
C -1 ; WX 600 ; N Otilde ; B 22 -18 578 759 ;
C -1 ; WX 600 ; N zdotaccent ; B 81 0 520 638 ;
C -1 ; WX 600 ; N Ecaron ; B 25 0 560 790 ;
C -1 ; WX 600 ; N Iogonek ; B 77 -199 523 562 ;
C -1 ; WX 600 ; N kcommaaccent ; B 20 -250 585 626 ;
C -1 ; WX 600 ; N minus ; B 71 203 529 313 ;
C -1 ; WX 600 ; N Icircumflex ; B 77 0 523 780 ;
C -1 ; WX 600 ; N ncaron ; B 18 0 592 667 ;
C -1 ; WX 600 ; N tcommaaccent ; B 47 -250 532 562 ;
C -1 ; WX 600 ; N logicalnot ; B 71 103 529 413 ;
C -1 ; WX 600 ; N odieresis ; B 30 -15 570 638 ;
C -1 ; WX 600 ; N udieresis ; B -1 -15 569 638 ;
C -1 ; WX 600 ; N notequal ; B 12 -47 537 563 ;
C -1 ; WX 600 ; N gcommaaccent ; B 30 -146 580 714 ;
C -1 ; WX 600 ; N eth ; B 58 -27 543 626 ;
C -1 ; WX 600 ; N zcaron ; B 81 0 520 667 ;
C -1 ; WX 600 ; N ncommaaccent ; B 18 -250 592 454 ;
C -1 ; WX 600 ; N onesuperior ; B 153 230 447 616 ;
C -1 ; WX 600 ; N imacron ; B 77 0 523 585 ;
C -1 ; WX 600 ; N Euro ; B 0 0 0 0 ;
EndCharMetrics
EndFontMetrics
+342
View File
@@ -0,0 +1,342 @@
StartFontMetrics 4.1
Comment Copyright (c) 1989, 1990, 1991, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
Comment Creation Date: Mon Jun 23 16:28:46 1997
Comment UniqueID 43049
Comment VMusage 17529 79244
FontName Courier-BoldOblique
FullName Courier Bold Oblique
FamilyName Courier
Weight Bold
ItalicAngle -12
IsFixedPitch true
CharacterSet ExtendedRoman
FontBBox -57 -250 869 801
UnderlinePosition -100
UnderlineThickness 50
Version 003.000
Notice Copyright (c) 1989, 1990, 1991, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
EncodingScheme AdobeStandardEncoding
CapHeight 562
XHeight 439
Ascender 629
Descender -157
StdHW 84
StdVW 106
StartCharMetrics 315
C 32 ; WX 600 ; N space ; B 0 0 0 0 ;
C 33 ; WX 600 ; N exclam ; B 215 -15 495 572 ;
C 34 ; WX 600 ; N quotedbl ; B 211 277 585 562 ;
C 35 ; WX 600 ; N numbersign ; B 88 -45 641 651 ;
C 36 ; WX 600 ; N dollar ; B 87 -126 630 666 ;
C 37 ; WX 600 ; N percent ; B 101 -15 625 616 ;
C 38 ; WX 600 ; N ampersand ; B 61 -15 595 543 ;
C 39 ; WX 600 ; N quoteright ; B 229 277 543 562 ;
C 40 ; WX 600 ; N parenleft ; B 265 -102 592 616 ;
C 41 ; WX 600 ; N parenright ; B 117 -102 444 616 ;
C 42 ; WX 600 ; N asterisk ; B 179 219 598 601 ;
C 43 ; WX 600 ; N plus ; B 114 39 596 478 ;
C 44 ; WX 600 ; N comma ; B 99 -111 430 174 ;
C 45 ; WX 600 ; N hyphen ; B 143 203 567 313 ;
C 46 ; WX 600 ; N period ; B 206 -15 427 171 ;
C 47 ; WX 600 ; N slash ; B 90 -77 626 626 ;
C 48 ; WX 600 ; N zero ; B 135 -15 593 616 ;
C 49 ; WX 600 ; N one ; B 93 0 562 616 ;
C 50 ; WX 600 ; N two ; B 61 0 594 616 ;
C 51 ; WX 600 ; N three ; B 71 -15 571 616 ;
C 52 ; WX 600 ; N four ; B 81 0 559 616 ;
C 53 ; WX 600 ; N five ; B 77 -15 621 601 ;
C 54 ; WX 600 ; N six ; B 135 -15 652 616 ;
C 55 ; WX 600 ; N seven ; B 147 0 622 601 ;
C 56 ; WX 600 ; N eight ; B 115 -15 604 616 ;
C 57 ; WX 600 ; N nine ; B 75 -15 592 616 ;
C 58 ; WX 600 ; N colon ; B 205 -15 480 425 ;
C 59 ; WX 600 ; N semicolon ; B 99 -111 481 425 ;
C 60 ; WX 600 ; N less ; B 120 15 613 501 ;
C 61 ; WX 600 ; N equal ; B 96 118 614 398 ;
C 62 ; WX 600 ; N greater ; B 97 15 589 501 ;
C 63 ; WX 600 ; N question ; B 183 -14 592 580 ;
C 64 ; WX 600 ; N at ; B 65 -15 642 616 ;
C 65 ; WX 600 ; N A ; B -9 0 632 562 ;
C 66 ; WX 600 ; N B ; B 30 0 630 562 ;
C 67 ; WX 600 ; N C ; B 74 -18 675 580 ;
C 68 ; WX 600 ; N D ; B 30 0 664 562 ;
C 69 ; WX 600 ; N E ; B 25 0 670 562 ;
C 70 ; WX 600 ; N F ; B 39 0 684 562 ;
C 71 ; WX 600 ; N G ; B 74 -18 675 580 ;
C 72 ; WX 600 ; N H ; B 20 0 700 562 ;
C 73 ; WX 600 ; N I ; B 77 0 643 562 ;
C 74 ; WX 600 ; N J ; B 58 -18 721 562 ;
C 75 ; WX 600 ; N K ; B 21 0 692 562 ;
C 76 ; WX 600 ; N L ; B 39 0 636 562 ;
C 77 ; WX 600 ; N M ; B -2 0 722 562 ;
C 78 ; WX 600 ; N N ; B 8 -12 730 562 ;
C 79 ; WX 600 ; N O ; B 74 -18 645 580 ;
C 80 ; WX 600 ; N P ; B 48 0 643 562 ;
C 81 ; WX 600 ; N Q ; B 83 -138 636 580 ;
C 82 ; WX 600 ; N R ; B 24 0 617 562 ;
C 83 ; WX 600 ; N S ; B 54 -22 673 582 ;
C 84 ; WX 600 ; N T ; B 86 0 679 562 ;
C 85 ; WX 600 ; N U ; B 101 -18 716 562 ;
C 86 ; WX 600 ; N V ; B 84 0 733 562 ;
C 87 ; WX 600 ; N W ; B 79 0 738 562 ;
C 88 ; WX 600 ; N X ; B 12 0 690 562 ;
C 89 ; WX 600 ; N Y ; B 109 0 709 562 ;
C 90 ; WX 600 ; N Z ; B 62 0 637 562 ;
C 91 ; WX 600 ; N bracketleft ; B 223 -102 606 616 ;
C 92 ; WX 600 ; N backslash ; B 222 -77 496 626 ;
C 93 ; WX 600 ; N bracketright ; B 103 -102 486 616 ;
C 94 ; WX 600 ; N asciicircum ; B 171 250 556 616 ;
C 95 ; WX 600 ; N underscore ; B -27 -125 585 -75 ;
C 96 ; WX 600 ; N quoteleft ; B 297 277 487 562 ;
C 97 ; WX 600 ; N a ; B 61 -15 593 454 ;
C 98 ; WX 600 ; N b ; B 13 -15 636 626 ;
C 99 ; WX 600 ; N c ; B 81 -15 631 459 ;
C 100 ; WX 600 ; N d ; B 60 -15 645 626 ;
C 101 ; WX 600 ; N e ; B 81 -15 605 454 ;
C 102 ; WX 600 ; N f ; B 83 0 677 626 ; L i fi ; L l fl ;
C 103 ; WX 600 ; N g ; B 40 -146 674 454 ;
C 104 ; WX 600 ; N h ; B 18 0 615 626 ;
C 105 ; WX 600 ; N i ; B 77 0 546 658 ;
C 106 ; WX 600 ; N j ; B 36 -146 580 658 ;
C 107 ; WX 600 ; N k ; B 33 0 643 626 ;
C 108 ; WX 600 ; N l ; B 77 0 546 626 ;
C 109 ; WX 600 ; N m ; B -22 0 649 454 ;
C 110 ; WX 600 ; N n ; B 18 0 615 454 ;
C 111 ; WX 600 ; N o ; B 71 -15 622 454 ;
C 112 ; WX 600 ; N p ; B -32 -142 622 454 ;
C 113 ; WX 600 ; N q ; B 60 -142 685 454 ;
C 114 ; WX 600 ; N r ; B 47 0 655 454 ;
C 115 ; WX 600 ; N s ; B 66 -17 608 459 ;
C 116 ; WX 600 ; N t ; B 118 -15 567 562 ;
C 117 ; WX 600 ; N u ; B 70 -15 592 439 ;
C 118 ; WX 600 ; N v ; B 70 0 695 439 ;
C 119 ; WX 600 ; N w ; B 53 0 712 439 ;
C 120 ; WX 600 ; N x ; B 6 0 671 439 ;
C 121 ; WX 600 ; N y ; B -21 -142 695 439 ;
C 122 ; WX 600 ; N z ; B 81 0 614 439 ;
C 123 ; WX 600 ; N braceleft ; B 203 -102 595 616 ;
C 124 ; WX 600 ; N bar ; B 201 -250 505 750 ;
C 125 ; WX 600 ; N braceright ; B 114 -102 506 616 ;
C 126 ; WX 600 ; N asciitilde ; B 120 153 590 356 ;
C 161 ; WX 600 ; N exclamdown ; B 196 -146 477 449 ;
C 162 ; WX 600 ; N cent ; B 121 -49 605 614 ;
C 163 ; WX 600 ; N sterling ; B 106 -28 650 611 ;
C 164 ; WX 600 ; N fraction ; B 22 -60 708 661 ;
C 165 ; WX 600 ; N yen ; B 98 0 710 562 ;
C 166 ; WX 600 ; N florin ; B -57 -131 702 616 ;
C 167 ; WX 600 ; N section ; B 74 -70 620 580 ;
C 168 ; WX 600 ; N currency ; B 77 49 644 517 ;
C 169 ; WX 600 ; N quotesingle ; B 303 277 493 562 ;
C 170 ; WX 600 ; N quotedblleft ; B 190 277 594 562 ;
C 171 ; WX 600 ; N guillemotleft ; B 62 70 639 446 ;
C 172 ; WX 600 ; N guilsinglleft ; B 195 70 545 446 ;
C 173 ; WX 600 ; N guilsinglright ; B 165 70 514 446 ;
C 174 ; WX 600 ; N fi ; B 12 0 644 626 ;
C 175 ; WX 600 ; N fl ; B 12 0 644 626 ;
C 177 ; WX 600 ; N endash ; B 108 203 602 313 ;
C 178 ; WX 600 ; N dagger ; B 175 -70 586 580 ;
C 179 ; WX 600 ; N daggerdbl ; B 121 -70 587 580 ;
C 180 ; WX 600 ; N periodcentered ; B 248 165 461 351 ;
C 182 ; WX 600 ; N paragraph ; B 61 -70 700 580 ;
C 183 ; WX 600 ; N bullet ; B 196 132 523 430 ;
C 184 ; WX 600 ; N quotesinglbase ; B 144 -142 458 143 ;
C 185 ; WX 600 ; N quotedblbase ; B 34 -142 560 143 ;
C 186 ; WX 600 ; N quotedblright ; B 119 277 645 562 ;
C 187 ; WX 600 ; N guillemotright ; B 71 70 647 446 ;
C 188 ; WX 600 ; N ellipsis ; B 35 -15 587 116 ;
C 189 ; WX 600 ; N perthousand ; B -45 -15 743 616 ;
C 191 ; WX 600 ; N questiondown ; B 100 -146 509 449 ;
C 193 ; WX 600 ; N grave ; B 272 508 503 661 ;
C 194 ; WX 600 ; N acute ; B 312 508 609 661 ;
C 195 ; WX 600 ; N circumflex ; B 212 483 607 657 ;
C 196 ; WX 600 ; N tilde ; B 199 493 643 636 ;
C 197 ; WX 600 ; N macron ; B 195 505 637 585 ;
C 198 ; WX 600 ; N breve ; B 217 468 652 631 ;
C 199 ; WX 600 ; N dotaccent ; B 348 498 493 638 ;
C 200 ; WX 600 ; N dieresis ; B 246 498 595 638 ;
C 202 ; WX 600 ; N ring ; B 319 481 528 678 ;
C 203 ; WX 600 ; N cedilla ; B 168 -206 368 0 ;
C 205 ; WX 600 ; N hungarumlaut ; B 171 488 729 661 ;
C 206 ; WX 600 ; N ogonek ; B 143 -199 367 0 ;
C 207 ; WX 600 ; N caron ; B 238 493 633 667 ;
C 208 ; WX 600 ; N emdash ; B 33 203 677 313 ;
C 225 ; WX 600 ; N AE ; B -29 0 708 562 ;
C 227 ; WX 600 ; N ordfeminine ; B 188 196 526 580 ;
C 232 ; WX 600 ; N Lslash ; B 39 0 636 562 ;
C 233 ; WX 600 ; N Oslash ; B 48 -22 673 584 ;
C 234 ; WX 600 ; N OE ; B 26 0 701 562 ;
C 235 ; WX 600 ; N ordmasculine ; B 188 196 543 580 ;
C 241 ; WX 600 ; N ae ; B 21 -15 652 454 ;
C 245 ; WX 600 ; N dotlessi ; B 77 0 546 439 ;
C 248 ; WX 600 ; N lslash ; B 77 0 587 626 ;
C 249 ; WX 600 ; N oslash ; B 54 -24 638 463 ;
C 250 ; WX 600 ; N oe ; B 18 -15 662 454 ;
C 251 ; WX 600 ; N germandbls ; B 22 -15 629 626 ;
C -1 ; WX 600 ; N Idieresis ; B 77 0 643 761 ;
C -1 ; WX 600 ; N eacute ; B 81 -15 609 661 ;
C -1 ; WX 600 ; N abreve ; B 61 -15 658 661 ;
C -1 ; WX 600 ; N uhungarumlaut ; B 70 -15 769 661 ;
C -1 ; WX 600 ; N ecaron ; B 81 -15 633 667 ;
C -1 ; WX 600 ; N Ydieresis ; B 109 0 709 761 ;
C -1 ; WX 600 ; N divide ; B 114 16 596 500 ;
C -1 ; WX 600 ; N Yacute ; B 109 0 709 784 ;
C -1 ; WX 600 ; N Acircumflex ; B -9 0 632 780 ;
C -1 ; WX 600 ; N aacute ; B 61 -15 609 661 ;
C -1 ; WX 600 ; N Ucircumflex ; B 101 -18 716 780 ;
C -1 ; WX 600 ; N yacute ; B -21 -142 695 661 ;
C -1 ; WX 600 ; N scommaaccent ; B 66 -250 608 459 ;
C -1 ; WX 600 ; N ecircumflex ; B 81 -15 607 657 ;
C -1 ; WX 600 ; N Uring ; B 101 -18 716 801 ;
C -1 ; WX 600 ; N Udieresis ; B 101 -18 716 761 ;
C -1 ; WX 600 ; N aogonek ; B 61 -199 593 454 ;
C -1 ; WX 600 ; N Uacute ; B 101 -18 716 784 ;
C -1 ; WX 600 ; N uogonek ; B 70 -199 592 439 ;
C -1 ; WX 600 ; N Edieresis ; B 25 0 670 761 ;
C -1 ; WX 600 ; N Dcroat ; B 30 0 664 562 ;
C -1 ; WX 600 ; N commaaccent ; B 151 -250 385 -57 ;
C -1 ; WX 600 ; N copyright ; B 53 -18 667 580 ;
C -1 ; WX 600 ; N Emacron ; B 25 0 670 708 ;
C -1 ; WX 600 ; N ccaron ; B 81 -15 633 667 ;
C -1 ; WX 600 ; N aring ; B 61 -15 593 678 ;
C -1 ; WX 600 ; N Ncommaaccent ; B 8 -250 730 562 ;
C -1 ; WX 600 ; N lacute ; B 77 0 639 801 ;
C -1 ; WX 600 ; N agrave ; B 61 -15 593 661 ;
C -1 ; WX 600 ; N Tcommaaccent ; B 86 -250 679 562 ;
C -1 ; WX 600 ; N Cacute ; B 74 -18 675 784 ;
C -1 ; WX 600 ; N atilde ; B 61 -15 643 636 ;
C -1 ; WX 600 ; N Edotaccent ; B 25 0 670 761 ;
C -1 ; WX 600 ; N scaron ; B 66 -17 633 667 ;
C -1 ; WX 600 ; N scedilla ; B 66 -206 608 459 ;
C -1 ; WX 600 ; N iacute ; B 77 0 609 661 ;
C -1 ; WX 600 ; N lozenge ; B 145 0 614 740 ;
C -1 ; WX 600 ; N Rcaron ; B 24 0 659 790 ;
C -1 ; WX 600 ; N Gcommaaccent ; B 74 -250 675 580 ;
C -1 ; WX 600 ; N ucircumflex ; B 70 -15 597 657 ;
C -1 ; WX 600 ; N acircumflex ; B 61 -15 607 657 ;
C -1 ; WX 600 ; N Amacron ; B -9 0 633 708 ;
C -1 ; WX 600 ; N rcaron ; B 47 0 655 667 ;
C -1 ; WX 600 ; N ccedilla ; B 81 -206 631 459 ;
C -1 ; WX 600 ; N Zdotaccent ; B 62 0 637 761 ;
C -1 ; WX 600 ; N Thorn ; B 48 0 620 562 ;
C -1 ; WX 600 ; N Omacron ; B 74 -18 663 708 ;
C -1 ; WX 600 ; N Racute ; B 24 0 665 784 ;
C -1 ; WX 600 ; N Sacute ; B 54 -22 673 784 ;
C -1 ; WX 600 ; N dcaron ; B 60 -15 861 626 ;
C -1 ; WX 600 ; N Umacron ; B 101 -18 716 708 ;
C -1 ; WX 600 ; N uring ; B 70 -15 592 678 ;
C -1 ; WX 600 ; N threesuperior ; B 193 222 526 616 ;
C -1 ; WX 600 ; N Ograve ; B 74 -18 645 784 ;
C -1 ; WX 600 ; N Agrave ; B -9 0 632 784 ;
C -1 ; WX 600 ; N Abreve ; B -9 0 684 784 ;
C -1 ; WX 600 ; N multiply ; B 104 39 606 478 ;
C -1 ; WX 600 ; N uacute ; B 70 -15 599 661 ;
C -1 ; WX 600 ; N Tcaron ; B 86 0 679 790 ;
C -1 ; WX 600 ; N partialdiff ; B 91 -38 627 728 ;
C -1 ; WX 600 ; N ydieresis ; B -21 -142 695 638 ;
C -1 ; WX 600 ; N Nacute ; B 8 -12 730 784 ;
C -1 ; WX 600 ; N icircumflex ; B 77 0 577 657 ;
C -1 ; WX 600 ; N Ecircumflex ; B 25 0 670 780 ;
C -1 ; WX 600 ; N adieresis ; B 61 -15 595 638 ;
C -1 ; WX 600 ; N edieresis ; B 81 -15 605 638 ;
C -1 ; WX 600 ; N cacute ; B 81 -15 649 661 ;
C -1 ; WX 600 ; N nacute ; B 18 0 639 661 ;
C -1 ; WX 600 ; N umacron ; B 70 -15 637 585 ;
C -1 ; WX 600 ; N Ncaron ; B 8 -12 730 790 ;
C -1 ; WX 600 ; N Iacute ; B 77 0 643 784 ;
C -1 ; WX 600 ; N plusminus ; B 76 24 614 515 ;
C -1 ; WX 600 ; N brokenbar ; B 217 -175 489 675 ;
C -1 ; WX 600 ; N registered ; B 53 -18 667 580 ;
C -1 ; WX 600 ; N Gbreve ; B 74 -18 684 784 ;
C -1 ; WX 600 ; N Idotaccent ; B 77 0 643 761 ;
C -1 ; WX 600 ; N summation ; B 15 -10 672 706 ;
C -1 ; WX 600 ; N Egrave ; B 25 0 670 784 ;
C -1 ; WX 600 ; N racute ; B 47 0 655 661 ;
C -1 ; WX 600 ; N omacron ; B 71 -15 637 585 ;
C -1 ; WX 600 ; N Zacute ; B 62 0 665 784 ;
C -1 ; WX 600 ; N Zcaron ; B 62 0 659 790 ;
C -1 ; WX 600 ; N greaterequal ; B 26 0 627 696 ;
C -1 ; WX 600 ; N Eth ; B 30 0 664 562 ;
C -1 ; WX 600 ; N Ccedilla ; B 74 -206 675 580 ;
C -1 ; WX 600 ; N lcommaaccent ; B 77 -250 546 626 ;
C -1 ; WX 600 ; N tcaron ; B 118 -15 627 703 ;
C -1 ; WX 600 ; N eogonek ; B 81 -199 605 454 ;
C -1 ; WX 600 ; N Uogonek ; B 101 -199 716 562 ;
C -1 ; WX 600 ; N Aacute ; B -9 0 655 784 ;
C -1 ; WX 600 ; N Adieresis ; B -9 0 632 761 ;
C -1 ; WX 600 ; N egrave ; B 81 -15 605 661 ;
C -1 ; WX 600 ; N zacute ; B 81 0 614 661 ;
C -1 ; WX 600 ; N iogonek ; B 77 -199 546 658 ;
C -1 ; WX 600 ; N Oacute ; B 74 -18 645 784 ;
C -1 ; WX 600 ; N oacute ; B 71 -15 649 661 ;
C -1 ; WX 600 ; N amacron ; B 61 -15 637 585 ;
C -1 ; WX 600 ; N sacute ; B 66 -17 609 661 ;
C -1 ; WX 600 ; N idieresis ; B 77 0 561 618 ;
C -1 ; WX 600 ; N Ocircumflex ; B 74 -18 645 780 ;
C -1 ; WX 600 ; N Ugrave ; B 101 -18 716 784 ;
C -1 ; WX 600 ; N Delta ; B 6 0 594 688 ;
C -1 ; WX 600 ; N thorn ; B -32 -142 622 626 ;
C -1 ; WX 600 ; N twosuperior ; B 191 230 542 616 ;
C -1 ; WX 600 ; N Odieresis ; B 74 -18 645 761 ;
C -1 ; WX 600 ; N mu ; B 49 -142 592 439 ;
C -1 ; WX 600 ; N igrave ; B 77 0 546 661 ;
C -1 ; WX 600 ; N ohungarumlaut ; B 71 -15 809 661 ;
C -1 ; WX 600 ; N Eogonek ; B 25 -199 670 562 ;
C -1 ; WX 600 ; N dcroat ; B 60 -15 712 626 ;
C -1 ; WX 600 ; N threequarters ; B 8 -60 699 661 ;
C -1 ; WX 600 ; N Scedilla ; B 54 -206 673 582 ;
C -1 ; WX 600 ; N lcaron ; B 77 0 731 626 ;
C -1 ; WX 600 ; N Kcommaaccent ; B 21 -250 692 562 ;
C -1 ; WX 600 ; N Lacute ; B 39 0 636 784 ;
C -1 ; WX 600 ; N trademark ; B 86 230 869 562 ;
C -1 ; WX 600 ; N edotaccent ; B 81 -15 605 638 ;
C -1 ; WX 600 ; N Igrave ; B 77 0 643 784 ;
C -1 ; WX 600 ; N Imacron ; B 77 0 663 708 ;
C -1 ; WX 600 ; N Lcaron ; B 39 0 757 562 ;
C -1 ; WX 600 ; N onehalf ; B 22 -60 716 661 ;
C -1 ; WX 600 ; N lessequal ; B 26 0 671 696 ;
C -1 ; WX 600 ; N ocircumflex ; B 71 -15 622 657 ;
C -1 ; WX 600 ; N ntilde ; B 18 0 643 636 ;
C -1 ; WX 600 ; N Uhungarumlaut ; B 101 -18 805 784 ;
C -1 ; WX 600 ; N Eacute ; B 25 0 670 784 ;
C -1 ; WX 600 ; N emacron ; B 81 -15 637 585 ;
C -1 ; WX 600 ; N gbreve ; B 40 -146 674 661 ;
C -1 ; WX 600 ; N onequarter ; B 13 -60 707 661 ;
C -1 ; WX 600 ; N Scaron ; B 54 -22 689 790 ;
C -1 ; WX 600 ; N Scommaaccent ; B 54 -250 673 582 ;
C -1 ; WX 600 ; N Ohungarumlaut ; B 74 -18 795 784 ;
C -1 ; WX 600 ; N degree ; B 173 243 570 616 ;
C -1 ; WX 600 ; N ograve ; B 71 -15 622 661 ;
C -1 ; WX 600 ; N Ccaron ; B 74 -18 689 790 ;
C -1 ; WX 600 ; N ugrave ; B 70 -15 592 661 ;
C -1 ; WX 600 ; N radical ; B 67 -104 635 778 ;
C -1 ; WX 600 ; N Dcaron ; B 30 0 664 790 ;
C -1 ; WX 600 ; N rcommaaccent ; B 47 -250 655 454 ;
C -1 ; WX 600 ; N Ntilde ; B 8 -12 730 759 ;
C -1 ; WX 600 ; N otilde ; B 71 -15 643 636 ;
C -1 ; WX 600 ; N Rcommaaccent ; B 24 -250 617 562 ;
C -1 ; WX 600 ; N Lcommaaccent ; B 39 -250 636 562 ;
C -1 ; WX 600 ; N Atilde ; B -9 0 669 759 ;
C -1 ; WX 600 ; N Aogonek ; B -9 -199 632 562 ;
C -1 ; WX 600 ; N Aring ; B -9 0 632 801 ;
C -1 ; WX 600 ; N Otilde ; B 74 -18 669 759 ;
C -1 ; WX 600 ; N zdotaccent ; B 81 0 614 638 ;
C -1 ; WX 600 ; N Ecaron ; B 25 0 670 790 ;
C -1 ; WX 600 ; N Iogonek ; B 77 -199 643 562 ;
C -1 ; WX 600 ; N kcommaaccent ; B 33 -250 643 626 ;
C -1 ; WX 600 ; N minus ; B 114 203 596 313 ;
C -1 ; WX 600 ; N Icircumflex ; B 77 0 643 780 ;
C -1 ; WX 600 ; N ncaron ; B 18 0 633 667 ;
C -1 ; WX 600 ; N tcommaaccent ; B 118 -250 567 562 ;
C -1 ; WX 600 ; N logicalnot ; B 135 103 617 413 ;
C -1 ; WX 600 ; N odieresis ; B 71 -15 622 638 ;
C -1 ; WX 600 ; N udieresis ; B 70 -15 595 638 ;
C -1 ; WX 600 ; N notequal ; B 30 -47 626 563 ;
C -1 ; WX 600 ; N gcommaaccent ; B 40 -146 674 714 ;
C -1 ; WX 600 ; N eth ; B 93 -27 661 626 ;
C -1 ; WX 600 ; N zcaron ; B 81 0 643 667 ;
C -1 ; WX 600 ; N ncommaaccent ; B 18 -250 615 454 ;
C -1 ; WX 600 ; N onesuperior ; B 212 230 514 616 ;
C -1 ; WX 600 ; N imacron ; B 77 0 575 585 ;
C -1 ; WX 600 ; N Euro ; B 0 0 0 0 ;
EndCharMetrics
EndFontMetrics
+342
View File
@@ -0,0 +1,342 @@
StartFontMetrics 4.1
Comment Copyright (c) 1989, 1990, 1991, 1992, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
Comment Creation Date: Thu May 1 17:37:52 1997
Comment UniqueID 43051
Comment VMusage 16248 75829
FontName Courier-Oblique
FullName Courier Oblique
FamilyName Courier
Weight Medium
ItalicAngle -12
IsFixedPitch true
CharacterSet ExtendedRoman
FontBBox -27 -250 849 805
UnderlinePosition -100
UnderlineThickness 50
Version 003.000
Notice Copyright (c) 1989, 1990, 1991, 1992, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
EncodingScheme AdobeStandardEncoding
CapHeight 562
XHeight 426
Ascender 629
Descender -157
StdHW 51
StdVW 51
StartCharMetrics 315
C 32 ; WX 600 ; N space ; B 0 0 0 0 ;
C 33 ; WX 600 ; N exclam ; B 243 -15 464 572 ;
C 34 ; WX 600 ; N quotedbl ; B 273 328 532 562 ;
C 35 ; WX 600 ; N numbersign ; B 133 -32 596 639 ;
C 36 ; WX 600 ; N dollar ; B 108 -126 596 662 ;
C 37 ; WX 600 ; N percent ; B 134 -15 599 622 ;
C 38 ; WX 600 ; N ampersand ; B 87 -15 580 543 ;
C 39 ; WX 600 ; N quoteright ; B 283 328 495 562 ;
C 40 ; WX 600 ; N parenleft ; B 313 -108 572 622 ;
C 41 ; WX 600 ; N parenright ; B 137 -108 396 622 ;
C 42 ; WX 600 ; N asterisk ; B 212 257 580 607 ;
C 43 ; WX 600 ; N plus ; B 129 44 580 470 ;
C 44 ; WX 600 ; N comma ; B 157 -112 370 122 ;
C 45 ; WX 600 ; N hyphen ; B 152 231 558 285 ;
C 46 ; WX 600 ; N period ; B 238 -15 382 109 ;
C 47 ; WX 600 ; N slash ; B 112 -80 604 629 ;
C 48 ; WX 600 ; N zero ; B 154 -15 575 622 ;
C 49 ; WX 600 ; N one ; B 98 0 515 622 ;
C 50 ; WX 600 ; N two ; B 70 0 568 622 ;
C 51 ; WX 600 ; N three ; B 82 -15 538 622 ;
C 52 ; WX 600 ; N four ; B 108 0 541 622 ;
C 53 ; WX 600 ; N five ; B 99 -15 589 607 ;
C 54 ; WX 600 ; N six ; B 155 -15 629 622 ;
C 55 ; WX 600 ; N seven ; B 182 0 612 607 ;
C 56 ; WX 600 ; N eight ; B 132 -15 588 622 ;
C 57 ; WX 600 ; N nine ; B 93 -15 574 622 ;
C 58 ; WX 600 ; N colon ; B 238 -15 441 385 ;
C 59 ; WX 600 ; N semicolon ; B 157 -112 441 385 ;
C 60 ; WX 600 ; N less ; B 96 42 610 472 ;
C 61 ; WX 600 ; N equal ; B 109 138 600 376 ;
C 62 ; WX 600 ; N greater ; B 85 42 599 472 ;
C 63 ; WX 600 ; N question ; B 222 -15 583 572 ;
C 64 ; WX 600 ; N at ; B 127 -15 582 622 ;
C 65 ; WX 600 ; N A ; B 3 0 607 562 ;
C 66 ; WX 600 ; N B ; B 43 0 616 562 ;
C 67 ; WX 600 ; N C ; B 93 -18 655 580 ;
C 68 ; WX 600 ; N D ; B 43 0 645 562 ;
C 69 ; WX 600 ; N E ; B 53 0 660 562 ;
C 70 ; WX 600 ; N F ; B 53 0 660 562 ;
C 71 ; WX 600 ; N G ; B 83 -18 645 580 ;
C 72 ; WX 600 ; N H ; B 32 0 687 562 ;
C 73 ; WX 600 ; N I ; B 96 0 623 562 ;
C 74 ; WX 600 ; N J ; B 52 -18 685 562 ;
C 75 ; WX 600 ; N K ; B 38 0 671 562 ;
C 76 ; WX 600 ; N L ; B 47 0 607 562 ;
C 77 ; WX 600 ; N M ; B 4 0 715 562 ;
C 78 ; WX 600 ; N N ; B 7 -13 712 562 ;
C 79 ; WX 600 ; N O ; B 94 -18 625 580 ;
C 80 ; WX 600 ; N P ; B 79 0 644 562 ;
C 81 ; WX 600 ; N Q ; B 95 -138 625 580 ;
C 82 ; WX 600 ; N R ; B 38 0 598 562 ;
C 83 ; WX 600 ; N S ; B 76 -20 650 580 ;
C 84 ; WX 600 ; N T ; B 108 0 665 562 ;
C 85 ; WX 600 ; N U ; B 125 -18 702 562 ;
C 86 ; WX 600 ; N V ; B 105 -13 723 562 ;
C 87 ; WX 600 ; N W ; B 106 -13 722 562 ;
C 88 ; WX 600 ; N X ; B 23 0 675 562 ;
C 89 ; WX 600 ; N Y ; B 133 0 695 562 ;
C 90 ; WX 600 ; N Z ; B 86 0 610 562 ;
C 91 ; WX 600 ; N bracketleft ; B 246 -108 574 622 ;
C 92 ; WX 600 ; N backslash ; B 249 -80 468 629 ;
C 93 ; WX 600 ; N bracketright ; B 135 -108 463 622 ;
C 94 ; WX 600 ; N asciicircum ; B 175 354 587 622 ;
C 95 ; WX 600 ; N underscore ; B -27 -125 584 -75 ;
C 96 ; WX 600 ; N quoteleft ; B 343 328 457 562 ;
C 97 ; WX 600 ; N a ; B 76 -15 569 441 ;
C 98 ; WX 600 ; N b ; B 29 -15 625 629 ;
C 99 ; WX 600 ; N c ; B 106 -15 608 441 ;
C 100 ; WX 600 ; N d ; B 85 -15 640 629 ;
C 101 ; WX 600 ; N e ; B 106 -15 598 441 ;
C 102 ; WX 600 ; N f ; B 114 0 662 629 ; L i fi ; L l fl ;
C 103 ; WX 600 ; N g ; B 61 -157 657 441 ;
C 104 ; WX 600 ; N h ; B 33 0 592 629 ;
C 105 ; WX 600 ; N i ; B 95 0 515 657 ;
C 106 ; WX 600 ; N j ; B 52 -157 550 657 ;
C 107 ; WX 600 ; N k ; B 58 0 633 629 ;
C 108 ; WX 600 ; N l ; B 95 0 515 629 ;
C 109 ; WX 600 ; N m ; B -5 0 615 441 ;
C 110 ; WX 600 ; N n ; B 26 0 585 441 ;
C 111 ; WX 600 ; N o ; B 102 -15 588 441 ;
C 112 ; WX 600 ; N p ; B -24 -157 605 441 ;
C 113 ; WX 600 ; N q ; B 85 -157 682 441 ;
C 114 ; WX 600 ; N r ; B 60 0 636 441 ;
C 115 ; WX 600 ; N s ; B 78 -15 584 441 ;
C 116 ; WX 600 ; N t ; B 167 -15 561 561 ;
C 117 ; WX 600 ; N u ; B 101 -15 572 426 ;
C 118 ; WX 600 ; N v ; B 90 -10 681 426 ;
C 119 ; WX 600 ; N w ; B 76 -10 695 426 ;
C 120 ; WX 600 ; N x ; B 20 0 655 426 ;
C 121 ; WX 600 ; N y ; B -4 -157 683 426 ;
C 122 ; WX 600 ; N z ; B 99 0 593 426 ;
C 123 ; WX 600 ; N braceleft ; B 233 -108 569 622 ;
C 124 ; WX 600 ; N bar ; B 222 -250 485 750 ;
C 125 ; WX 600 ; N braceright ; B 140 -108 477 622 ;
C 126 ; WX 600 ; N asciitilde ; B 116 197 600 320 ;
C 161 ; WX 600 ; N exclamdown ; B 225 -157 445 430 ;
C 162 ; WX 600 ; N cent ; B 151 -49 588 614 ;
C 163 ; WX 600 ; N sterling ; B 124 -21 621 611 ;
C 164 ; WX 600 ; N fraction ; B 84 -57 646 665 ;
C 165 ; WX 600 ; N yen ; B 120 0 693 562 ;
C 166 ; WX 600 ; N florin ; B -26 -143 671 622 ;
C 167 ; WX 600 ; N section ; B 104 -78 590 580 ;
C 168 ; WX 600 ; N currency ; B 94 58 628 506 ;
C 169 ; WX 600 ; N quotesingle ; B 345 328 460 562 ;
C 170 ; WX 600 ; N quotedblleft ; B 262 328 541 562 ;
C 171 ; WX 600 ; N guillemotleft ; B 92 70 652 446 ;
C 172 ; WX 600 ; N guilsinglleft ; B 204 70 540 446 ;
C 173 ; WX 600 ; N guilsinglright ; B 170 70 506 446 ;
C 174 ; WX 600 ; N fi ; B 3 0 619 629 ;
C 175 ; WX 600 ; N fl ; B 3 0 619 629 ;
C 177 ; WX 600 ; N endash ; B 124 231 586 285 ;
C 178 ; WX 600 ; N dagger ; B 217 -78 546 580 ;
C 179 ; WX 600 ; N daggerdbl ; B 163 -78 546 580 ;
C 180 ; WX 600 ; N periodcentered ; B 275 189 434 327 ;
C 182 ; WX 600 ; N paragraph ; B 100 -78 630 562 ;
C 183 ; WX 600 ; N bullet ; B 224 130 485 383 ;
C 184 ; WX 600 ; N quotesinglbase ; B 185 -134 397 100 ;
C 185 ; WX 600 ; N quotedblbase ; B 115 -134 478 100 ;
C 186 ; WX 600 ; N quotedblright ; B 213 328 576 562 ;
C 187 ; WX 600 ; N guillemotright ; B 58 70 618 446 ;
C 188 ; WX 600 ; N ellipsis ; B 46 -15 575 111 ;
C 189 ; WX 600 ; N perthousand ; B 59 -15 627 622 ;
C 191 ; WX 600 ; N questiondown ; B 105 -157 466 430 ;
C 193 ; WX 600 ; N grave ; B 294 497 484 672 ;
C 194 ; WX 600 ; N acute ; B 348 497 612 672 ;
C 195 ; WX 600 ; N circumflex ; B 229 477 581 654 ;
C 196 ; WX 600 ; N tilde ; B 212 489 629 606 ;
C 197 ; WX 600 ; N macron ; B 232 525 600 565 ;
C 198 ; WX 600 ; N breve ; B 279 501 576 609 ;
C 199 ; WX 600 ; N dotaccent ; B 373 537 478 640 ;
C 200 ; WX 600 ; N dieresis ; B 272 537 579 640 ;
C 202 ; WX 600 ; N ring ; B 332 463 500 627 ;
C 203 ; WX 600 ; N cedilla ; B 197 -151 344 10 ;
C 205 ; WX 600 ; N hungarumlaut ; B 239 497 683 672 ;
C 206 ; WX 600 ; N ogonek ; B 189 -172 377 4 ;
C 207 ; WX 600 ; N caron ; B 262 492 614 669 ;
C 208 ; WX 600 ; N emdash ; B 49 231 661 285 ;
C 225 ; WX 600 ; N AE ; B 3 0 655 562 ;
C 227 ; WX 600 ; N ordfeminine ; B 209 249 512 580 ;
C 232 ; WX 600 ; N Lslash ; B 47 0 607 562 ;
C 233 ; WX 600 ; N Oslash ; B 94 -80 625 629 ;
C 234 ; WX 600 ; N OE ; B 59 0 672 562 ;
C 235 ; WX 600 ; N ordmasculine ; B 210 249 535 580 ;
C 241 ; WX 600 ; N ae ; B 41 -15 626 441 ;
C 245 ; WX 600 ; N dotlessi ; B 95 0 515 426 ;
C 248 ; WX 600 ; N lslash ; B 95 0 587 629 ;
C 249 ; WX 600 ; N oslash ; B 102 -80 588 506 ;
C 250 ; WX 600 ; N oe ; B 54 -15 615 441 ;
C 251 ; WX 600 ; N germandbls ; B 48 -15 617 629 ;
C -1 ; WX 600 ; N Idieresis ; B 96 0 623 753 ;
C -1 ; WX 600 ; N eacute ; B 106 -15 612 672 ;
C -1 ; WX 600 ; N abreve ; B 76 -15 576 609 ;
C -1 ; WX 600 ; N uhungarumlaut ; B 101 -15 723 672 ;
C -1 ; WX 600 ; N ecaron ; B 106 -15 614 669 ;
C -1 ; WX 600 ; N Ydieresis ; B 133 0 695 753 ;
C -1 ; WX 600 ; N divide ; B 136 48 573 467 ;
C -1 ; WX 600 ; N Yacute ; B 133 0 695 805 ;
C -1 ; WX 600 ; N Acircumflex ; B 3 0 607 787 ;
C -1 ; WX 600 ; N aacute ; B 76 -15 612 672 ;
C -1 ; WX 600 ; N Ucircumflex ; B 125 -18 702 787 ;
C -1 ; WX 600 ; N yacute ; B -4 -157 683 672 ;
C -1 ; WX 600 ; N scommaaccent ; B 78 -250 584 441 ;
C -1 ; WX 600 ; N ecircumflex ; B 106 -15 598 654 ;
C -1 ; WX 600 ; N Uring ; B 125 -18 702 760 ;
C -1 ; WX 600 ; N Udieresis ; B 125 -18 702 753 ;
C -1 ; WX 600 ; N aogonek ; B 76 -172 569 441 ;
C -1 ; WX 600 ; N Uacute ; B 125 -18 702 805 ;
C -1 ; WX 600 ; N uogonek ; B 101 -172 572 426 ;
C -1 ; WX 600 ; N Edieresis ; B 53 0 660 753 ;
C -1 ; WX 600 ; N Dcroat ; B 43 0 645 562 ;
C -1 ; WX 600 ; N commaaccent ; B 145 -250 323 -58 ;
C -1 ; WX 600 ; N copyright ; B 53 -18 667 580 ;
C -1 ; WX 600 ; N Emacron ; B 53 0 660 698 ;
C -1 ; WX 600 ; N ccaron ; B 106 -15 614 669 ;
C -1 ; WX 600 ; N aring ; B 76 -15 569 627 ;
C -1 ; WX 600 ; N Ncommaaccent ; B 7 -250 712 562 ;
C -1 ; WX 600 ; N lacute ; B 95 0 640 805 ;
C -1 ; WX 600 ; N agrave ; B 76 -15 569 672 ;
C -1 ; WX 600 ; N Tcommaaccent ; B 108 -250 665 562 ;
C -1 ; WX 600 ; N Cacute ; B 93 -18 655 805 ;
C -1 ; WX 600 ; N atilde ; B 76 -15 629 606 ;
C -1 ; WX 600 ; N Edotaccent ; B 53 0 660 753 ;
C -1 ; WX 600 ; N scaron ; B 78 -15 614 669 ;
C -1 ; WX 600 ; N scedilla ; B 78 -151 584 441 ;
C -1 ; WX 600 ; N iacute ; B 95 0 612 672 ;
C -1 ; WX 600 ; N lozenge ; B 94 0 519 706 ;
C -1 ; WX 600 ; N Rcaron ; B 38 0 642 802 ;
C -1 ; WX 600 ; N Gcommaaccent ; B 83 -250 645 580 ;
C -1 ; WX 600 ; N ucircumflex ; B 101 -15 572 654 ;
C -1 ; WX 600 ; N acircumflex ; B 76 -15 581 654 ;
C -1 ; WX 600 ; N Amacron ; B 3 0 607 698 ;
C -1 ; WX 600 ; N rcaron ; B 60 0 636 669 ;
C -1 ; WX 600 ; N ccedilla ; B 106 -151 614 441 ;
C -1 ; WX 600 ; N Zdotaccent ; B 86 0 610 753 ;
C -1 ; WX 600 ; N Thorn ; B 79 0 606 562 ;
C -1 ; WX 600 ; N Omacron ; B 94 -18 628 698 ;
C -1 ; WX 600 ; N Racute ; B 38 0 670 805 ;
C -1 ; WX 600 ; N Sacute ; B 76 -20 650 805 ;
C -1 ; WX 600 ; N dcaron ; B 85 -15 849 629 ;
C -1 ; WX 600 ; N Umacron ; B 125 -18 702 698 ;
C -1 ; WX 600 ; N uring ; B 101 -15 572 627 ;
C -1 ; WX 600 ; N threesuperior ; B 213 240 501 622 ;
C -1 ; WX 600 ; N Ograve ; B 94 -18 625 805 ;
C -1 ; WX 600 ; N Agrave ; B 3 0 607 805 ;
C -1 ; WX 600 ; N Abreve ; B 3 0 607 732 ;
C -1 ; WX 600 ; N multiply ; B 103 43 607 470 ;
C -1 ; WX 600 ; N uacute ; B 101 -15 602 672 ;
C -1 ; WX 600 ; N Tcaron ; B 108 0 665 802 ;
C -1 ; WX 600 ; N partialdiff ; B 45 -38 546 710 ;
C -1 ; WX 600 ; N ydieresis ; B -4 -157 683 620 ;
C -1 ; WX 600 ; N Nacute ; B 7 -13 712 805 ;
C -1 ; WX 600 ; N icircumflex ; B 95 0 551 654 ;
C -1 ; WX 600 ; N Ecircumflex ; B 53 0 660 787 ;
C -1 ; WX 600 ; N adieresis ; B 76 -15 575 620 ;
C -1 ; WX 600 ; N edieresis ; B 106 -15 598 620 ;
C -1 ; WX 600 ; N cacute ; B 106 -15 612 672 ;
C -1 ; WX 600 ; N nacute ; B 26 0 602 672 ;
C -1 ; WX 600 ; N umacron ; B 101 -15 600 565 ;
C -1 ; WX 600 ; N Ncaron ; B 7 -13 712 802 ;
C -1 ; WX 600 ; N Iacute ; B 96 0 640 805 ;
C -1 ; WX 600 ; N plusminus ; B 96 44 594 558 ;
C -1 ; WX 600 ; N brokenbar ; B 238 -175 469 675 ;
C -1 ; WX 600 ; N registered ; B 53 -18 667 580 ;
C -1 ; WX 600 ; N Gbreve ; B 83 -18 645 732 ;
C -1 ; WX 600 ; N Idotaccent ; B 96 0 623 753 ;
C -1 ; WX 600 ; N summation ; B 15 -10 670 706 ;
C -1 ; WX 600 ; N Egrave ; B 53 0 660 805 ;
C -1 ; WX 600 ; N racute ; B 60 0 636 672 ;
C -1 ; WX 600 ; N omacron ; B 102 -15 600 565 ;
C -1 ; WX 600 ; N Zacute ; B 86 0 670 805 ;
C -1 ; WX 600 ; N Zcaron ; B 86 0 642 802 ;
C -1 ; WX 600 ; N greaterequal ; B 98 0 594 710 ;
C -1 ; WX 600 ; N Eth ; B 43 0 645 562 ;
C -1 ; WX 600 ; N Ccedilla ; B 93 -151 658 580 ;
C -1 ; WX 600 ; N lcommaaccent ; B 95 -250 515 629 ;
C -1 ; WX 600 ; N tcaron ; B 167 -15 587 717 ;
C -1 ; WX 600 ; N eogonek ; B 106 -172 598 441 ;
C -1 ; WX 600 ; N Uogonek ; B 124 -172 702 562 ;
C -1 ; WX 600 ; N Aacute ; B 3 0 660 805 ;
C -1 ; WX 600 ; N Adieresis ; B 3 0 607 753 ;
C -1 ; WX 600 ; N egrave ; B 106 -15 598 672 ;
C -1 ; WX 600 ; N zacute ; B 99 0 612 672 ;
C -1 ; WX 600 ; N iogonek ; B 95 -172 515 657 ;
C -1 ; WX 600 ; N Oacute ; B 94 -18 640 805 ;
C -1 ; WX 600 ; N oacute ; B 102 -15 612 672 ;
C -1 ; WX 600 ; N amacron ; B 76 -15 600 565 ;
C -1 ; WX 600 ; N sacute ; B 78 -15 612 672 ;
C -1 ; WX 600 ; N idieresis ; B 95 0 545 620 ;
C -1 ; WX 600 ; N Ocircumflex ; B 94 -18 625 787 ;
C -1 ; WX 600 ; N Ugrave ; B 125 -18 702 805 ;
C -1 ; WX 600 ; N Delta ; B 6 0 598 688 ;
C -1 ; WX 600 ; N thorn ; B -24 -157 605 629 ;
C -1 ; WX 600 ; N twosuperior ; B 230 249 535 622 ;
C -1 ; WX 600 ; N Odieresis ; B 94 -18 625 753 ;
C -1 ; WX 600 ; N mu ; B 72 -157 572 426 ;
C -1 ; WX 600 ; N igrave ; B 95 0 515 672 ;
C -1 ; WX 600 ; N ohungarumlaut ; B 102 -15 723 672 ;
C -1 ; WX 600 ; N Eogonek ; B 53 -172 660 562 ;
C -1 ; WX 600 ; N dcroat ; B 85 -15 704 629 ;
C -1 ; WX 600 ; N threequarters ; B 73 -56 659 666 ;
C -1 ; WX 600 ; N Scedilla ; B 76 -151 650 580 ;
C -1 ; WX 600 ; N lcaron ; B 95 0 667 629 ;
C -1 ; WX 600 ; N Kcommaaccent ; B 38 -250 671 562 ;
C -1 ; WX 600 ; N Lacute ; B 47 0 607 805 ;
C -1 ; WX 600 ; N trademark ; B 75 263 742 562 ;
C -1 ; WX 600 ; N edotaccent ; B 106 -15 598 620 ;
C -1 ; WX 600 ; N Igrave ; B 96 0 623 805 ;
C -1 ; WX 600 ; N Imacron ; B 96 0 628 698 ;
C -1 ; WX 600 ; N Lcaron ; B 47 0 632 562 ;
C -1 ; WX 600 ; N onehalf ; B 65 -57 669 665 ;
C -1 ; WX 600 ; N lessequal ; B 98 0 645 710 ;
C -1 ; WX 600 ; N ocircumflex ; B 102 -15 588 654 ;
C -1 ; WX 600 ; N ntilde ; B 26 0 629 606 ;
C -1 ; WX 600 ; N Uhungarumlaut ; B 125 -18 761 805 ;
C -1 ; WX 600 ; N Eacute ; B 53 0 670 805 ;
C -1 ; WX 600 ; N emacron ; B 106 -15 600 565 ;
C -1 ; WX 600 ; N gbreve ; B 61 -157 657 609 ;
C -1 ; WX 600 ; N onequarter ; B 65 -57 674 665 ;
C -1 ; WX 600 ; N Scaron ; B 76 -20 672 802 ;
C -1 ; WX 600 ; N Scommaaccent ; B 76 -250 650 580 ;
C -1 ; WX 600 ; N Ohungarumlaut ; B 94 -18 751 805 ;
C -1 ; WX 600 ; N degree ; B 214 269 576 622 ;
C -1 ; WX 600 ; N ograve ; B 102 -15 588 672 ;
C -1 ; WX 600 ; N Ccaron ; B 93 -18 672 802 ;
C -1 ; WX 600 ; N ugrave ; B 101 -15 572 672 ;
C -1 ; WX 600 ; N radical ; B 85 -15 765 792 ;
C -1 ; WX 600 ; N Dcaron ; B 43 0 645 802 ;
C -1 ; WX 600 ; N rcommaaccent ; B 60 -250 636 441 ;
C -1 ; WX 600 ; N Ntilde ; B 7 -13 712 729 ;
C -1 ; WX 600 ; N otilde ; B 102 -15 629 606 ;
C -1 ; WX 600 ; N Rcommaaccent ; B 38 -250 598 562 ;
C -1 ; WX 600 ; N Lcommaaccent ; B 47 -250 607 562 ;
C -1 ; WX 600 ; N Atilde ; B 3 0 655 729 ;
C -1 ; WX 600 ; N Aogonek ; B 3 -172 607 562 ;
C -1 ; WX 600 ; N Aring ; B 3 0 607 750 ;
C -1 ; WX 600 ; N Otilde ; B 94 -18 655 729 ;
C -1 ; WX 600 ; N zdotaccent ; B 99 0 593 620 ;
C -1 ; WX 600 ; N Ecaron ; B 53 0 660 802 ;
C -1 ; WX 600 ; N Iogonek ; B 96 -172 623 562 ;
C -1 ; WX 600 ; N kcommaaccent ; B 58 -250 633 629 ;
C -1 ; WX 600 ; N minus ; B 129 232 580 283 ;
C -1 ; WX 600 ; N Icircumflex ; B 96 0 623 787 ;
C -1 ; WX 600 ; N ncaron ; B 26 0 614 669 ;
C -1 ; WX 600 ; N tcommaaccent ; B 165 -250 561 561 ;
C -1 ; WX 600 ; N logicalnot ; B 155 108 591 369 ;
C -1 ; WX 600 ; N odieresis ; B 102 -15 588 620 ;
C -1 ; WX 600 ; N udieresis ; B 101 -15 575 620 ;
C -1 ; WX 600 ; N notequal ; B 43 -16 621 529 ;
C -1 ; WX 600 ; N gcommaaccent ; B 61 -157 657 708 ;
C -1 ; WX 600 ; N eth ; B 102 -15 639 629 ;
C -1 ; WX 600 ; N zcaron ; B 99 0 624 669 ;
C -1 ; WX 600 ; N ncommaaccent ; B 26 -250 585 441 ;
C -1 ; WX 600 ; N onesuperior ; B 231 249 491 622 ;
C -1 ; WX 600 ; N imacron ; B 95 0 543 565 ;
C -1 ; WX 600 ; N Euro ; B 0 0 0 0 ;
EndCharMetrics
EndFontMetrics
+342
View File
@@ -0,0 +1,342 @@
StartFontMetrics 4.1
Comment Copyright (c) 1989, 1990, 1991, 1992, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
Comment Creation Date: Thu May 1 17:27:09 1997
Comment UniqueID 43050
Comment VMusage 39754 50779
FontName Courier
FullName Courier
FamilyName Courier
Weight Medium
ItalicAngle 0
IsFixedPitch true
CharacterSet ExtendedRoman
FontBBox -23 -250 715 805
UnderlinePosition -100
UnderlineThickness 50
Version 003.000
Notice Copyright (c) 1989, 1990, 1991, 1992, 1993, 1997 Adobe Systems Incorporated. All Rights Reserved.
EncodingScheme AdobeStandardEncoding
CapHeight 562
XHeight 426
Ascender 629
Descender -157
StdHW 51
StdVW 51
StartCharMetrics 315
C 32 ; WX 600 ; N space ; B 0 0 0 0 ;
C 33 ; WX 600 ; N exclam ; B 236 -15 364 572 ;
C 34 ; WX 600 ; N quotedbl ; B 187 328 413 562 ;
C 35 ; WX 600 ; N numbersign ; B 93 -32 507 639 ;
C 36 ; WX 600 ; N dollar ; B 105 -126 496 662 ;
C 37 ; WX 600 ; N percent ; B 81 -15 518 622 ;
C 38 ; WX 600 ; N ampersand ; B 63 -15 538 543 ;
C 39 ; WX 600 ; N quoteright ; B 213 328 376 562 ;
C 40 ; WX 600 ; N parenleft ; B 269 -108 440 622 ;
C 41 ; WX 600 ; N parenright ; B 160 -108 331 622 ;
C 42 ; WX 600 ; N asterisk ; B 116 257 484 607 ;
C 43 ; WX 600 ; N plus ; B 80 44 520 470 ;
C 44 ; WX 600 ; N comma ; B 181 -112 344 122 ;
C 45 ; WX 600 ; N hyphen ; B 103 231 497 285 ;
C 46 ; WX 600 ; N period ; B 229 -15 371 109 ;
C 47 ; WX 600 ; N slash ; B 125 -80 475 629 ;
C 48 ; WX 600 ; N zero ; B 106 -15 494 622 ;
C 49 ; WX 600 ; N one ; B 96 0 505 622 ;
C 50 ; WX 600 ; N two ; B 70 0 471 622 ;
C 51 ; WX 600 ; N three ; B 75 -15 466 622 ;
C 52 ; WX 600 ; N four ; B 78 0 500 622 ;
C 53 ; WX 600 ; N five ; B 92 -15 497 607 ;
C 54 ; WX 600 ; N six ; B 111 -15 497 622 ;
C 55 ; WX 600 ; N seven ; B 82 0 483 607 ;
C 56 ; WX 600 ; N eight ; B 102 -15 498 622 ;
C 57 ; WX 600 ; N nine ; B 96 -15 489 622 ;
C 58 ; WX 600 ; N colon ; B 229 -15 371 385 ;
C 59 ; WX 600 ; N semicolon ; B 181 -112 371 385 ;
C 60 ; WX 600 ; N less ; B 41 42 519 472 ;
C 61 ; WX 600 ; N equal ; B 80 138 520 376 ;
C 62 ; WX 600 ; N greater ; B 66 42 544 472 ;
C 63 ; WX 600 ; N question ; B 129 -15 492 572 ;
C 64 ; WX 600 ; N at ; B 77 -15 533 622 ;
C 65 ; WX 600 ; N A ; B 3 0 597 562 ;
C 66 ; WX 600 ; N B ; B 43 0 559 562 ;
C 67 ; WX 600 ; N C ; B 41 -18 540 580 ;
C 68 ; WX 600 ; N D ; B 43 0 574 562 ;
C 69 ; WX 600 ; N E ; B 53 0 550 562 ;
C 70 ; WX 600 ; N F ; B 53 0 545 562 ;
C 71 ; WX 600 ; N G ; B 31 -18 575 580 ;
C 72 ; WX 600 ; N H ; B 32 0 568 562 ;
C 73 ; WX 600 ; N I ; B 96 0 504 562 ;
C 74 ; WX 600 ; N J ; B 34 -18 566 562 ;
C 75 ; WX 600 ; N K ; B 38 0 582 562 ;
C 76 ; WX 600 ; N L ; B 47 0 554 562 ;
C 77 ; WX 600 ; N M ; B 4 0 596 562 ;
C 78 ; WX 600 ; N N ; B 7 -13 593 562 ;
C 79 ; WX 600 ; N O ; B 43 -18 557 580 ;
C 80 ; WX 600 ; N P ; B 79 0 558 562 ;
C 81 ; WX 600 ; N Q ; B 43 -138 557 580 ;
C 82 ; WX 600 ; N R ; B 38 0 588 562 ;
C 83 ; WX 600 ; N S ; B 72 -20 529 580 ;
C 84 ; WX 600 ; N T ; B 38 0 563 562 ;
C 85 ; WX 600 ; N U ; B 17 -18 583 562 ;
C 86 ; WX 600 ; N V ; B -4 -13 604 562 ;
C 87 ; WX 600 ; N W ; B -3 -13 603 562 ;
C 88 ; WX 600 ; N X ; B 23 0 577 562 ;
C 89 ; WX 600 ; N Y ; B 24 0 576 562 ;
C 90 ; WX 600 ; N Z ; B 86 0 514 562 ;
C 91 ; WX 600 ; N bracketleft ; B 269 -108 442 622 ;
C 92 ; WX 600 ; N backslash ; B 118 -80 482 629 ;
C 93 ; WX 600 ; N bracketright ; B 158 -108 331 622 ;
C 94 ; WX 600 ; N asciicircum ; B 94 354 506 622 ;
C 95 ; WX 600 ; N underscore ; B 0 -125 600 -75 ;
C 96 ; WX 600 ; N quoteleft ; B 224 328 387 562 ;
C 97 ; WX 600 ; N a ; B 53 -15 559 441 ;
C 98 ; WX 600 ; N b ; B 14 -15 575 629 ;
C 99 ; WX 600 ; N c ; B 66 -15 529 441 ;
C 100 ; WX 600 ; N d ; B 45 -15 591 629 ;
C 101 ; WX 600 ; N e ; B 66 -15 548 441 ;
C 102 ; WX 600 ; N f ; B 114 0 531 629 ; L i fi ; L l fl ;
C 103 ; WX 600 ; N g ; B 45 -157 566 441 ;
C 104 ; WX 600 ; N h ; B 18 0 582 629 ;
C 105 ; WX 600 ; N i ; B 95 0 505 657 ;
C 106 ; WX 600 ; N j ; B 82 -157 410 657 ;
C 107 ; WX 600 ; N k ; B 43 0 580 629 ;
C 108 ; WX 600 ; N l ; B 95 0 505 629 ;
C 109 ; WX 600 ; N m ; B -5 0 605 441 ;
C 110 ; WX 600 ; N n ; B 26 0 575 441 ;
C 111 ; WX 600 ; N o ; B 62 -15 538 441 ;
C 112 ; WX 600 ; N p ; B 9 -157 555 441 ;
C 113 ; WX 600 ; N q ; B 45 -157 591 441 ;
C 114 ; WX 600 ; N r ; B 60 0 559 441 ;
C 115 ; WX 600 ; N s ; B 80 -15 513 441 ;
C 116 ; WX 600 ; N t ; B 87 -15 530 561 ;
C 117 ; WX 600 ; N u ; B 21 -15 562 426 ;
C 118 ; WX 600 ; N v ; B 10 -10 590 426 ;
C 119 ; WX 600 ; N w ; B -4 -10 604 426 ;
C 120 ; WX 600 ; N x ; B 20 0 580 426 ;
C 121 ; WX 600 ; N y ; B 7 -157 592 426 ;
C 122 ; WX 600 ; N z ; B 99 0 502 426 ;
C 123 ; WX 600 ; N braceleft ; B 182 -108 437 622 ;
C 124 ; WX 600 ; N bar ; B 275 -250 326 750 ;
C 125 ; WX 600 ; N braceright ; B 163 -108 418 622 ;
C 126 ; WX 600 ; N asciitilde ; B 63 197 540 320 ;
C 161 ; WX 600 ; N exclamdown ; B 236 -157 364 430 ;
C 162 ; WX 600 ; N cent ; B 96 -49 500 614 ;
C 163 ; WX 600 ; N sterling ; B 84 -21 521 611 ;
C 164 ; WX 600 ; N fraction ; B 92 -57 509 665 ;
C 165 ; WX 600 ; N yen ; B 26 0 574 562 ;
C 166 ; WX 600 ; N florin ; B 4 -143 539 622 ;
C 167 ; WX 600 ; N section ; B 113 -78 488 580 ;
C 168 ; WX 600 ; N currency ; B 73 58 527 506 ;
C 169 ; WX 600 ; N quotesingle ; B 259 328 341 562 ;
C 170 ; WX 600 ; N quotedblleft ; B 143 328 471 562 ;
C 171 ; WX 600 ; N guillemotleft ; B 37 70 563 446 ;
C 172 ; WX 600 ; N guilsinglleft ; B 149 70 451 446 ;
C 173 ; WX 600 ; N guilsinglright ; B 149 70 451 446 ;
C 174 ; WX 600 ; N fi ; B 3 0 597 629 ;
C 175 ; WX 600 ; N fl ; B 3 0 597 629 ;
C 177 ; WX 600 ; N endash ; B 75 231 525 285 ;
C 178 ; WX 600 ; N dagger ; B 141 -78 459 580 ;
C 179 ; WX 600 ; N daggerdbl ; B 141 -78 459 580 ;
C 180 ; WX 600 ; N periodcentered ; B 222 189 378 327 ;
C 182 ; WX 600 ; N paragraph ; B 50 -78 511 562 ;
C 183 ; WX 600 ; N bullet ; B 172 130 428 383 ;
C 184 ; WX 600 ; N quotesinglbase ; B 213 -134 376 100 ;
C 185 ; WX 600 ; N quotedblbase ; B 143 -134 457 100 ;
C 186 ; WX 600 ; N quotedblright ; B 143 328 457 562 ;
C 187 ; WX 600 ; N guillemotright ; B 37 70 563 446 ;
C 188 ; WX 600 ; N ellipsis ; B 37 -15 563 111 ;
C 189 ; WX 600 ; N perthousand ; B 3 -15 600 622 ;
C 191 ; WX 600 ; N questiondown ; B 108 -157 471 430 ;
C 193 ; WX 600 ; N grave ; B 151 497 378 672 ;
C 194 ; WX 600 ; N acute ; B 242 497 469 672 ;
C 195 ; WX 600 ; N circumflex ; B 124 477 476 654 ;
C 196 ; WX 600 ; N tilde ; B 105 489 503 606 ;
C 197 ; WX 600 ; N macron ; B 120 525 480 565 ;
C 198 ; WX 600 ; N breve ; B 153 501 447 609 ;
C 199 ; WX 600 ; N dotaccent ; B 249 537 352 640 ;
C 200 ; WX 600 ; N dieresis ; B 148 537 453 640 ;
C 202 ; WX 600 ; N ring ; B 218 463 382 627 ;
C 203 ; WX 600 ; N cedilla ; B 224 -151 362 10 ;
C 205 ; WX 600 ; N hungarumlaut ; B 133 497 540 672 ;
C 206 ; WX 600 ; N ogonek ; B 211 -172 407 4 ;
C 207 ; WX 600 ; N caron ; B 124 492 476 669 ;
C 208 ; WX 600 ; N emdash ; B 0 231 600 285 ;
C 225 ; WX 600 ; N AE ; B 3 0 550 562 ;
C 227 ; WX 600 ; N ordfeminine ; B 156 249 442 580 ;
C 232 ; WX 600 ; N Lslash ; B 47 0 554 562 ;
C 233 ; WX 600 ; N Oslash ; B 43 -80 557 629 ;
C 234 ; WX 600 ; N OE ; B 7 0 567 562 ;
C 235 ; WX 600 ; N ordmasculine ; B 157 249 443 580 ;
C 241 ; WX 600 ; N ae ; B 19 -15 570 441 ;
C 245 ; WX 600 ; N dotlessi ; B 95 0 505 426 ;
C 248 ; WX 600 ; N lslash ; B 95 0 505 629 ;
C 249 ; WX 600 ; N oslash ; B 62 -80 538 506 ;
C 250 ; WX 600 ; N oe ; B 19 -15 559 441 ;
C 251 ; WX 600 ; N germandbls ; B 48 -15 588 629 ;
C -1 ; WX 600 ; N Idieresis ; B 96 0 504 753 ;
C -1 ; WX 600 ; N eacute ; B 66 -15 548 672 ;
C -1 ; WX 600 ; N abreve ; B 53 -15 559 609 ;
C -1 ; WX 600 ; N uhungarumlaut ; B 21 -15 580 672 ;
C -1 ; WX 600 ; N ecaron ; B 66 -15 548 669 ;
C -1 ; WX 600 ; N Ydieresis ; B 24 0 576 753 ;
C -1 ; WX 600 ; N divide ; B 87 48 513 467 ;
C -1 ; WX 600 ; N Yacute ; B 24 0 576 805 ;
C -1 ; WX 600 ; N Acircumflex ; B 3 0 597 787 ;
C -1 ; WX 600 ; N aacute ; B 53 -15 559 672 ;
C -1 ; WX 600 ; N Ucircumflex ; B 17 -18 583 787 ;
C -1 ; WX 600 ; N yacute ; B 7 -157 592 672 ;
C -1 ; WX 600 ; N scommaaccent ; B 80 -250 513 441 ;
C -1 ; WX 600 ; N ecircumflex ; B 66 -15 548 654 ;
C -1 ; WX 600 ; N Uring ; B 17 -18 583 760 ;
C -1 ; WX 600 ; N Udieresis ; B 17 -18 583 753 ;
C -1 ; WX 600 ; N aogonek ; B 53 -172 587 441 ;
C -1 ; WX 600 ; N Uacute ; B 17 -18 583 805 ;
C -1 ; WX 600 ; N uogonek ; B 21 -172 590 426 ;
C -1 ; WX 600 ; N Edieresis ; B 53 0 550 753 ;
C -1 ; WX 600 ; N Dcroat ; B 30 0 574 562 ;
C -1 ; WX 600 ; N commaaccent ; B 198 -250 335 -58 ;
C -1 ; WX 600 ; N copyright ; B 0 -18 600 580 ;
C -1 ; WX 600 ; N Emacron ; B 53 0 550 698 ;
C -1 ; WX 600 ; N ccaron ; B 66 -15 529 669 ;
C -1 ; WX 600 ; N aring ; B 53 -15 559 627 ;
C -1 ; WX 600 ; N Ncommaaccent ; B 7 -250 593 562 ;
C -1 ; WX 600 ; N lacute ; B 95 0 505 805 ;
C -1 ; WX 600 ; N agrave ; B 53 -15 559 672 ;
C -1 ; WX 600 ; N Tcommaaccent ; B 38 -250 563 562 ;
C -1 ; WX 600 ; N Cacute ; B 41 -18 540 805 ;
C -1 ; WX 600 ; N atilde ; B 53 -15 559 606 ;
C -1 ; WX 600 ; N Edotaccent ; B 53 0 550 753 ;
C -1 ; WX 600 ; N scaron ; B 80 -15 513 669 ;
C -1 ; WX 600 ; N scedilla ; B 80 -151 513 441 ;
C -1 ; WX 600 ; N iacute ; B 95 0 505 672 ;
C -1 ; WX 600 ; N lozenge ; B 18 0 443 706 ;
C -1 ; WX 600 ; N Rcaron ; B 38 0 588 802 ;
C -1 ; WX 600 ; N Gcommaaccent ; B 31 -250 575 580 ;
C -1 ; WX 600 ; N ucircumflex ; B 21 -15 562 654 ;
C -1 ; WX 600 ; N acircumflex ; B 53 -15 559 654 ;
C -1 ; WX 600 ; N Amacron ; B 3 0 597 698 ;
C -1 ; WX 600 ; N rcaron ; B 60 0 559 669 ;
C -1 ; WX 600 ; N ccedilla ; B 66 -151 529 441 ;
C -1 ; WX 600 ; N Zdotaccent ; B 86 0 514 753 ;
C -1 ; WX 600 ; N Thorn ; B 79 0 538 562 ;
C -1 ; WX 600 ; N Omacron ; B 43 -18 557 698 ;
C -1 ; WX 600 ; N Racute ; B 38 0 588 805 ;
C -1 ; WX 600 ; N Sacute ; B 72 -20 529 805 ;
C -1 ; WX 600 ; N dcaron ; B 45 -15 715 629 ;
C -1 ; WX 600 ; N Umacron ; B 17 -18 583 698 ;
C -1 ; WX 600 ; N uring ; B 21 -15 562 627 ;
C -1 ; WX 600 ; N threesuperior ; B 155 240 406 622 ;
C -1 ; WX 600 ; N Ograve ; B 43 -18 557 805 ;
C -1 ; WX 600 ; N Agrave ; B 3 0 597 805 ;
C -1 ; WX 600 ; N Abreve ; B 3 0 597 732 ;
C -1 ; WX 600 ; N multiply ; B 87 43 515 470 ;
C -1 ; WX 600 ; N uacute ; B 21 -15 562 672 ;
C -1 ; WX 600 ; N Tcaron ; B 38 0 563 802 ;
C -1 ; WX 600 ; N partialdiff ; B 17 -38 459 710 ;
C -1 ; WX 600 ; N ydieresis ; B 7 -157 592 620 ;
C -1 ; WX 600 ; N Nacute ; B 7 -13 593 805 ;
C -1 ; WX 600 ; N icircumflex ; B 94 0 505 654 ;
C -1 ; WX 600 ; N Ecircumflex ; B 53 0 550 787 ;
C -1 ; WX 600 ; N adieresis ; B 53 -15 559 620 ;
C -1 ; WX 600 ; N edieresis ; B 66 -15 548 620 ;
C -1 ; WX 600 ; N cacute ; B 66 -15 529 672 ;
C -1 ; WX 600 ; N nacute ; B 26 0 575 672 ;
C -1 ; WX 600 ; N umacron ; B 21 -15 562 565 ;
C -1 ; WX 600 ; N Ncaron ; B 7 -13 593 802 ;
C -1 ; WX 600 ; N Iacute ; B 96 0 504 805 ;
C -1 ; WX 600 ; N plusminus ; B 87 44 513 558 ;
C -1 ; WX 600 ; N brokenbar ; B 275 -175 326 675 ;
C -1 ; WX 600 ; N registered ; B 0 -18 600 580 ;
C -1 ; WX 600 ; N Gbreve ; B 31 -18 575 732 ;
C -1 ; WX 600 ; N Idotaccent ; B 96 0 504 753 ;
C -1 ; WX 600 ; N summation ; B 15 -10 585 706 ;
C -1 ; WX 600 ; N Egrave ; B 53 0 550 805 ;
C -1 ; WX 600 ; N racute ; B 60 0 559 672 ;
C -1 ; WX 600 ; N omacron ; B 62 -15 538 565 ;
C -1 ; WX 600 ; N Zacute ; B 86 0 514 805 ;
C -1 ; WX 600 ; N Zcaron ; B 86 0 514 802 ;
C -1 ; WX 600 ; N greaterequal ; B 98 0 502 710 ;
C -1 ; WX 600 ; N Eth ; B 30 0 574 562 ;
C -1 ; WX 600 ; N Ccedilla ; B 41 -151 540 580 ;
C -1 ; WX 600 ; N lcommaaccent ; B 95 -250 505 629 ;
C -1 ; WX 600 ; N tcaron ; B 87 -15 530 717 ;
C -1 ; WX 600 ; N eogonek ; B 66 -172 548 441 ;
C -1 ; WX 600 ; N Uogonek ; B 17 -172 583 562 ;
C -1 ; WX 600 ; N Aacute ; B 3 0 597 805 ;
C -1 ; WX 600 ; N Adieresis ; B 3 0 597 753 ;
C -1 ; WX 600 ; N egrave ; B 66 -15 548 672 ;
C -1 ; WX 600 ; N zacute ; B 99 0 502 672 ;
C -1 ; WX 600 ; N iogonek ; B 95 -172 505 657 ;
C -1 ; WX 600 ; N Oacute ; B 43 -18 557 805 ;
C -1 ; WX 600 ; N oacute ; B 62 -15 538 672 ;
C -1 ; WX 600 ; N amacron ; B 53 -15 559 565 ;
C -1 ; WX 600 ; N sacute ; B 80 -15 513 672 ;
C -1 ; WX 600 ; N idieresis ; B 95 0 505 620 ;
C -1 ; WX 600 ; N Ocircumflex ; B 43 -18 557 787 ;
C -1 ; WX 600 ; N Ugrave ; B 17 -18 583 805 ;
C -1 ; WX 600 ; N Delta ; B 6 0 598 688 ;
C -1 ; WX 600 ; N thorn ; B -6 -157 555 629 ;
C -1 ; WX 600 ; N twosuperior ; B 177 249 424 622 ;
C -1 ; WX 600 ; N Odieresis ; B 43 -18 557 753 ;
C -1 ; WX 600 ; N mu ; B 21 -157 562 426 ;
C -1 ; WX 600 ; N igrave ; B 95 0 505 672 ;
C -1 ; WX 600 ; N ohungarumlaut ; B 62 -15 580 672 ;
C -1 ; WX 600 ; N Eogonek ; B 53 -172 561 562 ;
C -1 ; WX 600 ; N dcroat ; B 45 -15 591 629 ;
C -1 ; WX 600 ; N threequarters ; B 8 -56 593 666 ;
C -1 ; WX 600 ; N Scedilla ; B 72 -151 529 580 ;
C -1 ; WX 600 ; N lcaron ; B 95 0 533 629 ;
C -1 ; WX 600 ; N Kcommaaccent ; B 38 -250 582 562 ;
C -1 ; WX 600 ; N Lacute ; B 47 0 554 805 ;
C -1 ; WX 600 ; N trademark ; B -23 263 623 562 ;
C -1 ; WX 600 ; N edotaccent ; B 66 -15 548 620 ;
C -1 ; WX 600 ; N Igrave ; B 96 0 504 805 ;
C -1 ; WX 600 ; N Imacron ; B 96 0 504 698 ;
C -1 ; WX 600 ; N Lcaron ; B 47 0 554 562 ;
C -1 ; WX 600 ; N onehalf ; B 0 -57 611 665 ;
C -1 ; WX 600 ; N lessequal ; B 98 0 502 710 ;
C -1 ; WX 600 ; N ocircumflex ; B 62 -15 538 654 ;
C -1 ; WX 600 ; N ntilde ; B 26 0 575 606 ;
C -1 ; WX 600 ; N Uhungarumlaut ; B 17 -18 590 805 ;
C -1 ; WX 600 ; N Eacute ; B 53 0 550 805 ;
C -1 ; WX 600 ; N emacron ; B 66 -15 548 565 ;
C -1 ; WX 600 ; N gbreve ; B 45 -157 566 609 ;
C -1 ; WX 600 ; N onequarter ; B 0 -57 600 665 ;
C -1 ; WX 600 ; N Scaron ; B 72 -20 529 802 ;
C -1 ; WX 600 ; N Scommaaccent ; B 72 -250 529 580 ;
C -1 ; WX 600 ; N Ohungarumlaut ; B 43 -18 580 805 ;
C -1 ; WX 600 ; N degree ; B 123 269 477 622 ;
C -1 ; WX 600 ; N ograve ; B 62 -15 538 672 ;
C -1 ; WX 600 ; N Ccaron ; B 41 -18 540 802 ;
C -1 ; WX 600 ; N ugrave ; B 21 -15 562 672 ;
C -1 ; WX 600 ; N radical ; B 3 -15 597 792 ;
C -1 ; WX 600 ; N Dcaron ; B 43 0 574 802 ;
C -1 ; WX 600 ; N rcommaaccent ; B 60 -250 559 441 ;
C -1 ; WX 600 ; N Ntilde ; B 7 -13 593 729 ;
C -1 ; WX 600 ; N otilde ; B 62 -15 538 606 ;
C -1 ; WX 600 ; N Rcommaaccent ; B 38 -250 588 562 ;
C -1 ; WX 600 ; N Lcommaaccent ; B 47 -250 554 562 ;
C -1 ; WX 600 ; N Atilde ; B 3 0 597 729 ;
C -1 ; WX 600 ; N Aogonek ; B 3 -172 608 562 ;
C -1 ; WX 600 ; N Aring ; B 3 0 597 750 ;
C -1 ; WX 600 ; N Otilde ; B 43 -18 557 729 ;
C -1 ; WX 600 ; N zdotaccent ; B 99 0 502 620 ;
C -1 ; WX 600 ; N Ecaron ; B 53 0 550 802 ;
C -1 ; WX 600 ; N Iogonek ; B 96 -172 504 562 ;
C -1 ; WX 600 ; N kcommaaccent ; B 43 -250 580 629 ;
C -1 ; WX 600 ; N minus ; B 80 232 520 283 ;
C -1 ; WX 600 ; N Icircumflex ; B 96 0 504 787 ;
C -1 ; WX 600 ; N ncaron ; B 26 0 575 669 ;
C -1 ; WX 600 ; N tcommaaccent ; B 87 -250 530 561 ;
C -1 ; WX 600 ; N logicalnot ; B 87 108 513 369 ;
C -1 ; WX 600 ; N odieresis ; B 62 -15 538 620 ;
C -1 ; WX 600 ; N udieresis ; B 21 -15 562 620 ;
C -1 ; WX 600 ; N notequal ; B 15 -16 540 529 ;
C -1 ; WX 600 ; N gcommaaccent ; B 45 -157 566 708 ;
C -1 ; WX 600 ; N eth ; B 62 -15 538 629 ;
C -1 ; WX 600 ; N zcaron ; B 99 0 502 669 ;
C -1 ; WX 600 ; N ncommaaccent ; B 26 -250 575 441 ;
C -1 ; WX 600 ; N onesuperior ; B 172 249 428 622 ;
C -1 ; WX 600 ; N imacron ; B 95 0 505 565 ;
C -1 ; WX 600 ; N Euro ; B 0 0 0 0 ;
EndCharMetrics
EndFontMetrics
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
StartFontMetrics 4.1
Comment Copyright (c) 1985, 1987, 1989, 1990, 1997 Adobe Systems Incorporated. All rights reserved.
Comment Creation Date: Thu May 1 15:12:25 1997
Comment UniqueID 43064
Comment VMusage 30820 39997
FontName Symbol
FullName Symbol
FamilyName Symbol
Weight Medium
ItalicAngle 0
IsFixedPitch false
CharacterSet Special
FontBBox -180 -293 1090 1010
UnderlinePosition -100
UnderlineThickness 50
Version 001.008
Notice Copyright (c) 1985, 1987, 1989, 1990, 1997 Adobe Systems Incorporated. All rights reserved.
EncodingScheme FontSpecific
StdHW 92
StdVW 85
StartCharMetrics 190
C 32 ; WX 250 ; N space ; B 0 0 0 0 ;
C 33 ; WX 333 ; N exclam ; B 128 -17 240 672 ;
C 34 ; WX 713 ; N universal ; B 31 0 681 705 ;
C 35 ; WX 500 ; N numbersign ; B 20 -16 481 673 ;
C 36 ; WX 549 ; N existential ; B 25 0 478 707 ;
C 37 ; WX 833 ; N percent ; B 63 -36 771 655 ;
C 38 ; WX 778 ; N ampersand ; B 41 -18 750 661 ;
C 39 ; WX 439 ; N suchthat ; B 48 -17 414 500 ;
C 40 ; WX 333 ; N parenleft ; B 53 -191 300 673 ;
C 41 ; WX 333 ; N parenright ; B 30 -191 277 673 ;
C 42 ; WX 500 ; N asteriskmath ; B 65 134 427 551 ;
C 43 ; WX 549 ; N plus ; B 10 0 539 533 ;
C 44 ; WX 250 ; N comma ; B 56 -152 194 104 ;
C 45 ; WX 549 ; N minus ; B 11 233 535 288 ;
C 46 ; WX 250 ; N period ; B 69 -17 181 95 ;
C 47 ; WX 278 ; N slash ; B 0 -18 254 646 ;
C 48 ; WX 500 ; N zero ; B 24 -14 476 685 ;
C 49 ; WX 500 ; N one ; B 117 0 390 673 ;
C 50 ; WX 500 ; N two ; B 25 0 475 685 ;
C 51 ; WX 500 ; N three ; B 43 -14 435 685 ;
C 52 ; WX 500 ; N four ; B 15 0 469 685 ;
C 53 ; WX 500 ; N five ; B 32 -14 445 690 ;
C 54 ; WX 500 ; N six ; B 34 -14 468 685 ;
C 55 ; WX 500 ; N seven ; B 24 -16 448 673 ;
C 56 ; WX 500 ; N eight ; B 56 -14 445 685 ;
C 57 ; WX 500 ; N nine ; B 30 -18 459 685 ;
C 58 ; WX 278 ; N colon ; B 81 -17 193 460 ;
C 59 ; WX 278 ; N semicolon ; B 83 -152 221 460 ;
C 60 ; WX 549 ; N less ; B 26 0 523 522 ;
C 61 ; WX 549 ; N equal ; B 11 141 537 390 ;
C 62 ; WX 549 ; N greater ; B 26 0 523 522 ;
C 63 ; WX 444 ; N question ; B 70 -17 412 686 ;
C 64 ; WX 549 ; N congruent ; B 11 0 537 475 ;
C 65 ; WX 722 ; N Alpha ; B 4 0 684 673 ;
C 66 ; WX 667 ; N Beta ; B 29 0 592 673 ;
C 67 ; WX 722 ; N Chi ; B -9 0 704 673 ;
C 68 ; WX 612 ; N Delta ; B 6 0 608 688 ;
C 69 ; WX 611 ; N Epsilon ; B 32 0 617 673 ;
C 70 ; WX 763 ; N Phi ; B 26 0 741 673 ;
C 71 ; WX 603 ; N Gamma ; B 24 0 609 673 ;
C 72 ; WX 722 ; N Eta ; B 39 0 729 673 ;
C 73 ; WX 333 ; N Iota ; B 32 0 316 673 ;
C 74 ; WX 631 ; N theta1 ; B 18 -18 623 689 ;
C 75 ; WX 722 ; N Kappa ; B 35 0 722 673 ;
C 76 ; WX 686 ; N Lambda ; B 6 0 680 688 ;
C 77 ; WX 889 ; N Mu ; B 28 0 887 673 ;
C 78 ; WX 722 ; N Nu ; B 29 -8 720 673 ;
C 79 ; WX 722 ; N Omicron ; B 41 -17 715 685 ;
C 80 ; WX 768 ; N Pi ; B 25 0 745 673 ;
C 81 ; WX 741 ; N Theta ; B 41 -17 715 685 ;
C 82 ; WX 556 ; N Rho ; B 28 0 563 673 ;
C 83 ; WX 592 ; N Sigma ; B 5 0 589 673 ;
C 84 ; WX 611 ; N Tau ; B 33 0 607 673 ;
C 85 ; WX 690 ; N Upsilon ; B -8 0 694 673 ;
C 86 ; WX 439 ; N sigma1 ; B 40 -233 436 500 ;
C 87 ; WX 768 ; N Omega ; B 34 0 736 688 ;
C 88 ; WX 645 ; N Xi ; B 40 0 599 673 ;
C 89 ; WX 795 ; N Psi ; B 15 0 781 684 ;
C 90 ; WX 611 ; N Zeta ; B 44 0 636 673 ;
C 91 ; WX 333 ; N bracketleft ; B 86 -155 299 674 ;
C 92 ; WX 863 ; N therefore ; B 163 0 701 487 ;
C 93 ; WX 333 ; N bracketright ; B 33 -155 246 674 ;
C 94 ; WX 658 ; N perpendicular ; B 15 0 652 674 ;
C 95 ; WX 500 ; N underscore ; B -2 -125 502 -75 ;
C 96 ; WX 500 ; N radicalex ; B 480 881 1090 917 ;
C 97 ; WX 631 ; N alpha ; B 41 -18 622 500 ;
C 98 ; WX 549 ; N beta ; B 61 -223 515 741 ;
C 99 ; WX 549 ; N chi ; B 12 -231 522 499 ;
C 100 ; WX 494 ; N delta ; B 40 -19 481 740 ;
C 101 ; WX 439 ; N epsilon ; B 22 -19 427 502 ;
C 102 ; WX 521 ; N phi ; B 28 -224 492 673 ;
C 103 ; WX 411 ; N gamma ; B 5 -225 484 499 ;
C 104 ; WX 603 ; N eta ; B 0 -202 527 514 ;
C 105 ; WX 329 ; N iota ; B 0 -17 301 503 ;
C 106 ; WX 603 ; N phi1 ; B 36 -224 587 499 ;
C 107 ; WX 549 ; N kappa ; B 33 0 558 501 ;
C 108 ; WX 549 ; N lambda ; B 24 -17 548 739 ;
C 109 ; WX 576 ; N mu ; B 33 -223 567 500 ;
C 110 ; WX 521 ; N nu ; B -9 -16 475 507 ;
C 111 ; WX 549 ; N omicron ; B 35 -19 501 499 ;
C 112 ; WX 549 ; N pi ; B 10 -19 530 487 ;
C 113 ; WX 521 ; N theta ; B 43 -17 485 690 ;
C 114 ; WX 549 ; N rho ; B 50 -230 490 499 ;
C 115 ; WX 603 ; N sigma ; B 30 -21 588 500 ;
C 116 ; WX 439 ; N tau ; B 10 -19 418 500 ;
C 117 ; WX 576 ; N upsilon ; B 7 -18 535 507 ;
C 118 ; WX 713 ; N omega1 ; B 12 -18 671 583 ;
C 119 ; WX 686 ; N omega ; B 42 -17 684 500 ;
C 120 ; WX 493 ; N xi ; B 27 -224 469 766 ;
C 121 ; WX 686 ; N psi ; B 12 -228 701 500 ;
C 122 ; WX 494 ; N zeta ; B 60 -225 467 756 ;
C 123 ; WX 480 ; N braceleft ; B 58 -183 397 673 ;
C 124 ; WX 200 ; N bar ; B 65 -293 135 707 ;
C 125 ; WX 480 ; N braceright ; B 79 -183 418 673 ;
C 126 ; WX 549 ; N similar ; B 17 203 529 307 ;
C 160 ; WX 750 ; N Euro ; B 20 -12 714 685 ;
C 161 ; WX 620 ; N Upsilon1 ; B -2 0 610 685 ;
C 162 ; WX 247 ; N minute ; B 27 459 228 735 ;
C 163 ; WX 549 ; N lessequal ; B 29 0 526 639 ;
C 164 ; WX 167 ; N fraction ; B -180 -12 340 677 ;
C 165 ; WX 713 ; N infinity ; B 26 124 688 404 ;
C 166 ; WX 500 ; N florin ; B 2 -193 494 686 ;
C 167 ; WX 753 ; N club ; B 86 -26 660 533 ;
C 168 ; WX 753 ; N diamond ; B 142 -36 600 550 ;
C 169 ; WX 753 ; N heart ; B 117 -33 631 532 ;
C 170 ; WX 753 ; N spade ; B 113 -36 629 548 ;
C 171 ; WX 1042 ; N arrowboth ; B 24 -15 1024 511 ;
C 172 ; WX 987 ; N arrowleft ; B 32 -15 942 511 ;
C 173 ; WX 603 ; N arrowup ; B 45 0 571 910 ;
C 174 ; WX 987 ; N arrowright ; B 49 -15 959 511 ;
C 175 ; WX 603 ; N arrowdown ; B 45 -22 571 888 ;
C 176 ; WX 400 ; N degree ; B 50 385 350 685 ;
C 177 ; WX 549 ; N plusminus ; B 10 0 539 645 ;
C 178 ; WX 411 ; N second ; B 20 459 413 737 ;
C 179 ; WX 549 ; N greaterequal ; B 29 0 526 639 ;
C 180 ; WX 549 ; N multiply ; B 17 8 533 524 ;
C 181 ; WX 713 ; N proportional ; B 27 123 639 404 ;
C 182 ; WX 494 ; N partialdiff ; B 26 -20 462 746 ;
C 183 ; WX 460 ; N bullet ; B 50 113 410 473 ;
C 184 ; WX 549 ; N divide ; B 10 71 536 456 ;
C 185 ; WX 549 ; N notequal ; B 15 -25 540 549 ;
C 186 ; WX 549 ; N equivalence ; B 14 82 538 443 ;
C 187 ; WX 549 ; N approxequal ; B 14 135 527 394 ;
C 188 ; WX 1000 ; N ellipsis ; B 111 -17 889 95 ;
C 189 ; WX 603 ; N arrowvertex ; B 280 -120 336 1010 ;
C 190 ; WX 1000 ; N arrowhorizex ; B -60 220 1050 276 ;
C 191 ; WX 658 ; N carriagereturn ; B 15 -16 602 629 ;
C 192 ; WX 823 ; N aleph ; B 175 -18 661 658 ;
C 193 ; WX 686 ; N Ifraktur ; B 10 -53 578 740 ;
C 194 ; WX 795 ; N Rfraktur ; B 26 -15 759 734 ;
C 195 ; WX 987 ; N weierstrass ; B 159 -211 870 573 ;
C 196 ; WX 768 ; N circlemultiply ; B 43 -17 733 673 ;
C 197 ; WX 768 ; N circleplus ; B 43 -15 733 675 ;
C 198 ; WX 823 ; N emptyset ; B 39 -24 781 719 ;
C 199 ; WX 768 ; N intersection ; B 40 0 732 509 ;
C 200 ; WX 768 ; N union ; B 40 -17 732 492 ;
C 201 ; WX 713 ; N propersuperset ; B 20 0 673 470 ;
C 202 ; WX 713 ; N reflexsuperset ; B 20 -125 673 470 ;
C 203 ; WX 713 ; N notsubset ; B 36 -70 690 540 ;
C 204 ; WX 713 ; N propersubset ; B 37 0 690 470 ;
C 205 ; WX 713 ; N reflexsubset ; B 37 -125 690 470 ;
C 206 ; WX 713 ; N element ; B 45 0 505 468 ;
C 207 ; WX 713 ; N notelement ; B 45 -58 505 555 ;
C 208 ; WX 768 ; N angle ; B 26 0 738 673 ;
C 209 ; WX 713 ; N gradient ; B 36 -19 681 718 ;
C 210 ; WX 790 ; N registerserif ; B 50 -17 740 673 ;
C 211 ; WX 790 ; N copyrightserif ; B 51 -15 741 675 ;
C 212 ; WX 890 ; N trademarkserif ; B 18 293 855 673 ;
C 213 ; WX 823 ; N product ; B 25 -101 803 751 ;
C 214 ; WX 549 ; N radical ; B 10 -38 515 917 ;
C 215 ; WX 250 ; N dotmath ; B 69 210 169 310 ;
C 216 ; WX 713 ; N logicalnot ; B 15 0 680 288 ;
C 217 ; WX 603 ; N logicaland ; B 23 0 583 454 ;
C 218 ; WX 603 ; N logicalor ; B 30 0 578 477 ;
C 219 ; WX 1042 ; N arrowdblboth ; B 27 -20 1023 510 ;
C 220 ; WX 987 ; N arrowdblleft ; B 30 -15 939 513 ;
C 221 ; WX 603 ; N arrowdblup ; B 39 2 567 911 ;
C 222 ; WX 987 ; N arrowdblright ; B 45 -20 954 508 ;
C 223 ; WX 603 ; N arrowdbldown ; B 44 -19 572 890 ;
C 224 ; WX 494 ; N lozenge ; B 18 0 466 745 ;
C 225 ; WX 329 ; N angleleft ; B 25 -198 306 746 ;
C 226 ; WX 790 ; N registersans ; B 50 -20 740 670 ;
C 227 ; WX 790 ; N copyrightsans ; B 49 -15 739 675 ;
C 228 ; WX 786 ; N trademarksans ; B 5 293 725 673 ;
C 229 ; WX 713 ; N summation ; B 14 -108 695 752 ;
C 230 ; WX 384 ; N parenlefttp ; B 24 -293 436 926 ;
C 231 ; WX 384 ; N parenleftex ; B 24 -85 108 925 ;
C 232 ; WX 384 ; N parenleftbt ; B 24 -293 436 926 ;
C 233 ; WX 384 ; N bracketlefttp ; B 0 -80 349 926 ;
C 234 ; WX 384 ; N bracketleftex ; B 0 -79 77 925 ;
C 235 ; WX 384 ; N bracketleftbt ; B 0 -80 349 926 ;
C 236 ; WX 494 ; N bracelefttp ; B 209 -85 445 925 ;
C 237 ; WX 494 ; N braceleftmid ; B 20 -85 284 935 ;
C 238 ; WX 494 ; N braceleftbt ; B 209 -75 445 935 ;
C 239 ; WX 494 ; N braceex ; B 209 -85 284 935 ;
C 241 ; WX 329 ; N angleright ; B 21 -198 302 746 ;
C 242 ; WX 274 ; N integral ; B 2 -107 291 916 ;
C 243 ; WX 686 ; N integraltp ; B 308 -88 675 920 ;
C 244 ; WX 686 ; N integralex ; B 308 -88 378 975 ;
C 245 ; WX 686 ; N integralbt ; B 11 -87 378 921 ;
C 246 ; WX 384 ; N parenrighttp ; B 54 -293 466 926 ;
C 247 ; WX 384 ; N parenrightex ; B 382 -85 466 925 ;
C 248 ; WX 384 ; N parenrightbt ; B 54 -293 466 926 ;
C 249 ; WX 384 ; N bracketrighttp ; B 22 -80 371 926 ;
C 250 ; WX 384 ; N bracketrightex ; B 294 -79 371 925 ;
C 251 ; WX 384 ; N bracketrightbt ; B 22 -80 371 926 ;
C 252 ; WX 494 ; N bracerighttp ; B 48 -85 284 925 ;
C 253 ; WX 494 ; N bracerightmid ; B 209 -85 473 935 ;
C 254 ; WX 494 ; N bracerightbt ; B 48 -75 284 935 ;
C -1 ; WX 790 ; N apple ; B 56 -3 733 808 ;
EndCharMetrics
EndFontMetrics
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
StartFontMetrics 4.1
Comment Copyright (c) 1985, 1987, 1988, 1989, 1997 Adobe Systems Incorporated. All Rights Reserved.
Comment Creation Date: Thu May 1 15:14:13 1997
Comment UniqueID 43082
Comment VMusage 45775 55535
FontName ZapfDingbats
FullName ITC Zapf Dingbats
FamilyName ZapfDingbats
Weight Medium
ItalicAngle 0
IsFixedPitch false
CharacterSet Special
FontBBox -1 -143 981 820
UnderlinePosition -100
UnderlineThickness 50
Version 002.000
Notice Copyright (c) 1985, 1987, 1988, 1989, 1997 Adobe Systems Incorporated. All Rights Reserved.ITC Zapf Dingbats is a registered trademark of International Typeface Corporation.
EncodingScheme FontSpecific
StdHW 28
StdVW 90
StartCharMetrics 202
C 32 ; WX 278 ; N space ; B 0 0 0 0 ;
C 33 ; WX 974 ; N a1 ; B 35 72 939 621 ;
C 34 ; WX 961 ; N a2 ; B 35 81 927 611 ;
C 35 ; WX 974 ; N a202 ; B 35 72 939 621 ;
C 36 ; WX 980 ; N a3 ; B 35 0 945 692 ;
C 37 ; WX 719 ; N a4 ; B 34 139 685 566 ;
C 38 ; WX 789 ; N a5 ; B 35 -14 755 705 ;
C 39 ; WX 790 ; N a119 ; B 35 -14 755 705 ;
C 40 ; WX 791 ; N a118 ; B 35 -13 761 705 ;
C 41 ; WX 690 ; N a117 ; B 34 138 655 553 ;
C 42 ; WX 960 ; N a11 ; B 35 123 925 568 ;
C 43 ; WX 939 ; N a12 ; B 35 134 904 559 ;
C 44 ; WX 549 ; N a13 ; B 29 -11 516 705 ;
C 45 ; WX 855 ; N a14 ; B 34 59 820 632 ;
C 46 ; WX 911 ; N a15 ; B 35 50 876 642 ;
C 47 ; WX 933 ; N a16 ; B 35 139 899 550 ;
C 48 ; WX 911 ; N a105 ; B 35 50 876 642 ;
C 49 ; WX 945 ; N a17 ; B 35 139 909 553 ;
C 50 ; WX 974 ; N a18 ; B 35 104 938 587 ;
C 51 ; WX 755 ; N a19 ; B 34 -13 721 705 ;
C 52 ; WX 846 ; N a20 ; B 36 -14 811 705 ;
C 53 ; WX 762 ; N a21 ; B 35 0 727 692 ;
C 54 ; WX 761 ; N a22 ; B 35 0 727 692 ;
C 55 ; WX 571 ; N a23 ; B -1 -68 571 661 ;
C 56 ; WX 677 ; N a24 ; B 36 -13 642 705 ;
C 57 ; WX 763 ; N a25 ; B 35 0 728 692 ;
C 58 ; WX 760 ; N a26 ; B 35 0 726 692 ;
C 59 ; WX 759 ; N a27 ; B 35 0 725 692 ;
C 60 ; WX 754 ; N a28 ; B 35 0 720 692 ;
C 61 ; WX 494 ; N a6 ; B 35 0 460 692 ;
C 62 ; WX 552 ; N a7 ; B 35 0 517 692 ;
C 63 ; WX 537 ; N a8 ; B 35 0 503 692 ;
C 64 ; WX 577 ; N a9 ; B 35 96 542 596 ;
C 65 ; WX 692 ; N a10 ; B 35 -14 657 705 ;
C 66 ; WX 786 ; N a29 ; B 35 -14 751 705 ;
C 67 ; WX 788 ; N a30 ; B 35 -14 752 705 ;
C 68 ; WX 788 ; N a31 ; B 35 -14 753 705 ;
C 69 ; WX 790 ; N a32 ; B 35 -14 756 705 ;
C 70 ; WX 793 ; N a33 ; B 35 -13 759 705 ;
C 71 ; WX 794 ; N a34 ; B 35 -13 759 705 ;
C 72 ; WX 816 ; N a35 ; B 35 -14 782 705 ;
C 73 ; WX 823 ; N a36 ; B 35 -14 787 705 ;
C 74 ; WX 789 ; N a37 ; B 35 -14 754 705 ;
C 75 ; WX 841 ; N a38 ; B 35 -14 807 705 ;
C 76 ; WX 823 ; N a39 ; B 35 -14 789 705 ;
C 77 ; WX 833 ; N a40 ; B 35 -14 798 705 ;
C 78 ; WX 816 ; N a41 ; B 35 -13 782 705 ;
C 79 ; WX 831 ; N a42 ; B 35 -14 796 705 ;
C 80 ; WX 923 ; N a43 ; B 35 -14 888 705 ;
C 81 ; WX 744 ; N a44 ; B 35 0 710 692 ;
C 82 ; WX 723 ; N a45 ; B 35 0 688 692 ;
C 83 ; WX 749 ; N a46 ; B 35 0 714 692 ;
C 84 ; WX 790 ; N a47 ; B 34 -14 756 705 ;
C 85 ; WX 792 ; N a48 ; B 35 -14 758 705 ;
C 86 ; WX 695 ; N a49 ; B 35 -14 661 706 ;
C 87 ; WX 776 ; N a50 ; B 35 -6 741 699 ;
C 88 ; WX 768 ; N a51 ; B 35 -7 734 699 ;
C 89 ; WX 792 ; N a52 ; B 35 -14 757 705 ;
C 90 ; WX 759 ; N a53 ; B 35 0 725 692 ;
C 91 ; WX 707 ; N a54 ; B 35 -13 672 704 ;
C 92 ; WX 708 ; N a55 ; B 35 -14 672 705 ;
C 93 ; WX 682 ; N a56 ; B 35 -14 647 705 ;
C 94 ; WX 701 ; N a57 ; B 35 -14 666 705 ;
C 95 ; WX 826 ; N a58 ; B 35 -14 791 705 ;
C 96 ; WX 815 ; N a59 ; B 35 -14 780 705 ;
C 97 ; WX 789 ; N a60 ; B 35 -14 754 705 ;
C 98 ; WX 789 ; N a61 ; B 35 -14 754 705 ;
C 99 ; WX 707 ; N a62 ; B 34 -14 673 705 ;
C 100 ; WX 687 ; N a63 ; B 36 0 651 692 ;
C 101 ; WX 696 ; N a64 ; B 35 0 661 691 ;
C 102 ; WX 689 ; N a65 ; B 35 0 655 692 ;
C 103 ; WX 786 ; N a66 ; B 34 -14 751 705 ;
C 104 ; WX 787 ; N a67 ; B 35 -14 752 705 ;
C 105 ; WX 713 ; N a68 ; B 35 -14 678 705 ;
C 106 ; WX 791 ; N a69 ; B 35 -14 756 705 ;
C 107 ; WX 785 ; N a70 ; B 36 -14 751 705 ;
C 108 ; WX 791 ; N a71 ; B 35 -14 757 705 ;
C 109 ; WX 873 ; N a72 ; B 35 -14 838 705 ;
C 110 ; WX 761 ; N a73 ; B 35 0 726 692 ;
C 111 ; WX 762 ; N a74 ; B 35 0 727 692 ;
C 112 ; WX 762 ; N a203 ; B 35 0 727 692 ;
C 113 ; WX 759 ; N a75 ; B 35 0 725 692 ;
C 114 ; WX 759 ; N a204 ; B 35 0 725 692 ;
C 115 ; WX 892 ; N a76 ; B 35 0 858 705 ;
C 116 ; WX 892 ; N a77 ; B 35 -14 858 692 ;
C 117 ; WX 788 ; N a78 ; B 35 -14 754 705 ;
C 118 ; WX 784 ; N a79 ; B 35 -14 749 705 ;
C 119 ; WX 438 ; N a81 ; B 35 -14 403 705 ;
C 120 ; WX 138 ; N a82 ; B 35 0 104 692 ;
C 121 ; WX 277 ; N a83 ; B 35 0 242 692 ;
C 122 ; WX 415 ; N a84 ; B 35 0 380 692 ;
C 123 ; WX 392 ; N a97 ; B 35 263 357 705 ;
C 124 ; WX 392 ; N a98 ; B 34 263 357 705 ;
C 125 ; WX 668 ; N a99 ; B 35 263 633 705 ;
C 126 ; WX 668 ; N a100 ; B 36 263 634 705 ;
C 128 ; WX 390 ; N a89 ; B 35 -14 356 705 ;
C 129 ; WX 390 ; N a90 ; B 35 -14 355 705 ;
C 130 ; WX 317 ; N a93 ; B 35 0 283 692 ;
C 131 ; WX 317 ; N a94 ; B 35 0 283 692 ;
C 132 ; WX 276 ; N a91 ; B 35 0 242 692 ;
C 133 ; WX 276 ; N a92 ; B 35 0 242 692 ;
C 134 ; WX 509 ; N a205 ; B 35 0 475 692 ;
C 135 ; WX 509 ; N a85 ; B 35 0 475 692 ;
C 136 ; WX 410 ; N a206 ; B 35 0 375 692 ;
C 137 ; WX 410 ; N a86 ; B 35 0 375 692 ;
C 138 ; WX 234 ; N a87 ; B 35 -14 199 705 ;
C 139 ; WX 234 ; N a88 ; B 35 -14 199 705 ;
C 140 ; WX 334 ; N a95 ; B 35 0 299 692 ;
C 141 ; WX 334 ; N a96 ; B 35 0 299 692 ;
C 161 ; WX 732 ; N a101 ; B 35 -143 697 806 ;
C 162 ; WX 544 ; N a102 ; B 56 -14 488 706 ;
C 163 ; WX 544 ; N a103 ; B 34 -14 508 705 ;
C 164 ; WX 910 ; N a104 ; B 35 40 875 651 ;
C 165 ; WX 667 ; N a106 ; B 35 -14 633 705 ;
C 166 ; WX 760 ; N a107 ; B 35 -14 726 705 ;
C 167 ; WX 760 ; N a108 ; B 0 121 758 569 ;
C 168 ; WX 776 ; N a112 ; B 35 0 741 705 ;
C 169 ; WX 595 ; N a111 ; B 34 -14 560 705 ;
C 170 ; WX 694 ; N a110 ; B 35 -14 659 705 ;
C 171 ; WX 626 ; N a109 ; B 34 0 591 705 ;
C 172 ; WX 788 ; N a120 ; B 35 -14 754 705 ;
C 173 ; WX 788 ; N a121 ; B 35 -14 754 705 ;
C 174 ; WX 788 ; N a122 ; B 35 -14 754 705 ;
C 175 ; WX 788 ; N a123 ; B 35 -14 754 705 ;
C 176 ; WX 788 ; N a124 ; B 35 -14 754 705 ;
C 177 ; WX 788 ; N a125 ; B 35 -14 754 705 ;
C 178 ; WX 788 ; N a126 ; B 35 -14 754 705 ;
C 179 ; WX 788 ; N a127 ; B 35 -14 754 705 ;
C 180 ; WX 788 ; N a128 ; B 35 -14 754 705 ;
C 181 ; WX 788 ; N a129 ; B 35 -14 754 705 ;
C 182 ; WX 788 ; N a130 ; B 35 -14 754 705 ;
C 183 ; WX 788 ; N a131 ; B 35 -14 754 705 ;
C 184 ; WX 788 ; N a132 ; B 35 -14 754 705 ;
C 185 ; WX 788 ; N a133 ; B 35 -14 754 705 ;
C 186 ; WX 788 ; N a134 ; B 35 -14 754 705 ;
C 187 ; WX 788 ; N a135 ; B 35 -14 754 705 ;
C 188 ; WX 788 ; N a136 ; B 35 -14 754 705 ;
C 189 ; WX 788 ; N a137 ; B 35 -14 754 705 ;
C 190 ; WX 788 ; N a138 ; B 35 -14 754 705 ;
C 191 ; WX 788 ; N a139 ; B 35 -14 754 705 ;
C 192 ; WX 788 ; N a140 ; B 35 -14 754 705 ;
C 193 ; WX 788 ; N a141 ; B 35 -14 754 705 ;
C 194 ; WX 788 ; N a142 ; B 35 -14 754 705 ;
C 195 ; WX 788 ; N a143 ; B 35 -14 754 705 ;
C 196 ; WX 788 ; N a144 ; B 35 -14 754 705 ;
C 197 ; WX 788 ; N a145 ; B 35 -14 754 705 ;
C 198 ; WX 788 ; N a146 ; B 35 -14 754 705 ;
C 199 ; WX 788 ; N a147 ; B 35 -14 754 705 ;
C 200 ; WX 788 ; N a148 ; B 35 -14 754 705 ;
C 201 ; WX 788 ; N a149 ; B 35 -14 754 705 ;
C 202 ; WX 788 ; N a150 ; B 35 -14 754 705 ;
C 203 ; WX 788 ; N a151 ; B 35 -14 754 705 ;
C 204 ; WX 788 ; N a152 ; B 35 -14 754 705 ;
C 205 ; WX 788 ; N a153 ; B 35 -14 754 705 ;
C 206 ; WX 788 ; N a154 ; B 35 -14 754 705 ;
C 207 ; WX 788 ; N a155 ; B 35 -14 754 705 ;
C 208 ; WX 788 ; N a156 ; B 35 -14 754 705 ;
C 209 ; WX 788 ; N a157 ; B 35 -14 754 705 ;
C 210 ; WX 788 ; N a158 ; B 35 -14 754 705 ;
C 211 ; WX 788 ; N a159 ; B 35 -14 754 705 ;
C 212 ; WX 894 ; N a160 ; B 35 58 860 634 ;
C 213 ; WX 838 ; N a161 ; B 35 152 803 540 ;
C 214 ; WX 1016 ; N a163 ; B 34 152 981 540 ;
C 215 ; WX 458 ; N a164 ; B 35 -127 422 820 ;
C 216 ; WX 748 ; N a196 ; B 35 94 698 597 ;
C 217 ; WX 924 ; N a165 ; B 35 140 890 552 ;
C 218 ; WX 748 ; N a192 ; B 35 94 698 597 ;
C 219 ; WX 918 ; N a166 ; B 35 166 884 526 ;
C 220 ; WX 927 ; N a167 ; B 35 32 892 660 ;
C 221 ; WX 928 ; N a168 ; B 35 129 891 562 ;
C 222 ; WX 928 ; N a169 ; B 35 128 893 563 ;
C 223 ; WX 834 ; N a170 ; B 35 155 799 537 ;
C 224 ; WX 873 ; N a171 ; B 35 93 838 599 ;
C 225 ; WX 828 ; N a172 ; B 35 104 791 588 ;
C 226 ; WX 924 ; N a173 ; B 35 98 889 594 ;
C 227 ; WX 924 ; N a162 ; B 35 98 889 594 ;
C 228 ; WX 917 ; N a174 ; B 35 0 882 692 ;
C 229 ; WX 930 ; N a175 ; B 35 84 896 608 ;
C 230 ; WX 931 ; N a176 ; B 35 84 896 608 ;
C 231 ; WX 463 ; N a177 ; B 35 -99 429 791 ;
C 232 ; WX 883 ; N a178 ; B 35 71 848 623 ;
C 233 ; WX 836 ; N a179 ; B 35 44 802 648 ;
C 234 ; WX 836 ; N a193 ; B 35 44 802 648 ;
C 235 ; WX 867 ; N a180 ; B 35 101 832 591 ;
C 236 ; WX 867 ; N a199 ; B 35 101 832 591 ;
C 237 ; WX 696 ; N a181 ; B 35 44 661 648 ;
C 238 ; WX 696 ; N a200 ; B 35 44 661 648 ;
C 239 ; WX 874 ; N a182 ; B 35 77 840 619 ;
C 241 ; WX 874 ; N a201 ; B 35 73 840 615 ;
C 242 ; WX 760 ; N a183 ; B 35 0 725 692 ;
C 243 ; WX 946 ; N a184 ; B 35 160 911 533 ;
C 244 ; WX 771 ; N a197 ; B 34 37 736 655 ;
C 245 ; WX 865 ; N a185 ; B 35 207 830 481 ;
C 246 ; WX 771 ; N a194 ; B 34 37 736 655 ;
C 247 ; WX 888 ; N a198 ; B 34 -19 853 712 ;
C 248 ; WX 967 ; N a186 ; B 35 124 932 568 ;
C 249 ; WX 888 ; N a195 ; B 34 -19 853 712 ;
C 250 ; WX 831 ; N a187 ; B 35 113 796 579 ;
C 251 ; WX 873 ; N a188 ; B 36 118 838 578 ;
C 252 ; WX 927 ; N a189 ; B 35 150 891 542 ;
C 253 ; WX 970 ; N a190 ; B 35 76 931 616 ;
C 254 ; WX 918 ; N a191 ; B 34 99 884 593 ;
EndCharMetrics
EndFontMetrics
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
@echo off
rem ==================================================================
rem Registers the portal to start AUTOMATICALLY when Windows boots
rem (no login required) and starts it right now.
rem >> RUN THIS AS ADMINISTRATOR (right-click - Run as administrator)
rem All real work happens in install-autostart.ps1 (robust quoting).
rem ==================================================================
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-autostart.ps1"
pause
+44
View File
@@ -0,0 +1,44 @@
# ==================================================================
# SAP-ERP Portal - autostart installer (called by install-autostart.bat)
# Registers a Task-Scheduler task that starts the portal at every
# Windows boot (as SYSTEM, hidden), and starts it right now.
# ==================================================================
$ErrorActionPreference = 'Stop'
# must be admin
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host 'This script must be run as Administrator.' -ForegroundColor Red
Write-Host 'Right-click install-autostart.bat and choose "Run as administrator".'
exit 1
}
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$vbs = Join-Path $here 'run-hidden.vbs'
if (-not (Test-Path $vbs)) { Write-Host "Missing: $vbs" -ForegroundColor Red; exit 1 }
Write-Host 'Creating scheduled task "SAP-ERP-Portal"...'
$action = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument ('"{0}"' -f $vbs)
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName 'SAP-ERP-Portal' -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Write-Host 'Starting the portal now...'
Start-ScheduledTask -TaskName 'SAP-ERP-Portal'
Start-Sleep -Seconds 4
$state = (Get-ScheduledTask -TaskName 'SAP-ERP-Portal').State
Write-Host ''
Write-Host "Task state: $state"
$node = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'server\.js' }
if ($node) { Write-Host ("Portal RUNNING - node PID " + ($node.ProcessId -join ', ')) -ForegroundColor Green }
else { Write-Host 'Portal process not detected yet - check logs\server.log & logs\supervisor.log' -ForegroundColor Yellow }
Write-Host ''
Write-Host 'Done. The portal now starts automatically with Windows.'
Write-Host 'Check anytime with deploy\status.bat'
exit 0
+27
View File
@@ -0,0 +1,27 @@
@echo off
rem ==================================================================
rem Opens Windows Firewall so OTHER PCs on the network can reach the
rem portal. The port is read automatically from .env (PORT=...),
rem falling back to 5000. >> RUN AS ADMINISTRATOR
rem Re-run this after changing PORT in .env (old rule is replaced).
rem ==================================================================
net session >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo This script must be run as Administrator.
pause
exit /b 1
)
set "PORT=5000"
if exist "%~dp0..\.env" (
for /f "tokens=2 delims==" %%P in ('findstr /b /i "PORT=" "%~dp0..\.env"') do set "PORT=%%P"
)
rem strip possible spaces
set "PORT=%PORT: =%"
echo Using port: %PORT%
netsh advfirewall firewall delete rule name="SAP-ERP-Portal" >nul 2>&1
netsh advfirewall firewall delete rule name="SAP-ERP-Portal 5000" >nul 2>&1
netsh advfirewall firewall add rule name="SAP-ERP-Portal" dir=in action=allow protocol=TCP localport=%PORT%
echo Firewall rule added for TCP port %PORT%.
pause
+37
View File
@@ -0,0 +1,37 @@
@echo off
rem ==================================================================
rem Locks the application folder down so ONLY Administrators and the
rem SYSTEM account (which runs the portal's scheduled task) can read
rem it. Normal / standard Windows users on this machine will get
rem "Access denied" when trying to open the folder - protecting the
rem source code and, far more importantly, the passwords in .env.
rem
rem >> RUN AS ADMINISTRATOR
rem >> To undo: icacls "<app folder>" /reset /T
rem ==================================================================
net session >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo This script must be run as Administrator.
pause
exit /b 1
)
set "APPDIR=%~dp0.."
for %%I in ("%APPDIR%") do set "APPDIR=%%~fI"
echo Locking down: %APPDIR%
echo Only SYSTEM and Administrators will keep access. Continue? (Ctrl+C to abort)
pause
icacls "%APPDIR%" /inheritance:r /grant:r "SYSTEM:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F"
if %ERRORLEVEL% NEQ 0 (
echo Failed - no changes may have been applied.
pause
exit /b 1
)
echo.
echo Done. Standard users on this machine can no longer open the folder.
echo The portal keeps working (its task runs as SYSTEM).
echo To undo later: icacls "%APPDIR%" /reset /T
pause
+7
View File
@@ -0,0 +1,7 @@
' Launches start-server.bat with NO console window (used by the
' "SAP-ERP-Portal" scheduled task so the server runs in the background).
Dim fso, shell, here
Set fso = CreateObject("Scripting.FileSystemObject")
Set shell = CreateObject("Wscript.Shell")
here = fso.GetParentFolderName(WScript.ScriptFullName)
shell.Run """" & here & "\start-server.bat""", 0, False
+26
View File
@@ -0,0 +1,26 @@
@echo off
rem ==================================================================
rem SAP-ERP Portal - supervisor loop.
rem Runs the Node server and AUTOMATICALLY RESTARTS it if it ever
rem crashes. All output goes to the logs\ folder.
rem Run directly for a visible console, or via run-hidden.vbs /
rem install-autostart.bat for background operation.
rem ==================================================================
title SAP-ERP-Portal
cd /d "%~dp0.."
if not exist logs mkdir logs
rem Resolve node.exe - the SYSTEM account (autostart) may not have it on
rem PATH, so fall back to the standard install location.
set "NODEEXE=node"
where node >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
if exist "%ProgramFiles%\nodejs\node.exe" set "NODEEXE=%ProgramFiles%\nodejs\node.exe"
)
:loop
echo [%date% %time%] starting server via "%NODEEXE%" >> logs\supervisor.log
"%NODEEXE%" server.js >> logs\server.log 2>&1
echo [%date% %time%] server exited with code %ERRORLEVEL% - restarting in 5s >> logs\supervisor.log
timeout /t 5 /nobreak >nul
goto loop
+24
View File
@@ -0,0 +1,24 @@
@echo off
rem ==================================================================
rem Shows whether the portal is running and on which port.
rem Port is read from .env (PORT=...), default 5000.
rem ==================================================================
set "PORT=5000"
if exist "%~dp0..\.env" (
for /f "tokens=2 delims==" %%P in ('findstr /b /i "PORT=" "%~dp0..\.env"') do set "PORT=%%P"
)
set "PORT=%PORT: =%"
echo === SAP-ERP Portal status (port %PORT%) ===
echo.
powershell -NoProfile -Command "$p = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'server\.js' }; if ($p) { $p | ForEach-Object { Write-Host ('RUNNING - node.exe PID ' + $_.ProcessId) } } else { Write-Host 'NOT RUNNING' }"
echo.
echo Listening on port %PORT%:
netstat -ano | findstr :%PORT% | findstr LISTENING
if %ERRORLEVEL% NEQ 0 echo (nothing listening on %PORT%)
echo.
echo Autostart task:
schtasks /Query /TN "SAP-ERP-Portal" 2>nul | findstr /I "SAP-ERP-Portal Ready Running"
if %ERRORLEVEL% NEQ 0 echo (autostart task not installed - run install-autostart.bat)
echo.
pause
+23
View File
@@ -0,0 +1,23 @@
@echo off
rem ==================================================================
rem Stops the portal: ends the supervisor loop AND the node process.
rem (Only kills the node.exe running THIS app's server.js - other
rem Node programs on the machine are untouched.)
rem ==================================================================
echo Stopping SAP-ERP Portal...
rem End the scheduled-task instance (if it was started via autostart)
schtasks /End /TN "SAP-ERP-Portal" >nul 2>&1
rem Kill the supervisor console (titled window), if running in foreground
taskkill /F /FI "WINDOWTITLE eq SAP-ERP-Portal*" >nul 2>&1
rem Kill any cmd.exe still running the supervisor loop
powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'cmd.exe' -and $_.CommandLine -match 'start-server\.bat' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
rem Kill the node process running server.js (works whatever the app folder
rem is named; on a dedicated portal server no other node app runs server.js)
powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'server\.js' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force; Write-Host (' stopped node PID ' + $_.ProcessId) }"
echo Stopped.
pause
+15
View File
@@ -0,0 +1,15 @@
@echo off
rem ==================================================================
rem Removes the Windows autostart (scheduled task) and stops the app.
rem >> RUN AS ADMINISTRATOR
rem ==================================================================
net session >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo This script must be run as Administrator.
pause
exit /b 1
)
call "%~dp0stop-server.bat"
schtasks /Delete /TN "SAP-ERP-Portal" /F
echo Autostart removed.
pause
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Audit Trail — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1500px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:120px}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;height:34px}
.b-primary{background:var(--blue);color:#fff}.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap}
.lst td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top}
.lst tr.row{cursor:pointer}
.lst tr.row:hover td{background:rgba(26,111,255,.05)}
.mono{font-family:var(--mono)}
.pill{font-size:9px;font-weight:800;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.p-create{background:rgba(13,187,138,.14);color:#0aa457}
.p-update{background:rgba(26,111,255,.14);color:#5b9bff}
.p-delete{background:rgba(229,62,62,.14);color:#e06666}
.p-approve{background:rgba(159,90,253,.16);color:#b98bff}
.p-login,.p-logout{background:rgba(245,166,35,.14);color:#c78a1a}
.p-other{background:var(--surface3);color:var(--text2)}
.st-ok{color:var(--green);font-weight:700}
.st-fail{color:var(--red);font-weight:700}
.det{background:var(--surface3);border:1px solid var(--border2);border-radius:7px;padding:10px 12px;font-size:11.5px;color:var(--text2);max-height:360px;overflow:auto}
.chg{width:100%;border-collapse:collapse;font-size:11.5px}
.chg th{text-align:left;padding:4px 8px;font-size:9px;text-transform:uppercase;color:var(--text3);border-bottom:1px solid var(--border2)}
.chg td{padding:4px 8px;border-bottom:1px solid var(--border);vertical-align:top}
.chg .fld{font-weight:700;color:var(--text);font-family:var(--mono)}
.chg .old{color:#e06666;font-family:var(--mono);text-decoration:line-through;opacity:.85}
.chg .arw{color:var(--text3);padding:0 6px}
.chg .new{color:#0aa457;font-weight:700;font-family:var(--mono)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;font-family:var(--mono);font-size:11px}
.kv .k{color:var(--text3)}.kv .v{color:var(--text2);word-break:break-word}
.det-meta{margin-top:8px;font-size:10.5px;color:var(--text3)}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.pager{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
.count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-err{background:var(--red);color:#fff}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/audit-logs" class="nav-link active">Audit Trail</a></div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<script>
'use strict';
let _tok=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();location.href='/';};
const _f={from:'',to:'',username:'',entity:'',action:'',status:'',q:''};
let _page=0,_pageSize=50,_total=0,_rows=[],_facets={entities:[],actions:[],users:[]},_loading=false,_open=new Set();
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast t-err';e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function fmt(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';const p=n=>String(n).padStart(2,'0');return `${p(d.getDate())}-${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;}
function pcls(a){return {create:'p-create',update:'p-update',delete:'p-delete',approve:'p-approve',reject:'p-delete',cancel:'p-delete',login:'p-login',logout:'p-logout'}[a]||'p-other';}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
async function loadFacets(){try{const r=await api('/audit/facets');_facets=r.data||_facets;}catch(_e){}}
async function load(reset){
if(reset)_page=0;
_loading=true;render();
const qs=new URLSearchParams();
Object.entries(_f).forEach(([k,v])=>{if(v)qs.set(k,v);});
qs.set('top',_pageSize);qs.set('skip',_page*_pageSize);
try{const r=await api('/audit?'+qs.toString());_rows=r.data||[];_total=r.total||0;}
catch(e){toast(e.message);_rows=[];_total=0;}
_loading=false;render();
}
function opt(list,cur){return '<option value="">All</option>'+list.map(v=>`<option value="${esc(v)}" ${cur===v?'selected':''}>${esc(v)}</option>`).join('');}
function render(){
const app=document.getElementById('app');
const start=_total?_page*_pageSize+1:0, end=Math.min(_total,(_page+1)*_pageSize);
app.innerHTML=`
<div class="card"><div class="card-hd"><span>🛡 Audit Trail</span><span class="right">${_loading?'':`${_total.toLocaleString()} event${_total===1?'':'s'}`}</span></div>
<div class="card-bd">
<div class="filters">
<div><label class="fl">From</label><input class="fi" type="date" id="f-from" value="${_f.from}"/></div>
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}"/></div>
<div><label class="fl">User</label><select class="fi" id="f-user">${opt(_facets.users,_f.username)}</select></div>
<div><label class="fl">Module</label><select class="fi" id="f-entity">${opt(_facets.entities,_f.entity)}</select></div>
<div><label class="fl">Action</label><select class="fi" id="f-action">${opt(_facets.actions,_f.action)}</select></div>
<div><label class="fl">Result</label><select class="fi" id="f-status"><option value="">All</option><option value="ok" ${_f.status==='ok'?'selected':''}>Success</option><option value="fail" ${_f.status==='fail'?'selected':''}>Failed</option></select></div>
<div style="min-width:170px;flex:1"><label class="fl">Search</label><input class="fi" id="f-q" placeholder="summary / id / path…" value="${esc(_f.q)}"/></div>
<button class="btn b-primary" id="f-go">Apply</button>
<button class="btn b-cancel" id="f-clear">Clear</button>
</div>
<div style="overflow-x:auto;margin-top:12px">
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`:
!_rows.length?`<div class="empty-msg">No audit events match these filters.</div>`:
`<table class="lst"><thead><tr><th>When</th><th>User</th><th>Action</th><th>Module</th><th>Ref</th><th>Summary</th><th>Result</th></tr></thead>
<tbody>${_rows.map(r=>rowHtml(r)).join('')}</tbody></table>
<div class="pager">
<button class="btn b-cancel" id="p-prev" ${_page===0?'disabled':''}>‹ Prev</button>
<span class="count">${start.toLocaleString()}–${end.toLocaleString()} of ${_total.toLocaleString()}</span>
<button class="btn b-cancel" id="p-next" ${end>=_total?'disabled':''}>Next ›</button>
</div>`}
</div>
</div></div>`;
wire();
}
function fval(v){ if(v==null||v==='')return '<span style="opacity:.5">(empty)</span>'; return esc(typeof v==='object'?JSON.stringify(v):v); }
function renderDetails(r){
const d=r.details;
let inner;
if(d&&Array.isArray(d.changes)&&d.changes.length){
inner=`<table class="chg"><thead><tr><th>Field</th><th>Old value</th><th></th><th>New value</th></tr></thead><tbody>${
d.changes.map(c=>`<tr><td class="fld">${esc(c.field)}</td><td class="old">${c.complex?'(changed)':fval(c.from)}</td><td class="arw">→</td><td class="new">${c.complex?'(changed)':fval(c.to)}</td></tr>`).join('')
}</tbody></table>`;
} else if(d&&d.deleted&&typeof d.deleted==='object'){
inner=`<div style="font-weight:700;color:#e06666;margin-bottom:5px">Deleted record</div><div class="kv">${Object.entries(d.deleted).map(([k,v])=>`<div class="k">${esc(k)}</div><div class="v">${fval(v)}</div>`).join('')}</div>`;
} else if(d&&typeof d==='object'){
const ents=Object.entries(d);
inner=ents.length?`<div class="kv">${ents.map(([k,v])=>`<div class="k">${esc(k)}</div><div class="v">${fval(v)}</div>`).join('')}</div>`:'<span style="opacity:.6">(no fields)</span>';
} else if(d){
inner=`<span class="mono">${esc(String(d))}</span>`;
} else {
inner='<span style="opacity:.6">(no request body captured)</span>';
}
const meta=[r.ip?`IP ${esc(r.ip)}`:'',r.company?`Company ${esc(r.company)}`:'',`${esc(r.method)} ${esc(r.path)}`].filter(Boolean).join(' · ');
return `<div class="det">${inner}<div class="det-meta">${meta}</div></div>`;
}
function rowHtml(r){
const isOpen=_open.has(r.id);
return `<tr class="row" data-id="${r.id}">
<td class="mono" style="white-space:nowrap">${esc(fmt(r.at))}</td>
<td><b>${esc(r.username||'—')}</b>${r.role?`<div style="font-size:9.5px;color:var(--text3)">${esc(r.role)}</div>`:''}</td>
<td><span class="pill ${pcls(r.action)}">${esc(r.action||'—')}</span></td>
<td>${esc(r.entity||'—')}</td>
<td class="mono">${esc(r.entityId||'')}</td>
<td>${esc(r.summary||'')}<div style="font-size:9.5px;color:var(--text3)" class="mono">${esc(r.method)} ${esc(r.path)}</div></td>
<td><span class="${r.ok?'st-ok':'st-fail'}">${r.status||''}${r.ok?' OK':' ✗'}</span></td>
</tr>
${isOpen?`<tr><td colspan="7">${renderDetails(r)}</td></tr>`:''}`;
}
function wire(){
q('#f-go')&&(q('#f-go').onclick=()=>{
_f.from=q('#f-from').value;_f.to=q('#f-to').value;_f.username=q('#f-user').value;_f.entity=q('#f-entity').value;_f.action=q('#f-action').value;_f.status=q('#f-status').value;_f.q=q('#f-q').value.trim();
load(true);
});
q('#f-clear')&&(q('#f-clear').onclick=()=>{Object.keys(_f).forEach(k=>_f[k]='');load(true);});
q('#f-q')&&(q('#f-q').onkeydown=e=>{if(e.key==='Enter')q('#f-go').click();});
q('#p-prev')&&(q('#p-prev').onclick=()=>{if(_page>0){_page--;load();}});
q('#p-next')&&(q('#p-next').onclick=()=>{if((_page+1)*_pageSize<_total){_page++;load();}});
document.querySelectorAll('tr.row').forEach(tr=>tr.onclick=()=>{const id=Number(tr.dataset.id);_open.has(id)?_open.delete(id):_open.add(id);render();});
}
(async function init(){ await loadFacets(); load(); })();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
// Render-blocking auth gate — included as the FIRST <head> element on every
// protected page so unauthenticated visitors are redirected before any markup
// paints (prevents pages like /gstr1 from being viewable by direct URL access).
(function(){
try{
// Session-only auth: login is mirrored into a SESSION COOKIE (no
// expires/max-age) at login time — cookies, unlike sessionStorage,
// are shared across every tab of the same browser, so Ctrl+Click /
// "open in new tab" doesn't force a fresh login. A true session cookie
// is still wiped by the browser itself when it fully closes (not just a
// tab), so closing the browser still always ends the session — same
// guarantee as before, just no longer a per-tab-only one.
// Defensive cleanup: remove any leftover localStorage entries from an
// even older version of this scheme, so a lingering copy can't grant access.
try{ localStorage.removeItem('token'); localStorage.removeItem('portal_user'); }catch(e){}
function getCookie(name){
var m=document.cookie.match(new RegExp('(?:^|; )'+name+'=([^;]*)'));
return m?decodeURIComponent(m[1]):null;
}
if(!sessionStorage.getItem('portal_token')){
var cTok=getCookie('portal_token'), cUser=getCookie('portal_user');
if(cTok){
sessionStorage.setItem('portal_token',cTok);
if(cUser) sessionStorage.setItem('portal_user',cUser);
}
}
var tok=sessionStorage.getItem('portal_token');
if(!tok){ location.replace('/'); return; }
// Token PRESENCE alone isn't enough — a token issued at login lives in
// storage indefinitely even after its 12h JWT expiry (nothing clears it
// on its own), so a tab left open/idle past that would otherwise render
// the full page and let the user act right up until an API call 401s.
// Decode the JWT payload (no verification needed client-side, just the
// exp claim) and bounce immediately if it's already expired.
function jwtExpired(t){
try{
var payload=JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
return !payload.exp || (Date.now()>=payload.exp*1000);
}catch(e){ return true; }
}
function killSession(){
sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;
location.replace('/');
}
if(jwtExpired(tok)){ killSession(); return; }
// Re-check periodically so a tab left open through expiry gets logged
// out on its own, instead of only discovering it on the next API call.
setInterval(function(){
var t=sessionStorage.getItem('portal_token');
if(!t||jwtExpired(t)) killSession();
}, 60000);
// Page → required module key. Pages not listed here are accessible to any
// authenticated user (e.g. the dashboard itself).
var PAGE_MODULES={
'/gstr1':'gstr1', '/gstr2':'gstr2', '/itc04':'itc04', '/business-master':'business-master', '/admin':'admin',
'/bom':'bom', '/items':'items',
'/production':'production-create', '/issue-production':'production-issue', '/receipt-production':'production-receipt', '/close-production':'production-close',
'/requirements':'production-requirements', '/batch-issuance':'production-batch-issuance', '/work-order':'production-work-order',
'/purchase-request':'purchase-request', '/purchase-quotation':'purchase-quotation', '/purchase-order':'purchase-order', '/grpo':'purchase-grpo',
'/approvals':'approvals', '/sap-approvals':'sap-approvals',
'/vendor-register':'vendors', '/register':'customers',
'/documents':'documents', '/reports':'reports',
'/project-form':['projects-new','projects-view'], '/project-approvals':'projects-approvals',
'/costing-pl':'finance-monthly', '/costing-comparison':'finance-comparison', '/cost-sheet':'finance-costsheet', '/balance-sheet':'finance-balancesheet', '/cash-flow':'finance-cashflow',
'/salary':'salary',
};
// Backward-compat: a user granted the OLD broad key (e.g. 'production',
// before it was split into per-page sub-modules) still gets every new
// sub-key under it, so nobody silently loses access when this ships.
var LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
var path=location.pathname.replace(/\/$/,'')||'/';
var reqRaw=PAGE_MODULES[path];
if(reqRaw){
var required=Array.isArray(reqRaw)?reqRaw:[reqRaw]; // any ONE of these keys is enough
var raw=sessionStorage.getItem('portal_user');
var user=raw?JSON.parse(raw):null;
var role=user&&user.role;
var isAdmin=role==='admin'||role==='sap_adder';
var mods=user&&Array.isArray(user.modules)?user.modules:null;
var allowed=isAdmin; // no modules explicitly granted → no access (was: unchecked = full access)
if(!allowed&&mods){
for(var i=0;i<required.length&&!allowed;i++){
var req=required[i];
if(mods.indexOf(req)>-1){ allowed=true; break; }
for(var broad in LEGACY_BROAD_MODULES){
if(mods.indexOf(broad)>-1&&req.indexOf(LEGACY_BROAD_MODULES[broad])===0){ allowed=true; break; }
}
}
}
// '/production' also opens the Pre-PWO Store Review screen — a pure
// Store reviewer has no reason to hold the broad 'production-create'
// module (that would ALSO grant full Production Order creation), so
// let anyone holding EITHER Pre-PWO approval step in (any perm) reach
// the page even without the module — production.html's own per-tab
// permission checks (CAN_SHARE_PREPWO/CAN_REVIEW_PREPWO) still gate
// what they can actually see/do once there.
if(!allowed&&path==='/production'){
var steps=user&&user.approvalSteps;
var prepwoKeys=['production_order:prepwo_share','production_order:prepwo_review'];
for(var pi=0;pi<prepwoKeys.length&&!allowed;pi++){
var pk=prepwoKeys[pi];
if(Array.isArray(steps)){
for(var si=0;si<steps.length&&!allowed;si++){
var s=steps[si];
if(typeof s==='string'){ if(s===pk)allowed=true; }
else if(s&&s.step===pk&&Array.isArray(s.perms)&&s.perms.length>0){ allowed=true; }
}
}
}
}
if(!allowed){ location.replace('/'); }
}
// '/wo-header-profiles' has no module of its own (it's gated purely by
// the 'work_order:approved_mgr_qa' approval step — the same step that
// already represents "QA Manager" for Work Order approval — so it isn't
// in PAGE_MODULES above). Admin always passes; everyone else needs any
// perm on that step. wo-header-profiles.html's own CAN_EDIT check
// further gates whether they can actually add/edit/delete once there.
if(path==='/wo-header-profiles'){
var raw2=sessionStorage.getItem('portal_user');
var user2=raw2?JSON.parse(raw2):null;
var isAdmin2=user2&&(user2.role==='admin'||user2.role==='sap_adder');
var allowed2=!!isAdmin2;
if(!allowed2){
var steps2=user2&&user2.approvalSteps;
if(Array.isArray(steps2)){
for(var qi=0;qi<steps2.length&&!allowed2;qi++){
var qs=steps2[qi];
if(typeof qs==='string'){ if(qs==='work_order:approved_mgr_qa')allowed2=true; }
else if(qs&&qs.step==='work_order:approved_mgr_qa'&&Array.isArray(qs.perms)&&qs.perms.length>0){ allowed2=true; }
}
}
}
if(!allowed2){ location.replace('/'); }
}
}catch(e){ location.replace('/'); }
})();
+654
View File
@@ -0,0 +1,654 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Balance Sheet — Finance</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script>document.documentElement.setAttribute('data-theme',localStorage.getItem('portal_theme')||'obsidian')</script>
<link rel="stylesheet" href="/theme.css?v=3"/>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:var(--bg,#f8fafc);color:var(--text,#0f172a);-webkit-font-smoothing:antialiased}
.page{padding:20px 24px 40px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px}
.hdr-title{font-size:20px;font-weight:800;letter-spacing:-.4px;color:var(--text)}
.hdr-sub{font-size:12px;color:var(--text3);margin-top:3px}
.btn{padding:9px 18px;border-radius:9px;border:none;font-size:13px;font-weight:600;font-family:'Space Grotesk',sans-serif;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
.btn-primary{background:#047e7e;color:#fff}.btn-primary:hover{background:#035b5b}
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
.btn-outline{background:var(--surface);color:#047e7e;border:1.5px solid #047e7e}.btn-outline:hover{background:#f0fafa}
.btn-sm{padding:6px 12px;font-size:11px}
/* ── Filter card ── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-bottom:16px}
.filter-bar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;padding:16px 20px}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3)}
.f-input,.f-select{padding:8px 11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;font-family:'Space Grotesk',sans-serif;outline:none}
.f-input:focus,.f-select:focus{border-color:#047e7e}
/* ── Status ── */
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:var(--surface3,#f8fafc);border-top:1px solid var(--border);font-size:12px;color:var(--text3)}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ── Balance Sheet 4-column table ── */
.bs-tbl{width:100%;border-collapse:collapse;border:1px solid #b2dfdf;border-radius:12px;overflow:hidden;background:#fff;margin-bottom:16px;font-family:'Space Grotesk',sans-serif}
.bs-tbl thead th{background:#047e7e;color:#fff;padding:10px 16px;font-size:13px;font-weight:800;letter-spacing:.3px;text-align:left}
.bs-tbl thead th.rh{border-left:2px solid #035b5b}
.bs-tbl td{padding:6px 16px;font-size:12.5px;border-top:1px solid #f0f2f4;vertical-align:middle}
.bs-tbl td.rd{border-left:2px solid #b2dfdf}
/* Section header */
.bs-tbl td.sect{background:#e0f4f4;color:#004d40;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;border-top:1px solid #b2dfdf}
/* Items */
.bs-tbl td.lbl{color:#374151;padding-left:26px}
.bs-tbl td.amt{text-align:right;font-weight:700;font-variant-numeric:tabular-nums;min-width:110px;white-space:nowrap}
.bs-tbl td.amt .pos{color:#047e7e}.bs-tbl td.amt .neg{color:#dc2626}.bs-tbl td.amt .zero{color:#9ca3af}
/* Hover */
.bs-tbl tr.item:hover td{background:#f0fafa}
/* Sub-total */
.bs-tbl tr.sub td{background:#e0f4f4;border-top:2px solid #80cbc4;font-weight:700;color:#004d40;font-size:13px}
.bs-tbl tr.sub td.lbl{padding-left:16px}
/* Grand total */
.bs-tbl tr.grand td{background:#047e7e;border-top:3px solid #035b5b;color:#fff!important;font-weight:800;font-size:13.5px;padding:11px 16px}
.bs-tbl tr.grand td.amt .pos,.bs-tbl tr.grand td.amt .neg,.bs-tbl tr.grand td.amt .zero{color:#fff!important}
/* ── Configure Panel ── */
.cfg-panel{background:var(--surface);border:1px solid var(--border);border-radius:14px;margin-bottom:16px;overflow:hidden}
.cfg-hdr{display:flex;align-items:center;gap:10px;padding:12px 18px;cursor:pointer;user-select:none;border-bottom:1px solid transparent}
.cfg-hdr:hover{background:var(--surface3,#f8fafc)}
.cfg-hdr.open{border-bottom-color:var(--border)}
.cfg-title{font-size:14px;font-weight:700;color:var(--text)}
.cfg-chev{margin-left:auto;font-size:14px;transition:transform .2s}
.cfg-hdr.open .cfg-chev{transform:rotate(180deg)}
.cfg-body{display:none;padding:16px 20px}
.cfg-body.open{display:block}
.cfg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.cfg-item{background:var(--surface3,#f8fafc);border:1px solid var(--border);border-radius:10px;padding:14px}
.cfg-item-title{font-size:12px;font-weight:700;color:#047e7e;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.cfg-item-src{font-size:10px;color:var(--text3);font-weight:400;background:var(--surface);padding:1px 7px;border-radius:5px;border:1px solid var(--border)}
.cfg-codes{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;min-height:28px}
.cfg-code-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:6px;font-size:11px;font-weight:600;background:#e0f4f4;color:#004d40;border:1px solid #80cbc4}
.cfg-code-chip button{background:none;border:none;cursor:pointer;font-size:13px;line-height:1;color:#047e7e;padding:0}
.cfg-code-chip button:hover{color:#dc2626}
.cfg-add{display:flex;gap:6px;margin-top:4px}
.cfg-add input{flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:12px;font-family:'Space Grotesk',sans-serif;outline:none;background:var(--surface)}
.cfg-add input:focus{border-color:#047e7e}
.cfg-fixed{margin-top:8px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2)}
.cfg-fixed input{width:120px;padding:5px 8px;border:1px solid var(--border);border-radius:7px;font-size:12px;font-family:'Space Grotesk',sans-serif;outline:none;background:var(--surface)}
.cfg-fixed input:focus{border-color:#047e7e}
.cfg-save-btn{margin-top:10px;padding:5px 14px;border-radius:7px;font-size:11px;font-weight:700;font-family:'Space Grotesk',sans-serif;border:none;cursor:pointer;background:#047e7e;color:#fff}
.cfg-save-btn:hover{background:#035b5b}
/* ── Toast ── */
.toast-c{position:fixed;bottom:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast{padding:11px 18px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);animation:slideIn .3s ease}
@keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.toast.ok{background:#10b981}.toast.err{background:#ef4444}.toast.info{background:#047e7e}
@media print{.cfg-panel,.hdr .btn,.filter-bar button{display:none!important} .bs-tbl{page-break-inside:avoid}}
</style>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="page">
<div class="hdr">
<div>
<div class="hdr-title">Balance Sheet</div>
<div class="hdr-sub" id="bsSub">As per Companies Act (Schedule III) — Figures in Lakh (₹)</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label style="font-size:12px;font-weight:600;color:var(--text2);display:flex;align-items:center;gap:6px">
<input type="checkbox" id="chkLakh" checked onchange="renderBS()"> Figures in Lakh
</label>
<button class="btn btn-outline" onclick="window.print()">Print</button>
<button class="btn btn-outline" id="btnExport" onclick="exportXlsx()" disabled>Excel</button>
<button class="btn btn-primary" id="btnLoad" onclick="loadBS()">Load Balance Sheet</button>
</div>
</div>
<!-- Filter -->
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>Balance Sheet Date (As Of)</label>
<input type="date" id="asOf" class="f-input">
</div>
<div class="fg">
<label>P&amp;L From (For Net Profit)</label>
<input type="date" id="plFrom" class="f-input" onchange="autoMatchSnapshot()">
</div>
<div class="fg">
<label>P&amp;L To</label>
<input type="date" id="plTo" class="f-input" onchange="autoMatchSnapshot()">
</div>
<div class="fg" style="min-width:260px">
<label>Profit &amp; Loss — Select Snapshot</label>
<select id="snapSelect" class="f-select" style="min-width:260px" onchange="onSnapChange()">
<option value="">— Select snapshot —</option>
</select>
</div>
<div class="fg" style="min-width:200px">
<label>Net Profit (₹) <span id="npLabel" style="font-size:9px;color:var(--text3)">— select snapshot to auto-fill</span></label>
<input type="number" id="netProfitOverride" class="f-input" placeholder="auto-fills from snapshot"
style="min-width:200px" step="0.01"
title="Auto-fills from selected snapshot, or enter manually to override">
</div>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select dates and click Load Balance Sheet</span>
</div>
</div>
<!-- Configure Panel (admin only) -->
<div class="cfg-panel" id="cfgPanel" style="display:none">
<div class="cfg-hdr" id="cfgHdr" onclick="toggleCfg()">
<span style="font-size:16px">⚙</span>
<span class="cfg-title">Configure Account Groups</span>
<span style="font-size:11px;color:var(--text3);font-weight:400">Map ledger accounts to each Balance Sheet line item</span>
<span class="cfg-chev">▼</span>
</div>
<div class="cfg-body" id="cfgBody">
<div class="cfg-grid" id="cfgGrid">
<div style="color:var(--text3);font-size:12px">Loading configuration…</div>
</div>
<div style="margin-top:14px;padding-top:14px;border-top:1px solid var(--border);display:flex;gap:8px">
<button class="btn btn-primary btn-sm" onclick="saveAllCfg()">Save All Changes</button>
<span style="font-size:11px;color:var(--text3);align-self:center">Changes take effect after next Load</span>
</div>
</div>
</div>
<!-- Balance Sheet Output -->
<div id="bsOut"></div>
</div>
<div class="toast-c" id="toastC"></div>
<script>
'use strict';
const tok = sessionStorage.getItem('portal_token') || '';
const hdrs = { 'Content-Type':'application/json', Authorization:'Bearer '+tok };
if (!tok) window.location.href = '/';
let bsData = null; // latest API response
let bsItems = []; // structure from server
let bsCfg = {}; // config from server
let cfgDirty = {}; // local edits
// ── Auth: show configure panel for admin ─────────────────────────────────────
(function initAuth() {
let user = null;
try { user = JSON.parse(sessionStorage.getItem('portal_user')); } catch(_) {}
if (user?.role === 'admin' || user?.role === 'sap_adder')
document.getElementById('cfgPanel').style.display = '';
})();
// ── Default dates ─────────────────────────────────────────────────────────────
(function initDates() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth(); // 0-based
// Last day of previous month for BS date
const lastDay = new Date(y, m, 0);
document.getElementById('asOf').value = lastDay.toISOString().slice(0,10);
document.getElementById('plFrom').value = `${y}-${String(m).padStart(2,'0')}-01`;
document.getElementById('plTo').value = lastDay.toISOString().slice(0,10);
})();
// ── Helpers ───────────────────────────────────────────────────────────────────
function toast(msg, type='ok') {
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
document.getElementById('toastC').appendChild(el);
setTimeout(() => el.remove(), 3500);
}
function setStatus(msg, state='') {
document.getElementById('statusMsg').textContent = msg;
document.getElementById('statusDot').className = 'status-dot' + (state ? ' '+state : '');
}
function fmtAmt(v, inLakh) {
if (v == null) return '—';
const n = inLakh ? v / 100000 : v;
if (Math.abs(n) < 0.005) return '<span class="zero">—</span>';
const s = Math.abs(n).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
if (n < 0) return `<span class="neg">(${s})</span>`;
return `<span class="pos">${s}</span>`;
}
function fmtN(v, inLakh) {
if (v == null || Math.abs(inLakh ? v/100000 : v) < 0.005) return 0;
return inLakh ? v / 100000 : v;
}
let _snapMeta = {}; // id → {period_from, period_to}
async function autoMatchSnapshot() {
const plFrom = document.getElementById('plFrom').value;
const plTo = document.getElementById('plTo').value;
if (!plFrom || !plTo) return;
const sel = document.getElementById('snapSelect');
const matchId = Object.keys(_snapMeta).find(id => {
const m = _snapMeta[id];
return m.period_from === plFrom && m.period_to === plTo;
});
if (matchId && sel.value !== matchId) {
sel.value = matchId;
await onSnapChange();
}
}
window.autoMatchSnapshot = autoMatchSnapshot;
async function onSnapChange() {
const snapId = document.getElementById('snapSelect').value;
if (!snapId) {
document.getElementById('npLabel').textContent = '— select snapshot to auto-fill';
document.getElementById('netProfitOverride').value = '';
return;
}
const meta = _snapMeta[snapId];
if (meta) {
document.getElementById('plFrom').value = meta.period_from;
document.getElementById('plTo').value = meta.period_to;
}
// Fetch net profit from this snapshot and auto-fill the field
const npLabel = document.getElementById('npLabel');
npLabel.textContent = '— loading from snapshot…';
try {
const r = await fetch(`/api/balance-sheet/snapshot-profit?id=${snapId}`, { headers: hdrs }).then(r => r.json());
if (r.success && r.net_profit != null) {
document.getElementById('netProfitOverride').value = r.net_profit.toFixed(2);
npLabel.textContent = `— auto-filled from snapshot (₹${(r.net_profit/100000).toFixed(2)}L)`;
toast(`Net profit loaded: ₹${(r.net_profit/100000).toFixed(2)}L`, 'info');
} else {
npLabel.textContent = '— could not fetch from snapshot';
}
} catch(_) { npLabel.textContent = '— error loading snapshot'; }
}
window.onSnapChange = onSnapChange;
// ── Load snapshot list and auto-select the one matching the P&L period ───────
(async function loadSnapshots() {
try {
const r = await fetch('/api/balance-sheet/snapshots', { headers: hdrs }).then(r => r.json());
if (!r.success || !r.data.length) return;
const sel = document.getElementById('snapSelect');
r.data.forEach(s => {
_snapMeta[s.id] = { period_from: s.period_from, period_to: s.period_to };
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.snap_name || 'Snapshot'} (${s.period_from} → ${s.period_to})`;
sel.appendChild(opt);
});
// Auto-select snapshot matching current P&L period (most recent first)
const plFrom = document.getElementById('plFrom').value;
const plTo = document.getElementById('plTo').value;
const match = r.data.find(s => s.period_from === plFrom && s.period_to === plTo);
if (match) {
sel.value = String(match.id);
await onSnapChange();
}
} catch(_) {}
})();
async function loadBS() {
const asOf = document.getElementById('asOf').value;
const plFrom = document.getElementById('plFrom').value;
const plTo = document.getElementById('plTo').value;
if (!asOf || !plFrom || !plTo) { toast('Please fill all date fields', 'err'); return; }
document.getElementById('btnLoad').disabled = true;
document.getElementById('btnExport').disabled = true;
setStatus('Loading balance sheet…', 'loading');
document.getElementById('bsOut').innerHTML = '';
try {
const snapId = document.getElementById('snapSelect').value;
const manualProfit = document.getElementById('netProfitOverride').value.trim();
const snapParam = snapId ? `&snapshotId=${snapId}` : '';
const overrideParam = manualProfit !== '' ? `&netProfitOverride=${encodeURIComponent(manualProfit)}` : '';
const r = await fetch(`/api/balance-sheet/data?asOf=${asOf}&from=${plFrom}&to=${plTo}${snapParam}${overrideParam}`, { headers: hdrs }).then(r => r.json());
if (!r.success) throw new Error(r.message);
bsData = r;
// ── Force balance: set st_provisions so Assets = Liabilities ──────────
const aTotal = ['fixed_assets','non_current_invest','lt_loans_advances','other_non_current',
'current_invest','inventories','trade_receivables','cash_equivalents',
'st_loans_advances','other_current_assets']
.reduce((s, k) => s + Math.abs(bsData.values[k] || 0), 0);
const lTotalWithoutProv = ['share_capital','reserves_surplus','profit_loss',
'lt_borrowings','other_lt_liab','lt_provisions',
'st_borrowings','trade_payables','other_current_liab']
.reduce((s, k) => {
const v = bsData.values[k] || 0;
return s + (k === 'profit_loss' ? v : Math.abs(v));
}, 0);
bsData.values['st_provisions'] = aTotal - lTotalWithoutProv;
// ──────────────────────────────────────────────────────────────────────
renderBS();
document.getElementById('btnExport').disabled = false;
const snapLabel = snapId
? document.getElementById('snapSelect').options[document.getElementById('snapSelect').selectedIndex].text
: 'live P&L';
setStatus(`Balance Sheet as at ${asOf} · P&L: ${snapLabel}`, 'ok');
document.getElementById('bsSub').textContent = `As at ${asOf} · P&L: ${snapLabel} · Figures in ${document.getElementById('chkLakh').checked ? 'Lakh (₹)' : 'Rupees (₹)'}`;
} catch(e) {
setStatus(e.message, 'err');
toast(e.message, 'err');
} finally {
document.getElementById('btnLoad').disabled = false;
}
}
// ── Render Balance Sheet ──────────────────────────────────────────────────────
const STRUCTURE = {
L: [
{ key:'SF', label:"Shareholders' Funds", items:['share_capital','reserves_surplus','profit_loss'] },
{ key:'NCL', label:'Non-Current Liabilities', items:['lt_borrowings','other_lt_liab','lt_provisions'] },
{ key:'CL', label:'Current Liabilities', items:['st_borrowings','trade_payables','other_current_liab','st_provisions'] },
],
A: [
{ key:'NCA', label:'Non-Current Assets', items:['fixed_assets','non_current_invest','lt_loans_advances','other_non_current'] },
{ key:'CA', label:'Current Assets', items:['current_invest','inventories','trade_receivables','cash_equivalents','st_loans_advances','other_current_assets'] },
],
};
function renderBS() {
if (!bsData) return;
const inLakh = document.getElementById('chkLakh').checked;
const vals = bsData.values;
const itemMap = {};
bsItems.forEach(i => { itemMap[i.key] = i; });
// Build rows for one side — grand total returned separately
function buildRows(sideKey) {
const rows = [];
let total = 0;
STRUCTURE[sideKey].forEach(sub => {
rows.push({ t:'sect', label: sub.label });
let st = 0;
sub.items.forEach(key => {
// profit_loss shows actual sign; all other items show absolute value
const raw = key === 'profit_loss' ? (vals[key] || 0) : Math.abs(vals[key] || 0);
st += raw;
rows.push({ t:'item', label: (itemMap[key]?.label || key), val: raw });
});
total += st;
rows.push({ t:'sub', label: 'Total ' + sub.label, val: st });
});
return { rows, total };
}
const { rows: lRows, total: lTotal } = buildRows('L');
const { rows: aRows, total: aTotal } = buildRows('A');
const diff = aTotal - lTotal;
const maxLen = Math.max(lRows.length, aRows.length);
const emp = { t:'empty' };
while (lRows.length < maxLen) lRows.push(emp);
while (aRows.length < maxLen) aRows.push(emp);
function cells(row, right) {
const d = right ? ' rd' : '';
if (!row || row.t === 'empty') return `<td class="${d}"></td><td></td>`;
if (row.t === 'sect') return `<td class="sect${d}" colspan="2">${row.label}</td>`;
return `<td class="lbl${d}">${row.label}</td><td class="amt">${fmtAmt(row.val, inLakh)}</td>`;
}
let html = `<table class="bs-tbl">
<thead><tr>
<th colspan="2">I. Equity and Liabilities</th>
<th colspan="2" class="rh">II. Assets</th>
</tr></thead><tbody>`;
for (let i = 0; i < maxLen; i++) {
const l = lRows[i], r = aRows[i];
const isSub = l.t === 'sub' || r.t === 'sub';
html += `<tr class="${isSub ? 'sub' : (l.t === 'item' || r.t === 'item' ? 'item' : '')}">${cells(l,false)}${cells(r,true)}</tr>`;
}
// Grand total row
html += `<tr class="grand">
<td class="lbl">Total Equity &amp; Liabilities</td><td class="amt">${fmtAmt(lTotal,inLakh)}</td>
<td class="lbl rd">Total Assets</td><td class="amt">${fmtAmt(aTotal,inLakh)}</td>
</tr>`;
// Difference row
const diffColor = Math.abs(diff) < 0.005 ? '#10b981' : '#f59e0b';
const diffLabel = Math.abs(diff) < 0.005 ? 'Balanced ✓' : 'Difference (Assets − Liabilities)';
const diffVal = inLakh ? (diff / 100000).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2})
: diff.toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
html += `<tr class="no-hover" style="background:#fffbeb;border-top:2px dashed ${diffColor}">
<td colspan="3" style="padding:8px 16px;font-size:12px;font-weight:700;color:${diffColor}">${diffLabel}</td>
<td class="amt" style="font-weight:800;color:${diffColor}">${diff === 0 ? '—' : diffVal}</td>
</tr>`;
html += `</tbody></table>
<div style="font-size:11px;color:var(--text3);text-align:right;padding:4px 8px">
Net Profit: ₹${(bsData.meta.net_profit/100000).toFixed(2)}L &nbsp;·&nbsp;
Closing Stock: ₹${(bsData.meta.closing_stock/100000).toFixed(2)}L &nbsp;·&nbsp;
Total Provisions: ₹${(bsData.meta.total_provisions/100000).toFixed(2)}L
</div>`;
document.getElementById('bsOut').innerHTML = html;
}
// ── Load Config ───────────────────────────────────────────────────────────────
async function loadCfg() {
try {
const r = await fetch('/api/balance-sheet/config', { headers: hdrs }).then(r => r.json());
if (!r.success) return;
bsItems = r.items || [];
bsCfg = r.data || {};
renderCfgGrid();
} catch(_) {}
}
loadCfg();
function toggleCfg() {
const hdr = document.getElementById('cfgHdr');
const body = document.getElementById('cfgBody');
hdr.classList.toggle('open');
body.classList.toggle('open');
}
const SUB_LABELS = { SF:'Shareholders Funds', NCL:'Non-Current Liabilities', CL:'Current Liabilities', NCA:'Non-Current Assets', CA:'Current Assets' };
const SRC_LABELS = { fixed:'Fixed Value', ledger:'Ledger', pl_net:'P&L Net Profit', closing_stock:'Closing Stock (MA)', provisions:'MA Provisions + Ledger', reserves_formula:'Ledger + Opening Stock − P&L Opening' };
function renderCfgGrid() {
const itemMap = {};
bsItems.forEach(i => { itemMap[i.key] = i; });
const allItems = [...STRUCTURE.L, ...STRUCTURE.A].flatMap(sub =>
sub.items.map(k => ({ ...itemMap[k], subLabel: SUB_LABELS[itemMap[k]?.sub] || '' }))
).filter(Boolean);
document.getElementById('cfgGrid').innerHTML = allItems.map(item => {
const cfg = cfgDirty[item.key] || bsCfg[item.key] || {};
const codes = cfg.acct_codes || [];
const ocodes = cfg.opening_acct_codes || [];
const fv = cfg.fixed_value != null ? cfg.fixed_value : '';
const chipsHtml = codes.map(c =>
`<span class="cfg-code-chip">${c}<button onclick="removeCfgCode('${item.key}','${c}','main')">×</button></span>`
).join('');
const ochipsHtml = ocodes.map(c =>
`<span class="cfg-code-chip" style="background:#ede9fe;color:#5b21b6;border-color:#c4b5fd">${c}<button onclick="removeCfgCode('${item.key}','${c}','opening')">×</button></span>`
).join('');
const fixedHtml = item.src === 'fixed' ? `
<div class="cfg-fixed">
<label>Fixed Value (₹ Lakh):</label>
<input type="number" id="cfg-fv-${item.key}" value="${fv}" placeholder="e.g. 100"
onchange="setDirty('${item.key}',null,null,this.value)">
</div>` : '';
const showAccts = item.src === 'ledger' || item.src === 'provisions' || item.src === 'reserves_formula';
const showOpening = item.src === 'reserves_formula';
return `<div class="cfg-item">
<div class="cfg-item-title">
${item.label}
<span class="cfg-item-src">${SRC_LABELS[item.src]||item.src}</span>
<span style="font-size:10px;color:var(--text3);font-weight:400">${item.subLabel}</span>
</div>
${showAccts ? `
<div style="font-size:10px;font-weight:700;color:#047e7e;margin-bottom:4px">Main Account (2120020001)</div>
<div class="cfg-codes" id="chips-${item.key}">${chipsHtml||'<span style="font-size:11px;color:var(--text3)">None</span>'}</div>
<div class="cfg-add">
<input type="text" id="newacc-${item.key}" placeholder="e.g. 2120020001"
onkeydown="if(event.key==='Enter')addCfgCode('${item.key}','main')">
<button class="btn btn-outline btn-sm" onclick="addCfgCode('${item.key}','main')">+ Add</button>
</div>` : ''}
${showOpening ? `
<div style="font-size:10px;font-weight:700;color:#7c3aed;margin:10px 0 4px">Opening Balance Account (5100001001)</div>
<div style="font-size:10px;color:var(--text3);margin-bottom:6px">Total Opening = balance(this account, day before period) − Opening Stock</div>
<div class="cfg-codes" id="ochips-${item.key}">${ochipsHtml||'<span style="font-size:11px;color:var(--text3)">None</span>'}</div>
<div class="cfg-add">
<input type="text" id="onewacc-${item.key}" placeholder="e.g. 5100001001"
onkeydown="if(event.key==='Enter')addCfgCode('${item.key}','opening')">
<button class="btn btn-outline btn-sm" style="border-color:#7c3aed;color:#7c3aed" onclick="addCfgCode('${item.key}','opening')">+ Add</button>
</div>` : ''}
${fixedHtml}
<button class="cfg-save-btn" onclick="saveSingleCfg('${item.key}')">Save</button>
</div>`;
}).join('');
}
function setDirty(key, codes, ocodes, fv) {
if (!cfgDirty[key]) cfgDirty[key] = { ...(bsCfg[key] || {}) };
if (codes !== null) cfgDirty[key].acct_codes = codes;
if (ocodes !== null) cfgDirty[key].opening_acct_codes = ocodes;
if (fv !== null) cfgDirty[key].fixed_value = fv;
}
function addCfgCode(key, type) {
const inp = document.getElementById(type === 'opening' ? `onewacc-${key}` : `newacc-${key}`);
const code = (inp.value || '').trim();
if (!code) return;
if (type === 'opening') {
const cur = (cfgDirty[key]?.opening_acct_codes || bsCfg[key]?.opening_acct_codes || []).filter(Boolean);
if (cur.includes(code)) { toast('Code already added', 'err'); return; }
setDirty(key, null, [...cur, code], null);
} else {
const cur = (cfgDirty[key]?.acct_codes || bsCfg[key]?.acct_codes || []).filter(Boolean);
if (cur.includes(code)) { toast('Code already added', 'err'); return; }
setDirty(key, [...cur, code], null, null);
}
inp.value = '';
renderCfgGrid();
}
function removeCfgCode(key, code, type) {
if (type === 'opening') {
const cur = (cfgDirty[key]?.opening_acct_codes || bsCfg[key]?.opening_acct_codes || []);
setDirty(key, null, cur.filter(c => c !== code), null);
} else {
const cur = (cfgDirty[key]?.acct_codes || bsCfg[key]?.acct_codes || []);
setDirty(key, cur.filter(c => c !== code), null, null);
}
renderCfgGrid();
}
async function saveSingleCfg(key) {
const cfg = cfgDirty[key] || bsCfg[key] || {};
try {
const r = await fetch('/api/balance-sheet/config', {
method: 'POST', headers: hdrs,
body: JSON.stringify({
group_key: key,
acct_codes: cfg.acct_codes || [],
opening_acct_codes: cfg.opening_acct_codes || [],
fixed_value: cfg.fixed_value,
notes: cfg.notes,
}),
}).then(r => r.json());
if (!r.success) throw new Error(r.message);
bsCfg[key] = { ...cfg };
delete cfgDirty[key];
toast(`Saved: ${key}`, 'ok');
loadCfg();
} catch(e) { toast(e.message, 'err'); }
}
async function saveAllCfg() {
const keys = Object.keys(cfgDirty);
if (!keys.length) { toast('No unsaved changes', 'info'); return; }
for (const key of keys) await saveSingleCfg(key);
toast('All changes saved', 'ok');
}
window.toggleCfg = toggleCfg;
window.addCfgCode = addCfgCode;
window.removeCfgCode= removeCfgCode;
window.saveSingleCfg= saveSingleCfg;
window.saveAllCfg = saveAllCfg;
window.renderBS = renderBS;
// ── Excel Export ──────────────────────────────────────────────────────────────
function exportXlsx() {
if (!bsData) return;
const inLakh = document.getElementById('chkLakh').checked;
const asOf = document.getElementById('asOf').value;
const vals = bsData.values;
const itemMap = {};
bsItems.forEach(i => { itemMap[i.key] = i; });
const rows = [
['BALANCE SHEET'],
[`As at ${asOf}`],
[inLakh ? 'Figures in Lakh (₹)' : 'Figures in Rupees (₹)'],
[],
['I. EQUITY AND LIABILITIES','','','II. ASSETS',''],
['Line Item','Amount','','Line Item','Amount'],
];
const lItems = STRUCTURE.L.flatMap(s => [
[s.label,'','','',''],
...s.items.map(k => {
const n = fmtN(Math.abs(vals[k]||0), inLakh);
return [(itemMap[k]?.label||k), n];
}),
['Total '+s.label, s.items.reduce((t,k)=>t+Math.abs(vals[k]||0),0) / (inLakh?100000:1),'','',''],
]);
const aItems = STRUCTURE.A.flatMap(s => [
[s.label,'','','',''],
...s.items.map(k => {
const n = fmtN(Math.abs(vals[k]||0), inLakh);
return ['', '', '', (itemMap[k]?.label||k), n];
}),
['','','','Total '+s.label, s.items.reduce((t,k)=>t+Math.abs(vals[k]||0),0) / (inLakh?100000:1)],
]);
const maxLen = Math.max(lItems.length, aItems.length);
for (let i = 0; i < maxLen; i++) {
const l = lItems[i] || ['','','','',''];
const a = aItems[i] || ['','','','',''];
rows.push([l[0]||'', l[1]||'', '', a[3]||a[0]||'', a[4]||a[1]||'']);
}
rows.push([]);
rows.push(['Total Equity & Liabilities',
STRUCTURE.L.flatMap(s=>s.items).reduce((t,k)=>t+(vals[k]||0),0) / (inLakh?100000:1),
'',
'Total Assets',
STRUCTURE.A.flatMap(s=>s.items).reduce((t,k)=>t+(vals[k]||0),0) / (inLakh?100000:1),
]);
const ws = XLSX.utils.aoa_to_sheet(rows);
ws['!cols'] = [36,16,4,36,16].map(w=>({wch:w}));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Balance Sheet');
XLSX.writeFile(wb, `Balance_Sheet_${asOf}.xlsx`);
toast('Excel exported', 'ok');
}
window.loadBS = loadBS;
window.exportXlsx = exportXlsx;
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Board Dashboard — MITRA ERP</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/theme.css?v=3"/>
<style>
:root{
--bg:#f9fafb;--surface:#ffffff;--border:#e5e7eb;
--text:#111827;--text2:#374151;--text3:#6b7280;
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;
/* Validated data-viz palette (light surface #ffffff) */
--series-1:#2a78d6; /* Net Sales */
--series-2:#eb6834; /* Purchases */
--good:#008300;--bad:#c81e1e;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:26px 30px 50px;max-width:1240px;margin:0 auto}
.hero{background:linear-gradient(135deg,#1e3a8a 0%,#2563eb 55%,#4f46e5 100%);border-radius:16px;padding:24px 28px;color:#fff;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;box-shadow:0 4px 24px rgba(37,99,235,.22);margin-bottom:22px;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:-50px;right:-50px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.07)}
.hero-eyebrow{font-size:10px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:4px}
.hero-title{font-size:24px;font-weight:800}
.hero-sub{font-size:12.5px;color:rgba(255,255,255,.75);margin-top:4px}
.hero-right{font-size:11.5px;color:rgba(255,255,255,.8);text-align:right;z-index:1}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:12px;margin-bottom:22px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
.kpi-l{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.7px;color:var(--text3);margin-bottom:8px}
.kpi-v{font-family:var(--mono);font-size:21px;font-weight:600;color:var(--text);line-height:1.1}
.kpi-s{font-size:11px;color:var(--text3);margin-top:6px}
.kpi-delta{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;margin-top:8px}
.kpi-delta.up{color:var(--good);background:rgba(0,131,0,.08)}
.kpi-delta.down{color:var(--bad);background:rgba(200,30,30,.08)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;margin-bottom:18px;overflow:hidden}
.card-hd{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.card-title{font-size:13.5px;font-weight:700}
.card-sub{font-size:11px;color:var(--text3);margin-top:2px}
.card-bd{padding:16px 18px}
.legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.leg{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--text2);font-weight:600}
.leg .sw{width:10px;height:10px;border-radius:3px}
.leg.ch{cursor:pointer;user-select:none;padding:3px 9px;border-radius:999px;border:1px solid var(--border);transition:all .12s}
.leg.ch:hover{border-color:#c7cdd6}
.leg.ch.off{opacity:.38}
.leg.ch.off .sw{background:#cbd5e1!important}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
/* breakdown filter bar — one row above the charts it controls */
.fbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px 14px;margin-bottom:16px}
.fbar-l{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-right:4px}
.fchip{padding:5px 13px;border-radius:999px;font-size:11.5px;font-weight:700;cursor:pointer;border:1px solid var(--border);background:#fff;color:var(--text2);font-family:var(--font);transition:all .12s}
.fchip:hover{border-color:#c7cdd6}
.fchip.on{background:var(--series-1);color:#fff;border-color:transparent}
.fbar input[type=date]{padding:5px 8px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:11.5px;color:var(--text2)}
.fbar .sep{width:1px;height:22px;background:var(--border);margin:0 4px}
.fapply{padding:6px 16px;border-radius:8px;border:none;background:var(--series-1);color:#fff;font-size:11.5px;font-weight:700;font-family:var(--font);cursor:pointer}
.fapply:hover{opacity:.9}
/* product-group multi-select */
.gsel{position:relative}
.gsel-btn{padding:6px 12px;border-radius:8px;border:1px solid var(--border);background:#fff;color:var(--text2);font-family:var(--font);font-size:11.5px;font-weight:600;cursor:pointer}
.gsel-btn b{color:var(--series-1)}
.gsel-pop{display:none;position:absolute;top:calc(100% + 6px);right:0;z-index:50;background:#fff;border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(15,23,42,.16);padding:10px;width:270px}
.gsel-pop.show{display:block}
.gsel-search{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:12px;color:var(--text);margin-bottom:8px;outline:none}
.gsel-search:focus{border-color:var(--series-1);box-shadow:0 0 0 3px rgba(42,120,214,.12)}
.gsel-tools{display:flex;gap:6px;margin-bottom:8px}
.gsel-tools button{flex:1;padding:4px;border-radius:6px;border:1px solid var(--border);background:#fff;font-family:var(--font);font-size:10.5px;font-weight:700;color:var(--text3);cursor:pointer}
.gsel-tools button:hover{color:var(--text2);border-color:#c7cdd6}
.gsel-list{max-height:220px;overflow:auto;display:flex;flex-direction:column;gap:2px}
.gsel-list label{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:5px 6px;border-radius:6px;cursor:pointer}
.gsel-list label:hover{background:#f8fafc}
.gsel-list input{accent-color:var(--series-1)}
/* per-card search boxes */
.csearch{padding:6px 10px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:11.5px;color:var(--text);width:180px;outline:none}
.csearch:focus{border-color:var(--series-1);box-shadow:0 0 0 3px rgba(42,120,214,.12)}
/* horizontal bars (HTML) */
.hbar-row{display:grid;grid-template-columns:minmax(120px,220px) 1fr 92px;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid #f3f4f6}
.hbar-row:last-child{border-bottom:none}
.hbar-name{font-size:11.5px;color:var(--text2);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hbar-track{height:14px;position:relative}
.hbar-fill{height:100%;border-radius:0 4px 4px 0;background:var(--series-1);min-width:2px;transition:opacity .12s}
.hbar-row:hover .hbar-fill{opacity:.8}
.hbar-val{font-family:var(--mono);font-size:11px;color:var(--text);text-align:right}
/* chart */
.chart-wrap{position:relative}
.chart-wrap svg{display:block;width:100%;height:auto}
.tip{position:absolute;pointer-events:none;background:#111827;color:#fff;font-size:11px;line-height:1.5;padding:7px 10px;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.25);opacity:0;transition:opacity .08s;white-space:nowrap;z-index:20;transform:translate(-50%,-110%)}
.tip b{font-family:var(--mono);font-weight:600}
.tip .t-row{display:flex;align-items:center;gap:6px}
.tip .sw{width:8px;height:8px;border-radius:2px;display:inline-block}
details.tbl{border-top:1px solid var(--border)}
details.tbl summary{padding:9px 18px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;user-select:none}
details.tbl summary:hover{color:var(--text2)}
details.tbl table{width:100%;border-collapse:collapse;font-size:11.5px}
details.tbl th{text-align:left;padding:6px 18px;color:var(--text3);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:1px solid var(--border)}
details.tbl td{padding:5px 18px;border-top:1px solid #f3f4f6;font-family:var(--mono);color:var(--text2)}
details.tbl td:first-child{font-family:var(--font)}
.loading{padding:60px;text-align:center;color:var(--text3);font-size:13px}
.err-note{padding:40px;text-align:center;color:var(--bad);font-size:13px}
</style>
</head>
<body>
<main id="main"><div class="loading">Loading company analytics…</div></main>
<script>
const _tok=sessionStorage.getItem('portal_token');
let _user=null;try{_user=JSON.parse(sessionStorage.getItem('portal_user')||'null');}catch(_e){}
const MONTHS=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
// Indian-style currency: ₹ in Crores / Lakhs for board-level reading.
function inr(v){
const n=Number(v)||0,a=Math.abs(n),sign=n<0?'-':'';
if(a>=1e7)return sign+'₹'+(a/1e7).toFixed(2)+' Cr';
if(a>=1e5)return sign+'₹'+(a/1e5).toFixed(2)+' L';
return sign+'₹'+Math.round(a).toLocaleString('en-IN');
}
function inrFull(v){return '₹'+Math.round(Number(v)||0).toLocaleString('en-IN');}
let _allGroups=[]; // [{code,name}] from /api/board/groups — already admin-restricted if configured
let _groupsTotal=0; // how many groups the company actually has (may be > _allGroups.length if admin-restricted)
let _selGroups=new Set(); // selected ItmsGrpCod values — empty = All (of whatever _allGroups allows)
// "All" is ambiguous when an admin restriction is active — it means "every
// group you're ALLOWED to see", not "every group the company has". Spell
// that out so it's never mistaken for a genuinely unrestricted view.
function allLabel(){ return (_groupsTotal>_allGroups.length) ? `All (${_allGroups.length} of ${_groupsTotal})` : 'All'; }
async function load(){
const main=document.getElementById('main');
let d;
try{
const [r,gr]=await Promise.all([
fetch('/api/board/summary',{headers:{Authorization:'Bearer '+_tok}}),
fetch('/api/board/groups',{headers:{Authorization:'Bearer '+_tok}}),
]);
const j=await r.json();
if(r.status===403){main.innerHTML='<div class="err-note">This dashboard is for Board Members. Your role does not have access.</div>';return;}
if(!j.success)throw new Error(j.message||'Failed');
d=j.data;
try{const gj=await gr.json();if(gj.success){_allGroups=gj.data||[];_groupsTotal=gj.total||_allGroups.length;}}catch(_e){}
}catch(e){main.innerHTML='<div class="err-note">Could not load analytics: '+esc(e.message)+'</div>';return;}
render(d);
}
let _staticKpi=null; // as-of-today balances from /summary (AR/AP) — not range-dependent
function render(d){
_staticKpi=d.kpi;
const now=new Date();
document.getElementById('main').innerHTML=`
<div class="hero">
<div style="z-index:1">
<div class="hero-eyebrow">Board of Directors</div>
<div class="hero-title">Company Growth Dashboard</div>
<div class="hero-sub">Net sales, purchases &amp; customer performance — live from SAP Business One</div>
</div>
<div class="hero-right">As of ${now.toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'})}<br/>${esc(_user?.name||_user?.username||'')}</div>
</div>
<div class="kpis" id="kpis"><div class="loading" style="padding:20px;grid-column:1/-1">Loading…</div></div>
<div class="fbar">
<span class="fbar-l">Date range</span>
<button class="fchip" data-preset="3">3M</button>
<button class="fchip" data-preset="6">6M</button>
<button class="fchip on" data-preset="12">12M</button>
<button class="fchip" data-preset="24">24M</button>
<button class="fchip" data-preset="ytd">YTD</button>
<span class="sep"></span>
<input type="date" id="f-from"/> <span style="color:var(--text3);font-size:11px">to</span> <input type="date" id="f-to"/>
<button class="fapply" id="f-apply">Apply</button>
<span class="sep"></span>
<div class="gsel">
<button class="gsel-btn" id="g-btn">Product Groups: <b id="g-lbl">${allLabel()}</b> <span style="font-size:9px">▾</span></button>
<div class="gsel-pop" id="g-pop">
<input class="gsel-search" id="g-search" placeholder="Search product groups…" autocomplete="off"/>
<div class="gsel-tools"><button type="button" id="g-all">Select all</button><button type="button" id="g-none">Clear</button></div>
<div class="gsel-list" id="g-list"></div>
<div style="font-size:10px;color:var(--text3);padding:6px 2px 0;text-align:center">Ticking a group filters the data immediately</div>
</div>
</div>
</div>
<div class="card">
<div class="card-hd">
<div><div class="card-title">Net Sales vs Purchases</div><div class="card-sub" id="trend-sub">Monthly · net sales = invoices − credit memos · excl. GST</div></div>
<div class="legend">
<span class="leg"><span class="sw" style="background:var(--series-1)"></span>Net Sales</span>
<span class="leg"><span class="sw" style="background:var(--series-2)"></span>Purchases</span>
</div>
</div>
<div class="card-bd"><div class="chart-wrap" id="line-wrap"><div class="loading">Loading trend…</div></div></div>
<details class="tbl"><summary>View data table</summary><div style="max-height:260px;overflow:auto"><table><thead><tr><th>Month</th><th>Net Sales</th><th>Purchases</th></tr></thead><tbody id="trend-tbody">
</tbody></table></div></details>
</div>
<div id="breakdown"><div class="loading">Loading breakdown…</div></div>`;
wireFilters(); // draws the trend + breakdown for the default range
}
// ── Breakdown filters (customers / products / product groups) ────────────
function rangeDates(preset){
const now=new Date(),to=now.toISOString().slice(0,10);
let from;
if(preset==='ytd')from=now.getFullYear()+'-01-01';
else{const dd=new Date(now);dd.setMonth(dd.getMonth()-parseInt(preset));from=dd.toISOString().slice(0,10);}
return {from,to};
}
// One date range drives the WHOLE dashboard body: trend chart + breakdown.
function applyRange(from,to){
loadMonthly(from,to);
loadBreakdown(from,to);
}
function wireFilters(){
const setRange=(from,to)=>{document.getElementById('f-from').value=from;document.getElementById('f-to').value=to;};
document.querySelectorAll('.fchip').forEach(c=>c.onclick=()=>{
document.querySelectorAll('.fchip').forEach(x=>x.classList.remove('on'));
c.classList.add('on');
const r=rangeDates(c.dataset.preset);
setRange(r.from,r.to);
applyRange(r.from,r.to);
});
document.getElementById('f-apply').onclick=()=>{
const from=document.getElementById('f-from').value,to=document.getElementById('f-to').value;
if(!from||!to){alert('Pick both From and To dates');return;}
if(from>to){alert('From date must be before To date');return;}
document.querySelectorAll('.fchip').forEach(x=>x.classList.remove('on')); // custom range — no preset active
applyRange(from,to);
};
// ── Product Group multi-select popover ──
const pop=document.getElementById('g-pop');
document.getElementById('g-btn').onclick=e=>{e.stopPropagation();pop.classList.toggle('show');};
pop.onclick=e=>e.stopPropagation();
document.addEventListener('click',()=>pop.classList.remove('show'));
document.getElementById('g-list').innerHTML=_allGroups.length
? _allGroups.map(g=>`<label><input type="checkbox" class="g-cb" value="${g.code}" ${_selGroups.has(g.code)?'checked':''}/> ${esc(g.name)}</label>`).join('')
: '<div style="font-size:11px;color:var(--text3);padding:6px">No item groups found.</div>';
// The product-group filter is its OWN independent filter: ticking a group
// (or Select all / Clear) applies to the data IMMEDIATELY — no Apply button.
// The search box narrows the checklist so a group is easy to find & tick.
let _gT=null;
function applyGroups(){
_selGroups=new Set(Array.from(document.querySelectorAll('.g-cb:checked')).map(c=>c.value)); // string codes (incl. virtual '103bb'/'103capd')
if(_selGroups.size===_allGroups.length)_selGroups=new Set(); // everything ticked = no filter
document.getElementById('g-lbl').textContent=_selGroups.size?`${_selGroups.size} selected`:allLabel();
clearTimeout(_gT); // debounce rapid ticking — one reload after 350ms
_gT=setTimeout(()=>applyRange(document.getElementById('f-from').value,document.getElementById('f-to').value),350); // refreshes BOTH the trend chart and the breakdown/KPI cards
}
document.getElementById('g-list').addEventListener('change',applyGroups);
document.getElementById('g-search').addEventListener('input',function(){
const q=this.value.trim().toLowerCase();
document.querySelectorAll('#g-list label').forEach(l=>{
l.style.display=(!q||l.textContent.toLowerCase().includes(q))?'flex':'none';
});
});
const visibleCbs=()=>Array.from(document.querySelectorAll('#g-list label')).filter(l=>l.style.display!=='none').map(l=>l.querySelector('.g-cb'));
document.getElementById('g-all').onclick=()=>{visibleCbs().forEach(c=>c.checked=true);applyGroups();};
document.getElementById('g-none').onclick=()=>{visibleCbs().forEach(c=>c.checked=false);applyGroups();};
const r=rangeDates('12');setRange(r.from,r.to);
applyRange(r.from,r.to);
}
async function loadMonthly(from,to){
const wrap=document.getElementById('line-wrap');
wrap.innerHTML='<div class="loading">Loading trend…</div>';
try{
const gq=_selGroups.size?`&groups=${[..._selGroups].join(',')}`:'';
const r=await fetch(`/api/board/monthly?from=${from}&to=${to}${gq}`,{headers:{Authorization:'Bearer '+_tok}});
const j=await r.json();if(!j.success)throw new Error(j.message||'Failed');
const rows=j.data||[];
const gnote=_selGroups.size?` · ${_selGroups.size} product group${_selGroups.size>1?'s':''}`:'';
document.getElementById('trend-sub').textContent=`Monthly · ${from} → ${to}${gnote} · net sales = invoices − credit memos · excl. GST`;
document.getElementById('trend-tbody').innerHTML=rows.map(x=>`<tr><td>${MONTHS[x.m-1]} ${x.y}</td><td>${inrFull(x.sales)}</td><td>${inrFull(x.purchases)}</td></tr>`).join('');
if(!rows.length){wrap.innerHTML='<div class="loading">No transactions in this period.</div>';return;}
drawLineChart(rows);
}catch(e){wrap.innerHTML='<div class="err-note">Could not load trend: '+esc(e.message)+'</div>';}
}
let _lastB=null,_lastFrom='',_lastTo=''; // cached so channel toggles re-render without refetch
let _qCust='',_qItem='',_qGrp='',_qT=null,_refocus=null; // per-card search (server-side, ranked by sales)
let _chanD=true,_chanE=true; // Domestic / Export channel toggles (group card)
async function loadBreakdown(from,to){
const el=document.getElementById('breakdown');
el.innerHTML='<div class="loading">Loading breakdown…</div>';
try{
const gq=(_selGroups.size?`&groups=${[..._selGroups].join(',')}`:'')+(_qCust?`&custQ=${encodeURIComponent(_qCust)}`:'')+(_qItem?`&itemQ=${encodeURIComponent(_qItem)}`:'')+(_qGrp?`&grpQ=${encodeURIComponent(_qGrp)}`:'');
const r=await fetch(`/api/board/breakdown?from=${from}&to=${to}${gq}`,{headers:{Authorization:'Bearer '+_tok}});
const j=await r.json();if(!j.success)throw new Error(j.message||'Failed');
_lastB=j.data;_lastFrom=from;_lastTo=to;
renderBreakdown(j.data,from,to);
}catch(e){el.innerHTML='<div class="err-note">Could not load breakdown: '+esc(e.message)+'</div>';}
}
// KPI tiles follow the selected date range — including AR/AP outstanding,
// which show the still-unpaid amount of invoices DATED within the range.
function renderKpis(b){
const k=b.kpi||{},dv=b.divisions||{};
const growth=k.prevNetSales>0?((k.netSales-k.prevNetSales)/k.prevNetSales*100):null;
const gUp=growth!=null&&growth>=0;
// Every KPI tile AND Division Summary (dv.tm/pd/ed/ei) now recompute per the
// selected Product Groups (routes/board.js) — but they're still two
// DIFFERENT formulas for "export sales" (dv.ed/ei = G/L-account-exclusion
// based, matching the Sales Report to the paisa; k.exportDirect/Indirect =
// simple U_CustomerCategory based), so they can legitimately show different
// numbers even both correctly filtered to the same groups — prefer the
// Sales-Report-matching Division Summary figure when it loaded successfully.
const grpActive=_selGroups.size>0;
const expDirect=dv.ed!=null?dv.ed:k.exportDirect;
const expIndirect=dv.ei!=null?dv.ei:k.exportIndirect;
const gsuffix=grpActive?` · ${_selGroups.size} group${_selGroups.size>1?'s':''} selected`:'';
const tiles=[
{l:'Net Sales — Selected Period',v:inr(k.netSales),
delta:growth==null?'':`<span class="kpi-delta ${gUp?'up':'down'}">${gUp?'▲':'▼'} ${Math.abs(growth).toFixed(1)}% vs previous period</span>`,
s:inrFull(k.netSales)+gsuffix},
{l:'Purchases — Selected Period',v:inr(k.purchases),s:inrFull(k.purchases)+gsuffix},
{l:'Invoices — Selected Period',v:(k.invoices||0).toLocaleString('en-IN'),s:(grpActive?'AR invoices with a selected-group line':'AR invoices raised')+gsuffix},
{l:'Active Customers — Period',v:(k.activeCustomers||0).toLocaleString('en-IN'),s:(grpActive?'customers billed on selected groups':'customers billed')+gsuffix},
{l:'Export Sales — Direct',v:inr(expDirect),s:grpActive?('selected groups'+gsuffix):'as per sales report'},
{l:'Export Sales — Indirect',v:inr(expIndirect),s:grpActive?('selected groups'+gsuffix):'as per sales report'},
{l:'Receivables Outstanding',v:inr(k.openAR),s:'unpaid AR invoices dated in period — whole company, not group-filtered'},
{l:'Payables Outstanding',v:inr(k.openAP),s:'unpaid AP invoices dated in period — whole company, not group-filtered'},
];
document.getElementById('kpis').innerHTML=tiles.map(x=>`<div class="kpi"><div class="kpi-l">${x.l}</div><div class="kpi-v">${x.v}</div>${x.delta||`<div class="kpi-s">${x.s||''}</div>`}</div>`).join('');
}
function renderBreakdown(b,from,to){
const gnote=_selGroups.size?` · ${_selGroups.size} product group${_selGroups.size>1?'s':''}`:'';
const period=`${from} → ${to}${gnote}`;
renderKpis(b);
// Division Summary — the exact management-report figures (same SQL as
// /reports sales, verified to the paisa against the books) — now ALSO
// scoped to the selected Product Groups, same as every other card.
const dv=b.divisions;
const divCard=dv?`
<div class="card">
<div class="card-hd"><div><div class="card-title">Division Summary</div><div class="card-sub">As per Sales Report · ${from} → ${to}${gnote}</div></div></div>
<div class="card-bd"><div class="kpis" style="margin-bottom:0">
<div class="kpi"><div class="kpi-l">Blood Bag (TM)</div><div class="kpi-v">${inr(dv.tm)}</div><div class="kpi-s">${inrFull(dv.tm)}</div></div>
<div class="kpi"><div class="kpi-l">CAPD (PD)</div><div class="kpi-v">${inr(dv.pd)}</div><div class="kpi-s">${inrFull(dv.pd)}</div></div>
<div class="kpi"><div class="kpi-l">Export Direct</div><div class="kpi-v">${inr(dv.ed)}</div><div class="kpi-s">${inrFull(dv.ed)}</div></div>
<div class="kpi"><div class="kpi-l">Export Indirect</div><div class="kpi-v">${inr(dv.ei)}</div><div class="kpi-s">${inrFull(dv.ei)}</div></div>
<div class="kpi" style="border-color:var(--series-1);background:#f5f9ff"><div class="kpi-l" style="color:var(--series-1)">Total Sales</div><div class="kpi-v">${inr(dv.total)}</div><div class="kpi-s">${inrFull(dv.total)}</div></div>
</div></div>
</div>`:'';
document.getElementById('breakdown').innerHTML=divCard+`
<div class="card">
<div class="card-hd">
<div><div class="card-title">Sales by Product Group</div><div class="card-sub">Net of credit notes · ${period}${(!_chanD||!_chanE)?` · ${_chanD?'Domestic only':'Export only'}`:''}</div></div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<input class="csearch" id="q-grp" placeholder="Search product group…" value="${esc(_qGrp)}" autocomplete="off"/>
<div class="legend">
<span class="leg ch${_chanD?'':' off'}" id="ch-dom" title="Click to toggle Domestic"><span class="sw" style="background:var(--series-1)"></span>Domestic</span>
<span class="leg ch${_chanE?'':' off'}" id="ch-exp" title="Click to toggle Export"><span class="sw" style="background:var(--series-2)"></span>Export</span>
</div>
</div>
</div>
<div class="card-bd">${b.topGroups.length?hbarsSplit(b.topGroups):'<div class="loading">No sales in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Product Group</th><th>Domestic</th><th>Export</th><th>Total</th></tr></thead><tbody>
${b.topGroups.map(g=>`<tr><td>${esc(g.name)}</td><td>${inrFull(g.domestic)}</td><td>${inrFull(g.export)}</td><td>${inrFull(g.total)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="grid2">
<div class="card">
<div class="card-hd"><div><div class="card-title">Top Customers</div><div class="card-sub">By sales value · ${period}</div></div><input class="csearch" id="q-cust" placeholder="Search customer…" value="${esc(_qCust)}" autocomplete="off"/></div>
<div class="card-bd">${b.topCustomers.length?hbars(b.topCustomers.map(c=>({name:c.name,total:c.total}))):'<div class="loading">No sales in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Customer</th><th>Sales</th></tr></thead><tbody>
${b.topCustomers.map(c=>`<tr><td>${esc(c.name)}</td><td>${inrFull(c.total)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="card">
<div class="card-hd"><div><div class="card-title">Top Products</div><div class="card-sub">By sales value · ${period}</div></div><input class="csearch" id="q-item" placeholder="Search product…" value="${esc(_qItem)}" autocomplete="off"/></div>
<div class="card-bd">${b.topItems.length?hbars(b.topItems.map(i=>({name:i.name||i.code,total:i.total}))):'<div class="loading">No sales in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Product</th><th>Sales</th></tr></thead><tbody>
${b.topItems.map(i=>`<tr><td>${esc(i.code)} — ${esc(i.name)}</td><td>${inrFull(i.total)}</td></tr>`).join('')}
</tbody></table></details>
</div>
</div>`;
// Channel toggle chips — clicking the ONLY active one is ignored (at least
// one channel must stay selected). Re-renders from cached data, no refetch.
const cd=document.getElementById('ch-dom'),ce=document.getElementById('ch-exp');
if(cd)cd.onclick=()=>{if(_chanD&&!_chanE)return;_chanD=!_chanD;renderBreakdown(_lastB||b,_lastFrom||from,_lastTo||to);};
if(ce)ce.onclick=()=>{if(_chanE&&!_chanD)return;_chanE=!_chanE;renderBreakdown(_lastB||b,_lastFrom||from,_lastTo||to);};
// Per-card search — server-side (finds ANY row in the period, ranked by
// sales), debounced; the input that triggered the reload keeps its focus.
const wireQ=(id,set)=>{
const el=document.getElementById(id);if(!el)return;
el.oninput=function(){
set(this.value);
const pos=this.selectionStart;
clearTimeout(_qT);
_qT=setTimeout(()=>{_refocus={id,pos};loadBreakdown(_lastFrom||from,_lastTo||to);},450);
};
};
wireQ('q-cust',v=>_qCust=v);
wireQ('q-item',v=>_qItem=v);
wireQ('q-grp',v=>_qGrp=v);
if(_refocus){
const el=document.getElementById(_refocus.id);
if(el){el.focus();try{el.setSelectionRange(_refocus.pos,_refocus.pos);}catch(_e){}}
_refocus=null;
}
}
// ── Stacked Domestic/Export bars per product group — a 2px surface gap
// separates the two segments; hover shows the split via the title. ──
function hbarsSplit(rows){
// Values, widths and ordering follow the ACTIVE channel toggles (legend
// chips) — both on = total, one on = that channel only.
const sel=r=>(_chanD?Math.max(0,r.domestic):0)+(_chanE?Math.max(0,r.export):0);
const list=[...rows].sort((a,b)=>sel(b)-sel(a));
const max=Math.max(...list.map(sel),1);
return list.map(r=>{
const dw=_chanD?Math.max(0,r.domestic)/max*100:0, ew=_chanE?Math.max(0,r.export)/max*100:0;
return `<div class="hbar-row" title="${esc(r.name)} — Domestic: ${inrFull(r.domestic)} · Export: ${inrFull(r.export)} · Total: ${inrFull(r.total)}">
<div class="hbar-name">${esc(r.name)}</div>
<div class="hbar-track" style="display:flex;gap:2px">
${dw>0?`<div class="hbar-fill" style="width:${dw}%;border-radius:${ew>0?'0':'0 4px 4px 0'}"></div>`:''}
${ew>0?`<div class="hbar-fill" style="width:${ew}%;background:var(--series-2)"></div>`:''}
</div>
<div class="hbar-val">${inr(sel(r))}</div>
</div>`;
}).join('');
}
// ── Horizontal bars — single series (title carries identity, no legend) ──
function hbars(rows){
const max=Math.max(...rows.map(r=>r.total),1);
return rows.map(r=>`<div class="hbar-row" title="${esc(r.name)}: ${inrFull(r.total)}">
<div class="hbar-name">${esc(r.name)}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${Math.max(1,r.total/max*100)}%"></div></div>
<div class="hbar-val">${inr(r.total)}</div>
</div>`).join('');
}
// ── 2-series line chart with crosshair hover + tooltip ───────────────────
function drawLineChart(monthly){
const wrap=document.getElementById('line-wrap');if(!wrap||!monthly.length)return;
const W=1080,H=300,PL=64,PR=16,PT=14,PB=30;
const iw=W-PL-PR,ih=H-PT-PB;
const maxV=Math.max(...monthly.map(r=>Math.max(r.sales,r.purchases)),1)*1.08;
const x=i=>PL+(monthly.length===1?iw/2:i*(iw/(monthly.length-1)));
const y=v=>PT+ih-(Math.max(v,0)/maxV*ih);
const pts=key=>monthly.map((r,i)=>`${x(i).toFixed(1)},${y(r[key]).toFixed(1)}`).join(' ');
// recessive grid: 4 horizontal lines + axis labels in Cr
let grid='';
for(let g=0;g<=4;g++){
const gv=maxV*g/4, gy=y(gv);
grid+=`<line x1="${PL}" y1="${gy}" x2="${W-PR}" y2="${gy}" stroke="#eef0f3" stroke-width="1"/>
<text x="${PL-8}" y="${gy+3.5}" text-anchor="end" font-size="10" fill="#9ca3af" font-family="JetBrains Mono,monospace">${(gv/1e7).toFixed(gv>=1e7?0:1)} Cr</text>`;
}
// x labels — every 3rd month to stay uncluttered
let xl='';
monthly.forEach((r,i)=>{ if(i%3===0||i===monthly.length-1)
xl+=`<text x="${x(i)}" y="${H-8}" text-anchor="middle" font-size="10" fill="#9ca3af" font-family="Space Grotesk,sans-serif">${MONTHS[r.m-1]} ${String(r.y).slice(2)}</text>`;});
wrap.innerHTML=`
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Monthly net sales and purchases, last 24 months">
${grid}${xl}
<polyline points="${pts('purchases')}" fill="none" stroke="var(--series-2)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="${pts('sales')}" fill="none" stroke="var(--series-1)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<line id="xh" x1="0" y1="${PT}" x2="0" y2="${PT+ih}" stroke="#c7cdd6" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
<circle id="dot-s" r="4" fill="var(--series-1)" stroke="#fff" stroke-width="2" opacity="0"/>
<circle id="dot-p" r="4" fill="var(--series-2)" stroke="#fff" stroke-width="2" opacity="0"/>
</svg>
<div class="tip" id="line-tip"></div>`;
const svg=wrap.querySelector('svg'),tip=document.getElementById('line-tip');
const xh=svg.querySelector('#xh'),ds=svg.querySelector('#dot-s'),dp=svg.querySelector('#dot-p');
svg.addEventListener('mousemove',ev=>{
const rect=svg.getBoundingClientRect();
const sx=(ev.clientX-rect.left)*(W/rect.width);
let idx=Math.round((sx-PL)/(iw/(monthly.length-1)));
idx=Math.max(0,Math.min(monthly.length-1,idx));
const r=monthly[idx],cx=x(idx);
xh.setAttribute('x1',cx);xh.setAttribute('x2',cx);xh.setAttribute('opacity','1');
ds.setAttribute('cx',cx);ds.setAttribute('cy',y(r.sales));ds.setAttribute('opacity','1');
dp.setAttribute('cx',cx);dp.setAttribute('cy',y(r.purchases));dp.setAttribute('opacity','1');
tip.innerHTML=`<div style="font-weight:700;margin-bottom:3px">${MONTHS[r.m-1]} ${r.y}</div>
<div class="t-row"><span class="sw" style="background:var(--series-1)"></span>Net Sales <b>&nbsp;${inr(r.sales)}</b></div>
<div class="t-row"><span class="sw" style="background:var(--series-2)"></span>Purchases <b>&nbsp;${inr(r.purchases)}</b></div>`;
tip.style.left=(cx/W*100)+'%';
tip.style.top=(Math.min(y(r.sales),y(r.purchases))/H*100)+'%';
tip.style.opacity='1';
});
svg.addEventListener('mouseleave',()=>{tip.style.opacity='0';xh.setAttribute('opacity','0');ds.setAttribute('opacity','0');dp.setAttribute('opacity','0');});
}
load();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+520
View File
@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>BOM — MITRA ERP Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:12px;max-width:720px;margin:0 auto}
.tabs{display:flex;gap:4px;margin-bottom:14px;background:var(--surface3);border-radius:var(--radius);padding:4px}
.tab{flex:1;padding:8px;border-radius:7px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);text-align:center;transition:all .15s}.tab:hover{color:var(--text2)}.tab.on{background:var(--surface);color:var(--blue);border:1px solid var(--border2)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(26,111,255,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:420px){.fi-row{grid-template-columns:1fr}}
.fi-mono{font-family:var(--mono);font-size:12px;text-transform:uppercase}
.search-wrap{position:relative}
.search-dd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1px solid var(--blue);border-radius:7px;box-shadow:0 12px 40px rgba(0,0,0,.7);z-index:300;max-height:200px;overflow-y:auto;display:none;margin-top:4px}
.sd-row{padding:10px 12px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center}.sd-row:hover{background:rgba(26,111,255,.1)}
.sd-code{font-family:var(--mono);font-size:11px;color:var(--blue);font-weight:600;min-width:90px}.sd-name{font-size:11px;color:var(--text2)}
.comp-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;position:relative}
.comp-card:hover{border-color:var(--border2)}
.comp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}
@media(max-width:420px){.comp-grid{grid-template-columns:1fr 1fr}}
.comp-inp{width:100%;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:12px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;height:28px}.comp-inp:focus{border-color:var(--blue)}
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:var(--blue);color:#fff;flex:1}.b-primary:hover:not(:disabled){background:#0d4fd4}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-add{background:rgba(26,111,255,.1);color:var(--blue);border:1px solid rgba(26,111,255,.3)}.b-add:hover{background:rgba(26,111,255,.2)}
.b-del{padding:3px 10px;font-size:10px;background:rgba(229,62,62,.06);color:var(--red);border:1px solid rgba(229,62,62,.2);border-radius:5px;cursor:pointer}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-info{background:rgba(26,111,255,.08);border:1px solid rgba(26,111,255,.2);color:#60a5fa}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.suc-box{text-align:center;padding:28px 16px}
.suc-title{font-family:var(--display);font-size:26px;color:var(--green);margin-bottom:6px}
.suc-pill{display:inline-block;font-family:var(--mono);font-size:13px;padding:5px 16px;border-radius:6px;background:rgba(26,111,255,.12);color:var(--blue);border:1px solid rgba(26,111,255,.25);margin:8px 0 12px}
.req-card{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:6px}
.req-card:hover{border-color:var(--border2)}
.po-card{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:6px;cursor:pointer}.po-card:hover{border-color:var(--blue)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null,_cfg=null,_wh=[];
const BE='/api';
const ISSUE_LIST=['Manual','Backflush'];
const BOM_TYPES=['Production','Sales','Template','Assembly'];
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _tab='create'; // create|update|my
let _createH={productNo:'',qty:1,productDesc:'',bomType:'Production',warehouse:'BH-PF',distrRule:'',project:''};
let _createRows=[];
let _updateH={};
let _updateRows=[];
let _updateLoaded=null;
let _searchResults=[];
let _searchQ='';
let _myRequests=[];
let _createSuccess=false;
let _updateSuccess=false;
let _submitting=false;
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
function mkRow(){return{id:Date.now()+Math.random(),type:'Item',itemType:'pit_Item',no:'',desc:'',qty:1,uom:'PCS',warehouse:'BH-PC',issueMethod:'Manual',comment:'',unitCost:0};}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4000);}
async function loadWH(){try{const r=await api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_wh=r.data;}catch(_e){}}
async function fetchItems(v){try{const r=await api('GET',`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch(_e){}return[];}
async function fetchResources(v){try{const r=await api('GET',`/sap/lookup/resources?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch(_e){}return[];}
async function fetchBOM(code){try{const r=await api('GET',`/sap/bom/${encodeURIComponent(code)}?company=${encodeURIComponent(_co)}`);if(r.success&&r.data)return r.data;}catch(_e){}return null;}
async function loadMyRequests(){try{const r=await api('GET','/bom-requests?mine=true');_myRequests=r.data||[];}catch(_e){}}
async function init(){
try{const r=await api('GET','/config');_cfg=r;}catch(_e){}
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await loadWH();
await loadMyRequests();
render();
}
// ═══════════════════════════════════════
function render(){
try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}
}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
// Tabs
const tb=mk('tabs');
[{id:'create',l:'+ Create BOM'},{id:'update',l:'Update BOM'},{id:'my',l:'My Requests'}].forEach(t=>{
const b=document.createElement('button');b.className=`tab${_tab===t.id?' on':''}`;b.textContent=t.l;
b.onclick=()=>{_tab=t.id;render();};tb.appendChild(b);
});
app.appendChild(tb);
if(_tab==='create') renderCreate(app);
else if(_tab==='update') renderUpdate(app);
else renderMyRequests(app);
}
// ═══════════════════════════════════════
// CREATE BOM
// ═══════════════════════════════════════
function renderCreate(app){
if(_createSuccess){renderCreateSuccess(app);return;}
const levels=_cfg?.approvalLevels||3;
app.appendChild(mkAlert(`BOM Create requests go through <strong>${levels}-level approval</strong> before being pushed to SAP B1.`));
// Header card
const c1=mk('card');
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}" ${_createH.warehouse===w.code?'selected':''}>${esc(w.code)} - ${esc(w.name)}</option>`).join('');
c1.innerHTML=`<div class="card-hd">Create New BOM</div>
<div class="card-bd">
<div class="fi-row"><div class="fg"><label class="fl">Company</label><select class="fi" id="c-company">${coOptions(_co)}</select></div>
<div class="fg"><label class="fl">BOM Type</label><select class="fi" id="c-type">${BOM_TYPES.map(t=>`<option ${_createH.bomType===t?'selected':''}>${t}</option>`).join('')}</select></div></div>
<div class="fg"><label class="fl">Product No. *</label><div class="search-wrap"><input class="fi fi-mono" id="c-item" value="${esc(_createH.productNo)}" placeholder="Search item..." autocomplete="off"/><div class="search-dd" id="c-item-dd"></div></div></div>
<div class="fg"><label class="fl">Description</label><input class="fi" id="c-desc" value="${esc(_createH.productDesc)}" readonly style="opacity:.7"/></div>
<div class="fi-row">
<div class="fg"><label class="fl">Quantity</label><input class="fi fi-mono" id="c-qty" type="number" min="1" step="1" value="${_createH.qty}" style="text-align:right"/></div>
<div class="fg"><label class="fl">Warehouse</label><select class="fi" id="c-wh">${whOpts}</select></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Distr. Rule</label><input class="fi" id="c-dr" value="${esc(_createH.distrRule)}"/></div>
<div class="fg"><label class="fl">Project</label><input class="fi" id="c-proj" value="${esc(_createH.project)}"/></div>
</div>
</div>`;
app.appendChild(c1);
// Components card
app.appendChild(buildComponentsCard('create',_createRows));
// Actions
const c3=mk('card');
c3.innerHTML=`<div class="card-bd"><div class="btn-bar">
<button class="btn b-primary" id="c-submit" ${_submitting?'disabled':''}><span id="c-sp" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Submit for Approval</button>
<button class="btn b-cancel" id="c-reset">Reset</button>
</div></div>`;
app.appendChild(c3);
setTimeout(()=>{wireCreateEvents();},0);
}
function wireCreateEvents(){
q('#c-company')&&(q('#c-company').onchange=async function(){_co=this.value;_createRows=[];await loadWH();render();});
q('#c-type')&&(q('#c-type').onchange=function(){_createH.bomType=this.value;});
q('#c-qty')&&(q('#c-qty').onchange=function(){_createH.qty=parseFloat(this.value)||1;});
q('#c-wh')&&(q('#c-wh').onchange=function(){_createH.warehouse=this.value;});
q('#c-dr')&&(q('#c-dr').oninput=function(){_createH.distrRule=this.value;});
q('#c-proj')&&(q('#c-proj').oninput=function(){_createH.project=this.value;});
q('#c-submit')&&(q('#c-submit').onclick=doSubmitCreate);
q('#c-reset')&&(q('#c-reset').onclick=()=>{if(!confirm('Reset?'))return;_createH={productNo:'',qty:1,productDesc:'',bomType:'Production',warehouse:'BH-PF',distrRule:'',project:''};_createRows=[];render();});
wireItemSearch('c-item','c-item-dd',_createH,'create');
wireCompEvents('create');
}
// ═══════════════════════════════════════
// UPDATE BOM
// ═══════════════════════════════════════
function renderUpdate(app){
if(_updateSuccess){renderUpdateSuccess(app);return;}
// Search
const c1=mk('card');
const coVal=_co;
c1.innerHTML=`<div class="card-hd">Update BOM</div>
<div class="card-bd">
<div class="fg"><label class="fl">Company</label><select class="fi" id="u-company">${coOptions(coVal)}</select></div>
<div class="fg"><label class="fl">Search BOM * <span style="font-weight:400;text-transform:none;color:var(--text3)">(type item code or name)</span></label>
<div style="display:flex;gap:6px;margin-bottom:4px">
<div class="search-wrap" style="flex:1">
<input class="fi fi-mono" id="u-search" value="${esc(_searchQ)}" placeholder="Type FG, RM, PM..." autocomplete="off"/>
<div class="search-dd" id="u-search-dd" style="z-index:500"></div>
</div>
<button class="btn b-add" id="u-sbtn" style="white-space:nowrap;height:36px">Load</button>
<button class="btn b-cancel" id="u-listall" style="white-space:nowrap;font-size:11px;height:36px">List All</button>
</div>
</div>
${_searchResults.length?`<div style="margin-top:8px;font-size:10px;color:var(--text3);margin-bottom:6px">${_searchResults.length} result${_searchResults.length!==1?'s':''}</div>`+_searchResults.map(r=>`<div class="po-card" data-bom="${esc(r.TreeCode)}"><div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><span style="font-family:var(--mono);font-size:11px;color:#0a6ed1;font-weight:600">${esc(r.TreeCode)}</span><span style="font-size:12px;color:var(--text);flex:1">${esc(r.ProductDescription||'')}</span><span style="font-size:10px;color:var(--text3)">Qty:${r.Quantity||1}</span></div></div>`).join(''):''}
${_updateLoaded?`<div style="margin-top:6px;padding:8px 10px;background:var(--surface2);border:1px solid #0a6ed1;border-radius:8px"><div style="display:flex;gap:8px;align-items:center"><span style="font-family:var(--mono);font-size:12px;color:#0a6ed1;font-weight:600">${esc(_updateLoaded.TreeCode)}</span><span style="font-size:12px;color:var(--text)">${esc(_updateLoaded.ProductDescription||'')}</span></div></div>`:''}
</div>`;
app.appendChild(c1);
if(_updateLoaded){
// Header
const c2=mk('card');
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}" ${_updateH.warehouse===w.code?'selected':''}>${esc(w.code)} - ${esc(w.name)}</option>`).join('');
c2.innerHTML=`<div class="card-hd">Editing: ${esc(_updateLoaded.TreeCode)}</div>
<div class="card-bd">
<div class="fg"><label class="fl">Description</label><input class="fi" id="u-desc" value="${esc(_updateH.productDesc)}" style="text-transform:uppercase"/></div>
<div class="fi-row">
<div class="fg"><label class="fl">Quantity</label><input class="fi fi-mono" id="u-qty" type="number" min="1" step="1" value="${_updateH.qty}" style="text-align:right"/></div>
<div class="fg"><label class="fl">Warehouse</label><select class="fi" id="u-wh">${whOpts}</select></div>
</div>
</div>`;
app.appendChild(c2);
app.appendChild(buildComponentsCard('update',_updateRows));
const c4=mk('card');
c4.innerHTML=`<div class="card-bd"><div class="btn-bar">
<button class="btn b-primary" id="u-submit" ${_submitting?'disabled':''}><span id="u-sp" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Submit Update</button>
<button class="btn b-cancel" id="u-clear">Clear</button>
</div></div>`;
app.appendChild(c4);
}
setTimeout(()=>{wireUpdateEvents();},0);
}
function wireUpdateEvents(){
q('#u-company')&&(q('#u-company').onchange=async function(){_co=this.value;_searchResults=[];_updateLoaded=null;_updateRows=[];await loadWH();render();});
// Live search dropdown — same as Create BOM item search
let _ust=null;
const inp=q('#u-search'),dd=q('#u-search-dd');
if(inp){
inp.oninput=function(){
_searchQ=this.value.toUpperCase();
const v=this.value.trim();
if(v.length<2){if(dd)dd.style.display='none';return;}
clearTimeout(_ust);
_ust=setTimeout(async()=>{
if(!dd)return;
dd.innerHTML='<div style="padding:12px;text-align:center;color:var(--text3);font-size:11px"><span class="sp" style="width:12px;height:12px"></span> Searching...</div>';
dd.style.display='block';
const items=await fetchItems(v);
if(!items.length){dd.innerHTML='<div style="padding:12px;text-align:center;color:var(--text3);font-size:11px">No items found</div>';return;}
dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML='<span class="sd-code">'+esc(it.ItemCode)+'</span><span class="sd-name">'+esc(it.ItemName)+'</span>';
row.onclick=()=>{dd.style.display='none';inp.value=it.ItemCode;_searchQ=it.ItemCode;doLoadBom(it.ItemCode);};
dd.appendChild(row);
});
},250);
};
if(dd)document.addEventListener('click',e=>{if(!inp.parentElement.contains(e.target))dd.style.display='none';});
}
q('#u-sbtn')&&(q('#u-sbtn').onclick=function(){const v=(q('#u-search')?.value||'').trim();if(v)doLoadBom(v.toUpperCase());else toast('Enter item code','err');});
q('#u-listall')&&(q('#u-listall').onclick=doListAll);
q('#u-search')&&(q('#u-search').onkeydown=function(e){if(e.key==='Enter'){const v=this.value.trim();if(v)doLoadBom(v.toUpperCase());}});
document.querySelectorAll('[data-bom]').forEach(c=>{c.onclick=function(){doLoadBom(this.dataset.bom);};});
q('#u-desc')&&(q('#u-desc').oninput=function(){_updateH.productDesc=this.value.toUpperCase();});
q('#u-qty')&&(q('#u-qty').onchange=function(){_updateH.qty=parseFloat(this.value)||1;});
q('#u-wh')&&(q('#u-wh').onchange=function(){_updateH.warehouse=this.value;});
q('#u-submit')&&(q('#u-submit').onclick=doSubmitUpdate);
q('#u-clear')&&(q('#u-clear').onclick=()=>{_updateLoaded=null;_updateRows=[];render();});
wireCompEvents('update');
}
// ═══════════════════════════════════════
// SHARED: Components Card
// ═══════════════════════════════════════
function buildComponentsCard(ns,rows){
const c=mk('card');
let html='';
if(!rows.length) html='<div class="empty-msg">No components added</div>';
else html=rows.map((r,i)=>{
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}" ${r.warehouse===w.code?'selected':''}>${esc(w.code)}</option>`).join('');
return`<div class="comp-card">
<span style="position:absolute;top:6px;right:8px;font-family:var(--mono);font-size:10px;color:var(--text3);background:var(--surface3);padding:1px 6px;border-radius:4px">#${i+1}</span>
<div class="fg"><div class="search-wrap"><input class="fi fi-mono comp-no" data-i="${i}" data-ns="${ns}" value="${esc(r.no)}" placeholder="Search item code..." style="height:32px;font-size:11px" autocomplete="off"/><div class="search-dd comp-dd" id="${ns}-rdd-${i}"></div></div></div>
${r.desc?`<div style="font-size:12px;color:var(--text);margin-bottom:6px;font-weight:600">${esc(r.desc)}</div>`:''}
<div class="comp-grid">
<div><label style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)">Qty</label><input class="comp-inp" data-i="${i}" data-f="qty" data-ns="${ns}" type="number" min="0" step="0.001" value="${r.qty}" style="text-align:right"/></div>
<div><label style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)">UoM</label><input class="comp-inp" data-i="${i}" data-f="uom" data-ns="${ns}" value="${esc(r.uom)}" style="text-transform:uppercase"/></div>
<div><label style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)">Warehouse</label><select class="comp-inp" data-i="${i}" data-f="warehouse" data-ns="${ns}" style="padding:2px 4px">${whOpts}</select></div>
<div><label style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)">Issue</label><select class="comp-inp" data-i="${i}" data-f="issueMethod" data-ns="${ns}" style="padding:2px 4px">${ISSUE_LIST.map(m=>`<option ${r.issueMethod===m?'selected':''}>${m}</option>`).join('')}</select></div>
<div><label style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)">Type</label><select class="comp-inp" data-i="${i}" data-f="type" data-ns="${ns}" style="padding:2px 4px"><option ${r.type==='Item'?'selected':''}>Item</option><option ${r.type==='Resource'?'selected':''}>Resource</option></select></div>
<div><label style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)">Cost</label><div style="font-family:var(--mono);font-size:11px;color:var(--text3);padding:5px 0">${r.unitCost>0?'INR '+parseFloat(r.unitCost).toFixed(2):'-'}</div></div>
</div>
<div style="margin-top:6px;text-align:right"><button class="b-del" data-rm="${i}" data-ns="${ns}">Remove</button></div>
</div>`;
}).join('');
const total=rows.reduce((s,r)=>s+(parseFloat(r.qty)||0)*(parseFloat(r.unitCost)||0),0);
c.innerHTML=`<div class="card-hd">Components<span class="right">${rows.length} line${rows.length!==1?'s':''}</span></div>
<div class="card-bd">${html}
<button class="btn b-add" id="${ns}-add" style="width:100%;padding:8px;font-size:11px;margin-top:4px">+ Add Component</button>
${rows.length?`<div style="display:flex;justify-content:space-between;padding:8px 0;font-size:11px;color:var(--text3);margin-top:4px;border-top:1px solid var(--border)"><span>Total Cost</span><strong style="font-family:var(--mono);color:var(--text)">INR ${total.toFixed(4)}</strong></div>`:''}
</div>`;
return c;
}
function wireCompEvents(ns){
const rows=ns==='create'?_createRows:_updateRows;
q(`#${ns}-add`)&&(q(`#${ns}-add`).onclick=()=>{rows.push(mkRow());render();});
document.querySelectorAll(`[data-rm][data-ns="${ns}"]`).forEach(b=>{b.onclick=function(){rows.splice(parseInt(this.dataset.rm),1);if(!rows.length)rows.push(mkRow());render();};});
document.querySelectorAll(`.comp-inp[data-ns="${ns}"]`).forEach(el=>{
const handler=function(){const i=parseInt(this.dataset.i),f=this.dataset.f;
if(f==='qty')rows[i][f]=parseFloat(this.value)||0;
else if(f==='type'){rows[i][f]=this.value;rows[i].itemType=this.value==='Resource'?'pit_Resource':'pit_Item';rows[i].no='';rows[i].desc='';render();return;}
else rows[i][f]=this.value;
};el.oninput=handler;el.onchange=handler;
});
// Item search on comp cards
let _cst={};
document.querySelectorAll(`.comp-no[data-ns="${ns}"]`).forEach(inp=>{
inp.oninput=function(){
const i=parseInt(this.dataset.i),v=this.value.trim();
rows[i].no=this.value.toUpperCase();
const dd=document.getElementById(`${ns}-rdd-${i}`);if(!dd)return;
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_cst[ns+i]);
_cst[ns+i]=setTimeout(async()=>{
dd.innerHTML='<div style="padding:10px;text-align:center;color:var(--text3);font-size:11px"><span class="sp" style="width:12px;height:12px"></span></div>';dd.style.display='block';
const isResource=rows[i].type==='Resource';
const items=isResource?await fetchResources(v):await fetchItems(v);
if(!items.length){dd.innerHTML='<div style="padding:10px;text-align:center;color:var(--text3);font-size:11px">No '+(isResource?'resources':'items')+'</div>';return;}
dd.innerHTML='';items.forEach(it=>{
const code=isResource?it.ResCode:it.ItemCode;
const name=isResource?it.ResName:it.ItemName;
const r=document.createElement('div');r.className='sd-row';
r.innerHTML=`<span class="sd-code">${esc(code)}</span><span class="sd-name">${esc(name)}</span>`;
r.onclick=()=>{rows[i].no=code;rows[i].desc=name;rows[i].uom=isResource?'':it.UoM||'PCS';rows[i].unitCost=isResource?0:parseFloat(it.Price)||0;rows[i].itemType=isResource?'pit_Resource':'pit_Item';dd.style.display='none';render();};
dd.appendChild(r);
});
},250);
};
});
document.addEventListener('click',e=>{if(!e.target.closest('.search-wrap'))document.querySelectorAll('.comp-dd').forEach(d=>d.style.display='none');});
}
// ═══════════════════════════════════════
// SHARED: Item Search (header)
// ═══════════════════════════════════════
let _hst=null;
function wireItemSearch(inpId,ddId,hdr,ns){
const inp=q('#'+inpId),dd=q('#'+ddId);if(!inp||!dd)return;
inp.oninput=function(){
hdr.productNo=this.value.toUpperCase();const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_hst);
_hst=setTimeout(async()=>{
dd.innerHTML='<div style="padding:10px;text-align:center;color:var(--text3);font-size:11px"><span class="sp" style="width:12px;height:12px"></span></div>';dd.style.display='block';
const items=await fetchItems(v);
if(!items.length){dd.innerHTML='<div style="padding:10px;text-align:center;color:var(--text3);font-size:11px">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row';
r.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
r.onclick=()=>{hdr.productNo=it.ItemCode;hdr.productDesc=it.ItemName;dd.style.display='none';render();};
dd.appendChild(r);
});
},250);
};
}
// ═══════════════════════════════════════
// SEARCH + LOAD BOM (Update tab)
// ═══════════════════════════════════════
async function doSearch(){
const v=(q('#u-search')?.value||'').trim().toUpperCase();_searchQ=v;
if(!v){toast('Enter search term','err');return;}
const btn=q('#u-sbtn');if(btn){btn.disabled=true;btn.textContent='...';}
try{
// Try exact match
try{const d=await api('GET',`/sap/bom/${encodeURIComponent(v)}?company=${encodeURIComponent(_co)}`);if(d.success&&d.data){_searchResults=[d.data];render();return;}}catch(_e){}
// Search via backend
const r=await api('GET',`/sap/bom/search?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);
_searchResults=r.data||[];
if(!_searchResults.length)toast('No BOM found','err');
render();
}catch(e){toast(e.message,'err');}
finally{if(btn){btn.disabled=false;btn.textContent='Search';}}
}
async function doListAll(){
const btn=q('#u-listall');if(btn){btn.disabled=true;btn.textContent='...';}
try{
const r=await api('GET',`/sap/bom/search?q=&company=${encodeURIComponent(_co)}`);
_searchResults=r.data||[];_searchQ='(all)';
if(!_searchResults.length)toast('No BOMs found','err');
render();
}catch(e){toast(e.message,'err');}
finally{if(btn){btn.disabled=false;btn.textContent='List All';}}
}
async function doLoadBom(code){
try{
const r=await api('GET',`/sap/bom/${encodeURIComponent(code)}?company=${encodeURIComponent(_co)}`);
const d=r.data||{};
_updateLoaded=d;
_updateH={company:_co,productNo:d.TreeCode||code,qty:d.Quantity||1,productDesc:d.ProductDescription||'',bomType:{iProductionTree:'Production',iSalesTree:'Sales',iAssemblyTree:'Assembly',iTemplateTree:'Template'}[d.TreeType]||'Production',warehouse:d.Warehouse||'',distrRule:d.DistributionRule||'',project:d.Project||''};
_updateRows=(d.ProductTreeLines||[]).map(l=>({
id:Date.now()+Math.random(),type:l.ItemType==='pit_Resource'?'Resource':'Item',itemType:l.ItemType||'pit_Item',
no:l.ItemCode||'',desc:l.ItemName||l.ItemDescription||'',qty:l.Quantity||1,uom:l.UnitOfMeasurement||'PCS',
warehouse:l.Warehouse||d.Warehouse||'',issueMethod:l.IssueMethod==='im_Backflush'?'Backflush':'Manual',comment:'',unitCost:l.Price||0
}));
if(!_updateRows.length)_updateRows.push(mkRow());
toast('Loaded: '+code);render();
}catch(e){toast(e.message,'err');}
}
// ═══════════════════════════════════════
// SUBMIT
// ═══════════════════════════════════════
async function doSubmitCreate(){
const prodNo=(_createH.productNo||'').trim().toUpperCase();
const prodDesc=(_createH.productDesc||'').trim().toUpperCase();
if(!prodNo){toast('Product No. required','err');return;}
if(!prodDesc){toast('Description required','err');return;}
const valid=_createRows.filter(r=>r.no.trim()&&parseFloat(r.qty)>0);
if(!valid.length){toast('Add at least one component','err');return;}
_submitting=true;render();
try{
await api('POST','/bom-requests/create',{
company:_co,itemCode:prodNo,itemName:prodDesc,qty:_createH.qty,bomType:_createH.bomType,
warehouse:_createH.warehouse,distrRule:_createH.distrRule,project:_createH.project,
components:valid.map(r=>({itemCode:r.no.trim().toUpperCase(),itemName:r.desc,qty:parseFloat(r.qty)||1,uom:r.uom,warehouse:r.warehouse,issueMethod:r.issueMethod,unitCost:parseFloat(r.unitCost)||0,itemType:r.itemType||'pit_Item'}))
});
_createSuccess=true;toast('BOM submitted for approval!');await loadMyRequests();
}catch(e){toast(e.message,'err');}
_submitting=false;render();
}
async function doSubmitUpdate(){
if(!_updateLoaded)return;
const valid=_updateRows.filter(r=>r.no.trim()&&parseFloat(r.qty)>0);
if(!valid.length){toast('Add at least one component','err');return;}
_submitting=true;render();
try{
await api('POST','/bom-requests/update',{
company:_co,treeCode:_updateLoaded.TreeCode,itemName:(_updateH.productDesc||'').toUpperCase(),
qty:_updateH.qty,bomType:_updateH.bomType||'Production',warehouse:_updateH.warehouse,
components:valid.map((r,i)=>({itemCode:r.no.trim().toUpperCase(),qty:parseFloat(r.qty)||1,issueMethod:r.issueMethod,warehouse:r.warehouse,itemType:r.itemType||'pit_Item',visualOrder:i})),
originalData:{treeCode:_updateLoaded.TreeCode,description:_updateLoaded.ProductDescription}
});
_updateSuccess=true;toast('Update submitted!');await loadMyRequests();
}catch(e){toast(e.message,'err');}
_submitting=false;render();
}
// ═══════════════════════════════════════
// MY REQUESTS
// ═══════════════════════════════════════
function renderMyRequests(app){
const c=mk('card');
let html='';
if(!_myRequests.length) html='<div class="empty-msg">No requests yet. Create a BOM request to get started.</div>';
else html=_myRequests.map(r=>{
const stCls={PENDING:'color:var(--blue)',L1_APPROVED:'color:var(--amber)',L2_APPROVED:'color:#9f5afd',SAP_PUSHED:'color:var(--green)',REJECTED:'color:var(--red)',CANCELLED:'color:var(--text3)'}[r.status]||'';
const stLbl={PENDING:'Pending',L1_APPROVED:'L1 Approved',L2_APPROVED:'L2 Approved',SAP_PUSHED:'In SAP B1',REJECTED:'Rejected',CANCELLED:'Cancelled'}[r.status]||r.status;
return`<div class="req-card"><div style="display:flex;align-items:center;gap:8px"><span style="font-family:var(--mono);font-size:11px;color:var(--blue);font-weight:600">${esc(r.itemCode)}</span><span style="font-size:10px;padding:2px 6px;border-radius:4px;font-weight:700;${stCls};background:rgba(255,255,255,.05)">${stLbl}</span><span style="font-size:10px;color:var(--text3)">${r.type}</span></div>
<div style="font-size:12px;font-weight:600;color:var(--text);margin:4px 0">${esc(r.itemName||'-')}</div>
<div style="font-size:10px;color:var(--text3)">${new Date(r.submittedAt||Date.now()).toLocaleDateString('en-IN')} | ${(r.components||[]).length} components</div></div>`;
}).join('');
c.innerHTML=`<div class="card-hd">My Requests<span class="right"><button class="btn b-add" id="my-refresh" style="padding:4px 10px;font-size:10px">Refresh</button></span></div><div class="card-bd">${html}</div>`;
app.appendChild(c);
setTimeout(()=>{q('#my-refresh')&&(q('#my-refresh').onclick=async()=>{await loadMyRequests();render();});},0);
}
// ═══════════════════════════════════════
// SUCCESS
// ═══════════════════════════════════════
function renderCreateSuccess(app){
const c=mk('card');
c.innerHTML=`<div class="card-hd">BOM Request Submitted</div><div class="card-bd"><div class="suc-box">
<div style="font-size:44px;margin-bottom:10px"></div>
<div class="suc-title">Submitted!</div>
<p style="font-size:13px;color:var(--text3)">${esc(_createH.productDesc||_createH.productNo)}</p>
<div class="suc-pill">${esc(_createH.productNo)}</div>
<div class="alert al-info" style="max-width:400px;margin:10px auto;text-align:left">Requires <strong>${_cfg?.approvalLevels||3}-level approval</strong> before being pushed to SAP B1.</div>
<div class="btn-bar" style="justify-content:center"><button class="btn b-add" id="suc-new">+ New BOM</button><button class="btn b-primary" onclick="window.location.href='/approvals'">Approvals</button></div>
</div></div>`;
app.appendChild(c);
setTimeout(()=>{q('#suc-new')&&(q('#suc-new').onclick=()=>{_createSuccess=false;_createH={productNo:'',qty:1,productDesc:'',bomType:'Production',warehouse:'BH-PF',distrRule:'',project:''};_createRows=[];render();});},0);
}
function renderUpdateSuccess(app){
const c=mk('card');
c.innerHTML=`<div class="card-hd">Update Submitted</div><div class="card-bd"><div class="suc-box">
<div style="font-size:44px;margin-bottom:10px"></div>
<div class="suc-title">Update Submitted!</div>
<p style="font-size:13px;color:var(--text3)">${esc(_updateLoaded?.TreeCode||'')}</p>
<div class="btn-bar" style="justify-content:center"><button class="btn b-add" onclick="_updateSuccess=false;_updateLoaded=null;_updateRows=[];render();">Update Another</button><button class="btn b-primary" onclick="window.location.href='/approvals'">Approvals</button></div>
</div></div>`;
app.appendChild(c);
}
// ═══════════════════════════════════════
function mkAlert(msg){const d=mk('alert al-info');d.innerHTML=msg;return d;}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Budget — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--amber);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--amber);background:rgba(245,166,35,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:720px;margin:0 auto}
.tabs{display:flex;gap:4px;margin-bottom:14px;background:var(--surface3);border-radius:var(--radius);padding:4px}
.tab{flex:1;padding:8px;border-radius:7px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);text-align:center;transition:all .15s}.tab:hover{color:var(--text2)}.tab.on{background:var(--surface);color:var(--amber);border:1px solid var(--border2)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .ico{font-size:15px}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--amber);box-shadow:0 0 0 2px rgba(245,166,35,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:420px){.fi-row{grid-template-columns:1fr}}
.fi-mono{font-family:var(--mono);font-size:12px}
.line-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;position:relative}
.line-card:hover{border-color:var(--border2)}
.line-num{position:absolute;top:8px;right:10px;font-family:var(--mono);font-size:10px;color:var(--text3);background:var(--surface3);padding:1px 6px;border-radius:4px}
.line-month{font-family:var(--mono);font-size:13px;color:var(--amber);font-weight:600;margin-bottom:6px}
.line-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
@media(max-width:420px){.line-grid{grid-template-columns:1fr}}
.line-fl{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
.line-inp{width:100%;padding:5px 7px;border:1px solid var(--border2);border-radius:5px;font-size:12px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;height:30px}.line-inp:focus{border-color:var(--amber)}
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-save{background:var(--amber);color:#fff;flex:1}.b-save:hover:not(:disabled){background:#d4891b;box-shadow:0 4px 16px rgba(245,166,35,.4)}
.b-add{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)}.b-add:hover{background:rgba(245,166,35,.2)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-del{background:rgba(229,62,62,.06);color:var(--red);border:1px solid rgba(229,62,62,.2);padding:4px 10px;font-size:10px;border-radius:5px}.b-del:hover{background:rgba(229,62,62,.15)}
.blist-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
.blist-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;position:relative;overflow:hidden}
.blist-card:hover{border-color:var(--amber);box-shadow:0 6px 18px rgba(245,166,35,.14);transform:translateY(-2px)}
.blist-card .blist-budget{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.blist-card [data-del]{position:absolute;top:8px;right:8px}
.blist-top{display:flex;align-items:center;gap:6px;margin-bottom:4px}
.blist-id{font-family:var(--mono);font-size:11px;color:var(--amber);font-weight:600}
.blist-budget{font-size:12px;font-weight:600;color:var(--text)}
.blist-sub{font-size:11px;color:var(--text3)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.total-row{display:flex;justify-content:space-between;padding:8px 12px;background:var(--surface3);border-radius:7px;font-size:12px;font-weight:700;margin-top:8px}
.total-row .tl{color:var(--text3)}.total-row .tv{font-family:var(--mono);color:var(--amber)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a>
<a href="/production" class="nav-link">Production</a>
<a href="/budget" class="nav-link active">Budget</a>
<a href="/approvals" class="nav-link">Approvals</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
let _co='JIVO_OIL_HANADB';
let _tab='create'; // create | list
let _budgets=[],_dim3=[],_dim4=[];
let _loading=false,_saving=false;
// Form state
let _hdr={budget:'',subBudget:''};
let _lines=[]; // [{month:'2026-04-01',fixedAmount:0,vAmount:0,subBudget:''}]
let _editId=null; // null=new, number=editing existing
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
async function loadDim3(){try{const r=await api('GET',`/sap/lookup/costing-codes?dim=3&company=${encodeURIComponent(_co)}`);if(r.success)_dim3=r.data||[];}catch(_e){}}
async function loadDim4(){try{const r=await api('GET',`/sap/lookup/costing-codes?dim=4&company=${encodeURIComponent(_co)}`);if(r.success)_dim4=r.data||[];}catch(_e){}}
async function loadBudgets(){
_loading=true;render();
try{const r=await api('GET',`/sap/budget/list?company=${encodeURIComponent(_co)}`);_budgets=r.data||[];}
catch(e){toast(e.message,'err');}
_loading=false;render();
}
async function loadBudget(id){
_loading=true;render();
try{
const r=await api('GET',`/sap/budget/${id}?company=${encodeURIComponent(_co)}`);
const d=r.data;
_editId=d.DocEntry;
_hdr.budget=d.U_BUDGET||'';
_hdr.subBudget=d.U_SUB_BUDGET||'';
_lines=(d.BUDGET1Collection||[]).map(l=>({
month:(l.U_MONTH||'').substring(0,10),
fixedAmount:Number(l.U_FIXED_AMOUNT)||0,
vAmount:Number(l.U_V_AMOUNT)||0,
subBudget:l.U_SUB_BUDGET||'',
}));
_tab='create';
}catch(e){toast(e.message,'err');}
_loading=false;render();
}
function addLine(){
const now=new Date();
const m=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-01`;
_lines.push({month:m,fixedAmount:0,vAmount:0,subBudget:''});
render();
}
function removeLine(i){_lines.splice(i,1);render();}
async function doSave(){
if(!_hdr.budget){toast('Select a Budget','err');return;}
if(!_lines.length){toast('Add at least one month line','err');return;}
_saving=true;render();
const payload={
company:_co,
U_BUDGET:_hdr.budget,
U_SUB_BUDGET:_hdr.subBudget||null,
BUDGET1Collection:_lines.map(l=>({
U_MONTH:l.month,
U_FIXED_AMOUNT:parseFloat(l.fixedAmount)||0,
U_V_AMOUNT:parseFloat(l.vAmount)||0,
U_SUB_BUDGET:l.subBudget||null,
}))
};
try{
if(_editId){
await api('PUT',`/sap/budget/${_editId}?company=${encodeURIComponent(_co)}`,payload);
toast('Budget updated!');
}else{
const r=await api('POST',`/sap/budget?company=${encodeURIComponent(_co)}`,payload);
_editId=r.data?.DocEntry||null;
toast('Budget created!');
}
await loadBudgets();
}catch(e){toast(e.message||'Failed','err');}
_saving=false;render();
}
function resetForm(){
_editId=null;
_hdr={budget:'',subBudget:''};
_lines=[];
render();
}
// ═══════════════════════════════════════
function render(){
try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}
}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
// Tabs
const tb=mk('tabs');
[{id:'create',l:_editId?'Edit Budget':'+ New Budget'},{id:'list',l:'View Budgets'}].forEach(t=>{
const b=document.createElement('button');b.className=`tab${_tab===t.id?' on':''}`;b.textContent=t.l;
b.onclick=()=>{_tab=t.id;if(t.id==='list'&&!_budgets.length)loadBudgets();else render();};
tb.appendChild(b);
});
app.appendChild(tb);
if(_tab==='list'){renderList(app);return;}
// ── Company
const c1=mk('card');
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> ${_editId?'Edit Budget #'+_editId:'New Budget'}</div>
<div class="card-bd">
<div class="fg"><label class="fl">Company</label><select class="fi" id="sel-co">
<option value="JIVO_OIL_HANADB" ${_co==='JIVO_OIL_HANADB'?'selected':''}>Jivo Oil</option>
<option value="JIVO_BEVERAGES_HANADB" ${_co==='JIVO_BEVERAGES_HANADB'?'selected':''}>Jivo Beverages</option>
<option value="JIVO_MART_HANADB" ${_co==='JIVO_MART_HANADB'?'selected':''}>Jivo Mart</option>
<option value="TEST_OIL_15122025" ${_co==='TEST_OIL_15122025'?'selected':''}>Test Oil</option>
</select></div>
<div class="fi-row">
<div class="fg"><label class="fl">Budget (Dim3) *</label><select class="fi" id="sel-budget">
<option value="">-- Select Budget --</option>
${_dim3.map(d=>`<option value="${esc(d.PrcCode)}" ${_hdr.budget===d.PrcCode?'selected':''}>${esc(d.PrcCode)} - ${esc(d.PrcName)}</option>`).join('')}
</select></div>
<div class="fg"><label class="fl">Sub Budget (Dim4)</label><select class="fi" id="sel-sub">
<option value="">-- None --</option>
${_dim4.map(d=>`<option value="${esc(d.PrcCode)}" ${_hdr.subBudget===d.PrcCode?'selected':''}>${esc(d.PrcCode)} - ${esc(d.PrcName)}</option>`).join('')}
</select></div>
</div>
</div>`;
app.appendChild(c1);
// ── Month Lines
const c2=mk('card');
let linesHtml='';
if(!_lines.length){
linesHtml='<div class="empty-msg">No month lines added yet</div>';
}else{
const dim4Opts=_dim4.map(d=>`<option value="${esc(d.PrcCode)}">${esc(d.PrcCode)}</option>`).join('');
linesHtml=_lines.map((l,i)=>{
const selOpts=_dim4.map(d=>`<option value="${esc(d.PrcCode)}" ${l.subBudget===d.PrcCode?'selected':''}>${esc(d.PrcCode)}</option>`).join('');
return`<div class="line-card">
<span class="line-num">#${i+1}</span>
<div class="fg"><label class="line-fl">Month</label><input class="line-inp" data-i="${i}" data-f="month" type="month" value="${(l.month||'').substring(0,7)}" style="color:var(--amber)"/></div>
<div class="line-grid">
<div><label class="line-fl">Fixed Amount</label><input class="line-inp" data-i="${i}" data-f="fixedAmount" type="number" min="0" step="0.01" value="${l.fixedAmount}" style="text-align:right"/></div>
<div><label class="line-fl">V Amount</label><input class="line-inp" data-i="${i}" data-f="vAmount" type="number" min="0" step="0.01" value="${l.vAmount}" style="text-align:right"/></div>
</div>
<div class="fg" style="margin-top:6px"><label class="line-fl">Sub Budget</label><select class="line-inp" data-i="${i}" data-f="subBudget"><option value="">--</option>${selOpts}</select></div>
<div style="text-align:right;margin-top:4px"><button class="b-del" data-rm="${i}">Remove</button></div>
</div>`;
}).join('');
}
const totalFixed=_lines.reduce((s,l)=>s+(parseFloat(l.fixedAmount)||0),0);
const totalV=_lines.reduce((s,l)=>s+(parseFloat(l.vAmount)||0),0);
c2.innerHTML=`<div class="card-hd"><span class="ico"></span> Monthly Lines<span class="right">${_lines.length} month${_lines.length!==1?'s':''}</span></div>
<div class="card-bd">
${linesHtml}
<button class="btn b-add" id="btn-add-line" style="width:100%;padding:8px;font-size:11px;margin-top:4px">+ Add Month</button>
${_lines.length?`<div class="total-row"><span class="tl">Total Fixed</span><span class="tv">${totalFixed.toLocaleString('en-IN',{minimumFractionDigits:2})}</span></div>
<div class="total-row" style="margin-top:4px"><span class="tl">Total V Amount</span><span class="tv">${totalV.toLocaleString('en-IN',{minimumFractionDigits:2})}</span></div>`:''}
</div>`;
app.appendChild(c2);
// ── Actions
const c3=mk('card');
c3.innerHTML=`<div class="card-bd"><div class="btn-bar">
<button class="btn b-save" id="btn-save" ${_saving?'disabled':''}><span id="sp-save" style="display:${_saving?'inline-block':'none'}" class="sp"></span> ${_editId?'Update Budget':'Create Budget'}</button>
<button class="btn b-cancel" id="btn-reset">${_editId?'Cancel Edit':'Reset'}</button>
</div></div>`;
app.appendChild(c3);
// Wire
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=async function(){_co=this.value;await Promise.all([loadDim3(),loadDim4()]);render();});
q('#sel-budget')&&(q('#sel-budget').onchange=function(){_hdr.budget=this.value;});
q('#sel-sub')&&(q('#sel-sub').onchange=function(){_hdr.subBudget=this.value;});
q('#btn-add-line')&&(q('#btn-add-line').onclick=addLine);
q('#btn-save')&&(q('#btn-save').onclick=doSave);
q('#btn-reset')&&(q('#btn-reset').onclick=resetForm);
document.querySelectorAll('[data-rm]').forEach(b=>{b.onclick=function(){removeLine(parseInt(this.dataset.rm));};});
document.querySelectorAll('.line-inp').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.i),f=this.dataset.f;
if(f==='fixedAmount'||f==='vAmount')_lines[i][f]=parseFloat(this.value)||0;
else if(f==='month')_lines[i][f]=this.value+'-01';
else _lines[i][f]=this.value;
// Update totals without full re-render
const tf=document.querySelector('.total-row .tv');
if(tf){const t=_lines.reduce((s,l)=>s+(parseFloat(l.fixedAmount)||0),0);tf.textContent=t.toLocaleString('en-IN',{minimumFractionDigits:2});}
};
});
},0);
}
// ═══════════════════════════════════════
function renderList(app){
const c=mk('card');
let listHtml='';
if(_loading){
listHtml='<div class="empty-msg"><span class="sp" style="border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading...</div>';
}else if(!_budgets.length){
listHtml='<div class="empty-msg">No budgets found</div>';
}else{
listHtml=`<div class="blist-grid">${_budgets.map(b=>`<div class="blist-card">
<button data-del="${b.DocEntry}" style="padding:4px 6px;border-radius:6px;font-size:9px;font-weight:700;background:rgba(229,62,62,.06);color:var(--red);border:1px solid rgba(229,62,62,.2);cursor:pointer;white-space:nowrap">Del</button>
<div style="flex:1;cursor:pointer" data-bid="${b.DocEntry}">
<div class="blist-top"><span class="blist-id">#${b.DocEntry}</span></div>
<div class="blist-budget">${esc(b.U_BUDGET||'-')}</div>
${b.U_SUB_BUDGET?`<div class="blist-sub">Sub: ${esc(b.U_SUB_BUDGET)}</div>`:''}
${b.CreateDate?`<div class="blist-sub" style="margin-top:auto;padding-top:6px">${new Date(b.CreateDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</div>`:''}
</div>
</div>`).join('')}</div>`;
}
c.innerHTML=`<div class="card-hd"><span class="ico"></span> Budgets
<span class="right"><select class="fi" id="v-co" style="height:28px;font-size:11px;width:140px;padding:2px 22px 2px 8px">
<option value="JIVO_OIL_HANADB" ${_co==='JIVO_OIL_HANADB'?'selected':''}>Jivo Oil</option>
<option value="JIVO_BEVERAGES_HANADB" ${_co==='JIVO_BEVERAGES_HANADB'?'selected':''}>Jivo Beverages</option>
<option value="JIVO_MART_HANADB" ${_co==='JIVO_MART_HANADB'?'selected':''}>Jivo Mart</option>
<option value="TEST_OIL_15122025" ${_co==='TEST_OIL_15122025'?'selected':''}>Test Oil</option>
</select></span></div>
<div class="card-bd">
<div style="margin-bottom:10px"><button class="btn b-add" id="v-refresh" style="width:100%;padding:6px;font-size:11px">Refresh</button></div>
${listHtml}
</div>`;
app.appendChild(c);
setTimeout(()=>{
q('#v-co')&&(q('#v-co').onchange=function(){_co=this.value;loadBudgets();});
q('#v-refresh')&&(q('#v-refresh').onclick=()=>loadBudgets());
document.querySelectorAll('[data-bid]').forEach(c=>{c.onclick=function(){loadBudget(parseInt(this.dataset.bid));};});
document.querySelectorAll('[data-del]').forEach(b=>{b.onclick=async function(e){
e.stopPropagation();
const id=parseInt(this.dataset.del);
if(!confirm('Delete budget #'+id+'? This cannot be undone.'))return;
this.disabled=true;this.textContent='...';
try{await api('DELETE',`/sap/budget/${id}?company=${encodeURIComponent(_co)}`);toast('Budget deleted');loadBudgets();}
catch(err){toast(err.message,'err');this.disabled=false;this.textContent='Delete';}
};});
},0);
}
async function init(){
await Promise.all([loadDim3(),loadDim4()]);
render();
}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+611
View File
@@ -0,0 +1,611 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Business Master Data | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}
.hdr h1 span{color:#7c3aed}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#7c3aed;color:#fff}.btn-primary:hover{background:#6d28d9}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#7c3aed;border:1.5px solid #7c3aed}.btn-outline:hover{background:#f5f3ff}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.fg input{min-width:220px}
.tbl-wrap{overflow-x:auto;overflow-y:auto;max-height:62vh}
.tbl-wrap::-webkit-scrollbar{height:7px;width:7px}
.tbl-wrap::-webkit-scrollbar-track{background:#f1f3f5}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
.tbl-wrap::-webkit-scrollbar-thumb:hover{background:#9ca3af}
table{border-collapse:collapse;min-width:100%}
thead th{background:#f5f3ff;color:#4b5563;font-weight:700;padding:10px 12px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #ddd6fe;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s;cursor:pointer}
tbody tr:hover{background:#f5f3ff}
tbody tr.selected{background:#ede9fe}
tbody td{padding:9px 12px;white-space:nowrap;color:#374151;font-size:12px}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
tbody td.trunc{max-width:220px;overflow:hidden;text-overflow:ellipsis}
.badge{padding:2px 8px;border-radius:10px;font-size:10px;font-weight:700}
.badge-cust{background:#dbeafe;color:#1d4ed8}
.badge-vend{background:#fee2e2;color:#991b1b}
.badge-lead{background:#fef3c7;color:#92400e}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
.status-bar{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.pager{display:flex;align-items:center;gap:8px}
.pager button{height:28px;padding:0 10px;border:1.5px solid #dde1e7;border-radius:6px;background:#fff;font-size:11px;font-weight:700;color:#374151;cursor:pointer}
.pager button:disabled{opacity:.4;cursor:not-allowed}
.detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;display:none;backdrop-filter:blur(2px)}
.detail-overlay.show{display:block}
.detail-panel{position:fixed;top:0;right:-440px;width:440px;height:100vh;background:#fff;z-index:501;box-shadow:-4px 0 24px rgba(0,0,0,.12);display:flex;flex-direction:column;transition:right .28s cubic-bezier(.4,0,.2,1)}
.detail-panel.show{right:0}
.detail-header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb;flex-shrink:0}
.detail-header h3{font-size:14px;font-weight:700;color:#1a1a2e}
.detail-header .row-num{font-size:11px;color:#9ca3af;margin-top:2px}
.detail-close{width:32px;height:32px;border:none;background:#edf2f7;border-radius:8px;cursor:pointer;font-size:18px;color:#6b7280;display:flex;align-items:center;justify-content:center;transition:all .15s}
.detail-close:hover{background:#e2e8f0;color:#1a1a2e}
.detail-body{flex:1;overflow-y:auto;padding:16px 20px}
.detail-body::-webkit-scrollbar{width:4px}.detail-body::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:2px}
.detail-row{display:flex;gap:12px;padding:9px 0;border-bottom:1px solid #f3f4f6;align-items:center}
.detail-row:last-child{border-bottom:none}
.detail-key{font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.4px;width:140px;flex-shrink:0;padding-top:1px}
.detail-val{font-size:13px;color:#1a1a2e;font-weight:500;word-break:break-all;flex:1}
.detail-edit-input{flex:1;height:32px;border:1.5px solid #dde1e7;border-radius:6px;padding:0 10px;font-size:12px;font-family:inherit;color:#1a1a2e;outline:none;width:100%;box-sizing:border-box}
.detail-edit-input:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.detail-edit-select{flex:1;height:32px;border:1.5px solid #dde1e7;border-radius:6px;padding:0 10px;font-size:12px;font-family:inherit;color:#1a1a2e;outline:none;width:100%;box-sizing:border-box;background:#fff}
.detail-footer{display:flex;gap:8px;padding:14px 20px;border-top:1px solid #e8eaed;background:#f8fafb;flex-shrink:0}
.detail-footer .btn{flex:1;justify-content:center;height:34px}
.dtabs{display:flex;border-bottom:1.5px solid #e8eaed;margin:0 -20px 12px;padding:0 20px;flex-wrap:wrap}
.dtab{padding:8px 12px;font-size:11px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}
.dtab:hover{color:#7c3aed}
.dtab.active{color:#7c3aed;border-bottom-color:#7c3aed}
.dtab-content{display:none}
.dtab-content.active{display:block}
.sub-card{border:1.5px solid #e8eaed;border-radius:8px;padding:10px;margin-bottom:10px;position:relative}
.sub-card .remove-row{position:absolute;top:6px;right:6px;background:#fee2e2;color:#991b1b;border:none;border-radius:6px;width:22px;height:22px;cursor:pointer;font-size:12px;line-height:1}
.add-row-btn{width:100%;margin-top:4px}
.sub-title{font-size:10px;font-weight:700;color:#9ca3af;text-transform:uppercase;letter-spacing:.4px;margin-bottom:6px}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>Business Master</span> Data</h1>
</div>
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>Type</label>
<select id="bpType">
<option value="">All</option>
<option value="cCustomer">Customers</option>
<option value="cSupplier">Vendors</option>
<option value="cLid">Leads</option>
</select>
</div>
<div class="fg">
<label>Search (Name / Code)</label>
<input type="text" id="searchInput" placeholder="Type to search…">
</div>
<button class="btn btn-primary" id="btnLoad">Search</button>
</div>
<div class="tbl-wrap">
<div class="empty" id="emptyMsg">Click Search to load business partners</div>
<table id="tbl" style="display:none">
<thead><tr>
<th>Type</th><th>Code</th><th>Name</th><th>Phone</th><th>Email</th>
<th>Currency</th><th>Credit Limit</th><th>Status</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div class="status-bar">
<div style="display:flex;align-items:center;gap:10px">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select filters and click Search</span>
</div>
<div class="pager">
<button id="btnPrev" disabled>&laquo; Prev</button>
<span id="pageInfo">Page 1</span>
<button id="btnNext" disabled>Next &raquo;</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="detail-overlay" id="detailOverlay"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<div>
<h3 id="detailTitle">Business Partner</h3>
<div class="row-num" id="detailRowNum"></div>
</div>
<button class="detail-close" id="detailClose">&#x2715;</button>
</div>
<div class="detail-body" id="detailBody"></div>
<div class="detail-footer" id="detailFooterView">
<button class="btn btn-outline" id="detailDeleteBtn" style="color:#dc2626;border-color:#dc2626">Delete</button>
<button class="btn btn-primary" id="detailEditBtn">Edit</button>
</div>
<div class="detail-footer" id="detailFooterEdit" style="display:none">
<button class="btn btn-outline" id="detailCancelBtn">Cancel</button>
<button class="btn btn-primary" id="detailSaveBtn">Save Changes</button>
</div>
</div>
<script>
(function(){
const tok=sessionStorage.getItem('portal_token');
if(!tok){window.location.href='/';return;}
// ── General tab ───────────────────────────────────────────────────────────
const GENERAL_VIEW_FIELDS=[
{k:'CardCode',l:'Card Code'},
{k:'CardType',l:'Type'},
{k:'GroupCode',l:'Group Code'},
{k:'CardName',l:'Name'},
{k:'CardForeignName',l:'Foreign Name'},
{k:'Phone1',l:'Phone'},
{k:'Phone2',l:'Phone 2'},
{k:'Cellular',l:'Mobile'},
{k:'Fax',l:'Fax'},
{k:'EmailAddress',l:'Email'},
{k:'Website',l:'Website'},
{k:'ContactPerson',l:'Contact Person'},
{k:'Currency',l:'Currency'},
{k:'Notes',l:'Notes'},
{k:'FederalTaxID',l:'GSTIN / Tax ID'},
{k:'Valid',l:'Active'},
{k:'Frozen',l:'Frozen'},
];
const GENERAL_EDIT_FIELDS=[
{k:'CardName',l:'Name'},
{k:'CardForeignName',l:'Foreign Name'},
{k:'Phone1',l:'Phone'},
{k:'Phone2',l:'Phone 2'},
{k:'Cellular',l:'Mobile'},
{k:'Fax',l:'Fax'},
{k:'EmailAddress',l:'Email'},
{k:'Website',l:'Website'},
{k:'ContactPerson',l:'Contact Person'},
{k:'Currency',l:'Currency'},
{k:'FederalTaxID',l:'GSTIN / Tax ID'},
{k:'Notes',l:'Notes'},
{k:'Valid',l:'Active',select:['tYES','tNO']},
{k:'Frozen',l:'Frozen',select:['tYES','tNO']},
];
// ── Payment / Accounting tab ────────────────────────────────────────────────
const PAYMENT_VIEW_FIELDS=[
{k:'GroupCode',l:'BP Group Code'},
{k:'PayTermsGrpCode',l:'Payment Terms Code'},
{k:'CreditLimit',l:'Credit Limit',num:true},
];
const PAYMENT_EDIT_FIELDS=[
{k:'PayTermsGrpCode',l:'Payment Terms Code',num:true},
{k:'CreditLimit',l:'Credit Limit',num:true},
];
// ── Address sub-form (BPAddresses) ──────────────────────────────────────────
const ADDRESS_FIELDS=[
{k:'AddressName',l:'Address ID'},
{k:'AddressType',l:'Type',select:['bo_BillTo','bo_ShipTo']},
{k:'Street',l:'Street'},
{k:'Block',l:'Block / Area'},
{k:'City',l:'City'},
{k:'ZipCode',l:'Zip Code'},
{k:'County',l:'State'},
{k:'Country',l:'Country'},
];
// ── Bank account sub-form (BPBankAccounts) ───────────────────────────────────
const BANK_FIELDS=[
{k:'BankCode',l:'Bank Code / Name'},
{k:'Branch',l:'Branch'},
{k:'Account',l:'Account Number'},
{k:'BICSwiftCode',l:'IFSC / SWIFT Code'},
{k:'AccountName',l:'Account Holder Name'},
{k:'IBAN',l:'IBAN'},
];
const bpType=document.getElementById('bpType');
const searchInput=document.getElementById('searchInput');
const btnLoad=document.getElementById('btnLoad');
const statusDot=document.getElementById('statusDot');
const statusMsg=document.getElementById('statusMsg');
const tbl=document.getElementById('tbl');
const emptyMsg=document.getElementById('emptyMsg');
const btnPrev=document.getElementById('btnPrev');
const btnNext=document.getElementById('btnNext');
const pageInfo=document.getElementById('pageInfo');
const TOP=50;
let skip=0,rows=[];
function setStatus(msg,st){statusMsg.textContent=msg;statusDot.className='status-dot'+(st?' '+st:'');}
function showToast(msg,type='info'){const t=document.getElementById('toast');t.textContent=msg;t.className=`toast ${type} show`;setTimeout(()=>t.classList.remove('show'),3500);}
function escHtml(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function typeBadge(t){
const m={cCustomer:['Customer','badge-cust'],cSupplier:['Vendor','badge-vend'],cLid:['Lead','badge-lead']};
const [label,cls]=m[t]||[t||'',''];
return `<span class="badge ${cls}">${label}</span>`;
}
async function loadData(reset=true){
if(reset) skip=0;
setStatus('Loading…','loading');
btnLoad.disabled=true;
try{
const params=new URLSearchParams();
if(bpType.value) params.set('type',bpType.value);
if(searchInput.value.trim()) params.set('search',searchInput.value.trim());
params.set('top',TOP);
params.set('skip',skip);
const resp=await fetch(`/api/business-master?${params}`,{headers:{'Authorization':'Bearer '+tok}});
const json=await resp.json();
if(!json.success){setStatus('Error: '+json.message,'err');showToast(json.message,'error');return;}
rows=json.data||[];
renderTable();
setStatus(`Loaded ${rows.length} record(s)`,'ok');
pageInfo.textContent=`Page ${Math.floor(skip/TOP)+1}`;
btnPrev.disabled=skip===0;
btnNext.disabled=rows.length<TOP;
}catch(e){
setStatus('Failed: '+e.message,'err');
showToast(e.message,'error');
}finally{
btnLoad.disabled=false;
}
}
function renderTable(){
if(!rows.length){
tbl.style.display='none';
emptyMsg.textContent='No matching business partners';
emptyMsg.style.display='block';
return;
}
tbl.querySelector('tbody').innerHTML=rows.map((r,i)=>`
<tr data-idx="${i}">
<td>${typeBadge(r.CardType)}</td>
<td>${escHtml(r.CardCode)}</td>
<td class="trunc">${escHtml(r.CardName)}</td>
<td>${escHtml(r.Phone1)}</td>
<td class="trunc">${escHtml(r.EmailAddress)}</td>
<td>${escHtml(r.Currency)}</td>
<td class="num">${fmtNum(r.CreditLimit)}</td>
<td>${r.Frozen==='Y'?'<span class="badge badge-vend">Frozen</span>':(r.Valid==='N'?'<span class="badge badge-lead">Inactive</span>':'<span class="badge badge-cust">Active</span>')}</td>
</tr>`).join('');
tbl.style.display='table';
emptyMsg.style.display='none';
tbl.querySelector('tbody').addEventListener('click',e=>{
const tr=e.target.closest('tr');
if(!tr)return;
document.querySelectorAll('tbody tr.selected').forEach(r=>r.classList.remove('selected'));
tr.classList.add('selected');
openDetail(rows[+tr.dataset.idx]);
});
}
function fmtNum(v){if(v==null||v==='')return'';return Number(v).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});}
btnLoad.addEventListener('click',()=>loadData(true));
searchInput.addEventListener('keydown',e=>{if(e.key==='Enter')loadData(true);});
btnPrev.addEventListener('click',()=>{if(skip>0){skip-=TOP;loadData(false);}});
btnNext.addEventListener('click',()=>{skip+=TOP;loadData(false);});
// ── Detail panel ─────────────────────────────────────────────────────────────
const detailOverlay=document.getElementById('detailOverlay');
const detailPanel=document.getElementById('detailPanel');
const detailBody=document.getElementById('detailBody');
const detailTitle=document.getElementById('detailTitle');
const detailRowNum=document.getElementById('detailRowNum');
const detailFooterView=document.getElementById('detailFooterView');
const detailFooterEdit=document.getElementById('detailFooterEdit');
const detailEditBtn=document.getElementById('detailEditBtn');
const detailCancelBtn=document.getElementById('detailCancelBtn');
const detailSaveBtn=document.getElementById('detailSaveBtn');
const detailDeleteBtn=document.getElementById('detailDeleteBtn');
let curBp=null,curFull=null;
async function openDetail(row){
curBp=row;
detailTitle.textContent=row.CardName||row.CardCode;
detailRowNum.textContent=row.CardCode;
detailBody.innerHTML='<div class="empty">Loading details…</div>';
detailFooterView.style.display='flex';
detailFooterEdit.style.display='none';
detailOverlay.classList.add('show');
detailPanel.classList.add('show');
try{
const resp=await fetch(`/api/business-master/${encodeURIComponent(row.CardCode)}`,{headers:{'Authorization':'Bearer '+tok}});
const json=await resp.json();
if(!json.success){detailBody.innerHTML=`<div class="empty">${escHtml(json.message)}</div>`;return;}
curFull=json.data;
renderDetailView();
}catch(e){
detailBody.innerHTML=`<div class="empty">${escHtml(e.message)}</div>`;
}
}
const TABS=[
{id:'general',l:'General'},
{id:'addresses',l:'Addresses'},
{id:'bank',l:'Payment / Bank'},
{id:'props',l:'Properties'},
];
let activeTab='general';
let editAddresses=null, editBankAccts=null;
function tabsHtml(){
return `<div class="dtabs">`+TABS.map(t=>`<div class="dtab${t.id===activeTab?' active':''}" data-tab="${t.id}">${t.l}</div>`).join('')+`</div>`;
}
function renderDetailView(){
const addrs=curFull.BPAddresses||[];
const banks=curFull.BPBankAccounts||[];
const udfKeys=Object.keys(curFull).filter(k=>k.startsWith('U_'));
const generalHtml=GENERAL_VIEW_FIELDS.map(f=>{
const v=curFull[f.k];
const disp=v==null||v===''?'—':String(v);
return `<div class="detail-row"><div class="detail-key">${f.l}</div><div class="detail-val${f.num?' num':''}">${escHtml(f.num?fmtNum(v):disp)}</div></div>`;
}).join('');
const addrHtml=addrs.length?addrs.map(a=>{
return `<div class="sub-card">`+ADDRESS_FIELDS.map(f=>{
const v=a[f.k]; const disp=v==null||v===''?'—':String(v);
return `<div class="detail-row"><div class="detail-key">${f.l}</div><div class="detail-val">${escHtml(disp)}</div></div>`;
}).join('')+`</div>`;
}).join(''):'<div class="empty">No addresses on file</div>';
const bankHtml=`<div class="sub-title">Payment Terms / Credit</div>`
+PAYMENT_VIEW_FIELDS.map(f=>{
const v=curFull[f.k]; const disp=v==null||v===''?'—':String(v);
return `<div class="detail-row"><div class="detail-key">${f.l}</div><div class="detail-val${f.num?' num':''}">${escHtml(f.num?fmtNum(v):disp)}</div></div>`;
}).join('')
+`<div class="sub-title" style="margin-top:12px">Bank Accounts</div>`
+(banks.length?banks.map(b=>{
return `<div class="sub-card">`+BANK_FIELDS.map(f=>{
const v=b[f.k]; const disp=v==null||v===''?'—':String(v);
return `<div class="detail-row"><div class="detail-key">${f.l}</div><div class="detail-val">${escHtml(disp)}</div></div>`;
}).join('')+`</div>`;
}).join(''):'<div class="empty">No bank accounts on file</div>');
const propsHtml=udfKeys.length?udfKeys.map(k=>{
const v=curFull[k]; const disp=v==null||v===''?'—':String(v);
return `<div class="detail-row"><div class="detail-key">${k}</div><div class="detail-val">${escHtml(disp)}</div></div>`;
}).join(''):'<div class="empty">No custom fields on file</div>';
detailBody.innerHTML=tabsHtml()
+`<div class="dtab-content${activeTab==='general'?' active':''}" data-content="general">${generalHtml}</div>`
+`<div class="dtab-content${activeTab==='addresses'?' active':''}" data-content="addresses">${addrHtml}</div>`
+`<div class="dtab-content${activeTab==='bank'?' active':''}" data-content="bank">${bankHtml}</div>`
+`<div class="dtab-content${activeTab==='props'?' active':''}" data-content="props">${propsHtml}</div>`;
detailFooterView.style.display='flex';
detailFooterEdit.style.display='none';
}
function fieldRowEdit(f,obj,prefix){
const v=obj[f.k];
const val=v==null?'':String(v);
const dataAttrs=`data-key="${f.k}"${prefix?` data-prefix="${prefix}"`:''}`;
if(f.select){
return `<div class="detail-row"><div class="detail-key">${f.l}</div>`
+`<select class="detail-edit-select" ${dataAttrs}>`
+f.select.map(o=>`<option value="${o}"${val===o?' selected':''}>${o==='tYES'?'Yes':'No'}</option>`).join('')
+`</select></div>`;
}
return `<div class="detail-row"><div class="detail-key">${f.l}</div>`
+`<input class="detail-edit-input${f.num?' num':''}" ${dataAttrs} data-num="${f.num?'1':'0'}" value="${escHtml(val)}"></div>`;
}
function addrCardHtml(a,idx){
return `<div class="sub-card" data-arr-idx="${idx}">`
+`<button type="button" class="remove-row" data-remove-addr="${idx}">×</button>`
+ADDRESS_FIELDS.map(f=>fieldRowEdit(f,a)).join('')
+`</div>`;
}
function bankCardHtml(b,idx){
return `<div class="sub-card" data-arr-idx="${idx}">`
+`<button type="button" class="remove-row" data-remove-bank="${idx}">×</button>`
+BANK_FIELDS.map(f=>fieldRowEdit(f,b)).join('')
+`</div>`;
}
function renderAddressesEdit(){
const wrap=detailBody.querySelector('[data-content="addresses"]');
if(!wrap)return;
wrap.innerHTML=(editAddresses.length?editAddresses.map(addrCardHtml).join(''):'')
+`<button type="button" class="btn btn-outline add-row-btn" id="addAddrBtn">+ Add Address</button>`;
}
function renderBankEdit(){
const wrap=detailBody.querySelector('[data-content="bank"]');
if(!wrap)return;
wrap.innerHTML=`<div class="sub-title">Payment Terms / Credit</div>`
+PAYMENT_EDIT_FIELDS.map(f=>fieldRowEdit(f,curFull)).join('')
+`<div class="sub-title" style="margin-top:12px">Bank Accounts</div>`
+(editBankAccts.length?editBankAccts.map(bankCardHtml).join(''):'')
+`<button type="button" class="btn btn-outline add-row-btn" id="addBankBtn">+ Add Bank Account</button>`;
}
function renderDetailEdit(){
editAddresses=(curFull.BPAddresses||[]).map(a=>({...a}));
editBankAccts=(curFull.BPBankAccounts||[]).map(b=>({...b}));
const udfKeys=Object.keys(curFull).filter(k=>k.startsWith('U_'));
const generalHtml=GENERAL_EDIT_FIELDS.map(f=>fieldRowEdit(f,curFull)).join('');
const propsHtml=udfKeys.length?udfKeys.map(k=>{
const v=curFull[k]; const val=v==null?'':String(v);
return `<div class="detail-row"><div class="detail-key">${k}</div><input class="detail-edit-input" data-key="${k}" data-num="0" value="${escHtml(val)}"></div>`;
}).join(''):'<div class="empty">No custom fields on file</div>';
detailBody.innerHTML=tabsHtml()
+`<div class="dtab-content${activeTab==='general'?' active':''}" data-content="general">${generalHtml}</div>`
+`<div class="dtab-content${activeTab==='addresses'?' active':''}" data-content="addresses"></div>`
+`<div class="dtab-content${activeTab==='bank'?' active':''}" data-content="bank"></div>`
+`<div class="dtab-content${activeTab==='props'?' active':''}" data-content="props">${propsHtml}</div>`;
renderAddressesEdit();
renderBankEdit();
detailFooterView.style.display='none';
detailFooterEdit.style.display='flex';
}
detailBody.addEventListener('click',(e)=>{
const tab=e.target.closest('.dtab');
if(tab){
activeTab=tab.dataset.tab;
detailBody.querySelectorAll('.dtab').forEach(t=>t.classList.toggle('active',t.dataset.tab===activeTab));
detailBody.querySelectorAll('.dtab-content').forEach(c=>c.classList.toggle('active',c.dataset.content===activeTab));
return;
}
if(e.target.id==='addAddrBtn'){
syncEditArraysFromDom();
editAddresses.push({AddressName:'',AddressType:'bo_BillTo'});
renderAddressesEdit();
return;
}
if(e.target.id==='addBankBtn'){
syncEditArraysFromDom();
editBankAccts.push({});
renderBankEdit();
return;
}
const rmAddr=e.target.closest('[data-remove-addr]');
if(rmAddr){
syncEditArraysFromDom();
editAddresses.splice(parseInt(rmAddr.dataset.removeAddr,10),1);
renderAddressesEdit();
return;
}
const rmBank=e.target.closest('[data-remove-bank]');
if(rmBank){
syncEditArraysFromDom();
editBankAccts.splice(parseInt(rmBank.dataset.removeBank,10),1);
renderBankEdit();
return;
}
});
function syncEditArraysFromDom(){
const addrWrap=detailBody.querySelector('[data-content="addresses"]');
if(addrWrap){
addrWrap.querySelectorAll('.sub-card').forEach((card,idx)=>{
const obj=editAddresses[idx]||{};
card.querySelectorAll('[data-key]').forEach(inp=>{ obj[inp.dataset.key]=inp.value; });
editAddresses[idx]=obj;
});
}
const bankWrap=detailBody.querySelector('[data-content="bank"]');
if(bankWrap){
bankWrap.querySelectorAll('.sub-card').forEach((card,idx)=>{
const obj=editBankAccts[idx]||{};
card.querySelectorAll('[data-key]').forEach(inp=>{ obj[inp.dataset.key]=inp.value; });
editBankAccts[idx]=obj;
});
}
}
detailEditBtn.addEventListener('click',()=>renderDetailEdit());
detailCancelBtn.addEventListener('click',()=>renderDetailView());
detailSaveBtn.addEventListener('click',async ()=>{
syncEditArraysFromDom();
const patch={};
detailBody.querySelectorAll('[data-content="general"] [data-key], [data-content="bank"] > .detail-row [data-key], [data-content="props"] [data-key]').forEach(inp=>{
const key=inp.dataset.key;
const raw=inp.value.trim();
patch[key]=inp.dataset.num==='1'?(raw===''?0:parseFloat(raw)):raw;
});
patch.BPAddresses=editAddresses;
patch.BPBankAccounts=editBankAccts;
detailSaveBtn.disabled=true;
try{
const resp=await fetch(`/api/business-master/${encodeURIComponent(curFull.CardCode)}`,{
method:'PATCH',
headers:{'Authorization':'Bearer '+tok,'Content-Type':'application/json'},
body:JSON.stringify(patch),
});
const json=await resp.json();
if(json.success){
showToast('Business partner updated','success');
Object.assign(curFull,patch);
renderDetailView();
loadData(false);
}else{
showToast(json.message,'error');
}
}catch(e){
showToast(e.message,'error');
}finally{
detailSaveBtn.disabled=false;
}
});
detailDeleteBtn.addEventListener('click',async ()=>{
if(!curFull)return;
if(!confirm(`Delete business partner ${curFull.CardCode} — ${curFull.CardName}? This cannot be undone.`))return;
detailDeleteBtn.disabled=true;
try{
const resp=await fetch(`/api/business-master/${encodeURIComponent(curFull.CardCode)}`,{
method:'DELETE',
headers:{'Authorization':'Bearer '+tok},
});
const json=await resp.json();
if(json.success){
showToast('Business partner deleted','success');
closeDetail();
loadData(false);
}else{
showToast(json.message,'error');
}
}catch(e){
showToast(e.message,'error');
}finally{
detailDeleteBtn.disabled=false;
}
});
function closeDetail(){
detailOverlay.classList.remove('show');
detailPanel.classList.remove('show');
document.querySelectorAll('tbody tr.selected').forEach(r=>r.classList.remove('selected'));
}
document.getElementById('detailClose').addEventListener('click',closeDetail);
detailOverlay.addEventListener('click',closeDetail);
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDetail();});
})();
</script>
</body>
</html>
+914
View File
@@ -0,0 +1,914 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Cash Flow — Finance</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script>document.documentElement.setAttribute('data-theme',localStorage.getItem('portal_theme')||'obsidian')</script>
<link rel="stylesheet" href="/theme.css?v=3"/>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:var(--bg,#f8fafc);color:var(--text,#0f172a);-webkit-font-smoothing:antialiased}
.page{padding:20px 24px 40px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px}
.hdr-title{font-size:20px;font-weight:800;letter-spacing:-.4px}
.btn{padding:9px 18px;border-radius:9px;border:none;font-size:13px;font-weight:600;font-family:'Space Grotesk',sans-serif;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
.btn-primary{background:#047e7e;color:#fff}.btn-primary:hover{background:#035b5b}
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
.btn-outline{background:var(--surface);color:#047e7e;border:1.5px solid #047e7e}.btn-outline:hover{background:#f0fafa}
.btn-sm{padding:5px 11px;font-size:11px}
.btn-danger{background:#fef2f2;color:#dc2626;border:1.5px solid #fca5a5}.btn-danger:hover{background:#fee2e2}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-bottom:16px}
.filter-bar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;padding:16px 20px}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3)}
.f-input,.f-select{padding:8px 11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;font-family:'Space Grotesk',sans-serif;outline:none}
.f-input:focus,.f-select:focus{border-color:#047e7e}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:var(--surface3,#f8fafc);border-top:1px solid var(--border);font-size:12px;color:var(--text3)}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ── Statement of Cash Flow ── */
.stmt-wrap{overflow-x:auto;padding:0 20px 20px}
.stmt-tbl{width:100%;border-collapse:collapse;font-family:'Space Grotesk',sans-serif;min-width:560px}
.stmt-tbl thead th{padding:5px 10px;font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#004d40;border-bottom:2px solid #047e7e;background:#e0f4f4}
.stmt-tbl thead th.num{text-align:right}
.stmt-tbl td{padding:3px 10px;font-size:12px;border-top:1px solid #f0f2f4;vertical-align:middle;line-height:1.35}
.stmt-tbl td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;min-width:140px}
.stmt-tbl td.cr{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;min-width:90px;font-size:11.5px;color:#374151}
.stmt-tbl tr.s-sep td{border-top:2px solid #b2dfdf;padding-top:0;padding-bottom:0}
.stmt-tbl tr.s-total td{background:#e8f7f7;font-weight:800;border-top:2px solid #047e7e;border-bottom:1px solid #047e7e;color:#004d40}
.stmt-tbl tr.s-net td{background:#047e7e;color:#fff!important;font-weight:800;font-size:12.5px;border-top:2px solid #035b5b}
.stmt-tbl tr.s-cash-lbl td{background:#b2f0e8;font-weight:800;color:#004d40;font-size:12px;border-top:2px solid #80cbc4;padding-top:4px;padding-bottom:4px}
.stmt-tbl tr.s-cash-item td{background:#fff;font-size:12px}
.stmt-tbl tr.s-cash-item td:first-child{padding-left:22px;color:#374151}
.stmt-tbl tr.s-cash-tot td{background:#e0f4f4;font-weight:800;border-top:1.5px solid #80cbc4;color:#004d40}
.stmt-tbl tr.s-increase td{background:#047e7e;color:#fff!important;font-weight:800;font-size:12.5px;border-top:2px solid #035b5b}
.stmt-tbl tr.s-increase td span.amt-pos,.stmt-tbl tr.s-increase td span.amt-neg,.stmt-tbl tr.s-increase td span.amt-zero{color:#fff!important}
.stmt-tbl tr.s-net td span.amt-pos,.stmt-tbl tr.s-net td span.amt-neg,.stmt-tbl tr.s-net td span.amt-zero{color:#fff!important}
.stmt-tbl tr.s-closing td{background:#047e7e;color:#fff!important;font-weight:800;font-size:12.5px}
.amt-pos{color:#047e7e;font-weight:600}.amt-neg{color:#dc2626;font-weight:600}.amt-zero{color:#9ca3af}
.stmt-period{font-size:11px;font-weight:700;color:#047e7e;text-align:right;padding:2px 0}
.stmt-hdr-card{display:flex;justify-content:space-between;align-items:center;padding:14px 20px;border-bottom:1px solid var(--border)}
/* ── Detailed Cash Flow Table ── */
.cf-tbl{width:100%;border-collapse:collapse;font-family:'Space Grotesk',sans-serif;background:#fff;border:1px solid #b2dfdf;border-radius:12px;overflow:hidden;margin-bottom:4px}
.cf-tbl thead th{background:#047e7e;color:#fff;padding:5px 10px;font-size:11px;font-weight:800;letter-spacing:.3px;text-align:left}
.cf-tbl thead th.num{text-align:right}
.cf-tbl td{padding:2px 10px;font-size:11.5px;border-top:1px solid #f0f2f4;vertical-align:middle;line-height:1.35}
.cf-tbl td.num{text-align:right;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap;min-width:120px}
.cf-tbl td.lbl{padding-left:20px;color:#374151}
.cf-tbl td.lbl.indent{padding-left:32px}
.cf-tbl tr.sect-hdr td{background:#e0f4f4;color:#004d40;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.7px;border-top:2px solid #b2dfdf;padding:4px 10px}
.cf-tbl tr.sub td{background:#e0f4f4;border-top:2px solid #80cbc4;font-weight:700;color:#004d40;font-size:11.5px}
.cf-tbl tr.sub td.lbl{padding-left:10px}
.cf-tbl tr.grand td{background:#047e7e;border-top:2px solid #035b5b;color:#fff!important;font-weight:800;font-size:12.5px;padding:5px 10px}
.cf-tbl tr.item:hover td{background:#f0fafa}
.tag-util{display:inline-block;padding:2px 10px;border-radius:5px;font-size:10px;font-weight:800;letter-spacing:.5px;background:#dbeafe;color:#1d4ed8;text-transform:uppercase}
.tag-gen{display:inline-block;padding:2px 10px;border-radius:5px;font-size:10px;font-weight:800;letter-spacing:.5px;background:#dcfce7;color:#15803d;text-transform:uppercase}
.tag-nil{color:#9ca3af;font-size:11px;font-weight:600}
/* ── Collapsible detail section ── */
.detail-toggle{display:flex;align-items:center;gap:8px;padding:10px 20px;cursor:pointer;font-size:12px;font-weight:600;color:#047e7e;user-select:none;border-top:1px solid var(--border)}
.detail-toggle:hover{background:var(--surface3,#f8fafc)}
.detail-body{display:none;padding:0 20px 20px}
.detail-body.open{display:block}
/* ── Config Panel ── */
.cfg-panel{background:var(--surface);border:1px solid var(--border);border-radius:14px;margin-bottom:16px;overflow:hidden}
.cfg-hdr{display:flex;align-items:center;gap:10px;padding:13px 18px;cursor:pointer;user-select:none;border-bottom:1px solid transparent}
.cfg-hdr:hover{background:var(--surface3,#f8fafc)}
.cfg-hdr.open{border-bottom-color:var(--border)}
.cfg-title{font-size:14px;font-weight:700;color:var(--text)}
.cfg-chev{margin-left:auto;font-size:14px;transition:transform .2s}
.cfg-hdr.open .cfg-chev{transform:rotate(180deg)}
.cfg-body{display:none;padding:18px 20px}
.cfg-body.open{display:block}
.add-form{background:var(--surface3,#f8fafc);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:18px}
.add-form-title{font-size:12px;font-weight:700;color:#047e7e;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px}
.form-row{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}
.form-field{display:flex;flex-direction:column;gap:4px}
.form-field label{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.5px}
.form-field input,.form-field select{padding:7px 10px;border:1px solid var(--border);border-radius:7px;font-size:12px;font-family:'Space Grotesk',sans-serif;outline:none;background:var(--surface);color:var(--text)}
.form-field input:focus,.form-field select:focus{border-color:#047e7e}
.acct-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;min-height:30px}
.acct-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:6px;font-size:11px;font-weight:600;background:#e0f4f4;color:#004d40;border:1px solid #80cbc4}
.acct-chip button{background:none;border:none;cursor:pointer;font-size:13px;color:#047e7e;padding:0;line-height:1}.acct-chip button:hover{color:#dc2626}
.acct-search{display:flex;gap:6px;align-items:center}
.acct-search input{flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:7px;font-size:12px;font-family:'Space Grotesk',sans-serif;outline:none;background:var(--surface);color:var(--text)}
.acct-search input:focus{border-color:#047e7e}
.acct-dropdown{position:absolute;z-index:100;background:var(--surface);border:1px solid var(--border);border-radius:9px;box-shadow:0 8px 24px rgba(0,0,0,.12);max-height:220px;overflow-y:auto;min-width:340px}
.acct-dropdown-item{padding:8px 12px;font-size:12px;cursor:pointer;display:flex;gap:10px;align-items:center}
.acct-dropdown-item:hover{background:var(--surface3,#f8fafc)}
.acct-dropdown-item .code{font-weight:700;color:#047e7e;font-size:11px;white-space:nowrap}
.acct-dropdown-item .name{color:var(--text3)}
.items-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:900px){.items-grid{grid-template-columns:1fr}}
.item-card{background:var(--surface3,#f8fafc);border:1px solid var(--border);border-radius:10px;padding:12px}
.item-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px}
.item-card-label{font-size:13px;font-weight:700;color:var(--text)}
.item-card-meta{font-size:10px;color:var(--text3);margin-top:2px}
.item-card-codes{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px;min-height:24px}
.item-card-code{font-size:10px;font-weight:600;background:#e0f4f4;color:#004d40;padding:2px 7px;border-radius:5px;border:1px solid #80cbc4}
.item-card-actions{display:flex;gap:6px}
.sect-tab{display:flex;gap:0;border:1px solid var(--border);border-radius:9px;overflow:hidden;margin-bottom:14px}
.sect-tab button{flex:1;padding:7px 0;border:none;font-size:12px;font-weight:600;font-family:'Space Grotesk',sans-serif;cursor:pointer;background:var(--surface);color:var(--text3);transition:all .15s}
.sect-tab button.active{background:#047e7e;color:#fff}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;font-family:'Space Grotesk',sans-serif;opacity:0;pointer-events:none;transition:opacity .25s;box-shadow:0 4px 20px rgba(0,0,0,.15)}
.toast.ok{background:#10b981;color:#fff}.toast.err{background:#ef4444;color:#fff}
</style>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="page">
<div class="hdr">
<div>
<div class="hdr-title">Cash Flow Statement</div>
<div style="font-size:12px;color:var(--text3);margin-top:3px">Statement of Cash Flow &amp; Working Capital Analysis</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-outline" id="btnExport" onclick="exportXlsx()" disabled>Download Excel</button>
</div>
</div>
<!-- ── Filter Card ── -->
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>From Date</label>
<input type="date" id="fromDate" class="f-input" style="width:150px"/>
</div>
<div class="fg">
<label>To Date</label>
<input type="date" id="toDate" class="f-input" style="width:150px"/>
</div>
<div class="fg" style="flex:1;min-width:220px">
<label>Snapshot — Working Capital Items</label>
<select id="snapSelect" class="f-select" style="min-width:260px" onchange="onSnapChange()">
<option value="">— Live data (from DB) —</option>
</select>
</div>
<div class="fg" style="flex:1;min-width:220px">
<label>Monthly A/c Snapshot — Profit</label>
<select id="monthlySnapSelect" class="f-select" style="min-width:260px">
<option value="">— Same as above / Live —</option>
</select>
</div>
<button class="btn btn-primary" id="btnLoad" onclick="loadData()" style="margin-top:auto">Load Cash Flow</button>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Configure items below, then select a date range and click Load</span>
</div>
</div>
<!-- ── Output ── -->
<div id="cfOut"></div>
<!-- ── Config Panel ── -->
<div class="cfg-panel">
<div class="cfg-hdr" id="cfgHdr" onclick="toggleCfg()">
<span style="font-size:16px">&#9881;</span>
<span class="cfg-title">Configure Line Items</span>
<span style="font-size:11px;color:var(--text3);margin-left:8px" id="cfgCount"></span>
<span class="cfg-chev">&#9660;</span>
</div>
<div class="cfg-body" id="cfgBody">
<!-- Add / Edit form -->
<div class="add-form">
<div class="add-form-title" id="formTitle">Add New Line Item</div>
<input type="hidden" id="editId"/>
<div class="form-row">
<div class="form-field" style="flex:2;min-width:200px">
<label>Label</label>
<input type="text" id="fLabel" placeholder="e.g. Sundry Debtors"/>
</div>
<div class="form-field">
<label>Section</label>
<select id="fSection" onchange="onSectionChange()">
<option value="Assets">Assets (Working Capital)</option>
<option value="Liabilities">Liabilities (Working Capital)</option>
<option value="Capital">Capital / Fixed Assets</option>
<option value="Cash">Cash / Bank / Investment</option>
</select>
</div>
<div class="form-field" id="typeField">
<label>Type</label>
<select id="fType">
<option value="working">Working Capital</option>
<option value="capital">Capital / Fixed</option>
</select>
</div>
<div class="form-field" style="width:80px">
<label>Order</label>
<input type="number" id="fOrder" value="999" style="width:80px"/>
</div>
<div class="form-field" style="flex:2;min-width:180px">
<label>Notes</label>
<input type="text" id="fNotes" placeholder="optional note"/>
</div>
</div>
<!-- Account code search -->
<div style="margin-bottom:10px">
<div style="font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Account Codes</div>
<div class="acct-chips" id="acctChips"></div>
<div style="position:relative">
<div class="acct-search">
<input type="text" id="acctSearch" placeholder="Search or paste multiple codes (space / comma / newline)&#8230;" oninput="searchAccounts()" onpaste="pasteAcctCodes(event)" autocomplete="off"/>
<button class="btn btn-sm btn-outline" onclick="clearAcctSearch()">Clear</button>
</div>
<div class="acct-dropdown" id="acctDropdown" style="display:none"></div>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" onclick="saveItem()">Save Item</button>
<button class="btn btn-outline btn-sm" onclick="cancelEdit()">Cancel</button>
</div>
</div>
<!-- Section tabs + item list -->
<div class="sect-tab">
<button id="tabAssets" class="active" onclick="setCfgTab('Assets')">Assets</button>
<button id="tabLiabilities" onclick="setCfgTab('Liabilities')">Liabilities</button>
<button id="tabCapital" onclick="setCfgTab('Capital')">Capital</button>
<button id="tabCash" onclick="setCfgTab('Cash')">Cash/Bank</button>
</div>
<div class="items-grid" id="itemsGrid"></div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
const tok = sessionStorage.getItem('portal_token');
if (!tok) { window.location.href = '/'; return; }
const hdrs = { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' };
let _items = [];
let _values = {};
let _periodValues = {};
let _openingValues = {};
let _summary = null;
let _cfgTab = 'Assets';
let _editChips = [];
let _snapMeta = {};
// ── Formatting ────────────────────────────────────────────────────────────────
function fmtRs(v) {
if (v == null || isNaN(v)) return '&#8212;';
const abs = Math.abs(v);
if (abs < 0.005) return '<span class="amt-zero">&#8212;</span>';
const s = abs.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return v < 0
? `<span class="amt-neg">(${s})</span>`
: `<span class="amt-pos">${s}</span>`;
}
function fmtCr(v) {
if (v == null || isNaN(v)) return '&#8212;';
const cr = v / 10000000;
const abs = Math.abs(cr);
if (abs < 0.005) return '<span class="amt-zero">&#8212;</span>';
const s = abs.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return cr < 0
? `<span class="amt-neg">(${s})</span>`
: `<span class="amt-pos">${s}</span>`;
}
function fmtN(v, lakh) {
if (v == null || isNaN(v)) return '&#8212;';
const n = lakh ? v / 100000 : v;
if (Math.abs(n) < 0.005) return '<span class="amt-zero">&#8212;</span>';
const s = Math.abs(n).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return n < 0
? `<span class="amt-neg">(${s})</span>`
: `<span class="amt-pos">${s}</span>`;
}
function fmtTag(v) {
if (Math.abs(v) < 0.005) return '<span class="tag-nil">&#8212;</span>';
return v > 0
? '<span class="tag-util">Utilisation</span>'
: '<span class="tag-gen">Generation</span>';
}
// ── Status / Toast ────────────────────────────────────────────────────────────
function setStatus(msg, state) {
document.getElementById('statusMsg').textContent = msg;
document.getElementById('statusDot').className = 'status-dot' + (state ? ' ' + state : '');
}
function showToast(msg, type) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast ' + (type === 'error' ? 'err' : 'ok');
t.style.opacity = '1';
setTimeout(() => { t.style.opacity = '0'; }, 3000);
}
// ── Snapshot list ─────────────────────────────────────────────────────────────
(async function loadSnaps() {
try {
const r = await fetch('/api/cash-flow/snapshots', { headers: hdrs }).then(r => r.json());
if (!r.success) return;
const sel = document.getElementById('snapSelect');
const msel = document.getElementById('monthlySnapSelect');
r.data.forEach(s => {
_snapMeta[s.id] = { from: s.period_from, to: s.period_to };
const dt = new Date(s.saved_at).toLocaleDateString('en-IN');
const txt = `${s.snap_name} (${s.period_from} → ${s.period_to}) — ${dt}`;
const opt1 = document.createElement('option'); opt1.value = s.id; opt1.textContent = txt;
const opt2 = document.createElement('option'); opt2.value = s.id; opt2.textContent = txt;
sel.appendChild(opt1);
msel.appendChild(opt2);
});
} catch(_) {}
})();
function onSnapChange() {
const id = document.getElementById('snapSelect').value;
const meta = _snapMeta[id];
if (meta) {
document.getElementById('fromDate').value = meta.from;
document.getElementById('toDate').value = meta.to;
}
}
window.onSnapChange = onSnapChange;
// ── Load config items ─────────────────────────────────────────────────────────
async function loadItems() {
try {
const r = await fetch('/api/cash-flow/items', { headers: hdrs }).then(r => r.json());
if (!r.success) return;
_items = r.data;
renderCfgItems();
document.getElementById('cfgCount').textContent = `(${_items.length} items)`;
} catch(_) {}
}
loadItems();
// ── Load Cash Flow data ───────────────────────────────────────────────────────
async function loadData() {
const from = document.getElementById('fromDate').value;
const to = document.getElementById('toDate').value;
const snapId = document.getElementById('snapSelect').value;
if (!from || !to) { showToast('Select date range', 'error'); return; }
if (!_items.length) { showToast('Add line items in the config panel first', 'error'); return; }
document.getElementById('btnLoad').disabled = true;
setStatus('Loading…', 'loading');
document.getElementById('cfOut').innerHTML = '';
try {
const monthlySnapId = document.getElementById('monthlySnapSelect').value;
const qs = `from=${from}&to=${to}${snapId ? `&snapshotId=${snapId}` : ''}${monthlySnapId ? `&monthlySnapId=${monthlySnapId}` : ''}`;
const r = await fetch(`/api/cash-flow/data?${qs}`, { headers: hdrs }).then(r => r.json());
if (!r.success) throw new Error(r.message);
_items = r.items;
_values = r.values;
_periodValues = r.period_values || {};
_openingValues = r.opening_values || {};
_summary = r.summary || null;
renderCF(from, to);
document.getElementById('btnExport').disabled = false;
const src = snapId ? 'snapshot' : 'live DB';
const profitSrc = monthlySnapId ? 'monthly A/c snapshot' : (snapId ? 'same snapshot' : 'live DB');
setStatus(`Loaded — ${from} to ${to} · WC: ${src} · Profit: ${profitSrc}`, 'ok');
} catch(e) {
setStatus(e.message, 'err');
showToast(e.message, 'error');
} finally {
document.getElementById('btnLoad').disabled = false;
}
}
window.loadData = loadData;
// ── Render Statement of Cash Flow ─────────────────────────────────────────────
function renderStatement(from, to) {
if (!_summary) return '';
const s = _summary;
const lbl = s.period_label || `${from} to ${to}`;
const cashItems = _items.filter(i => i.section === 'Cash' || i.item_type === 'cash')
.sort((a, b) => a.sort_order - b.sort_order);
function row(cls, label, val) {
return `<tr class="${cls}">
<td>${label}</td>
<td class="num">${fmtRs(val)}</td>
<td class="cr">${fmtCr(val)}</td>
</tr>`;
}
// Build opening/closing cash breakdown
let openRows = '', closeRows = '';
let totalOpen = 0, totalClose = 0;
if (cashItems.length) {
cashItems.forEach((item, idx) => {
const ov = _openingValues[item.id] || 0;
const cv = _values[item.id] || 0;
totalOpen += ov;
totalClose += cv;
// Closing label: replace "Opening" prefix with "Closing" for first item
const closeLbl = idx === 0 ? item.label.replace(/^Opening\b/i, 'Closing') : item.label;
if (idx === 0) {
openRows += `<tr class="s-cash-lbl"><td>${item.label}</td><td class="num">${fmtRs(ov)}</td><td class="cr">${fmtCr(ov)}</td></tr>`;
closeRows += `<tr class="s-cash-lbl"><td>${closeLbl}</td><td class="num">${fmtRs(cv)}</td><td class="cr">${fmtCr(cv)}</td></tr>`;
} else {
openRows += `<tr class="s-cash-item"><td>${item.label}</td><td class="num">${fmtRs(ov)}</td><td class="cr">${fmtCr(ov)}</td></tr>`;
closeRows += `<tr class="s-cash-item"><td>${item.label}</td><td class="num">${fmtRs(cv)}</td><td class="cr">${fmtCr(cv)}</td></tr>`;
}
});
} else {
// Fallback: no cash items configured yet
openRows = row('s-cash-lbl', 'Opening Balance of Cash / Bank / Investment', s.opening_cash);
closeRows = row('s-cash-lbl', 'Closing Balance of Cash / Bank / Investment', s.closing_cash);
totalOpen = s.opening_cash;
totalClose = s.closing_cash;
}
const increase = totalClose - totalOpen;
return `
<div class="card">
<div class="stmt-hdr-card">
<div style="font-size:15px;font-weight:800">Statement of Cash Flow</div>
<div class="stmt-period">${lbl}</div>
</div>
<div class="stmt-wrap">
<table class="stmt-tbl">
<thead><tr>
<th style="min-width:320px">Particulars</th>
<th class="num">Rs.</th>
<th class="num">Rs. In Crores</th>
</tr></thead>
<tbody>
${row('', 'Profit as per Monthly Accounts', s.net_profit)}
${row('', 'Changes in Working Capital', s.wc_change)}
${row('s-total', 'Total', s.total)}
${row('', 'Capital Expenditure', s.capex)}
${row('s-net', 'Net Increase / (Decrease) in Cash Flow', s.net_increase)}
${openRows}
${cashItems.length > 1 ? `<tr class="s-cash-tot"><td>Total Opening</td><td class="num">${fmtRs(totalOpen)}</td><td class="cr">${fmtCr(totalOpen)}</td></tr>` : ''}
${closeRows}
${cashItems.length > 1 ? `<tr class="s-cash-tot"><td>Total Closing</td><td class="num">${fmtRs(totalClose)}</td><td class="cr">${fmtCr(totalClose)}</td></tr>` : ''}
${row('s-increase', 'Increase in Cash', increase)}
</tbody>
</table>
</div>
</div>`;
}
// ── Render detailed working capital table ─────────────────────────────────────
function renderCF(from, to) {
const lakh = true;
const assets = _items.filter(i => i.section === 'Assets');
const liabs = _items.filter(i => i.section === 'Liabilities');
const capItems= _items.filter(i => i.section === 'Capital' || (i.section === 'Assets' && i.item_type === 'capital'));
const cashItems= _items.filter(i => i.section === 'Cash' || i.item_type === 'cash');
let assetsTot = 0, assetsWork = 0;
let liabsTot = 0;
let capTot = 0;
let aRows = '';
assets.forEach(item => {
const v = _values[item.id] || 0;
assetsTot += v;
if (item.item_type !== 'capital') assetsWork += v;
const col2 = item.item_type === 'capital' ? '<td class="num"></td>' : `<td class="num">${fmtN(v, lakh)}</td>`;
aRows += `<tr class="item">
<td class="lbl indent">${item.label}${item.notes ? ` <span style="font-size:10px;color:#9ca3af">${item.notes}</span>` : ''}</td>
<td class="num">${fmtN(v, lakh)}</td>
${col2}
<td style="width:110px"></td>
</tr>`;
});
let lRows = '';
liabs.forEach(item => {
const v = _values[item.id] || 0;
liabsTot += v;
const tag = Math.abs(v) > 0.005 ? (v < 0 ? '<span class="tag-gen">Generation</span>' : '<span class="tag-util">Utilisation</span>') : '';
lRows += `<tr class="item">
<td class="lbl indent">${item.label}${item.notes ? ` <span style="font-size:10px;color:#9ca3af">${item.notes}</span>` : ''}</td>
<td class="num">${fmtN(v, lakh)}</td>
<td class="num">${fmtN(v, lakh)}</td>
<td style="width:110px">${tag}</td>
</tr>`;
});
let cRows = '';
capItems.forEach(item => {
const v = _values[item.id] || 0;
const pv = _periodValues[item.id] || 0;
capTot += pv;
cRows += `<tr class="item">
<td class="lbl indent">${item.label}${item.notes ? ` <span style="font-size:10px;color:#9ca3af">${item.notes}</span>` : ''}</td>
<td class="num">${fmtN(v, lakh)}</td>
<td class="num" style="color:#374151;font-size:11px">Period: ${fmtN(pv, lakh)}</td>
<td style="width:110px"></td>
</tr>`;
});
let cashRows = '';
cashItems.forEach(item => {
const open = _openingValues[item.id] || 0;
const close = _values[item.id] || 0;
cashRows += `<tr class="item">
<td class="lbl indent">${item.label}${item.notes ? ` <span style="font-size:10px;color:#9ca3af">${item.notes}</span>` : ''}</td>
<td class="num">${fmtN(open, lakh)}</td>
<td class="num">${fmtN(close, lakh)}</td>
<td style="width:110px"></td>
</tr>`;
});
const grandTot = assetsTot + liabsTot;
const grandWork = assetsWork + liabsTot;
const detailHtml = `
<div style="padding:0 0 8px;font-size:11px;color:var(--text3);text-align:right">Amounts in &#8377; Lakhs &nbsp;|&nbsp; Period: ${from} to ${to}</div>
<table class="cf-tbl">
<thead><tr>
<th style="min-width:280px">Particulars</th>
<th class="num" style="min-width:150px">Amount / Closing (&#8377;L)</th>
<th class="num" style="min-width:150px">Working Capital / Period (&#8377;L)</th>
<th style="width:110px"></th>
</tr></thead>
<tbody>
${assets.length ? `
<tr class="sect-hdr"><td colspan="4">Working Capital &#8212; Assets</td></tr>
${aRows}
<tr class="sub">
<td class="lbl">Total &#8212; Assets Working Capital</td>
<td class="num">${fmtN(assetsTot, lakh)}</td>
<td class="num">${fmtN(assetsWork, lakh)}</td>
<td>${fmtTag(assetsWork)}</td>
</tr>` : ''}
${liabs.length ? `
<tr class="sect-hdr"><td colspan="4">Working Capital &#8212; Liabilities</td></tr>
${lRows}
<tr class="sub">
<td class="lbl">Total &#8212; Liabilities Working Capital</td>
<td class="num">${fmtN(liabsTot, lakh)}</td>
<td class="num">${fmtN(liabsTot, lakh)}</td>
<td>${fmtTag(liabsTot)}</td>
</tr>` : ''}
${(assets.length || liabs.length) ? `
<tr class="grand">
<td>Net Working Capital Change</td>
<td class="num" style="color:#fff">${fmtN(grandTot, lakh)}</td>
<td class="num" style="color:#fff">${fmtN(grandWork, lakh)}</td>
<td></td>
</tr>` : ''}
${capItems.length ? `
<tr class="sect-hdr"><td colspan="4">Capital / Fixed Assets</td></tr>
${cRows}
<tr class="sub">
<td class="lbl">Total Capital Items</td>
<td class="num"></td>
<td class="num">${fmtN(capTot, lakh)}</td>
<td>${fmtTag(-capTot)}</td>
</tr>` : ''}
${cashItems.length ? `
<tr class="sect-hdr"><td colspan="4">Cash / Bank / Investment</td></tr>
<tr class="sub" style="background:#e0f4f4">
<td class="lbl" colspan="2">Opening Balance (before period)</td>
<td class="lbl">Closing Balance</td>
<td></td>
</tr>
${cashRows}` : ''}
</tbody>
</table>`;
document.getElementById('cfOut').innerHTML =
renderStatement(from, to) +
`<div class="card" style="margin-bottom:16px">
<div class="detail-toggle" id="detailToggle" onclick="toggleDetail()">
&#9660; Working Capital &amp; Capital Details
</div>
<div class="detail-body" id="detailBody">
${detailHtml}
</div>
</div>`;
}
function toggleDetail() {
const body = document.getElementById('detailBody');
const tog = document.getElementById('detailToggle');
const open = body.classList.toggle('open');
tog.innerHTML = (open ? '&#9650;' : '&#9660;') + ' Working Capital &amp; Capital Details';
}
window.toggleDetail = toggleDetail;
// ── Config panel ──────────────────────────────────────────────────────────────
function toggleCfg() {
document.getElementById('cfgHdr').classList.toggle('open');
document.getElementById('cfgBody').classList.toggle('open');
}
window.toggleCfg = toggleCfg;
function onSectionChange() {
const sec = document.getElementById('fSection').value;
const typeField = document.getElementById('typeField');
if (sec === 'Cash') {
typeField.style.display = 'none';
} else {
typeField.style.display = '';
// Auto-set type based on section
const fType = document.getElementById('fType');
if (sec === 'Capital') fType.value = 'capital';
else fType.value = 'working';
}
}
window.onSectionChange = onSectionChange;
function setCfgTab(tab) {
_cfgTab = tab;
['Assets','Liabilities','Capital','Cash'].forEach(t => {
document.getElementById('tab' + t).classList.toggle('active', t === tab);
});
renderCfgItems();
}
window.setCfgTab = setCfgTab;
function typeLabel(item) {
if (item.item_type === 'cash') return '&#128994; Cash/Bank';
if (item.item_type === 'capital') return '&#127959; Capital';
return '&#128260; Working Capital';
}
function renderCfgItems() {
const visible = _items.filter(i => i.section === _cfgTab);
if (!visible.length) {
document.getElementById('itemsGrid').innerHTML = `<div style="color:var(--text3);font-size:13px;grid-column:1/-1;padding:12px 0">No items in ${_cfgTab} section. Add one above.</div>`;
return;
}
document.getElementById('itemsGrid').innerHTML = visible.map(item => `
<div class="item-card">
<div class="item-card-top">
<div>
<div class="item-card-label">${item.label}</div>
<div class="item-card-meta">
Order: ${item.sort_order} &nbsp;&middot;&nbsp;
${typeLabel(item)}
${item.notes ? ` &nbsp;&middot;&nbsp; ${item.notes}` : ''}
</div>
</div>
<div class="item-card-actions">
<button class="btn btn-sm btn-outline" onclick="startEdit(${item.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteItem(${item.id})">Del</button>
</div>
</div>
<div class="item-card-codes">
${item.acct_codes.length ? item.acct_codes.map(c => `<span class="item-card-code">${c}</span>`).join('') : '<span style="color:#9ca3af;font-size:11px">No accounts</span>'}
</div>
</div>
`).join('');
}
// ── Account search ────────────────────────────────────────────────────────────
function pasteAcctCodes(e) {
const text = (e.clipboardData || window.clipboardData).getData('text');
const codes = text.split(/[\s,;\n\r]+/).map(s => s.trim()).filter(s => s.length > 0);
if (codes.length > 1) {
e.preventDefault();
let added = 0;
codes.forEach(code => {
if (!_editChips.includes(code)) { _editChips.push(code); added++; }
});
renderChips();
document.getElementById('acctSearch').value = '';
document.getElementById('acctDropdown').style.display = 'none';
if (added > 0) showToast(`Added ${added} account code${added > 1 ? 's' : ''}`, 'success');
}
}
window.pasteAcctCodes = pasteAcctCodes;
let _searchTimer = null;
async function searchAccounts() {
const q = document.getElementById('acctSearch').value.trim();
clearTimeout(_searchTimer);
if (q.length < 2) { document.getElementById('acctDropdown').style.display = 'none'; return; }
_searchTimer = setTimeout(async () => {
try {
const r = await fetch(`/api/cash-flow/accounts?q=${encodeURIComponent(q)}`, { headers: hdrs }).then(r => r.json());
if (!r.success) return;
const dd = document.getElementById('acctDropdown');
if (!r.data.length) { dd.style.display = 'none'; return; }
dd.innerHTML = r.data.map(a =>
`<div class="acct-dropdown-item" onclick="addAcctCode('${a.AcctCode}','${a.AcctName.replace(/'/g,"\\'")}')">
<span class="code">${a.AcctCode}</span>
<span class="name">${a.AcctName}</span>
</div>`
).join('');
dd.style.display = 'block';
} catch(_) {}
}, 250);
}
window.searchAccounts = searchAccounts;
function addAcctCode(code, name) {
if (!_editChips.includes(code)) { _editChips.push(code); renderChips(); }
document.getElementById('acctSearch').value = '';
document.getElementById('acctDropdown').style.display = 'none';
}
window.addAcctCode = addAcctCode;
function removeChip(code) {
_editChips = _editChips.filter(c => c !== code);
renderChips();
}
window.removeChip = removeChip;
function renderChips() {
document.getElementById('acctChips').innerHTML = _editChips.map(c =>
`<span class="acct-chip">${c} <button onclick="removeChip('${c}')" title="Remove">&times;</button></span>`
).join('') || '<span style="color:#9ca3af;font-size:11px">No accounts added</span>';
}
function clearAcctSearch() {
document.getElementById('acctSearch').value = '';
document.getElementById('acctDropdown').style.display = 'none';
}
window.clearAcctSearch = clearAcctSearch;
document.addEventListener('click', e => {
if (!e.target.closest('#acctSearch') && !e.target.closest('#acctDropdown'))
document.getElementById('acctDropdown').style.display = 'none';
});
// ── Form: Save / Edit / Cancel ────────────────────────────────────────────────
async function saveItem() {
const id = document.getElementById('editId').value || null;
const section = document.getElementById('fSection').value;
const label = document.getElementById('fLabel').value.trim();
let type = document.getElementById('fType').value;
const order = document.getElementById('fOrder').value;
const notes = document.getElementById('fNotes').value.trim();
if (!label) { showToast('Label is required', 'error'); return; }
if (section === 'Cash') type = 'cash';
try {
const r = await fetch('/api/cash-flow/items', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ id, section, item_type: type, label, acct_codes: _editChips, sort_order: order, notes })
}).then(r => r.json());
if (!r.success) throw new Error(r.message);
showToast(id ? 'Item updated' : 'Item added', 'success');
cancelEdit();
await loadItems();
_cfgTab = section;
setCfgTab(section);
} catch(e) { showToast(e.message, 'error'); }
}
window.saveItem = saveItem;
function cancelEdit() {
document.getElementById('editId').value = '';
document.getElementById('fLabel').value = '';
document.getElementById('fSection').value = 'Assets';
document.getElementById('fType').value = 'working';
document.getElementById('fOrder').value = '999';
document.getElementById('fNotes').value = '';
document.getElementById('typeField').style.display = '';
_editChips = [];
renderChips();
document.getElementById('formTitle').textContent = 'Add New Line Item';
}
window.cancelEdit = cancelEdit;
function startEdit(id) {
const item = _items.find(i => i.id === id);
if (!item) return;
document.getElementById('editId').value = item.id;
document.getElementById('fLabel').value = item.label;
document.getElementById('fSection').value = item.section;
document.getElementById('fType').value = item.item_type === 'cash' ? 'working' : (item.item_type || 'working');
document.getElementById('fOrder').value = item.sort_order;
document.getElementById('fNotes').value = item.notes || '';
document.getElementById('typeField').style.display = item.section === 'Cash' ? 'none' : '';
_editChips = [...item.acct_codes];
renderChips();
document.getElementById('formTitle').textContent = 'Edit Line Item';
document.getElementById('cfgBody').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
window.startEdit = startEdit;
async function deleteItem(id) {
if (!confirm('Delete this item?')) return;
try {
const r = await fetch(`/api/cash-flow/items/${id}`, { method: 'DELETE', headers: hdrs }).then(r => r.json());
if (!r.success) throw new Error(r.message);
showToast('Deleted', 'success');
await loadItems();
} catch(e) { showToast(e.message, 'error'); }
}
window.deleteItem = deleteItem;
// ── Excel Export ──────────────────────────────────────────────────────────────
function exportXlsx() {
if (!_summary) return;
const from = document.getElementById('fromDate').value;
const to = document.getElementById('toDate').value;
const s = _summary;
const cr = v => parseFloat((v / 10000000).toFixed(2));
const cashItems2 = _items.filter(i => i.section === 'Cash' || i.item_type === 'cash').sort((a,b)=>a.sort_order-b.sort_order);
let xTotalOpen = 0, xTotalClose = 0;
const openRows2 = cashItems2.map((item, idx) => {
const v = _openingValues[item.id] || 0; xTotalOpen += v;
return [idx===0 ? item.label : ` ${item.label}`, v, cr(v)];
});
const closeRows2 = cashItems2.map((item, idx) => {
const v = _values[item.id] || 0; xTotalClose += v;
return [idx===0 ? item.label : ` ${item.label}`, v, cr(v)];
});
if (!cashItems2.length) { xTotalOpen = s.opening_cash; xTotalClose = s.closing_cash; }
const rows = [
['Statement of Cash Flow', '', ''],
[s.period_label || `${from} to ${to}`, '', ''],
[],
['Particulars', 'Rs.', 'Rs. In Crores'],
['Profit as per Monthly Accounts', s.net_profit, cr(s.net_profit)],
['Changes in Working Capital', s.wc_change, cr(s.wc_change)],
['Total', s.total, cr(s.total)],
[],
['Capital Expenditure', s.capex, cr(s.capex)],
[],
['Net Increase / (Decrease) in Cash Flow', s.net_increase, cr(s.net_increase)],
[],
...(cashItems2.length ? openRows2 : [['Opening Balance of Cash / Bank / Investment', s.opening_cash, cr(s.opening_cash)]]),
...(cashItems2.length > 1 ? [['Total Opening', xTotalOpen, cr(xTotalOpen)]] : []),
[],
...(cashItems2.length ? closeRows2 : [['Closing Balance of Cash / Bank / Investment', s.closing_cash, cr(s.closing_cash)]]),
...(cashItems2.length > 1 ? [['Total Closing', xTotalClose, cr(xTotalClose)]] : []),
[],
['Increase in Cash', xTotalClose - xTotalOpen, cr(xTotalClose - xTotalOpen)],
[],
[],
['Working Capital Details', '', ''],
['Particulars', 'Amount (Rs. L)', 'Working Capital (Rs. L)'],
];
const addSection = (label, items) => {
rows.push([label]);
let tot = 0, work = 0;
items.forEach(item => {
const v = (_values[item.id] || 0) / 100000;
tot += v;
if (item.item_type !== 'capital') work += v;
rows.push([' ' + item.label, parseFloat(v.toFixed(2)), item.item_type === 'capital' ? '' : parseFloat(v.toFixed(2))]);
});
rows.push(['Total', parseFloat(tot.toFixed(2)), parseFloat(work.toFixed(2))]);
rows.push([]);
};
addSection('Working Capital — Assets', _items.filter(i => i.section === 'Assets'));
addSection('Working Capital — Liabilities', _items.filter(i => i.section === 'Liabilities'));
addSection('Capital / Fixed Assets', _items.filter(i => i.section === 'Capital'));
const ws = XLSX.utils.aoa_to_sheet(rows);
ws['!cols'] = [40, 18, 16].map(w => ({ wch: w }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Cash Flow');
XLSX.writeFile(wb, `Cash_Flow_${from}_to_${to}.xlsx`);
showToast('Excel exported', 'success');
}
window.exportXlsx = exportXlsx;
// Init date range to current month
(function initDates() {
const now = new Date(), y = now.getFullYear(), m = String(now.getMonth()+1).padStart(2,'0');
document.getElementById('fromDate').value = `${y}-${m}-01`;
const last = new Date(y, now.getMonth()+1, 0);
document.getElementById('toDate').value = `${y}-${m}-${String(last.getDate()).padStart(2,'0')}`;
})();
})();
</script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
(function () {
'use strict';
// ── inject styles ─────────────────────────────────────────────────────────
const style = document.createElement('style');
style.textContent = `
#cb-fab{
position:fixed;bottom:24px;right:24px;z-index:9999;
width:52px;height:52px;border-radius:50%;border:none;cursor:pointer;
background:linear-gradient(135deg,#047e7e,#065f46);
box-shadow:0 4px 16px rgba(4,126,126,.45);
display:flex;align-items:center;justify-content:center;
transition:transform .2s,box-shadow .2s;
}
#cb-fab:hover{transform:scale(1.08);box-shadow:0 6px 20px rgba(4,126,126,.6)}
#cb-fab svg{width:26px;height:26px;fill:#fff}
#cb-fab .cb-badge{
position:absolute;top:-3px;right:-3px;
background:#ef4444;color:#fff;font-size:10px;font-weight:700;
border-radius:50%;width:18px;height:18px;
display:none;align-items:center;justify-content:center;
}
#cb-panel{
position:fixed;bottom:84px;right:24px;z-index:9998;
width:420px;height:540px;border-radius:16px;
background:#fff;box-shadow:0 8px 40px rgba(0,0,0,.18);
display:flex;flex-direction:column;overflow:hidden;
transform:scale(.85) translateY(20px);opacity:0;pointer-events:none;
transition:transform .22s cubic-bezier(.34,1.56,.64,1),opacity .18s;
}
#cb-panel.cb-open{transform:scale(1) translateY(0);opacity:1;pointer-events:all}
#cb-header{
background:linear-gradient(135deg,#047e7e,#065f46);
padding:14px 16px;display:flex;align-items:center;gap:10px;flex-shrink:0;
}
#cb-header .cb-avatar{
width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,.2);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
}
#cb-header .cb-avatar svg{width:20px;height:20px;fill:#fff}
#cb-header .cb-info{flex:1;min-width:0}
#cb-header .cb-name{font-size:14px;font-weight:700;color:#fff;letter-spacing:.2px}
#cb-header .cb-status{font-size:11px;color:rgba(255,255,255,.75);display:flex;align-items:center;gap:4px}
#cb-header .cb-dot{width:7px;height:7px;border-radius:50%;background:#4ade80}
#cb-close{
background:none;border:none;cursor:pointer;padding:4px;
color:rgba(255,255,255,.8);border-radius:6px;line-height:0;
transition:background .15s;
}
#cb-close:hover{background:rgba(255,255,255,.15);color:#fff}
#cb-close svg{width:18px;height:18px;fill:currentColor}
#cb-msgs{
flex:1;overflow-y:auto;padding:14px 14px 6px;
display:flex;flex-direction:column;gap:8px;
scroll-behavior:smooth;
}
#cb-msgs::-webkit-scrollbar{width:4px}
#cb-msgs::-webkit-scrollbar-thumb{background:#d1dae3;border-radius:4px}
.cb-msg{display:flex;gap:8px;align-items:flex-end;max-width:100%}
.cb-msg.cb-user{flex-direction:row-reverse}
.cb-bubble{
padding:8px 12px;border-radius:14px;font-size:13px;line-height:1.5;
max-width:82%;word-break:break-word;white-space:pre-wrap;
}
.cb-msg.cb-bot .cb-bubble{background:#f0f4f8;color:#1a1a2e;border-bottom-left-radius:4px}
.cb-msg.cb-user .cb-bubble{background:linear-gradient(135deg,#047e7e,#065f46);color:#fff;border-bottom-right-radius:4px}
.cb-msg-avatar{
width:28px;height:28px;border-radius:50%;flex-shrink:0;
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;
}
.cb-msg.cb-bot .cb-msg-avatar{background:#e0f4f4;color:#047e7e}
.cb-msg.cb-user .cb-msg-avatar{background:#065f46;color:#fff}
.cb-typing{display:flex;gap:5px;padding:10px 14px;align-items:center}
.cb-typing span{
width:7px;height:7px;border-radius:50%;background:#9ca3af;
animation:cb-bounce .9s infinite;
}
.cb-typing span:nth-child(2){animation-delay:.15s}
.cb-typing span:nth-child(3){animation-delay:.3s}
@keyframes cb-bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}
#cb-footer{
padding:10px 12px;border-top:1px solid #f0f2f4;flex-shrink:0;
display:flex;gap:8px;align-items:flex-end;background:#fff;
}
#cb-input{
flex:1;border:1.5px solid #e8eaed;border-radius:12px;
padding:9px 12px;font-size:13px;font-family:inherit;color:#1a1a2e;
resize:none;max-height:100px;overflow-y:auto;outline:none;
transition:border-color .15s;line-height:1.4;
}
#cb-input:focus{border-color:#047e7e}
#cb-input::placeholder{color:#9ca3af}
#cb-send{
width:38px;height:38px;border-radius:10px;border:none;cursor:pointer;
background:linear-gradient(135deg,#047e7e,#065f46);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
transition:opacity .15s,transform .1s;
}
#cb-send:hover{opacity:.88;transform:scale(1.05)}
#cb-send:disabled{opacity:.4;cursor:not-allowed;transform:none}
#cb-send svg{width:18px;height:18px;fill:#fff}
#cb-suggestions{
padding:0 12px 10px;display:flex;flex-wrap:wrap;gap:6px;flex-shrink:0;
}
.cb-chip{
padding:5px 10px;border-radius:20px;font-size:11.5px;cursor:pointer;
background:#f0f4f8;color:#047e7e;border:1px solid #d1e8e8;
transition:background .15s;white-space:nowrap;
}
.cb-chip:hover{background:#e0f4f4}
/* ── markdown rendering inside bot bubbles ── */
.cb-bubble.cb-md{white-space:normal}
.cb-bubble.cb-md p{margin:0 0 6px}
.cb-bubble.cb-md p:last-child{margin-bottom:0}
.cb-bubble.cb-md strong{font-weight:700}
.cb-bubble.cb-md em{font-style:italic}
.cb-bubble.cb-md code{background:rgba(0,0,0,.07);padding:1px 5px;border-radius:4px;font-family:monospace;font-size:11.5px}
.cb-bubble.cb-md h3{font-size:13px;font-weight:700;margin:8px 0 4px;color:#004d40}
.cb-bubble.cb-md h4{font-size:12px;font-weight:700;margin:6px 0 3px;color:#047e7e}
.cb-bubble.cb-md ul,.cb-bubble.cb-md ol{padding-left:18px;margin:4px 0}
.cb-bubble.cb-md li{margin:2px 0}
.cb-bubble.cb-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:11.5px}
.cb-bubble.cb-md th{background:#047e7e;color:#fff;padding:5px 9px;text-align:left;font-weight:700;white-space:nowrap}
.cb-bubble.cb-md td{padding:4px 9px;border-bottom:1px solid #e8eaed;white-space:nowrap}
.cb-bubble.cb-md tr:nth-child(even) td{background:rgba(4,126,126,.05)}
.cb-bubble.cb-md .cb-tbl-wrap{overflow-x:auto;border-radius:6px;border:1px solid #d1e8e8}
.cb-bubble.cb-md hr{border:none;border-top:1px solid #e8eaed;margin:8px 0}
`;
document.head.appendChild(style);
// ── inject HTML ───────────────────────────────────────────────────────────
const fab = document.createElement('button');
fab.id = 'cb-fab';
fab.title = 'AI Assistant';
fab.innerHTML = `
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
<span class="cb-badge" id="cb-badge"></span>`;
const panel = document.createElement('div');
panel.id = 'cb-panel';
panel.innerHTML = `
<div id="cb-header">
<div class="cb-avatar">
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2m0 14c-4.42 0-8 1.79-8 4v2h16v-2c0-2.21-3.58-4-8-4m7-9h-2v2h-2V7H9v2H7V7H5v9h14V7h-2V5z"/></svg>
</div>
<div class="cb-info">
<div class="cb-name">Portal Assistant</div>
<div class="cb-status"><span class="cb-dot"></span>Gemini AI · Mitra Industries</div>
</div>
<button id="cb-close" title="Close">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div id="cb-msgs"></div>
<div id="cb-suggestions">
<span class="cb-chip">Show today's sales</span>
<span class="cb-chip">Blood Bag BOM prices</span>
<span class="cb-chip">Monthly revenue</span>
<span class="cb-chip">CAPD BOM costs</span>
<span class="cb-chip">How to create a BOM?</span>
<span class="cb-chip">Explain P&L Summary</span>
</div>
<div id="cb-footer">
<textarea id="cb-input" rows="1" placeholder="Ask anything about the portal…"></textarea>
<button id="cb-send" title="Send">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>`;
document.body.appendChild(fab);
document.body.appendChild(panel);
// ── state ─────────────────────────────────────────────────────────────────
let isOpen = false;
let loading = false;
const history = []; // [{role:'user'|'assistant', content:'...'}]
let unread = 0;
const msgsEl = document.getElementById('cb-msgs');
const inputEl = document.getElementById('cb-input');
const sendBtn = document.getElementById('cb-send');
const badge = document.getElementById('cb-badge');
// ── helpers ───────────────────────────────────────────────────────────────
function getToken() {
return sessionStorage.getItem('portal_token') || '';
}
// ── markdown renderer ─────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function inlineMd(text) {
return escHtml(text)
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>');
}
function renderMd(raw) {
const lines = raw.split('\n');
let html = '', i = 0;
while (i < lines.length) {
const line = lines[i];
const trim = line.trim();
// Blank line
if (!trim) { html += '<br>'; i++; continue; }
// Heading
if (trim.startsWith('### ')) { html += `<h4>${inlineMd(trim.slice(4))}</h4>`; i++; continue; }
if (trim.startsWith('## ')) { html += `<h3>${inlineMd(trim.slice(3))}</h3>`; i++; continue; }
if (trim.startsWith('# ')) { html += `<h3>${inlineMd(trim.slice(2))}</h3>`; i++; continue; }
// Horizontal rule
if (/^[-*_]{3,}$/.test(trim)) { html += '<hr>'; i++; continue; }
// Table: line starts with | and next non-empty line is a separator
if (trim.startsWith('|')) {
const nextTrim = (lines[i + 1] || '').trim();
if (nextTrim.startsWith('|') && /^[\|:\- ]+$/.test(nextTrim)) {
const parseRow = l => l.trim().replace(/^\||\|$/g,'').split('|').map(c => c.trim());
const headers = parseRow(lines[i]);
const aligns = parseRow(lines[i + 1]).map(c => c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left');
i += 2;
let tbl = '<div class="cb-tbl-wrap"><table><thead><tr>';
headers.forEach((h, ci) => { tbl += `<th style="text-align:${aligns[ci]||'left'}">${inlineMd(h)}</th>`; });
tbl += '</tr></thead><tbody>';
while (i < lines.length && lines[i].trim().startsWith('|')) {
const cells = parseRow(lines[i]);
tbl += '<tr>';
cells.forEach((c, ci) => { tbl += `<td style="text-align:${aligns[ci]||'left'}">${inlineMd(c)}</td>`; });
tbl += '</tr>';
i++;
}
tbl += '</tbody></table></div>';
html += tbl;
continue;
}
}
// Unordered list
if (/^[-*+]\s/.test(trim)) {
let ul = '<ul>';
while (i < lines.length && /^[-*+]\s/.test(lines[i].trim())) {
ul += `<li>${inlineMd(lines[i].trim().slice(2))}</li>`;
i++;
}
html += ul + '</ul>';
continue;
}
// Ordered list
if (/^\d+\.\s/.test(trim)) {
let ol = '<ol>';
while (i < lines.length && /^\d+\.\s/.test(lines[i].trim())) {
ol += `<li>${inlineMd(lines[i].trim().replace(/^\d+\.\s/,''))}</li>`;
i++;
}
html += ol + '</ol>';
continue;
}
// Plain paragraph
html += `<p>${inlineMd(line)}</p>`;
i++;
}
// Clean up excess <br> tags
return html.replace(/(<br>){3,}/g,'<br><br>').replace(/^<br>+|<br>+$/g,'');
}
function setUnread(n) {
unread = n;
if (n > 0 && !isOpen) {
badge.textContent = n > 9 ? '9+' : n;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function appendMsg(role, text) {
// remove suggestions after first real message
const sug = document.getElementById('cb-suggestions');
if (sug) sug.remove();
const wrap = document.createElement('div');
wrap.className = `cb-msg cb-${role}`;
const av = document.createElement('div');
av.className = 'cb-msg-avatar';
av.textContent = role === 'bot' ? 'AI' : '👤';
const bub = document.createElement('div');
if (role === 'bot') {
bub.className = 'cb-bubble cb-md';
bub.innerHTML = renderMd(text);
} else {
bub.className = 'cb-bubble';
bub.textContent = text;
}
if (role === 'user') { wrap.appendChild(bub); wrap.appendChild(av); }
else { wrap.appendChild(av); wrap.appendChild(bub); }
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
return bub;
}
function showTyping() {
const wrap = document.createElement('div');
wrap.className = 'cb-msg cb-bot';
wrap.id = 'cb-typing-row';
wrap.innerHTML = `
<div class="cb-msg-avatar">AI</div>
<div class="cb-bubble cb-typing">
<span></span><span></span><span></span>
</div>`;
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function removeTyping() {
const t = document.getElementById('cb-typing-row');
if (t) t.remove();
}
// ── welcome message ───────────────────────────────────────────────────────
function showWelcome() {
appendMsg('bot', 'Hi! I\'m your Portal Assistant. I can help you navigate the portal, understand P&L data, cost sheets, BOM workflows, and more.\n\nWhat would you like to know?');
}
// ── send message ──────────────────────────────────────────────────────────
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || loading) return;
inputEl.value = '';
inputEl.style.height = 'auto';
loading = true;
sendBtn.disabled = true;
appendMsg('user', text);
history.push({ role: 'user', content: text });
showTyping();
try {
const token = getToken();
const res = await fetch('/api/chat/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ message: text, history: history.slice(0, -1) }),
});
const data = await res.json();
removeTyping();
const reply = data.success
? data.reply
: (data.message || 'Something went wrong. Please try again.');
appendMsg('bot', reply);
history.push({ role: 'assistant', content: reply });
if (!isOpen) setUnread(unread + 1);
} catch {
removeTyping();
appendMsg('bot', 'Network error. Please check your connection and try again.');
} finally {
loading = false;
sendBtn.disabled = false;
inputEl.focus();
}
}
// ── toggle panel ──────────────────────────────────────────────────────────
function openPanel() {
isOpen = true;
panel.classList.add('cb-open');
setUnread(0);
if (!msgsEl.children.length) showWelcome();
setTimeout(() => inputEl.focus(), 250);
}
function closePanel() {
isOpen = false;
panel.classList.remove('cb-open');
}
// ── events ────────────────────────────────────────────────────────────────
fab.addEventListener('click', () => isOpen ? closePanel() : openPanel());
document.getElementById('cb-close').addEventListener('click', closePanel);
// quick chips
panel.querySelectorAll('.cb-chip').forEach(chip => {
chip.addEventListener('click', () => {
inputEl.value = chip.textContent;
sendMessage();
});
});
// send on Enter (Shift+Enter = newline)
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// auto-resize textarea
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 100) + 'px';
});
sendBtn.addEventListener('click', sendMessage);
})();
+409
View File
@@ -0,0 +1,409 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Close Production Order — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--amber);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--amber);background:rgba(245,166,35,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:720px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .ico{font-size:15px}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:600}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--amber);box-shadow:0 0 0 2px rgba(245,166,35,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:420px){.fi-row{grid-template-columns:1fr}}
.fi-mono{font-family:var(--mono);font-size:12px;text-transform:uppercase}
.order-info{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;font-size:11px}
@media(max-width:680px){.order-info{grid-template-columns:1fr 1fr}}
@media(max-width:420px){.order-info{grid-template-columns:1fr}}
.oi-item{display:flex;flex-direction:column;gap:1px}
.oi-l{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
.oi-v{font-family:var(--mono);font-size:11px;color:var(--text)}
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-close{background:var(--amber);color:#fff;flex:1}.b-close:hover:not(:disabled){background:#d4891b;box-shadow:0 4px 16px rgba(245,166,35,.4)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-new{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3);flex:1}.b-new:hover{background:rgba(245,166,35,.2)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.al-red{background:rgba(229,62,62,.08);border:1px solid rgba(229,62,62,.2);color:#fca5a5}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.suc-box{text-align:center;padding:28px 16px}
.suc-icon{font-size:44px;margin-bottom:10px}
.suc-title{font-family:var(--display);font-size:26px;color:var(--green);margin-bottom:6px}
.suc-pill{display:inline-block;font-family:var(--mono);font-size:13px;padding:5px 16px;border-radius:6px;background:rgba(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25);margin:8px 0 12px}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.comp-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:6px}
.comp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;font-size:11px}
@media(max-width:420px){.comp-grid{grid-template-columns:1fr 1fr}}
.comp-fl{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
.comp-fv{font-family:var(--mono);font-size:11px;color:var(--text2)}
/* Square-tile grid (matches issue/receipt) */
.po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.po-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:10px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.po-card:hover{border-color:var(--amber);box-shadow:0 6px 18px rgba(245,166,35,.14);transform:translateY(-2px)}
.po-card.selected{border-color:var(--amber);background:rgba(245,166,35,.10);box-shadow:0 0 0 2px rgba(245,166,35,.25)}
.po-top{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;flex-wrap:wrap}
.po-code{font-family:var(--mono);font-size:10px;color:var(--amber);font-weight:600}
.po-icon{width:26px;height:26px;border-radius:7px;background:rgba(245,166,35,.12);color:var(--amber);display:flex;align-items:center;justify-content:center;margin:5px 0;font-size:13px}
.po-name{font-size:11px;font-weight:600;color:var(--text);line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.po-meta{font-size:9.5px;color:var(--text3);display:flex;flex-direction:column;gap:2px;margin-top:auto;padding-top:5px;border-top:1px solid var(--border)}
.po-meta b{color:var(--text2)}
.po-badge{font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px}
.po-badge.done{background:rgba(13,187,138,.12);color:var(--green)}
.po-badge.part{background:rgba(245,166,35,.12);color:var(--amber)}
/* Detail / close popup */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
.modal-bg.show{display:flex}
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:980px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:space-between}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
<a href="/issue-production" class="nav-link">Issue</a>
<a href="/receipt-production" class="nav-link">Receipt</a>
<a href="/close-production" class="nav-link active">Close</a>
<a href="/approvals" class="nav-link">Approvals</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<div class="modal-bg" id="close-detail-modal"><div class="modal" id="close-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
// Fail fast (with a clear message) if the user hasn't set their own SAP login.
function hasSapLogin(){try{const p=JSON.parse(atob(_tok.split('.')[1]));return !!(p&&p.sapLogins&&p.sapLogins[_co]&&p.sapLogins[_co].pwdEnc);}catch(_e){return false;}}
function requireSapLogin(){if(!hasSapLogin()){toast('Set your SAP User ID & Password first — open Profile → "My SAP Account".','err');return false;}return true;}
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
// Mirrors middleware/auth.js's hasStepPerm — this page is normally only
// reached via production.html's already-gated button, but a direct/bookmarked
// URL should still be blocked here too (the server enforces this regardless).
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
// Being assigned the step (any perm) is enough to perform the action.
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
const CAN_CLOSE_PO=hasStepAssigned('production_order:close');
// Admin / System Admin can close ANY order regardless of the approval-step
// assignment or the usual full-issuance/full-qty/sequencing gates — the
// server enforces the same override (routes/sap.js's isPoAdminOverride()).
const IS_PO_ADMIN_OVERRIDE=!!(_user&&['admin','system_admin'].includes(_user.role));
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _order=null;
let _loadingOrder=false;
let _closing=false;
let _success=false;
let _modalOpen=false;
let _readyOrders=[];
let _readyLoading=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
let _closeRequireFullQty=true; // admin setting (Settings → closeRequireFullQty) — fetched in init()
let _localStatusByEntry={}; // sapAbsEntry → local status (to exempt REJECTED orders from the full-qty gate)
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
// Whether a next page of Released orders exists — from the server total when
// known, else from whether a full page came back.
function pagerHasNext(){ if(_total!=null) return (_page+1)*_pageSize<_total; return _rawCount>=_pageSize; }
function renderPager(){
if(_readyLoading) return '';
const hasNext=pagerHasNext();
const totalPages=_total!=null?Math.max(1,Math.ceil(_total/_pageSize)):null;
const label=_total!=null?`Page ${_page+1} of ${totalPages} · ${_total} released`:`Page ${_page+1}`;
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn" id="rp-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${_page===0?';opacity:.45;cursor:not-allowed':''}">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">${label}</span>
<button class="btn" id="rp-next" ${hasNext?'':'disabled'} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${hasNext?'':';opacity:.45;cursor:not-allowed'}">Next ›</button>
</div>`;
}
// Released (open) orders — the candidates to close. Shown with a receipt
// badge so it's clear which are fully received and safe to close.
async function loadReadyOrders(resetPage){
if(resetPage)_page=0;
_readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{
const r=await api('GET',`/sap/production-orders?status=Released&readyToClose=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}`);
_rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]);
_total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render();
}
async function loadOrder(absEntry){
_modalOpen=true;_loadingOrder=true;_order=null;_success=false;render();
try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
_order=r.data||r;
}catch(e){toast(e.message,'err');}
_loadingOrder=false;render();
}
function selectOrder(absEntry){ loadOrder(absEntry); }
async function doClose(){
if(!_order)return;
if(!requireSapLogin())return;
_closing=true;render();
try{
await api('POST',`/sap/production-order/${_order.AbsoluteEntry}/close`,{company:_co});
_success=true;
toast('Production Order closed!');
}catch(e){toast(e.message||'Failed','err');}
_closing=false;render();
}
// ═══════════════════════════════════════
function render(){
try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}
}
function closeCloseModal(){_modalOpen=false;_order=null;_success=false;render();loadReadyOrders();}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
if(!CAN_CLOSE_PO&&!IS_PO_ADMIN_OVERRIDE){
app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You\'re not assigned the "Close" approval step, so you can\'t close production orders. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
document.getElementById('close-detail-modal')?.classList.remove('show');
return;
}
// Company
const c1=mk('card');
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> Close Production Order</div>
<div class="card-bd"><div class="fg"><label class="fl">Company</label>
<select class="fi" id="sel-co">${coOptions(_co)}</select></div></div>`;
app.appendChild(c1);
// Orders ready to close — square-tile grid + search + pagination.
const c2b=mk('card');
const gridBody=_readyLoading
?'<div class="empty-msg"><span class="sp" style="border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading released orders...</div>'
:!_readyOrders.length?'<div class="empty-msg">No open (released) orders on this page.</div>'
:`<div class="po-grid">${_readyOrders.map(o=>{
const badge=o._receiptStatus==='done'?'<span class="po-badge done">RECEIVED</span>':o._receiptStatus==='partial'?'<span class="po-badge part">PARTIAL</span>':'';
return `<div class="po-card${_order&&_order.AbsoluteEntry===o.AbsoluteEntry?' selected':''}" data-ready="${o.AbsoluteEntry}">
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${badge}</div>
<div class="po-icon">🔒</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</div>
<div class="po-meta"><span>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>Done <b>${o.CompletedQuantity||0}</b> · WH <b>${esc(o.Warehouse||'-')}</b></span></div>
${(o.WoNo||o.BatchNumber)?`<div class="po-meta"><span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span></div>`:''}
</div>`;}).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px">
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--amber);color:#fff;white-space:nowrap">Search</button>
</div>`;
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Close<span class="right">${_readyOrders.length}</span></div>
<div class="card-bd">
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">Only Released orders that are fully issued and fully received are listed here (per Admin → System Settings). An order missing either isn't shown — issue or receive the rest first, then it appears here.</div>
${searchRow}${gridBody}${renderPager()}</div>`;
app.appendChild(c2b);
renderModal();
// Wire (page-level controls)
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;_order=null;loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;});
q('#rp-search')&&(q('#rp-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadReadyOrders(true);}});
q('#rp-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}});
q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}});
document.querySelectorAll('[data-ready]').forEach(el=>el.onclick=()=>selectOrder(parseInt(el.dataset.ready)));
},0);
}
// ── Detail / close popup ──────────────────────────────
function renderModal(){
const bg=document.getElementById('close-detail-modal');
const inner=document.getElementById('close-modal-inner');
if(!_modalOpen){bg.classList.remove('show');return;}
bg.classList.add('show');
// Success
if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Close Production Order — Done</span><button class="modal-close" id="close-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Order Closed!</div>
<p style="font-size:13px;color:var(--text3)">${esc(_order?.ItemNo||'')} — ${esc(_order?.ProductDescription||'')}</p>
<div class="suc-pill">Order #${esc(String(_order?.DocumentNumber||_order?.AbsoluteEntry||''))}</div>
<div class="alert al-green" style="max-width:400px;margin:10px auto;text-align:left">
Production Order has been closed in SAP B1. No further issues or receipts can be made.
</div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
<button class="btn b-new" id="btn-done">Done</button>
</div>
</div></div>`;
setTimeout(()=>{q('#close-modal-x').onclick=closeCloseModal;q('#btn-done').onclick=closeCloseModal;},0);
return;
}
// Loading
if(_loadingOrder||!_order){
inner.innerHTML=`<div class="modal-hd"><span>Loading Order…</span><button class="modal-close" id="close-modal-x">✕</button></div>
<div class="modal-bd"><div class="empty-msg"><span class="sp" style="border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading order details...</div></div>`;
setTimeout(()=>{q('#close-modal-x').onclick=closeCloseModal;},0);
return;
}
const d=_order;
const st=(d.ProductionOrderStatus||'').replace('bopos','');
const isClosed=st==='Closed';
const lines=d.ProductionOrderLines||[];
const linesHtml=lines.map((l,i)=>`<div class="comp-card">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="font-family:var(--mono);font-size:11px;color:var(--teal);font-weight:600">${esc(l.ItemNo)}</span>
<span style="font-size:11px;color:var(--text2);flex:1">${esc(l.ItemName||'')}</span>
<span style="font-size:10px;color:var(--text3)">#${i+1}</span>
</div>
<div class="comp-grid">
<div><span class="comp-fl">Planned</span><div class="comp-fv">${l.PlannedQuantity}</div></div>
<div><span class="comp-fl">Issued</span><div class="comp-fv" style="color:${(l.IssuedQuantity||0)>=(l.PlannedQuantity||1)?'var(--green)':'var(--amber)'}">${l.IssuedQuantity||0}</div></div>
<div><span class="comp-fl">Warehouse</span><div class="comp-fv">${esc(l.Warehouse||'-')}</div></div>
</div>
</div>`).join('');
// Full-quantity gate (admin setting closeRequireFullQty): close only when
// Completed = Planned. Orders whose receipt was REJECTED are exempt — they
// can never reach full qty. The server enforces the same rule.
const _planned=parseFloat(d.PlannedQuantity)||0,_completed=parseFloat(d.CompletedQuantity)||0;
const _rejectedLocal=_localStatusByEntry[d.AbsoluteEntry]==='REJECTED';
const qtyOk=!_closeRequireFullQty||_rejectedLocal||Math.abs(_completed-_planned)<=0.0001||IS_PO_ADMIN_OVERRIDE;
const actionHtml=isClosed
?`<div class="alert al-green" style="margin-top:12px;margin-bottom:0">This production order is already closed.</div>`
:!qtyOk
?`<div class="alert al-red" style="margin-top:12px">Cannot close yet: <b>Completed Qty (${_completed})</b> must equal <b>Planned Qty (${_planned})</b>. Receive the remaining <b>${Math.round((_planned-_completed)*1000)/1000}</b> from production first.</div>
<div class="btn-bar">
<button class="btn b-close" disabled style="opacity:.5;cursor:not-allowed">Close Production Order</button>
<button class="btn b-cancel" id="btn-cancel">Cancel</button>
</div>`
:`<div class="alert al-amber" style="margin-top:12px">${IS_PO_ADMIN_OVERRIDE&&Math.abs(_completed-_planned)>0.0001?'<strong>Admin override:</strong> quantities are NOT fully matched — closing anyway. ':''}Closing this production order is permanent. All open quantities will be zeroed out. Make sure Issue and Receipt are complete before closing.</div>
<div class="btn-bar">
<button class="btn b-close" id="btn-close" ${_closing?'disabled':''}><span id="sp-close" style="display:${_closing?'inline-block':'none'}" class="sp"></span> Close Production Order</button>
<button class="btn b-cancel" id="btn-cancel">Cancel</button>
</div>`;
inner.innerHTML=`
<div class="modal-hd">
<span style="font-family:var(--mono);color:var(--amber)">${esc(d.ItemNo)}</span>
<span style="color:var(--text3);font-size:11px">Order #${d.DocumentNumber||d.AbsoluteEntry}</span>
<button class="modal-close" id="close-modal-x" style="margin-left:auto">✕</button>
</div>
<div class="modal-bd">
<div class="order-info">
<div class="oi-item"><span class="oi-l">Product</span><span class="oi-v" style="color:var(--amber)">${esc(d.ItemNo)}</span></div>
<div class="oi-item"><span class="oi-l">Description</span><span class="oi-v" style="font-family:var(--font)">${esc(d.ProductDescription||'-')}</span></div>
<div class="oi-item"><span class="oi-l">Status</span><span class="oi-v" style="color:${st==='Closed'?'var(--text3)':st==='Released'?'var(--blue)':'var(--amber)'}">${st}</span></div>
<div class="oi-item"><span class="oi-l">Planned Qty</span><span class="oi-v">${d.PlannedQuantity}</span></div>
<div class="oi-item"><span class="oi-l">Completed Qty</span><span class="oi-v">${d.CompletedQuantity||0}</span></div>
<div class="oi-item"><span class="oi-l">Warehouse</span><span class="oi-v">${esc(d.Warehouse||'-')}</span></div>
</div>
${lines.length?`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Components (${lines.length})</div>${linesHtml}`:''}
${actionHtml}
</div>`;
setTimeout(()=>{
q('#close-modal-x').onclick=closeCloseModal;
q('#btn-cancel')&&(q('#btn-cancel').onclick=closeCloseModal);
q('#btn-close')&&(q('#btn-close').onclick=function(){if(!confirm('Are you sure you want to close this production order? This cannot be undone.'))return;doClose();});
},0);
}
async function init(){
try{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
// Full-qty gate config + local statuses (for the REJECTED exemption)
try{const cfg=await api('GET','/config');if(cfg&&typeof cfg.closeRequireFullQty==='boolean')_closeRequireFullQty=cfg.closeRequireFullQty;}catch(_e){}
try{const lr=await api('GET',`/production-orders?company=${encodeURIComponent(_co)}&status=ALL`);(lr.data||[]).forEach(p=>{if(p.sapAbsEntry)_localStatusByEntry[p.sapAbsEntry]=p.status;});}catch(_e){}
render();
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry) await selectOrder(entry);
}else{
loadReadyOrders();
}
}catch(e){console.error('Init error:',e);render();}
}
init();
// Hash-only link changes (e.g. the sidebar "Pending Actions" bell linking
// here for a different order while already on this page) don't reload the
// page, so init() alone would never pick up the new order — re-check on
// hashchange too. See issue-production.html for the fuller explanation.
window.addEventListener('hashchange',()=>{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry)selectOrder(entry);
}
});
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
// company-list.js — shared helper: fetch the LIVE SAP company list once,
// cached for the page's lifetime. Backed by GET /api/companies (server
// scans SQL Server for company databases, no hardcoded list — public/no-auth
// since register.html/vendor-register.html use it before login too).
//
// Admin → user → "Displayed SAP Company" can restrict which of those
// companies a given user sees: filtered HERE, client-side, against the
// logged-in user's allowedCompanies (baked into portal_user at login/refresh
// — see server.js buildAuthPayload). Empty/absent = no restriction (sees
// everything the server found), same as unauthenticated pages. This is a
// display-level filter only, matching how Issue Items/Man Power/OEE tab
// restrictions work elsewhere in this app — it doesn't block API calls made
// with an explicit company param.
(function(){
let _p = null;
window.fetchCompanyList = function(){
if(_p) return _p;
_p = fetch('/api/companies').then(r=>r.json()).then(d=>{
const all=d.success?(d.data||[]):[];
let allowed=null;
try{
const u=JSON.parse(sessionStorage.getItem('portal_user')||'null');
if(u&&Array.isArray(u.allowedCompanies)&&u.allowedCompanies.length) allowed=new Set(u.allowedCompanies.map(String));
}catch(_e){}
return allowed?all.filter(c=>allowed.has(String(c.value))):all;
}).catch(()=>[]);
return _p;
};
// Every page initializes its working company as `let _co=window.__DEFAULT_
// COMPANY__` (the .env SAP_B1_COMPANY, injected server-side into EVERY
// page for EVERY user — see server.js's static-file middleware). That's
// fine for an unrestricted user, but a user restricted to specific
// companies (Admin → user → "Displayed SAP Company") would still silently
// default to and fetch data from the .env company, even when it's NOT in
// their allowed list — the restriction only ever filtered the DROPDOWN,
// never the actual starting selection. Fixed here, synchronously, before
// any page's own inline script runs (this file is always loaded in <head>,
// ahead of the page body): if the user has a non-empty allowedCompanies
// list that does NOT include the .env default, swap window.__DEFAULT_
// COMPANY__ to their first allowed company so every page's `_co` picks up
// the corrected value with no per-page changes needed.
try{
const u=JSON.parse(sessionStorage.getItem('portal_user')||'null');
if(u&&Array.isArray(u.allowedCompanies)&&u.allowedCompanies.length&&!u.allowedCompanies.includes(window.__DEFAULT_COMPANY__)){
window.__DEFAULT_COMPANY__=u.allowedCompanies[0];
}
}catch(_e){}
})();
File diff suppressed because it is too large Load Diff
+885
View File
@@ -0,0 +1,885 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>P&L Comparison | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/xlsx-js-style@1.2.0/dist/xlsx.bundle.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700}.hdr h1 span{color:#047e7e}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#047e7e;color:#fff}.btn-primary:hover{background:#035b5b}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#047e7e;border:1.5px solid #047e7e}.btn-outline:hover{background:#f0fafa}
.btn-sm{height:30px;padding:0 12px;font-size:11px}
.btn-purple{background:#7c3aed;color:#fff}.btn-purple:hover{background:#6d28d9}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
/* ── Snapshot picker ── */
.snap-picker{padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.snap-picker-title{font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px;margin-bottom:10px}
.snap-list{display:flex;flex-wrap:wrap;gap:8px;max-height:180px;overflow-y:auto;padding:2px}
.snap-list::-webkit-scrollbar{width:5px}.snap-list::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
.snap-item{display:flex;align-items:center;gap:8px;padding:7px 12px;border-radius:8px;border:1.5px solid #e8eaed;background:#fafbfc;cursor:pointer;transition:all .15s;font-size:11px;font-weight:600;color:#374151;max-width:340px}
.snap-item:hover{border-color:#047e7e;background:#f0fafa;color:#047e7e}
.snap-item.selected{border-color:#047e7e;background:#e0f4f4;color:#004d40}
.snap-item .snap-tag{font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;background:#047e7e;color:#fff;flex-shrink:0}
.snap-item .snap-meta{font-size:10px;color:#9ca3af;font-weight:400}
.snap-empty{font-size:12px;color:#9ca3af;padding:16px 0}
/* ── Selected bar ── */
.sel-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb;flex-wrap:wrap;min-height:52px}
.sel-bar-label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px;white-space:nowrap;flex-shrink:0}
.sel-chips{display:flex;flex-wrap:wrap;gap:6px;flex:1}
.sel-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:6px;background:#e0f4f4;border:1.5px solid #80cbc4;font-size:11px;font-weight:600;color:#004d40}
.sel-chip button{background:none;border:none;cursor:pointer;color:#047e7e;font-size:14px;line-height:1;padding:0 0 0 2px}
.sel-chip button:hover{color:#dc2626}
/* ── Toggles ── */
.toggle-row{display:flex;align-items:center;gap:16px;padding:8px 20px;background:#f8fafb;border-bottom:1px solid #e8eaed;font-size:12px;color:#374151;flex-wrap:wrap}
.toggle-row label{display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:600}
.toggle-row input[type=checkbox]{width:15px;height:15px;accent-color:#047e7e;cursor:pointer}
/* ── Progress ── */
.progress-bar{height:3px;background:#e0f4f4;overflow:hidden}
.progress-fill{height:100%;background:#047e7e;transition:width .3s;width:0%}
/* ── Table ── */
.cmp-wrap{overflow-x:auto;overflow-y:auto;max-height:65vh}
.cmp-wrap::-webkit-scrollbar{height:7px;width:7px}
.cmp-wrap::-webkit-scrollbar-track{background:#f1f3f5}
.cmp-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
table.cmp{border-collapse:collapse;font-size:12px}
/* sticky col */
.cmp th.part,.cmp td.part{position:sticky;left:0;z-index:3;min-width:210px;max-width:210px;font-size:12px}
.cmp thead th.part{background:#0f4c4c}
.cmp tbody td.part{background:#fff;z-index:2;border-right:2px solid #d1dae3;font-weight:600;color:#1a1a2e}
/* thead row 1 — period names — sticky top */
.cmp thead tr:first-child th{background:#0f4c4c;color:#fff;font-weight:700;padding:10px 14px;text-align:center;white-space:nowrap;font-size:11px;letter-spacing:.2px;border-right:1px solid rgba(255,255,255,.12);position:sticky;top:0;z-index:4}
.cmp thead tr:first-child th.part{text-align:left;font-size:12px;letter-spacing:.3px;border-right:2px solid rgba(255,255,255,.25);background:#047e7e;z-index:5}
/* thead row 2 — Total / % Rev — sticky below row 1 */
.cmp thead tr.sub-hdr th{background:#065f46;color:rgba(255,255,255,.9);font-weight:700;padding:6px 14px;text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:.4px;border-right:1px solid rgba(255,255,255,.08);position:sticky;top:44px;z-index:4}
.cmp thead tr.sub-hdr th.part{text-align:left;font-size:10px;color:rgba(255,255,255,.7);z-index:5}
.cmp thead tr.sub-hdr th.bdr{border-left:2px solid rgba(255,255,255,.2)}
/* body cells */
.cmp tbody td{padding:6px 14px;border-bottom:1px solid #f0f2f4;vertical-align:middle;white-space:nowrap;color:#374151}
.cmp tbody td.num{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;color:#1a1a2e}
.cmp tbody td.pct{text-align:right;color:#9ca3af;font-size:10px;font-weight:400;min-width:56px}
.cmp tbody td.bdr{border-left:2px solid #e8eaed}
.cmp tr.sub:hover td:not(.part){background:#f5fefe}
/* Revenue row */
.cmp tr.rev td{background:#e0f4f4;border-top:2px solid #047e7e;border-bottom:2px solid #80cbc4;padding:9px 14px}
.cmp tr.rev td.part{background:#e0f4f4;color:#004d40;font-weight:700;font-size:13px}
.cmp tr.rev td.num{color:#047e7e;font-size:13px;font-weight:800}
/* sub-items (Opening Stock, Purchases etc.) */
.cmp tr.sub td.part{padding-left:28px;color:#047e7e;font-weight:600}
.cmp tr.sub td.num{color:#374151;font-weight:600}
/* section headers */
.cmp tr.sec-hdr td{background:#f0f4f8;font-weight:700;color:#1a1a2e;padding:8px 14px;border-top:1px solid #d1dae3}
.cmp tr.sec-hdr td.part{background:#f0f4f8}
/* total rows */
.cmp tr.tot td{background:#e8f4f4;font-weight:700;border-top:2px solid #80cbc4;padding:7px 14px}
.cmp tr.tot td.part{color:#004d40;background:#e8f4f4;font-size:12px}
.cmp tr.tot td.num{color:#047e7e}
/* gross profit */
.cmp tr.gp td{background:#e8f5e9;font-weight:700;border-top:2px solid #66bb6a;padding:8px 14px}
.cmp tr.gp td.part{background:#e8f5e9;color:#1b5e20;font-size:13px}
.cmp tr.gp td.num{color:#2e7d32;font-size:13px;font-weight:800}
/* net profit */
.cmp tr.grand td{background:#004d40;color:#fff;font-weight:800;font-size:13px;padding:10px 14px;border-top:2px solid #047e7e}
.cmp tr.grand td.part{background:#004d40;color:#fff;font-size:13px}
.cmp tr.grand td.pct{color:rgba(255,255,255,.6)}
/* blank spacer */
.cmp tr.blank td{height:10px;border:none!important;background:#fff!important}
.cmp tr.blank td.part{background:#fff!important}
/* colors */
.neg{color:#dc2626!important;font-weight:700!important}
/* ── Status ── */
.status-bar{display:flex;align-items:center;gap:10px;padding:8px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:4px}
@keyframes spin{to{transform:rotate(360deg)}}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#047e7e}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>P&amp;L</span> Comparison</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" id="btnCompare" onclick="runComparison()" disabled>Compare</button>
<button class="btn btn-outline btn-sm" id="btnPrint" onclick="printSection('cmpPane','P&L Comparison')" disabled>Print</button>
<button class="btn btn-outline btn-sm" id="btnExport" disabled>Download Excel</button>
</div>
</div>
<div class="card">
<!-- Snapshot picker -->
<div class="snap-picker">
<div class="snap-picker-title">Select snapshots to compare &mdash; click to add/remove</div>
<div class="snap-list" id="snapList">
<div class="snap-empty">Loading snapshots…</div>
</div>
</div>
<!-- Selected bar -->
<div class="sel-bar">
<span class="sel-bar-label">Comparing</span>
<div class="sel-chips" id="selChips"><span style="font-size:11px;color:#9ca3af">No snapshots selected yet</span></div>
<button class="btn btn-sm btn-outline" onclick="clearSelection()">Clear All</button>
</div>
<!-- Toggles -->
<div class="toggle-row">
<label><input type="checkbox" id="chkPct" checked> Show % of Revenue</label>
</div>
<!-- Progress -->
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<!-- Table -->
<div id="cmpPane"><div class="empty">Select snapshots above and click Compare</div></div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select snapshots to compare</span>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
const tok=sessionStorage.getItem('portal_token');
if(!tok){window.location.href='/';return;}
const hdrs={'Authorization':'Bearer '+tok,'Content-Type':'application/json'};
let allSnaps=[]; // full list from API
let selectedIds=[]; // ordered list of selected snapshot IDs
let periodsData=[]; // loaded comparison data
// ── Load snapshot list ────────────────────────────────────────────────────────
async function loadSnapshotList(){
try{
const r=await fetch('/api/costing/snapshots',{headers:hdrs});
const j=await r.json(); if(!j.success) return;
// Exclude Trial Balance snapshots (saved with a "TB: " name prefix) — only
// Monthly Accounts P&L snapshots are relevant to this comparison.
allSnaps=j.data.filter(s=>!/^TB:/i.test(s.snap_name||''));
renderSnapList();
}catch(e){
document.getElementById('snapList').innerHTML=`<div class="snap-empty">Failed to load snapshots: ${esc(e.message)}</div>`;
}
}
function renderSnapList(){
const el=document.getElementById('snapList');
if(!allSnaps.length){
el.innerHTML='<div class="snap-empty">No snapshots saved yet. Go to <b>Monthly Accounts</b>, load data and click <b>Save Snapshot</b>.</div>';
return;
}
el.innerHTML=allSnaps.map(s=>{
const sel=selectedIds.includes(String(s.id));
return `<div class="snap-item${sel?' selected':''}" onclick="toggleSnap('${s.id}')">
<span class="snap-tag">${sel?'✓':'+'}${selectedIds.indexOf(String(s.id))>=0?' #'+(selectedIds.indexOf(String(s.id))+1):''}</span>
<div>
<div>${esc(s.snap_name)}</div>
</div>
</div>`;
}).join('');
}
function toggleSnap(id){
const idx=selectedIds.indexOf(id);
if(idx>=0) selectedIds.splice(idx,1);
else selectedIds.push(id);
renderSnapList();
renderSelChips();
document.getElementById('btnCompare').disabled=selectedIds.length<1;
}
window.toggleSnap=toggleSnap;
function clearSelection(){selectedIds=[];renderSnapList();renderSelChips();document.getElementById('btnCompare').disabled=true;}
window.clearSelection=clearSelection;
function renderSelChips(){
const el=document.getElementById('selChips');
if(!selectedIds.length){
el.innerHTML='<span style="font-size:11px;color:#9ca3af">No snapshots selected yet</span>';
return;
}
el.innerHTML=selectedIds.map((id,i)=>{
const s=allSnaps.find(x=>String(x.id)===id);
if(!s) return '';
return `<span class="sel-chip">
<span style="font-size:9px;color:#9ca3af">#${i+1}</span>
${esc(s.snap_name)}
<button onclick="toggleSnap('${id}')" title="Remove">×</button>
</span>`;
}).join('');
}
// ── Run comparison ─────────────────────────────────────────────────────────────
async function runComparison(){
if(!selectedIds.length){showToast('Select at least one snapshot','error');return;}
const btn=document.getElementById('btnCompare');
btn.disabled=true; btn.innerHTML='<span class="spinner"></span>Loading…';
setStatus('Loading snapshot data…','loading');
setProgress(0);
try{
let done=0;
const results=await Promise.all(selectedIds.map(async id=>{
const r=await fetch(`/api/costing/snapshot/${id}`,{headers:hdrs});
const j=await r.json(); if(!j.success) throw new Error(j.message);
const d=j.data;
const data=(d.accounts||[]).map(r=>({...r,provision:r.override_val!=null?Number(r.override_val):null}));
const det=d.stock_detail||{open:{rm:0,wip:0,fg:0,trade:0,conv:0},close:{rm:0,wip:0,fg:0,trade:0,conv:0}};
const stock={open:d.opening_stock||0,close:d.closing_stock||0,detail:det};
const snap=allSnaps.find(s=>String(s.id)===id);
done++; setProgress(Math.round(done/selectedIds.length*100));
return{label:snap?snap.snap_name:id, from:snap?.period_from||'', to:snap?.period_to||'', data, stock, pl:calcPL(data,stock)};
}));
periodsData=results;
renderTable();
document.getElementById('btnExport').disabled=false;
document.getElementById('btnPrint').disabled=false;
setStatus(`Comparing ${results.length} snapshot${results.length>1?'s':''}`, 'ok');
showToast('Comparison ready','success');
}catch(e){
setStatus('Error: '+e.message,'err');
showToast('Error: '+e.message,'error');
}finally{
btn.disabled=false; btn.innerHTML='Compare';
}
}
window.runComparison=runComparison;
document.getElementById('chkPct').addEventListener('change',()=>{if(periodsData.length) renderTable();});
// ── P&L calculations ──────────────────────────────────────────────────────────
function finalVal(r,sk){
const p=r.provision!=null?Number(r.provision):0;
return sk==='Revenue'?r.period_credit-r.period_debit+p:r.period_debit-r.period_credit+p;
}
function calcPL(data,stock){
const tot={Revenue:0,Purchase:0,Employee:0,Factory:0,Admin:0,SND:0,Finance:0,OtherIncome:0,Other:0};
data.forEach(r=>{const s=r.sheet||'Other';if(tot[s]!==undefined)tot[s]+=finalVal(r,s);});
const rev=tot.Revenue, purchT=tot.Purchase;
const matCost=stock.open+purchT-stock.close;
const convChange=(stock.detail.open.conv||0)-(stock.detail.close.conv||0);
const totalCons=matCost+convChange;
const grossP=rev-totalCons;
const empT=tot.Employee, factT=tot.Factory, adminT=tot.Admin, sndT=tot.SND, finT=tot.Finance;
const oiT=-(tot.OtherIncome||0);
const totalExp=empT+factT+adminT+sndT+finT;
const netOps=grossP-totalExp;
const netTax=netOps+oiT;
return{tot,rev,purchT,matCost,convChange,totalCons,grossP,empT,factT,adminT,sndT,finT,oiT,totalExp,netOps,netTax,stock};
}
function getHeadTotals(data,sheetKey){
const headMap={},headOrder=[];
data.filter(r=>r.sheet===sheetKey).forEach(r=>{
const h=(r.head||'').trim()||r.acct_name;
if(!headMap[h]){headMap[h]={total:0,minOrd:r.sort_order};headOrder.push(h);}
headMap[h].total+=finalVal(r,sheetKey);
headMap[h].minOrd=Math.min(headMap[h].minOrd,r.sort_order);
});
headOrder.sort((a,b)=>headMap[a].minOrd-headMap[b].minOrd);
return headOrder.map(h=>({head:h,total:headMap[h].total}));
}
function getHeadVal(data,sheetKey,head){
return data.filter(r=>r.sheet===sheetKey&&((r.head||'').trim()||r.acct_name)===head)
.reduce((s,r)=>s+finalVal(r,sheetKey),0);
}
function getAcctVal(data,code,sheetKey){
const r=data.find(x=>x.acct_code===code&&x.sheet===sheetKey);
return r?finalVal(r,sheetKey):0;
}
function mergedEmpRows(){
const seen=new Set(),result=[];
periodsData.forEach(p=>{
const rows=p.data.filter(r=>r.sheet==='Employee');
const headMap={},headOrder=[];
rows.forEach(r=>{
const h=(r.head||'').trim()||r.acct_name;
if(!headMap[h]){headMap[h]={accts:[],minOrd:r.sort_order};headOrder.push(h);}
if(h==='Direct Expenses') headMap[h].accts.push({code:r.acct_code,name:r.acct_name,ord:r.sort_order});
headMap[h].minOrd=Math.min(headMap[h].minOrd,r.sort_order);
});
headOrder.sort((a,b)=>headMap[a].minOrd-headMap[b].minOrd);
headOrder.forEach(h=>{
if(h==='Direct Expenses'){
headMap[h].accts.sort((a,b)=>a.ord-b.ord).forEach(a=>{
if(!seen.has('a:'+a.code)){seen.add('a:'+a.code);result.push({type:'acct',label:a.name,key:a.code});}
});
}else{
if(!seen.has('h:'+h)){seen.add('h:'+h);result.push({type:'head',label:h,key:h});}
}
});
});
return result;
}
function mergedHeads(sheetKey){
const seen=new Set(),result=[];
periodsData.forEach(p=>{
getHeadTotals(p.data,sheetKey).forEach(({head})=>{
if(!seen.has(head)){seen.add(head);result.push(head);}
});
});
return result;
}
// ── Render table ──────────────────────────────────────────────────────────────
function renderTable(){
const showPct=document.getElementById('chkPct').checked;
const nc=periodsData.length;
const fmt=v=>Math.abs(v||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
const pctOf=(v,b)=>b!==0?(Math.abs(v)/Math.abs(b)*100).toFixed(1)+'%':'—';
const sign=v=>v<0?'-':'';
const showNet=nc>=2;
// Build header
let th1='<th class="part" rowspan="2">Particulars</th>';
let th2='';
periodsData.forEach(p=>{
const span=showPct?'2':'1';
const fromFmt=fmtDateLong(p.from), toFmt=fmtDateLong(p.to);
th1+=`<th class="bdr" colspan="${span}" style="text-align:center;padding:10px 14px">
<div style="font-size:12px;font-weight:700;letter-spacing:.2px">${esc(p.label)}</div>
<div style="font-size:9px;font-weight:400;opacity:.7;margin-top:2px">${fromFmt} &rarr; ${toFmt}</div>
</th>`;
th2+=`<th class="bdr" style="min-width:130px">Total (&#8377;)</th>${showPct?'<th style="min-width:60px">% Rev</th>':''}`;
});
if(showNet){
th1+=`<th class="bdr" colspan="2" style="text-align:center;padding:10px 14px">
<div style="font-size:12px;font-weight:700;letter-spacing:.2px">Net Change</div>
<div style="font-size:9px;font-weight:400;opacity:.7;margin-top:2px">${esc(periodsData[nc-1].label)} &minus; ${esc(periodsData[0].label)}</div>
</th>`;
th2+=`<th class="bdr" style="min-width:120px">Amount (&#8377;)</th><th style="min-width:60px">% Chg</th>`;
}
const netCols=showNet?2:0;
const revNetChange=showNet?((periodsData[nc-1].pl.rev||0)-(periodsData[0].pl.rev||0)):0;
function netCell(vals,color){
const base=vals[0]||0, last=vals[nc-1]||0;
const v=last-base;
const clr=color?color(v):(v<0?'#dc2626':'#047e7e');
const pctStr=pctOf(v,revNetChange);
return `<td class="num bdr" style="color:${clr}">${sign(v)}${fmt(v)}</td><td class="pct" style="color:${clr}">${pctStr}</td>`;
}
function numCells(vals,noPct){
let html=periodsData.map((p,i)=>{
const v=vals[i]||0;
const neg=v<0?' neg':'';
const pct=(showPct&&!noPct)?`<td class="pct">${pctOf(v,p.pl.rev)}</td>`:(showPct?'<td class="pct"></td>':'');
return `<td class="num bdr${neg}">${sign(v)}${fmt(v)}</td>${pct}`;
}).join('');
if(showNet)html+=netCell(vals);
return html;
}
function convCells(vals){
let html=periodsData.map((p,i)=>{
const v=vals[i]||0;
const pct=showPct?`<td class="pct">${pctOf(v,p.pl.rev)}</td>`:'';
return `<td class="num bdr" style="color:${v<0?'#dc2626':'#047e7e'}">${sign(v)}${fmt(v)}</td>${pct}`;
}).join('');
if(showNet)html+=netCell(vals);
return html;
}
const blank=()=>`<tr class="blank"><td class="part"></td><td colspan="${nc*(showPct?2:1)+netCols}"></td></tr>`;
const secHdr=lbl=>`<tr class="sec-hdr"><td class="part">${esc(lbl)}</td><td colspan="${nc*(showPct?2:1)+netCols}"></td></tr>`;
const subRow=(lbl,vals,cls='sub',noPct)=>`<tr class="${cls}"><td class="part">${esc(lbl)}</td>${numCells(vals,noPct)}</tr>`;
const totRow=(lbl,vals,cls='tot')=>`<tr class="${cls}"><td class="part">${esc(lbl)}</td>${numCells(vals)}</tr>`;
function buildSection(sheetKey,secLabel,totLabel){
const heads=mergedHeads(sheetKey);
if(!heads.length) return '';
let html=secHdr(secLabel);
heads.forEach(h=>{html+=subRow(h,periodsData.map(p=>getHeadVal(p.data,sheetKey,h)));});
html+=totRow(totLabel,periodsData.map(p=>p.pl.tot[sheetKey]||0));
html+=blank();
return html;
}
function buildEmpSection(){
const rows=mergedEmpRows();
if(!rows.length) return '';
let html=secHdr('Employee Benefit Expenses');
rows.forEach(({type,label,key})=>{
const vals=periodsData.map(p=>type==='acct'?getAcctVal(p.data,key,'Employee'):getHeadVal(p.data,'Employee',key));
html+=subRow(label,vals);
});
html+=totRow('Total Employee Benefit Expenses',periodsData.map(p=>p.pl.empT));
html+=blank();
return html;
}
const grandRow=`<tr class="grand">
<td class="part">Net Profit before Tax</td>
${periodsData.map(p=>{
const v=p.pl.netTax;
const pct=showPct?`<td class="pct" style="color:rgba(255,255,255,.6)">${pctOf(v,p.pl.rev)}</td>`:'';
return `<td class="num bdr" style="color:${v>=0?'#6ee7b7':'#fca5a5'}">${sign(v)}${fmt(v)}</td>${pct}`;
}).join('')}
${showNet?netCell(periodsData.map(p=>p.pl.netTax),v=>v>=0?'#6ee7b7':'#fca5a5'):''}
</tr>`;
const tbody=`
${totRow('Revenue from Operations',periodsData.map(p=>p.pl.rev),'rev')}
${blank()}
${subRow('Opening Stock',periodsData.map(p=>p.stock.open),'sub',true)}
${subRow('Purchases',periodsData.map(p=>p.pl.purchT),'sub',true)}
${subRow('Closing Stock',periodsData.map(p=>p.stock.close),'sub',true)}
${totRow('Consumption of Material',periodsData.map(p=>p.pl.matCost))}
<tr class="sub"><td class="part" style="padding-left:24px;color:#6b7280">Change in Conversion Cost</td>${convCells(periodsData.map(p=>p.pl.convChange))}</tr>
${totRow('Total Consumption',periodsData.map(p=>p.pl.totalCons))}
${blank()}
${totRow('Gross Profit',periodsData.map(p=>p.pl.grossP),'gp')}
${blank()}
${buildEmpSection()}
${buildSection('Factory','Factory / Utility Expenses','Total Factory Expenses')}
${buildSection('Admin','Administration Expenses','Total Administration Expenses')}
${buildSection('SND','Selling &amp; Distribution Expenses','Total Selling &amp; Distribution Expenses')}
${buildSection('Finance','Finance Cost','Total Finance Cost')}
${totRow('Total Expenses',periodsData.map(p=>p.pl.totalExp))}
${blank()}
${totRow('Net Profit from Operations',periodsData.map(p=>p.pl.netOps))}
${periodsData.some(p=>p.pl.oiT!==0)?subRow('Other Income',periodsData.map(p=>p.pl.oiT)):''}
${grandRow}`;
document.getElementById('cmpPane').innerHTML=`<div class="cmp-wrap">
<table class="cmp">
<thead>
<tr>${th1}</tr>
<tr class="sub-hdr">${th2}</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
<div style="padding:6px 12px;font-size:10px;color:#9ca3af">* All values in &#8377;. Override/provision values included from each snapshot.</div>
</div>`;
// Fix second header row's sticky top to exactly match first row height
requestAnimationFrame(()=>{
const row1=document.querySelector('.cmp thead tr:first-child');
if(row1){
const h=row1.getBoundingClientRect().height;
document.querySelectorAll('.cmp thead tr.sub-hdr th').forEach(th=>th.style.top=h+'px');
}
});
}
// ── Monthly comparison sheet (LB / Provision / Total / Net + cumulative) ────────
function acctLPTN(r,sheetKey){
const lb=sheetKey==='Revenue'?(r.period_credit-r.period_debit):(r.period_debit-r.period_credit);
const prov=r.provision!=null?Number(r.provision):0;
const total=lb+prov;
return{lb,prov,total,net:-total};
}
function getAcctLPTN(data,code,sheetKey){
const r=data.find(x=>x.acct_code===code&&x.sheet===sheetKey);
return r?acctLPTN(r,sheetKey):{lb:0,prov:0,total:0,net:0};
}
function mergedAccts(sheetKey){
const seen=new Set(),result=[];
periodsData.forEach(p=>{
p.data.filter(r=>r.sheet===sheetKey).sort((a,b)=>(a.sort_order||999)-(b.sort_order||999)).forEach(r=>{
if(!seen.has(r.acct_code)){seen.add(r.acct_code);result.push({code:r.acct_code,name:r.acct_name,head:(r.head||'').trim()});}
});
});
return result;
}
function buildMonthlySheet(sheetKey){
const accts=mergedAccts(sheetKey);
if(!accts.length) return null;
const FC=2; // fixed cols: Code, Account Name
const blocks=[];
periodsData.forEach((p,i)=>{
if(i===0) blocks.push({title:p.label,type:'cum',idx:i});
else{ blocks.push({title:p.label,type:'delta',idx:i}); blocks.push({title:'Upto '+p.label,type:'cum',idx:i}); }
});
const hdr1=['Code','Account Name'], hdr2=['',''];
const merges=[{s:{r:0,c:0},e:{r:1,c:0}},{s:{r:0,c:1},e:{r:1,c:1}}];
const blockStarts=[];
let col=FC;
blocks.forEach(b=>{
blockStarts.push(col);
hdr1.push(b.title,'','','');
hdr2.push('LB','Provision','Total','Net');
merges.push({s:{r:0,c:col},e:{r:0,c:col+3}});
col+=4;
});
const blockVals=(b,code)=>{
const cur=getAcctLPTN(periodsData[b.idx].data,code,sheetKey);
if(b.type==='cum') return [cur.lb,cur.prov,cur.total,cur.net];
const prev=getAcctLPTN(periodsData[b.idx-1].data,code,sheetKey);
const lb=cur.lb-prev.lb, prov=cur.prov-prev.prov, total=cur.total-prev.total;
return [lb,prov,total,-total];
};
// Group accounts by head
const headOrder=[],headMap={};
accts.forEach(a=>{
const h=a.head||a.name;
if(!headMap[h]){headMap[h]=[];headOrder.push(h);}
headMap[h].push(a);
});
const rows=[hdr1,hdr2];
const rowTypes=[]; // 'hdr','data','sub','grand' per row (starting from row index 2)
const subRanges=[]; // {headRow, dataStart, dataEnd, subRow} for SUM formulas
let allDataRows=[];
headOrder.forEach(h=>{
const grpAccts=headMap[h];
// Head section header row
const hdrIdx=rows.length;
rows.push([h]);rowTypes.push('hdr');
const dataStart=rows.length;
grpAccts.forEach(a=>{
const row=[a.code,a.name];
blocks.forEach(b=>row.push(...blockVals(b,a.code)));
rows.push(row);rowTypes.push('data');
allDataRows.push(rows.length-1);
});
const dataEnd=rows.length-1;
// Subtotal row for this head
const subRow=['','Total '+h];
blocks.forEach(b=>{
let lb=0,prov=0,total=0;
grpAccts.forEach(a=>{const v=blockVals(b,a.code);lb+=v[0];prov+=v[1];total+=v[2];});
subRow.push(lb,prov,total,-total);
});
rows.push(subRow);rowTypes.push('sub');
subRanges.push({head:h,dataStart,dataEnd,subRow:rows.length-1});
rows.push([]);rowTypes.push('blank'); // blank separator
});
// Grand total row
const grandRow=['','Grand Total'];
blocks.forEach(b=>{
let lb=0,prov=0,total=0;
accts.forEach(a=>{const v=blockVals(b,a.code);lb+=v[0];prov+=v[1];total+=v[2];});
grandRow.push(lb,prov,total,-total);
});
rows.push(grandRow);rowTypes.push('grand');
const totalCols=FC+blocks.length*4;
// Build lookup maps for cross-sheet references
const headSubRows={};subRanges.forEach(s=>{headSubRows[s.head]=s.subRow;});
const acctRows={};
let ai=0;
headOrder.forEach(h=>{headMap[h].forEach(a=>{acctRows[a.code]=allDataRows[ai++];});});
return{rows,merges,cols:totalCols,blockStarts,dataStartRow:2,acctCount:accts.length,rowTypes,subRanges,allDataRows,grandRow:rows.length-1,headSubRows,acctRows};
}
function applyMonthlyFormulas(ws,info){
const ec=XLSX.utils.encode_cell;
function setF(addr,f){const c=ws[addr];const v=c?.v||0;const s=c?.s;ws[addr]={t:'n',v,f};if(s)ws[addr].s=s;}
info.blockStarts.forEach(c=>{
const lbCol=c, prCol=c+1, totCol=c+2, netCol=c+3;
// Data rows: Total=LB+Provision, Net=-Total
(info.allDataRows||[]).forEach(r=>{
const lbA=ec({r,c:lbCol}), prA=ec({r,c:prCol}), totA=ec({r,c:totCol});
setF(totA,`${lbA}+${prA}`);
setF(ec({r,c:netCol}),`-${totA}`);
});
// Subtotal rows: SUM of their data range
(info.subRanges||[]).forEach(({dataStart,dataEnd,subRow})=>{
[lbCol,prCol,totCol,netCol].forEach(cc=>{
setF(ec({r:subRow,c:cc}),`SUM(${ec({r:dataStart,c:cc})}:${ec({r:dataEnd,c:cc})})`);
});
});
// Grand total: SUM of subtotal rows
if(info.grandRow!=null&&(info.subRanges||[]).length){
[lbCol,prCol,totCol,netCol].forEach(cc=>{
const parts=info.subRanges.map(s=>ec({r:s.subRow,c:cc}));
setF(ec({r:info.grandRow,c:cc}),parts.join('+'));
});
}
});
}
// ── Excel styles ──────────────────────────────────────────────────────────────
const XB={top:{style:'thin',color:{rgb:'B0BEC5'}},bottom:{style:'thin',color:{rgb:'B0BEC5'}},left:{style:'thin',color:{rgb:'B0BEC5'}},right:{style:'thin',color:{rgb:'B0BEC5'}}};
const XBD={top:{style:'thin',color:{rgb:'78909C'}},bottom:{style:'thin',color:{rgb:'78909C'}},left:{style:'thin',color:{rgb:'78909C'}},right:{style:'thin',color:{rgb:'78909C'}}};
const XS={
hdr:{font:{bold:true,sz:11,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1A5276'}},alignment:{horizontal:'center',vertical:'center',wrapText:true},border:XBD},
subHdr:{font:{bold:true,sz:10,color:{rgb:'1A5276'}},fill:{fgColor:{rgb:'D6EAF8'}},alignment:{horizontal:'center'},border:XBD},
secHdr:{font:{bold:true,sz:10,color:{rgb:'374151'}},fill:{fgColor:{rgb:'F2F3F4'}},border:XB},
secHdrNum:{font:{bold:true,sz:10,color:{rgb:'374151'}},fill:{fgColor:{rgb:'F2F3F4'}},border:XB,alignment:{horizontal:'right'}},
rev:{font:{bold:true,sz:10,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'0E6655'}},border:XBD},
revNum:{font:{bold:true,sz:10,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'0E6655'}},border:XBD,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}},
revPct:{font:{bold:true,sz:10,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'0E6655'}},border:XBD,alignment:{horizontal:'right'}},
tot:{font:{bold:true,sz:10,color:{rgb:'1A5276'}},fill:{fgColor:{rgb:'EBF5FB'}},border:XBD},
totNum:{font:{bold:true,sz:10,color:{rgb:'1A5276'}},fill:{fgColor:{rgb:'EBF5FB'}},border:XBD,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}},
totPct:{font:{bold:true,sz:10,color:{rgb:'1A5276'}},fill:{fgColor:{rgb:'EBF5FB'}},border:XBD,alignment:{horizontal:'right'}},
gp:{font:{bold:true,sz:10,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1B7A43'}},border:XBD},
gpNum:{font:{bold:true,sz:10,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1B7A43'}},border:XBD,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}},
gpPct:{font:{bold:true,sz:10,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1B7A43'}},border:XBD,alignment:{horizontal:'right'}},
grand:{font:{bold:true,sz:11,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1A1A2E'}},border:XBD},
grandNum:{font:{bold:true,sz:11,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1A1A2E'}},border:XBD,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}},
grandPct:{font:{bold:true,sz:11,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1A1A2E'}},border:XBD,alignment:{horizontal:'right'}},
sub:{font:{sz:10},border:XB},
subNum:{font:{sz:10},border:XB,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}},
subPct:{font:{sz:10},border:XB,alignment:{horizontal:'right'}},
};
function xlsStyleRow(ws,r,maxC,lblStyle,numStyle,pctStyle){
for(let c=0;c<=maxC;c++){
const addr=XLSX.utils.encode_cell({r,c});
if(!ws[addr]) ws[addr]={t:'s',v:''};
if(c===0) ws[addr].s=lblStyle;
else if(c%2===1) ws[addr].s=numStyle; // val col
else ws[addr].s=pctStyle; // pct col
}
}
// ── Excel export ───────────────────────────────────────────────────────────────
document.getElementById('btnExport').addEventListener('click',()=>{
if(!periodsData.length){showToast('Run comparison first','error');return;}
const labels=periodsData.map(p=>p.label);
const nc=periodsData.length, showNet=nc>=2;
const ec=XLSX.utils.encode_cell;
const pR=(v,b)=>b?+(Math.abs(v)/Math.abs(b)*100).toFixed(1):0;
const revNC=showNet?((periodsData[nc-1].pl.rev||0)-(periodsData[0].pl.rev||0)):0;
const wb=XLSX.utils.book_new();
// ── Build monthly detail sheets FIRST (need metadata for cross-sheet refs) ──
const mm={}; // monthlyMeta: key→{label,blockStarts,headSubRows,acctRows,grandRow}
const SDEFS=[['Revenue','Revenue'],['Purchase','Purchases'],['Employee','Employee'],['Factory','Factory Expenses'],
['Admin','Admin'],['SND','S&D'],['Finance','Finance Cost'],['OtherIncome','Other Income'],['Other','Other']];
const detailSheets=[];
SDEFS.forEach(([key,label])=>{
const built=buildMonthlySheet(key);
if(!built)return;
mm[key]={label,blockStarts:built.blockStarts,headSubRows:built.headSubRows,acctRows:built.acctRows,grandRow:built.grandRow};
detailSheets.push({key,label,built});
});
// Cumulative Total column on a detail sheet for period i
function cumTC(meta,i){const bi=i===0?0:2*i;return meta.blockStarts[bi]+2;}
// Cross-sheet ref formula
function sRef(label,r,c){return`'${label}'!${ec({r,c})}`;}
// ── P&L Comparison header (back to simple: Total + %Rev per period) ──
const hdr=['Particulars'];labels.forEach(l=>hdr.push(l,'% Revenue'));
if(showNet)hdr.push(`Net Change (${labels[nc-1]} - ${labels[0]})`,'% Chg');
const rows=[hdr];
const ri={};
function addRow(lbl,vals,tag,noPct){
const r=[lbl];
periodsData.forEach((p,i)=>{r.push(vals[i]||0,noPct?'':pR(vals[i]||0,p.pl.rev)+'%');});
if(showNet){const d=(vals[nc-1]||0)-(vals[0]||0);r.push(d,pR(d,revNC)+'%');}
if(tag)ri[tag]=rows.length;
rows.push(r);
}
function addBlank(){rows.push([]);}
function addSec(lbl){rows.push([lbl]);}
// Track which rows should get cross-sheet formulas
const xRefs=[]; // {row, sheetKey, type:'grand'|'head'|'acct', key}
addRow('Revenue from Operations',periodsData.map(p=>p.pl.rev),'rev');
xRefs.push({tag:'rev',sk:'Revenue',type:'grand'});
addBlank();
addRow('Opening Stock',periodsData.map(p=>p.stock.open),'openStk',true);
addRow('Purchases',periodsData.map(p=>p.pl.purchT),'purch',true);
xRefs.push({tag:'purch',sk:'Purchase',type:'grand'});
addRow('Closing Stock',periodsData.map(p=>p.stock.close),'closeStk',true);
addRow('Consumption of Material',periodsData.map(p=>p.pl.matCost),'matCost');
addRow('Change in Conversion Cost',periodsData.map(p=>p.pl.convChange),'convChg');
addRow('Total Consumption',periodsData.map(p=>p.pl.totalCons),'totalCons');
addBlank();
addRow('Gross Profit',periodsData.map(p=>p.pl.grossP),'grossP');
addBlank();
addSec('Employee Benefit Expenses');
const empS=rows.length;
mergedEmpRows().forEach(({type,label,key})=>{
const vals=periodsData.map(p=>type==='acct'?getAcctVal(p.data,key,'Employee'):getHeadVal(p.data,'Employee',key));
addRow(' '+label,vals);
if(type==='acct') xRefs.push({row:rows.length-1,sk:'Employee',type:'acct',key});
else xRefs.push({row:rows.length-1,sk:'Employee',type:'head',key});
});
const empE=rows.length-1;
addRow('Total Employee Benefit Expenses',periodsData.map(p=>p.pl.empT),'empT');
addBlank();
const secInfo=[];
[['Factory','Factory / Utility Expenses','factT'],['Admin','Administration Expenses','adminT'],['SND','Selling & Distribution Expenses','sndT'],['Finance','Finance Cost','finT']].forEach(([k,l,tag])=>{
addSec(l);
const ss=rows.length;
mergedHeads(k).forEach(h=>{
addRow(' '+h,periodsData.map(p=>getHeadVal(p.data,k,h)));
xRefs.push({row:rows.length-1,sk:k,type:'head',key:h});
});
const se=rows.length-1;
addRow('Total '+l,periodsData.map(p=>p.pl.tot[k]||0),tag);
secInfo.push({tag,ss,se});
addBlank();
});
addRow('Total Expenses',periodsData.map(p=>p.pl.totalExp),'totalExp');
addBlank();
addRow('Net Profit from Operations',periodsData.map(p=>p.pl.netOps),'netOps');
const hasOI=periodsData.some(p=>p.pl.oiT!==0);
if(hasOI){addRow('Other Income',periodsData.map(p=>p.pl.oiT),'oiT');xRefs.push({tag:'oiT',sk:'OtherIncome',type:'grand'});}
addRow('Net Profit before Tax',periodsData.map(p=>p.pl.netTax),'netTax');
const ws=XLSX.utils.aoa_to_sheet(rows);
const valC=i=>1+i*2, netAmtC=1+nc*2;
function setF(r,c,f){const a=ec({r,c});if(!ws[a])return;const s=ws[a].s;ws[a]={t:'n',v:ws[a].v||0,f};if(s)ws[a].s=s;}
function netF(r){if(showNet)setF(r,netAmtC,`${ec({r,c:valC(nc-1)})}-${ec({r,c:valC(0)})}`);}
// ── Cross-sheet reference formulas ──
xRefs.forEach(x=>{
const r=x.row!=null?x.row:ri[x.tag];
const meta=mm[x.sk];if(!meta||r==null)return;
for(let i=0;i<nc;i++){
let refRow;
if(x.type==='grand') refRow=meta.grandRow;
else if(x.type==='head') refRow=meta.headSubRows[x.key];
else if(x.type==='acct') refRow=meta.acctRows[x.key];
if(refRow==null)continue;
setF(r,valC(i),sRef(meta.label,refRow,cumTC(meta,i)));
}
netF(r);
});
// ── Calculated row formulas ──
function calcF(tag,fn){const r=ri[tag];if(r==null)return;for(let i=0;i<nc;i++)setF(r,valC(i),fn(i));netF(r);}
function cRef(tag,i){return ec({r:ri[tag],c:valC(i)});}
calcF('matCost',i=>`${cRef('openStk',i)}+${cRef('purch',i)}-${cRef('closeStk',i)}`);
calcF('totalCons',i=>`${cRef('matCost',i)}+${cRef('convChg',i)}`);
calcF('grossP',i=>`${cRef('rev',i)}-${cRef('totalCons',i)}`);
if(empS<=empE)calcF('empT',i=>`SUM(${ec({r:empS,c:valC(i)})}:${ec({r:empE,c:valC(i)})})`);
secInfo.forEach(({tag,ss,se})=>{if(ss<=se)calcF(tag,i=>`SUM(${ec({r:ss,c:valC(i)})}:${ec({r:se,c:valC(i)})})`);});
calcF('totalExp',i=>{const p=['empT','factT','adminT','sndT','finT'].filter(t=>ri[t]!=null).map(t=>cRef(t,i));return p.join('+');});
calcF('netOps',i=>`${cRef('grossP',i)}-${cRef('totalExp',i)}`);
calcF('netTax',i=>hasOI&&ri.oiT!=null?`${cRef('netOps',i)}+${cRef('oiT',i)}`:`${cRef('netOps',i)}`);
// ── Styles ──
const maxCol=hdr.length-1;
const tagSt={rev:'rev',grossP:'gp',netTax:'grand'};
const totSet=new Set(['matCost','totalCons','empT','factT','adminT','sndT','finT','totalExp','netOps']);
if(hasOI)totSet.add('oiT');
xlsStyleRow(ws,0,maxCol,XS.hdr,XS.hdr,XS.hdr);
for(let r=1;r<rows.length;r++){
const lbl=rows[r][0]||'';
const isEmpty=rows[r].length<=1&&!lbl;
let tag=null;for(const[t,rv] of Object.entries(ri)){if(rv===r){tag=t;break;}}
if(isEmpty){for(let c=0;c<=maxCol;c++){const a=ec({r,c});if(!ws[a])ws[a]={t:'s',v:''};ws[a].s={border:XB};}}
else if(tagSt[tag]){const b=tagSt[tag];xlsStyleRow(ws,r,maxCol,XS[b],XS[b+'Num'],XS[b+'Pct']);}
else if(totSet.has(tag)){xlsStyleRow(ws,r,maxCol,XS.tot,XS.totNum,XS.totPct);}
else if(lbl&&!lbl.startsWith(' ')&&rows[r].length<=1){for(let c=0;c<=maxCol;c++){const a=ec({r,c});if(!ws[a])ws[a]={t:'s',v:''};ws[a].s=c===0?XS.secHdr:XS.secHdrNum;}}
else{xlsStyleRow(ws,r,maxCol,XS.sub,XS.subNum,XS.subPct);}
}
ws['!rows']=[{hpt:32}];
XLSX.utils.book_append_sheet(wb,ws,'P&L Comparison');
const w=[{wch:36}];labels.forEach(()=>w.push({wch:18},{wch:10}));if(showNet)w.push({wch:18},{wch:10});
ws['!cols']=w;
// ── Monthly comparison sheets (styled) ───────────────────────────────────
detailSheets.forEach(({key,label,built})=>{
const ws2=XLSX.utils.aoa_to_sheet(built.rows);
ws2['!merges']=built.merges;
ws2['!cols']=[{wch:14},{wch:30}].concat(Array(built.cols-2).fill({wch:14}));
applyMonthlyFormulas(ws2,built);
// Style monthly sheet
const mc=built.cols-1;
const txtS={font:{sz:10},border:XB};
const numS={font:{sz:10},border:XB,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}};
const subTxtS={font:{bold:true,sz:10,color:{rgb:'1A5276'}},fill:{fgColor:{rgb:'EBF5FB'}},border:XBD};
const subNumS={font:{bold:true,sz:10,color:{rgb:'1A5276'}},fill:{fgColor:{rgb:'EBF5FB'}},border:XBD,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}};
const grandTxtS={font:{bold:true,sz:11,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1A1A2E'}},border:XBD};
const grandNumS={font:{bold:true,sz:11,color:{rgb:'FFFFFF'}},fill:{fgColor:{rgb:'1A1A2E'}},border:XBD,numFmt:'#,##,##0.00',alignment:{horizontal:'right'}};
// Header row 0 (merged titles)
for(let c=0;c<=mc;c++){const a=XLSX.utils.encode_cell({r:0,c});if(!ws2[a])ws2[a]={t:'s',v:''};ws2[a].s=XS.hdr;}
// Header row 1 (LB/Provision/Total/Net)
for(let c=0;c<=mc;c++){const a=XLSX.utils.encode_cell({r:1,c});if(!ws2[a])ws2[a]={t:'s',v:''};ws2[a].s=XS.subHdr;}
// Data/section rows
const rt=built.rowTypes||[];
for(let r=2;r<built.rows.length;r++){
const type=rt[r-2]||'data';
for(let c=0;c<=mc;c++){
const a=XLSX.utils.encode_cell({r,c});
if(!ws2[a])ws2[a]={t:'s',v:''};
if(type==='hdr') ws2[a].s=c===0?XS.secHdr:XS.secHdrNum;
else if(type==='sub') ws2[a].s=c<2?subTxtS:subNumS;
else if(type==='grand') ws2[a].s=c<2?grandTxtS:grandNumS;
else if(type==='blank') ws2[a].s={border:XB};
else ws2[a].s=c<2?txtS:numS;
}
}
ws2['!rows']=[{hpt:28},{hpt:24}];
XLSX.utils.book_append_sheet(wb,ws2,label);
});
XLSX.writeFile(wb,'PL_Comparison.xlsx');
showToast('Excel downloaded','success');
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function setStatus(m,t=''){const d=document.getElementById('statusDot'),x=document.getElementById('statusMsg');if(d)d.className='status-dot'+(t?' '+t:'');if(x)x.textContent=m;}
function setProgress(p){const e=document.getElementById('progressFill');if(e)e.style.width=p+'%';}
function printSection(id,title){
const el=document.getElementById(id);
if(!el||!el.innerHTML.trim()){alert('No data to print.');return;}
const w=window.open('','_blank','width=1200,height=800');
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title||'Print'}</title>
<style>
@page{size:A4 landscape;margin:5mm}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,Helvetica,sans-serif;color:#1a1a2e;padding:1px;zoom:0.82}
h2{font-size:9px;font-weight:700;margin-bottom:3px;color:#004d40;padding-bottom:2px;border-bottom:1px solid #047e7e}
table{border-collapse:collapse;width:100%}
th,td{font-size:7px!important;padding:1.5px 4px!important;white-space:nowrap;line-height:1.25!important}
th{background:#047e7e!important;color:#fff!important;font-weight:700;text-align:right;-webkit-print-color-adjust:exact;print-color-adjust:exact}
th:first-child,td:first-child,.part{text-align:left!important}
td{border-bottom:1px solid #f0f2f4;vertical-align:middle}
td:not(:first-child){text-align:right;font-variant-numeric:tabular-nums}
tr.rev td,tr.tot td{background:#e0f4f4!important;font-weight:700;border-top:1px solid #80cbc4;-webkit-print-color-adjust:exact;print-color-adjust:exact}
tr.gp td{background:#bbf7d0!important;font-weight:800;border-top:2px solid #4ade80;-webkit-print-color-adjust:exact;print-color-adjust:exact}
tr.grand td{background:#004d40!important;color:#fff!important;font-weight:800;-webkit-print-color-adjust:exact;print-color-adjust:exact}
tr.sec-hdr td{background:#f0f4f8!important;font-weight:700;border-top:1px solid #d1dae3;-webkit-print-color-adjust:exact;print-color-adjust:exact}
tr.sub td:first-child{padding-left:12px!important;color:#047e7e}
tr.blank{display:none!important}
div[style*="color:#9ca3af"]{display:none!important}
.neg{color:#dc2626!important}
.cmp-wrap{overflow:visible!important;max-height:none!important}
.part{position:static!important}
thead th:first-child{text-align:left}
</style><link rel="stylesheet" href="/theme.css?v=3"/>
</head><body>
<h2>${title||''}</h2>
${el.innerHTML}
<script>window.onload=()=>{window.print();}<\/script>
</body></html>`);
w.document.close();
}
window.printSection=printSection;
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function fmtDateLong(d){if(!d)return '';const[y,m,day]=d.split('-');const mn=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];return `${parseInt(day)} ${mn[parseInt(m)-1]} ${y}`;}
function showToast(m,t='info'){const e=document.getElementById('toast');e.textContent=m;e.className='toast '+t+' show';setTimeout(()=>e.classList.remove('show'),3500);}
loadSnapshotList();
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,844 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MITRA ERP Portal &mdash; User &amp; Technical Documentation</title>
<style>
@page {
size: A4;
margin: 25mm 20mm 25mm 20mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 11.5pt;
line-height: 1.65;
color: #1a1a1a;
background: #e8e8e8;
}
/* ── Page container ── */
.page {
width: 210mm;
min-height: 297mm;
margin: 12mm auto;
padding: 30mm 25mm 30mm 25mm;
background: #fff;
box-shadow: 0 2px 12px rgba(0,0,0,.12);
page-break-after: always;
position: relative;
}
.page:last-child { page-break-after: auto; }
/* ── Typography ── */
h1 {
font-family: 'Segoe UI', Calibri, Arial, sans-serif;
font-size: 28pt;
font-weight: 700;
color: #1A5276;
margin-bottom: 10px;
letter-spacing: -0.5px;
}
h2 {
font-family: 'Segoe UI', Calibri, Arial, sans-serif;
font-size: 18pt;
font-weight: 700;
color: #1A5276;
margin-top: 22px;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 2px solid #1A5276;
}
h3 {
font-family: 'Segoe UI', Calibri, Arial, sans-serif;
font-size: 13pt;
font-weight: 700;
color: #2471A3;
margin-top: 16px;
margin-bottom: 6px;
}
h4 {
font-family: 'Segoe UI', Calibri, Arial, sans-serif;
font-size: 11.5pt;
font-weight: 600;
color: #1A5276;
margin-top: 10px;
margin-bottom: 4px;
}
p { margin-bottom: 8px; text-align: justify; }
ul, ol { margin-left: 22px; margin-bottom: 10px; }
li { margin-bottom: 4px; }
/* ── Cover page ── */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
min-height: 237mm;
}
.cover-logo {
width: 90px; height: 90px;
border-radius: 18px;
background: #1A5276;
display: flex; align-items: center; justify-content: center;
font-family: 'Segoe UI', Calibri, sans-serif;
font-size: 32pt; font-weight: 800; color: #fff;
margin-bottom: 30px;
box-shadow: 0 4px 16px rgba(26,82,118,.3);
}
.cover h1 {
font-size: 30pt;
border: none;
margin-bottom: 8px;
}
.cover .subtitle {
font-family: 'Segoe UI', Calibri, sans-serif;
font-size: 15pt;
color: #5D6D7E;
font-weight: 400;
margin-bottom: 50px;
}
.cover .date {
font-family: 'Segoe UI', Calibri, sans-serif;
font-size: 12pt;
color: #7F8C8D;
margin-top: 20px;
}
.cover-line {
width: 80px;
height: 3px;
background: #1A5276;
margin: 18px auto 18px auto;
}
.cover .confidential {
font-family: 'Segoe UI', Calibri, sans-serif;
font-size: 10pt;
color: #AAB7B8;
text-transform: uppercase;
letter-spacing: 2px;
margin-top: 40px;
}
/* ── TOC ── */
.toc a {
color: #1a1a1a;
text-decoration: none;
display: flex;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px dotted #ccc;
font-size: 11pt;
}
.toc a:hover { color: #1A5276; }
.toc .toc-section {
font-weight: 700;
font-family: 'Segoe UI', Calibri, sans-serif;
color: #1A5276;
margin-top: 8px;
font-size: 12pt;
}
.toc .toc-sub {
padding-left: 18px;
font-size: 10.5pt;
}
/* ── Tables ── */
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 16px 0;
font-size: 10pt;
}
th {
background: #1A5276;
color: #fff;
font-family: 'Segoe UI', Calibri, sans-serif;
font-weight: 600;
font-size: 10pt;
padding: 8px 10px;
text-align: left;
}
td {
padding: 6px 10px;
border-bottom: 1px solid #e0e0e0;
vertical-align: top;
}
tr:nth-child(even) td { background: #F8F9F9; }
/* ── Code blocks ── */
code {
font-family: Consolas, 'Courier New', monospace;
background: #F2F3F4;
padding: 1px 5px;
border-radius: 3px;
font-size: 10pt;
}
.code-block {
background: #F2F3F4;
border-left: 4px solid #1A5276;
padding: 10px 14px;
font-family: Consolas, 'Courier New', monospace;
font-size: 9.5pt;
line-height: 1.5;
margin: 10px 0 14px 0;
overflow-x: auto;
white-space: pre;
}
/* ── Module cards ── */
.module-box {
border: 1px solid #D5DBDB;
border-radius: 6px;
padding: 14px 16px;
margin: 12px 0;
background: #FDFEFE;
}
.module-box h3 {
margin-top: 0;
font-size: 13pt;
color: #1A5276;
border-bottom: 1px solid #EBF0F0;
padding-bottom: 5px;
margin-bottom: 8px;
}
.module-box .features {
margin-top: 6px;
}
.module-box .features li {
font-size: 10.5pt;
margin-bottom: 3px;
}
.badge {
display: inline-block;
font-family: 'Segoe UI', Calibri, sans-serif;
font-size: 8.5pt;
font-weight: 600;
padding: 2px 8px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-left: 8px;
vertical-align: middle;
}
.badge-route { background: #EBF5FB; color: #1A5276; }
/* ── Footer ── */
.page-footer {
position: absolute;
bottom: 15mm;
left: 25mm;
right: 25mm;
display: flex;
justify-content: space-between;
font-family: 'Segoe UI', Calibri, sans-serif;
font-size: 8.5pt;
color: #AAB7B8;
border-top: 1px solid #E5E8E8;
padding-top: 6px;
}
/* ── Print styles ── */
@media print {
body { background: #fff; }
.page {
width: auto;
margin: 0;
padding: 0;
box-shadow: none;
min-height: auto;
}
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 1: COVER
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<div class="cover">
<div class="cover-logo">B1</div>
<h1>MITRA ERP Portal</h1>
<div class="subtitle">User &amp; Technical Documentation</div>
<div class="cover-line"></div>
<div class="subtitle" style="font-size:13pt; margin-bottom:10px;">Comprehensive Guide for All Modules</div>
<div class="date">June 2026</div>
<div class="confidential">Internal Use &mdash; Confidential</div>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 1</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 2: TABLE OF CONTENTS
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h1 style="font-size:22pt; margin-bottom:20px;">Table of Contents</h1>
<div class="toc">
<a class="toc-section"><span>1. Overview &amp; Architecture</span><span>3</span></a>
<a class="toc-sub"><span>1.1 What is MITRA ERP Portal</span><span>3</span></a>
<a class="toc-sub"><span>1.2 Technology Stack</span><span>3</span></a>
<a class="toc-sub"><span>1.3 Authentication &amp; Access Control</span><span>3</span></a>
<a class="toc-sub"><span>1.4 How to Access</span><span>3</span></a>
<a class="toc-section"><span>2. Dashboard / Home</span><span>4</span></a>
<a class="toc-section"><span>3. Inventory Module</span><span>4</span></a>
<a class="toc-sub"><span>3.1 Bill of Materials (BOM)</span><span>4</span></a>
<a class="toc-sub"><span>3.2 Item Creation &amp; Management</span><span>4</span></a>
<a class="toc-section"><span>4. Production Module</span><span>5</span></a>
<a class="toc-section"><span>5. Purchase Module</span><span>5</span></a>
<a class="toc-section"><span>6. Approvals Module</span><span>6</span></a>
<a class="toc-section"><span>7. Forms &amp; Registration</span><span>6</span></a>
<a class="toc-section"><span>8. Documents</span><span>7</span></a>
<a class="toc-section"><span>9. Projects Module</span><span>7</span></a>
<a class="toc-section"><span>10. Finance Module</span><span>7</span></a>
<a class="toc-section"><span>11. GST Reports Module</span><span>8</span></a>
<a class="toc-section"><span>12. Masters</span><span>8</span></a>
<a class="toc-section"><span>13. Reports Module</span><span>9</span></a>
<a class="toc-section"><span>14. Admin &amp; User Management</span><span>9</span></a>
<a class="toc-section"><span>15. Technical Reference</span><span>10</span></a>
<a class="toc-sub"><span>15.1 Server Setup &amp; Configuration</span><span>10</span></a>
<a class="toc-sub"><span>15.2 API Routes Reference</span><span>10</span></a>
<a class="toc-sub"><span>15.3 Project File Structure</span><span>10</span></a>
<a class="toc-sub"><span>15.4 Deployment &amp; Restart</span><span>10</span></a>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 2</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 3: OVERVIEW & ARCHITECTURE
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>1. Overview &amp; Architecture</h2>
<h3>1.1 What is MITRA ERP Portal</h3>
<p>
The MITRA ERP Portal is a web-based application that provides a modern, browser-accessible interface for interacting with SAP Business One operations. It enables users across departments to perform critical business functions including inventory management, production control, purchase workflows, financial reporting, GST compliance, and customer/vendor registration &mdash; all without requiring direct access to the SAP B1 client application.
</p>
<p>
The portal streamlines operations by providing role-based access, multi-level approval workflows, and real-time data retrieval from the SAP B1 database via SQL Server. It serves as a unified hub for day-to-day business operations, approvals, and reporting.
</p>
<h3>1.2 Technology Stack</h3>
<table>
<tr><th style="width:35%">Component</th><th>Technology</th></tr>
<tr><td>Backend Runtime</td><td>Node.js with Express.js framework</td></tr>
<tr><td>Frontend</td><td>Vanilla HTML, CSS, and JavaScript (no framework)</td></tr>
<tr><td>Database</td><td>Microsoft SQL Server</td></tr>
<tr><td>SAP Integration</td><td>SAP B1 Service Layer REST API + Direct SQL queries</td></tr>
<tr><td>Authentication</td><td>JSON Web Tokens (JWT) with 12-hour expiry</td></tr>
<tr><td>Hosting</td><td>Windows Server, port 5000</td></tr>
<tr><td>UI Library</td><td>Lucide Icons, Space Grotesk font</td></tr>
<tr><td>Validation</td><td>express-validator for input validation</td></tr>
</table>
<h3>1.3 Authentication &amp; Access Control</h3>
<p>
The portal uses JWT token-based authentication. Upon login, users receive a signed token valid for 12 hours. All API endpoints require a valid Bearer token in the Authorization header. The system implements role-based access control with the following roles:
</p>
<ul>
<li><strong>admin</strong> &mdash; Full access to all modules and administrative functions, can bypass approval workflows.</li>
<li><strong>sap_adder</strong> &mdash; Final approver with permission to push records to SAP B1 and manage users.</li>
<li><strong>sr_manager</strong> &mdash; Senior manager with second-level approval authority.</li>
<li><strong>manager</strong> &mdash; First-level approval authority for BOM and customer requests.</li>
<li><strong>project_approver</strong> &mdash; Dedicated role for project approval workflows.</li>
<li><strong>user</strong> &mdash; Standard user with module-based access permissions.</li>
</ul>
<p>
Module-level permissions are assigned per user. The sidebar dynamically shows only the modules each user has access to. Administrators can configure per-user module access from the Admin panel.
</p>
<h3>1.4 How to Access</h3>
<p>Open a web browser and navigate to:</p>
<div class="code-block">http://&lt;server-ip&gt;:5000</div>
<p>Users will see the login page. After authentication, they are redirected to the Home dashboard with the sidebar showing their permitted modules.</p>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 3</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 4: DASHBOARD + INVENTORY
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>2. Dashboard / Home</h2>
<div class="module-box">
<h3>Home Dashboard <span class="badge badge-route">Route: /</span></h3>
<p>
The Home page is the landing page of the MITRA ERP Portal, accessible to all authenticated users. It presents a card-based overview of all available modules, providing quick-access navigation to each functional area. The dashboard dynamically renders module cards based on the user's assigned permissions, ensuring each user sees only the features relevant to their role.
</p>
<p>
The dashboard also provides BOM request statistics (total, pending, approved, SAP-pushed) via the <code>/api/stats</code> endpoint, giving managers and administrators an at-a-glance view of pending workflows.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Dynamic module cards based on user role and permissions</li>
<li>BOM request statistics overview</li>
<li>Quick navigation to all portal modules</li>
<li>Responsive design with mobile bottom navigation bar</li>
</ul>
</div>
<h2>3. Inventory Module</h2>
<div class="module-box">
<h3>3.1 Bill of Materials (BOM) <span class="badge badge-route">Route: /bom</span></h3>
<p>
The BOM module enables users to create and update Bills of Materials in SAP Business One through a multi-level approval workflow. Users can compose a BOM by specifying a parent item, quantity, BOM type (Production, Sales, Assembly, Template, or Disassembly), warehouse, distribution rule, project code, and a list of component items with individual quantities, issue methods, and unit costs.
</p>
<p>
Submitted BOM requests flow through a configurable multi-level approval process (2, 3, or 4 levels) before being pushed to SAP B1 via the Service Layer API. The status flow progresses through DRAFT, PENDING, L1_APPROVED, L2_APPROVED (if 3+ levels), L3_APPROVED (if 4 levels), and finally SAP_PUSHED. Admin users can bypass the approval chain and push BOMs directly to SAP.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Create new BOMs and update existing BOMs in SAP B1</li>
<li>Configurable 2/3/4-level approval workflow (Manager, Senior Manager, SAP Adder)</li>
<li>Component-level detail: item code, quantity, issue method, warehouse, unit cost</li>
<li>Admin direct-push capability for bypassing approvals</li>
<li>Full audit trail with approval log entries</li>
</ul>
</div>
<div class="module-box">
<h3>3.2 Item Creation &amp; Management <span class="badge badge-route">Route: /items</span></h3>
<p>
The Items module provides functionality to create new items in SAP B1 and view existing items. The "Create Item" view (<code>/items</code>) presents a form for entering item master data, while the "View Items" view (<code>/items?list=1</code>) lists all items with search and filter capabilities. Item creation requests go through an approval workflow managed by the Item Approval system.
</p>
<p>
The module integrates with SAP B1 lookups for item groups, warehouses, tax codes, GL accounts, and other master data to ensure consistency with the SAP system.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Create new item master records with full SAP field mapping</li>
<li>View and search existing items</li>
<li>Item approval workflow before SAP push</li>
<li>Auto-populated dropdowns from SAP master data (groups, warehouses, tax codes)</li>
</ul>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 4</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 5: PRODUCTION + PURCHASE
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>4. Production Module</h2>
<div class="module-box">
<h3>Production Order Management <span class="badge badge-route">Routes: /production, /issue-production, /receipt-production, /close-production</span></h3>
<p>
The Production module covers the full lifecycle of production orders in SAP Business One. It is divided into four sub-modules, each handling a specific stage of the production process. Users can create new production orders, issue raw materials to production, receive finished goods from production, and close completed production orders.
</p>
<h4>Sub-modules:</h4>
<ul class="features">
<li><strong>Create Order</strong> (<code>/production</code>) &mdash; Create new production orders by selecting the product item, specifying quantity, planned dates, warehouse, and associated BOM. The system pulls BOM component details from SAP.</li>
<li><strong>Issue for Production</strong> (<code>/issue-production</code>) &mdash; Issue raw materials and components against an existing production order. Records material consumption from the warehouse to the production floor.</li>
<li><strong>Receipt from Production</strong> (<code>/receipt-production</code>) &mdash; Receive finished goods back into inventory upon production completion. Updates stock quantities and records production output.</li>
<li><strong>Close Production</strong> (<code>/close-production</code>) &mdash; Close a production order after all materials have been issued and finished goods received. Finalizes the production cycle and locks the order from further transactions.</li>
</ul>
</div>
<h2>5. Purchase Module</h2>
<div class="module-box">
<h3>Purchase Workflow Management <span class="badge badge-route">Routes: /purchase-request, /purchase-quotation, /purchase-order, /grpo</span></h3>
<p>
The Purchase module provides end-to-end procurement management, covering the complete purchase cycle from initial request through to goods receipt. Each stage is handled by a dedicated sub-module with its own interface and workflow.
</p>
<h4>Sub-modules:</h4>
<ul class="features">
<li><strong>Purchase Request</strong> (<code>/purchase-request</code>) &mdash; Initiate internal purchase requests specifying required items, quantities, preferred vendors, and required delivery dates. Serves as the starting point of the procurement workflow.</li>
<li><strong>Purchase Quotation</strong> (<code>/purchase-quotation</code>) &mdash; Manage vendor quotations received against purchase requests. Compare pricing, terms, and delivery schedules from multiple vendors to select the best offer.</li>
<li><strong>Purchase Order</strong> (<code>/purchase-order</code>) &mdash; Create and manage purchase orders sent to vendors. Generated from approved quotations or created directly, these orders formalize the commitment to purchase goods or services.</li>
<li><strong>Goods Receipt PO (GRPO)</strong> (<code>/grpo</code>) &mdash; Record the receipt of goods against purchase orders. Verifies delivered quantities, updates inventory stock, and triggers accounts payable entries in SAP B1. Supports partial receipts and multi-line document processing.</li>
</ul>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 5</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 6: APPROVALS + FORMS
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>6. Approvals Module</h2>
<div class="module-box">
<h3>6.1 External Approval (Portal Approvals) <span class="badge badge-route">Route: /approvals</span></h3>
<p>
The External Approval module manages the portal's internal approval workflows for BOM requests, customer registrations, and item creation requests. Approvers see a unified queue of pending requests relevant to their role level. The multi-level approval system routes requests through the appropriate chain: Manager (L1), Senior Manager (L2), and SAP Adder (L3/Final), with configurable 2, 3, or 4-level workflows.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Unified approval queue filtered by user's role and approval level</li>
<li>Approve or reject with comments and audit trail</li>
<li>View full request details including components and history</li>
<li>Status tracking: PENDING, L1_APPROVED, L2_APPROVED, SAP_PUSHED, REJECTED</li>
</ul>
</div>
<div class="module-box">
<h3>6.2 SAP Approval <span class="badge badge-route">Route: /sap-approvals</span></h3>
<p>
The SAP Approval module provides an interface for managing SAP Business One's native approval procedures. It surfaces approval requests generated by SAP B1's built-in approval workflow engine, allowing authorized users to review and act on pending SAP documents (such as purchase orders, invoices, and other transactional documents) directly from the web portal without opening the SAP client.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>View pending SAP B1 native approval requests</li>
<li>Approve or reject SAP documents from the web portal</li>
<li>Integration with SAP B1's approval procedure engine</li>
</ul>
</div>
<h2>7. Forms &amp; Registration</h2>
<div class="module-box">
<h3>7.1 Customer Registration <span class="badge badge-route">Route: /register</span></h3>
<p>
The Customer Registration form allows users to submit new customer (Business Partner) records for creation in SAP B1. The form captures comprehensive business partner data including company details, contact information, billing and shipping addresses (with support for multiple addresses), currency preferences, GST/PAN details, MSME information, and file attachments. Submitted registrations go through a verification and approval workflow before being pushed to SAP as a new customer master record.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Multi-step registration form with validation</li>
<li>Two-stage approval: Verification (Manager) then SAP Push (SAP Adder)</li>
<li>Admin direct-approve capability</li>
<li>Multiple billing and shipping address support</li>
<li>GST, PAN, and MSME detail capture</li>
<li>Automatic SAP card code generation with configurable prefix</li>
</ul>
</div>
<div class="module-box">
<h3>7.2 Vendor Registration <span class="badge badge-route">Route: /vendor-register</span></h3>
<p>
The Vendor Registration form mirrors the customer registration process but is tailored for vendor (supplier) business partners. It captures vendor-specific information, AP account mappings, and vendor BP group classifications. Registered vendors follow a similar approval workflow before being created in the SAP B1 system as vendor-type business partners.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Vendor-specific form with AP account configuration</li>
<li>Vendor BP group selection from SAP master data</li>
<li>Approval workflow with verification and final push to SAP</li>
</ul>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 6</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 7: DOCUMENTS + PROJECTS + FINANCE
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>8. Documents</h2>
<div class="module-box">
<h3>Document Management <span class="badge badge-route">Route: /documents</span></h3>
<p>
The Documents module provides a centralized document management interface for uploading, organizing, and retrieving business documents associated with SAP transactions. Users can upload files and attachments, which are stored in the server's <code>/uploads</code> directory and linked to relevant business records. This module serves as a repository for supporting documentation such as purchase orders, invoices, delivery notes, and other transactional evidence.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Upload and manage business documents</li>
<li>File storage in dedicated uploads directory</li>
<li>Document linking to business transactions</li>
</ul>
</div>
<h2>9. Projects Module</h2>
<div class="module-box">
<h3>Project Management <span class="badge badge-route">Routes: /project-form, /project-approvals</span></h3>
<p>
The Projects module enables users to create, view, and manage project records with an integrated approval workflow. The "New Project" view (<code>/project-form?new=1</code>) provides a form for defining new project details, while the "View Projects" view (<code>/project-form?list=1</code>) displays all existing projects with filtering and search. The "Project Approvals" view (<code>/project-approvals</code>) presents a queue for project approvers to review and approve pending project submissions.
</p>
<p>
Project data is managed through a dedicated project store with its own database tables, supporting full CRUD operations and approval state management.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Create new projects with detailed project information</li>
<li>View and filter existing projects</li>
<li>Dedicated project approval workflow with <code>project_approver</code> role</li>
<li>Project-specific data storage and management</li>
</ul>
</div>
<h2>10. Finance Module</h2>
<div class="module-box">
<h3>Financial Reporting &amp; Analysis <span class="badge badge-route">Routes: /costing-pl, /costing-comparison, /cost-sheet, /balance-sheet, /cash-flow, /salary</span></h3>
<p>
The Finance module provides a comprehensive suite of financial reporting tools that pull data directly from the SAP B1 database. It enables management to view and analyze key financial statements and reports without exporting data from the SAP client.
</p>
<h4>Sub-modules:</h4>
<ul class="features">
<li><strong>Monthly Accounts (P&amp;L)</strong> (<code>/costing-pl</code>) &mdash; View monthly profit and loss statements with revenue, cost of goods sold, operating expenses, and net profit calculations. Data is sourced from SAP GL accounts with costing store snapshots for period comparisons.</li>
<li><strong>P&amp;L Comparison</strong> (<code>/costing-comparison</code>) &mdash; Compare profit and loss performance across multiple periods. Enables month-over-month and year-over-year variance analysis for financial planning.</li>
<li><strong>Cost Sheet</strong> (<code>/cost-sheet</code>) &mdash; Detailed cost breakdown analysis showing direct costs, overhead allocations, and cost center-wise expenditure summaries.</li>
<li><strong>Balance Sheet</strong> (<code>/balance-sheet</code>) &mdash; View the company's balance sheet with assets, liabilities, and equity sections. Pulls account balances from SAP GL accounts organized by the chart of accounts hierarchy.</li>
<li><strong>Cash Flow</strong> (<code>/cash-flow</code>) &mdash; Cash flow statement showing operating, investing, and financing activities. Tracks cash inflows and outflows for liquidity management.</li>
<li><strong>Salary</strong> (<code>/salary</code>) &mdash; Salary management and reporting module for processing and viewing employee salary data. Managed through a dedicated salary store with its own data tables.</li>
</ul>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 7</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 8: GST REPORTS + MASTERS
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>11. GST Reports Module</h2>
<div class="module-box">
<h3>11.1 GSTR-1 (Sales Register) <span class="badge badge-route">Route: /gstr1</span></h3>
<p>
The GSTR-1 module generates the outward supply register required for GST filing in India. It pulls sales invoice data from the SAP B1 database and formats it according to the GSTR-1 filing requirements. Users can filter by date range and export the data for upload to the GST portal. The report covers B2B invoices, B2C invoices, credit/debit notes, and other categories as required by the GSTR-1 format.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Automated GSTR-1 report generation from SAP sales data</li>
<li>Date range filtering for specific filing periods</li>
<li>Data formatted for GST portal upload compatibility</li>
<li>Export functionality for filing submissions</li>
</ul>
</div>
<div class="module-box">
<h3>11.2 GSTR-2 Purchase Register <span class="badge badge-route">Route: /gstr2</span></h3>
<p>
The GSTR-2 module generates the inward supply (purchase) register for GST compliance. It extracts purchase invoice data from SAP B1 and presents it in the GSTR-2 format. The module includes TCS (Tax Collected at Source) upload functionality, allowing users to upload TCS data files and reconcile them against purchase records for accurate GST reporting.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Purchase register generation from SAP purchase invoices</li>
<li>TCS data upload and reconciliation</li>
<li>Supplier-wise purchase summary</li>
<li>Export-ready format for GST portal submission</li>
</ul>
</div>
<div class="module-box">
<h3>11.3 ITC-04 Job Work Register <span class="badge badge-route">Route: /itc04</span></h3>
<p>
The ITC-04 module generates the job work register required under GST for tracking goods sent to and received from job workers. It pulls data from SAP B1 related to job work transactions and formats the report according to the ITC-04 filing requirements. This is essential for manufacturers who outsource production processes to third-party job workers.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Job work goods sent and received tracking</li>
<li>ITC-04 formatted report generation</li>
<li>Job worker-wise transaction summary</li>
<li>Compliance reporting for GST authorities</li>
</ul>
</div>
<h2>12. Masters</h2>
<div class="module-box">
<h3>Business Master <span class="badge badge-route">Route: /business-master</span></h3>
<p>
The Business Master module provides an interface for viewing and managing business partner master data from SAP B1. It allows users to search, view, and manage customer and vendor master records, including contact details, address information, financial terms, and classification data. The module serves as a quick-reference tool for business partner information without requiring direct SAP client access.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Search and browse business partner master records</li>
<li>View detailed partner information (contacts, addresses, terms)</li>
<li>Customer and vendor classification management</li>
<li>Integration with SAP B1 business partner master data</li>
</ul>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 8</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 9: REPORTS + ADMIN
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>13. Reports Module</h2>
<div class="module-box">
<h3>13.1 Reports Dashboard <span class="badge badge-route">Route: /reports</span></h3>
<p>
The Reports module provides a centralized reporting dashboard for generating and viewing various business reports from the SAP B1 database. It offers a collection of predefined report templates covering sales, purchases, inventory, and other operational metrics. Users can specify date ranges and filters to generate reports tailored to their analytical needs.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Pre-built report templates for common business queries</li>
<li>Date range and parameter-based filtering</li>
<li>Data sourced directly from SAP B1 SQL database</li>
<li>Export capabilities for further analysis</li>
</ul>
</div>
<div class="module-box">
<h3>13.2 General Ledger Report <span class="badge badge-route">Route: /general-ledger</span></h3>
<p>
The General Ledger Report provides detailed GL account transaction listings from the SAP B1 database. Users can view individual account activity with debits, credits, and running balances for any specified period. The report supports filtering by account code, date range, and other criteria to drill down into specific financial transactions recorded in the general ledger.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Account-wise transaction detail with debit/credit columns</li>
<li>Date range filtering for period-specific reporting</li>
<li>Running balance calculation</li>
<li>Direct SQL queries against SAP B1 GL tables (JDT1/OJDT)</li>
</ul>
</div>
<h2>14. Admin &amp; User Management</h2>
<div class="module-box">
<h3>Administration Panel <span class="badge badge-route">Route: /admin</span></h3>
<p>
The Admin module is the central administration panel for managing portal users, roles, and module access permissions. Accessible only to users with <code>admin</code> or <code>sap_adder</code> roles, it provides a complete user management interface for creating, editing, and deactivating user accounts. Administrators can assign roles, configure per-user module permissions, set approval departments, and link portal users to SAP user IDs.
</p>
<h4>Key Features:</h4>
<ul class="features">
<li>Create new user accounts with username, password, role, and module permissions</li>
<li>Edit existing users: update role, modules, full name, email, and active status</li>
<li>Delete user accounts (with self-deletion protection)</li>
<li>Role assignment: admin, sap_adder, sr_manager, manager, project_approver, user</li>
<li>Granular module-level access control (bom, items, production, purchase, approvals, sap-approvals, vendors, customers, documents, projects, costing, salary, gstr1, gstr2, itc04, business-master, reports, general-ledger)</li>
<li>Approval department assignment for workflow routing</li>
<li>User activity tracking with last login timestamps</li>
</ul>
</div>
<div class="module-box">
<h3>User Profile <span class="badge badge-route">Route: /profile</span></h3>
<p>
Each user has a personal profile page where they can update their display name, email address, and password. The profile page shows current role and module assignments (read-only) and provides a secure password change mechanism that requires the current password for verification before allowing updates.
</p>
</div>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 9</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
PAGE 10: TECHNICAL REFERENCE
═══════════════════════════════════════════════════════════════════ -->
<div class="page">
<h2>15. Technical Reference</h2>
<h3>15.1 Server Setup &amp; Configuration</h3>
<p>The application is a Node.js Express server defined in <code>server.js</code>. It listens on port 5000 (configurable via the <code>PORT</code> environment variable) and binds to <code>0.0.0.0</code> for network-wide access. Configuration is managed through environment variables loaded via <code>dotenv</code>.</p>
<table>
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>PORT</code></td><td>Server port (default: 5000)</td></tr>
<tr><td><code>JWT_SECRET</code></td><td>Secret key for JWT token signing</td></tr>
<tr><td><code>SQL_HOST</code></td><td>SQL Server hostname/IP</td></tr>
<tr><td><code>SQL_PORT</code></td><td>SQL Server port</td></tr>
<tr><td><code>SQL_USER</code></td><td>SQL Server username</td></tr>
<tr><td><code>SQL_PASSWORD</code></td><td>SQL Server password</td></tr>
<tr><td><code>SQL_DATABASE</code></td><td>SAP B1 database name</td></tr>
<tr><td><code>BOM_APPROVAL_LEVELS</code></td><td>Number of BOM approval levels (2, 3, or 4)</td></tr>
<tr><td><code>CUST_APPROVAL_LEVELS</code></td><td>Customer approval levels (default: 2)</td></tr>
</table>
<h3>15.2 API Routes Reference</h3>
<table>
<tr><th>Route Prefix</th><th>Description</th><th>Auth</th></tr>
<tr><td><code>/api/auth</code></td><td>Login, profile, token verification</td><td>Public / Token</td></tr>
<tr><td><code>/api/users</code></td><td>User CRUD (admin)</td><td>sap_adder+</td></tr>
<tr><td><code>/api/bom-requests</code></td><td>BOM create/update/approve/reject</td><td>Token</td></tr>
<tr><td><code>/api/customers</code></td><td>Customer registration &amp; approval</td><td>Public / Token</td></tr>
<tr><td><code>/api/vendors</code></td><td>Vendor registration &amp; management</td><td>Token</td></tr>
<tr><td><code>/api/sap</code></td><td>SAP lookups (items, warehouses, GL, tax, etc.) &amp; GRPO</td><td>Token</td></tr>
<tr><td><code>/api/projects</code></td><td>Project CRUD &amp; approvals</td><td>Token</td></tr>
<tr><td><code>/api/item-approvals</code></td><td>Item creation approval workflow</td><td>Token</td></tr>
<tr><td><code>/api/costing</code></td><td>P&amp;L, monthly accounts, cost analysis</td><td>Token</td></tr>
<tr><td><code>/api/balance-sheet</code></td><td>Balance sheet data</td><td>Token</td></tr>
<tr><td><code>/api/cash-flow</code></td><td>Cash flow statement data</td><td>Token</td></tr>
<tr><td><code>/api/costsheet</code></td><td>Cost sheet report data</td><td>Token</td></tr>
<tr><td><code>/api/salary</code></td><td>Salary data management</td><td>Token</td></tr>
<tr><td><code>/api/gstr1</code></td><td>GSTR-1 outward supply report</td><td>Token</td></tr>
<tr><td><code>/api/gstr2</code></td><td>GSTR-2 purchase register &amp; TCS</td><td>Token</td></tr>
<tr><td><code>/api/itc04</code></td><td>ITC-04 job work report</td><td>Token</td></tr>
<tr><td><code>/api/reports</code></td><td>General business reports</td><td>Token</td></tr>
<tr><td><code>/api/general-ledger</code></td><td>GL account transaction report</td><td>Token</td></tr>
<tr><td><code>/api/business-master</code></td><td>Business partner master data</td><td>Token</td></tr>
<tr><td><code>/api/chat</code></td><td>AI chatbot assistant</td><td>Token</td></tr>
<tr><td><code>/api/config</code></td><td>Portal configuration (approval levels)</td><td>Public</td></tr>
<tr><td><code>/api/health</code></td><td>Health check endpoint</td><td>Public</td></tr>
<tr><td><code>/api/stats</code></td><td>BOM request statistics</td><td>Token</td></tr>
</table>
<h3>15.3 Project File Structure</h3>
<div class="code-block">backend/
server.js Entry point &mdash; Express app, middleware, inline routes
.env Environment variables (SQL, JWT, ports)
routes/ Modular route handlers
sap.js SAP lookups &amp; GRPO
gstr1.js, gstr2.js, itc04.js GST report routes
projects.js Project CRUD &amp; approvals
costing.js P&amp;L and costing routes
balanceSheet.js Balance sheet route
cashFlow.js Cash flow route
salary.js Salary route
costsheet.js Cost sheet route
reports.js General reports
generalLedger.js GL report
vendors.js Vendor management
businessMaster.js Business master data
itemApproval.js Item approval workflow
chatbot.js AI chatbot
services/ Data access &amp; business logic
hanaStore.js Customer data store (SQL)
hanaUsers.js User authentication &amp; management
sapServiceLayer.js SAP B1 Service Layer API client
bomRequestStore.js BOM request persistence
hanaItemStore.js Item data store
hanaVendorStore.js Vendor data store
projectStore.js Project data store
costingStore.js Costing &amp; P&amp;L data store
salaryStore.js Salary data store
cashFlowStore.js Cash flow data store
companyConfig.js Multi-company configuration
sqlPool.js SQL connection pool
middleware/
auth.js JWT verification middleware
public/ Static frontend files
index.html Home/dashboard page
sidebar.js Shared sidebar navigation
*.html Module-specific pages
docs/ Documentation files</div>
<h3>15.4 Deployment &amp; Restart</h3>
<p>To restart the portal server:</p>
<div class="code-block">1. Stop the running process on port 5000
(e.g., taskkill /F /PID &lt;process-id&gt; or stop the service)
2. Navigate to the project directory:
cd d:\Project\backend
3. Start the server:
node server.js</div>
<p>The server will display connection status in the console. Wait for the "Database ready" message to confirm all services are initialized and available.</p>
<div class="page-footer">
<span>MITRA ERP Portal &mdash; Documentation</span>
<span>Page 10</span>
</div>
</div>
</body>
</html>
@@ -0,0 +1,956 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MITRA ERP Portal — User Guide & Feature Overview</title>
<style>
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Calibri, 'Helvetica Neue', Arial, sans-serif;
color: #2C3E50;
background: #fff;
font-size: 11pt;
line-height: 1.7;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ── Page structure ── */
.page {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 40px 50px;
position: relative;
page-break-after: always;
background: #fff;
}
.page:last-child { page-break-after: auto; }
@media screen {
.page {
box-shadow: 0 2px 20px rgba(0,0,0,0.08);
margin-bottom: 30px;
border: 1px solid #e8e8e8;
}
body { background: #f0f2f5; padding: 30px 0; }
}
@media print {
body { background: #fff; }
.page { box-shadow: none; border: none; margin: 0; padding: 30px 40px; }
}
/* ── Footer / Page number ── */
.page-footer {
position: absolute;
bottom: 25px;
left: 50px;
right: 50px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 8.5pt;
color: #95A5A6;
border-top: 1px solid #ECF0F1;
padding-top: 10px;
}
.page-footer .doc-title { font-weight: 600; }
/* ── Cover Page ── */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
min-height: 297mm;
padding: 60px 50px;
}
.cover-logo {
width: 80px; height: 80px;
background: #1A5276;
border-radius: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 28px; font-weight: 800; color: #fff;
letter-spacing: 1px;
margin-bottom: 40px;
box-shadow: 0 8px 30px rgba(26,82,118,0.25);
}
.cover-title {
font-size: 36pt;
font-weight: 700;
color: #1A5276;
letter-spacing: -0.5px;
margin-bottom: 12px;
}
.cover-subtitle {
font-size: 16pt;
font-weight: 400;
color: #5D6D7E;
margin-bottom: 60px;
}
.cover-line {
width: 80px; height: 3px;
background: #3498DB;
border-radius: 2px;
margin-bottom: 60px;
}
.cover-meta {
font-size: 10pt;
color: #7F8C8D;
line-height: 2;
}
.cover-confidential {
margin-top: 50px;
padding: 10px 28px;
border: 1.5px solid #E74C3C;
border-radius: 6px;
font-size: 9pt;
font-weight: 700;
color: #E74C3C;
letter-spacing: 1.5px;
text-transform: uppercase;
}
/* ── Section Headers ── */
h1.section-title {
font-size: 22pt;
font-weight: 700;
color: #1A5276;
border-left: 5px solid #3498DB;
padding-left: 18px;
margin-bottom: 24px;
margin-top: 10px;
line-height: 1.3;
}
h2 {
font-size: 14pt;
font-weight: 700;
color: #1A5276;
margin-top: 28px;
margin-bottom: 12px;
}
h3 {
font-size: 12pt;
font-weight: 600;
color: #2E4053;
margin-top: 20px;
margin-bottom: 8px;
}
/* ── Body text ── */
p { margin-bottom: 12px; }
.intro-text {
font-size: 11.5pt;
color: #5D6D7E;
margin-bottom: 20px;
line-height: 1.8;
}
/* ── Feature lists (checkmark) ── */
ul.features {
list-style: none;
padding: 0;
margin-bottom: 16px;
}
ul.features li {
padding: 5px 0 5px 28px;
position: relative;
line-height: 1.6;
}
ul.features li::before {
content: "\2713";
position: absolute;
left: 0;
top: 5px;
color: #27AE60;
font-weight: 700;
font-size: 13pt;
}
ul.features li strong { color: #1A5276; }
/* ── Tip callout ── */
.tip {
background: #EBF5FB;
border-left: 4px solid #3498DB;
border-radius: 0 8px 8px 0;
padding: 14px 18px;
margin: 18px 0;
font-size: 10.5pt;
}
.tip-label {
font-weight: 700;
color: #2E86C1;
font-size: 10pt;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
/* ── Screenshot placeholder ── */
.screenshot {
width: 100%;
max-width: 600px;
height: 200px;
border: 2px dashed #BDC3C7;
border-radius: 10px;
background: #F8F9FA;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 16px auto;
color: #95A5A6;
font-size: 10pt;
text-align: center;
gap: 6px;
}
.screenshot .cam { font-size: 28pt; opacity: 0.5; }
.screenshot .label { font-weight: 600; color: #7F8C8D; }
.figure-caption {
text-align: center;
font-size: 9pt;
font-style: italic;
color: #7F8C8D;
margin-bottom: 18px;
}
/* ── Tables ── */
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 10.5pt;
}
table thead th {
background: #1A5276;
color: #fff;
font-weight: 600;
padding: 10px 14px;
text-align: left;
font-size: 10pt;
}
table thead th:first-child { border-radius: 6px 0 0 0; }
table thead th:last-child { border-radius: 0 6px 0 0; }
table tbody td {
padding: 9px 14px;
border-bottom: 1px solid #ECF0F1;
vertical-align: top;
}
table tbody tr:nth-child(even) { background: #F8F9FA; }
table tbody tr:hover { background: #EBF5FB; }
/* ── TOC ── */
.toc-list {
list-style: none;
padding: 0;
margin-top: 20px;
}
.toc-list li {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px dotted #D5D8DC;
font-size: 11pt;
}
.toc-list li a {
text-decoration: none;
color: #2C3E50;
font-weight: 500;
}
.toc-list li a:hover { color: #3498DB; }
.toc-list li .toc-page {
font-weight: 600;
color: #1A5276;
flex-shrink: 0;
margin-left: 12px;
}
.toc-section-num {
font-weight: 700;
color: #1A5276;
margin-right: 10px;
min-width: 22px;
display: inline-block;
}
/* ── Workflow steps ── */
.workflow {
display: flex;
align-items: center;
gap: 0;
margin: 20px 0;
flex-wrap: wrap;
justify-content: center;
}
.wf-step {
background: #EBF5FB;
border: 1.5px solid #AED6F1;
border-radius: 8px;
padding: 10px 18px;
font-size: 10pt;
font-weight: 600;
color: #1A5276;
text-align: center;
min-width: 100px;
}
.wf-arrow {
font-size: 16pt;
color: #3498DB;
margin: 0 6px;
font-weight: 700;
}
/* ── Numbered steps ── */
ol.steps {
padding-left: 22px;
margin-bottom: 16px;
}
ol.steps li {
padding: 4px 0;
line-height: 1.7;
}
/* ── Key highlight ── */
.highlight-box {
background: linear-gradient(135deg, #1A5276 0%, #2E86C1 100%);
border-radius: 10px;
padding: 22px 24px;
color: #fff;
margin: 20px 0;
}
.highlight-box h3 { color: #fff; margin-top: 0; }
.highlight-box ul { list-style: none; padding: 0; }
.highlight-box li {
padding: 3px 0 3px 22px;
position: relative;
font-size: 10.5pt;
}
.highlight-box li::before {
content: "\2713";
position: absolute; left: 0; top: 3px;
color: #82E0AA; font-weight: 700;
}
</style>
</head>
<body>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 1: COVER -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page cover">
<div class="cover-logo">B1</div>
<div class="cover-title">MITRA ERP Portal</div>
<div class="cover-subtitle">User Guide &amp; Feature Overview</div>
<div class="cover-line"></div>
<div class="cover-meta">
Version 1.0<br>
June 2026<br>
Prepared for Authorized Users
</div>
<div class="cover-confidential">Confidential &mdash; For Authorized Use Only</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 2: TABLE OF CONTENTS -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page">
<h1 class="section-title">Table of Contents</h1>
<ul class="toc-list">
<li>
<span><span class="toc-section-num">1.</span><a href="#welcome">Welcome &amp; Getting Started</a></span>
<span class="toc-page">Page 3</span>
</li>
<li>
<span><span class="toc-section-num">2.</span><a href="#inventory">Inventory &amp; Production Management</a></span>
<span class="toc-page">Page 4</span>
</li>
<li>
<span><span class="toc-section-num">3.</span><a href="#purchase">Purchase Management</a></span>
<span class="toc-page">Page 5</span>
</li>
<li>
<span><span class="toc-section-num">4.</span><a href="#finance-pl">Finance &mdash; Monthly Accounts &amp; P&amp;L</a></span>
<span class="toc-page">Page 6</span>
</li>
<li>
<span><span class="toc-section-num">5.</span><a href="#finance-bs">Finance &mdash; Balance Sheet, Cash Flow &amp; Salary</a></span>
<span class="toc-page">Page 7</span>
</li>
<li>
<span><span class="toc-section-num">6.</span><a href="#gst">GST Compliance</a></span>
<span class="toc-page">Page 8</span>
</li>
<li>
<span><span class="toc-section-num">7.</span><a href="#reports">Reports &amp; General Ledger</a></span>
<span class="toc-page">Page 9</span>
</li>
<li>
<span><span class="toc-section-num">8.</span><a href="#admin">Administration &amp; Support</a></span>
<span class="toc-page">Page 10</span>
</li>
</ul>
<div class="tip" style="margin-top: 40px;">
<div class="tip-label">About This Guide</div>
This document provides an overview of the MITRA ERP Portal, its features, and step-by-step guidance for everyday use. Each section covers a major area of the portal with descriptions, key benefits, and screenshot placeholders for visual reference.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 2</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 3: WELCOME & GETTING STARTED -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="welcome">
<h1 class="section-title">1. Welcome &amp; Getting Started</h1>
<h2>What is the MITRA ERP Portal?</h2>
<p class="intro-text">
The MITRA ERP Portal is a secure, web-based platform that connects directly to your SAP Business One system. It gives your team a simple, modern interface to manage day-to-day business operations &mdash; from inventory and purchasing to finance, GST compliance, and reporting &mdash; all from any web browser, on any device.
</p>
<h2>How to Access the Portal</h2>
<ol class="steps">
<li>Open your web browser (Google Chrome, Microsoft Edge, or Firefox recommended).</li>
<li>Enter the portal URL provided by your administrator in the address bar.</li>
<li>Enter your <strong>username</strong> and <strong>password</strong> on the login screen.</li>
<li>Click <strong>Sign In</strong> to access the portal.</li>
</ol>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Login Page</span>
</div>
<div class="figure-caption">Figure 1: Login Page</div>
<h2>Navigating the Portal</h2>
<p>
Once logged in, you will see a clean dashboard with a <strong>sidebar menu</strong> on the left. The sidebar organizes all modules into clear groups:
</p>
<ul class="features">
<li><strong>Home Dashboard</strong> &mdash; Your starting point with quick access to key modules</li>
<li><strong>Grouped Menus</strong> &mdash; Modules are organized under categories like Inventory, Production, Purchase, Finance, GST Reports, and more</li>
<li><strong>Collapsible Sections</strong> &mdash; Click any group header to expand or collapse its sub-items, keeping your workspace tidy</li>
<li><strong>Mobile Friendly</strong> &mdash; On phones and tablets, a bottom navigation bar provides quick access to frequently used modules</li>
<li><strong>User Profile</strong> &mdash; Your name and role are visible at the bottom of the sidebar; click to view your profile</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Dashboard / Home Page</span>
</div>
<div class="figure-caption">Figure 2: Dashboard / Home Page</div>
<div class="tip">
<div class="tip-label">Tip</div>
You can collapse the sidebar on desktop by clicking the menu icon at the top-right of the sidebar. This gives you more screen space when working with large tables or reports.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 3</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 4: INVENTORY & PRODUCTION MANAGEMENT -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="inventory">
<h1 class="section-title">2. Inventory &amp; Production Management</h1>
<p class="intro-text">
Manage your entire product lifecycle &mdash; from defining what goes into each product, to creating and tracking production orders, issuing materials, and receiving finished goods. Everything stays in sync with SAP Business One in real time.
</p>
<h2>Bill of Materials (BOM)</h2>
<p>
The BOM module lets you define the exact components, quantities, and structure needed to manufacture each product.
</p>
<ul class="features">
<li><strong>Create &amp; Edit BOMs</strong> &mdash; Define parent items and their component materials with precise quantities</li>
<li><strong>Multi-Level Approval Workflow</strong> &mdash; BOMs go through a structured approval process before they become active, ensuring accuracy and accountability</li>
<li><strong>Version Control</strong> &mdash; Track changes to BOMs over time so you always know what was used in past production runs</li>
</ul>
<h2>Item Management</h2>
<ul class="features">
<li><strong>Create New Items</strong> &mdash; Add new products, raw materials, or components to your inventory with all required details</li>
<li><strong>View Item List</strong> &mdash; Browse and search your complete item catalog with filters for quick lookup</li>
<li><strong>Real-Time Stock Visibility</strong> &mdash; See current stock levels pulled directly from SAP Business One</li>
</ul>
<h2>Production Orders</h2>
<p>
Track every stage of your manufacturing process from start to finish:
</p>
<ul class="features">
<li><strong>Create Production Orders</strong> &mdash; Initiate production runs for specific items and quantities</li>
<li><strong>Issue Materials</strong> &mdash; Record when raw materials are issued from inventory to the production floor</li>
<li><strong>Receive Finished Goods</strong> &mdash; Log completed products back into inventory once manufacturing is done</li>
<li><strong>Close Orders</strong> &mdash; Finalize production orders to complete the cycle and update all records</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: BOM Page</span>
</div>
<div class="figure-caption">Figure 3: Bill of Materials (BOM) Management</div>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Production Order</span>
</div>
<div class="figure-caption">Figure 4: Production Order Workflow</div>
<div class="tip">
<div class="tip-label">Tip</div>
Use the production workflow in order: Create Order &rarr; Issue Materials &rarr; Receive Finished Goods &rarr; Close Order. Each step is available as a separate menu item under the Production group for clarity.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 4</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 5: PURCHASE MANAGEMENT -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="purchase">
<h1 class="section-title">3. Purchase Management</h1>
<p class="intro-text">
The Purchase module covers your entire procurement workflow &mdash; from requesting materials to receiving goods at your warehouse. Every step is tracked and linked to SAP Business One for seamless record-keeping.
</p>
<h2>End-to-End Procurement Workflow</h2>
<div class="workflow">
<div class="wf-step">Purchase<br>Request</div>
<span class="wf-arrow">&#10132;</span>
<div class="wf-step">Vendor<br>Quotation</div>
<span class="wf-arrow">&#10132;</span>
<div class="wf-step">Purchase<br>Order</div>
<span class="wf-arrow">&#10132;</span>
<div class="wf-step">Goods Receipt<br>(GRPO)</div>
</div>
<h2>Purchase Request</h2>
<ul class="features">
<li><strong>Raise Requests</strong> &mdash; Any authorized user can submit a purchase request for materials or services needed</li>
<li><strong>Approval Routing</strong> &mdash; Requests are routed to the appropriate approvers based on your company's workflow</li>
<li><strong>Track Status</strong> &mdash; See whether a request is pending, approved, or converted into a purchase order</li>
</ul>
<h2>Purchase Quotation</h2>
<ul class="features">
<li><strong>Compare Vendors</strong> &mdash; Request and compare quotations from multiple vendors side by side</li>
<li><strong>Best Price Selection</strong> &mdash; Easily identify the most competitive offer to make informed procurement decisions</li>
</ul>
<h2>Purchase Order</h2>
<ul class="features">
<li><strong>Create Orders</strong> &mdash; Generate purchase orders directly from approved requests or quotations</li>
<li><strong>Track Delivery</strong> &mdash; Monitor order status and expected delivery dates</li>
<li><strong>SAP Integration</strong> &mdash; Orders are automatically synced to SAP Business One for accurate financial tracking</li>
</ul>
<h2>Goods Receipt PO (GRPO)</h2>
<ul class="features">
<li><strong>Record Goods Received</strong> &mdash; When materials arrive at your warehouse, record the receipt against the original purchase order</li>
<li><strong>Quantity Verification</strong> &mdash; Confirm received quantities match what was ordered, flag discrepancies immediately</li>
<li><strong>Inventory Update</strong> &mdash; Stock levels are automatically updated in SAP Business One upon receipt</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Purchase Request Form</span>
</div>
<div class="figure-caption">Figure 5: Purchase Request</div>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Purchase Order</span>
</div>
<div class="figure-caption">Figure 6: Purchase Order</div>
<div class="tip">
<div class="tip-label">Tip</div>
When creating a Purchase Order, you can pull in items directly from an approved Purchase Request. This saves time and avoids manual re-entry of item details.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 5</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 6: FINANCE — MONTHLY ACCOUNTS & P&L -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="finance-pl">
<h1 class="section-title">4. Finance &mdash; Monthly Accounts &amp; P&amp;L</h1>
<p class="intro-text">
Get a complete view of your company's financial performance. The Monthly Accounts module brings your Profit &amp; Loss statement to life with interactive tools for provisions, snapshots, and period comparisons.
</p>
<h2>Monthly Accounts (Profit &amp; Loss)</h2>
<ul class="features">
<li><strong>View P&amp;L by Period</strong> &mdash; Select any month and year to see a detailed Profit &amp; Loss statement pulled from SAP Business One</li>
<li><strong>Categorized Breakdown</strong> &mdash; Expenses and income are organized into clear categories: Revenue, Purchases, Employee Costs, Factory Expenses, Administrative Costs, Selling &amp; Distribution, and Finance Charges</li>
<li><strong>Save &amp; Load Snapshots</strong> &mdash; Save a snapshot of your P&amp;L at any point in time and reload it later for reference or audit purposes</li>
<li><strong>Search &amp; Filter</strong> &mdash; Quickly find specific accounts or line items within your P&amp;L using the built-in search bar</li>
</ul>
<h2>Provision Management</h2>
<ul class="features">
<li><strong>Enter Provisions</strong> &mdash; Add estimated provisions for specific accounts directly within the P&amp;L view</li>
<li><strong>Track by Period</strong> &mdash; Manage provisions on a month-by-month basis to ensure accurate financial reporting</li>
<li><strong>Instant Recalculation</strong> &mdash; Totals and net profit update automatically as you enter or modify provisions</li>
</ul>
<h2>P&amp;L Comparison</h2>
<ul class="features">
<li><strong>Side-by-Side Analysis</strong> &mdash; Compare Profit &amp; Loss statements across multiple periods to spot trends and changes</li>
<li><strong>Trend Identification</strong> &mdash; Easily see which expense categories are growing or shrinking over time</li>
<li><strong>Variance Highlighting</strong> &mdash; Significant changes between periods are visually highlighted for quick attention</li>
</ul>
<h2>Excel Export</h2>
<ul class="features">
<li><strong>One-Click Download</strong> &mdash; Export your P&amp;L comparison to a fully formatted Excel file with a single click</li>
<li><strong>Detailed Breakdown</strong> &mdash; The exported file includes monthly breakdowns by category with formulas for easy further analysis</li>
<li><strong>Professional Formatting</strong> &mdash; Exported spreadsheets are formatted and ready to share with management or auditors</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Monthly P&amp;L Statement</span>
</div>
<div class="figure-caption">Figure 7: Monthly Profit &amp; Loss Statement</div>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: P&amp;L Comparison View</span>
</div>
<div class="figure-caption">Figure 8: P&amp;L Period Comparison</div>
<div class="tip">
<div class="tip-label">Tip</div>
Save a snapshot of your P&amp;L at the end of each month before making adjustments. This gives you a reliable audit trail and makes it easy to compare "before" and "after" figures.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 6</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 7: FINANCE — BALANCE SHEET, CASH FLOW & SALARY -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="finance-bs">
<h1 class="section-title">5. Finance &mdash; Balance Sheet, Cash Flow &amp; Salary</h1>
<p class="intro-text">
Beyond the P&amp;L, the portal provides a complete suite of financial tools to help you understand your company's overall financial health, liquidity position, and employee costs.
</p>
<h2>Balance Sheet</h2>
<ul class="features">
<li><strong>Assets, Liabilities &amp; Equity</strong> &mdash; View your company's financial position at a glance with a structured balance sheet</li>
<li><strong>Period Selection</strong> &mdash; Choose any date to see the balance sheet as of that point in time</li>
<li><strong>Drill-Down</strong> &mdash; Click on any category to see the detailed accounts that make up each total</li>
</ul>
<h2>Cash Flow Statement</h2>
<ul class="features">
<li><strong>Track Cash Movements</strong> &mdash; See where your cash is coming from and where it is going</li>
<li><strong>Operating, Investing &amp; Financing</strong> &mdash; Cash flows are categorized into standard sections for clear analysis</li>
<li><strong>Liquidity Planning</strong> &mdash; Use cash flow data to plan for upcoming expenses and investments</li>
</ul>
<h2>Cost Sheet</h2>
<ul class="features">
<li><strong>Product Costing Analysis</strong> &mdash; Break down the true cost of manufacturing each product</li>
<li><strong>Material &amp; Overhead Costs</strong> &mdash; See how raw material costs, labor, and overheads contribute to your product cost</li>
<li><strong>Pricing Decisions</strong> &mdash; Use accurate cost data to set competitive and profitable pricing</li>
</ul>
<h2>Salary Management</h2>
<ul class="features">
<li><strong>Process Salaries</strong> &mdash; Manage monthly salary processing for all employees</li>
<li><strong>View Salary Details</strong> &mdash; Access detailed salary breakdowns including components, deductions, and net pay</li>
<li><strong>Financial Integration</strong> &mdash; Salary data flows directly into your financial statements for accurate cost reporting</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Balance Sheet</span>
</div>
<div class="figure-caption">Figure 9: Balance Sheet View</div>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Salary Management Page</span>
</div>
<div class="figure-caption">Figure 10: Salary Management</div>
<div class="tip">
<div class="tip-label">Tip</div>
Use the Cost Sheet alongside the P&amp;L to get a complete picture of profitability. The Cost Sheet shows you per-product margins, while the P&amp;L gives you the overall company view.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 7</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 8: GST COMPLIANCE -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="gst">
<h1 class="section-title">6. GST Compliance</h1>
<p class="intro-text">
Stay compliant with India's GST regulations effortlessly. The portal automates data collection from SAP Business One and provides tools to prepare, review, and file your GST returns directly.
</p>
<h2>GSTR-1 (Sales Register)</h2>
<ul class="features">
<li><strong>Auto-Generated Sales Data</strong> &mdash; Your sales transactions are automatically pulled from SAP and formatted for GSTR-1 filing</li>
<li><strong>Review Before Filing</strong> &mdash; Check all entries for accuracy before submitting to the GST portal</li>
<li><strong>Export Options</strong> &mdash; Download your GSTR-1 data for offline review or backup</li>
</ul>
<h2>GSTR-2 Purchase Register</h2>
<p>
The GSTR-2 module is your most powerful GST tool, offering a complete purchase register with multiple views and editing capabilities:
</p>
<ul class="features">
<li><strong>Auto-Fetch from SAP</strong> &mdash; Purchase data is pulled directly from SAP Business One, saving hours of manual data entry</li>
<li><strong>Multiple Tabs for Different Transaction Types:</strong>
<ul style="list-style:none; padding-left:20px; margin-top:6px;">
<li style="padding:3px 0;"><strong>B2B</strong> &mdash; Business-to-business purchases from registered vendors</li>
<li style="padding:3px 0;"><strong>IMPG</strong> &mdash; Import of goods transactions</li>
<li style="padding:3px 0;"><strong>B2BUR</strong> &mdash; Purchases from unregistered vendors</li>
<li style="padding:3px 0;"><strong>HSN Summary</strong> &mdash; Summary of purchases by HSN (Harmonized System of Nomenclature) codes</li>
<li style="padding:3px 0;"><strong>CDN</strong> &mdash; Credit and debit notes</li>
</ul>
</li>
<li><strong>Inline Editing</strong> &mdash; Click on any row to edit values directly in the table without opening a separate form</li>
<li><strong>Add New Rows</strong> &mdash; Manually add entries that may not be in SAP, such as adjustments or corrections</li>
<li><strong>Import from Excel</strong> &mdash; Bulk-upload purchase data from Excel spreadsheets for faster processing</li>
<li><strong>One-Click TCS GSP Upload</strong> &mdash; Submit your finalized data directly to the TCS GSP portal for official filing</li>
</ul>
<h2>ITC-04 (Job Work Tracking)</h2>
<ul class="features">
<li><strong>Track Goods Sent to Job Workers</strong> &mdash; Record materials sent out for processing or manufacturing by third parties</li>
<li><strong>Monitor Returns</strong> &mdash; Track when processed goods are returned from job workers</li>
<li><strong>Input Tax Credit Compliance</strong> &mdash; Ensure proper documentation for claiming Input Tax Credit on job work transactions</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: GSTR-2 Purchase Register with Tabs</span>
</div>
<div class="figure-caption">Figure 11: GSTR-2 Purchase Register</div>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: TCS GSP Upload Confirmation</span>
</div>
<div class="figure-caption">Figure 12: TCS GSP Upload</div>
<div class="tip">
<div class="tip-label">Tip</div>
Always review the summary chips at the top of the GSTR-2 page before uploading. They show you the total taxable value, IGST, CGST, SGST, and total tax &mdash; verify these match your expectations before filing.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 8</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 9: REPORTS & GENERAL LEDGER -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="reports">
<h1 class="section-title">7. Reports &amp; General Ledger</h1>
<p class="intro-text">
Access the data you need to make informed decisions. The Reports section provides a central hub for business reports, and the General Ledger gives you detailed transaction-level visibility into every account.
</p>
<h2>Reports Dashboard</h2>
<ul class="features">
<li><strong>Central Report Hub</strong> &mdash; Access all available business reports from a single, organized dashboard</li>
<li><strong>Quick Access</strong> &mdash; Find and run the report you need in just a few clicks</li>
<li><strong>Multiple Report Types</strong> &mdash; Inventory reports, financial summaries, production analysis, and more</li>
</ul>
<h2>General Ledger Report</h2>
<p>
The General Ledger is one of the most powerful reporting tools in the portal. It lets you see every transaction that has been posted to any account in your Chart of Accounts.
</p>
<h3>How to Use the General Ledger</h3>
<ol class="steps">
<li>Click <strong>"Select Accounts"</strong> to open the account picker.</li>
<li>Browse accounts by category &mdash; Assets, Liabilities, Equity, Revenue, or Expenditure &mdash; using the filter tabs.</li>
<li>Check the accounts you want to include in the report. You can select multiple accounts at once.</li>
<li>Set your <strong>date range</strong> (From Date and To Date).</li>
<li>Click <strong>"Load Report"</strong> to generate the ledger.</li>
</ol>
<h3>What You Will See</h3>
<ul class="features">
<li><strong>Transactions Grouped by Account</strong> &mdash; Each account appears as a section with its opening balance, individual transactions, and closing balance</li>
<li><strong>Running Cumulative Balance</strong> &mdash; A running total column shows how your balance changes with each transaction</li>
<li><strong>Debit &amp; Credit Columns</strong> &mdash; Clear separation of debits and credits for every journal entry</li>
<li><strong>Transaction Details</strong> &mdash; See posting dates, document references, remarks, and amounts for full traceability</li>
</ul>
<h3>Export &amp; Print</h3>
<ul class="features">
<li><strong>Download to Excel</strong> &mdash; Export the complete ledger with professional formatting, account headers, and totals preserved</li>
<li><strong>Print-Friendly Output</strong> &mdash; Generate a clean, printer-ready version of the ledger for physical filing or distribution</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: General Ledger Account Picker</span>
</div>
<div class="figure-caption">Figure 13: Selecting Accounts for the General Ledger</div>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: General Ledger Report with Transactions</span>
</div>
<div class="figure-caption">Figure 14: General Ledger Report</div>
<div class="tip">
<div class="tip-label">Tip</div>
Use the category filter tabs in the account picker to quickly narrow down to the type of accounts you need. For example, select "Revenue" to see only income-related accounts, or "Expenditure" for expense accounts.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 9</span>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE 10: ADMINISTRATION & SUPPORT -->
<!-- ══════════════════════════════════════════════════════════════════════════ -->
<div class="page" id="admin">
<h1 class="section-title">8. Administration &amp; Support</h1>
<p class="intro-text">
The Administration module gives authorized administrators full control over who can access the portal, what they can do, and how approval workflows operate. This ensures your business data stays secure and processes remain well-governed.
</p>
<h2>User Management</h2>
<ul class="features">
<li><strong>Create User Accounts</strong> &mdash; Add new users and assign them a username, password, and role</li>
<li><strong>Activate or Deactivate Users</strong> &mdash; Quickly enable or disable access without deleting any records</li>
<li><strong>View All Users</strong> &mdash; See a complete list of all portal users with their roles, status, and last activity</li>
</ul>
<h2>Roles &amp; Permissions</h2>
<p>
The portal uses a role-based access system. Each user is assigned one of the following roles:
</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Description</th>
<th>Key Capabilities</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Admin</strong></td>
<td>Full system administrator</td>
<td>Manage users, assign roles, configure modules, access all areas, approve/reject requests</td>
</tr>
<tr>
<td><strong>Manager</strong></td>
<td>Department or team manager</td>
<td>Access assigned modules, approve purchase requests and BOMs, view reports and financial data</td>
</tr>
<tr>
<td><strong>User</strong></td>
<td>Standard team member</td>
<td>Access assigned modules, create and submit requests, view own data and assigned reports</td>
</tr>
</tbody>
</table>
<h2>Module Access Control</h2>
<ul class="features">
<li><strong>Granular Access</strong> &mdash; Administrators can control exactly which modules each user can see and use</li>
<li><strong>Module Assignment</strong> &mdash; Assign modules individually (e.g., give a user access to Purchase and Inventory but not Finance)</li>
<li><strong>Instant Updates</strong> &mdash; When an admin changes a user's module access, the change takes effect immediately &mdash; no need for the user to log out and back in</li>
</ul>
<h2>Approval Workflows</h2>
<ul class="features">
<li><strong>External Approvals</strong> &mdash; Review and approve pending requests from within the portal, including BOMs and purchase requests</li>
<li><strong>SAP Approvals</strong> &mdash; Handle approvals that originate from SAP Business One directly within the portal interface</li>
<li><strong>Project Approvals</strong> &mdash; Approve or reject project submissions through a dedicated project approval workflow</li>
<li><strong>Audit Trail</strong> &mdash; Every approval or rejection is logged with the user, date, and any comments for accountability</li>
</ul>
<div class="screenshot">
<span class="cam">&#128247;</span>
<span class="label">Insert Screenshot: Admin Panel &mdash; User Management</span>
</div>
<div class="figure-caption">Figure 15: Administration Panel</div>
<div class="highlight-box">
<h3>Additional Portal Features</h3>
<ul>
<li><strong>Documents Module</strong> &mdash; Store and access important business documents in a central repository</li>
<li><strong>Vendor &amp; Customer Registration</strong> &mdash; Register new vendors and customers directly through the portal using structured forms</li>
<li><strong>Project Management</strong> &mdash; Create new projects, track their status, and manage project approvals</li>
<li><strong>Business Master Data</strong> &mdash; Manage master records that drive your business operations</li>
<li><strong>Built-in AI Chatbot</strong> &mdash; Get quick help and answers through the AI-powered assistant available on every page</li>
</ul>
</div>
<h2 style="margin-top: 28px;">Need Help?</h2>
<p>
If you experience any issues or have questions about using the portal, please contact your system administrator or reach out to your IT support team. When reporting an issue, include:
</p>
<ul class="features">
<li>The page or module where the issue occurred</li>
<li>A brief description of what you were trying to do</li>
<li>Any error messages that appeared on screen</li>
<li>Your username and the approximate time of the issue</li>
</ul>
<div class="tip">
<div class="tip-label">Tip</div>
Look for the AI chatbot icon in the bottom-right corner of any page. It can answer common questions about how to use portal features and help guide you through tasks.
</div>
<div class="page-footer">
<span class="doc-title">MITRA ERP Portal &mdash; User Guide</span>
<span>Page 10</span>
</div>
</div>
</body>
</html>
+472
View File
@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>View Documents — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<style>
:root{
--teal:#047e7e;--teal2:#035b5b;--teal3:#06b6b6;
--blue:#1d4ed8;--amber:#d97706;--green:#059669;--red:#dc2626;
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
--bg:#f0f2f5;--surface:#fff;--border:#e5e7eb;--border2:#d1d5db;
--font:'Space Grotesk',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:20px 24px 40px}
/* ── Cards ─────────────────────────────────────────────── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,.05)}
.card-hd{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
.card-hd-icon{width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#ecfeff,#cffafe);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.card-hd-title{font-size:14px;font-weight:800;color:var(--text)}
.card-hd .right{margin-left:auto;font-size:11px;font-weight:700;color:var(--text3);background:var(--bg);padding:3px 10px;border-radius:20px;border:1px solid var(--border)}
.card-bd{padding:16px 20px}
/* ── Form ───────────────────────────────────────────────── */
.fg{margin-bottom:12px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:5px;display:block}
.fi{width:100%;padding:9px 12px;border:1.5px solid var(--border);border-radius:9px;font-size:13px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none;height:38px;transition:border .15s,box-shadow .15s}
.fi:focus{border-color:var(--teal);box-shadow:0 0 0 3px rgba(4,126,126,.1)}
.fi::placeholder{color:var(--text4)}
select.fi{cursor:pointer;appearance:none;background:var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E") no-repeat right 12px center;padding-right:30px}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:500px){.fi-row{grid-template-columns:1fr}}
/* ── Type chips ─────────────────────────────────────────── */
.type-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:4px}
.type-tab{padding:6px 13px;border-radius:20px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;border:1.5px solid var(--border);background:none;font-family:var(--font);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:5px}
.type-tab:hover{color:var(--text2);border-color:var(--border2);background:var(--bg)}
.type-tab.on{color:var(--teal);border-color:var(--teal3);background:#ecfeff}
/* ── Buttons ────────────────────────────────────────────── */
.btn{padding:9px 18px;border-radius:9px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px}
.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:linear-gradient(135deg,#047e7e,#035b5b);color:#fff}
.b-primary:hover:not(:disabled){box-shadow:0 4px 14px rgba(4,126,126,.35);transform:translateY(-1px)}
.b-cancel{background:var(--bg);color:var(--text2);border:1.5px solid var(--border)}
.b-cancel:hover:not(:disabled){border-color:var(--border2);background:#fff}
/* ── Document cards ─────────────────────────────────────── */
.doc-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 16px 12px 20px;margin-bottom:8px;cursor:pointer;transition:all .18s;position:relative;overflow:hidden}
.doc-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;border-radius:12px 0 0 12px}
.doc-card.purchase::before{background:var(--teal)}
.doc-card.sales::before{background:var(--blue)}
.doc-card.finance::before{background:var(--amber)}
.doc-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.07);border-color:var(--teal3)}
.doc-top{display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap}
.doc-num{font-family:monospace;font-size:13px;color:var(--teal);font-weight:700;letter-spacing:.3px}
.doc-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:6px;white-space:nowrap}
.doc-badge.open{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
.doc-badge.closed{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}
.doc-badge.attach{background:#eff6ff;color:var(--blue);border:1px solid #bfdbfe}
.doc-name{font-size:13px;font-weight:600;color:var(--text);margin-bottom:3px}
.doc-meta{font-size:11px;color:var(--text3);display:flex;gap:10px;flex-wrap:wrap}
.doc-meta b{color:var(--text2);font-weight:600}
/* ── Empty / Spinner ────────────────────────────────────── */
.empty-msg{text-align:center;padding:36px;color:var(--text3);font-size:13px}
.sp{width:14px;height:14px;border:2px solid rgba(4,126,126,.25);border-top-color:var(--teal);border-radius:50%;animation:spin .6s linear infinite;display:inline-block;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── Toast ──────────────────────────────────────────────── */
.toast-c{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.15);display:flex;align-items:center;gap:8px;max-width:300px;pointer-events:auto;animation:toastIn .25s ease}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes toastIn{from{transform:translateY(12px);opacity:0}to{transform:translateY(0);opacity:1}}
/* ── Modal ──────────────────────────────────────────────── */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:20px;overflow-y:auto;backdrop-filter:blur(3px)}
.modal-bg.show{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:18px;width:100%;max-width:740px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.18);animation:mIn .22s ease}
@keyframes mIn{from{transform:translateY(24px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal-hd{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px}
.modal-hd-left{display:flex;align-items:center;gap:10px;min-width:0}
.modal-hd-icon{width:36px;height:36px;border-radius:9px;background:#ecfeff;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.modal-hd-info{min-width:0}
.modal-hd-title{font-size:13px;font-weight:800;color:var(--text);line-height:1}
.modal-hd-num{font-family:monospace;font-size:12px;font-weight:700;color:var(--teal);margin-top:2px}
.modal-close{width:30px;height:30px;border-radius:8px;border:1.5px solid var(--border);background:var(--bg);color:var(--text3);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;line-height:1;transition:all .15s;flex-shrink:0}
.modal-close:hover{background:#fee2e2;color:var(--red);border-color:#fca5a5}
.modal-bd{padding:16px 20px;max-height:74vh;overflow-y:auto}
.modal-bd::-webkit-scrollbar{width:4px}
.modal-bd::-webkit-scrollbar-thumb{background:var(--border2);border-radius:4px}
/* Detail rows */
.dt-section{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin:14px 0 8px;padding-bottom:5px;border-bottom:2px solid var(--border)}
.dt-grid{display:grid;grid-template-columns:1fr 1fr;gap:0;border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:4px}
.dt-row{padding:8px 12px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
.dt-row:nth-last-child(-n+2){border-bottom:none}
.dt-row.full{grid-column:1/-1}
.dt-l{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3)}
.dt-v{font-size:13px;font-weight:600;color:var(--text)}
/* Lines table */
.lines-wrap{border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-top:4px}
.lines-tbl{width:100%;border-collapse:collapse;font-size:11px}
.lines-tbl th{background:#f9fafb;padding:7px 10px;text-align:left;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);border-bottom:1px solid var(--border);white-space:nowrap}
.lines-tbl td{padding:7px 10px;border-bottom:1px solid #f3f4f6;color:var(--text2);vertical-align:middle}
.lines-tbl tr:last-child td{border-bottom:none}
.lines-tbl tr:hover td{background:#f9fafb}
.td-mono{font-family:monospace;color:var(--teal);font-weight:600}
.td-right{text-align:right;font-family:monospace}
.td-grn{color:var(--green);font-family:monospace}
.td-red{color:var(--red);font-family:monospace}
/* Attachment */
.att-list{display:flex;flex-direction:column;gap:6px;margin-top:4px}
.att-row{display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:10px}
.att-icon{font-size:22px;flex-shrink:0}
.att-info{flex:1;min-width:0}
.att-name{font-size:12px;font-weight:700;color:var(--text)}
.att-sub{font-size:10px;color:var(--text3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.att-btn{padding:5px 13px;border-radius:7px;font-size:11px;font-weight:700;background:#eff6ff;color:var(--blue);border:1px solid #bfdbfe;cursor:pointer;font-family:var(--font);transition:all .15s;white-space:nowrap;flex-shrink:0}
.att-btn:hover{background:#dbeafe;border-color:#93c5fd}
/* Pagination */
.pag-row{display:flex;gap:8px;justify-content:center;margin-top:12px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<div class="modal-bg" id="doc-modal"><div class="modal" id="modal-inner"></div></div>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated at bootstrap
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _docType='PurchaseRequests';
let _docs=[];
let _loading=false;
let _search='';
let _bpSearch='';
let _dateFrom='';
let _dateTo='';
let _docStatus='';
let _skip=0;
const TYPES=[
{key:'PurchaseRequests', label:'Purchase Request', icon:'', group:'Purchase'},
{key:'PurchaseOrders', label:'Purchase Order', icon:'', group:'Purchase'},
{key:'PurchaseDeliveryNotes',label:'GRPO', icon:'', group:'Purchase'},
{key:'PurchaseInvoices', label:'AP Invoice', icon:'', group:'Purchase'},
{key:'PurchaseCreditNotes',label:'AP Credit Note', icon:'', group:'Purchase'},
{key:'PurchaseReturns', label:'Goods Return', icon:'', group:'Purchase'},
{key:'Invoices', label:'AR Invoice', icon:'', group:'Sales'},
{key:'CreditNotes', label:'AR Credit Memo', icon:'', group:'Sales'},
{key:'Returns', label:'Return Notes', icon:'', group:'Sales'},
{key:'JournalEntries', label:'Journal Entry', icon:'', group:'Finance'},
];
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function fmtDate(d){if(!d)return'—';return new Date(d).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'});}
function fmtAmt(v,c){return(c||'INR')+' '+Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){
const tc=document.getElementById('toast-c');
const el=document.createElement('div');
el.className=`toast t-${type}`;
el.innerHTML=(type==='ok'?'&#10003;':'&#10007;')+' '+esc(msg);
tc.appendChild(el);setTimeout(()=>el.remove(),4000);
}
async function loadDocs(){
_loading=true;render();
try{
let qs=_search?`&q=${encodeURIComponent(_search)}`:'';
if(_bpSearch)qs+=`&bp=${encodeURIComponent(_bpSearch)}`;
if(_dateFrom)qs+=`&dateFrom=${_dateFrom}`;
if(_dateTo)qs+=`&dateTo=${_dateTo}`;
if(_docStatus)qs+=`&status=${_docStatus}`;
const r=await api('GET',`/sap/documents/${_docType}?company=${encodeURIComponent(_co)}&top=20&skip=${_skip}${qs}`);
_docs=r.data||[];
}catch(e){toast(e.message,'err');_docs=[];}
_loading=false;render();
}
function render(){
const app=document.getElementById('app');app.innerHTML='';
const typeCfg=TYPES.find(t=>t.key===_docType);
const grpCls=typeCfg?.group==='Sales'?'sales':typeCfg?.group==='Finance'?'finance':'purchase';
// ── Filter card ──────────────────────────────────────────────
const c1=document.createElement('div');c1.className='card';
c1.innerHTML=`
<div class="card-hd">
<div class="card-hd-icon"></div>
<span class="card-hd-title">View Documents</span>
</div>
<div class="card-bd">
<div class="fg">
<label class="fl">Company</label>
<select class="fi" id="sel-co">
${coOptions(_co)}
</select>
</div>
<div class="fg">
<label class="fl">Document Type</label>
<div class="type-tabs" id="type-tabs">
${TYPES.map(t=>`<button class="type-tab${_docType===t.key?' on':''}" data-dt="${t.key}">${t.label}</button>`).join('')}
</div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Doc Number</label><input class="fi" id="inp-search" value="${esc(_search)}" placeholder="e.g. 123456" style="font-family:monospace"/></div>
<div class="fg"><label class="fl">Vendor / Customer</label><input class="fi" id="inp-bp" value="${esc(_bpSearch)}" placeholder="BP Code or Name…"/></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Date From</label><input class="fi" id="inp-datefrom" type="date" value="${_dateFrom}"/></div>
<div class="fg"><label class="fl">Date To</label><input class="fi" id="inp-dateto" type="date" value="${_dateTo}"/></div>
</div>
<div class="fg">
<label class="fl">Status</label>
<select class="fi" id="inp-status">
<option value="" ${_docStatus===''?'selected':''}>All</option>
<option value="O" ${_docStatus==='O'?'selected':''}>Open</option>
<option value="C" ${_docStatus==='C'?'selected':''}>Closed</option>
<option value="L" ${_docStatus==='L'?'selected':''}>Cancelled</option>
</select>
</div>
<div style="display:flex;gap:8px">
<button class="btn b-primary" id="btn-search" style="flex:1">Search</button>
<button class="btn b-cancel" id="btn-clear">Clear</button>
</div>
</div>`;
app.appendChild(c1);
// ── Results card ─────────────────────────────────────────────
const c2=document.createElement('div');c2.className='card';
let listHtml='';
if(_loading){
listHtml=`<div class="empty-msg"><span class="sp"></span>Loading documents…</div>`;
}else if(!_docs.length){
listHtml=`<div class="empty-msg">No documents found. Adjust filters and search again.</div>`;
}else{
const isJE=_docType==='JournalEntries';
const isPR=_docType==='PurchaseRequests';
listHtml=_docs.map(d=>{
const num=isJE?(d.Number||d.JdtNum):(d.DocNum||d.DocEntry);
const entry=isJE?d.JdtNum:d.DocEntry;
const date=isJE?fmtDate(d.ReferenceDate):fmtDate(d.DocDate);
const total=isJE?'':fmtAmt(d.DocTotal,d.DocCurrency);
const status=d.DocumentStatus==='bost_Close'?'closed':'open';
const hasAttach=d.AttachmentEntry&&d.AttachmentEntry>0;
const requiredDate=isPR&&d.RequriedDate?fmtDate(d.RequriedDate):'';
return`<div class="doc-card ${grpCls}" data-entry="${entry}" data-type="${_docType}">
<div class="doc-top">
<span class="doc-num">#${num}</span>
${!isJE?`<span class="doc-badge ${status}">${status==='closed'?'Closed':'Open'}</span>`:''}
${hasAttach?'<span class="doc-badge attach">Attachment</span>':''}
</div>
<div class="doc-name">${esc(isJE?(d.Memo||'—'):(d.CardName||d.CardCode||'— No Vendor —'))}</div>
<div class="doc-meta">
<span>Date: <b>${date}</b></span>
${isPR&&requiredDate?`<span>Required: <b>${requiredDate}</b></span>`:''}
${!isJE&&!isPR?`<span>Total: <b>${total}</b></span>`:''}
${isPR&&d.DocTotal?`<span>Est. Total: <b>${total}</b></span>`:''}
${!isJE&&d.CardCode?`<span>BP: <b>${esc(d.CardCode)}</b></span>`:''}
${isJE&&d.Reference?`<span>Ref: <b>${esc(d.Reference)}</b></span>`:''}
</div>
</div>`;
}).join('');
}
c2.innerHTML=`
<div class="card-hd">
<div class="card-hd-icon"></div>
<span class="card-hd-title">${typeCfg?.label||_docType}</span>
<span class="right">${_docs.length} doc${_docs.length!==1?'s':''}</span>
</div>
<div class="card-bd">
${listHtml}
${!_loading&&_docs.length>=20?`<div class="pag-row">
${_skip>0?`<button class="btn b-cancel" id="btn-prev">&#8592; Prev</button>`:''}
<button class="btn b-cancel" id="btn-next">Next 20 &#8594;</button>
</div>`:''}
</div>`;
app.appendChild(c2);
// ── Wire events ──────────────────────────────────────────────
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;_skip=0;loadDocs();});
document.querySelectorAll('[data-dt]').forEach(b=>{b.onclick=function(){_docType=this.dataset.dt;_skip=0;_search='';loadDocs();};});
const doSearch=()=>{
_search=(q('#inp-search')?.value||'').trim();
_bpSearch=(q('#inp-bp')?.value||'').trim();
_dateFrom=q('#inp-datefrom')?.value||'';
_dateTo=q('#inp-dateto')?.value||'';
_docStatus=q('#inp-status')?.value||'';
_skip=0;loadDocs();
};
q('#btn-search')&&(q('#btn-search').onclick=doSearch);
q('#btn-clear')&&(q('#btn-clear').onclick=()=>{_search='';_bpSearch='';_dateFrom='';_dateTo='';_docStatus='';_skip=0;loadDocs();});
q('#inp-search')&&(q('#inp-search').onkeydown=e=>{if(e.key==='Enter')doSearch();});
q('#inp-bp')&&(q('#inp-bp').onkeydown=e=>{if(e.key==='Enter')doSearch();});
q('#btn-prev')&&(q('#btn-prev').onclick=()=>{_skip=Math.max(0,_skip-20);loadDocs();});
q('#btn-next')&&(q('#btn-next').onclick=()=>{_skip+=20;loadDocs();});
document.querySelectorAll('[data-entry]').forEach(c=>{c.onclick=function(){openDetail(this.dataset.type,parseInt(this.dataset.entry));};});
},0);
}
async function openDetail(type,entry){
const modal=document.getElementById('doc-modal'),inner=document.getElementById('modal-inner');
modal.classList.add('show');
inner.innerHTML=`<div style="padding:48px;text-align:center;color:var(--text3)"><span class="sp"></span>Loading…</div>`;
try{
const r=await api('GET',`/sap/documents/${type}/${entry}?company=${encodeURIComponent(_co)}`);
const d=r.data;
const isJE=type==='JournalEntries';
const isPR=type==='PurchaseRequests';
const typeCfg=TYPES.find(t=>t.key===type);
const num=isJE?(d.Number||d.JdtNum):(d.DocNum||d.DocEntry);
const lines=isJE?(d.JournalEntryLines||[]):(d.DocumentLines||[]);
// ── Lines table ──────────────────────────────────────────
let linesHtml='';
if(lines.length){
if(isJE){
linesHtml=`<div class="lines-wrap"><table class="lines-tbl">
<thead><tr><th>Account</th><th>Memo</th><th class="td-right">Debit</th><th class="td-right">Credit</th></tr></thead>
<tbody>${lines.map(l=>`<tr>
<td class="td-mono">${esc(l.AccountCode)}</td>
<td>${esc(l.LineMemo||'')}</td>
<td class="td-grn td-right">${l.Debit?fmtAmt(l.Debit):''}</td>
<td class="td-red td-right">${l.Credit?fmtAmt(l.Credit):''}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}else if(isPR){
linesHtml=`<div class="lines-wrap"><table class="lines-tbl">
<thead><tr><th>Item Code</th><th>Description</th><th class="td-right">Qty</th><th>Whse</th><th>Vendor</th><th class="td-right">Req. Date</th></tr></thead>
<tbody>${lines.map(l=>`<tr>
<td class="td-mono">${esc(l.ItemCode||'')}</td>
<td>${esc(l.ItemDescription||'')}</td>
<td class="td-right">${l.Quantity||''}</td>
<td style="color:var(--text3)">${esc(l.WarehouseCode||'')}</td>
<td>${esc(l.LineVendor||'—')}</td>
<td class="td-right" style="color:var(--text3)">${l.RequriedDate?fmtDate(l.RequriedDate):'—'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}else{
linesHtml=`<div class="lines-wrap"><table class="lines-tbl">
<thead><tr><th>Item Code</th><th>Description</th><th class="td-right">Qty</th><th>Whse</th><th class="td-right">Unit Price</th><th class="td-right">Total</th></tr></thead>
<tbody>${lines.map(l=>`<tr>
<td class="td-mono">${esc(l.ItemCode||'')}</td>
<td>${esc(l.ItemDescription||'')}</td>
<td class="td-right">${l.Quantity||''}</td>
<td style="color:var(--text3)">${esc(l.WarehouseCode||'')}</td>
<td class="td-right" style="color:var(--text3)">${l.UnitPrice?fmtAmt(l.UnitPrice):'—'}</td>
<td class="td-right" style="font-weight:600;color:var(--text)">${l.LineTotal?fmtAmt(l.LineTotal):''}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
}else{
linesHtml=`<div class="empty-msg" style="padding:16px">No lines</div>`;
}
// ── Attachments ──────────────────────────────────────────
let attachHtml='';
if(!isJE&&d.AttachmentEntry&&d.AttachmentEntry>0){
try{
const att=await api('GET',`/sap/attachments/${d.AttachmentEntry}?company=${encodeURIComponent(_co)}`);
const attLines=att.data?.Attachments2_Lines||[];
if(attLines.length){
attachHtml=`<div class="dt-section">Attachments (${attLines.length})</div>
<div class="att-list">${attLines.map((a,i)=>`<div class="att-row">
<span class="att-icon"></span>
<div class="att-info">
<div class="att-name">${esc(a.FileName||'')}.${esc(a.FileExtension||'')}</div>
<div class="att-sub">${esc(a.SourcePath||'')}</div>
</div>
<button class="att-btn" onclick="viewAttachment(${d.AttachmentEntry},${a.LineNum||i})">View</button>
</div>`).join('')}</div>`;
}
}catch(_e){}
}
// ── Detail rows ──────────────────────────────────────────
let detailRows='';
if(isJE){
detailRows=`<div class="dt-grid">
<div class="dt-row"><span class="dt-l">Number</span><span class="dt-v">${d.Number||d.JdtNum}</span></div>
<div class="dt-row"><span class="dt-l">Date</span><span class="dt-v">${fmtDate(d.ReferenceDate)}</span></div>
<div class="dt-row"><span class="dt-l">Due Date</span><span class="dt-v">${fmtDate(d.DueDate)}</span></div>
<div class="dt-row"><span class="dt-l">Reference</span><span class="dt-v">${esc(d.Reference||'—')}</span></div>
<div class="dt-row full"><span class="dt-l">Memo</span><span class="dt-v">${esc(d.Memo||'—')}</span></div>
</div>`;
}else if(isPR){
detailRows=`<div class="dt-grid">
<div class="dt-row"><span class="dt-l">Doc Number</span><span class="dt-v">${d.DocNum||d.DocEntry}</span></div>
<div class="dt-row"><span class="dt-l">Status</span><span class="dt-v">${d.DocumentStatus==='bost_Close'?'Closed':'Open'}</span></div>
<div class="dt-row"><span class="dt-l">Date</span><span class="dt-v">${fmtDate(d.DocDate)}</span></div>
<div class="dt-row"><span class="dt-l">Required Date</span><span class="dt-v">${fmtDate(d.RequriedDate)}</span></div>
<div class="dt-row full"><span class="dt-l">Requester</span><span class="dt-v">${esc(d.CardName||d.CardCode||'— Internal Request —')}</span></div>
<div class="dt-row full"><span class="dt-l">Comments</span><span class="dt-v" style="color:var(--text3)">${esc(d.Comments||'—')}</span></div>
</div>`;
}else{
detailRows=`<div class="dt-grid">
<div class="dt-row"><span class="dt-l">Doc Number</span><span class="dt-v">${d.DocNum||d.DocEntry}</span></div>
<div class="dt-row"><span class="dt-l">Status</span><span class="dt-v">${d.DocumentStatus==='bost_Close'?'Closed':'Open'}</span></div>
<div class="dt-row"><span class="dt-l">Date</span><span class="dt-v">${fmtDate(d.DocDate)}</span></div>
<div class="dt-row"><span class="dt-l">Due Date</span><span class="dt-v">${fmtDate(d.DocDueDate)}</span></div>
<div class="dt-row full"><span class="dt-l">Business Partner</span><span class="dt-v">${esc(d.CardName||d.CardCode||'—')}</span></div>
<div class="dt-row"><span class="dt-l">Total</span><span class="dt-v" style="color:var(--teal);font-weight:700">${fmtAmt(d.DocTotal,d.DocCurrency)}</span></div>
<div class="dt-row full"><span class="dt-l">Comments</span><span class="dt-v" style="color:var(--text3)">${esc(d.Comments||'—')}</span></div>
</div>`;
}
inner.innerHTML=`
<div class="modal-hd">
<div class="modal-hd-left">
<div class="modal-hd-icon"></div>
<div class="modal-hd-info">
<div class="modal-hd-title">${typeCfg?.label||type}</div>
<div class="modal-hd-num">#${num}</div>
</div>
</div>
<button class="modal-close" id="m-close">&times;</button>
</div>
<div class="modal-bd">
<div class="dt-section">Document Details</div>
${detailRows}
<div class="dt-section">Lines (${lines.length})</div>
${linesHtml}
${attachHtml}
</div>`;
q('#m-close').onclick=()=>modal.classList.remove('show');
}catch(e){
inner.innerHTML=`<div style="padding:32px;text-align:center;color:var(--red)">&#10007; ${esc(e.message)}</div>`;
}
}
window.viewAttachment=function(entry,lineId){
const url=BE+`/sap/attachments/${entry}/download/${lineId}?company=${encodeURIComponent(_co)}`;
fetch(url,{headers:{'Authorization':'Bearer '+_tok}})
.then(r=>{if(!r.ok)throw new Error('File not accessible');return r.blob();})
.then(blob=>{const burl=URL.createObjectURL(blob);window.open(burl,'_blank');})
.catch(e=>{toast('Cannot open: '+e.message,'err');});
};
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}loadDocs();})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+653
View File
@@ -0,0 +1,653 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>General Ledger | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/xlsx-js-style@1.2.0/dist/xlsx.bundle.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}
.hdr h1 span{color:#7c3aed}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#7c3aed;color:#fff}.btn-primary:hover{background:#6d28d9}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#7c3aed;border:1.5px solid #7c3aed}.btn-outline:hover{background:#f5f3ff}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.tbl-wrap{overflow-x:auto;overflow-y:auto;max-height:62vh}
.tbl-wrap::-webkit-scrollbar{height:7px;width:7px}
.tbl-wrap::-webkit-scrollbar-track{background:#f1f3f5}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
.tbl-wrap::-webkit-scrollbar-thumb:hover{background:#9ca3af}
table{border-collapse:collapse;min-width:100%}
thead th{background:#f5f3ff;color:#4b5563;font-weight:700;padding:10px 12px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #ddd6fe;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s}
tbody tr:hover{background:#f5f3ff}
tbody td{padding:9px 12px;white-space:nowrap;color:#374151;font-size:12px}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
.acct-header td{background:#ede9fe;font-weight:700;color:#4c1d95;font-size:12px;border-bottom:2px solid #c4b5fd;padding:10px 12px}
.acct-footer td{background:#f5f3ff;font-weight:700;color:#1a1a2e;font-size:12px;border-top:2px solid #ddd6fe;padding:10px 12px}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
/* Account picker button */
.acct-pick-btn{height:36px;padding:0 14px;border:1.5px solid #dde1e7;border-radius:8px;background:#fff;font-size:13px;font-family:inherit;color:#1a1a2e;cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:border-color .15s;min-width:180px}
.acct-pick-btn:hover{border-color:#7c3aed}
.acct-pick-btn .badge{background:#7c3aed;color:#fff;border-radius:10px;padding:1px 8px;font-size:10px;font-weight:700;margin-left:4px}
/* Modal overlay */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:600;display:none;backdrop-filter:blur(2px)}
.modal-overlay.show{display:flex;align-items:center;justify-content:center}
.modal{background:#fff;border-radius:14px;width:560px;max-width:95vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 40px rgba(0,0,0,.18)}
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e8eaed}
.modal-header h3{font-size:15px;font-weight:700;color:#1a1a2e}
.modal-close{width:32px;height:32px;border:none;background:#edf2f7;border-radius:8px;cursor:pointer;font-size:18px;color:#6b7280;display:flex;align-items:center;justify-content:center}
.modal-close:hover{background:#e2e8f0;color:#1a1a2e}
.modal-search{padding:12px 20px;border-bottom:1px solid #e8eaed}
.modal-search input{width:100%;height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;outline:none}
.modal-search input:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.modal-body{flex:1;overflow-y:auto;padding:8px 0}
.modal-body::-webkit-scrollbar{width:6px}.modal-body::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:3px}
.modal-footer{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-top:1px solid #e8eaed;background:#f8fafb;border-radius:0 0 14px 14px}
.grp-tab{padding:4px 12px;border-radius:16px;font-size:11px;font-weight:700;cursor:pointer;border:1.5px solid #ddd6fe;background:#fff;color:#7c3aed;transition:all .15s;white-space:nowrap}
.grp-tab:hover{background:#f5f3ff}
.grp-tab.active{background:#7c3aed;color:#fff;border-color:#7c3aed}
.acct-group-hdr{display:flex;align-items:center;gap:8px;padding:8px 20px;background:#f5f3ff;font-size:12px;font-weight:700;color:#7c3aed;cursor:pointer;user-select:none;border-bottom:1px solid #ede9fe}
.acct-group-hdr:hover{background:#ede9fe}
.acct-group-hdr input[type=checkbox]{accent-color:#7c3aed;width:15px;height:15px;cursor:pointer}
.acct-row{display:flex;align-items:center;gap:8px;padding:6px 20px 6px 40px;font-size:12px;color:#374151;cursor:pointer;border-bottom:1px solid #f3f4f6}
.acct-row:hover{background:#faf5ff}
.acct-row input[type=checkbox]{accent-color:#7c3aed;width:14px;height:14px;cursor:pointer}
.acct-row .code{font-weight:600;color:#1a1a2e;min-width:70px}
.acct-row .name{flex:1}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>General Ledger</span> Report</h1>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-outline" id="btnDownload" disabled>Download Excel</button>
<button class="btn btn-outline" id="btnPrint" disabled>Print Ledger</button>
</div>
</div>
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>From Date</label>
<input type="date" id="fromDate">
</div>
<div class="fg">
<label>To Date</label>
<input type="date" id="toDate">
</div>
<div class="fg">
<label>Accounts</label>
<button type="button" class="acct-pick-btn" id="btnPickAccounts">
Select Accounts <span class="badge" id="acctCount" style="display:none">0</span>
</button>
</div>
<button class="btn btn-primary" id="btnLoad">Load Report</button>
</div>
<div class="tbl-wrap" id="tblWrap">
<div class="empty" id="emptyMsg">Select accounts and date range, then click <b>Load Report</b>.</div>
<table id="glTable" style="display:none">
<thead>
<tr>
<th>Acct Code</th>
<th>Acct Name</th>
<th>Posting Date</th>
<th>Due Date</th>
<th>Doc Date</th>
<th>Series</th>
<th>Doc No</th>
<th>Trans No</th>
<th>Seq</th>
<th>Remarks</th>
<th>Offset Acct</th>
<th>Offset Name</th>
<th style="text-align:right">Deb/Cred</th>
<th style="text-align:right">Debit</th>
<th style="text-align:right">Credit</th>
<th style="text-align:right">Cumulative Bal</th>
<th>Ref1</th>
</tr>
</thead>
<tbody id="glBody"></tbody>
</table>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Ready</span>
</div>
</div>
</div>
<!-- Account Picker Modal -->
<div class="modal-overlay" id="acctModal">
<div class="modal">
<div class="modal-header">
<h3>Select Accounts</h3>
<button class="modal-close" id="acctModalClose">&times;</button>
</div>
<div class="modal-search">
<input type="text" id="acctSearch" placeholder="Search by code or name...">
<div id="groupTabs" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px"></div>
</div>
<div class="modal-body" id="acctList"></div>
<div class="modal-footer">
<span id="acctSelectedInfo" style="font-size:12px;color:#6b7280">0 selected</span>
<div style="display:flex;gap:8px">
<button class="btn btn-outline" id="acctClearAll" style="height:32px;font-size:11px">Clear All</button>
<button class="btn btn-primary" id="acctDone" style="height:32px;font-size:11px">Done</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
(function(){
const tok = sessionStorage.getItem('portal_token');
const hdr = { Authorization:'Bearer '+tok };
const hdrJson = { 'Content-Type':'application/json', Authorization:'Bearer '+tok };
// ── State ──────────────────────────────────────────────────────────────────
let allAccounts = []; // from API
let selectedAccts = new Set();
let reportData = null; // { data, openingBalances }
// ── DOM refs ───────────────────────────────────────────────────────────────
const $from = document.getElementById('fromDate');
const $to = document.getElementById('toDate');
const $btnLoad = document.getElementById('btnLoad');
const $btnDl = document.getElementById('btnDownload');
const $btnPr = document.getElementById('btnPrint');
const $table = document.getElementById('glTable');
const $body = document.getElementById('glBody');
const $empty = document.getElementById('emptyMsg');
const $statusDot= document.getElementById('statusDot');
const $statusTxt= document.getElementById('statusText');
const $modal = document.getElementById('acctModal');
const $acctList = document.getElementById('acctList');
const $acctSearch=document.getElementById('acctSearch');
const $acctCount= document.getElementById('acctCount');
const $acctInfo = document.getElementById('acctSelectedInfo');
// ── Default dates (current month) ─────────────────────────────────────────
const now = new Date();
$from.value = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-01';
$to.value = now.toISOString().slice(0,10);
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg, type='info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + type + ' show';
setTimeout(() => t.classList.remove('show'), 3500);
}
function setStatus(state, text) {
$statusDot.className = 'status-dot ' + state;
$statusTxt.textContent = text;
}
// ── Number formatting (Indian) ─────────────────────────────────────────────
function fmt(v) {
if (v == null || isNaN(v)) return '';
return Number(v).toLocaleString('en-IN', { minimumFractionDigits:2, maximumFractionDigits:2 });
}
function fmtDate(d) {
if (!d) return '';
const dt = new Date(d);
return dt.toLocaleDateString('en-IN', { day:'2-digit', month:'short', year:'numeric' });
}
// ── Load accounts ──────────────────────────────────────────────────────────
let accountsLoaded = false;
async function loadAccounts() {
try {
const r = await fetch('/api/general-ledger/accounts', { headers: hdr });
const j = await r.json();
if (j.success) { allAccounts = j.data; accountsLoaded = true; }
else { console.error('Accounts API error:', j.message); toast('Failed to load accounts: '+j.message,'error'); }
} catch (e) { console.error('Failed to load accounts', e); toast('Failed to load accounts','error'); }
}
loadAccounts();
// ── Account picker modal ───────────────────────────────────────────────────
document.getElementById('btnPickAccounts').addEventListener('click', async () => {
if (!accountsLoaded) {
toast('Loading accounts…','info');
await loadAccounts();
}
renderGroupTabs();
renderAccountList();
$modal.classList.add('show');
$acctSearch.value = '';
$acctSearch.focus();
});
document.getElementById('acctModalClose').addEventListener('click', () => $modal.classList.remove('show'));
document.getElementById('acctDone').addEventListener('click', () => $modal.classList.remove('show'));
document.getElementById('acctClearAll').addEventListener('click', () => {
selectedAccts.clear();
renderAccountList();
updateAcctBadge();
});
$modal.addEventListener('click', e => { if (e.target === $modal) $modal.classList.remove('show'); });
$acctSearch.addEventListener('input', () => renderAccountList());
let activeGroupFilter = null; // null = All, else GroupMask number
const $groupTabs = document.getElementById('groupTabs');
function renderGroupTabs() {
const gLabels = {};
allAccounts.forEach(a => { gLabels[a.GroupMask] = a.GroupLabel; });
let html = `<span class="grp-tab${activeGroupFilter==null?' active':''}" data-gf="all">All</span>`;
Object.keys(gLabels).sort((a,b)=>a-b).forEach(gm => {
html += `<span class="grp-tab${activeGroupFilter==gm?' active':''}" data-gf="${gm}">${gLabels[gm]}</span>`;
});
$groupTabs.innerHTML = html;
}
$groupTabs.addEventListener('click', e => {
const tab = e.target.closest('.grp-tab');
if (!tab) return;
const gf = tab.dataset.gf;
activeGroupFilter = gf === 'all' ? null : gf;
$groupTabs.querySelectorAll('.grp-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Auto-select all accounts in the clicked group
selectedAccts.clear();
if (gf === 'all') {
allAccounts.forEach(a => selectedAccts.add(String(a.AcctCode)));
} else {
allAccounts.filter(a => String(a.GroupMask) === gf).forEach(a => selectedAccts.add(String(a.AcctCode)));
}
updateAcctBadge();
renderAccountList();
});
function renderAccountList() {
const q = ($acctSearch.value || '').toLowerCase();
// Group by GroupMask
const groups = {};
for (const a of allAccounts) {
if (activeGroupFilter != null && String(a.GroupMask) !== String(activeGroupFilter)) continue;
const code = String(a.AcctCode);
const name = (a.AcctName || '').toLowerCase();
if (q && !code.includes(q) && !name.includes(q)) continue;
const key = a.GroupMask;
if (!groups[key]) groups[key] = { label: a.GroupLabel, items: [] };
groups[key].items.push(a);
}
let html = '';
for (const gm of Object.keys(groups).sort((a,b) => a - b)) {
const g = groups[gm];
const allChecked = g.items.every(a => selectedAccts.has(String(a.AcctCode)));
const someChecked = g.items.some(a => selectedAccts.has(String(a.AcctCode)));
html += `<div class="acct-group-hdr" data-gm="${gm}">
<input type="checkbox" ${allChecked ? 'checked' : ''} ${someChecked && !allChecked ? 'indeterminate' : ''} data-group="${gm}">
${g.label} (${g.items.length})
</div>`;
for (const a of g.items) {
const c = String(a.AcctCode);
html += `<div class="acct-row" data-code="${c}">
<input type="checkbox" ${selectedAccts.has(c) ? 'checked' : ''} data-acct="${c}">
<span class="code">${c}</span>
<span class="name">${a.AcctName || ''}</span>
</div>`;
}
}
if (!html) html = '<div style="padding:20px;text-align:center;color:#9ca3af">No accounts found</div>';
$acctList.innerHTML = html;
// Set indeterminate state (can't do via HTML attr)
$acctList.querySelectorAll('input[data-group]').forEach(cb => {
const gm = cb.dataset.group;
const g = groups[gm];
if (!g) return;
const allC = g.items.every(a => selectedAccts.has(String(a.AcctCode)));
const someC = g.items.some(a => selectedAccts.has(String(a.AcctCode)));
cb.indeterminate = someC && !allC;
});
updateAcctInfo();
}
$acctList.addEventListener('change', e => {
const cb = e.target;
if (cb.dataset.group) {
// Group toggle
const gm = cb.dataset.group;
const items = allAccounts.filter(a => String(a.GroupMask) === gm);
const q = ($acctSearch.value || '').toLowerCase();
const filtered = q ? items.filter(a => String(a.AcctCode).includes(q) || (a.AcctName||'').toLowerCase().includes(q)) : items;
if (cb.checked) filtered.forEach(a => selectedAccts.add(String(a.AcctCode)));
else filtered.forEach(a => selectedAccts.delete(String(a.AcctCode)));
renderAccountList();
} else if (cb.dataset.acct) {
if (cb.checked) selectedAccts.add(cb.dataset.acct);
else selectedAccts.delete(cb.dataset.acct);
renderAccountList();
}
updateAcctBadge();
});
function updateAcctBadge() {
const n = selectedAccts.size;
$acctCount.style.display = n ? '' : 'none';
$acctCount.textContent = n;
}
function updateAcctInfo() {
$acctInfo.textContent = selectedAccts.size + ' selected';
}
// ── Load report ────────────────────────────────────────────────────────────
$btnLoad.addEventListener('click', loadReport);
async function loadReport() {
if (!selectedAccts.size) return toast('Please select at least one account', 'error');
if (!$from.value || !$to.value) return toast('Please select date range', 'error');
$btnLoad.disabled = true;
setStatus('loading', 'Loading...');
$table.style.display = 'none';
$empty.style.display = 'none';
try {
const codes = Array.from(selectedAccts).join(',');
const url = `/api/general-ledger/data?from=${$from.value}&to=${$to.value}&accounts=${encodeURIComponent(codes)}`;
const r = await fetch(url, { headers: hdr });
const j = await r.json();
if (!j.success) throw new Error(j.message || 'Failed');
reportData = j;
renderTable(j.data, j.openingBalances);
setStatus('ok', `Loaded ${j.data.length} transactions`);
$btnDl.disabled = false;
$btnPr.disabled = false;
} catch (e) {
toast(e.message, 'error');
setStatus('err', 'Error: ' + e.message);
$empty.textContent = 'Failed to load data.';
$empty.style.display = '';
} finally {
$btnLoad.disabled = false;
}
}
// ── Render table ───────────────────────────────────────────────────────────
function renderTable(data, ob) {
$body.innerHTML = '';
if (!data.length) {
$empty.textContent = 'No transactions found for the selected filters.';
$empty.style.display = '';
$table.style.display = 'none';
return;
}
// Group by account
const grouped = {};
const order = [];
for (const row of data) {
const k = row.acct_code;
if (!grouped[k]) { grouped[k] = []; order.push(k); }
grouped[k].push(row);
}
const colCount = 17;
for (const acct of order) {
const rows = grouped[acct];
const acctName = rows[0].acct_name || '';
const opening = ob[acct] || 0;
// Account header
const hr = document.createElement('tr');
hr.className = 'acct-header';
hr.innerHTML = `<td colspan="${colCount}">${acct} - ${acctName} &nbsp;|&nbsp; Opening Balance: ${fmt(opening)}</td>`;
$body.appendChild(hr);
let totalDebit = 0, totalCredit = 0;
let runBal = opening;
for (const r of rows) {
const debit = Number(r.debit) || 0;
const credit = Number(r.credit) || 0;
totalDebit += debit;
totalCredit += credit;
runBal += (debit - credit);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${acct}</td>
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis" title="${acctName.replace(/"/g,'&quot;')}">${acctName}</td>
<td>${fmtDate(r.posting_date)}</td>
<td>${fmtDate(r.due_date)}</td>
<td>${fmtDate(r.document_date)}</td>
<td>${r.series ?? ''}</td>
<td>${r.doc_no ?? ''}</td>
<td>${r.trans_no ?? ''}</td>
<td>${r.seq_no ?? ''}</td>
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis" title="${(r.remarks||'').replace(/"/g,'&quot;')}">${r.remarks || ''}</td>
<td>${r.offset_acct || ''}</td>
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis" title="${(r.offset_acct_name||'').replace(/"/g,'&quot;')}">${r.offset_acct_name || ''}</td>
<td class="num">${fmt(r.deb_cred)}</td>
<td class="num">${fmt(debit)}</td>
<td class="num">${fmt(credit)}</td>
<td class="num">${fmt(runBal)}</td>
<td>${r.ref1 || ''}</td>`;
$body.appendChild(tr);
}
// Footer
const closing = opening + totalDebit - totalCredit;
const fr = document.createElement('tr');
fr.className = 'acct-footer';
fr.innerHTML = `<td colspan="12" style="text-align:right">Account Total:</td>
<td class="num">${fmt(totalDebit - totalCredit)}</td>
<td class="num">${fmt(totalDebit)}</td>
<td class="num">${fmt(totalCredit)}</td>
<td class="num">${fmt(closing)}</td>
<td></td>`;
$body.appendChild(fr);
// Spacer
const sp = document.createElement('tr');
sp.innerHTML = `<td colspan="${colCount}" style="height:10px;background:#f0f2f5;border:none"></td>`;
$body.appendChild(sp);
}
$table.style.display = '';
}
// ── Excel download ─────────────────────────────────────────────────────────
$btnDl.addEventListener('click', downloadExcel);
function downloadExcel() {
if (!reportData) return;
const { data, openingBalances: ob } = reportData;
const wb = XLSX.utils.book_new();
const rows = [];
const hdrStyle = { font:{bold:true,color:{rgb:'FFFFFF'},sz:11}, fill:{fgColor:{rgb:'4C1D95'}}, alignment:{horizontal:'center'}, border:{bottom:{style:'thin',color:{rgb:'999999'}}} };
const acctHdrStyle = { font:{bold:true,color:{rgb:'FFFFFF'},sz:11}, fill:{fgColor:{rgb:'3B82F6'}}, alignment:{horizontal:'left'} };
const footerStyle = { font:{bold:true,sz:10}, fill:{fgColor:{rgb:'EDE9FE'}} };
const numFmt = '#,##,##0.00';
const numStyle = { numFmt, alignment:{horizontal:'right'} };
const footerNumStyle = { numFmt, font:{bold:true,sz:10}, fill:{fgColor:{rgb:'EDE9FE'}}, alignment:{horizontal:'right'} };
// Header row
const headers = ['Acct Code','Acct Name','Posting Date','Due Date','Doc Date','Series','Doc No','Trans No','Seq','Remarks','Offset Acct','Offset Name','Deb/Cred','Debit','Credit','Cumulative Bal','Ref1'];
rows.push(headers.map(h => ({ v:h, s:hdrStyle })));
// Group by account
const grouped = {};
const order = [];
for (const r of data) {
if (!grouped[r.acct_code]) { grouped[r.acct_code] = []; order.push(r.acct_code); }
grouped[r.acct_code].push(r);
}
for (const acct of order) {
const txns = grouped[acct];
const acctName = txns[0].acct_name || '';
const opening = ob[acct] || 0;
// Account header row
const acctRow = Array(17).fill({ v:'', s:acctHdrStyle });
acctRow[0] = { v:`${acct} - ${acctName} | Opening Balance: ${fmt(opening)}`, s:acctHdrStyle };
rows.push(acctRow);
let totalDebit = 0, totalCredit = 0, runBal = opening;
for (const r of txns) {
const d = Number(r.debit)||0, c = Number(r.credit)||0;
totalDebit += d; totalCredit += c;
runBal += (d - c);
rows.push([
acct,
acctName,
{ v: r.posting_date ? new Date(r.posting_date) : '', t: r.posting_date ? 'd' : 's', z:'DD-MMM-YYYY' },
{ v: r.due_date ? new Date(r.due_date) : '', t: r.due_date ? 'd' : 's', z:'DD-MMM-YYYY' },
{ v: r.document_date ? new Date(r.document_date) : '', t: r.document_date ? 'd' : 's', z:'DD-MMM-YYYY' },
r.series ?? '',
r.doc_no ?? '',
r.trans_no ?? '',
r.seq_no ?? '',
r.remarks || '',
r.offset_acct || '',
r.offset_acct_name || '',
{ v: Number(r.deb_cred)||0, t:'n', s:numStyle },
{ v: d, t:'n', s:numStyle },
{ v: c, t:'n', s:numStyle },
{ v: runBal, t:'n', s:numStyle },
r.ref1 || ''
]);
}
// Footer
const closing = opening + totalDebit - totalCredit;
const fr = Array(17).fill({ v:'', s:footerStyle });
fr[0] = { v:'Account Total', s:footerStyle };
fr[12] = { v: totalDebit - totalCredit, t:'n', s:footerNumStyle };
fr[13] = { v: totalDebit, t:'n', s:footerNumStyle };
fr[14] = { v: totalCredit, t:'n', s:footerNumStyle };
fr[15] = { v: closing, t:'n', s:footerNumStyle };
rows.push(fr);
// Blank row
rows.push([]);
}
const ws = XLSX.utils.aoa_to_sheet(rows);
// Column widths
ws['!cols'] = [
{wch:12},{wch:25},{wch:13},{wch:13},{wch:13},{wch:8},{wch:10},{wch:10},{wch:5},
{wch:30},{wch:12},{wch:25},{wch:14},{wch:14},{wch:14},{wch:16},{wch:14}
];
XLSX.utils.book_append_sheet(wb, ws, 'General Ledger');
XLSX.writeFile(wb, `General_Ledger_${$from.value}_to_${$to.value}.xlsx`);
toast('Excel downloaded', 'success');
}
// ── Print ─────────────────────────────────────────────────────────────────
$btnPr.addEventListener('click', () => {
if (!reportData) return;
const {data, openingBalances: ob} = reportData;
if (!data.length) return toast('No data to print','error');
const grouped={}, order=[];
for(const r of data){if(!grouped[r.acct_code]){grouped[r.acct_code]=[];order.push(r.acct_code);}grouped[r.acct_code].push(r);}
let tbl='';
for(const acct of order){
const txns=grouped[acct], acctName=txns[0].acct_name||'', opening=ob[acct]||0;
tbl+=`<tr class="ah"><td colspan="17">${acct} - ${acctName} &nbsp;|&nbsp; Opening Balance: ${fmt(opening)}</td></tr>`;
let td=0,tc=0,rb=opening;
for(const r of txns){
const d=Number(r.debit)||0, c=Number(r.credit)||0;
td+=d;tc+=c;rb+=(d-c);
tbl+=`<tr>
<td>${acct}</td><td>${acctName}</td>
<td>${fmtDate(r.posting_date)}</td><td>${fmtDate(r.due_date)}</td><td>${fmtDate(r.document_date)}</td>
<td>${r.series??''}</td><td>${r.doc_no??''}</td><td>${r.trans_no??''}</td><td>${r.seq_no??''}</td>
<td class="rm">${r.remarks||''}</td>
<td>${r.offset_acct||''}</td><td class="rm">${r.offset_acct_name||''}</td>
<td class="n">${fmt(r.deb_cred)}</td><td class="n">${fmt(d)}</td><td class="n">${fmt(c)}</td>
<td class="n">${fmt(rb)}</td><td>${r.ref1||''}</td>
</tr>`;
}
const cl=opening+td-tc;
tbl+=`<tr class="af"><td colspan="12" style="text-align:right">Account Total:</td>
<td class="n">${fmt(td-tc)}</td><td class="n">${fmt(td)}</td><td class="n">${fmt(tc)}</td>
<td class="n">${fmt(cl)}</td><td></td></tr>`;
tbl+=`<tr><td colspan="17" style="height:6px;border:none"></td></tr>`;
}
const w=window.open('','_blank','width=1400,height=900');
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8">
<title>General Ledger - ${$from.value} to ${$to.value}</title>
<style>
@page{size:A4 landscape;margin:4mm}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,Helvetica,sans-serif;color:#1a1a2e;padding:4px;zoom:0.7}
h2{font-size:11px;font-weight:700;margin-bottom:4px;color:#4C1D95;border-bottom:1px solid #7c3aed;padding-bottom:2px}
.sub{font-size:9px;color:#6b7280;margin-bottom:6px}
table{border-collapse:collapse;width:100%}
th,td{font-size:7px!important;padding:2px 4px!important;white-space:nowrap;line-height:1.3!important;border-bottom:1px solid #e5e7eb}
th{background:#4C1D95!important;color:#fff!important;font-weight:700;text-align:left;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.n{text-align:right;font-variant-numeric:tabular-nums}
.rm{max-width:100px;overflow:hidden;text-overflow:ellipsis}
.ah td{background:#3B82F6!important;color:#fff!important;font-weight:700;font-size:8px!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
.af td{background:#EDE9FE!important;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}
</style><link rel="stylesheet" href="/theme.css?v=3"/>
</head><body>
<h2>General Ledger Report</h2>
<div class="sub">${$from.value} to ${$to.value} &nbsp;|&nbsp; ${order.length} account(s) &nbsp;|&nbsp; ${data.length} transaction(s)</div>
<table>
<thead><tr>
<th>Code</th><th>Name</th><th>Post Date</th><th>Due Date</th><th>Doc Date</th>
<th>Series</th><th>Doc No</th><th>Trans No</th><th>Seq</th><th>Remarks</th>
<th>Offset Acct</th><th>Offset Name</th><th class="n">Deb/Cred</th><th class="n">Debit</th>
<th class="n">Credit</th><th class="n">Cum. Bal</th><th>Ref1</th>
</tr></thead>
<tbody>${tbl}</tbody>
</table>
<script>window.onload=()=>{window.print();}<\/script>
</body></html>`);
w.document.close();
});
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+700
View File
@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GSTR Reports | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}
.hdr h1 span{color:#047e7e}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#047e7e;color:#fff}.btn-primary:hover{background:#035b5b}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#047e7e;border:1.5px solid #047e7e}.btn-outline:hover{background:#f0fafa}
.btn-tcs{background:#1d4ed8;color:#fff}.btn-tcs:hover{background:#1e40af}
.btn-tcs:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
/* ── Card ── */
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
/* ── Filter bar ── */
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#047e7e;box-shadow:0 0 0 3px rgba(4,126,126,.08)}
/* ── Summary chips ── */
.summary-chips{display:flex;gap:8px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb}
.chip{background:#fff;border:1.5px solid #c8e6e6;border-radius:10px;padding:6px 14px;font-size:11px;font-weight:600;color:#047e7e;min-width:80px}
.chip span{font-size:15px;font-weight:700;display:block;color:#1a1a2e;margin-top:1px}
/* ── Tabs ── */
.tabs{display:flex;justify-content:space-evenly;border-bottom:2px solid #e8eaed;background:#fff}
.tab{flex:1;text-align:center;padding:14px 10px;font-size:12px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap;transition:all .15s;letter-spacing:.2px}
.tab:hover{color:#047e7e;background:#f8fffe}
.tab.active{color:#047e7e;border-bottom-color:#047e7e;background:#f5fefe}
.tab-badge{display:inline-block;background:#e0f4f4;color:#047e7e;border-radius:10px;padding:1px 7px;font-size:10px;font-weight:700;margin-left:5px}
.tab.active .tab-badge{background:#047e7e;color:#fff}
/* ── Search bar ── */
.search-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;border-bottom:1px solid #e8eaed;background:#fafbfc}
.search-bar input{flex:1;height:34px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px 0 34px;font-size:12px;font-family:inherit;color:#1a1a2e;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") no-repeat 10px center;outline:none;transition:border-color .15s}
.search-bar input:focus{border-color:#047e7e;box-shadow:0 0 0 3px rgba(4,126,126,.08)}
.srch-count{font-size:11px;color:#9ca3af;white-space:nowrap;flex-shrink:0;font-weight:600}
/* ── Tab panes ── */
.tab-content{display:none}
.tab-content.active{display:block}
/* ── Table ── */
.tbl-wrap{overflow-x:auto;overflow-y:auto;max-height:54vh}
.tbl-wrap::-webkit-scrollbar{height:7px;width:7px}
.tbl-wrap::-webkit-scrollbar-track{background:#f1f3f5}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
.tbl-wrap::-webkit-scrollbar-thumb:hover{background:#9ca3af}
table{border-collapse:collapse;min-width:100%}
thead th{background:#f0f4f8;color:#4b5563;font-weight:700;padding:10px 12px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #d1dae3;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s;cursor:pointer}
tbody tr:hover{background:#edfafa}
tbody tr.selected{background:#d9f5f5}
tbody td{padding:9px 12px;white-space:nowrap;color:#374151;font-size:12px}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums;font-size:12px}
tbody td.trunc{max-width:160px;overflow:hidden;text-overflow:ellipsis}
tbody td.trunc-sm{max-width:110px;overflow:hidden;text-overflow:ellipsis}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
/* ── Status bar ── */
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ── Badges ── */
.badge-b2b{background:#dbeafe;color:#1d4ed8}
.badge-b2c{background:#fef3c7;color:#92400e}
.badge-exp{background:#d1fae5;color:#065f46}
.badge-de{background:#ede9fe;color:#5b21b6}
.badge{padding:2px 8px;border-radius:10px;font-size:10px;font-weight:700}
/* ── Row detail panel ── */
.detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;display:none;backdrop-filter:blur(2px)}
.detail-overlay.show{display:block}
.detail-panel{position:fixed;top:0;right:-420px;width:420px;height:100vh;background:#fff;z-index:501;box-shadow:-4px 0 24px rgba(0,0,0,.12);display:flex;flex-direction:column;transition:right .28s cubic-bezier(.4,0,.2,1)}
.detail-panel.show{right:0}
.detail-header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb;flex-shrink:0}
.detail-header h3{font-size:14px;font-weight:700;color:#1a1a2e}
.detail-header .row-num{font-size:11px;color:#9ca3af;margin-top:2px}
.detail-close{width:32px;height:32px;border:none;background:#edf2f7;border-radius:8px;cursor:pointer;font-size:18px;color:#6b7280;display:flex;align-items:center;justify-content:center;transition:all .15s}
.detail-close:hover{background:#e2e8f0;color:#1a1a2e}
.detail-body{flex:1;overflow-y:auto;padding:16px 20px}
.detail-body::-webkit-scrollbar{width:4px}.detail-body::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:2px}
.detail-row{display:flex;gap:12px;padding:9px 0;border-bottom:1px solid #f3f4f6;align-items:center}
.detail-row:last-child{border-bottom:none}
.detail-edit-input{flex:1;height:32px;border:1.5px solid #dde1e7;border-radius:6px;padding:0 10px;font-size:12px;font-family:inherit;color:#1a1a2e;outline:none;width:100%;box-sizing:border-box}
.detail-edit-input:focus{border-color:#047e7e;box-shadow:0 0 0 3px rgba(4,126,126,.08)}
.detail-edit-input.num{text-align:right;font-variant-numeric:tabular-nums}
.detail-footer{display:flex;gap:8px;padding:14px 20px;border-top:1px solid #e8eaed;background:#f8fafb;flex-shrink:0}
.detail-footer .btn{flex:1;justify-content:center;height:34px}
.badge-edited{display:inline-block;margin-left:8px;font-size:10px;font-weight:700;color:#b45309;background:#fef3c7;border-radius:5px;padding:2px 7px;letter-spacing:.3px}
.badge-added{display:inline-block;margin-left:8px;font-size:10px;font-weight:700;color:#15803d;background:#dcfce7;border-radius:5px;padding:2px 7px;letter-spacing:.3px}
.detail-key{font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.4px;width:110px;flex-shrink:0;padding-top:1px}
.detail-val{font-size:13px;color:#1a1a2e;font-weight:500;word-break:break-all;flex:1}
.detail-val.num{font-variant-numeric:tabular-nums}
/* ── Toast ── */
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>GSTR Reports</span></h1>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-outline" id="btnDownload" disabled>Download Excel</button>
<select id="uploadSection" style="height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;min-width:150px">
<option value="all">All Sections</option>
<option value="b2b">B2B Only</option>
<option value="b2cl">B2CL Only</option>
<option value="b2cs">B2CS Only</option>
<option value="export">EXP Only</option>
<option value="hsn">HSN Only</option>
<option value="docs">Docs Issued Only</option>
</select>
<button class="btn btn-tcs" id="btnUpload" disabled>Upload to TCS</button>
</div>
</div>
<div class="card">
<!-- Filter bar -->
<div class="filter-bar">
<div class="fg">
<label>From Date</label>
<input type="date" id="fromDate">
</div>
<div class="fg">
<label>To Date</label>
<input type="date" id="toDate">
</div>
<div class="fg">
<label>TCS Period (MMYYYY)</label>
<input type="text" id="tcsPeriod" placeholder="e.g. 012026" maxlength="6" style="width:140px">
</div>
<button class="btn btn-primary" id="btnLoad">Load Data</button>
</div>
<!-- Summary chips -->
<div class="summary-chips" id="summaryChips" style="display:none">
<div class="chip">B2B Rows<span id="cntB2b">—</span></div>
<div class="chip">B2CL Rows<span id="cntB2cl">—</span></div>
<div class="chip">B2CS Rows<span id="cntB2cs">—</span></div>
<div class="chip">Export<span id="cntExp">—</span></div>
<div class="chip">HSN Lines<span id="cntHsn">—</span></div>
<div class="chip">Doc Types<span id="cntDocs">—</span></div>
</div>
<!-- Tabs -->
<div class="tabs" id="tabBar">
<div class="tab active" data-tab="b2b">B2B <span class="tab-badge" id="tb2b">0</span></div>
<div class="tab" data-tab="b2cl">B2CL <span class="tab-badge" id="tb2cl">0</span></div>
<div class="tab" data-tab="b2cs">B2CS <span class="tab-badge" id="tb2cs">0</span></div>
<div class="tab" data-tab="export">Direct Export <span class="tab-badge" id="texp">0</span></div>
<div class="tab" data-tab="hsn">HSN Summary <span class="tab-badge" id="thsn">0</span></div>
<div class="tab" data-tab="docs">Docs Issued <span class="tab-badge" id="tdocs">0</span></div>
</div>
<!-- Search bar -->
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search across all columns in active tab…">
<span class="srch-count" id="srchCount"></span>
<button class="btn btn-outline" id="btnAddRow" style="height:34px" disabled>+ Add Row</button>
</div>
<!-- Tab panes -->
<div class="tab-content active" id="pane-b2b">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-b2b">Load data to view B2B invoices</div><table id="tbl-b2b" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-b2cl">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-b2cl">Load data to view B2CL invoices</div><table id="tbl-b2cl" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-b2cs">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-b2cs">Load data to view B2CS invoices</div><table id="tbl-b2cs" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-export">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-export">Load data to view Export invoices</div><table id="tbl-export" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-hsn">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-hsn">Load data to view HSN summary</div><table id="tbl-hsn" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-docs">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-docs">Load data to view Documents Issued summary</div><table id="tbl-docs" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select date range and click Load Data</span>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- Row detail panel -->
<div class="detail-overlay" id="detailOverlay"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<div>
<h3 id="detailTitle">Row Details</h3>
<div class="row-num" id="detailRowNum"></div>
</div>
<button class="detail-close" id="detailClose">&#x2715;</button>
</div>
<div class="detail-body" id="detailBody"></div>
<div class="detail-footer" id="detailFooterView">
<button class="btn btn-outline" id="detailEditBtn">Edit Row</button>
</div>
<div class="detail-footer" id="detailFooterEdit" style="display:none">
<button class="btn btn-outline" id="detailDeleteBtn" style="color:#dc2626;border-color:#dc2626">Delete Row</button>
<button class="btn btn-outline" id="detailCancelBtn">Cancel</button>
<button class="btn btn-primary" id="detailSaveBtn">Save Changes</button>
</div>
</div>
<script>
(function(){
const tok=sessionStorage.getItem('portal_token');
if(!tok){window.location.href='/';return;}
// ── column definitions ────────────────────────────────────────────────────
const COLS={
b2b:[
{k:'trans_type_code',l:'Type',fmt:typeBadge},
{k:'fp',l:'FP'},
{k:'cp_gstin',l:'GSTIN',cls:'trunc-sm'},
{k:'cp_gstin_name',l:'Party',cls:'trunc'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date'},
{k:'invoice_val',l:'Value',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'cgst_amnt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'sgst_amnt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'igst_amnt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'pos',l:'POS'},{k:'rchrg',l:'RC'},
],
b2cl:[
{k:'trans_type_code',l:'Type',fmt:typeBadge},
{k:'fp',l:'FP'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date'},
{k:'invoice_val',l:'Value',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'cgst_amnt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'sgst_amnt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'igst_amnt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'pos',l:'POS'},
],
b2cs:[
{k:'trans_type_code',l:'Type',fmt:typeBadge},
{k:'fp',l:'FP'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date'},
{k:'invoice_val',l:'Value',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'cgst_amnt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'sgst_amnt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'igst_amnt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'pos',l:'POS'},
],
export:[
{k:'trans_type_code',l:'Type',fmt:typebadge2},
{k:'fp',l:'FP'},
{k:'export_type_desc',l:'Exp Type'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date'},
{k:'invoice_val',l:'Value',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
],
hsn:[
{k:'hsn_sc',l:'HSN/SAC'},
{k:'gstr1Desc',l:'Type'},
{k:'section',l:'Section'},
{k:'ack_sr_no',l:'Sl.No.',cls:'num'},
{k:'num',l:'Num',cls:'num'},
{k:'uqc',l:'UQC'},
{k:'qty',l:'Qty',cls:'num',fmt:v=>fmtNum(v,3)},
{k:'txval',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'iamt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'camt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'samt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'tcs',l:'TCS',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
],
docs:[
{k:'fp',l:'FP'},
{k:'DocumentType',l:'Doc Type'},
{k:'Series',l:'Series'},
{k:'sr_from',l:'From',cls:'trunc-sm'},
{k:'sr_to',l:'To',cls:'trunc-sm'},
{k:'totnum',l:'Total',cls:'num'},
{k:'canceled',l:'Cancelled',cls:'num'},
{k:'net_issue',l:'Net Issued',cls:'num'},
],
};
function typeBadge(v){const m={B2B:'badge-b2b',B2C:'badge-b2c',EXP:'badge-exp',DE:'badge-de'};return`<span class="badge ${m[v]||''}">${v||''}</span>`;}
function typebadge2(v){return`<span class="badge badge-exp">${v||''}</span>`;}
function fmtNum(v,dec=2){if(v==null||v==='')return'';return Number(v).toLocaleString('en-IN',{minimumFractionDigits:dec,maximumFractionDigits:dec});}
// ── state ─────────────────────────────────────────────────────────────────
const state={b2b:[],b2cl:[],b2cs:[],export:[],hsn:[],docs:[]};
let activeTab='b2b';
let searchTerm='';
// ── DOM refs ──────────────────────────────────────────────────────────────
const fromDate=document.getElementById('fromDate');
const toDate=document.getElementById('toDate');
const tcsPeriod=document.getElementById('tcsPeriod');
const btnLoad=document.getElementById('btnLoad');
const btnDownload=document.getElementById('btnDownload');
const btnUpload=document.getElementById('btnUpload');
const uploadSection=document.getElementById('uploadSection');
const statusDot=document.getElementById('statusDot');
const statusMsg=document.getElementById('statusMsg');
const summaryChips=document.getElementById('summaryChips');
const searchInput=document.getElementById('searchInput');
const srchCount=document.getElementById('srchCount');
// default to current month
(function(){
const now=new Date();
const y=now.getFullYear(),m=now.getMonth();
const from=new Date(y,m,1),to=new Date(y,m+1,0);
fromDate.value=from.toISOString().slice(0,10);
toDate.value=to.toISOString().slice(0,10);
const mm=String(m+1).padStart(2,'0');
tcsPeriod.value=`${mm}${y}`;
})();
// ── tab switching ─────────────────────────────────────────────────────────
document.getElementById('tabBar').addEventListener('click',e=>{
const t=e.target.closest('.tab');
if(!t)return;
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
activeTab=t.dataset.tab;
document.getElementById('pane-'+activeTab).classList.add('active');
applySearch();
});
// ── search ────────────────────────────────────────────────────────────────
searchInput.addEventListener('input',()=>{
searchTerm=searchInput.value.trim().toLowerCase();
applySearch();
});
function applySearch(){
const rows=state[activeTab]||[];
const filtered=filterRows(rows);
renderTable(activeTab,filtered,true);
if(searchTerm&&rows.length){
srchCount.textContent=`${filtered.length} of ${rows.length}`;
} else {
srchCount.textContent=rows.length?`${rows.length} rows`:'';
}
}
function filterRows(rows){
if(!searchTerm)return rows;
return rows.filter(r=>Object.values(r).some(v=>v!=null&&String(v).toLowerCase().includes(searchTerm)));
}
// ── helpers ────────────────────────────────────────────────────────────────
function setStatus(msg,state){
statusMsg.textContent=msg;
statusDot.className='status-dot'+(state?' '+state:'');
}
function showToast(msg,type='info'){
const t=document.getElementById('toast');
t.textContent=msg;t.className=`toast ${type} show`;
setTimeout(()=>t.classList.remove('show'),3500);
}
function renderTable(tabKey,rows,isSearch=false){
const cols=COLS[tabKey];
const tbl=document.getElementById('tbl-'+tabKey);
const empty=document.getElementById('empty-'+tabKey);
if(!rows||!rows.length){
tbl.style.display='none';
empty.textContent=isSearch?'No matching rows':'No data for selected period';
empty.style.display='block';
return;
}
tbl.querySelector('thead').innerHTML=`<tr>${cols.map(c=>`<th>${c.l}</th>`).join('')}</tr>`;
tbl.querySelector('tbody').innerHTML=rows.map((r,i)=>{
const bg=r._added?'#ecfdf5':(r._edited?'#fffbeb':'');
return`<tr data-idx="${i}"${bg?` style="background:${bg}"`:''}>${cols.map((c,ci)=>{
const v=r[c.k];
const cell=c.fmt?c.fmt(v):escHtml(v==null?'':String(v));
const badge=ci===0?(r._added?'<span class="badge-added">NEW</span>':(r._edited?'<span class="badge-edited">EDITED</span>':'')):'';
return`<td${c.cls?` class="${c.cls}"`:''}>${cell}${badge}</td>`;
}).join('')}</tr>`;
}).join('');
tbl.style.display='table';
empty.style.display='none';
// row click → detail panel
tbl.querySelector('tbody').addEventListener('click',e=>{
const tr=e.target.closest('tr');
if(!tr)return;
document.querySelectorAll('tbody tr.selected').forEach(r=>r.classList.remove('selected'));
tr.classList.add('selected');
openDetail(tabKey,rows[+tr.dataset.idx],+tr.dataset.idx);
});
}
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// ── Row detail panel ──────────────────────────────────────────────────────
const detailOverlay=document.getElementById('detailOverlay');
const detailPanel=document.getElementById('detailPanel');
const detailBody=document.getElementById('detailBody');
const detailTitle=document.getElementById('detailTitle');
const detailRowNum=document.getElementById('detailRowNum');
const detailFooterView=document.getElementById('detailFooterView');
const detailFooterEdit=document.getElementById('detailFooterEdit');
const detailEditBtn=document.getElementById('detailEditBtn');
const detailCancelBtn=document.getElementById('detailCancelBtn');
const detailSaveBtn=document.getElementById('detailSaveBtn');
const detailDeleteBtn=document.getElementById('detailDeleteBtn');
const btnAddRow=document.getElementById('btnAddRow');
const tabLabels={b2b:'B2B Invoice',b2cl:'B2CL Invoice',b2cs:'B2CS Invoice',export:'Export Invoice',hsn:'HSN Line',docs:'Document Issued'};
let curTab=null,curRow=null,curIdx=null;
function renderDetailView(){
const cols=COLS[curTab];
detailBody.innerHTML=cols.map(c=>{
const v=curRow[c.k];
const disp=v==null||v===''?'—':(c.fmt?v:String(v));
const isNum=!!c.cls?.includes('num');
const formatted=isNum&&v!=null&&v!==''?fmtNum(v):escHtml(String(disp));
return`<div class="detail-row"><div class="detail-key">${c.l}</div><div class="detail-val${isNum?' num':''}">${formatted}</div></div>`;
}).join('');
detailFooterView.style.display='flex';
detailFooterEdit.style.display='none';
}
function renderDetailEdit(){
const cols=COLS[curTab];
detailBody.innerHTML=cols.map(c=>{
const v=curRow[c.k];
const isNum=!!c.cls?.includes('num');
const val=v==null?'':String(v);
return`<div class="detail-row"><div class="detail-key">${c.l}</div>`
+`<input class="detail-edit-input${isNum?' num':''}" data-key="${c.k}" data-num="${isNum?'1':'0'}" value="${escHtml(val)}"></div>`;
}).join('');
detailFooterView.style.display='none';
detailFooterEdit.style.display='flex';
}
detailEditBtn.addEventListener('click',()=>renderDetailEdit());
detailCancelBtn.addEventListener('click',()=>renderDetailView());
detailSaveBtn.addEventListener('click',()=>{
detailBody.querySelectorAll('.detail-edit-input').forEach(inp=>{
const key=inp.dataset.key;
const raw=inp.value.trim();
if(inp.dataset.num==='1'){
curRow[key]=raw===''?null:(isNaN(+raw)?raw:+raw);
} else {
curRow[key]=raw;
}
});
if(!curRow._added) curRow._edited=true;
showToast('Row saved locally — re-download/upload to apply','success');
renderDetailView();
applySearch(); // refresh table to reflect edited/added values
});
detailDeleteBtn.addEventListener('click',()=>{
if(!confirm('Remove this row from the current dataset?'))return;
const arr=state[curTab];
const realIdx=arr.indexOf(curRow);
if(realIdx>-1) arr.splice(realIdx,1);
closeDetail();
applySearch();
showToast('Row removed locally — re-download/upload to apply','success');
});
// ── Add row ───────────────────────────────────────────────────────────────
btnAddRow.addEventListener('click',()=>{
const cols=COLS[activeTab];
const blank={};
cols.forEach(c=>{ blank[c.k]=c.cls?.includes('num')?0:''; });
blank._added=true;
state[activeTab].push(blank);
applySearch();
const idx=filterRows(state[activeTab]).indexOf(blank);
openDetail(activeTab,blank,idx<0?state[activeTab].length-1:idx);
renderDetailEdit();
});
function openDetail(tabKey,rowData,rowIndex){
curTab=tabKey;curRow=rowData;curIdx=rowIndex;
const badge=rowData._added?'<span class="badge-added">NEW</span>':(rowData._edited?'<span class="badge-edited">EDITED</span>':'');
detailTitle.innerHTML=(tabLabels[tabKey]||'Row Details')+badge;
detailRowNum.textContent=`Row ${rowIndex+1}`;
renderDetailView();
detailOverlay.classList.add('show');
detailPanel.classList.add('show');
}
function closeDetail(){
detailOverlay.classList.remove('show');
detailPanel.classList.remove('show');
document.querySelectorAll('tbody tr.selected').forEach(r=>r.classList.remove('selected'));
}
document.getElementById('detailClose').addEventListener('click',closeDetail);
detailOverlay.addEventListener('click',closeDetail);
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDetail();});
// ── load data ─────────────────────────────────────────────────────────────
btnLoad.addEventListener('click', loadData);
async function loadData(){
const from=fromDate.value,to=toDate.value;
if(!from||!to){showToast('Select from and to dates','error');return;}
btnLoad.disabled=true;
setStatus('Loading all sections…','loading');
summaryChips.style.display='none';
const hdrs={'Authorization':'Bearer '+tok};
const params=`from=${from}&to=${to}`;
try {
const [r1,r2,r3,r4,r5,r6]=await Promise.all([
fetch(`/api/gstr1/b2b?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr1/b2cl?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr1/b2cs?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr1/export?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr1/hsn?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr1/docs?${params}`,{headers:hdrs}).then(r=>r.json()),
]);
const errs=[r1,r2,r3,r4,r5,r6].filter(r=>!r.success).map(r=>r.message);
if(errs.length){setStatus('Errors: '+errs[0],'err');showToast(errs[0],'error');btnLoad.disabled=false;return;}
state.b2b=r1.data||[];state.b2cl=r2.data||[];state.b2cs=r3.data||[];
state.export=r4.data||[];state.hsn=r5.data||[];state.docs=r6.data||[];
searchInput.value='';searchTerm='';srchCount.textContent='';
['b2b','b2cl','b2cs','export','hsn','docs'].forEach(k=>renderTable(k,state[k]));
// update badges
document.getElementById('tb2b').textContent=state.b2b.length;
document.getElementById('tb2cl').textContent=state.b2cl.length;
document.getElementById('tb2cs').textContent=state.b2cs.length;
document.getElementById('texp').textContent=state.export.length;
document.getElementById('thsn').textContent=state.hsn.length;
document.getElementById('tdocs').textContent=state.docs.length;
document.getElementById('cntB2b').textContent=state.b2b.length;
document.getElementById('cntB2cl').textContent=state.b2cl.length;
document.getElementById('cntB2cs').textContent=state.b2cs.length;
document.getElementById('cntExp').textContent=state.export.length;
document.getElementById('cntHsn').textContent=state.hsn.length;
document.getElementById('cntDocs').textContent=state.docs.length;
summaryChips.style.display='flex';
const total=state.b2b.length+state.b2cl.length+state.b2cs.length+state.export.length+state.hsn.length+state.docs.length;
setStatus(`Loaded ${total} rows for ${from} to ${to}`,'ok');
btnDownload.disabled=false;
btnUpload.disabled=false;
btnAddRow.disabled=false;
showToast('Data loaded successfully','success');
} catch(e){
setStatus('Failed: '+e.message,'err');
showToast(e.message,'error');
} finally {
btnLoad.disabled=false;
}
}
// ── build workbook ─────────────────────────────────────────────────────────
function buildWorkbook(){
const wb=XLSX.utils.book_new();
function toSheet(rows,cols){
if(!rows.length) return XLSX.utils.aoa_to_sheet([cols.map(c=>c.l)]);
const ws=XLSX.utils.json_to_sheet(rows.map(r=>{
const obj={};
cols.forEach(c=>{obj[c.l]=r[c.k]??'';});
return obj;
}),{header:cols.map(c=>c.l)});
return ws;
}
const plainCols=k=>COLS[k].map(c=>({k:c.k,l:c.l}));
XLSX.utils.book_append_sheet(wb, toSheet(state.b2b, plainCols('b2b')), 'B2B');
XLSX.utils.book_append_sheet(wb, toSheet(state.b2cl, plainCols('b2cl')), 'b2c(l)');
XLSX.utils.book_append_sheet(wb, toSheet(state.b2cs, plainCols('b2cs')), 'b2c(s)');
XLSX.utils.book_append_sheet(wb, toSheet(state.export,plainCols('export')),'direct export');
XLSX.utils.book_append_sheet(wb, toSheet(state.hsn, plainCols('hsn')), 'HSN summary');
XLSX.utils.book_append_sheet(wb, toSheet(state.docs, plainCols('docs')), 'Documents Issued Summary');
return wb;
}
// ── download ───────────────────────────────────────────────────────────────
btnDownload.addEventListener('click',()=>{
try{
const wb=buildWorkbook();
const from=fromDate.value.replace(/-/g,'');
const to=toDate.value.replace(/-/g,'');
XLSX.writeFile(wb,`GSTR1_${from}_${to}.xlsx`);
showToast('Excel downloaded','success');
}catch(e){showToast('Download failed: '+e.message,'error');}
});
// ── upload to TCS ──────────────────────────────────────────────────────────
const SECTION_LABELS = { all:'All Sections', b2b:'B2B', b2cl:'B2CL', b2cs:'B2CS', export:'EXP', hsn:'HSN', docs:'Docs Issued' };
btnUpload.addEventListener('click', async () => {
const period = tcsPeriod.value.trim();
if (!/^\d{6}$/.test(period)) { showToast('Enter valid period as MMYYYY','error'); return; }
const sel = uploadSection.value; // 'all' | 'b2b' | 'b2cl' | 'b2cs' | 'export' | 'hsn' | 'docs'
const label = SECTION_LABELS[sel] || sel;
// Build payload — for a single-section upload, only that section's array is sent
// (the others stay empty), so the backend only processes the chosen section.
const payload = {
period,
b2b: sel === 'all' || sel === 'b2b' ? state.b2b : [],
b2cl: sel === 'all' || sel === 'b2cl' ? state.b2cl : [],
b2cs: sel === 'all' || sel === 'b2cs' ? state.b2cs : [],
export: sel === 'all' || sel === 'export' ? state.export : [],
hsn: sel === 'all' || sel === 'hsn' ? state.hsn : [],
docs: sel === 'all' || sel === 'docs' ? state.docs : [],
};
const total = payload.b2b.length + payload.b2cl.length + payload.b2cs.length
+ payload.export.length + payload.hsn.length + payload.docs.length;
if (!total) { showToast(`No ${label} data to upload. Load data first.`,'error'); return; }
const confirmMsg = sel === 'all'
? `Upload all sections to TCS GSP for period ${period}?\n\n` +
`B2B: ${payload.b2b.length} | B2CL: ${payload.b2cl.length} | B2CS: ${payload.b2cs.length} | ` +
`EXP: ${payload.export.length} | HSN: ${payload.hsn.length} | DOCS: ${payload.docs.length}`
: `Upload ${label} only to TCS GSP for period ${period}?\n\nRows: ${total}`;
if (!confirm(confirmMsg)) return;
btnUpload.disabled = true;
setStatus(`Uploading ${label} to TCS GSP…`,'loading');
try {
const resp = await fetch('/api/gstr1/upload-tcs', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await resp.json();
if (json.success) {
const ack = json.data?.ackNo || json.data?.ackno || JSON.stringify(json.data);
setStatus(`Uploaded ✓ Ack No: ${ack}`, 'ok');
showToast(`${label} uploaded to TCS. Ack: ` + ack, 'success');
} else {
setStatus('Upload failed: ' + json.message, 'err');
showToast(json.message, 'error');
}
} catch(e) {
setStatus('Error: ' + e.message, 'err');
showToast(e.message, 'error');
} finally {
btnUpload.disabled = false;
}
});
})();
</script>
</body>
</html>
+766
View File
@@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GSTR-2 Purchase Register | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}
.hdr h1 span{color:#7c3aed}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#7c3aed;color:#fff}.btn-primary:hover{background:#6d28d9}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#7c3aed;border:1.5px solid #7c3aed}.btn-outline:hover{background:#f5f3ff}
.btn-tcs{background:#1d4ed8;color:#fff}.btn-tcs:hover{background:#1e40af}
.btn-tcs:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.summary-chips{display:flex;gap:8px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb}
.chip{background:#fff;border:1.5px solid #ddd6fe;border-radius:10px;padding:6px 14px;font-size:11px;font-weight:600;color:#7c3aed;min-width:80px}
.chip span{font-size:15px;font-weight:700;display:block;color:#1a1a2e;margin-top:1px}
.tabs{display:flex;justify-content:space-evenly;border-bottom:2px solid #e8eaed;background:#fff}
.tab{flex:1;text-align:center;padding:14px 10px;font-size:12px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap;transition:all .15s;letter-spacing:.2px}
.tab:hover{color:#7c3aed;background:#faf5ff}
.tab.active{color:#7c3aed;border-bottom-color:#7c3aed;background:#faf5ff}
.tab-badge{display:inline-block;background:#ede9fe;color:#7c3aed;border-radius:10px;padding:1px 7px;font-size:10px;font-weight:700;margin-left:5px}
.tab.active .tab-badge{background:#7c3aed;color:#fff}
.search-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;border-bottom:1px solid #e8eaed;background:#fafbfc}
.search-bar input{flex:1;height:34px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px 0 34px;font-size:12px;font-family:inherit;color:#1a1a2e;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") no-repeat 10px center;outline:none;transition:border-color .15s}
.search-bar input:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.srch-count{font-size:11px;color:#9ca3af;white-space:nowrap;flex-shrink:0;font-weight:600}
.tab-content{display:none}
.tab-content.active{display:block}
.tbl-wrap{overflow-x:auto;overflow-y:auto;max-height:54vh}
.tbl-wrap::-webkit-scrollbar{height:7px;width:7px}
.tbl-wrap::-webkit-scrollbar-track{background:#f1f3f5}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
.tbl-wrap::-webkit-scrollbar-thumb:hover{background:#9ca3af}
table{border-collapse:collapse;min-width:100%}
thead th{background:#f5f3ff;color:#4b5563;font-weight:700;padding:10px 12px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #ddd6fe;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s;cursor:pointer}
tbody tr:hover{background:#f5f3ff}
tbody tr.selected{background:#ede9fe}
tbody td{padding:9px 12px;white-space:nowrap;color:#374151;font-size:12px}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums;font-size:12px}
tbody td.trunc{max-width:160px;overflow:hidden;text-overflow:ellipsis}
tbody td.trunc-sm{max-width:110px;overflow:hidden;text-overflow:ellipsis}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.badge-b2b{background:#dbeafe;color:#1d4ed8}
.badge-rcm{background:#fee2e2;color:#991b1b}
.badge-impg{background:#d1fae5;color:#065f46}
.badge{padding:2px 8px;border-radius:10px;font-size:10px;font-weight:700}
.itc-capital{background:#fef3c7;color:#92400e;padding:2px 7px;border-radius:8px;font-size:10px;font-weight:600}
.itc-inputs{background:#d1fae5;color:#065f46;padding:2px 7px;border-radius:8px;font-size:10px;font-weight:600}
.itc-services{background:#dbeafe;color:#1d4ed8;padding:2px 7px;border-radius:8px;font-size:10px;font-weight:600}
.itc-ineligible{background:#fee2e2;color:#991b1b;padding:2px 7px;border-radius:8px;font-size:10px;font-weight:600}
.itc-other{background:#f3f4f6;color:#374151;padding:2px 7px;border-radius:8px;font-size:10px;font-weight:600}
.detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;display:none;backdrop-filter:blur(2px)}
.detail-overlay.show{display:block}
.detail-panel{position:fixed;top:0;right:-440px;width:440px;height:100vh;background:#fff;z-index:501;box-shadow:-4px 0 24px rgba(0,0,0,.12);display:flex;flex-direction:column;transition:right .28s cubic-bezier(.4,0,.2,1)}
.detail-panel.show{right:0}
.detail-header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb;flex-shrink:0}
.detail-header h3{font-size:14px;font-weight:700;color:#1a1a2e}
.detail-header .row-num{font-size:11px;color:#9ca3af;margin-top:2px}
.detail-close{width:32px;height:32px;border:none;background:#edf2f7;border-radius:8px;cursor:pointer;font-size:18px;color:#6b7280;display:flex;align-items:center;justify-content:center;transition:all .15s}
.detail-close:hover{background:#e2e8f0;color:#1a1a2e}
.detail-body{flex:1;overflow-y:auto;padding:16px 20px}
.detail-body::-webkit-scrollbar{width:4px}.detail-body::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:2px}
.detail-row{display:flex;gap:12px;padding:9px 0;border-bottom:1px solid #f3f4f6;align-items:center}
.detail-row:last-child{border-bottom:none}
.detail-key{font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.4px;width:130px;flex-shrink:0;padding-top:1px}
.detail-val{font-size:13px;color:#1a1a2e;font-weight:500;word-break:break-all;flex:1}
.detail-val.num{font-variant-numeric:tabular-nums}
.detail-edit-input{flex:1;height:32px;border:1.5px solid #dde1e7;border-radius:6px;padding:0 10px;font-size:12px;font-family:inherit;color:#1a1a2e;outline:none;width:100%;box-sizing:border-box}
.detail-edit-input:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.08)}
.detail-edit-input.num{text-align:right;font-variant-numeric:tabular-nums}
.detail-footer{display:flex;gap:8px;padding:14px 20px;border-top:1px solid #e8eaed;background:#f8fafb;flex-shrink:0}
.detail-footer .btn{flex:1;justify-content:center;height:34px}
.badge-edited{display:inline-block;margin-left:8px;font-size:10px;font-weight:700;color:#b45309;background:#fef3c7;border-radius:5px;padding:2px 7px;letter-spacing:.3px}
.badge-added{display:inline-block;margin-left:8px;font-size:10px;font-weight:700;color:#15803d;background:#dcfce7;border-radius:5px;padding:2px 7px;letter-spacing:.3px}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>GSTR-2</span> Purchase Register</h1>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-outline" id="btnDownload" disabled>Download Excel</button>
<input type="file" id="importFileInput" accept=".xlsx,.xls" style="display:none">
<button class="btn btn-outline" id="btnImportExcel" title="Import Excel into active tab (temporary)">Import Excel</button>
<select id="uploadSection" style="height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;min-width:150px">
<option value="all">All Sections</option>
<option value="b2b">B2B Only</option>
<option value="impg">IMPG Only</option>
<option value="b2bur">B2BUR Only</option>
<option value="hsn">HSN Only</option>
<option value="cdn">CDN Only</option>
</select>
<button class="btn btn-tcs" id="btnUpload" disabled>Upload to TCS</button>
</div>
</div>
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>From Date</label>
<input type="date" id="fromDate">
</div>
<div class="fg">
<label>To Date</label>
<input type="date" id="toDate">
</div>
<div class="fg">
<label>TCS Period (MMYYYY)</label>
<input type="text" id="tcsPeriod" placeholder="e.g. 052025" maxlength="6" style="width:140px">
</div>
<button class="btn btn-primary" id="btnLoad">Load Data</button>
</div>
<div class="summary-chips" id="summaryChips" style="display:none">
<div class="chip">B2B Rows<span id="cntB2b">—</span></div>
<div class="chip">IMPG Rows<span id="cntImpg">—</span></div>
<div class="chip">B2BUR Rows<span id="cntB2bur">—</span></div>
<div class="chip">HSN Lines<span id="cntHsn">—</span></div>
<div class="chip">CDN Rows<span id="cntCdn">—</span></div>
</div>
<div class="tabs" id="tabBar">
<div class="tab active" data-tab="b2b">B2B <span class="tab-badge" id="tb2b">0</span></div>
<div class="tab" data-tab="impg">IMPG <span class="tab-badge" id="timpg">0</span></div>
<div class="tab" data-tab="b2bur">B2BUR <span class="tab-badge" id="tb2bur">0</span></div>
<div class="tab" data-tab="hsn">HSN Summary <span class="tab-badge" id="thsn">0</span></div>
<div class="tab" data-tab="cdn">CDN <span class="tab-badge" id="tcdn">0</span></div>
</div>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search across all columns in active tab…">
<span class="srch-count" id="srchCount"></span>
<button class="btn btn-outline" id="btnAddRow" style="height:34px" disabled>+ Add Row</button>
</div>
<div class="tab-content active" id="pane-b2b">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-b2b">Load data to view B2B purchases</div><table id="tbl-b2b" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-impg">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-impg">Load data to view Import of Goods</div><table id="tbl-impg" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-b2bur">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-b2bur">Load data to view B2BUR purchases</div><table id="tbl-b2bur" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-hsn">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-hsn">Load data to view HSN summary</div><table id="tbl-hsn" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="tab-content" id="pane-cdn">
<div class="tbl-outer"><div class="tbl-wrap"><div class="empty" id="empty-cdn">Load data to view Credit/Debit Notes</div><table id="tbl-cdn" style="display:none"><thead></thead><tbody></tbody></table></div></div>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select date range and click Load Data</span>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="detail-overlay" id="detailOverlay"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<div>
<h3 id="detailTitle">Row Details</h3>
<div class="row-num" id="detailRowNum"></div>
</div>
<button class="detail-close" id="detailClose">&#x2715;</button>
</div>
<div class="detail-body" id="detailBody"></div>
<div class="detail-footer" id="detailFooterView">
<button class="btn btn-outline" id="detailEditBtn">Edit Row</button>
</div>
<div class="detail-footer" id="detailFooterEdit" style="display:none">
<button class="btn btn-outline" id="detailDeleteBtn" style="color:#dc2626;border-color:#dc2626">Delete Row</button>
<button class="btn btn-outline" id="detailCancelBtn">Cancel</button>
<button class="btn btn-primary" id="detailSaveBtn">Save Changes</button>
</div>
</div>
<script>
(function(){
const tok=sessionStorage.getItem('portal_token');
if(!tok){window.location.href='/';return;}
const COLS={
b2b:[
{k:'invoice_type',l:'Type',fmt:typeBadge},
{k:'fp',l:'FP'},
{k:'cp_gstin',l:'Supplier GSTIN',cls:'trunc-sm'},
{k:'cp_gstin_name',l:'Supplier Name',cls:'trunc'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date',fmt:fmtDate},
{k:'invoice_val',l:'Inv Value',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'igst_amnt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'cgst_amnt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'sgst_amnt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'cess_amnt',l:'Cess',cls:'num',fmt:fmtNum},
{k:'pos',l:'POS'},
{k:'rchrg',l:'RC'},
{k:'eligibility_of_itc',l:'ITC Eligibility',fmt:itcBadge},
],
impg:[
{k:'invoice_type',l:'Type',fmt:typeBadge},
{k:'fp',l:'FP'},
{k:'cp_gstin',l:'Supplier GSTIN',cls:'trunc-sm'},
{k:'cp_gstin_name',l:'Supplier Name',cls:'trunc'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date',fmt:fmtDate},
{k:'invoice_val',l:'Inv Value',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'igst_amnt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'cess_amnt',l:'Cess',cls:'num',fmt:fmtNum},
{k:'pos',l:'POS'},
{k:'eligibility_of_itc',l:'ITC Eligibility',fmt:itcBadge},
{k:'is_pur_frm_sez',l:'Purchase from SEZ (Y/N)'},
{k:'bill_entry_no',l:'BoE No',cls:'trunc-sm'},
{k:'bill_entry_dt',l:'BoE Date',fmt:fmtDate},
{k:'bill_entry_val',l:'BoE Value',cls:'num',fmt:fmtNum},
],
b2bur:[
{k:'invoice_type',l:'Type',fmt:typeBadge},
{k:'fp',l:'FP'},
{k:'cp_gstin_name',l:'Supplier Name',cls:'trunc'},
{k:'invoice_category',l:'Category'},
{k:'invoice_no',l:'Invoice No',cls:'trunc-sm'},
{k:'invoice_dt',l:'Date',fmt:fmtDate},
{k:'payment_vchr_no',l:'Payment Vchr No',cls:'trunc-sm'},
{k:'payment_vchr_dt',l:'Payment Vchr Dt',fmt:fmtDate},
{k:'unique_ref_no',l:'Unique Ref No',cls:'trunc-sm'},
{k:'invoice_val',l:'Inv Value',cls:'num',fmt:fmtNum},
{k:'taxable_val',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'igst_amnt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'cgst_amnt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'sgst_amnt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'cess_amnt',l:'Cess',cls:'num',fmt:fmtNum},
{k:'pos',l:'POS'},
{k:'rchrg',l:'RC'},
{k:'eligibility_of_itc',l:'ITC Eligibility',fmt:itcBadge},
{k:'tot_tax_avlbl_itc_igst',l:'ITC IGST',cls:'num',fmt:fmtNum},
{k:'tot_tax_avlbl_itc_cgst',l:'ITC CGST',cls:'num',fmt:fmtNum},
{k:'tot_tax_avlbl_itc_sgst',l:'ITC SGST',cls:'num',fmt:fmtNum},
{k:'total_tax_itc_cess',l:'ITC Cess',cls:'num',fmt:fmtNum},
{k:'orig_invoice_no',l:'Orig Invoice No',cls:'trunc-sm'},
{k:'orig_invoice_dt',l:'Orig Invoice Dt',fmt:fmtDate},
],
hsn:[
{k:'fp',l:'FP'},
{k:'hsn_sc',l:'HSN/SAC'},
{k:'uom',l:'UQC'},
{k:'rate_of_tax',l:'Rate%',cls:'num'},
{k:'qty',l:'Qty',cls:'num',fmt:v=>fmtNum(v,3)},
{k:'txval',l:'Taxable',cls:'num',fmt:fmtNum},
{k:'iamt',l:'IGST',cls:'num',fmt:fmtNum},
{k:'camt',l:'CGST',cls:'num',fmt:fmtNum},
{k:'samt',l:'SGST',cls:'num',fmt:fmtNum},
{k:'csamt',l:'Cess',cls:'num',fmt:fmtNum},
],
cdn:[
{k:'invoice_date', l:'Date', fmt:fmtDate},
{k:'invoice_no', l:'CN/DN No', cls:'trunc-sm'},
{k:'bill_no', l:'Bill No', cls:'trunc-sm'},
{k:'supplier_name', l:'Supplier', cls:'trunc'},
{k:'supplier_gstin', l:'GSTIN', cls:'trunc-sm'},
{k:'state_pos', l:'State'},
{k:'taxable_value', l:'Taxable', cls:'num', fmt:fmtNum},
{k:'igst_rate', l:'IGST%', cls:'num'},
{k:'igst_amount', l:'IGST', cls:'num', fmt:fmtNum},
{k:'cgst_rate', l:'CGST%', cls:'num'},
{k:'cgst_amount', l:'CGST', cls:'num', fmt:fmtNum},
{k:'sgst_rate', l:'SGST%', cls:'num'},
{k:'sgst_amount', l:'SGST', cls:'num', fmt:fmtNum},
{k:'line_total_inc_gst', l:'Total incl GST', cls:'num', fmt:fmtNum},
],
};
function typeBadge(v){
const m={B2B:'badge-b2b',IMPG:'badge-impg',RCM:'badge-rcm',B2BUR:'badge-rcm'};
const lbl=v==='RCM'?'B2B':(v||'');
return`<span class="badge ${m[v]||''}">${lbl}</span>`;
}
function itcBadge(v){
if(!v)return'';
const m={'Capital Goods':'itc-capital','Inputs':'itc-inputs','Input Services':'itc-services','Ineligible':'itc-ineligible','All other ITC':'itc-other'};
return`<span class="${m[v]||'itc-other'}">${v}</span>`;
}
function fmtNum(v,dec=2){if(v==null||v==='')return'';return Number(v).toLocaleString('en-IN',{minimumFractionDigits:dec,maximumFractionDigits:dec});}
function fmtDate(v){
if(!v)return'';
const d=new Date(v);
if(isNaN(d))return String(v);
const dd=String(d.getUTCDate()).padStart(2,'0');
const mm=String(d.getUTCMonth()+1).padStart(2,'0');
return `${dd}-${mm}-${d.getUTCFullYear()}`;
}
const state={b2b:[],impg:[],b2bur:[],hsn:[],cdn:[]};
let activeTab='b2b',searchTerm='';
const fromDate=document.getElementById('fromDate');
const toDate=document.getElementById('toDate');
const tcsPeriod=document.getElementById('tcsPeriod');
const btnLoad=document.getElementById('btnLoad');
const btnDownload=document.getElementById('btnDownload');
const btnUpload=document.getElementById('btnUpload');
const uploadSection=document.getElementById('uploadSection');
const statusDot=document.getElementById('statusDot');
const statusMsg=document.getElementById('statusMsg');
const summaryChips=document.getElementById('summaryChips');
const searchInput=document.getElementById('searchInput');
const srchCount=document.getElementById('srchCount');
(function(){
const now=new Date();
const y=now.getFullYear(),m=now.getMonth();
const from=new Date(y,m,1),to=new Date(y,m+1,0);
fromDate.value=from.toISOString().slice(0,10);
toDate.value=to.toISOString().slice(0,10);
tcsPeriod.value=`${String(m+1).padStart(2,'0')}${y}`;
})();
document.getElementById('tabBar').addEventListener('click',e=>{
const t=e.target.closest('.tab');
if(!t)return;
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
activeTab=t.dataset.tab;
document.getElementById('pane-'+activeTab).classList.add('active');
applySearch();
});
searchInput.addEventListener('input',()=>{
searchTerm=searchInput.value.trim().toLowerCase();
applySearch();
});
function applySearch(){
const rows=state[activeTab]||[];
const filtered=filterRows(rows);
renderTable(activeTab,filtered,true);
srchCount.textContent=searchTerm&&rows.length?`${filtered.length} of ${rows.length}`:(rows.length?`${rows.length} rows`:'');
}
function filterRows(rows){
if(!searchTerm)return rows;
return rows.filter(r=>Object.values(r).some(v=>v!=null&&String(v).toLowerCase().includes(searchTerm)));
}
function setStatus(msg,st){statusMsg.textContent=msg;statusDot.className='status-dot'+(st?' '+st:'');}
function showToast(msg,type='info'){const t=document.getElementById('toast');t.textContent=msg;t.className=`toast ${type} show`;setTimeout(()=>t.classList.remove('show'),3500);}
function renderTable(tabKey,rows,isSearch=false){
const cols=COLS[tabKey];
const tbl=document.getElementById('tbl-'+tabKey);
const empty=document.getElementById('empty-'+tabKey);
if(!rows||!rows.length){
tbl.style.display='none';
empty.textContent=isSearch?'No matching rows':'No data for selected period';
empty.style.display='block';
return;
}
tbl.querySelector('thead').innerHTML=`<tr>${cols.map(c=>`<th>${c.l}</th>`).join('')}</tr>`;
tbl.querySelector('tbody').innerHTML=rows.map((r,i)=>{
const bg=r._added?'#ecfdf5':(r._edited?'#fffbeb':'');
return`<tr data-idx="${i}"${bg?` style="background:${bg}"`:''}>${cols.map((c,ci)=>{
const v=r[c.k];
const cell=c.fmt?c.fmt(v):escHtml(v==null?'':String(v));
const badge=ci===0?(r._added?'<span class="badge-added">NEW</span>':(r._edited?'<span class="badge-edited">EDITED</span>':'')):'';
return`<td${c.cls?` class="${c.cls}"`:''}>${cell}${badge}</td>`;
}).join('')}</tr>`;
}).join('');
tbl.style.display='table';
empty.style.display='none';
tbl.querySelector('tbody').addEventListener('click',e=>{
const tr=e.target.closest('tr');
if(!tr)return;
document.querySelectorAll('tbody tr.selected').forEach(r=>r.classList.remove('selected'));
tr.classList.add('selected');
openDetail(tabKey,rows[+tr.dataset.idx],+tr.dataset.idx);
});
}
function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
const detailOverlay=document.getElementById('detailOverlay');
const detailPanel=document.getElementById('detailPanel');
const detailBody=document.getElementById('detailBody');
const detailTitle=document.getElementById('detailTitle');
const detailRowNum=document.getElementById('detailRowNum');
const detailFooterView=document.getElementById('detailFooterView');
const detailFooterEdit=document.getElementById('detailFooterEdit');
const detailEditBtn=document.getElementById('detailEditBtn');
const detailCancelBtn=document.getElementById('detailCancelBtn');
const detailSaveBtn=document.getElementById('detailSaveBtn');
const detailDeleteBtn=document.getElementById('detailDeleteBtn');
const btnAddRow=document.getElementById('btnAddRow');
const tabLabels={b2b:'B2B Invoice',impg:'IMPG Invoice',b2bur:'B2BUR Invoice',hsn:'HSN Line',cdn:'Credit/Debit Note'};
let curTab=null,curRow=null,curIdx=null;
function renderDetailView(){
const cols=COLS[curTab];
detailBody.innerHTML=cols.map(c=>{
const v=curRow[c.k];
const disp=v==null||v===''?'—':(c.fmt?v:String(v));
const isNum=!!c.cls?.includes('num');
const formatted=isNum&&v!=null&&v!==''?fmtNum(v):escHtml(String(disp));
return`<div class="detail-row"><div class="detail-key">${c.l}</div><div class="detail-val${isNum?' num':''}">${formatted}</div></div>`;
}).join('');
detailFooterView.style.display='flex';
detailFooterEdit.style.display='none';
}
function renderDetailEdit(){
const cols=COLS[curTab];
detailBody.innerHTML=cols.map(c=>{
const v=curRow[c.k];
const isNum=!!c.cls?.includes('num');
const val=v==null?'':String(v);
return`<div class="detail-row"><div class="detail-key">${c.l}</div>`
+`<input class="detail-edit-input${isNum?' num':''}" data-key="${c.k}" data-num="${isNum?'1':'0'}" value="${escHtml(val)}"></div>`;
}).join('');
detailFooterView.style.display='none';
detailFooterEdit.style.display='flex';
// Paste support: pasting a tab/newline-separated row of values (e.g. copied
// from Excel) into any field fills that field and the ones below it in order.
const inputs=Array.from(detailBody.querySelectorAll('.detail-edit-input'));
inputs.forEach((inp,startIdx)=>{
inp.addEventListener('paste',e=>{
const text=(e.clipboardData||window.clipboardData).getData('text');
const parts=text.split(/\r\n|\r|\n|\t/).map(s=>s.trim());
if(parts.length<2)return; // single value — let default paste happen
e.preventDefault();
parts.forEach((p,i)=>{
const target=inputs[startIdx+i];
if(!target)return;
target.value=p;
});
});
});
}
detailEditBtn.addEventListener('click',()=>renderDetailEdit());
detailCancelBtn.addEventListener('click',()=>renderDetailView());
detailSaveBtn.addEventListener('click',()=>{
detailBody.querySelectorAll('.detail-edit-input').forEach(inp=>{
const key=inp.dataset.key;
const raw=inp.value.trim();
if(inp.dataset.num==='1'){
curRow[key]=raw===''?null:(isNaN(+raw)?raw:+raw);
} else {
curRow[key]=raw;
}
});
if(!curRow._added) curRow._edited=true;
showToast('Row saved locally — re-download/upload to apply','success');
renderDetailView();
applySearch();
});
detailDeleteBtn.addEventListener('click',()=>{
if(!confirm('Remove this row from the current dataset?'))return;
const arr=state[curTab];
const realIdx=arr.indexOf(curRow);
if(realIdx>-1) arr.splice(realIdx,1);
closeDetail();
applySearch();
showToast('Row removed locally — re-download/upload to apply','success');
});
btnAddRow.addEventListener('click',()=>{
const cols=COLS[activeTab];
const blank={};
cols.forEach(c=>{ blank[c.k]=c.cls?.includes('num')?0:''; });
blank._added=true;
state[activeTab].push(blank);
applySearch();
const idx=filterRows(state[activeTab]).indexOf(blank);
openDetail(activeTab,blank,idx<0?state[activeTab].length-1:idx);
renderDetailEdit();
});
function openDetail(tabKey,rowData,rowIndex){
curTab=tabKey;curRow=rowData;curIdx=rowIndex;
const badge=rowData._added?'<span class="badge-added">NEW</span>':(rowData._edited?'<span class="badge-edited">EDITED</span>':'');
detailTitle.innerHTML=(tabLabels[tabKey]||'Row Details')+badge;
detailRowNum.textContent=`Row ${rowIndex+1}`;
renderDetailView();
detailOverlay.classList.add('show');
detailPanel.classList.add('show');
}
function closeDetail(){
detailOverlay.classList.remove('show');
detailPanel.classList.remove('show');
document.querySelectorAll('tbody tr.selected').forEach(r=>r.classList.remove('selected'));
}
document.getElementById('detailClose').addEventListener('click',closeDetail);
detailOverlay.addEventListener('click',closeDetail);
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDetail();});
// ── load data ─────────────────────────────────────────────────────────────
btnLoad.addEventListener('click', loadData);
async function loadData(){
const from=fromDate.value,to=toDate.value;
if(!from||!to){showToast('Select from and to dates','error');return;}
btnLoad.disabled=true;
setStatus('Loading purchase data…','loading');
summaryChips.style.display='none';
const hdrs={'Authorization':'Bearer '+tok};
const params=`from=${from}&to=${to}`;
try {
const [rb2b,rb2bur,rhsn,rcdn]=await Promise.all([
fetch(`/api/gstr2/b2b?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr2/b2bur?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr2/hsn?${params}`,{headers:hdrs}).then(r=>r.json()),
fetch(`/api/gstr2/cdn?${params}`,{headers:hdrs}).then(r=>r.json()),
]);
const errs=[rb2b,rb2bur,rhsn,rcdn].filter(r=>!r.success).map(r=>r.message);
if(errs.length){setStatus('Error: '+errs[0],'err');showToast(errs[0],'error');btnLoad.disabled=false;return;}
const allB2b=rb2b.data||[];
state.b2b =allB2b.filter(r=>r.invoice_type==='B2B'||r.invoice_type==='RCM');
state.impg =allB2b.filter(r=>r.invoice_type==='IMPG');
state.b2bur=rb2bur.data||[];
state.hsn =rhsn.data||[];
state.cdn =rcdn.data||[];
searchInput.value='';searchTerm='';srchCount.textContent='';
['b2b','impg','b2bur','hsn','cdn'].forEach(k=>renderTable(k,state[k]));
document.getElementById('tb2b').textContent=state.b2b.length;
document.getElementById('timpg').textContent=state.impg.length;
document.getElementById('tb2bur').textContent=state.b2bur.length;
document.getElementById('thsn').textContent=state.hsn.length;
document.getElementById('tcdn').textContent=state.cdn.length;
document.getElementById('cntB2b').textContent=state.b2b.length;
document.getElementById('cntImpg').textContent=state.impg.length;
document.getElementById('cntB2bur').textContent=state.b2bur.length;
document.getElementById('cntHsn').textContent=state.hsn.length;
document.getElementById('cntCdn').textContent=state.cdn.length;
summaryChips.style.display='flex';
const total=allB2b.length+state.b2bur.length+state.hsn.length+state.cdn.length;
setStatus(`Loaded ${total} rows for ${from} to ${to}`,'ok');
btnDownload.disabled=false;
btnUpload.disabled=false;
btnAddRow.disabled=false;
showToast('Data loaded successfully','success');
} catch(e){
setStatus('Failed: '+e.message,'err');
showToast(e.message,'error');
} finally {
btnLoad.disabled=false;
}
}
// ── download ───────────────────────────────────────────────────────────────
btnDownload.addEventListener('click',()=>{
try{
const wb=XLSX.utils.book_new();
const toSheet=(rows,cols)=>{
if(!rows.length)return XLSX.utils.aoa_to_sheet([cols.map(c=>c.l)]);
return XLSX.utils.json_to_sheet(rows.map(r=>{
const obj={};cols.forEach(c=>{obj[c.l]=r[c.k]??'';});return obj;
}),{header:cols.map(c=>c.l)});
};
const plain=k=>COLS[k].map(c=>({k:c.k,l:c.l}));
XLSX.utils.book_append_sheet(wb,toSheet(state.b2b,plain('b2b')),'B2B');
XLSX.utils.book_append_sheet(wb,toSheet(state.impg,plain('impg')),'IMPG');
XLSX.utils.book_append_sheet(wb,toSheet(state.b2bur,plain('b2bur')),'B2BUR');
XLSX.utils.book_append_sheet(wb,toSheet(state.hsn,plain('hsn')),'HSN Summary');
XLSX.utils.book_append_sheet(wb,toSheet(state.cdn,plain('cdn')),'CDN');
const from=fromDate.value.replace(/-/g,''),to=toDate.value.replace(/-/g,'');
XLSX.writeFile(wb,`GSTR2_${from}_${to}.xlsx`);
showToast('Excel downloaded','success');
}catch(e){showToast('Download failed: '+e.message,'error');}
});
// ── import excel (temporary) ───────────────────────────────────────────────
const btnImportExcel=document.getElementById('btnImportExcel');
const importFileInput=document.getElementById('importFileInput');
btnImportExcel.addEventListener('click',()=>importFileInput.click());
// Parse any date string/object → YYYY-MM-DD (what toTcsDate expects)
function parseToIso(v){
if(!v&&v!==0) return '';
// JS Date object (from cellDates:true)
if(v instanceof Date) return isNaN(v)?'':v.toISOString().slice(0,10);
const s=String(v).trim();
if(!s) return '';
// Already YYYY-MM-DD
if(/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0,10);
// DD-MM-YYYY or DD/MM/YYYY
const m=s.match(/^(\d{2})[\/\-](\d{2})[\/\-](\d{4})/);
if(m) return `${m[3]}-${m[2]}-${m[1]}`;
// MM/DD/YYYY (SheetJS default string)
const m2=s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if(m2) return `${m2[3]}-${String(m2[1]).padStart(2,'0')}-${String(m2[2]).padStart(2,'0')}`;
return s;
}
importFileInput.addEventListener('change',e=>{
const file=e.target.files[0];
if(!file)return;
importFileInput.value=''; // reset so same file can re-import
const reader=new FileReader();
reader.onload=evt=>{
try{
const wb=XLSX.read(evt.target.result,{type:'array',cellDates:true});
// Map sheet name → tab key
const sheetMap={'B2B':'b2b','IMPG':'impg','B2BUR':'b2bur','HSN Summary':'hsn','HSN':'hsn','CDN':'cdn'};
let imported=0;
wb.SheetNames.forEach(sname=>{
const tabKey=sheetMap[sname];
if(!tabKey) return;
const cols=COLS[tabKey];
// build label→key map and date-key set
const l2k={};
const dateCols=new Set();
cols.forEach(c=>{
l2k[c.l.toLowerCase()]=c.k;
if(c.fmt===fmtDate) dateCols.add(c.k);
});
const rows=XLSX.utils.sheet_to_json(wb.Sheets[sname],{raw:true,defval:'',cellDates:true});
if(!rows.length) return;
const mapped=rows.map(r=>{
const obj={_added:true};
Object.entries(r).forEach(([h,v])=>{
const key=l2k[h.trim().toLowerCase()];
if(!key)return;
const col=cols.find(c=>c.k===key);
const isNum=!!col?.cls?.includes('num');
if(dateCols.has(key)){
obj[key]=parseToIso(v);
} else {
obj[key]=isNum&&v!==''&&v!=null?+v:v;
}
});
return obj;
});
state[tabKey]=[...state[tabKey],...mapped];
renderTable(tabKey,state[tabKey]);
document.getElementById('t'+tabKey).textContent=state[tabKey].length;
imported+=mapped.length;
});
if(imported){
btnDownload.disabled=false; btnUpload.disabled=false;
document.getElementById('btnAddRow').disabled=false;
summaryChips.style.display='flex';
['b2b','impg','b2bur','hsn','cdn'].forEach(k=>{
const el=document.getElementById({b2b:'cntB2b',impg:'cntImpg',b2bur:'cntB2bur',hsn:'cntHsn',cdn:'cntCdn'}[k]);
if(el) el.textContent=state[k].length||'—';
});
setStatus(`Imported ${imported} row(s) from Excel (temporary)`,'ok');
showToast(`Imported ${imported} row(s) — not saved to DB`,'success');
applySearch();
} else {
showToast('No matching sheets found. Expected: B2B, IMPG, B2BUR, HSN Summary, CDN','error');
}
}catch(err){showToast('Import failed: '+err.message,'error');}
};
reader.readAsArrayBuffer(file);
});
// ── upload to TCS ──────────────────────────────────────────────────────────
const SECTION_LABELS = { all:'All Sections', b2b:'B2B', impg:'IMPG', b2bur:'B2BUR', hsn:'HSN', cdn:'CDN' };
btnUpload.addEventListener('click', async () => {
const period = tcsPeriod.value.trim();
if (!/^\d{6}$/.test(period)) { showToast('Enter valid period as MMYYYY','error'); return; }
const sel = uploadSection.value; // 'all' | 'b2b' | 'impg' | 'b2bur' | 'hsn' | 'cdn'
const label = SECTION_LABELS[sel] || sel;
// b2b/impg all feed the single 'b2b' purchase array on the backend (b2b already includes RCM rows)
const purchaseSel = sel === 'all' || sel === 'b2b' || sel === 'impg';
const payload = {
period,
b2b: purchaseSel ? [
...(sel === 'all' || sel === 'b2b' ? state.b2b : []),
...(sel === 'all' || sel === 'impg' ? state.impg : []),
] : [],
b2bur: sel === 'all' || sel === 'b2bur' ? state.b2bur : [],
hsn: sel === 'all' || sel === 'hsn' ? state.hsn : [],
cdn: sel === 'all' || sel === 'cdn' ? state.cdn : [],
};
const total = payload.b2b.length + payload.b2bur.length + payload.hsn.length + payload.cdn.length;
if (!total) { showToast(`No ${label} data to upload. Load data first.`,'error'); return; }
const confirmMsg = sel === 'all'
? `Upload all sections to TCS GSP for period ${period}?\n\n` +
`B2B (incl. RCM): ${state.b2b.length} | IMPG: ${state.impg.length} | B2BUR: ${state.b2bur.length} | HSN: ${state.hsn.length} | CDN: ${state.cdn.length}`
: `Upload ${label} only to TCS GSP for period ${period}?\n\nRows: ${total}`;
if (!confirm(confirmMsg)) return;
btnUpload.disabled = true;
setStatus(`Uploading ${label} to TCS GSP…`,'loading');
try {
const resp = await fetch('/api/gstr2/upload-tcs', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await resp.json();
if (json.success) {
setStatus('Uploaded ✓ ' + json.message, 'ok');
showToast(json.message, 'success');
} else {
setStatus('Upload failed: ' + json.message, 'err');
showToast(json.message, 'error');
}
} catch(e) {
setStatus('Error: ' + e.message, 'err');
showToast(e.message, 'error');
} finally {
btnUpload.disabled = false;
}
});
})();
</script>
</body>
</html>
+447
View File
@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Guide / Help — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:16px;max-width:1200px;margin:0 auto;display:grid;grid-template-columns:220px 1fr;gap:18px;align-items:start}
@media(max-width:860px){main{grid-template-columns:1fr}}
.toc{position:sticky;top:62px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px;max-height:calc(100vh - 80px);overflow:auto}
@media(max-width:860px){.toc{position:static;max-height:none}}
.toc-t{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:8px}
.toc a{display:block;padding:6px 8px;border-radius:6px;font-size:12px;color:var(--text2);text-decoration:none;margin-bottom:1px}
.toc a:hover{background:var(--surface2);color:var(--text)}
.toc a.active{background:rgba(26,111,255,.12);color:var(--blue);font-weight:600}
.hero{background:linear-gradient(135deg,rgba(26,111,255,.12),rgba(159,90,253,.08));border:1px solid var(--border);border-radius:14px;padding:20px 22px;margin-bottom:18px}
.hero h1{font-family:var(--display);font-size:30px;letter-spacing:.5px;color:var(--text)}
.hero p{font-size:13px;color:var(--text2);margin-top:6px;line-height:1.55;max-width:640px}
.hero .note{margin-top:10px;font-size:11.5px;color:var(--text3);background:var(--surface3);border:1px solid var(--border);border-radius:8px;padding:8px 11px;display:inline-block}
.sec{background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:14px;overflow:hidden;scroll-margin-top:62px}
.sec summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;align-items:center;gap:10px;background:var(--surface3);font-weight:700;font-size:14px;color:var(--text)}
.sec summary::-webkit-details-marker{display:none}
.sec summary .chev{margin-left:auto;color:var(--text3);transition:transform .15s;font-size:14px}
.sec[open] summary .chev{transform:rotate(90deg)}
.sec-ico{width:30px;height:30px;border-radius:8px;background:rgba(26,111,255,.12);display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
.sec-bd{padding:16px 18px}
.sec-bd p.lead{font-size:12.5px;color:var(--text2);margin-bottom:12px;line-height:1.6}
ol.steps{counter-reset:step;list-style:none;padding:0}
ol.steps>li{position:relative;padding:8px 0 8px 34px;font-size:13px;color:var(--text);line-height:1.55;border-bottom:1px solid var(--border)}
ol.steps>li:last-child{border-bottom:none}
ol.steps>li::before{counter-increment:step;content:counter(step);position:absolute;left:0;top:8px;width:22px;height:22px;border-radius:6px;background:var(--blue);color:#fff;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;font-family:var(--mono)}
ol.steps b{color:var(--text)}
.badge{display:inline-block;font-size:9.5px;font-weight:700;padding:1px 7px;border-radius:20px;background:var(--surface2);border:1px solid var(--border2);color:var(--text2);margin:0 2px}
.b-blue{background:rgba(26,111,255,.12);color:var(--blue);border-color:rgba(26,111,255,.25)}
.b-green{background:rgba(13,187,138,.12);color:var(--green);border-color:rgba(13,187,138,.25)}
.b-amber{background:rgba(245,166,35,.12);color:var(--amber);border-color:rgba(245,166,35,.25)}
.b-teal{background:rgba(6,182,212,.12);color:var(--teal);border-color:rgba(6,182,212,.25)}
.flow{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:10px 0 14px;font-size:11.5px}
.flow span.step{background:var(--surface2);border:1px solid var(--border2);border-radius:20px;padding:4px 11px;color:var(--text2);font-weight:600}
.flow span.arrow{color:var(--text3)}
.tip{font-size:12px;color:var(--text2);background:var(--surface3);border-left:3px solid var(--teal);border-radius:6px;padding:8px 11px;margin-top:10px;line-height:1.5}
.tip b{color:var(--teal)}
.warn{font-size:12px;color:var(--text2);background:rgba(245,166,35,.07);border-left:3px solid var(--amber);border-radius:6px;padding:8px 11px;margin-top:10px;line-height:1.5}
.warn b{color:var(--amber)}
.linklike{color:var(--blue);font-weight:600}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/guide" class="nav-link active">Guide / Help</a></div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main>
<div class="toc" id="toc">
<div class="toc-t">On this page</div>
<a href="#start">1. Getting Started</a>
<a href="#bom">2. BOM Requests</a>
<a href="#items">3. Items</a>
<a href="#workorder">4. Work Order</a>
<a href="#batchissue">5. Batch Issuance</a>
<a href="#requirements">6. Requirements</a>
<a href="#prodorder">7. Production Order</a>
<a href="#issue">8. Issue for Production</a>
<a href="#receipt">9. Receipt from Production</a>
<a href="#verifywo">10. Verify Work Order</a>
<a href="#manpower">11. Man Power</a>
<a href="#oee">12. OEE</a>
<a href="#purchase">13. Purchase</a>
<a href="#custvendor">14. Customer / Vendor</a>
<a href="#approvals">15. Approvals</a>
<a href="#projects">16. Projects</a>
<a href="#reports">17. Reports &amp; Dashboards</a>
<a href="#documents">18. Documents</a>
<a href="#profile">19. Your Profile</a>
</div>
<div>
<div class="hero">
<h1>User Guide</h1>
<p>A step-by-step walkthrough of how to work in this portal — from logging in to completing everyday tasks like Work Orders, Production, Purchases and Approvals. Click any section below to expand it.</p>
<div class="note">This guide covers day-to-day work only. Admin setup (user permissions, system settings) is documented separately for administrators.</div>
</div>
<details class="sec" id="start" open>
<summary><span class="sec-ico">🔑</span> 1. Getting Started <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">A few things to set up the first time you log in.</p>
<ol class="steps">
<li>Log in with the <b>username and password</b> given to you. Closing the browser fully ends your session — you'll need to log in again next time.</li>
<li>The <b>sidebar</b> on the left shows only the modules you've been granted. If something you need is missing, ask your admin to grant it — you don't need to request it here.</li>
<li>Open <b>Profile</b> (top-right area / sidebar) and check three things: your <b>full name &amp; email</b> are correct, your <b>SAP User ID &amp; Password</b> are set (needed for anything that posts to SAP — Work Orders, Production, Purchase), and your <b>Signature image</b> is uploaded if you'll be approving or issuing anything — it prints on Work Order PDFs wherever you sign.</li>
</ol>
<div class="tip"><b>Tip:</b> If a page says "You are not assigned this permission," it means an approval step needs to be granted to you — ask your admin, don't try to work around it.</div>
<div class="tip"><b>Tip:</b> Keeping your <b>email</b> correct in Profile matters — Work Order, Production Order and Batch Issuance now email whoever needs to act next at each stage, so you'll be notified automatically once something is waiting on you (as long as email is set up by your admin).</div>
</div>
</details>
<details class="sec" id="bom">
<summary><span class="sec-ico">🧪</span> 2. BOM Requests <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">Bill of Materials changes go through a multi-level review before they reach SAP.</p>
<div class="flow"><span class="step">Submitted</span><span class="arrow">→</span><span class="step">Manager (L1)</span><span class="arrow">→</span><span class="step">Sr. Manager (L2)</span><span class="arrow">→</span><span class="step">SAP Adder (Final)</span><span class="arrow">→</span><span class="step badge b-green">Pushed to SAP</span></div>
<ol class="steps">
<li>Open <b>BOM</b> and submit a new request with the product, components and quantities.</li>
<li>Each approver in the chain reviews and either <b>approves</b> (moves to the next level) or <b>rejects</b> (sends it back with a reason).</li>
<li>Once every level approves, the final approver pushes it into SAP B1.</li>
<li>Track status any time from the BOM list — Pending / L1 Approved / L2 Approved / Pushed / Rejected.</li>
</ol>
</div>
</details>
<details class="sec" id="items">
<summary><span class="sec-ico">📦</span> 3. Items <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Use <b>Create Item</b> to add a new SAP item with its code, description, group and UoM.</li>
<li>Use <b>View Items</b> to search existing items before creating a duplicate.</li>
</ol>
</div>
</details>
<details class="sec" id="workorder">
<summary><span class="sec-ico">📋</span> 4. Work Order <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">A Work Order records the batch's product, raw materials and packing materials, and passes through a QA/Production sign-off chain before it's fully approved.</p>
<div class="flow"><span class="step">Prepared By QA</span><span class="arrow">→</span><span class="step">Checked By Store In-Charge</span><span class="arrow">→</span><span class="step">Checked By Mgr. Production</span><span class="arrow">→</span><span class="step badge b-green">Approved By Mgr. QA</span></div>
<ol class="steps">
<li>Open <b>Work Order</b> and click <b>+ New Work Order</b>. Fill in the product, batch size, total units, MFG/EXP dates, and the Raw Material / Packing Material tables (or a single Components table if the product has no real raw materials).</li>
<li>Enter <b>Weighing Balance ID</b> and <b>AR No.</b> per raw material row if known at this stage — they can also be filled in later by whoever issues the item.</li>
<li>Save. The order enters the chain above — whoever holds the current step can <b>Approve</b> or <b>Reject</b> it from the Work Order list.</li>
<li>Once fully approved, open the order and use <b>Print</b> (browser print) or <b>Download PDF</b> (a ready-to-file PDF) to get the printable Work Order — it shows Qty issued and the Issued/Received/Verified By columns as they get filled in (see section 10).</li>
</ol>
<div class="warn"><b>Note:</b> A blood-bag finished good's Expiry date auto-fills to 3 years after the Mfg date during Batch Issuance — you can still override it, but Expiry must always be after Mfg.</div>
<div class="tip"><b>Note:</b> "Checked By QC" currently isn't part of this chain (it's switched off in system settings), so it's left out above. If it's ever switched back on, it appears again between Prepared By QA and Checked By Store In-Charge.</div>
<div class="tip"><b>Header Profiles:</b> the company name, address, FORM NO., dates and logo printed at the top of the Work Order PDF can differ by product group (e.g. Blood Bag vs. CAPD vs. Accessories) — open <b>Work Order → Header Profiles</b> to manage them. Whoever holds "Approved By (Manager QA)" can manage these too, not just an admin. Each Work Order automatically picks the right profile from its product's SAP item group; if none match, the profile marked <b>Default</b> is used.</div>
</div>
</details>
<details class="sec" id="batchissue">
<summary><span class="sec-ico">🗓️</span> 5. Batch Issuance <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Open <b>Batch Issuance</b> and select the product/requirement this batch fulfils. Each batch's <b>Total Units</b> defaults to that item's full pending quantity — edit it down (or add more batches) if you're splitting the requirement across several batches.</li>
<li>Enter the <b>Mfg date</b> — for blood-bag finished goods the <b>Exp date</b> auto-fills to 3 years later (minus a day/month so it lands exactly at the end of the shelf life, e.g. 10-Jan-2026 → 09-Jan-2029; editable). The Exp date picker won't let you choose a date before Mfg.</li>
<li>Save to record the intimation. Its status shows as <span class="badge b-amber">SENT TO QA</span> until a Work Order is generated for at least one of its batches, then <b>PARTIALLY CAPTURED</b> or <span class="badge b-green">FULLY CAPTURED</span> once every batch has one.</li>
</ol>
<div class="warn"><b>Note:</b> Once a Work Order has been generated from a batch, that Intimation locks — Edit and Delete are disabled (editing afterward could desync from a Work Order that already copied its data), and that same (item, batch) can't be used to generate a second Work Order.</div>
<div class="tip"><b>Tip:</b> If your admin has turned on the SFG Workflow, a second <b>Without Intimation</b> tab appears — search and add items directly with no Requirement needed at all. It's restricted to items whose SAP Item Group is tagged <b>Without Intimation</b> in Item Group Rules (Admin). You can also set a <b>Batch Size (Ltr)</b> here — it's fetched automatically into the Work Order's Solution Batch Size when generated from this batch.</div>
</div>
</details>
<details class="sec" id="requirements">
<summary><span class="sec-ico">📝</span> 6. Requirements <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Open <b>Requirements</b> and log a new requirement period with <b>From</b> and <b>To</b> dates — To must be strictly after From (you'll see an inline alert if not).</li>
<li>Not sure what quantity to request? Use the <b>Calculator</b> tab: set your two comparison periods, pick the Stock and Quarantine warehouses, add items, then <b>Calculate All</b> — it works out Average Sale/Month, Stock in Hand and a suggested Requirement per item from historical sale data and current SAP stock.</li>
<li>Tick the items you want, then <b>Send Selected to New Requirement →</b> — it carries the computed quantities straight into the Create tab so you can review and submit.</li>
<li>Requirements drive what shows up as "ready" for Batch Issuance and Work Orders.</li>
</ol>
<div class="tip"><b>Optional — Store Review Workflow:</b> if your admin has turned this on, you can <b>Share with Store</b> a requirement before raising Batch Issuance. Store cross-checks it against SAP's own MRP Wizard, adjusts quantities if needed, and reverts it back to you — you'll see exactly what changed in a <b>Before / After</b> summary. It's a single round trip (no further back-and-forth), and it's optional unless your admin has separately made it a hard gate on raising Batch Intimation.</div>
</div>
</details>
<details class="sec" id="prodorder">
<summary><span class="sec-ico">🏭</span> 7. Production Order <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">A Production Order is created from an approved Work Order and moves through a fixed sequence in SAP.</p>
<div class="flow"><span class="step">Create</span><span class="arrow">→</span><span class="step">Release</span><span class="arrow">→</span><span class="step">Issuance</span><span class="arrow">→</span><span class="step">Receipt</span><span class="arrow">→</span><span class="step">Transfer to FG</span><span class="arrow">→</span><span class="step badge b-green">Close</span></div>
<ol class="steps">
<li>Open <b>Production Order</b>, pick the approved Work Order to create from (or a manual entry, if you're permitted to create without a Work Order). A Work Order can only ever produce <b>one</b> Production Order — once it has one, it no longer appears in this picker.</li>
<li>Components that exist in both the item's SAP BOM and the Work Order's approved materials are pre-filled with the <b>Work Order's approved quantity</b>, not SAP's plain BOM-ratio calculation — it already reflects any real overage/measured adjustment approved for this batch. Any Work Order item that isn't in the standard BOM at all is added as an extra component automatically.</li>
<li><b>Release</b> the order once it's created — this makes it available for issuance.</li>
<li>Go to <b>Issue</b> to post the raw material issuance (see section 8 for the full flow).</li>
<li>Go to <b>Receipt</b> to receive the finished quantity into stock (section 9).</li>
<li>Once fully received, <b>Transfer to Finished Goods</b> then <b>Close</b> the order.</li>
<li>Open any order's detail to see the <b>QA — Approved vs Issued</b> comparison: BOM Planned vs Work Order Approved vs actually Issued, with mismatches highlighted. A component with no entry in the Work Order at all is flagged <b>NOT IN WORK ORDER</b>, and one that also isn't a real ingredient anywhere in the item's BOM is flagged <b>NOT IN BOM</b> too — so QA can catch anything added directly on the Production Order before releasing/closing. (Solution containers, e.g. a chemical solution that's approved via its own raw ingredients, are exempt from this flag — that's expected, not an error.)</li>
<li><b>Raise Deviation</b> — once an order has completed Issuance, you can raise a <b>Shortage</b> (extra quantity needed), <b>Substitution</b> (replace one item with another) or <b>Damage/Loss</b> deviation with a reason. Any SAP write-off for a Damage/Loss deviation posts automatically — there's no separate checkbox to trigger it. If your admin requires QA approval, the deviation sits <b>Pending</b> until QA approves or rejects it; approving applies the change to the Production Order, rejecting reverses anything it already did. Whoever raised it and whoever holds the Deviation approval step both get an email at every stage — raised, approved, and rejected.</li>
</ol>
<div class="warn"><b>Optional — Pre-PWO Store Review:</b> if your admin has turned this on, creating from a Work Order no longer writes to SAP straight away. It first stages a <b>Pre-PWO</b> in the portal (same components table, editable, nothing sent to SAP yet) — find it under the new <b>Pre-PWO</b> tab. You must <b>Share it with Store</b>, who can adjust quantities or add/remove/substitute components outright, then revert it back to you. This is a <b>hard gate</b>: the order cannot be pushed to SAP until Store has completed that review — there's no way to skip ahead. Once pushed, the Pre-PWO locks and the normal Release → Issuance → Receipt → Transfer to FG → Close sequence above continues as usual.</div>
</div>
</details>
<details class="sec" id="issue">
<summary><span class="sec-ico">📤</span> 8. Issue for Production <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">You don't have to issue every component at once — pick exactly what you're issuing today.</p>
<ol class="steps">
<li>Open <b>Issue</b>, pick a Released order from the grid (search by item, description or document/order number if needed).</li>
<li>Each component shows as a card with a <b>checkbox</b> (checked by default), the quantity to issue, warehouse and any batches. <b>Uncheck</b> any item you don't want to issue right now — its inputs grey out.</li>
<li>Use <b>✕ Remove</b> to take an item out of the list entirely for this session (a "Restore" option appears if you remove one by mistake). This doesn't affect SAP — it just declutters your current screen.</li>
<li>Use <b>Select all / Clear all</b> at the top of the list for quick bulk changes, and check the running "<b>X of Y selected · Qty to issue Z</b>" summary before posting.</li>
<li>Adjust quantities/warehouse/batches as needed, then click <b>Issue to Production</b>. Only the checked items with a quantity are posted to SAP.</li>
<li>Items already fully issued show a <span class="badge b-green">✓ Fully Issued</span> badge and can't be changed.</li>
</ol>
<div class="tip"><b>Tip:</b> If you're restricted to certain Item Groups (Admin → Issue Items), items outside your allowed groups simply won't appear.</div>
</div>
</details>
<details class="sec" id="receipt">
<summary><span class="sec-ico">📥</span> 9. Receipt from Production <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Open <b>Receipt</b> and select the order (fully or partially issued orders both show up here).</li>
<li>Enter the received quantity per warehouse line. The <b>Transaction Type</b> (Complete/Reject) is set automatically based on the warehouse you choose — you don't pick it manually.</li>
<li>Enter batch, Mfg and Exp dates if the item requires batch tracking.</li>
<li>Submit to post the receipt into SAP.</li>
<li>Check <b>Receipts History</b> any time to see everything received against an order.</li>
</ol>
</div>
</details>
<details class="sec" id="verifywo">
<summary><span class="sec-ico">✅</span> 10. Verify Work Order <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">This is where "Issued By", "Received By" and "Verified By" get recorded for the printed Work Order — per material row, since different items are often issued/received/verified on different days by different people.</p>
<div class="flow"><span class="step badge b-teal">Issued By</span><span class="arrow">→</span><span class="step badge b-blue">Received By</span><span class="arrow">→</span><span class="step badge b-green">Verified By</span></div>
<ol class="steps">
<li>Open <b>Verify Work Order</b> — it lists Production Orders that have reached Issuance.</li>
<li>Click <b>View Materials</b> to see every raw/packing material row for that Work Order.</li>
<li>If you're the one issuing, you can type the row's <b>Weighing Balance ID</b> and <b>AR No.</b> right there, and click <b>Mark Issued</b> — this stamps your name and the current date/time on that row.</li>
<li>Once a row is marked Issued, whoever is responsible for receiving can click <b>Mark Received</b> on it; once Received, whoever verifies can click <b>Mark Verified</b>. Each step only becomes available after the one before it — you'll see "Waiting on issued/received" until then.</li>
<li>These stamps appear automatically on the Work Order's printed PDF and its own detail popup — nothing further to do.</li>
</ol>
<div class="tip"><b>Tip:</b> Each of these three actions requires its own permission — you may be able to do one, two, or all three depending on what's been granted to you.</div>
</div>
</details>
<details class="sec" id="manpower">
<summary><span class="sec-ico">👷</span> 11. Man Power <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Open <b>Man Power</b> and click <b>+ New Entry</b> for the day.</li>
<li>You'll only see the production-area <b>tabs</b> you've been assigned — fill in each one's rows (Section, Target, Manpower, Actual Production, Rejection, etc.).</li>
<li>Save. The list view shows only your assigned tabs' totals.</li>
</ol>
</div>
</details>
<details class="sec" id="oee">
<summary><span class="sec-ico">📈</span> 12. OEE (Overall Equipment Efficiency) <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Open <b>OEE</b> and pick the form for your area (EBB Production, Plastic Sheet Plant, Moulding, Autoclave, etc. — forms not yet set up show as "Coming soon").</li>
<li>Click <b>+ New Entry</b>, pick the month, then <b>+ Add Shift</b> for each shift you want to log.</li>
<li>Fill in the downtime/output fields for that shift — Availability, Performance, Quality and the overall <b>OEE %</b> calculate live as you type.</li>
<li>Save the shift, then save the whole entry once all shifts for the month are in.</li>
</ol>
</div>
</details>
<details class="sec" id="purchase">
<summary><span class="sec-ico">🛒</span> 13. Purchase <span class="chev">›</span></summary>
<div class="sec-bd">
<div class="flow"><span class="step">Purchase Request</span><span class="arrow">→</span><span class="step">Quotation</span><span class="arrow">→</span><span class="step">Purchase Order</span><span class="arrow">→</span><span class="step badge b-green">Goods Receipt PO</span></div>
<ol class="steps">
<li>Raise a <b>Purchase Request</b> for what's needed.</li>
<li>Record vendor <b>Quotations</b> against it if comparison/approval is required.</li>
<li>Convert the approved request into a <b>Purchase Order</b>.</li>
<li>When goods arrive, post a <b>Goods Receipt PO (GRPO)</b> to bring them into stock.</li>
</ol>
</div>
</details>
<details class="sec" id="custvendor">
<summary><span class="sec-ico">🏢</span> 14. Customer / Vendor Registration <span class="chev">›</span></summary>
<div class="sec-bd">
<div class="flow"><span class="step">Submitted</span><span class="arrow">→</span><span class="step">Verify</span><span class="arrow">→</span><span class="step badge b-green">Approved → pushed to SAP</span></div>
<ol class="steps">
<li>Fill out the <b>Customer</b> or <b>Vendor</b> registration form with the required details.</li>
<li>A verifier checks it (Pending → Verified), then a final approver pushes it into SAP B1 as a real Business Partner.</li>
</ol>
</div>
</details>
<details class="sec" id="approvals">
<summary><span class="sec-ico">🛡️</span> 15. Approvals <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li><b>External Approval</b> — review and act on items awaiting your sign-off from outside SAP's own approval flow.</li>
<li><b>SAP Approval</b> — review documents pending approval inside SAP B1 itself.</li>
</ol>
</div>
</details>
<details class="sec" id="projects">
<summary><span class="sec-ico">💼</span> 16. Projects <span class="chev">›</span></summary>
<div class="sec-bd">
<div class="flow"><span class="step">New Project</span><span class="arrow">→</span><span class="step">Purchase</span><span class="arrow">→</span><span class="step">Engineering</span><span class="arrow">→</span><span class="step">QA</span><span class="arrow">→</span><span class="step">QC</span><span class="arrow">→</span><span class="step">Legal</span><span class="arrow">→</span><span class="step">Owner</span><span class="arrow">→</span><span class="step">Plant</span><span class="arrow">→</span><span class="step badge b-green">Finance</span></div>
<ol class="steps">
<li>Create a <b>New Project</b> with its scope and details.</li>
<li>It moves through each department's approval in sequence — every department you're assigned to can approve/reject at their step.</li>
<li>Track any project's current stage from <b>View Projects</b>.</li>
</ol>
</div>
</details>
<details class="sec" id="reports">
<summary><span class="sec-ico">📊</span> 17. Reports &amp; Dashboards <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li><b>Reports</b> — sales, purchase and inventory reports for day-to-day reference.</li>
<li><b>Board Dashboard</b> (if granted) — company-wide growth analysis: sales by division, product group, customer, with date-range and product-group filters.</li>
<li><b>Production Dashboard</b> — production analytics across orders and lines.</li>
</ol>
</div>
</details>
<details class="sec" id="documents">
<summary><span class="sec-ico">📁</span> 18. Documents <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li>Open <b>Documents</b> to upload or browse shared files relevant to your work.</li>
</ol>
</div>
</details>
<details class="sec" id="profile">
<summary><span class="sec-ico">👤</span> 19. Your Profile <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li><b>Edit Profile</b> — update your name and email.</li>
<li><b>Change Password</b> — update your login password any time.</li>
<li><b>My SAP Account</b> — set your own SAP User ID &amp; Password so documents you post are attributed to you, not a shared login.</li>
<li><b>My Signature</b> — upload a signature image; it's drawn on printed Work Orders wherever you've signed an approval, issuance, receipt or verification step.</li>
</ol>
</div>
</details>
</div>
</main>
<script>
'use strict';
try{
const tok=sessionStorage.getItem('portal_token');
if(!tok) window.location.href='/';
}catch(_e){}
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
// ── Personalize: show only the sections this user actually has access to ──
// (same modules/role that gate the sidebar itself — see public/sidebar.js's
// hasModule()). "Getting Started" and "Your Profile" always show since every
// user logs in and has a profile, regardless of what else they're granted.
(function personalizeGuide(){
let user=null;
try{ const u=sessionStorage.getItem('portal_user'); if(u) user=JSON.parse(u); }catch(_e){}
const isAdmin=!!(user&&(user.role==='admin'||user.role==='sap_adder')); // matches sidebar.js's bypass exactly — system_admin is NOT full-bypass, it only sees its own granted modules
const mods=Array.isArray(user&&user.modules)?user.modules:null;
const LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
function hasModule(m){
if(isAdmin)return true;
if(!mods)return false;
if(mods.includes(m))return true;
for(const b in LEGACY_BROAD_MODULES){ if(mods.includes(b)&&m.indexOf(LEGACY_BROAD_MODULES[b])===0)return true; }
return false;
}
const RULES={
start: {always:true},
bom: {modules:['bom']},
items: {modules:['items']},
workorder: {modules:['production-work-order']},
batchissue: {modules:['production-batch-issuance']},
requirements:{modules:['production-requirements']},
prodorder: {modules:['production-create']},
issue: {modules:['production-issue']},
receipt: {modules:['production-receipt']},
verifywo: {modules:['work-order-verify','production-verify']},
manpower: {modules:['man-power']},
oee: {modules:['oee']},
purchase: {modules:['purchase-request','purchase-quotation','purchase-order','purchase-grpo']},
custvendor: {modules:['customers','vendors']},
approvals: {modules:['approvals','sap-approvals']},
projects: {modules:['projects-new','projects-view','projects-approvals']},
reports: {modules:['reports','board-dashboard','production-dashboard'],roles:['board_member']},
documents: {modules:['documents']},
profile: {always:true},
};
function isVisible(id){
const r=RULES[id];
if(!r)return true; // unknown section → show (fail open)
if(r.always||isAdmin)return true;
if(r.roles&&user&&r.roles.includes(user.role))return true;
if(r.modules&&r.modules.some(hasModule))return true;
return false;
}
document.querySelectorAll('.sec').forEach(sec=>{ if(!isVisible(sec.id)) sec.style.display='none'; });
document.querySelectorAll('.toc a').forEach(a=>{ if(!isVisible(a.getAttribute('href').slice(1))) a.style.display='none'; });
// Renumber whatever's left so the visible list always reads 1, 2, 3…
// instead of skipping numbers for hidden sections.
let n=0;
document.querySelectorAll('.sec').forEach(sec=>{
if(sec.style.display==='none')return;
n++;
const summary=sec.querySelector('summary');
if(summary) summary.innerHTML=summary.innerHTML.replace(/(<\/span>\s*)\d+\.\s/,`$1${n}. `);
});
let m=0;
document.querySelectorAll('.toc a').forEach(a=>{
if(a.style.display==='none')return;
m++;
a.textContent=a.textContent.replace(/^\d+\.\s/,`${m}. `);
});
const visibleCount=document.querySelectorAll('.sec:not([style*="display: none"])').length;
if(visibleCount<=2){
const hero=document.querySelector('.hero');
if(hero)hero.insertAdjacentHTML('beforeend','<div class="note" style="margin-top:8px">You don\'t have many modules granted yet — ask your admin for access, and more of this guide will show up automatically.</div>');
}
})();
// Clicking a TOC link opens that section (in case it was collapsed) and
// highlights the active entry while scrolling.
document.querySelectorAll('.toc a').forEach(a=>{
a.addEventListener('click',()=>{
const id=a.getAttribute('href').slice(1);
const sec=document.getElementById(id);
if(sec && sec.tagName==='DETAILS') sec.open=true;
});
});
const tocLinks=Array.from(document.querySelectorAll('.toc a')).filter(a=>a.style.display!=='none');
const secs=tocLinks.map(a=>document.getElementById(a.getAttribute('href').slice(1))).filter(Boolean);
function syncToc(){
let activeIdx=0;
const y=window.scrollY+80;
secs.forEach((s,i)=>{ if(s.offsetTop<=y) activeIdx=i; });
tocLinks.forEach((a,i)=>a.classList.toggle('active',i===activeIdx));
}
window.addEventListener('scroll',syncToc,{passive:true});
syncToc();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+694
View File
@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>MITRA ERP Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="/lucide.min.js?v=399"></script>
<style>
:root{
--blue:#2563eb;--blue2:#1d4ed8;--blue-light:#eff6ff;
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
--bg:#f9fafb;--surface:#fff;--border:#e5e7eb;
--font:'Space Grotesk',sans-serif;
--red:#dc2626;--green:#16a34a;--amber:#d97706;--purple:#7c3aed;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
/* ── Login — professional split panel ── */
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);padding:20px}
.login-shell{display:flex;width:100%;max-width:880px;min-height:560px;border-radius:20px;overflow:hidden;box-shadow:0 20px 60px rgba(15,23,42,.14);border:1px solid var(--border)}
.login-brand{
flex:1 1 46%;position:relative;overflow:hidden;
background:linear-gradient(150deg,#1e3a8a 0%,#2563eb 55%,#4f46e5 100%);
padding:44px 40px;display:flex;flex-direction:column;justify-content:space-between;
color:#fff;min-width:0;
}
.login-brand::before{content:'';position:absolute;top:-60px;right:-60px;width:260px;height:260px;border-radius:50%;background:rgba(255,255,255,.07);pointer-events:none}
.login-brand::after{content:'';position:absolute;bottom:-90px;left:-40px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.05);pointer-events:none}
.lb-logo{display:flex;align-items:center;gap:11px;z-index:1}
.lb-logo-mark{width:38px;height:38px;border-radius:10px;background:#fff;border:1px solid rgba(255,255,255,.25);display:flex;align-items:center;justify-content:center;padding:5px;flex-shrink:0}
.lb-logo-mark svg,.lb-logo-mark img{width:100%;height:100%;display:block;object-fit:contain}
.lb-logo-text{font-size:14px;font-weight:700;letter-spacing:.2px}
.lb-logo-text small{display:block;font-size:10.5px;font-weight:400;color:rgba(255,255,255,.65);margin-top:1px}
.lb-mid{z-index:1;margin:36px 0}
.lb-title{font-size:26px;font-weight:800;line-height:1.25;margin-bottom:10px}
.lb-sub{font-size:13px;color:rgba(255,255,255,.75);line-height:1.6;max-width:320px}
.lb-features{z-index:1;display:flex;flex-direction:column;gap:12px}
.lb-feat{display:flex;align-items:center;gap:10px;font-size:12.5px;color:rgba(255,255,255,.85)}
.lb-feat-ico{width:26px;height:26px;border-radius:7px;background:rgba(255,255,255,.14);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.login-formside{flex:1 1 54%;background:#fff;padding:48px 44px;display:flex;flex-direction:column;justify-content:center;min-width:0}
.login-h{font-size:23px;font-weight:800;color:var(--text);margin-bottom:5px}
.login-sub{font-size:13px;color:var(--text3);margin-bottom:26px}
.field{margin-bottom:16px}
.field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:6px;display:block}
.field-input{width:100%;padding:11px 13px;border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-size:13.5px;font-family:var(--font);outline:none;transition:border .15s,box-shadow .15s;background:#fff}
.field-input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.field-input::placeholder{color:var(--text4)}
.login-row2{display:flex;align-items:center;justify-content:flex-end;margin:-8px 0 18px}
.forgot-link{font-size:12px;font-weight:600;color:var(--blue);background:none;border:none;cursor:pointer;font-family:var(--font);padding:0}
.forgot-link:hover{color:var(--blue2);text-decoration:underline}
.btn-login{width:100%;padding:12px;background:var(--blue);color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;font-family:var(--font);cursor:pointer;transition:background .15s;box-shadow:0 4px 14px rgba(37,99,235,.25)}
.btn-login:hover:not(:disabled){background:var(--blue2)}
.btn-login:disabled{opacity:.6;cursor:not-allowed}
.login-foot{margin-top:22px;font-size:11.5px;color:var(--text4);text-align:center}
.err-box{background:#fef2f2;border:1px solid #fecaca;color:var(--red);padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:14px;display:none}
.err-box.show{display:block}
.sp{width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .65s linear infinite;display:inline-block;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:760px){
.login-shell{flex-direction:column;max-width:420px;min-height:0}
.login-brand{padding:28px 26px;min-height:0}
.lb-mid{margin:20px 0}
.lb-title{font-size:20px}
.lb-features{display:none}
.login-formside{padding:32px 26px}
}
/* Forgot-password modal */
.fp-overlay{position:fixed;inset:0;background:rgba(15,23,42,.5);z-index:2000;display:none;align-items:center;justify-content:center;padding:20px}
.fp-overlay.show{display:flex}
.fp-modal{background:#fff;border-radius:16px;width:100%;max-width:400px;box-shadow:0 24px 60px rgba(15,23,42,.25);padding:28px}
.fp-hd{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:6px}
.fp-title{font-size:17px;font-weight:800;color:var(--text)}
.fp-sub{font-size:12.5px;color:var(--text3);margin-bottom:20px;line-height:1.5}
.fp-close{flex-shrink:0;width:28px;height:28px;border-radius:8px;border:1px solid var(--border);background:#fff;color:var(--text3);cursor:pointer}
.fp-actions{display:flex;gap:8px;margin-top:6px}
.fp-cancel{flex:0 0 auto;padding:11px 16px;background:#fff;border:1.5px solid var(--border);color:var(--text2);border-radius:9px;font-size:13px;font-weight:700;font-family:var(--font);cursor:pointer}
.fp-submit{flex:1;padding:11px;background:var(--blue);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:700;font-family:var(--font);cursor:pointer}
.fp-submit:hover:not(:disabled){background:var(--blue2)}
.fp-submit:disabled{opacity:.6;cursor:not-allowed}
.fp-done{text-align:center;padding:8px 0}
.fp-done-icon{width:48px;height:48px;border-radius:50%;background:#f0fdf4;color:var(--green);display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
/* ── Dashboard page ───────────────────── */
.dash-page{padding:28px 32px 48px;min-height:100vh}
/* Welcome card */
.welcome-card{
background:linear-gradient(135deg,#1e40af 0%,#2563eb 50%,#3b82f6 100%);
border-radius:16px;padding:28px 32px;margin-bottom:28px;
display:flex;align-items:center;justify-content:space-between;gap:20px;
box-shadow:0 4px 24px rgba(37,99,235,.25);position:relative;overflow:hidden;
}
.welcome-card::before{
content:'';position:absolute;top:-40px;right:-40px;
width:200px;height:200px;border-radius:50%;
background:rgba(255,255,255,.06);pointer-events:none;
}
.welcome-card::after{
content:'';position:absolute;bottom:-60px;right:120px;
width:150px;height:150px;border-radius:50%;
background:rgba(255,255,255,.04);pointer-events:none;
}
.wc-left{display:flex;align-items:center;gap:18px;z-index:1}
.wc-avatar{
width:54px;height:54px;border-radius:14px;
background:rgba(255,255,255,.15);border:2px solid rgba(255,255,255,.25);
display:flex;align-items:center;justify-content:center;
font-size:20px;font-weight:800;color:#fff;flex-shrink:0;letter-spacing:0;
}
.wc-info{}
.wc-greeting{font-size:12px;font-weight:600;color:rgba(255,255,255,.65);text-transform:uppercase;letter-spacing:.8px;margin-bottom:4px}
.wc-name{font-size:22px;font-weight:800;color:#fff;line-height:1.1;margin-bottom:4px}
.wc-un{font-size:11.5px;color:rgba(255,255,255,.55);font-family:monospace}
.wc-right{text-align:right;z-index:1;flex-shrink:0}
.wc-badge{
display:inline-flex;align-items:center;gap:5px;
padding:4px 12px;border-radius:20px;font-size:11px;font-weight:700;
letter-spacing:.4px;margin-bottom:8px;
background:rgba(255,255,255,.18);color:#fff;border:1px solid rgba(255,255,255,.25);
}
.wc-company{font-size:12px;font-weight:600;color:rgba(255,255,255,.75);margin-bottom:3px}
.wc-login{font-size:11px;color:rgba(255,255,255,.45)}
.wc-divider{width:1px;background:rgba(255,255,255,.12);align-self:stretch;margin:0 8px;z-index:1}
.wc-stats{display:flex;gap:24px;z-index:1}
.wc-stat{text-align:center}
.wc-stat-val{font-size:20px;font-weight:800;color:#fff;line-height:1}
.wc-stat-lbl{font-size:10px;color:rgba(255,255,255,.5);text-transform:uppercase;letter-spacing:.6px;margin-top:3px;font-weight:600}
.role-badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600;letter-spacing:.3px;margin-bottom:4px}
.role-badge.admin{background:#f3e8ff;color:#7c3aed}
.role-badge.user{background:#dcfce7;color:#16a34a}
.role-badge.manager{background:#dbeafe;color:var(--blue)}
@media(max-width:768px){
.welcome-card{flex-direction:column;align-items:flex-start;padding:20px}
.wc-right{text-align:left}
.wc-divider,.wc-stats{display:none}
}
/* Section header */
.sec-hdr{display:flex;align-items:center;gap:7px;margin-bottom:12px;margin-top:4px}
.sec-hdr svg{color:var(--text4);flex-shrink:0}
.sec-hdr span{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:var(--text4)}
/* Module cards grid */
.mods-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px;margin-bottom:28px}
/* Pending-count badge on a module card (top-right corner) */
.mc-badge{
position:absolute;top:10px;right:10px;
background:#dc2626;color:#fff;font-size:10px;font-weight:800;
border-radius:10px;padding:1px 7px;line-height:1.6;min-width:8px;text-align:center;
box-shadow:0 1px 3px rgba(220,38,38,.4);
}
/* Module card matches MSPL exactly */
.mc{
background:#fff;
border:1px solid var(--border);
border-radius:12px;
padding:18px;
cursor:pointer;text-decoration:none;
display:flex;flex-direction:column;gap:14px;
transition:box-shadow .18s,border-color .18s;
box-shadow:0 1px 3px rgba(0,0,0,.05);
position:relative;
}
.mc:hover{box-shadow:0 4px 16px rgba(0,0,0,.09);border-color:#d1d5db}
.mc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
.mc-icon{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.mc-arrow{color:#d1d5db;flex-shrink:0;margin-top:2px;transition:color .15s,transform .15s}
.mc:hover .mc-arrow{color:#9ca3af;transform:translateX(2px)}
.mc-title{font-size:13px;font-weight:700;color:var(--text);margin-bottom:2px}
.mc-desc{font-size:11.5px;color:var(--text3);line-height:1.45}
/* Icon colour schemes */
.ic-blue{background:#eff6ff;color:#2563eb}
.ic-indigo{background:#eef2ff;color:#4338ca}
.ic-teal{background:#f0fdfa;color:#0d9488}
.ic-green{background:#f0fdf4;color:#16a34a}
.ic-amber{background:#fffbeb;color:#d97706}
.ic-orange{background:#fff7ed;color:#ea580c}
.ic-red{background:#fef2f2;color:#dc2626}
.ic-purple{background:#faf5ff;color:#7c3aed}
.ic-pink{background:#fdf2f8;color:#be185d}
.ic-grey{background:#f9fafb;color:#4b5563}
/* Toast */
.toast-c{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{padding:11px 16px;border-radius:9px;font-size:12px;font-weight:600;box-shadow:0 4px 16px rgba(0,0,0,.12);display:flex;align-items:center;gap:7px;max-width:300px;pointer-events:auto;animation:tIn .2s ease}
.toast.ok{background:var(--green);color:#fff}.toast.err{background:var(--red);color:#fff}.toast.warn{background:var(--amber);color:#fff}
@keyframes tIn{from{transform:translateY(12px);opacity:0}to{transform:translateY(0);opacity:1}}
@media(max-width:768px){.dash-page{padding:14px 14px 80px}.mods-grid{grid-template-columns:repeat(2,1fr);gap:10px}.welcome-row{flex-direction:column;margin-bottom:20px}.mc{padding:14px;gap:10px}.mc-icon{width:34px;height:34px}.mc-title{font-size:12px}.mc-desc{display:none}}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<div id="root"></div>
<script>
'use strict';
let _tok=null,_user=null,_cfg=null;
function jwtExpired(t){
try{
const payload=JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
return !payload.exp || (Date.now()>=payload.exp*1000);
}catch(_e){ return true; }
}
function getCookie(name){
const m=document.cookie.match(new RegExp('(?:^|; )'+name+'=([^;]*)'));
return m?decodeURIComponent(m[1]):null;
}
(function(){
try{
// Session-only auth: login is mirrored into a SESSION COOKIE (no
// expires/max-age) at login time — cookies are shared across every tab
// of the same browser (unlike sessionStorage), so Ctrl+Click/"open in
// new tab" doesn't force a fresh login. A true session cookie is still
// wiped by the browser itself when it fully closes, so closing the
// browser still always ends the session.
// Defensive cleanup: wipe any leftover localStorage entries from an even
// older version of this scheme, so a lingering copy can't grant access.
try{ localStorage.removeItem('token'); localStorage.removeItem('portal_user'); }catch{}
if(!sessionStorage.getItem('portal_token')){
const cTok=getCookie('portal_token'), cUser=getCookie('portal_user');
if(cTok){
sessionStorage.setItem('portal_token',cTok);
if(cUser) sessionStorage.setItem('portal_user',cUser);
}
}
_tok=sessionStorage.getItem('portal_token')||null;
const u=sessionStorage.getItem('portal_user');
if(u)_user=JSON.parse(u);
// Stale-but-present token from a past session: treat exactly like no
// token so the login screen shows immediately, without waiting on the
// async /api/config round trip (which sidebar.js's own render doesn't
// wait on either — see sidebar.js's matching jwtExpired check).
if(_tok&&jwtExpired(_tok)){
try{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;}catch{}
_tok=null;_user=null;
}
}catch{}
})();
const BACKEND='/api';
async function api(method,path,body=null){
const h={'Content-Type':'application/json'};
if(_tok)h['Authorization']='Bearer '+_tok;
const opt={method:method.toUpperCase(),headers:h};
if(body)opt.body=JSON.stringify(body);
let r,d;
try{r=await fetch(BACKEND+path,opt);d=await r.json();}
catch(e){throw new Error('Cannot reach server');}
if(r.status===401){doLogout();throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);
return d;
}
function toast(msg,type='ok'){
const tc=document.getElementById('toast-c');
const el=document.createElement('div');
el.className='toast '+type;
el.textContent=msg;tc.appendChild(el);
setTimeout(()=>el.remove(),3500);
}
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function doLogout(){_tok=null;_user=null;_cfg=null;try{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user')}catch{}window.location.reload();}
async function loadConfig(){try{const r=await api('GET','/config');_cfg=r;}catch{}}
// icon helper
function ico(name,size=20){return`<i data-lucide="${name}" style="width:${size}px;height:${size}px;stroke-width:1.75"></i>`}
// ─── LOGIN ───────────────────────────────────────────────
function buildLogin(){
const wrap=document.createElement('div');
wrap.innerHTML=`
<div class="login-wrap">
<div class="login-shell">
<div class="login-brand">
<div class="lb-logo">
<div class="lb-logo-mark"><img class="lb-img" src="/logo.png" alt="Logo" onerror="this.style.display='none';this.nextElementSibling.style.display='block'"/><svg class="lb-svg" style="display:none" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label="Logo"><g stroke-linejoin="round" stroke-linecap="round"><polygon points="66,16 78,16 90,84 78,84" fill="#7cc36f" stroke="#7cc36f" stroke-width="6"/><polygon points="42,22 54,22 66,84 54,84" fill="#23a06e" stroke="#23a06e" stroke-width="6"/><polygon points="26,54 6,86 44,86" fill="#1866a1" stroke="#1866a1" stroke-width="6"/></g></svg></div>
<div class="lb-logo-text">MITRA<small>ERP Portal</small></div>
</div>
<div class="lb-mid">
<div class="lb-title">Run your ERP operations from one MITRA portal</div>
<div class="lb-sub">Work orders, production and approvals — all in sync with SAP B1.</div>
</div>
<div class="lb-features">
<div class="lb-feat"><span class="lb-feat-ico">${ico('shield-check',15)}</span> Role-based approval workflows</div>
<div class="lb-feat"><span class="lb-feat-ico">${ico('workflow',15)}</span> Live SAP Business One integration</div>
<div class="lb-feat"><span class="lb-feat-ico">${ico('layout-grid',15)}</span> One portal for every module</div>
</div>
</div>
<div class="login-formside">
<div class="login-h">Welcome back</div>
<div class="login-sub">Sign in to your portal account</div>
<div class="err-box" id="login-err"></div>
<div class="field">
<label class="field-label">Username</label>
<input class="field-input" id="l-user" placeholder="Enter username" autocomplete="username"/>
</div>
<div class="field">
<label class="field-label">Password</label>
<input class="field-input" id="l-pass" type="password" placeholder="Enter password" autocomplete="current-password"/>
</div>
<div class="login-row2"><button type="button" class="forgot-link" id="l-forgot">Forgot password?</button></div>
<button class="btn-login" id="l-btn">Sign In</button>
<div class="login-foot">Trouble signing in? Contact your administrator.</div>
</div>
</div>
</div>
<div class="fp-overlay" id="fp-overlay">
<div class="fp-modal" id="fp-modal"></div>
</div>`;
function renderForgotForm(){
const m=wrap.querySelector('#fp-modal');
m.innerHTML=`
<div class="fp-hd">
<div class="fp-title">Forgot password?</div>
<button class="fp-close" id="fp-close">${ico('x',15)}</button>
</div>
<div class="fp-sub">This portal doesn't send reset emails — submit your username below and an administrator will reset your password for you.</div>
<div class="err-box" id="fp-err"></div>
<div class="field">
<label class="field-label">Username</label>
<input class="field-input" id="fp-user" placeholder="Your username" autocomplete="username"/>
</div>
<div class="field">
<label class="field-label">Note (optional)</label>
<input class="field-input" id="fp-note" placeholder="Anything the admin should know"/>
</div>
<div class="fp-actions">
<button class="fp-cancel" id="fp-cancel">Cancel</button>
<button class="fp-submit" id="fp-submit">Submit Request</button>
</div>`;
m.querySelector('#fp-close').onclick=closeForgot;
m.querySelector('#fp-cancel').onclick=closeForgot;
const submit=async()=>{
const u=m.querySelector('#fp-user').value.trim();
const err=m.querySelector('#fp-err');
const btn=m.querySelector('#fp-submit');
err.classList.remove('show');
if(!u){err.textContent='Username is required';err.classList.add('show');return;}
btn.disabled=true;btn.innerHTML='<span class="sp"></span> Submitting';
try{
const r=await fetch(BACKEND+'/password-reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,note:m.querySelector('#fp-note').value.trim()})});
const d=await r.json();
if(!r.ok||!d.success)throw new Error(d?.message||'Could not submit request');
renderForgotDone();
}catch(e){
err.textContent=e.message;err.classList.add('show');
btn.disabled=false;btn.textContent='Submit Request';
}
};
m.querySelector('#fp-submit').onclick=submit;
m.querySelector('#fp-user').focus();
if(window._injectLucide)window._injectLucide();
}
function renderForgotDone(){
const m=wrap.querySelector('#fp-modal');
m.innerHTML=`
<div class="fp-done">
<div class="fp-done-icon">${ico('check',24)}</div>
<div class="fp-title" style="margin-bottom:8px">Request submitted</div>
<div class="fp-sub">An administrator will reset your password and let you know. You can close this window.</div>
<button class="fp-submit" id="fp-ok" style="width:100%">Close</button>
</div>`;
m.querySelector('#fp-ok').onclick=closeForgot;
if(window._injectLucide)window._injectLucide();
}
function openForgot(){renderForgotForm();wrap.querySelector('#fp-overlay').classList.add('show');}
function closeForgot(){wrap.querySelector('#fp-overlay').classList.remove('show');}
setTimeout(()=>{
const go=async()=>{
const u=wrap.querySelector('#l-user').value.trim();
const p=wrap.querySelector('#l-pass').value;
const err=wrap.querySelector('#login-err');
const btn=wrap.querySelector('#l-btn');
err.classList.remove('show');
if(!u||!p){err.textContent='Username and password required';err.classList.add('show');return;}
btn.disabled=true;btn.innerHTML='<span class="sp"></span> Signing';
try{
const r=await fetch(BACKEND+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
const d=await r.json();
if(!r.ok)throw new Error(d?.message||'Login failed');
_tok=d.token;_user=d.user;
try{
sessionStorage.setItem('portal_token',_tok);sessionStorage.setItem('portal_user',JSON.stringify(_user));
// Session cookie (no expires/max-age) — shared across tabs, wiped when the browser fully closes.
document.cookie='portal_token='+encodeURIComponent(_tok)+';path=/;SameSite=Lax';
document.cookie='portal_user='+encodeURIComponent(JSON.stringify(_user))+';path=/;SameSite=Lax';
}catch{}
window.location.reload();
}catch(e){
err.textContent=e.message;err.classList.add('show');
btn.disabled=false;btn.textContent='Sign In';
}
};
wrap.querySelector('#l-btn').onclick=go;
wrap.querySelector('#l-pass').addEventListener('keydown',e=>{if(e.key==='Enter')go();});
wrap.querySelector('#l-forgot').onclick=openForgot;
wrap.querySelector('#fp-overlay').onclick=e=>{if(e.target.id==='fp-overlay')closeForgot();};
wrap.querySelector('#l-user').focus();
if(window._injectLucide)window._injectLucide();
},0);
return wrap;
}
// ─── DASHBOARD ───────────────────────────────────────────
function buildDashboard(){
const wrap=document.createElement('div');
wrap.className='dash-page';
const role=_user?.role||'user';
const isAdmin=role==='admin'||role==='sap_adder'; // full tile bypass (SAP Adder kept its original admin-like visibility)
const isSubAdmin=role==='system_admin'; // System Admin: sees ONLY granted modules
const userModules=Array.isArray(_user?.modules)?_user.modules:null;
// Backward-compat: a user granted the OLD broad key (e.g. 'production',
// before it was split into per-page sub-modules) still sees every card
// under that group.
const LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
const canSee=m=>{
if(isAdmin)return true;
if(!userModules)return false; // no modules explicitly granted → no access (was: unchecked = full access)
if(userModules.includes(m))return true;
for(const broad in LEGACY_BROAD_MODULES){
if(userModules.includes(broad)&&m.indexOf(LEGACY_BROAD_MODULES[broad])===0)return true;
}
return false;
};
// Header Profiles has no module of its own — same as sidebar.js, it's
// gated purely by the 'work_order:approved_mgr_qa' step (the existing
// "QA Manager" step), so a QA Manager without production-work-order still
// needs to see this card.
const hasQaMgrStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(_user?.approvalSteps)?_user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s==='work_order:approved_mgr_qa';
return s&&s.step==='work_order:approved_mgr_qa'&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
// Same idea as sidebar.js's hasPrePwoStepAccess: a pure Pre-PWO Store
// reviewer has no reason to hold the broad 'production-create' module, so
// holding either Pre-PWO approval step (any perm) is enough to see the
// Production Order card too — otherwise they can reach the page (via
// auth-guard.js's own exception) but the dashboard would never show them
// how to get there.
const hasPrePwoStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(_user?.approvalSteps)?_user.approvalSteps:[];
const keys=['production_order:prepwo_share','production_order:prepwo_review'];
return steps.some(s=>{
if(typeof s==='string')return keys.includes(s);
return s&&keys.includes(s.step)&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
// Same idea for OEE: holding the 'oee:entry' approval step (any perm) is
// enough to see the card even without the broader 'oee' module — mirrors
// the identical exception already made for Man Power's own module gate
// being the looser, more commonly-granted one.
const hasOeeStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(_user?.approvalSteps)?_user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s==='oee:entry';
return s&&s.step==='oee:entry'&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
const canSeeItem=m=>canSee(m.module)
||(m.href==='/wo-header-profiles'&&hasQaMgrStepAccess)
||(m.href==='/production'&&hasPrePwoStepAccess)
||(m.href==='/oee'&&hasOeeStepAccess);
const name=_user?.name||_user?.username||'User';
const username=_user?.username||'';
const badgeCls=(isAdmin||isSubAdmin)?'admin':role.includes('manager')?'manager':'user';
// const roleLabel=({system_admin:'System Admin',board_member:'Board Member',sap_adder:'SAP Adder',sr_manager:'Sr. Manager',project_approver:'Project Approver'})[role]||(role||'').replace(/_/g,' ');
const roleLabel=(role||'').replace(/_/g,' ');
const company=_user?.company||'MSPL';
const loginAt=_user?.loginAt?new Date(_user.loginAt).toLocaleString('en-IN',{dateStyle:'medium',timeStyle:'short'}):'';
// ── Module definitions ─────────────────────────────────
const GROUPS=[
{
id:'inventory',label:'Inventory',icon:'package',
items:[
{href:'/bom', module:'bom', icon:'cpu', ic:'ic-blue', title:'BOM', desc:'Bill of Materials with approval workflow.'},
{href:'/items', module:'items', icon:'package', ic:'ic-teal', title:'Items', desc:'Create & manage SAP B1 item master data.'},
]
},
{
id:'production',label:'Production',icon:'factory',
items:[
{href:'/production-dashboard', module:'production-dashboard', icon:'gauge', ic:'ic-teal', title:'Dashboard', desc:'Production output, orders & workflow analytics.'},
{href:'/production', module:'production-create', icon:'factory', ic:'ic-purple',title:'Production Order', desc:'Create & manage production orders.'},
{href:'/issue-production', module:'production-issue', icon:'arrow-up-right', ic:'ic-orange',title:'Issue', desc:'Issue components for production.'},
{href:'/receipt-production',module:'production-receipt', icon:'arrow-down-left', ic:'ic-green', title:'Receipt', desc:'Receive finished goods from production.'},
{href:'/verify-work-order', module:'work-order-verify', icon:'clipboard-check', ic:'ic-green', title:'Verify Work Order',desc:'Sign off Issued/Received/Verified per material row.'},
{href:'/verify-production', module:'production-verify', icon:'clipboard-check', ic:'ic-green', title:'Verify', desc:'Post-Issuance verification sign-off.'},
{href:'/receipts-history', module:'production-receipt', icon:'history', ic:'ic-teal', title:'Receipts History',desc:'All posted receipts from production.'},
{href:'/close-production', module:'production-close', icon:'check-circle', ic:'ic-grey', title:'Close', desc:'Close completed production orders.'},
{href:'/requirements', module:'production-requirements', icon:'clipboard-list', ic:'ic-teal', title:'Requirements', desc:'Raise logistics material requirements.'},
{href:'/batch-issuance', module:'production-batch-issuance',icon:'layers', ic:'ic-indigo',title:'Batch Issuance', desc:'Batch issuance intimation from requirements.'},
{href:'/work-order', module:'production-work-order', icon:'file-cog', ic:'ic-blue', title:'Work Order', desc:'Production work orders with QA/QC approval.'},
{href:'/wo-header-profiles',module:'production-work-order', icon:'file-badge-2', ic:'ic-teal', title:'Header Profiles', desc:'Per-product-group Work Order PDF letterhead (also usable by QA Manager).'},
{href:'/man-power', module:'man-power', icon:'users', ic:'ic-purple',title:'Man Power', desc:'Daily manpower & production data entry.'},
{href:'/oee', module:'oee', icon:'activity', ic:'ic-orange',title:'OEE', desc:'Overall Equipment Efficiency data entry.'},
]
},
{
id:'purchase',label:'Purchase',icon:'shopping-cart',
items:[
{href:'/purchase-request', module:'purchase-request', icon:'file-text', ic:'ic-teal', title:'Purchase Request', desc:'Raise & track purchase requests.'},
{href:'/purchase-quotation', module:'purchase-quotation',icon:'tag', ic:'ic-indigo',title:'Quotation', desc:'Manage purchase quotations from vendors.'},
{href:'/purchase-order', module:'purchase-order', icon:'shopping-cart',ic:'ic-blue', title:'Purchase Order', desc:'Create and send purchase orders.'},
{href:'/grpo', module:'purchase-grpo', icon:'truck', ic:'ic-green', title:'GRPO', desc:'Goods receipt against purchase orders.'},
]
},
{
id:'approval',label:'Approvals',icon:'check-square',
items:[
{href:'/approvals', module:'approvals', icon:'check-square', ic:'ic-amber',title:'External Approval',desc:'Review & approve external requests.'},
{href:'/sap-approvals',module:'sap-approvals',icon:'shield-check', ic:'ic-green',title:'SAP Approval', desc:'Approve & push orders to SAP B1.'},
]
},
{
id:'forms',label:'Forms',icon:'file-plus',
items:[
{href:'/vendor-register',module:'vendors', icon:'building-2', ic:'ic-orange',title:'Vendor', desc:'Register new vendors with documents.'},
{href:'/register', module:'customers', icon:'user-plus', ic:'ic-teal', title:'Customer', desc:'Register new customers with contacts.'},
]
},
{
id:'projects',label:'Projects',icon:'briefcase',
items:[
{href:'/project-form?new=1', module:'projects-new', icon:'plus-circle', ic:'ic-blue', title:'New Project', desc:'Create a new project and assign team.'},
{href:'/project-form?list=1',module:'projects-view', icon:'list', ic:'ic-indigo',title:'View Projects', desc:'Browse and manage all projects.'},
{href:'/project-approvals', module:'projects-approvals',icon:'check-circle', ic:'ic-green', title:'Project Approvals', desc:'Review and approve project requests.'},
]
},
{
id:'finance',label:'Finance',icon:'bar-chart-2',
items:[
{href:'/costing-pl', module:'finance-monthly', icon:'bar-chart-2', ic:'ic-blue', title:'Monthly Accounts',desc:'P&L with Trial Balance snapshots.'},
{href:'/costing-comparison',module:'finance-comparison', icon:'trending-up', ic:'ic-indigo',title:'P&L Comparison', desc:'Compare P&L across periods.'},
{href:'/cost-sheet', module:'finance-costsheet', icon:'clipboard', ic:'ic-teal', title:'Cost Sheet', desc:'Analyse project cost breakdowns.'},
{href:'/balance-sheet', module:'finance-balancesheet',icon:'scale', ic:'ic-green', title:'Balance Sheet', desc:'Assets, liabilities and equity.'},
{href:'/cash-flow', module:'finance-cashflow', icon:'arrow-left-right',ic:'ic-amber',title:'Cash Flow', desc:'Statement of cash flow analysis.'},
{href:'/salary', module:'salary', icon:'credit-card', ic:'ic-purple',title:'Salary', desc:'Payroll and salary register.'},
]
},
{
id:'gstr',label:'GST Reports',icon:'receipt',
items:[
{href:'/gstr1',module:'gstr1',icon:'receipt', ic:'ic-indigo',title:'GSTR-1', desc:'GST outward supply return.'},
{href:'/gstr2',module:'gstr2',icon:'file-input', ic:'ic-teal', title:'GSTR-2 Purchase',desc:'GST purchase reconciliation.'},
{href:'/itc04',module:'itc04',icon:'repeat', ic:'ic-orange',title:'ITC-04 Job Work', desc:'Job work ITC reconciliation.'},
]
},
{
id:'reports',label:'Reports',icon:'pie-chart',
items:[
{href:'/board-dashboard', module:'board-dashboard', icon:'trending-up', ic:'ic-purple', title:'Board Dashboard', desc:'Company growth analytics for the board.'},
{href:'/reports', module:'reports', icon:'pie-chart',ic:'ic-grey',title:'Reports', desc:'Custom business reports.'},
{href:'/general-ledger',module:'general-ledger',icon:'book-open', ic:'ic-blue',title:'General Ledger',desc:'SAP B1 general ledger browser.'},
{href:'/documents',module:'documents',icon:'folder-open', ic:'ic-grey',title:'View Documents', desc:'All uploaded documents.'},
{href:'/audit-logs',module:'audit',icon:'scroll-text',ic:'ic-grey',title:'Audit Logs',desc:'Every mutation logged across the portal.'},
{href:'/mail-logs',module:'mail_log',icon:'mail',ic:'ic-grey',title:'Mail Log',desc:'Every email the portal has sent, with delivery status.'},
]
},
{
id:'masters',label:'Masters',icon:'database',
items:[
{href:'/business-master',module:'business-master',icon:'building',ic:'ic-indigo',title:'Business Master',desc:'Company/branch master data.'},
]
},
];
if(isAdmin||isSubAdmin){
// Sub-admins still need the 'admin' MODULE granted — canSee() filters below.
GROUPS.push({
id:'admin',label:'Admin',icon:'settings',
items:[
{href:'/admin#users',module:'admin',icon:'users',ic:'ic-purple',title:'Manage Users',desc:'Create, view and manage portal users.'},
{href:'/admin#settings',module:'admin',icon:'settings',ic:'ic-grey',title:'System Settings',desc:'Portal configuration & workflow rules.'},
{href:'/item-group-classification',module:'item-group-classification',icon:'layers-3',ic:'ic-teal',title:'Item Group Rules',desc:'Classify SAP item groups as Raw/Pack/Component.'},
]
});
}
let html='';
for(const g of GROUPS){
const visible=g.items.filter(canSeeItem);
if(!visible.length)continue;
html+=`
<div class="sec-hdr">
${ico(g.icon,14)}
<span>${g.label.toUpperCase()}</span>
</div>
<div class="mods-grid">`;
for(const m of visible){
html+=`<a href="${esc(m.href)}" class="mc">
<div class="mc-top">
<div class="mc-icon ${m.ic}">${ico(m.icon,20)}</div>
<i data-lucide="chevron-right" class="mc-arrow" style="width:15px;height:15px;stroke-width:1.75"></i>
</div>
<div>
<div class="mc-title">${esc(m.title)}</div>
<div class="mc-desc">${esc(m.desc)}</div>
</div>
</a>`;
}
html+=`</div>`;
}
const initials = name.split(' ').map(w=>w[0]||'').join('').slice(0,2).toUpperCase() || '?';
const totalModules = GROUPS.reduce((s,g)=>s+g.items.filter(canSeeItem).length, 0);
const now = new Date();
const todayStr = now.toLocaleDateString('en-IN',{weekday:'short',day:'numeric',month:'short',year:'numeric'});
const hourGreet = now.getHours() < 12 ? 'Good Morning' : now.getHours() < 17 ? 'Good Afternoon' : 'Good Evening';
wrap.innerHTML=`
<div class="welcome-card">
<div class="wc-left">
<div class="wc-avatar">${esc(initials)}</div>
<div class="wc-info">
<div class="wc-greeting">${hourGreet}</div>
<div class="wc-name">${esc(name)}</div>
<div class="wc-un">@${esc(username)}</div>
</div>
</div>
</div>
${html}`;
// `wrap` isn't attached to the document yet at this point (buildDashboard()
// is still running as an argument to root.appendChild()) — document.getElementById
// would find nothing, so hand the container itself to loadPendingCounts
// instead of having it search the live document.
loadPendingCounts(wrap);
setTimeout(()=>{if(window._injectLucide)window._injectLucide();},0);
return wrap;
}
// ─── PENDING-COUNT BADGES ON MODULE CARDS ─────────────────────────
// Same aggregation the sidebar bell uses (routes/notifications.js) — rather
// than a separate dashboard list, each count is shown as a small badge on
// the module card it belongs to. Grouped by cardHref (not module) since a
// single module — Production Order — spans several distinct cards
// (Create/View, Issue, Receipt, Close), each needing its own count.
const PA_HREF_FALLBACK={work_order:'/work-order',production_order:'/production',bom:'/bom',password_reset:'/admin'};
async function loadPendingCounts(root){
try{
const r=await fetch(BACKEND+'/notifications/pending',{headers:_tok?{Authorization:'Bearer '+_tok}:{}});
const d=await r.json();
const items=(d&&d.success&&d.data)||[];
const counts={};
items.forEach(it=>{
const href=it.cardHref||PA_HREF_FALLBACK[it.module];
if(!href)return;
counts[href]=(counts[href]||0)+1;
});
Object.keys(counts).forEach(href=>{
const card=root.querySelector(`a.mc[href="${href}"]`);
if(!card||card.querySelector('.mc-badge'))return;
const n=counts[href];
const badge=document.createElement('span');
badge.className='mc-badge';
badge.textContent=n>99?'99+':String(n);
badge.title=`${n} pending`;
card.appendChild(badge);
});
}catch(_e){/* dashboard still works fine without pending counts */}
}
// ─── INIT ────────────────────────────────────────────────
(async()=>{
// Board Members land straight on their analytics dashboard — both right
// after login (the reload comes back through here) and whenever they open
// "/" while logged in. The sidebar still lets them navigate anywhere granted.
if(_tok&&_user&&_user.role==='board_member'){window.location.replace('/board-dashboard');return;}
if(_tok)await loadConfig();
const root=document.getElementById('root');
root.innerHTML='';
if(!_tok||!_user){root.appendChild(buildLogin());}
else{root.appendChild(buildDashboard());}
if(window._injectLucide)window._injectLucide();
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+709
View File
@@ -0,0 +1,709 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Issue for Production — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--purple2:#7c3aed;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--teal);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--teal);background:rgba(6,182,212,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:720px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .ico{font-size:15px}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--teal);box-shadow:0 0 0 2px rgba(6,182,212,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:420px){.fi-row{grid-template-columns:1fr}}
.fi-mono{font-family:var(--mono);font-size:12px;text-transform:uppercase}
/* Order cards — square-tile grid, same pattern as production.html */
.po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.po-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:10px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.po-card:hover{border-color:var(--teal);box-shadow:0 6px 18px rgba(6,182,212,.14);transform:translateY(-2px)}
.po-card.selected{border-color:var(--teal);background:rgba(6,182,212,.10);box-shadow:0 0 0 2px rgba(6,182,212,.25)}
.po-top{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;flex-wrap:wrap}
.po-code{font-family:var(--mono);font-size:10px;color:var(--teal);font-weight:600}
.po-doc{font-family:var(--mono);font-size:10px;color:var(--text3)}
.po-icon{width:26px;height:26px;border-radius:7px;background:rgba(6,182,212,.12);color:var(--teal);display:flex;align-items:center;justify-content:center;margin:5px 0;font-size:13px}
.po-name{font-size:11px;font-weight:600;color:var(--text);line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.po-meta{font-size:9.5px;color:var(--text3);display:flex;flex-direction:column;gap:2px;margin-top:auto;padding-top:5px;border-top:1px solid var(--border)}
.po-meta b{color:var(--text2)}
/* Detail / issue popup */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
.modal-bg.show{display:flex}
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:640px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:space-between}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
/* Component cards */
.comp-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;position:relative;transition:opacity .15s,border-color .15s}
.comp-card:hover{border-color:var(--border2)}
.comp-card.unselected{opacity:.5}
.comp-card.done{opacity:.65}
.comp-top-row{display:flex;align-items:center;gap:8px;margin-bottom:2px}
.comp-check{width:16px;height:16px;accent-color:var(--teal);cursor:pointer;flex-shrink:0}
.comp-actions{margin-left:auto;display:flex;align-items:center;gap:6px}
.comp-remove{background:none;border:1px solid var(--border2);color:var(--text3);font-size:9px;padding:3px 8px;border-radius:5px;cursor:pointer;font-weight:700;white-space:nowrap}
.comp-remove:hover{background:rgba(229,62,62,.12);border-color:var(--red);color:var(--red)}
.comp-num{font-family:var(--mono);font-size:10px;color:var(--text3);background:var(--surface3);padding:1px 6px;border-radius:4px}
.comp-badge-done{font-size:9px;font-weight:700;padding:1px 6px;border-radius:4px;background:rgba(13,187,138,.12);color:var(--green)}
/* Toolbar above the components list */
.comp-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:var(--surface3);border:1px solid var(--border);border-radius:8px;font-size:11px;color:var(--text2)}
.comp-toolbar b{color:var(--text)}
.chip-btn{background:none;border:1px solid var(--border2);color:var(--teal);font-size:10px;font-weight:700;padding:4px 10px;border-radius:6px;cursor:pointer}
.chip-btn:hover{background:rgba(6,182,212,.1);border-color:var(--teal)}
.removed-strip{display:flex;align-items:center;gap:8px;padding:7px 10px;background:rgba(229,62,62,.06);border:1px solid rgba(229,62,62,.18);border-radius:7px;font-size:11px;color:var(--text2);margin-bottom:10px}
.removed-strip .chip-btn{color:var(--amber);border-color:rgba(245,166,35,.3)}
.removed-strip .chip-btn:hover{background:rgba(245,166,35,.12);border-color:var(--amber)}
.comp-type{font-size:9px;font-weight:700;text-transform:uppercase;padding:2px 6px;border-radius:3px;display:inline-block;margin-bottom:6px}
.comp-type.item{background:rgba(6,182,212,.12);color:var(--teal)}
.comp-type.resource{background:rgba(245,166,35,.12);color:var(--amber)}
.comp-item{font-family:var(--mono);font-size:12px;color:var(--teal);font-weight:600}
.comp-desc{font-size:12px;color:var(--text);margin:3px 0 8px;line-height:1.3}
.comp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}
@media(max-width:420px){.comp-grid{grid-template-columns:1fr 1fr}}
.comp-field{display:flex;flex-direction:column;gap:2px}
.comp-fl{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3);letter-spacing:.3px}
.comp-fv{font-size:12px;font-family:var(--mono);color:var(--text2)}
.comp-inp{width:100%;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:12px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;height:28px}.comp-inp:focus{border-color:var(--teal)}
/* Batch section */
.batch-sec{margin-top:8px;padding:8px;background:var(--surface3);border-radius:6px;border:1px solid var(--border)}
.batch-title{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--amber);margin-bottom:6px;display:flex;align-items:center;gap:4px}
.batch-row{display:flex;gap:6px;align-items:center;padding:4px 0;border-bottom:1px solid rgba(28,46,72,.3);font-size:11px;flex-wrap:wrap}
.batch-row:last-child{border-bottom:none}
.batch-num{font-family:var(--mono);color:var(--teal);font-size:11px;min-width:100px;flex:1}
.batch-avail{color:var(--text3);font-size:10px;min-width:60px}
.batch-inp{width:80px;padding:3px 5px;border:1px solid var(--border2);border-radius:4px;font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;text-align:right}.batch-inp:focus{border-color:var(--teal)}
.batch-auto{font-size:9px;color:var(--green);font-weight:600}
.batch-loading{font-size:10px;color:var(--text3);padding:6px 0}
/* Buttons */
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-issue{background:var(--teal);color:#fff;flex:1}.b-issue:hover:not(:disabled){background:#0891b2;box-shadow:0 4px 16px rgba(6,182,212,.4)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-new{background:rgba(6,182,212,.1);color:var(--teal);border:1px solid rgba(6,182,212,.3);flex:1}.b-new:hover{background:rgba(6,182,212,.2)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-teal{background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);color:#67e8f9}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.al-red{background:rgba(229,62,62,.08);border:1px solid rgba(229,62,62,.2);color:#fca5a5}
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.suc-box{text-align:center;padding:28px 16px}
.suc-icon{font-size:44px;margin-bottom:10px}
.suc-title{font-family:var(--display);font-size:26px;color:var(--green);margin-bottom:6px}
.suc-pill{display:inline-block;font-family:var(--mono);font-size:13px;padding:5px 16px;border-radius:6px;background:rgba(6,182,212,.12);color:var(--teal);border:1px solid rgba(6,182,212,.25);margin:8px 0 12px}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.order-info{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;font-size:11px}
@media(max-width:420px){.order-info{grid-template-columns:1fr}}
.oi-item{display:flex;flex-direction:column;gap:1px}
.oi-l{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
.oi-v{font-family:var(--mono);font-size:11px;color:var(--text)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/bom" class="nav-link">BOM</a>
<a href="/production" class="nav-link">Production</a>
<a href="/issue-production" class="nav-link active">Issue</a>
<a href="/receipt-production" class="nav-link">Receipt</a>
<a href="/grpo" class="nav-link">GRPO</a><a href="/approvals" class="nav-link">Approvals</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<div class="modal-bg" id="iss-detail-modal"><div class="modal" id="iss-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null,_wh=[],_varieties=[];
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch{}})();
if(!_tok)window.location.href='/';
// Fail fast (with a clear message) if the user hasn't set their own SAP
// login — the JWT carries a non-empty sapUser claim when they have.
function hasSapLogin(){try{const p=JSON.parse(atob(_tok.split('.')[1]));return !!(p&&p.sapLogins&&p.sapLogins[_co]&&p.sapLogins[_co].pwdEnc);}catch(_e){return false;}}
function requireSapLogin(){if(!hasSapLogin()){toast('Set your SAP User ID & Password first — open Profile → "My SAP Account".','err');return false;}return true;}
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
// Mirrors middleware/auth.js's hasStepPerm — this page is normally only
// reached via production.html's already-gated button, but a direct/bookmarked
// URL should still be blocked here too (the server enforces this regardless).
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
// Being assigned the step (any perm) is enough to perform the action.
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
const CAN_ISSUE_PO=hasStepAssigned('production_order:issuance');
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _branches=[];
let _selectedOrder=null;
let _lines=[];
let _removedLines=[]; // components taken out of THIS issue (not submitted); can be restored
let _hasHiddenPending=false; // a per-user item-group restriction hid a component that still genuinely needs issuing
let _issueGroups=null; // this user's allowed Item Groups for issuing (null = not yet loaded / no restriction)
let _loadingDetail=false;
let _submitting=false;
let _success=null;
let _readyOrders=[];
let _readyLoading=false;
let _modalOpen=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
// Whether a next page of Released orders exists — from the server total when
// known, else from whether a full RAW (pre-filter) page came back.
function pagerHasNext(){ if(_total!=null) return (_page+1)*_pageSize<_total; return _rawCount>=_pageSize; }
function renderPager(){
if(_readyLoading) return '';
const hasNext=pagerHasNext();
const totalPages=_total!=null?Math.max(1,Math.ceil(_total/_pageSize)):null;
const label=_total!=null?`Page ${_page+1} of ${totalPages} · ${_total} released`:`Page ${_page+1}`;
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn" id="rp-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${_page===0?';opacity:.45;cursor:not-allowed':''}">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">${label}</span>
<button class="btn" id="rp-next" ${hasNext?'':'disabled'} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${hasNext?'':';opacity:.45;cursor:not-allowed'}">Next ›</button>
</div>`;
}
// Released orders not yet fully issued — the default "what's ready to
// issue" list shown when the page isn't deep-linked to one specific order.
async function loadReadyOrders(resetPage){
if(resetPage)_page=0;
_readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{
const r=await api('GET',`/sap/production-orders?status=Released&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}`);
_rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>o._issueStatus!=='full');
_total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render();
}
async function loadWH(){try{const r=await api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_wh=r.data;}catch(_e){}}
async function loadVarieties(){try{const r=await api('GET',`/sap/lookup/costing-codes?dim=1&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_varieties=r.data;}catch(_e){}}
async function loadBranches(){try{const r=await api('GET',`/sap/lookup/branches?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_branches=r.data;}catch(_e){}}
async function selectOrder(absEntry){
_modalOpen=true;_loadingDetail=true;_selectedOrder=null;_lines=[];_removedLines=[];render();
try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
const d=r.data||r;
_selectedOrder=d;
_lines=(d.ProductionOrderLines||[]).map(l=>({
lineNum:l.LineNumber,
itemCode:l.ItemNo||'',
itemName:l.ItemName||'',
itemType:l.ItemType||'pit_Item',
plannedQty:l.PlannedQuantity||0,
issuedQty:l.IssuedQuantity||0,
remainingQty:Math.max(0,(l.PlannedQuantity||0)-(l.IssuedQuantity||0)),
issueQty:Math.max(0,(l.PlannedQuantity||0)-(l.IssuedQuantity||0)),
selected:true, // include in this issue by default; user may uncheck or Remove
_savedQty:null, // remembers issueQty while unchecked, so re-checking restores it
warehouse:l.Warehouse||d.Warehouse||'',
costingCode:l.DistributionRule||'',
batches:[],
selectedBatches:[],
batchLoading:false,
}));
// Backflush-type lines are consumed by SAP itself, never manually issued
// here — hide them outright (not a permission thing, so it doesn't count
// toward _hasHiddenPending like the item-group filter below does).
_lines=_lines.filter(l=>{
const src=(d.ProductionOrderLines||[]).find(x=>x.LineNumber===l.lineNum);
return src?.ProductionOrderIssueType!=='im_Backflush';
});
// Per-user item-group restriction: hide components whose item group is NOT
// in this user's allowed list. Empty/no list = show everything (unchanged).
// Track whether anything genuinely still-outstanding got hidden this way
// — the "all fully issued" congratulatory message must NOT claim the
// order is done just because nothing left VISIBLE to this user needs
// issuing; that's a permission gap, not the order's real state.
_hasHiddenPending=false;
if(Array.isArray(_issueGroups)&&_issueGroups.length&&_lines.length){
try{
const codes=[...new Set(_lines.map(l=>l.itemCode).filter(Boolean))];
const gr=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
const grpByCode=gr.data||{};const allow=new Set(_issueGroups.map(String));
const hidden=_lines.filter(l=>!allow.has(String(grpByCode[l.itemCode])));
_hasHiddenPending=hidden.some(l=>l.remainingQty>0.001);
_lines=_lines.filter(l=>allow.has(String(grpByCode[l.itemCode])));
}catch(_e){}
}
// A component replaced by an approved Substitution deviation, whose own
// remaining is already down to SAP's zero-floor (see [[production-
// deviation-workflow]]) — must never be issuable (it isn't actually
// available), and must not keep this order from ever reading as "fully
// issued" just because a negligible 0.0001 sits unissued forever.
try{
const devR=await api('GET',`/sap/deviations?sapAbsEntry=${absEntry}&company=${encodeURIComponent(_co)}`);
const supersededCodes=new Set((devR.data||[])
.filter(dv=>dv.type==='SUBSTITUTION'&&dv.qaStatus==='APPROVED'&&dv.itemCode)
.map(dv=>dv.itemCode.toUpperCase()));
if(supersededCodes.size){
_lines.forEach(l=>{
if(supersededCodes.has((l.itemCode||'').toUpperCase())&&l.remainingQty<=0.001){
l.superseded=true; l.selected=false; l.issueQty=0; l.remainingQty=0;
}
});
}
}catch(_e){}
// Load batches for all lines in parallel
await Promise.all(_lines.map(l=>loadBatches(l)));
}catch(e){toast(e.message,'err');}
_loadingDetail=false;render();
}
async function loadBatches(line){
line.batchLoading=true;
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(line.itemCode)}&warehouse=${encodeURIComponent(line.warehouse)}&company=${encodeURIComponent(_co)}`);
line.batches=r.data||[];
if(line.batches.length) autoSelectBatches(line);
}catch{}
line.batchLoading=false;
}
function autoSelectBatches(line){
let rem=line.issueQty;
line.selectedBatches=[];
for(const b of line.batches){
if(rem<=0) break;
const take=Math.min(b.Quantity,rem);
line.selectedBatches.push({BatchNumber:b.BatchNumber,Quantity:take});
rem-=take;
}
}
async function doIssue(){
if(!_selectedOrder||!_lines.length)return;
const issuable=_lines.filter(l=>l.selected&&l.issueQty>0);
if(!issuable.length){toast('No items selected to issue — check at least one component and enter a quantity','err');return;}
// Validate batch totals
for(const l of issuable){
if(l.batches.length){
const batchTotal=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
if(Math.abs(batchTotal-l.issueQty)>0.001){
toast(`${l.itemCode}: batch qty (${batchTotal}) must equal issue qty (${l.issueQty})`,'err');return;
}
}
}
if(!requireSapLogin())return;
_submitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const postDate=q('#inp-postdate')?.value||'';
const payload={
company:_co,
branchId:bplId,
};
if(postDate){payload.DocDate=postDate;payload.DocDueDate=postDate;}
payload.DocumentLines=issuable.map(l=>{
const dl={BaseEntry:_selectedOrder.AbsoluteEntry,BaseLine:l.lineNum,BaseType:202,ItemCode:l.itemCode,Quantity:l.issueQty,WarehouseCode:l.warehouse};
if(l.costingCode) dl.CostingCode=l.costingCode;
if(l.selectedBatches.length){
dl.BatchNumbers=l.selectedBatches.filter(b=>b.Quantity>0).map(b=>({BatchNumber:b.BatchNumber,Quantity:b.Quantity}));
}
return dl;
});
try{
const r=await api('POST','/sap/issue-production',payload);
_success={docEntry:r.data?.DocEntry||'',docNum:r.data?.DocNum||''};
toast('Issue for Production posted!');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
// ═══════════════════════════════════════
// RENDER
// ═══════════════════════════════════════
function render(){
try{ _doRender(); }catch(e){ console.error('Render error:',e); document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Render error: ${e.message}</div>`; }
}
function closeIssModal(){_modalOpen=false;_selectedOrder=null;_lines=[];_removedLines=[];_success=null;render();loadReadyOrders();}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
if(!CAN_ISSUE_PO){
app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You\'re not assigned the "Issuance" approval step, so you can\'t issue items for production. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
document.getElementById('iss-detail-modal')?.classList.remove('show');
return;
}
// Company
const c1=mk('card');
const brOpts=_branches.map(b=>`<option value="${b.BPLId}" ${b.BPLId===2?'selected':''}>${esc(b.BPLName)} (${b.BPLId})</option>`).join('')||'<option value="2">FACTORY (2)</option>';
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> Issue for Production</div>
<div class="card-bd">
<div class="fi-row">
<div class="fg"><label class="fl">Company</label>
<select class="fi" id="sel-co">
${coOptions(_co)}
</select></div>
<div class="fg"><label class="fl">Branch</label><select class="fi" id="sel-branch">${brOpts}</select></div>
</div>
<div class="fg"><label class="fl">Posting Date</label><input class="fi" id="inp-postdate" type="date" value="${new Date().toISOString().slice(0,10)}"/></div>
</div>`;
app.appendChild(c1);
// Orders ready for issuance — square-tile grid, always visible (matches
// production.html's own list/picker), opens the detail popup on click.
const c2b=mk('card');
const gridBody=_readyLoading
?'<div class="empty-msg"><span class="sp" style="border-color:rgba(6,182,212,.25);border-top-color:var(--teal)"></span> Loading released orders...</div>'
:!_readyOrders.length?'<div class="empty-msg">No released orders are awaiting issuance right now.</div>'
:`<div class="po-grid">${_readyOrders.map(o=>`<div class="po-card${_selectedOrder&&_selectedOrder.AbsoluteEntry===o.AbsoluteEntry?' selected':''}" data-ready="${o.AbsoluteEntry}">
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${o._issueStatus==='partial'?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(245,166,35,.12);color:var(--amber)">PARTIAL</span>':''}</div>
<div class="po-icon">📦</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</div>
<div class="po-meta"><span>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>WH <b>${esc(o.Warehouse||'-')}</b></span></div>
${(o.WoNo||o.BatchNumber)?`<div class="po-meta"><span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span></div>`:''}
</div>`).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px">
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--teal);color:#fff;white-space:nowrap">Search</button>
</div>`;
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready for Issuance<span class="right">${_readyOrders.length}</span></div><div class="card-bd">${searchRow}${gridBody}${renderPager()}</div>`;
app.appendChild(c2b);
renderModal();
// Wire (page-level controls)
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=async function(){_co=this.value;await Promise.all([loadWH(),loadBranches(),loadVarieties()]);loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;});
q('#rp-search')&&(q('#rp-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadReadyOrders(true);}});
q('#rp-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}});
q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}});
document.querySelectorAll('[data-ready]').forEach(c=>{c.onclick=()=>selectOrder(parseInt(c.dataset.ready));});
},0);
}
// ═══════════════════════════════════════
// DETAIL / ISSUE POPUP
// ═══════════════════════════════════════
function renderModal(){
const bg=document.getElementById('iss-detail-modal');
const inner=document.getElementById('iss-modal-inner');
if(!_modalOpen){bg.classList.remove('show');return;}
bg.classList.add('show');
if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Issue for Production — Posted</span><button class="modal-close" id="iss-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Issue Posted!</div>
<p style="font-size:13px;color:var(--text3)">Components issued against Production Order #${esc(String(_selectedOrder?.DocumentNumber||''))}</p>
<div class="suc-pill">DocEntry #${esc(String(_success.docEntry))} | DocNum #${esc(String(_success.docNum))}</div>
<div class="alert al-green" style="max-width:400px;margin:10px auto;text-align:left">
Inventory General Exit created in SAP B1. The issued quantities are now deducted from stock.
</div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
<button class="btn b-new" id="btn-new">+ New Issue</button>
</div>
</div></div>`;
setTimeout(()=>{
q('#iss-modal-x').onclick=closeIssModal;
q('#btn-new').onclick=closeIssModal;
},0);
return;
}
if(_loadingDetail||!_selectedOrder){
inner.innerHTML=`<div class="modal-hd"><span>Loading Order…</span><button class="modal-close" id="iss-modal-x">✕</button></div>
<div class="modal-bd"><div class="empty-msg"><span class="sp" style="border-color:rgba(6,182,212,.25);border-top-color:var(--teal)"></span> Loading order details...</div></div>`;
setTimeout(()=>{q('#iss-modal-x').onclick=closeIssModal;},0);
return;
}
const d=_selectedOrder;
const linesHtml=_lines.map((l,i)=>{
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}" ${l.warehouse===w.code?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('');
const typeLabel=l.itemType==='pit_Resource'?'Resource':'Item';
const typeCls=l.itemType==='pit_Resource'?'resource':'item';
const isDone=l.remainingQty<=0;
const inactive=isDone||!l.selected; // fully issued, or user unchecked it
let batchHtml='';
if(!inactive&&l.batchLoading){
batchHtml='<div class="batch-loading"><span class="sp" style="width:10px;height:10px;border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading batches...</div>';
}else if(!inactive&&l.batches.length){
batchHtml=`<div class="batch-sec"><div class="batch-title">Batches <span class="batch-auto">(auto-selected)</span></div>
${l.batches.map((b,bi)=>{
const sel=l.selectedBatches.find(s=>s.BatchNumber===b.BatchNumber);
const qty=sel?sel.Quantity:0;
return`<div class="batch-row">
<span class="batch-num">${esc(b.BatchNumber)}</span>
<span class="batch-avail">Avail: ${b.Quantity}</span>
<input class="batch-inp" data-li="${i}" data-bi="${bi}" data-bn="${esc(b.BatchNumber)}" type="number" min="0" max="${b.Quantity}" step="0.001" value="${qty}"/>
</div>`;
}).join('')}
</div>`;
}
return`<div class="comp-card${!l.selected&&!isDone?' unselected':''}${isDone?' done':''}">
<div class="comp-top-row">
${isDone?'':`<input type="checkbox" class="comp-check" data-i="${i}" ${l.selected?'checked':''} title="Include this item in this issue"/>`}
<span class="comp-num">#${i+1}</span>
<span class="comp-type ${typeCls}">${typeLabel}</span>
${l.superseded?'<span class="comp-badge-done" style="background:rgba(122,154,184,.15);color:var(--text2)" title="Replaced by a Substitution deviation — no longer available to issue">↦ Superseded</span>':(isDone?'<span class="comp-badge-done">✓ Fully Issued</span>':'')}
<div class="comp-actions">${isDone?'':`<button type="button" class="comp-remove" data-remove-i="${i}" title="Take this item out of this issue">✕ Remove</button>`}</div>
</div>
<div class="comp-item">${esc(l.itemCode)}</div>
<div class="comp-desc">${esc(l.itemName)}</div>
<div class="comp-grid">
<div class="comp-field"><span class="comp-fl">Planned</span><span class="comp-fv">${l.plannedQty}</span></div>
<div class="comp-field"><span class="comp-fl">Issued</span><span class="comp-fv">${l.issuedQty}</span></div>
<div class="comp-field"><span class="comp-fl">Remaining</span><span class="comp-fv" style="color:${l.remainingQty>0?'var(--amber)':'var(--green)'}">${l.remainingQty}</span></div>
<div class="comp-field"><span class="comp-fl">Issue Qty *</span><input class="comp-inp" data-i="${i}" data-f="issueQty" type="number" min="0" step="0.001" value="${l.issueQty}" ${inactive?'disabled':''}/></div>
<div class="comp-field" style="grid-column:span 2"><span class="comp-fl">Warehouse</span><select class="comp-inp" data-i="${i}" data-f="warehouse" style="padding:2px 4px" ${inactive?'disabled':''}>${whOpts}</select></div>
</div>
${batchHtml}
</div>`;
}).join('');
const pendingLines=_lines.filter(l=>l.remainingQty>0);
const selectedCount=pendingLines.filter(l=>l.selected).length;
const totalQty=_lines.filter(l=>l.selected).reduce((s,l)=>s+(l.issueQty||0),0);
const toolbar=pendingLines.length?`<div class="comp-toolbar">
<span><b>${selectedCount}</b> of <b>${pendingLines.length}</b> item${pendingLines.length===1?'':'s'} selected${totalQty>0?` · Qty to issue <b>${Math.round(totalQty*1000)/1000}</b>`:''}</span>
<button type="button" class="chip-btn" id="btn-select-all">Select all</button>
<button type="button" class="chip-btn" id="btn-select-none">Clear all</button>
</div>`:'';
const removedStrip=_removedLines.length?`<div class="removed-strip">
<span><b>${_removedLines.length}</b> item${_removedLines.length===1?'':'s'} removed from this issue</span>
<button type="button" class="chip-btn" id="btn-restore-removed" style="margin-left:auto">↩ Restore</button>
</div>`:'';
const allIssued=_lines.length>0&&_lines.every(l=>l.remainingQty<=0);
const hasQty=_lines.some(l=>l.selected&&l.issueQty>0);
// A per-user item-group restriction can hide a component that genuinely
// still needs issuing — in that case the order is NOT actually done, this
// user just has nothing left THEY'RE permitted to act on. Never claim
// "fully issued" when that's true — it's misleading and could make
// someone think the order needs no further attention from anyone.
const footer=allIssued
?(_hasHiddenPending
?`<div class="alert al-amber" style="margin:0">Nothing left for you to issue in your assigned item group(s) — but this order still has other components pending in a different item group. Someone with access to those groups still needs to issue them.</div>
<div class="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`
:`<div class="alert al-green" style="margin:0">All components have been fully issued for this production order.</div>
<div class="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`)
:`<div class="btn-bar" style="margin-top:8px">
<button class="btn b-issue" id="btn-issue" ${!hasQty||_submitting?'disabled':''}><span id="sp-issue" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Issue to Production</button>
<button class="btn b-cancel" id="btn-clear">Clear</button>
</div>`;
inner.innerHTML=`
<div class="modal-hd">
<span style="font-family:var(--mono);color:var(--teal)">${esc(d.ItemNo)}</span>
<span style="color:var(--text3);font-size:11px">Order #${d.DocumentNumber||d.AbsoluteEntry}</span>
<button class="modal-close" id="iss-modal-x" style="margin-left:auto">✕</button>
</div>
<div class="modal-bd">
<div class="order-info">
<div class="oi-item"><span class="oi-l">Product</span><span class="oi-v">${esc(d.ItemNo)}</span></div>
<div class="oi-item"><span class="oi-l">Description</span><span class="oi-v" style="font-family:var(--font)">${esc(d.ProductDescription||'-')}</span></div>
<div class="oi-item"><span class="oi-l">Planned Qty</span><span class="oi-v">${d.PlannedQuantity}</span></div>
<div class="oi-item"><span class="oi-l">Completed</span><span class="oi-v">${d.CompletedQuantity||0}</span></div>
<div class="oi-item"><span class="oi-l">Warehouse</span><span class="oi-v">${esc(d.Warehouse||'-')}</span></div>
<div class="oi-item"><span class="oi-l">Due Date</span><span class="oi-v">${d.DueDate?new Date(d.DueDate).toLocaleDateString('en-IN'):'-'}</span></div>
</div>
<div class="sec-title" style="font-size:10px;font-weight:800;text-transform:uppercase;color:var(--text3);margin:10px 0 8px">Issue Components (${_lines.length})</div>
${removedStrip}
${toolbar}
${_lines.length?linesHtml:'<div class="empty-msg">No component lines</div>'}
${footer}
</div>`;
setTimeout(()=>{
q('#iss-modal-x').onclick=closeIssModal;
wireLineInputs();
wireBatchInputs();
wireSelectionControls();
q('#btn-issue')&&(q('#btn-issue').onclick=doIssue);
q('#btn-clear')&&(q('#btn-clear').onclick=closeIssModal);
},0);
}
// Checkbox (include/exclude), Remove button, and the select-all/clear-all/
// restore toolbar — lets the user issue only SOME of an order's components
// today and come back for the rest later, instead of an all-or-nothing post.
function wireSelectionControls(){
document.querySelectorAll('.comp-check[data-i]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.i);
const l=_lines[i];if(!l)return;
l.selected=this.checked;
if(l.selected){
l.issueQty=l._savedQty!=null?l._savedQty:l.remainingQty;
l._savedQty=null;
if(l.batches.length) autoSelectBatches(l);
}else{
l._savedQty=l.issueQty;
l.issueQty=0;
}
render();
};
});
document.querySelectorAll('[data-remove-i]').forEach(el=>{
el.onclick=function(){
const i=parseInt(this.dataset.removeI);
const removed=_lines.splice(i,1)[0];
if(removed)_removedLines.push(removed);
render();
};
});
q('#btn-select-all')&&(q('#btn-select-all').onclick=()=>{
_lines.forEach(l=>{ if(l.remainingQty>0&&!l.selected){ l.selected=true; l.issueQty=l._savedQty!=null?l._savedQty:l.remainingQty; l._savedQty=null; if(l.batches.length)autoSelectBatches(l); } });
render();
});
q('#btn-select-none')&&(q('#btn-select-none').onclick=()=>{
_lines.forEach(l=>{ if(l.remainingQty>0&&l.selected){ l._savedQty=l.issueQty; l.selected=false; l.issueQty=0; } });
render();
});
q('#btn-restore-removed')&&(q('#btn-restore-removed').onclick=()=>{
_lines.push(..._removedLines);
_removedLines=[];
render();
});
}
function wireLineInputs(){
document.querySelectorAll('.comp-inp[data-i]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.i),f=this.dataset.f;
if(f==='warehouse'){
_lines[i].warehouse=this.value;
// Reload batches for new warehouse
_lines[i].batches=[];_lines[i].selectedBatches=[];
loadBatches(_lines[i]).then(()=>render());
}else if(f==='issueQty'){
_lines[i].issueQty=parseFloat(this.value)||0;
if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
render();
}else if(f==='costingCode'){
_lines[i].costingCode=this.value;return;
}
};
});
}
function wireBatchInputs(){
document.querySelectorAll('.batch-inp').forEach(el=>{
el.onchange=function(){
const li=parseInt(this.dataset.li);
const bn=this.dataset.bn;
const qty=parseFloat(this.value)||0;
const line=_lines[li];
if(!line)return;
const existing=line.selectedBatches.find(b=>b.BatchNumber===bn);
if(existing) existing.Quantity=qty;
else if(qty>0) line.selectedBatches.push({BatchNumber:bn,Quantity:qty});
// Remove zeros
line.selectedBatches=line.selectedBatches.filter(b=>b.Quantity>0);
};
});
}
async function init(){
try{
// Check URL hash for auto-load (e.g. #order=8312&company=TEST_OIL_15122025)
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch){_co=decodeURIComponent(coMatch[1]);}
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
// Load this user's allowed issue Item Groups (fresh from DB, so admin
// changes apply without a re-login). Empty/absent = no restriction.
try{const pr=await api('GET','/auth/profile');_issueGroups=Array.isArray(pr?.user?.issueItemGroups)?pr.user.issueItemGroups:[];}catch(_e){_issueGroups=[];}
await Promise.all([loadWH(),loadBranches(),loadVarieties()]);
render();
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry) await selectOrder(entry);
}else{
// No specific order in the URL — show what's ready for issuance by
// default instead of an empty page.
loadReadyOrders();
}
}catch(e){
console.error('Init error:',e);
render();
}
}
init();
// If this page is already open (e.g. the sidebar "Pending Actions" bell
// linked here for a different order while the user was still on this same
// page), only the URL's hash changes — browsers don't reload the page for
// a hash-only change, so init() would never re-run and the old/empty state
// would stick around until a manual refresh. Re-check the hash and open
// the newly-linked order whenever that happens.
window.addEventListener('hashchange',()=>{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry)selectOrder(entry);
}
});
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ITC-04 Report | MITRA ERP Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}.hdr h1 span{color:#047e7e}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#047e7e;color:#fff}.btn-primary:hover{background:#035b5b}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#047e7e;border:1.5px solid #047e7e}.btn-outline:hover{background:#f0fafa}
.btn-outline:disabled{opacity:.5;cursor:not-allowed}
.btn-tcs{background:#1d4ed8;color:#fff}.btn-tcs:hover{background:#1e40af}
.btn-tcs:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-grp{display:flex;gap:8px;flex-wrap:wrap}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#047e7e;box-shadow:0 0 0 3px rgba(4,126,126,.08)}
.summary-chips{display:flex;gap:8px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb}
.chip{background:#fff;border:1.5px solid #c8e6e6;border-radius:10px;padding:6px 14px;font-size:11px;font-weight:600;color:#047e7e;min-width:100px}
.chip span{font-size:15px;font-weight:700;display:block;color:#1a1a2e;margin-top:1px}
.chip.blue{border-color:#bfdbfe;color:#1d4ed8}.chip.blue span{color:#1a1a2e}
.chip.amber{border-color:#fde68a;color:#92400e}.chip.amber span{color:#1a1a2e}
.tbl-wrap{overflow-x:auto;overflow-y:auto;max-height:54vh}
.tbl-wrap::-webkit-scrollbar{height:7px;width:7px}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
table{border-collapse:collapse;min-width:100%}
thead th{background:#f0f4f8;color:#4b5563;font-weight:700;padding:9px 11px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #d1dae3;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s}
tbody tr:hover{background:#edfafa}
tbody td{padding:8px 11px;white-space:nowrap;color:#374151;font-size:12px}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
tbody td.trunc{max-width:160px;overflow:hidden;text-overflow:ellipsis}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.type-badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:12px;font-size:10px;font-weight:700;letter-spacing:.4px}
.type-m2jw{background:#dcfce7;color:#166534}.type-jw2m{background:#dbeafe;color:#1e40af}
.ack-box{display:none;margin-top:12px;padding:14px 18px;background:#f0fdf4;border:1.5px solid #86efac;border-radius:10px;font-size:13px;color:#166534}
.ack-box.err{background:#fef2f2;border-color:#fca5a5;color:#991b1b}
.ack-box.show{display:block}
.ack-box strong{font-size:15px;display:block;margin-bottom:4px}
.info-note{background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:10px 14px;font-size:12px;color:#1e40af;margin:12px 20px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div id="sidebar-placeholder"></div>
<div class="sb-main">
<div class="pg">
<div class="hdr">
<h1>ITC-04 <span>Job Work Challan</span></h1>
<div class="btn-grp">
<button class="btn btn-outline" id="btnDownload" disabled onclick="downloadCsv()">Download CSV</button>
<button class="btn btn-tcs" id="btnUpload" disabled onclick="uploadToGsp()">Upload to GSP</button>
</div>
</div>
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>Type</label>
<select id="selType" onchange="onTypeChange()">
<option value="M2JW">M2JW — Manufacturer to Job Worker</option>
<option value="JW2M">JW2M — Job Worker to Manufacturer</option>
</select>
</div>
<div class="fg">
<label>From Date</label>
<input type="date" id="fromDate">
</div>
<div class="fg">
<label>To Date</label>
<input type="date" id="toDate">
</div>
<div class="fg">
<label>Quarter Period <span style="font-size:9px;color:#9ca3af;font-weight:400">(override if needed)</span></label>
<input type="text" id="quarterDisplay" placeholder="e.g. 132026" maxlength="6"
style="width:120px;text-transform:none"
title="Q1(Apr-Jun)=13yyyy Q2(Jul-Sep)=14yyyy Q3(Oct-Dec)=15yyyy Q4(Jan-Mar)=16yyyy Half-Yr Apr-Sep=17yyyy Half-Yr Oct-Mar=18yyyy Annual=19yyyy">
</div>
<div class="fg">
<label>Action Flag</label>
<select id="selFlag" title="A=Add (first upload), M=Modify (re-upload same period), D=Delete">
<option value="A">A — Add (new)</option>
<option value="M">M — Modify (re-upload)</option>
<option value="D">D — Delete</option>
</select>
</div>
<button class="btn btn-primary" id="btnLoad" onclick="loadData()">Load Data</button>
</div>
<div class="info-note" id="infoNote">
<strong>M2JW</strong>: Goods sent to Job Worker. Uses SAP B1 stock transfers with series <code>JW*</code>.
</div>
<div class="summary-chips" id="chips" style="display:none">
<div class="chip"><div>Type</div><span id="chType">—</span></div>
<div class="chip"><div>Quarter Period</div><span id="chPeriod">—</span></div>
<div class="chip blue"><div>Total Records</div><span id="chCount">0</span></div>
<div class="chip amber"><div>Total Taxable Value</div><span id="chVal">₹0</span></div>
</div>
<div class="tbl-wrap" id="tblWrap">
<div class="empty">Select a date range and click <strong>Load Data</strong></div>
</div>
<div id="ackBox" class="ack-box"></div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Ready</span>
</div>
</div>
</div>
</div>
<script>
(function(){
const tok = ()=> sessionStorage.getItem('portal_token')||'';
const authH = ()=>({'Content-Type':'application/json','Authorization':'Bearer '+tok()});
const fmt = v => '₹'+Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
const fmtNum = v => Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
let currentData = [];
// ── Set default date range (current quarter) ──────────────────────────────
(function setDefaults(){
const today = new Date();
const mm = today.getMonth()+1, yyyy = today.getFullYear();
let qFrom, qTo;
if (mm>=4 && mm<=6) { qFrom=`${yyyy}-04-01`; qTo=`${yyyy}-06-30`; }
else if (mm>=7 && mm<=9) { qFrom=`${yyyy}-07-01`; qTo=`${yyyy}-09-30`; }
else if (mm>=10 && mm<=12) { qFrom=`${yyyy}-10-01`; qTo=`${yyyy}-12-31`; }
else { qFrom=`${yyyy-1}-01-01`; qTo=`${yyyy-1}-03-31`; }
document.getElementById('fromDate').value = qFrom;
document.getElementById('toDate').value = qTo;
updateQuarterDisplay();
})();
function updateQuarterDisplay(){
const from = document.getElementById('fromDate').value;
if(!from){ document.getElementById('quarterDisplay').value=''; return; }
const d = new Date(from);
const mm = d.getMonth()+1, yyyy = d.getFullYear();
// TCS period codes: Q1=13, Q2=14, Q3=15, Q4=16
let code;
if (mm>=4 && mm<=6) code='13';
else if (mm>=7 && mm<=9) code='14';
else if (mm>=10 && mm<=12) code='15';
else code='16';
const fy = mm>=4 ? yyyy : yyyy-1;
document.getElementById('quarterDisplay').value = code+fy; // e.g. 132026
}
document.getElementById('fromDate').addEventListener('change', updateQuarterDisplay);
function onTypeChange(){
const t = document.getElementById('selType').value;
document.getElementById('infoNote').innerHTML = t==='M2JW'
? '<strong>M2JW</strong>: Goods sent to Job Worker. Uses SAP B1 stock transfers with series <code>JW*</code>.'
: '<strong>JW2M</strong>: Goods received from Job Worker. Uses SAP B1 stock transfers with series <code>JWR*</code>.';
currentData = [];
document.getElementById('tblWrap').innerHTML = '<div class="empty">Click <strong>Load Data</strong> to fetch</div>';
document.getElementById('chips').style.display='none';
document.getElementById('btnDownload').disabled=true;
document.getElementById('btnUpload').disabled=true;
}
function setStatus(msg,type=''){
document.getElementById('statusMsg').textContent=msg;
const d=document.getElementById('statusDot');
d.className='status-dot'+(type?' '+type:'');
}
// ── Load Data ─────────────────────────────────────────────────────────────
async function loadData(){
const from=document.getElementById('fromDate').value;
const to=document.getElementById('toDate').value;
const type=document.getElementById('selType').value;
const flag=document.getElementById('selFlag').value;
if(!from||!to){setStatus('Select from and to dates','err');return;}
document.getElementById('btnLoad').disabled=true;
document.getElementById('btnDownload').disabled=true;
document.getElementById('btnUpload').disabled=true;
document.getElementById('ackBox').className='ack-box';
document.getElementById('tblWrap').innerHTML='<div class="empty">Loading…</div>';
setStatus('Fetching data from SAP B1…','loading');
try{
const r=await fetch(`/api/itc04/data?from=${from}&to=${to}&type=${type}&resultflag=${flag}`,{headers:authH()});
const j=await r.json();
if(!j.success) throw new Error(j.message);
currentData=j.data||[];
renderTable(currentData,type);
updateChips(type,currentData,j.totalValue);
setStatus(`${currentData.length} records loaded`,'ok');
document.getElementById('btnDownload').disabled=currentData.length===0;
document.getElementById('btnUpload').disabled=currentData.length===0;
}catch(e){
setStatus(e.message,'err');
document.getElementById('tblWrap').innerHTML=`<div class="empty" style="color:#ef4444">${e.message}</div>`;
}finally{
document.getElementById('btnLoad').disabled=false;
}
}
function updateChips(type,rows,total){
const fp=rows[0]?.fp||'—';
document.getElementById('chType').textContent=type;
document.getElementById('chPeriod').textContent=fp;
document.getElementById('chCount').textContent=rows.length;
document.getElementById('chVal').textContent=fmt(total);
document.getElementById('chips').style.display='flex';
}
function renderTable(rows,type){
if(!rows.length){
document.getElementById('tblWrap').innerHTML='<div class="empty">No data found for the selected period and type</div>';
return;
}
const badge=type==='M2JW'
?`<span class="type-badge type-m2jw">M2JW</span>`
:`<span class="type-badge type-jw2m">JW2M</span>`;
const headers=[
{k:'ack_sr_no',l:'#'},
{k:'chnum',l:'Challan No'},
{k:'chdt',l:'Challan Date'},
{k:'ctin_name',l:'Job Worker',css:'trunc'},
{k:'ctin',l:'GSTIN'},
{k:'jw_stcd',l:'State Code'},
{k:'fp',l:'Quarter'},
{k:'goods_ty',l:'Goods Type'},
{k:'uqc',l:'UOM'},
{k:'qty',l:'Qty',css:'num'},
{k:'desc',l:'Description',css:'trunc'},
{k:'txval',l:'Taxable Value',css:'num'},
{k:'tx_i',l:'IGST %',css:'num'},
{k:'tx_c',l:'CGST %',css:'num'},
{k:'tx_s',l:'SGST %',css:'num'},
{k:'resultflag',l:'Flag'},
];
const thead='<thead><tr>'+headers.map(h=>`<th>${h.l}</th>`).join('')+'</tr></thead>';
const tbody='<tbody>'+rows.map(r=>'<tr>'+headers.map(h=>{
const v=r[h.k]??'';
const cls=h.css||'';
const disp=['txval','qty'].includes(h.k)?fmtNum(v):v;
return `<td class="${cls}">${disp}</td>`;
}).join('')+'</tr>').join('')+'</tbody>';
document.getElementById('tblWrap').innerHTML=
`<table>${thead}${tbody}</table>`;
}
// ── Download CSV ──────────────────────────────────────────────────────────
async function downloadCsv(){
const from=document.getElementById('fromDate').value;
const to=document.getElementById('toDate').value;
const type=document.getElementById('selType').value;
const flag=document.getElementById('selFlag').value;
const manualPeriod=document.getElementById('quarterDisplay').value.trim();
setStatus('Preparing CSV…','loading');
try{
const qs=`from=${from}&to=${to}&type=${type}&resultflag=${flag}`+(manualPeriod?`&period=${manualPeriod}`:'');
const r=await fetch(`/api/itc04/download?${qs}`,{headers:{'Authorization':'Bearer '+tok()}});
if(!r.ok){const e=await r.json();throw new Error(e.message);}
const blob=await r.blob();
const fp=currentData[0]?.fp||'period';
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`ITC04_${type}_${fp}.csv`;
a.click();
setStatus('CSV downloaded','ok');
}catch(e){setStatus(e.message,'err');}
}
// ── Upload to GSP ─────────────────────────────────────────────────────────
async function uploadToGsp(){
const from=document.getElementById('fromDate').value;
const to=document.getElementById('toDate').value;
const type=document.getElementById('selType').value;
const flag=document.getElementById('selFlag').value;
const manualPeriod=document.getElementById('quarterDisplay').value.trim();
const ackBox=document.getElementById('ackBox');
ackBox.className='ack-box';
if(!confirm(`Upload ITC-04 ${type} data to TCS GSP?\n\nPeriod: ${document.getElementById('quarterDisplay').value}\nRecords: ${currentData.length}`)) return;
document.getElementById('btnUpload').disabled=true;
setStatus('Uploading to TCS GSP…','loading');
try{
const r=await fetch('/api/itc04/upload',{
method:'POST',
headers:authH(),
body:JSON.stringify({from,to,type,resultflag:flag,period:manualPeriod||undefined})
});
const j=await r.json();
if(!j.success) throw new Error(j.message);
ackBox.className='ack-box show';
// Build a detailed response panel
const ackHtml = j.ackNo
? `<strong>Upload Successful</strong>
<table style="margin-top:8px;border-collapse:collapse;width:100%;font-size:12px">
<tr><td style="padding:3px 10px 3px 0;color:#166534;font-weight:700;white-space:nowrap">Acknowledgement No.</td><td style="padding:3px 0"><b style="font-size:15px">${j.ackNo}</b></td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#166534;font-weight:700">Status</td><td>${j.status}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#166534;font-weight:700">Message</td><td>${j.message}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#166534;font-weight:700">Records</td><td>${currentData.length}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#166534;font-weight:700">Period (fp)</td><td>${currentData[0]?.fp||'—'}</td></tr>
</table>`
: `<strong>Upload Submitted</strong>
<div style="margin-top:6px;font-size:12px">Status: ${j.status} &nbsp;|&nbsp; Message: ${j.message}</div>
<details style="margin-top:8px;font-size:11px"><summary style="cursor:pointer;color:#166534">Full API Response</summary>
<pre style="margin-top:6px;background:#f0fdf4;padding:8px;border-radius:6px;overflow-x:auto;font-size:10.5px">${JSON.stringify(j.raw,null,2)}</pre>
</details>`;
ackBox.innerHTML = ackHtml;
setStatus('Upload successful — Ack: '+(j.ackNo||'submitted'),'ok');
}catch(e){
ackBox.className='ack-box err show';
ackBox.innerHTML=`<strong>Upload Failed</strong>${e.message}`;
setStatus('Upload failed: '+e.message,'err');
}finally{
document.getElementById('btnUpload').disabled=false;
}
}
window.loadData=loadData;
window.downloadCsv=downloadCsv;
window.uploadToGsp=uploadToGsp;
window.onTypeChange=onTypeChange;
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>Item Group Rules — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="/lucide.min.js?v=399"></script>
<style>
:root{
--igc-blue:#2563eb;--igc-blue2:#1d4ed8;--igc-blue-light:#eff6ff;
--igc-text:#0f172a;--igc-text2:#334155;--igc-text3:#64748b;--igc-text4:#94a3b8;
--igc-bg:#f4f6f9;--igc-surface:#fff;--igc-surface2:#f8fafc;--igc-surface3:#eef1f5;--igc-border:#e2e8f0;
--igc-font:'Space Grotesk',sans-serif;--igc-mono:'JetBrains Mono',ui-monospace,monospace;
--igc-red:#dc2626;--igc-green:#16a34a;--igc-amber:#d97706;--igc-purple:#7c3aed;
--igc-shadow:0 1px 3px rgba(15,23,42,.06),0 1px 2px rgba(15,23,42,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--igc-font);background:var(--igc-bg)!important;color:var(--igc-text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:24px;max-width:980px;margin:0 auto}
.page-title{font-size:22px;font-weight:800;display:flex;align-items:center;gap:10px}
.page-sub{font-size:13px;color:var(--igc-text3);margin-top:4px;line-height:1.5;max-width:700px}
.card{background:var(--igc-surface);border:1px solid var(--igc-border);border-radius:12px;box-shadow:var(--igc-shadow);margin-top:18px;overflow:hidden}
.card-hd{padding:14px 18px;border-bottom:1px solid var(--igc-border);font-size:13px;font-weight:700;background:var(--igc-surface2);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.card-hd input{margin-left:auto;padding:7px 11px;border:1.5px solid var(--igc-border);border-radius:8px;font-size:12.5px;font-family:var(--igc-font);width:220px;outline:none;background:var(--igc-surface)!important;color:var(--igc-text)!important}
.card-hd input:focus{border-color:var(--igc-blue)}
.card table{width:100%;border-collapse:collapse;font-size:13px;background:var(--igc-surface)!important;color:var(--igc-text)!important}
.card th{background:var(--igc-surface3)!important;color:var(--igc-text3)!important;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;text-align:left;padding:9px 14px;border-color:var(--igc-border)!important}
.card td{padding:9px 14px;border-top:1px solid var(--igc-border);vertical-align:middle;color:var(--igc-text)!important;border-color:var(--igc-border)!important}
.card tbody tr:hover td{background:var(--igc-surface2)!important}
.g-code{font-family:var(--igc-mono);font-size:11.5px;color:var(--igc-text3)!important}
.g-name{font-weight:600;color:var(--igc-text)!important}
.cls-chips{display:flex;gap:6px;flex-wrap:wrap}
.chip{display:inline-flex;align-items:center;gap:5px;padding:6px 11px;border-radius:20px;border:1.5px solid var(--igc-border);background:var(--igc-surface)!important;color:var(--igc-text3)!important;font-size:11.5px;font-weight:700;cursor:pointer;user-select:none;transition:all .12s}
.chip input{display:none}
.chip.on.c-RAW{border-color:#16a34a;background:#f0faf0!important;color:#15803d!important}
.chip.on.c-PACK{border-color:var(--igc-blue);background:var(--igc-blue-light)!important;color:var(--igc-blue2)!important}
.chip.on.c-COMPONENT{border-color:var(--igc-purple);background:#f5f0fe!important;color:var(--igc-purple)!important}
.chip.on.c-SFG{border-color:var(--igc-amber);background:#fef8ef!important;color:var(--igc-amber)!important}
.chip:not(.on):hover{border-color:var(--igc-text4)}
.row-saved{font-size:11px;color:var(--igc-green);display:inline-flex;align-items:center;gap:4px;margin-left:8px;opacity:0;transition:opacity .2s}
.row-saved.show{opacity:1}
.empty-msg{text-align:center;padding:44px;color:var(--igc-text3);font-size:13px}
.access-denied{text-align:center;padding:80px 20px}
.ad-title{font-size:22px;font-weight:800;color:var(--igc-red);margin-bottom:8px}
.ad-msg{font-size:13px;color:var(--igc-text3);max-width:360px;margin:0 auto}
.legend{display:flex;gap:16px;flex-wrap:wrap;padding:12px 18px;font-size:11.5px;color:var(--igc-text3);border-top:1px solid var(--igc-border);background:var(--igc-surface2)}
.legend b{color:var(--igc-text2)}
.toast-c{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast{padding:11px 16px;border-radius:9px;font-size:12px;font-weight:600;box-shadow:0 4px 16px rgba(15,23,42,.18);color:#fff;max-width:340px}
.t-ok{background:#16a34a}.t-err{background:#dc2626}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<nav></nav>
<main id="app">
<div style="padding:60px;text-align:center;color:var(--igc-text3)">Loading…</div>
</main>
<div class="toast-c" id="toast-c"></div>
<script src="/sidebar.js?v=25"></script>
<script>
'use strict';
let _tok=null,_user=null;
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch{}})();
if(!_tok){window.location.href='/';}
const BACKEND='/api';
let _groups=[], _loading=true, _q='';
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BACKEND+p,o);d=await r.json();}catch(e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=msg;tc.appendChild(el);setTimeout(()=>el.remove(),3200);}
async function loadGroups(){
_loading=true;render();
try{const r=await api('GET','/item-group-classification');_groups=r.data||[];}
catch(e){toast(e.message,'err');_groups=[];}
_loading=false;render();
}
async function saveClass(code,name,cls,rowEl){
try{
await api('PUT',`/item-group-classification/${code}`,{name,classification:cls});
const g=_groups.find(x=>x.code===code);if(g)g.classification=cls;
const flag=rowEl.querySelector('.row-saved');
if(flag){flag.classList.add('show');setTimeout(()=>flag.classList.remove('show'),1400);}
}catch(e){toast(e.message,'err');}
}
function render(){
const app=document.getElementById('app');
const isAdmin=_user?.role==='admin'||_user?.role==='sap_adder'||_user?.role==='system_admin';
if(!isAdmin){
app.innerHTML=`
<div class="access-denied">
<div class="ad-title">Access Denied</div>
<div class="ad-msg">Item Group Rules requires <strong>Admin</strong> or <strong>SAP Adder</strong> role. Your current role is <strong>${esc(_user?.role||'unknown')}</strong>.</div>
<div style="margin-top:20px"><a href="/" style="text-decoration:none;color:var(--igc-blue);font-weight:700">← Back to Home</a></div>
</div>`;
return;
}
const filtered=_q?_groups.filter(g=>(g.name||'').toLowerCase().includes(_q.toLowerCase())||String(g.code).includes(_q)):_groups;
const chip=(g,tag,label)=>{
const tags=(g.classification||'').split(',').filter(Boolean);
const on=tags.includes(tag);
return `<label class="chip ${on?'on c-'+tag:''}" data-code="${g.code}" data-name="${esc(g.name)}" data-tag="${tag}"><input type="checkbox" ${on?'checked':''}/>${label}</label>`;
};
const rows=_loading?'':filtered.length?filtered.map(g=>`
<tr data-code="${g.code}">
<td class="g-code">${esc(g.code)}</td>
<td class="g-name">${esc(g.name)}</td>
<td>
<div class="cls-chips">
${chip(g,'RAW','Raw Material')}
${chip(g,'PACK','Packing Material')}
${chip(g,'COMPONENT','Component')}
${chip(g,'SFG','Without Intimation')}
</div>
</td>
<td style="width:70px"><span class="row-saved"><i data-lucide="check" style="width:12px;height:12px"></i> Saved</span></td>
</tr>`).join(''):`<tr><td colspan="4"><div class="empty-msg">No item groups match “${esc(_q)}”.</div></td></tr>`;
app.innerHTML=`
<div class="page-title"><i data-lucide="layers-3" style="width:22px;height:22px"></i> Item Group Rules</div>
<div class="page-sub">Configure which Work Order table(s) each SAP Item Group's items land in. Raw Material and Packing Material can both be selected together for the same group — items in that group will then appear in BOTH tables. Component is exclusive of the other two. When a group has no chips selected, the Work Order module falls back to its default Solution-name / PK-code detection.</div>
<div class="card">
<div class="card-hd"><i data-lucide="search" style="width:14px;height:14px"></i> Item Groups ${_groups.length?`<span style="color:var(--igc-text3);font-weight:400">(${_groups.length})</span>`:''}<input id="q" placeholder="Search item group…" value="${esc(_q)}"/></div>
${_loading?`<div class="empty-msg">Loading item groups from SAP…</div>`:`
<table>
<thead><tr><th style="width:90px">Code</th><th>Item Group</th><th>Classification</th><th style="width:70px"></th></tr></thead>
<tbody>${rows}</tbody>
</table>`}
<div class="legend">
<span><b>Raw Material</b> — items land in the Raw Material table.</span>
<span><b>Packing Material</b> — items land in the Packing Material table.</span>
<span><b>Component</b> — items land in the Components table, shown as themselves (not exploded).</span>
<span><b>Without Intimation</b> — independent of the other three; marks this group's items as eligible for Batch Issuance's "Without Intimation" workflow, letting them be intimated directly without a Requirement.</span>
</div>
</div>`;
if(window.lucide)lucide.createIcons();
const qEl=document.getElementById('q');
if(qEl){qEl.oninput=function(){_q=this.value;render();document.getElementById('q').focus();document.getElementById('q').selectionStart=document.getElementById('q').selectionEnd=this.value.length;};}
document.querySelectorAll('.chip').forEach(ch=>{
ch.onclick=function(e){
e.preventDefault();
const code=+this.dataset.code,name=this.dataset.name,tag=this.dataset.tag;
const g=_groups.find(x=>x.code===code);
let tags=(g.classification||'').split(',').filter(Boolean);
const turningOn=!tags.includes(tag);
if(turningOn){
// SFG is an independent axis — toggles on its own, never clears or is
// cleared by RAW/PACK/COMPONENT (which stay mutually exclusive among
// themselves as before).
if(tag==='SFG')tags.push(tag);
else if(tag==='COMPONENT')tags=tags.filter(t=>t==='SFG').concat(['COMPONENT']);
else{tags=tags.filter(t=>t!=='COMPONENT');tags.push(tag);}
}else{
tags=tags.filter(t=>t!==tag);
}
g.classification=[...new Set(tags)].join(',');
render();
saveClass(code,name,g.classification,document.querySelector(`tr[data-code="${code}"]`));
};
});
}
loadGroups();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+12
View File
File diff suppressed because one or more lines are too long
+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Mail Log — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1300px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:120px}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;height:34px}
.b-primary{background:var(--blue);color:#fff}.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap}
.lst td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top}
.mono{font-family:var(--mono)}
.pill{font-size:9px;font-weight:800;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.p-sent{background:rgba(13,187,138,.14);color:#0aa457}
.p-failed{background:rgba(229,62,62,.14);color:#e06666}
.p-skipped{background:rgba(245,166,35,.14);color:#c78a1a}
.err-msg{font-size:10.5px;color:var(--red);margin-top:2px}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.pager{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
.count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-err{background:var(--red);color:#fff}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/mail-logs" class="nav-link active">Mail Log</a></div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<script>
'use strict';
let _tok=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();location.href='/';};
const _f={from:'',to:'',status:'',q:''};
let _page=0,_pageSize=50,_total=0,_rows=[],_loading=false;
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast t-err';e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function fmt(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';const p=n=>String(n).padStart(2,'0');return `${p(d.getDate())}-${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;}
function pcls(s){return {SENT:'p-sent',FAILED:'p-failed',SKIPPED:'p-skipped'}[s]||'p-skipped';}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
async function load(reset){
if(reset)_page=0;
_loading=true;render();
const qs=new URLSearchParams();
Object.entries(_f).forEach(([k,v])=>{if(v)qs.set(k,v);});
qs.set('top',_pageSize);qs.set('skip',_page*_pageSize);
try{const r=await api('/mail-logs?'+qs.toString());_rows=r.data||[];_total=r.total||0;}
catch(e){toast(e.message);_rows=[];_total=0;}
_loading=false;render();
}
function render(){
const app=document.getElementById('app');
const start=_total?_page*_pageSize+1:0, end=Math.min(_total,(_page+1)*_pageSize);
app.innerHTML=`
<div class="card"><div class="card-hd"><span>✉ Mail Log</span><span class="right">${_loading?'':`${_total.toLocaleString()} email${_total===1?'':'s'}`}</span></div>
<div class="card-bd">
<div class="filters">
<div><label class="fl">From</label><input class="fi" type="date" id="f-from" value="${_f.from}"/></div>
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}"/></div>
<div><label class="fl">Status</label><select class="fi" id="f-status"><option value="">All</option><option value="SENT" ${_f.status==='SENT'?'selected':''}>Sent</option><option value="FAILED" ${_f.status==='FAILED'?'selected':''}>Failed</option><option value="SKIPPED" ${_f.status==='SKIPPED'?'selected':''}>Skipped</option></select></div>
<div style="min-width:200px;flex:1"><label class="fl">Search</label><input class="fi" id="f-q" placeholder="recipient / subject…" value="${esc(_f.q)}"/></div>
<button class="btn b-primary" id="f-go">Apply</button>
<button class="btn b-cancel" id="f-clear">Clear</button>
</div>
<div style="overflow-x:auto;margin-top:12px">
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`:
!_rows.length?`<div class="empty-msg">No emails match these filters.</div>`:
`<table class="lst"><thead><tr><th>Sent At</th><th>To</th><th>Subject</th><th>Status</th></tr></thead>
<tbody>${_rows.map(r=>rowHtml(r)).join('')}</tbody></table>
<div class="pager">
<button class="btn b-cancel" id="p-prev" ${_page===0?'disabled':''}>‹ Prev</button>
<span class="count">${start.toLocaleString()}–${end.toLocaleString()} of ${_total.toLocaleString()}</span>
<button class="btn b-cancel" id="p-next" ${end>=_total?'disabled':''}>Next ›</button>
</div>`}
</div>
</div></div>`;
wire();
}
function rowHtml(r){
return `<tr>
<td class="mono" style="white-space:nowrap">${esc(fmt(r.at))}</td>
<td>${esc(r.to||'—')}</td>
<td>${esc(r.subject||'—')}</td>
<td><span class="pill ${pcls(r.status)}">${esc(r.status||'—')}</span>${r.error?`<div class="err-msg">${esc(r.error)}</div>`:''}</td>
</tr>`;
}
function wire(){
q('#f-go')&&(q('#f-go').onclick=()=>{
_f.from=q('#f-from').value;_f.to=q('#f-to').value;_f.status=q('#f-status').value;_f.q=q('#f-q').value.trim();
load(true);
});
q('#f-clear')&&(q('#f-clear').onclick=()=>{Object.keys(_f).forEach(k=>_f[k]='');load(true);});
q('#f-q')&&(q('#f-q').onkeydown=e=>{if(e.key==='Enter')q('#f-go').click();});
q('#p-prev')&&(q('#p-prev').onclick=()=>{if(_page>0){_page--;load();}});
q('#p-next')&&(q('#p-next').onclick=()=>{if((_page+1)*_pageSize<_total){_page++;load();}});
}
load();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+529
View File
@@ -0,0 +1,529 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Man Power — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1400px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:600}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(26,111,255,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
.fi-mono{font-family:var(--mono);font-size:12px}
.btn{padding:10px 18px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:var(--blue);color:#fff}.b-primary:hover:not(:disabled){background:#155ad6}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-danger{background:rgba(229,62,62,.1);color:var(--red);border:1px solid rgba(229,62,62,.3)}.b-danger:hover{background:rgba(229,62,62,.2)}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);max-width:300px}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
/* Form header */
.mp-head{align-items:center}
.mp-head-title{display:flex;align-items:center;gap:11px}
.mp-head-ico{width:34px;height:34px;border-radius:9px;background:rgba(26,111,255,.12);border:1px solid rgba(26,111,255,.25);display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}
.mp-head-t{font-size:13px;font-weight:700;color:var(--text)}
.mp-head-s{font-size:10.5px;color:var(--text3);margin-top:1px;font-weight:400}
/* Meta panel (date/remark) */
.mp-meta{display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;padding:14px;background:var(--surface3);border:1px solid var(--border);border-radius:10px;margin-bottom:14px}
.mp-meta .fg{margin:0}
.mp-meta-summary{margin-left:auto;align-self:center;font-size:11px;font-weight:600;color:var(--text2);background:var(--surface2);padding:6px 13px;border-radius:20px;border:1px solid var(--border2);white-space:nowrap}
/* Segmented tabs */
.mp-tabs{display:flex;gap:5px;overflow-x:auto;padding:5px;background:var(--surface3);border:1px solid var(--border);border-radius:11px;margin-bottom:12px}
.mp-tabs::-webkit-scrollbar{height:0}
.mp-tab{padding:8px 13px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;white-space:nowrap;border:none;background:none;border-radius:8px;font-family:var(--font);display:flex;align-items:center;gap:7px;transition:all .15s}
.mp-tab:hover{color:var(--text);background:var(--surface2)}
.mp-tab.on{color:#fff;background:var(--blue);box-shadow:0 2px 8px rgba(26,111,255,.35)}
.mp-tab-badge{font-size:9px;font-weight:800;border-radius:9px;padding:1px 6px;min-width:17px;text-align:center;background:var(--blue);color:#fff;line-height:1.5}
.mp-tab.on .mp-tab-badge{background:rgba(255,255,255,.28);color:#fff}
/* Add-row */
.mp-addrow{margin-top:8px;padding:9px 14px;font-size:12px;font-weight:700;color:var(--blue);background:none;border:1.5px dashed var(--border2);border-radius:9px;cursor:pointer;font-family:var(--font);width:100%;transition:all .15s}
.mp-addrow:hover{border-color:var(--blue);background:rgba(26,111,255,.06)}
/* Action bar */
.mp-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;padding-top:14px;border-top:1px solid var(--border)}
.mp-actions .grow{margin-left:auto}
/* Grid */
.mp-grid-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
table.mp{border-collapse:separate;border-spacing:0;font-size:11.5px;min-width:1200px;width:100%}
table.mp th{position:sticky;top:0;z-index:1;text-align:left;padding:8px 8px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);background:var(--surface3);border-bottom:2px solid var(--border2);white-space:nowrap}
table.mp td{padding:4px 5px;border-bottom:1px solid var(--border);vertical-align:middle}
table.mp tbody tr:nth-child(even) td{background:rgba(255,255,255,.02)}
table.mp tbody tr:hover td{background:rgba(26,111,255,.05)}
table.mp th.mp-idx,table.mp td.mp-idx{width:36px;min-width:36px;text-align:center;color:var(--text3);font-size:10px;font-weight:700;font-family:var(--mono)}
table.mp input{width:100%;padding:5px 7px;border:1px solid var(--border2);border-radius:5px;font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;height:28px}
table.mp input:focus{border-color:var(--blue)}
table.mp input[readonly]{background:var(--ink2);cursor:not-allowed;color:var(--text2)}
table.mp select{width:100%;padding:5px 22px 5px 7px;border:1px solid var(--border2);border-radius:5px;font-size:11px;font-family:var(--font);background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5 4.5-5z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 7px center;color:var(--text);outline:none;height:28px;cursor:pointer;appearance:none}
table.mp select:focus{border-color:var(--blue)}
table.mp select:disabled{background-color:var(--ink2);cursor:not-allowed;color:var(--text2)}
table.mp select option{background:var(--surface2)}
table.mp select.mp-missing{border-color:var(--red);box-shadow:0 0 0 2px rgba(229,62,62,.18)}
.mp-num{text-align:right}
.mp-rm{width:26px;height:26px;border-radius:5px;border:1px solid var(--border2);background:var(--surface2);color:var(--red);cursor:pointer;font-size:12px}
.mp-rm:hover{background:var(--red);color:#fff;border-color:var(--red)}
/* List table */
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.lst th{text-align:left;padding:8px 10px;font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--text3);border-bottom:2px solid var(--border2)}
.lst td{padding:9px 10px;border-bottom:1px solid var(--border)}
.lst tr{cursor:pointer}
.lst tr:hover td{background:rgba(26,111,255,.05)}
.pill{font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px}
.pill-ok{background:rgba(13,187,138,.12);color:var(--green)}
.pill-cx{background:rgba(229,62,62,.12);color:var(--red)}
.pill-lg{font-size:10px;padding:3px 10px;border-radius:20px}
/* Professional document list */
.mp-list{display:flex;flex-direction:column;gap:8px}
.mp-doc{display:flex;align-items:center;gap:14px;padding:12px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;cursor:pointer;transition:all .15s}
.mp-doc:hover{border-color:var(--blue);transform:translateY(-1px);box-shadow:0 8px 22px rgba(0,0,0,.28)}
.mp-doc.cx{opacity:.72}
.mp-doc-date{display:flex;flex-direction:column;align-items:center;justify-content:center;width:60px;height:60px;border-radius:12px;background:rgba(26,111,255,.10);border:1px solid rgba(26,111,255,.22);flex-shrink:0}
.mp-doc.cx .mp-doc-date{background:rgba(229,62,62,.08);border-color:rgba(229,62,62,.22)}
.mp-doc-day{font-size:24px;font-weight:700;color:var(--blue);line-height:1;font-family:var(--display);letter-spacing:1.5px}
.mp-doc.cx .mp-doc-day{color:var(--red)}
.mp-doc-mon{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text2);margin-top:3px}
.mp-doc-main{flex:1;min-width:0}
.mp-doc-title{font-size:14px;font-weight:700;color:var(--text)}
.mp-doc-sub{font-size:11px;color:var(--text3);margin-top:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.mp-doc-sub b{color:var(--text2);font-family:var(--mono)}
.mp-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}
.mp-chip{font-size:9px;font-weight:700;color:var(--text2);background:var(--surface3);border:1px solid var(--border2);border-radius:6px;padding:2px 8px;display:inline-flex;align-items:center;gap:5px;text-transform:uppercase;letter-spacing:.3px}
.mp-chip b{color:var(--blue);font-family:var(--mono);font-size:10px}
.mp-doc-right{display:flex;align-items:center;gap:12px;flex-shrink:0}
.mp-total{font-size:11px;font-weight:600;color:var(--text3);background:var(--surface3);border:1px solid var(--border2);border-radius:20px;padding:5px 12px;white-space:nowrap}
.mp-total b{color:var(--text);font-family:var(--mono)}
.mp-doc-chev{color:var(--text3);font-size:20px;line-height:1;transition:transform .15s,color .15s}
.mp-doc:hover .mp-doc-chev{color:var(--blue);transform:translateX(3px)}
.mp-pager{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px}
.mp-count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
/* Compact summary table (row = one Section entry; click to edit in a popup) */
table.mp-sum{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}
table.mp-sum th{text-align:left;padding:9px 12px;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap}
table.mp-sum td{padding:9px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
table.mp-sum tbody tr{cursor:pointer}
table.mp-sum tbody tr:hover td{background:rgba(26,111,255,.06)}
table.mp-sum .num{text-align:right;font-family:var(--mono)}
/* Row entry popup (matches OEE's per-shift popup pattern) */
.mp-mv{position:fixed;inset:0;z-index:9998;background:rgba(4,8,14,.72);backdrop-filter:blur(4px);display:flex;align-items:flex-start;justify-content:center;padding:22px 14px;overflow:auto}
.mp-mw{width:100%;max-width:820px;background:var(--surface);border:1px solid var(--border2);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
.mp-mh{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--surface3);border-bottom:1px solid var(--border)}
.mp-mh-t{font-size:14px;font-weight:700;color:var(--text)}
.mp-mh-s{font-size:11px;color:var(--text3);margin-top:1px}
.mp-mx{width:30px;height:30px;border-radius:8px;border:1px solid var(--border2);background:none;color:var(--text2);cursor:pointer;font-size:14px;margin-left:auto}
.mp-mx:hover{border-color:var(--red);color:var(--red)}
.mp-mb{padding:16px 18px;max-height:calc(100vh - 210px);overflow:auto}
.mp-msec{border:1px solid var(--border);border-radius:11px;overflow:hidden}
.mp-msec-hd{padding:7px 13px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#0a0f18;background:#8aa0c0}
.mp-mgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;padding:14px}
.mp-mfld{display:flex;flex-direction:column;gap:4px;min-width:0}
.mp-mfld.wide{grid-column:1/-1}
.mp-mfld label{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3)}
.mp-mfld input,.mp-mfld select{width:100%;height:36px;padding:6px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none}
.mp-mfld input:focus,.mp-mfld select:focus{border-color:var(--blue)}
.mp-mfld select{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
.mp-mfld select.mp-missing{border-color:var(--red);box-shadow:0 0 0 2px rgba(229,62,62,.18)}
.mp-mf{display:flex;gap:8px;padding:14px 18px;border-top:1px solid var(--border);background:var(--surface3)}
.mp-mf .grow{margin-left:auto}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
<a href="/man-power" class="nav-link active">Man Power</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
const CAN_VIEW=hasStepPerm('man_power:entry','view');
const CAN_ADD =hasStepPerm('man_power:entry','add');
const CAN_EDIT=hasStepPerm('man_power:entry','edit');
const TABS=[
{key:'bb',label:'BB PRODUCTION'},{key:'packing',label:'PACKING'},{key:'autoclave',label:'AUTOCLAVE'},
{key:'capd',label:'CAPD PRODUCTION'},{key:'moulding',label:'MOULDING'},{key:'sheet',label:'SHEET PLANT'},
{key:'lamination',label:'LAMINATION'},{key:'stent',label:'STENT'},{key:'equipment',label:'EQUIPMENT'},{key:'needle',label:'NEEDLE'},
];
const COLS=[
{k:'section',l:'Section *',type:'text',w:'200px'},
{k:'target',l:'Production Target',type:'num'},
{k:'appManpower',l:'Approved Manpower (Casual + Cont)',type:'num'},
{k:'prntWorker',l:'Permanent Worker',type:'num'},
{k:'totalSaction',l:'Total Sactioned Manpower',type:'num'},
{k:'approvedEt',l:'Approved ET/Day',type:'num'},
{k:'present',l:'Present ManPower StartLink Data',type:'num'},
{k:'overtime',l:'OverTime StarLink Data',type:'num'},
{k:'actual',l:'Actual Production',type:'num'},
{k:'rejection',l:'Rejection',type:'num'},
{k:'remarks',l:'Remarks',type:'text',w:'170px'},
];
function blankRow(){const r={};COLS.forEach(c=>r[c.k]=c.type==='num'?0:'');return r;}
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _mode='list'; // 'list' | 'form'
let _docs=[];let _listLoading=false;let _filterDate='';let _page=0,_pageSize=20,_rawCount=0;
let _editEntry=null; // docEntry when editing, null for new
let _date='';let _remark='';let _tabsData={};let _active='bb';
let _canceled=false;let _saving=false;let _formLoading=false;
let _sections={}; // per-tab Section dropdown values (from UFD1)
let _visibleKeys=TABS.map(t=>t.key); // which tabs are shown (admin-configurable)
function visibleTabs(){return TABS.filter(t=>_visibleKeys.includes(t.key));}
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
function today(){return new Date().toISOString().slice(0,10);}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const _DOW=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
function fmtDate(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';return `${String(d.getUTCDate()).padStart(2,'0')} ${_MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;}
function dateParts(iso){const d=new Date(iso);if(isNaN(d))return null;return {day:String(d.getUTCDate()).padStart(2,'0'),mon:_MON[d.getUTCMonth()],year:d.getUTCFullYear(),dow:_DOW[d.getUTCDay()]};}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
// ── LIST ──────────────────────────────────────────────
async function loadList(resetPage){
if(resetPage)_page=0;
_listLoading=true;render();
const dq=_filterDate?`&date=${_filterDate}`:'';
try{const r=await api('GET',`/manpower?top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${dq}`);_docs=r.data||[];_rawCount=_docs.length;}
catch(e){toast(e.message,'err');_docs=[];_rawCount=0;}
_listLoading=false;render();
}
function newEntry(){
_editEntry=null;_date=today();_remark='';_canceled=false;_active=(visibleTabs()[0]||TABS[0]).key;
_tabsData={};TABS.forEach(t=>_tabsData[t.key]=[]);
_mode='form';render();
}
async function openEntry(docEntry){
_mode='form';_formLoading=true;_editEntry=docEntry;render();
try{
const r=await api('GET',`/manpower/${docEntry}?company=${encodeURIComponent(_co)}`);
const d=r.data||{};
_date=(d.date||'').slice(0,10);_remark=d.remark||'';_canceled=!!d.canceled;_active=(visibleTabs()[0]||TABS[0]).key;
_tabsData={};TABS.forEach(t=>{_tabsData[t.key]=(d.tabs&&d.tabs[t.key])||[];});
}catch(e){toast(e.message,'err');_mode='list';}
_formLoading=false;render();
}
async function save(){
if(!_date){toast('Date is required','err');return;}
const editing=_editEntry!=null;
if(editing&&!CAN_EDIT){toast('You are not permitted to edit','err');return;}
if(!editing&&!CAN_ADD){toast('You are not permitted to add','err');return;}
// Every row (added via the popup) already requires a Section — but a row
// loaded from an older, pre-popup document could still lack one, so this
// stays as a final safety check before saving.
let firstBad=null,badCount=0;
for(const t of TABS){
const rows=_tabsData[t.key]||[];
rows.forEach((r,i)=>{ if(!(r.section||'').trim()){ badCount++; if(!firstBad)firstBad={key:t.key,label:t.label,row:i}; } });
}
if(firstBad){
if(_visibleKeys.includes(firstBad.key))_active=firstBad.key;
render();
toast(`Section is required in all rows — ${badCount} row${badCount===1?'':'s'} missing (see ${firstBad.label}, row ${firstBad.row+1}).`,'err');
return;
}
// Nothing to save unless at least one row exists.
const anyValid=TABS.some(t=>(_tabsData[t.key]||[]).length>0);
if(!anyValid){ toast('Add at least one row before saving.','err'); return; }
_saving=true;render();
const body={company:_co,date:_date,remark:_remark,tabs:_tabsData};
try{
if(editing){ await api('PATCH',`/manpower/${_editEntry}`,body); toast('Man Power data updated'); }
else{ const r=await api('POST','/manpower',body); toast(`Saved — Doc #${r.data?.docNum||r.data?.docEntry||''}`); }
_mode='list';_saving=false;loadList(true);
}catch(e){toast(e.message||'Failed','err');_saving=false;render();}
}
async function cancelDoc(){
if(_editEntry==null||!CAN_EDIT)return;
if(!confirm('Cancel this Man Power document in SAP? This cannot be undone.'))return;
_saving=true;render();
try{ await api('POST',`/manpower/${_editEntry}/cancel`,{company:_co}); toast('Document cancelled'); _mode='list';_saving=false;loadList(true); }
catch(e){ toast(e.message||'Failed','err');_saving=false;render(); }
}
// ── RENDER ────────────────────────────────────────────
function render(){try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
if(!CAN_VIEW){app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You are not assigned the "Man Power" permission. Ask an admin to grant it (User Management → Approval Steps → Man Power).</div></div>';return;}
if(_mode==='form')renderForm(app);else renderList(app);
}
function renderList(app){
const c=mk('card');
const docsHtml=_docs.map(d=>{
const p=dateParts(d.date)||{day:'—',mon:'',year:'',dow:''};
const tc=d.tabCounts||{};
// Only show the tabs this user is allowed to see (visibleTabs = global
// enabled ∩ user's assigned) — and total only those rows.
const vis=visibleTabs();
const chips=vis.filter(t=>tc[t.key]).map(t=>`<span class="mp-chip">${t.label} <b>${tc[t.key]}</b></span>`).join('');
const visTotal=vis.reduce((s,t)=>s+(Number(tc[t.key])||0),0);
return `<div class="mp-doc${d.canceled?' cx':''}" data-open="${d.docEntry}">
<div class="mp-doc-date"><div class="mp-doc-day">${p.day}</div><div class="mp-doc-mon">${p.mon} ${p.year}</div></div>
<div class="mp-doc-main">
<div class="mp-doc-title">${p.dow?p.dow+', ':''}${fmtDate(d.date)}</div>
<div class="mp-doc-sub">
<span>Doc <b>#${esc(String(d.docNum))}</b></span>
<span>·</span><span>Created ${fmtDate(d.createDate)}</span>
<span>${d.canceled?'<span class="pill pill-cx pill-lg">CANCELLED</span>':'<span class="pill pill-ok pill-lg">ACTIVE</span>'}</span>
</div>
${chips?`<div class="mp-chips">${chips}</div>`:''}
</div>
<div class="mp-doc-right">${visTotal?`<span class="mp-total"><b>${visTotal}</b> row${visTotal===1?'':'s'}</span>`:''}<span class="mp-doc-chev">›</span></div>
</div>`;
}).join('');
c.innerHTML=`<div class="card-hd"><span>Man Power Data</span><span class="right">${_listLoading?'':`${_docs.length} on this page`}</span></div>
<div class="card-bd">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:14px">
<div class="fg" style="margin:0;min-width:150px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div class="fg" style="margin:0"><label class="fl">Filter by Date</label><input class="fi" id="f-date" type="date" value="${_filterDate}"/></div>
<button class="btn b-cancel" id="f-clear" style="height:36px">Clear</button>
${CAN_ADD?`<button class="btn b-primary" id="btn-new" style="height:36px;margin-left:auto">+ New Entry</button>`:''}
</div>
${_listLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading…</div>`:
!_docs.length?`<div class="empty-msg">No Man Power records${_filterDate?' for '+fmtDate(_filterDate):''}.</div>`:
`<div class="mp-list">${docsHtml}</div>
<div class="mp-pager">
<button class="btn b-cancel" id="l-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button>
<span class="mp-count">Page ${_page+1}</span>
<button class="btn b-cancel" id="l-next" ${_rawCount>=_pageSize?'':'disabled'} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`}
</div>`;
app.appendChild(c);
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;loadList(true);});
q('#f-date')&&(q('#f-date').onchange=function(){_filterDate=this.value;loadList(true);});
q('#f-clear')&&(q('#f-clear').onclick=()=>{_filterDate='';loadList(true);});
q('#btn-new')&&(q('#btn-new').onclick=newEntry);
q('#l-prev')&&(q('#l-prev').onclick=()=>{if(_page>0){_page--;loadList();}});
q('#l-next')&&(q('#l-next').onclick=()=>{if(_rawCount>=_pageSize){_page++;loadList();}});
document.querySelectorAll('[data-open]').forEach(tr=>tr.onclick=()=>openEntry(parseInt(tr.dataset.open)));
},0);
}
const SUM_COLS=['section','target','present','actual','rejection','remarks'];
function renderForm(app){
const editing=_editEntry!=null;
const readOnly=editing?!CAN_EDIT:!CAN_ADD;
const c=mk('card');
const rows=_tabsData[_active]||[];
const sumRows=rows.map((row,i)=>`<tr data-row="${i}">
${SUM_COLS.map(k=>{
const col=COLS.find(cc=>cc.k===k);
const v=row[k];
const disp=k==='section'?(v||'<span style="color:var(--muted)">— not set —</span>'):esc(String(v==null||v===''?(col.type==='num'?0:''):v));
return `<td>${disp}</td>`;
}).join('')}
<td>${readOnly?'':`<button type="button" class="mp-rm" data-rm="${i}" title="Remove row">✕</button>`}</td>
</tr>`).join('');
const totalFilled=visibleTabs().reduce((s,t)=>s+((_tabsData[t.key]||[]).length),0);
c.innerHTML=`<div class="card-hd mp-head">
<div class="mp-head-title">
<span class="mp-head-ico">👷</span>
<div>
<div class="mp-head-t">${editing?'Man Power Data':'New Man Power Entry'}${_canceled?' <span class="pill pill-cx" style="margin-left:4px">CANCELLED</span>':''}</div>
<div class="mp-head-s">${editing?`Document #${_editEntry}`:'Record daily manpower for each production area'}</div>
</div>
</div>
<button class="btn b-cancel" id="btn-back" style="padding:6px 14px;font-size:11px">← Back to list</button>
</div>
<div class="card-bd">
${_formLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading…</div>`:`
<div class="mp-meta">
<div class="fg"><label class="fl">Date <span style="color:var(--red)">*</span></label><input class="fi" id="inp-date" type="date" value="${_date}" ${readOnly?'readonly':''} style="min-width:160px"/></div>
<div class="fg" style="flex:1;min-width:220px"><label class="fl">Remark</label><input class="fi" id="inp-remark" value="${esc(_remark)}" placeholder="Optional note for this day" ${readOnly?'readonly':''}/></div>
<div class="mp-meta-summary">${totalFilled} row${totalFilled===1?'':'s'} total</div>
</div>
<div class="mp-tabs">${visibleTabs().map(t=>{
const cnt=(_tabsData[t.key]||[]).length;
return `<button class="mp-tab${_active===t.key?' on':''}" data-tab="${t.key}">${t.label}${cnt?`<span class="mp-tab-badge">${cnt}</span>`:''}</button>`;
}).join('')}</div>
<div class="mp-grid-wrap"><table class="mp-sum">
<thead><tr>${SUM_COLS.map(k=>`<th>${esc(COLS.find(cc=>cc.k===k).l)}</th>`).join('')}<th></th></tr></thead>
<tbody>${sumRows||`<tr><td colspan="${SUM_COLS.length+1}" style="text-align:center;color:var(--muted);padding:16px">No rows yet${readOnly?'':' — click "+ Add Row" below'}</td></tr>`}</tbody>
</table></div>
${readOnly?'':`<button type="button" class="mp-addrow" id="btn-addrow">+ Add Row</button>`}
<div class="mp-actions">
${readOnly?'':`<button class="btn b-primary" id="btn-save" ${_saving?'disabled':''}><span id="sp-save" style="display:${_saving?'inline-block':'none'}" class="sp"></span> ${editing?'Save Changes':'Save Entry'}</button>`}
${editing&&CAN_EDIT&&!_canceled?`<button class="btn b-danger" id="btn-cancel-doc" ${_saving?'disabled':''}>Cancel Document</button>`:''}
<button class="btn b-cancel${readOnly?'':' grow'}" id="btn-discard">${readOnly?'Close':'Discard'}</button>
</div>
`}
</div>`;
app.appendChild(c);
setTimeout(()=>{
q('#btn-back')&&(q('#btn-back').onclick=()=>{_mode='list';render();});
q('#btn-discard')&&(q('#btn-discard').onclick=()=>{_mode='list';render();});
if(_formLoading)return;
q('#inp-date')&&!readOnly&&(q('#inp-date').onchange=function(){_date=this.value;});
q('#inp-remark')&&!readOnly&&(q('#inp-remark').oninput=function(){_remark=this.value;});
document.querySelectorAll('.mp-tab').forEach(b=>b.onclick=function(){_active=this.dataset.tab;render();});
document.querySelectorAll('table.mp-sum tbody tr[data-row]').forEach(tr=>{
tr.onclick=function(e){
if(e.target.closest('[data-rm]'))return;
openRowModal(parseInt(this.dataset.row),readOnly);
};
});
document.querySelectorAll('[data-rm]').forEach(el=>el.onclick=function(e){
e.stopPropagation();
_tabsData[_active].splice(parseInt(this.dataset.rm),1);
render();
});
q('#btn-addrow')&&(q('#btn-addrow').onclick=()=>openRowModal(null,false));
q('#btn-save')&&(q('#btn-save').onclick=save);
q('#btn-cancel-doc')&&(q('#btn-cancel-doc').onclick=cancelDoc);
},0);
}
// ── Single-row entry popup ──────────────────────────
function openRowModal(index,readOnly){
const editing=index!=null&&index>=0;
const work=editing?JSON.parse(JSON.stringify(_tabsData[_active][index])):blankRow();
const secOpts=_sections[_active]||[];
const fldHtml=(col)=>{
const wide=col.k==='remarks';
let inp;
if(col.k==='section'){
const cur=work.section||'';
const inList=secOpts.includes(cur);
inp=`<select data-f="section">
<option value="">— select —</option>
${secOpts.map(s=>`<option value="${esc(s)}" ${cur===s?'selected':''}>${esc(s)}</option>`).join('')}
${cur&&!inList?`<option value="${esc(cur)}" selected>${esc(cur)}</option>`:''}
</select>`;
}else{
inp=`<input data-f="${col.k}" ${col.type==='num'?'type="number" step="any"':'type="text"'} value="${esc(String(work[col.k]!=null?work[col.k]:(col.type==='num'?0:'')))}" ${readOnly?'disabled':''}/>`;
}
if(col.k==='section')inp=inp.replace('<select ',`<select ${readOnly?'disabled':''} `);
return `<div class="mp-mfld${wide?' wide':''}"><label>${esc(col.l)}</label>${inp}</div>`;
};
const ov=document.createElement('div');ov.className='mp-mv';
ov.innerHTML=`<div class="mp-mw">
<div class="mp-mh">
<div>
<div class="mp-mh-t">${editing?'Edit Row':'Add Row'}</div>
<div class="mp-mh-s">${esc((visibleTabs().find(t=>t.key===_active)||{}).label||'')} · ${esc(fmtDate(_date))}</div>
</div>
<button class="mp-mx" data-x title="Close">✕</button>
</div>
<div class="mp-mb">
<div class="mp-msec">
<div class="mp-msec-hd">Man Power Details</div>
<div class="mp-mgrid">${COLS.map(fldHtml).join('')}</div>
</div>
</div>
<div class="mp-mf">
${readOnly?'':`<button class="btn b-primary" data-save>${editing?'Update Row':'Add Row'}</button>`}
<button class="btn b-cancel${readOnly?'':' grow'}" data-cancel>${readOnly?'Close':'Cancel'}</button>
</div>
</div>`;
document.body.appendChild(ov);
ov.querySelectorAll('[data-f]').forEach(el=>{
el.oninput=el.onchange=function(){
const k=this.dataset.f;const col=COLS.find(c=>c.k===k);
work[k]=col.type==='num'?(parseFloat(this.value)||0):this.value;
if(k==='section')this.classList.remove('mp-missing');
};
});
const close=()=>ov.remove();
ov.querySelector('[data-x]').onclick=close;
ov.querySelector('[data-cancel]').onclick=close;
const saveBtn=ov.querySelector('[data-save]');
if(saveBtn)saveBtn.onclick=()=>{
if(!(work.section||'').toString().trim()){
const se=ov.querySelector('[data-f="section"]');if(se){se.classList.add('mp-missing');se.focus();}
toast('Section is required for this row','err');return;
}
if(editing)_tabsData[_active][index]=work;else _tabsData[_active].push(work);
close();render();
};
}
async function loadSections(){
try{const r=await api('GET',`/manpower/sections?company=${encodeURIComponent(_co)}`);_sections=r.data||{};}
catch(_e){_sections={};}
}
async function loadConfig(){
let globalKeys=TABS.map(t=>t.key);
try{const cfg=await api('GET','/config');
if(cfg&&Array.isArray(cfg.manpowerTabs)&&cfg.manpowerTabs.length)globalKeys=cfg.manpowerTabs;
}catch(_e){}
// Per-user tab permission (Admin → user → Man Power). Empty = all. The user
// sees the INTERSECTION of globally-enabled tabs and their allowed tabs.
let userKeys=null;
try{const pr=await api('GET','/auth/profile');const ut=pr?.user?.manpowerTabs;if(Array.isArray(ut)&&ut.length)userKeys=ut.map(String);}catch(_e){}
_visibleKeys=userKeys?globalKeys.filter(k=>userKeys.includes(k)):globalKeys;
if(!_visibleKeys.includes(_active))_active=(visibleTabs()[0]||TABS[0]).key;
}
async function init(){
try{
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
if(CAN_VIEW){ await Promise.all([loadSections(),loadConfig()]); loadList(); } else render();
}catch(e){console.error('Init error:',e);render();}
}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+952
View File
@@ -0,0 +1,952 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>OEE — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1600px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:600}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(26,111,255,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
.btn{padding:10px 18px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:var(--blue);color:#fff}.b-primary:hover:not(:disabled){background:#155ad6}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-danger{background:rgba(229,62,62,.1);color:var(--red);border:1px solid rgba(229,62,62,.3)}.b-danger:hover{background:rgba(229,62,62,.2)}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);max-width:320px}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
.mp-head{align-items:center}
.mp-head-title{display:flex;align-items:center;gap:11px}
.mp-head-ico{width:34px;height:34px;border-radius:9px;background:rgba(26,111,255,.12);border:1px solid rgba(26,111,255,.25);display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}
.mp-head-t{font-size:13px;font-weight:700;color:var(--text)}
.mp-head-s{font-size:10.5px;color:var(--text3);margin-top:1px;font-weight:400}
.mp-meta{display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;padding:14px;background:var(--surface3);border:1px solid var(--border);border-radius:10px;margin-bottom:14px}
.mp-meta .fg{margin:0}
.mp-meta-summary{margin-left:auto;align-self:center;font-size:11px;font-weight:600;color:var(--text2);background:var(--surface2);padding:6px 13px;border-radius:20px;border:1px solid var(--border2);white-space:nowrap}
.mp-tabs{display:flex;gap:5px;overflow-x:auto;padding:5px;background:var(--surface3);border:1px solid var(--border);border-radius:11px;margin-bottom:12px}
.mp-tabs::-webkit-scrollbar{height:0}
.mp-tab{padding:8px 13px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;white-space:nowrap;border:none;background:none;border-radius:8px;font-family:var(--font);display:flex;align-items:center;gap:7px;transition:all .15s}
.mp-tab:hover{color:var(--text);background:var(--surface2)}
.mp-tab.on{color:#fff;background:var(--blue);box-shadow:0 2px 8px rgba(26,111,255,.35)}
.mp-tab:disabled{opacity:.45;cursor:not-allowed}
.mp-tab-soon{font-size:8px;font-weight:800;color:var(--text3);border:1px solid var(--border2);border-radius:6px;padding:0 4px}
.mp-addrow{margin-top:8px;padding:9px 14px;font-size:12px;font-weight:700;color:var(--blue);background:none;border:1.5px dashed var(--border2);border-radius:9px;cursor:pointer;font-family:var(--font);width:100%;transition:all .15s}
.mp-addrow:hover{border-color:var(--blue);background:rgba(26,111,255,.06)}
.mp-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;padding-top:14px;border-top:1px solid var(--border)}
.mp-actions .grow{margin-left:auto}
.oee-grid-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
table.oee{border-collapse:separate;border-spacing:0;font-size:11px;width:100%}
table.oee th{text-align:center;padding:5px 5px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.2px;color:var(--text3);background:var(--surface3);border-bottom:1px solid var(--border2);border-right:1px solid var(--border);white-space:normal;line-height:1.18;vertical-align:middle}
table.oee th.oee-band{font-size:10px;letter-spacing:.4px;padding:6px 4px;color:var(--text2)}
table.oee th.oee-sub{font-size:8.5px;color:var(--text2);background:var(--surface2)}
table.oee th.oee-name{font-size:8.5px;font-weight:700;color:var(--text2)}
table.oee th.oee-u2{font-size:8px;font-weight:700;color:var(--text3);text-transform:none;letter-spacing:0;background:var(--ink2);white-space:nowrap;padding:3px 4px}
table.oee th .oee-u{font-size:8px;font-weight:600;color:var(--text3);margin-top:2px;text-transform:none;letter-spacing:0}
table.oee td{padding:3px 4px;border-bottom:1px solid var(--border);border-right:1px solid var(--border);vertical-align:middle}
table.oee tbody tr:nth-child(even) td{background:rgba(255,255,255,.02)}
table.oee tbody tr:hover td{background:rgba(26,111,255,.05)}
table.oee th.oee-idx,table.oee td.oee-idx{width:32px;min-width:32px;text-align:center;color:var(--text3);font-size:10px;font-weight:700;font-family:var(--mono);position:sticky;left:0;background:var(--surface3);z-index:2}
table.oee tbody td.oee-idx{background:var(--ink2)}
table.oee input{width:100%;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;height:26px;text-align:right}
table.oee input[type=date],table.oee input[type=text]{text-align:left}
table.oee input:focus{border-color:var(--blue)}
table.oee input.oee-calc{background:var(--ink2);cursor:not-allowed;color:var(--teal);font-weight:600;border-color:var(--border)}
table.oee input.oee-date.oee-missing{border-color:var(--red);box-shadow:0 0 0 2px rgba(229,62,62,.18)}
td.oee-oee input{color:var(--green);font-weight:700}
.oee-rm{width:24px;height:24px;border-radius:5px;border:1px solid var(--border2);background:var(--surface2);color:var(--red);cursor:pointer;font-size:11px}
.oee-rm:hover{background:var(--red);color:#fff;border-color:var(--red)}
.pill{font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px}
.pill-ok{background:rgba(13,187,138,.12);color:var(--green)}
.pill-cx{background:rgba(229,62,62,.12);color:var(--red)}
.pill-lg{font-size:10px;padding:3px 10px;border-radius:20px}
.mp-list{display:flex;flex-direction:column;gap:8px}
.mp-doc{display:flex;align-items:center;gap:14px;padding:12px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;cursor:pointer;transition:all .15s}
.mp-doc:hover{border-color:var(--blue);transform:translateY(-1px);box-shadow:0 8px 22px rgba(0,0,0,.28)}
.mp-doc.cx{opacity:.72}
.mp-doc-badge{display:flex;flex-direction:column;align-items:center;justify-content:center;width:58px;height:58px;border-radius:12px;background:rgba(26,111,255,.10);border:1px solid rgba(26,111,255,.22);flex-shrink:0;text-align:center;padding:4px}
.mp-doc.cx .mp-doc-badge{background:rgba(229,62,62,.08);border-color:rgba(229,62,62,.22)}
.mp-doc-badge-t{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--blue);line-height:1.15}
.mp-doc.cx .mp-doc-badge-t{color:var(--red)}
.mp-doc-main{flex:1;min-width:0}
.mp-doc-title{font-size:14px;font-weight:700;color:var(--text)}
.mp-doc-sub{font-size:11px;color:var(--text3);margin-top:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.mp-doc-sub b{color:var(--text2);font-family:var(--mono)}
.mp-doc-right{display:flex;align-items:center;gap:12px;flex-shrink:0}
.mp-total{font-size:11px;font-weight:600;color:var(--text3);background:var(--surface3);border:1px solid var(--border2);border-radius:20px;padding:5px 12px;white-space:nowrap}
.mp-total b{color:var(--text);font-family:var(--mono)}
.mp-doc-chev{color:var(--text3);font-size:20px;line-height:1;transition:transform .15s,color .15s}
.mp-doc:hover .mp-doc-chev{color:var(--blue);transform:translateX(3px)}
.mp-pager{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px}
.mp-count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
.oee-stub{text-align:center;padding:40px 20px;color:var(--text3);font-size:13px;border:1.5px dashed var(--border2);border-radius:10px;background:var(--surface3)}
/* Compact summary table */
table.oee-sum{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}
table.oee-sum th{text-align:left;padding:9px 12px;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap}
table.oee-sum td{padding:9px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
table.oee-sum tbody tr{cursor:pointer}
table.oee-sum tbody tr:hover td{background:rgba(26,111,255,.06)}
table.oee-sum .num{text-align:right;font-family:var(--mono)}
.oee-kpi{font-family:var(--mono);font-weight:700}
.oee-kpi.good{color:var(--green)}.oee-kpi.warn{color:var(--amber)}.oee-kpi.bad{color:var(--red)}
.oee-oeecell{font-family:var(--mono);font-weight:800;padding:2px 9px;border-radius:6px}
.oee-del{width:26px;height:26px;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--red);cursor:pointer;font-size:12px}
.oee-del:hover{background:var(--red);color:#fff;border-color:var(--red)}
/* Entry popup */
.oee-mv{position:fixed;inset:0;z-index:9998;background:rgba(4,8,14,.72);backdrop-filter:blur(4px);display:flex;align-items:flex-start;justify-content:center;padding:22px 14px;overflow:auto}
.oee-mw{width:100%;max-width:940px;background:var(--surface);border:1px solid var(--border2);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
.oee-mh{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--surface3);border-bottom:1px solid var(--border)}
.oee-mh-t{font-size:14px;font-weight:700;color:var(--text)}
.oee-mh-s{font-size:11px;color:var(--text3);margin-top:1px}
.oee-badge{margin-left:auto;display:flex;align-items:center;gap:7px;background:rgba(0,176,80,.14);border:1px solid rgba(0,176,80,.45);padding:6px 15px;border-radius:30px}
.oee-badge-l{font-size:10px;font-weight:800;letter-spacing:.5px;color:#0aa457}
.oee-badge-v{font-size:21px;font-weight:800;font-family:var(--mono);color:#0aa457;line-height:1}
.oee-mx{width:30px;height:30px;border-radius:8px;border:1px solid var(--border2);background:none;color:var(--text2);cursor:pointer;font-size:14px}
.oee-mx:hover{border-color:var(--red);color:var(--red)}
.oee-mb{padding:14px 18px;max-height:calc(100vh - 210px);overflow:auto;display:flex;flex-direction:column;gap:12px}
.oee-sec{border:1px solid var(--border);border-radius:11px;overflow:hidden}
.oee-sec-hd{padding:7px 13px;font-size:11px;font-weight:800;letter-spacing:.4px;color:#0a0f18;background:var(--sec,#8aa0c0)}
.oee-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(148px,1fr));gap:9px;padding:12px 13px}
.oee-fld{display:flex;flex-direction:column;gap:4px;min-width:0}
.oee-fld label{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);display:flex;align-items:center;gap:5px;line-height:1.2;min-height:22px}
.oee-fu{font-size:8.5px;color:var(--text2);background:var(--surface2);padding:0 5px;border-radius:4px;text-transform:none;white-space:nowrap}
.oee-fld input{width:100%;height:34px;padding:6px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none}
.oee-fld input:focus{border-color:var(--blue)}
.oee-fld input.miss{border-color:var(--red);box-shadow:0 0 0 2px rgba(229,62,62,.2)}
.oee-calcbox{height:34px;display:flex;align-items:center;justify-content:flex-end;padding:6px 10px;border-radius:7px;font-family:var(--mono);font-size:13px;font-weight:600;background:var(--ink2);color:var(--teal);border:1px dashed var(--border2)}
.oee-calcbox.hl{background:rgba(0,176,80,.12);color:#0aa457;border-style:solid;border-color:rgba(0,176,80,.4);font-weight:800}
.oee-mf{display:flex;gap:8px;padding:14px 18px;border-top:1px solid var(--border);background:var(--surface3)}
.oee-mf .grow{margin-left:auto}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
<a href="/man-power" class="nav-link">Man Power</a><a href="/oee" class="nav-link active">OEE</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
const CAN_VIEW=hasStepPerm('oee:entry','view');
const CAN_ADD =hasStepPerm('oee:entry','add');
const CAN_EDIT=hasStepPerm('oee:entry','edit');
// ── OEE form schemas ──────────────────────────────────────────────
// Each form: grouped columns (colored bands like the source sheet) + a
// compute() that derives all 'calc' cells from the typed inputs.
// 'ebb', 'pd', 'sheet', 'moulding' and 'autoclave' are defined; 'needle' and
// 'packing' remain stubs until their source sheets are provided.
// Band fills matched to the source sheet's Excel colours.
const AMBER='#fff200',BLUE='#00b0f0',RED='#ff0000',GREEN='#00b050';
// EBB Production — flat leaf columns (body + units) drive rendering; `thead`
// is a hand-built 3-tier header that mirrors the source sheet exactly.
const OEE_SCHEMA={
ebb:{
ready:true,
leaves:[
{k:'date',type:'date',w:'116px'},
{k:'shift',type:'text',w:'70px'},
{k:'shiftLenHrs',type:'num',w:'54px',u:'Hrs'},
{k:'equMin',type:'calc',w:'62px',u:'Min'},
{k:'goldStd',type:'num',w:'62px',u:'Nos./min'},
{k:'waterTea',type:'num',w:'58px',u:'Min'},
{k:'meal',type:'num',w:'56px',u:'Min'},
{k:'breakdown',type:'num',w:'62px',u:'Min'},
{k:'cleaning',type:'num',w:'58px',u:'Min'},
{k:'changeOver',type:'num',w:'58px',u:'Min'},
{k:'dta',type:'calc',w:'58px',u:'Min'},
{k:'availTime',type:'calc',w:'66px',u:'Min'},
{k:'availability',type:'calc',w:'66px',u:'Min'},
{k:'availRate',type:'calc',w:'66px',u:'%'},
{k:'slowSpeed',type:'num',w:'60px',u:'Min'},
{k:'microStops',type:'num',w:'62px',u:'Min'},
{k:'emptyRun',type:'num',w:'62px',u:'Min'},
{k:'startShut',type:'num',w:'60px',u:'Min'},
{k:'dtp',type:'calc',w:'62px',u:'Min'},
{k:'apt',type:'calc',w:'62px',u:'Min'},
{k:'theoOutput',type:'calc',w:'66px',u:'Nos.'},
{k:'actualOutput',type:'num',w:'62px',u:'Nos.'},
{k:'perfRate',type:'calc',w:'64px',u:'Nos.'},
{k:'perfRate2',type:'calc',w:'64px',u:'%'},
{k:'scrap',type:'num',w:'54px',u:'Nos.'},
{k:'rework',type:'num',w:'56px',u:'Nos.'},
{k:'processLoss',type:'num',w:'62px',u:'Nos.'},
{k:'totalRej',type:'calc',w:'58px',u:'Nos.'},
{k:'netOutput',type:'calc',w:'66px',u:'Nos.'},
{k:'qualityRate',type:'calc',w:'62px',u:'Nos.'},
{k:'qualityRate2',type:'calc',w:'62px',u:'%'},
{k:'oee',type:'calc',w:'66px',u:'%'},
],
// Grouped fields for the entry popup (labels shown there).
sections:[
{title:'Shift Details (Potential Prod Time)',color:'#8aa0c0',fields:[
{k:'date',l:'Date',type:'date'},
{k:'shift',l:'Shift',type:'text'},
{k:'shiftLenHrs',l:'Shift Length (SL)',u:'Hrs',type:'num'},
{k:'equMin',l:'Shift Length',u:'Equ. Min',type:'calc'},
{k:'goldStd',l:'Prod rate (Gold Std)',u:'Nos./min',type:'num'},
]},
{title:'Availability Rating (Target 90%) (Downtime due to…)',color:AMBER,fields:[
{k:'waterTea',l:'Water / Tea Break',u:'Min',type:'num'},
{k:'meal',l:'Meal break',u:'Min',type:'num'},
{k:'breakdown',l:'Breakdown',u:'Min',type:'num'},
{k:'cleaning',l:'Cleaning',u:'Min',type:'num'},
{k:'changeOver',l:'Change over',u:'Min',type:'num'},
{k:'dta',l:'Down time (DTA)',u:'Min',type:'calc'},
{k:'availTime',l:'Available time (AT=SL−DTA)',u:'Min',type:'calc'},
{k:'availability',l:'Availability (AT/SL×100)',u:'Min',type:'calc'},
{k:'availRate',l:'Availability Rate',u:'%',type:'calc',hl:true},
]},
{title:'Performance Rating (Target 95%) (Downtime due to…)',color:BLUE,fields:[
{k:'slowSpeed',l:'Slow speed / High Cycle Time',u:'Min',type:'num'},
{k:'microStops',l:'Micro Stops < 10 min',u:'Min',type:'num'},
{k:'emptyRun',l:'Empty run (mat. shortage)',u:'Min',type:'num'},
{k:'startShut',l:'Start up / shut down',u:'Min',type:'num'},
{k:'dtp',l:'Perf. Down time (DTP)',u:'Min',type:'calc'},
{k:'apt',l:'Actual Prod time (APT=AT−DTP)',u:'Min',type:'calc'},
{k:'theoOutput',l:'Theoretical Output (APT×Gold Std)',u:'Nos.',type:'calc'},
{k:'actualOutput',l:'Actual Output (AO)',u:'Nos.',type:'num'},
{k:'perfRate',l:'Performance Rate',u:'Nos.',type:'calc'},
{k:'perfRate2',l:'Performance Rate',u:'%',type:'calc',hl:true},
]},
{title:'Quality Rating (Target 99.9%) (Rejection due to …)',color:RED,fields:[
{k:'scrap',l:'Scrap',u:'Nos.',type:'num'},
{k:'rework',l:'Rework',u:'Nos.',type:'num'},
{k:'processLoss',l:'Process loss',u:'Nos.',type:'num'},
{k:'totalRej',l:'Total Rej (TR)',u:'Nos.',type:'calc'},
{k:'netOutput',l:'Net output post Rej (AO−TR)',u:'Nos.',type:'calc'},
{k:'qualityRate',l:'Quality Rate',u:'Nos.',type:'calc'},
{k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]},
],
thead:`<thead>
<tr>
<th class="oee-idx" rowspan="4">#</th>
<th class="oee-name" rowspan="4">Date</th>
<th class="oee-band oee-sub" colspan="3">Potential Prod Time</th>
<th class="oee-name" rowspan="3">Prod rate (Gold Std)</th>
<th class="oee-band" colspan="9" style="background:${AMBER} !important;color:#0a0f18 !important">Availability Rating (Target 90%) (Downtime due to…)</th>
<th class="oee-band" colspan="10" style="background:${BLUE} !important;color:#0a0f18 !important">Performance Rating (Target 95%) (Downtime due to…)</th>
<th class="oee-band" colspan="7" style="background:${RED} !important;color:#fff !important">Quality Rating (Target 99.9%) (Rejection due to …)</th>
<th class="oee-band" colspan="1" style="background:${GREEN} !important;color:#0a0f18 !important">OEE</th>
<th rowspan="4"></th>
</tr>
<tr>
<th class="oee-name" rowspan="3">Shift</th>
<th class="oee-sub" colspan="2" rowspan="2">Shift Length (SL)</th>
<th class="oee-sub" colspan="5">Downtime due to…</th>
<th class="oee-name" rowspan="2">Down time (DTA)</th>
<th class="oee-name" rowspan="2">Available time (AT = SL−DTA)</th>
<th class="oee-name" rowspan="2">Availability (AT/SL×100)</th>
<th class="oee-name" rowspan="2">Availability Rate</th>
<th class="oee-sub" colspan="4">Downtime due to…</th>
<th class="oee-name" rowspan="2">Performance Down time (DTP)</th>
<th class="oee-name" rowspan="2">Actual Prod time (APT = AT−DTP)</th>
<th class="oee-name" rowspan="2">Theoretical Output (APT×Gold Std)</th>
<th class="oee-name" rowspan="2">Actual Output (AO)</th>
<th class="oee-name" rowspan="2">Performance Rate</th>
<th class="oee-name" rowspan="2">Performance Rate</th>
<th class="oee-sub" colspan="3">Rejection due to …</th>
<th class="oee-name" rowspan="2">Total Rej (TR)</th>
<th class="oee-name" rowspan="2">Net output post Rej (AO−TR)</th>
<th class="oee-name" rowspan="2">Quality Rate</th>
<th class="oee-name" rowspan="2">Quality Rate</th>
<th class="oee-name" rowspan="2">A×P×Q (Target 85%)</th>
</tr>
<tr>
<th class="oee-name">Water / Tea Break</th>
<th class="oee-name">Meal break</th>
<th class="oee-name">Breakdown</th>
<th class="oee-name">Cleaning</th>
<th class="oee-name">Change over</th>
<th class="oee-name">Slow speed / High Cycle Time</th>
<th class="oee-name">Micro Stops &lt; 10 min (Utility failure etc)</th>
<th class="oee-name">Empty run (Shortage of material etc)</th>
<th class="oee-name">Start up / shut down</th>
<th class="oee-name">Scrap</th>
<th class="oee-name">Rework</th>
<th class="oee-name">Process loss</th>
</tr>
<tr>
<th class="oee-u2">Hrs</th><th class="oee-u2">Equ. Min</th><th class="oee-u2">Nos./min</th>
<th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Min</th>
<th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">%</th>
<th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Min</th>
<th class="oee-u2">Min</th><th class="oee-u2">Min</th><th class="oee-u2">Nos.</th><th class="oee-u2">Nos.</th><th class="oee-u2">Nos.</th><th class="oee-u2">%</th>
<th class="oee-u2">Nos.</th><th class="oee-u2">Nos.</th><th class="oee-u2">Nos.</th>
<th class="oee-u2">Nos.</th><th class="oee-u2">Nos.</th><th class="oee-u2">Nos.</th><th class="oee-u2">%</th>
<th class="oee-u2">%</th>
</tr>
</thead>`,
compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
const dta=n('waterTea')+n('meal')+n('breakdown')+n('cleaning')+n('changeOver');
const slMin=n('shiftLenHrs')*60; // Shift Length in equivalent minutes
const at=slMin-dta;
const avail=slMin>0?at/slMin*100:0;
const dtp=n('slowSpeed')+n('microStops')+n('emptyRun')+n('startShut');
const apt=at-dtp;
const theo=apt*n('goldStd');
const ao=n('actualOutput');
const perf=theo>0?ao/theo*100:0;
const tr=n('scrap')+n('rework')+n('processLoss');
const net=ao-tr;
const qual=ao>0?net/ao*100:0;
const oee=avail*perf*qual/10000;
return {equMin:slMin,dta,availTime:at,availability:avail,availRate:avail,dtp,apt,theoOutput:theo,perfRate:perf,perfRate2:perf,totalRej:tr,netOutput:net,qualityRate:qual,qualityRate2:qual,oee};
},
},
// PD Production — identical shape/fields/formula to EBB Production (same
// sections, same downtime reasons, same compute()); just its own tab so PD
// shift data is stored separately from EBB's.
pd:{
ready:true,
sections:[
{title:'Shift Details (Potential Prod Time)',color:'#8aa0c0',fields:[
{k:'date',l:'Date',type:'date'},
{k:'shift',l:'Shift',type:'text'},
{k:'shiftLenHrs',l:'Shift Length (SL)',u:'Hrs',type:'num'},
{k:'equMin',l:'Shift Length',u:'Equ. Min',type:'calc'},
{k:'goldStd',l:'Prod rate (Gold Std)',u:'Nos./min',type:'num'},
]},
{title:'Availability Rating (Target 90%) (Downtime due to…)',color:AMBER,fields:[
{k:'waterTea',l:'Water / Tea Break',u:'Min',type:'num'},
{k:'meal',l:'Meal break',u:'Min',type:'num'},
{k:'breakdown',l:'Breakdown',u:'Min',type:'num'},
{k:'cleaning',l:'Cleaning',u:'Min',type:'num'},
{k:'changeOver',l:'Change over',u:'Min',type:'num'},
{k:'dta',l:'Down time (DTA)',u:'Min',type:'calc'},
{k:'availTime',l:'Available time (AT=SL−DTA)',u:'Min',type:'calc'},
{k:'availability',l:'Availability (AT/SL×100)',u:'Min',type:'calc'},
{k:'availRate',l:'Availability Rate',u:'%',type:'calc',hl:true},
]},
{title:'Performance Rating (Target 95%) (Downtime due to…)',color:BLUE,fields:[
{k:'slowSpeed',l:'Slow speed / High Cycle Time',u:'Min',type:'num'},
{k:'microStops',l:'Micro Stops < 10 min',u:'Min',type:'num'},
{k:'emptyRun',l:'Empty run (mat. shortage)',u:'Min',type:'num'},
{k:'startShut',l:'Start up / shut down',u:'Min',type:'num'},
{k:'dtp',l:'Perf. Down time (DTP)',u:'Min',type:'calc'},
{k:'apt',l:'Actual Prod time (APT=AT−DTP)',u:'Min',type:'calc'},
{k:'theoOutput',l:'Theoretical Output (APT×Gold Std)',u:'Nos.',type:'calc'},
{k:'actualOutput',l:'Actual Output (AO)',u:'Nos.',type:'num'},
{k:'perfRate',l:'Performance Rate',u:'Nos.',type:'calc'},
{k:'perfRate2',l:'Performance Rate',u:'%',type:'calc',hl:true},
]},
{title:'Quality Rating (Target 99.9%) (Rejection due to …)',color:RED,fields:[
{k:'scrap',l:'Scrap',u:'Nos.',type:'num'},
{k:'rework',l:'Rework',u:'Nos.',type:'num'},
{k:'processLoss',l:'Process loss',u:'Nos.',type:'num'},
{k:'totalRej',l:'Total Rej (TR)',u:'Nos.',type:'calc'},
{k:'netOutput',l:'Net output post Rej (AO−TR)',u:'Nos.',type:'calc'},
{k:'qualityRate',l:'Quality Rate',u:'Nos.',type:'calc'},
{k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]},
],
compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
const dta=n('waterTea')+n('meal')+n('breakdown')+n('cleaning')+n('changeOver');
const slMin=n('shiftLenHrs')*60; // Shift Length in equivalent minutes
const at=slMin-dta;
const avail=slMin>0?at/slMin*100:0;
const dtp=n('slowSpeed')+n('microStops')+n('emptyRun')+n('startShut');
const apt=at-dtp;
const theo=apt*n('goldStd');
const ao=n('actualOutput');
const perf=theo>0?ao/theo*100:0;
const tr=n('scrap')+n('rework')+n('processLoss');
const net=ao-tr;
const qual=ao>0?net/ao*100:0;
const oee=avail*perf*qual/10000;
return {equMin:slMin,dta,availTime:at,availability:avail,availRate:avail,dtp,apt,theoOutput:theo,perfRate:perf,perfRate2:perf,totalRej:tr,netOutput:net,qualityRate:qual,qualityRate2:qual,oee};
},
},
// Plastic Sheet Plant — same shape as EBB; differs in the Performance
// downtime reasons (Head Clean / Other Problem), "Rejection" in place of
// "Scrap", and output units measured in Nos. or Kg.
sheet:{
ready:true,
sections:[
{title:'Shift Details (Potential Prod Time)',color:'#8aa0c0',fields:[
{k:'date',l:'Date',type:'date'},
{k:'shift',l:'Shift',type:'text'},
{k:'shiftLenHrs',l:'Shift Length (SL)',u:'Hrs',type:'num'},
{k:'equMin',l:'Shift Length',u:'Equ. Min',type:'calc'},
{k:'goldStd',l:'Prod rate (Gold Std)',u:'Nos. or Kg/min',type:'num'},
]},
{title:'Availability Rating (Target 90%) (Downtime due to…)',color:AMBER,fields:[
{k:'waterTea',l:'Water / Tea Break',u:'Min',type:'num'},
{k:'meal',l:'Meal break',u:'Min',type:'num'},
{k:'breakdown',l:'Breakdown',u:'Min',type:'num'},
{k:'cleaning',l:'Cleaning',u:'Min',type:'num'},
{k:'changeOver',l:'Change over',u:'Min',type:'num'},
{k:'dta',l:'Down time (DTA)',u:'Min',type:'calc'},
{k:'availTime',l:'Available time (AT=SL−DTA)',u:'Min',type:'calc'},
{k:'availability',l:'Availability (AT/SL×100)',u:'Min',type:'calc'},
{k:'availRate',l:'Availability Rate',u:'%',type:'calc',hl:true},
]},
{title:'Performance Rating (Target 95%) (Downtime due to…)',color:BLUE,fields:[
{k:'slowSpeed',l:'Slow speed / High Cycle Time',u:'Min',type:'num'},
{k:'headClean',l:'Head Clean',u:'Min',type:'num'},
{k:'otherProblem',l:'Other Problem',u:'Min',type:'num'},
{k:'mcStartShut',l:'M/C Start up / shut down',u:'Min',type:'num'},
{k:'dtp',l:'Perf. Down time (DTP)',u:'Min',type:'calc'},
{k:'apt',l:'Actual Prod time (APT=AT−DTP)',u:'Min',type:'calc'},
{k:'theoOutput',l:'Theoretical Output (APT×Gold Std)',u:'Nos. or Kg',type:'calc'},
{k:'actualOutput',l:'Actual Output (AO)',u:'Nos. or Kg',type:'num'},
{k:'perfRate',l:'Performance Rate',u:'Nos. or Kg',type:'calc'},
{k:'perfRate2',l:'Performance Rate',u:'%',type:'calc',hl:true},
]},
{title:'Quality Rating (Target 99.9%) (Rejection due to …)',color:RED,fields:[
{k:'rejection',l:'Rejection',u:'Nos. or Kg',type:'num'},
{k:'rework',l:'Rework',u:'Nos. or Kg',type:'num'},
{k:'processLoss',l:'Process loss',u:'Nos. or Kg',type:'num'},
{k:'totalRej',l:'Total Rej (TR)',u:'Nos. or Kg',type:'calc'},
{k:'netOutput',l:'Net output post Rej (AO−TR)',u:'Nos. or Kg',type:'calc'},
{k:'qualityRate',l:'Quality Rate',u:'Nos. or Kg',type:'calc'},
{k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]},
],
compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
const dta=n('waterTea')+n('meal')+n('breakdown')+n('cleaning')+n('changeOver');
const slMin=n('shiftLenHrs')*60;
const at=slMin-dta;
const avail=slMin>0?at/slMin*100:0;
const dtp=n('slowSpeed')+n('headClean')+n('otherProblem')+n('mcStartShut');
const apt=at-dtp;
const theo=apt*n('goldStd');
const ao=n('actualOutput');
const perf=theo>0?ao/theo*100:0;
const tr=n('rejection')+n('rework')+n('processLoss');
const net=ao-tr;
const qual=ao>0?net/ao*100:0;
const oee=avail*perf*qual/10000;
return {equMin:slMin,dta,availTime:at,availability:avail,availRate:avail,dtp,apt,theoOutput:theo,perfRate:perf,perfRate2:perf,totalRej:tr,netOutput:net,qualityRate:qual,qualityRate2:qual,oee};
},
},
// Moulding — like EBB but with a Component Name field and Performance
// reasons Power Cut/Slow Speed, Head Clean, Nozzle Clean, M/C Start-up.
moulding:{
ready:true,
summaryCols:[{k:'componentName',l:'Component'}],
sections:[
{title:'Shift Details',color:'#8aa0c0',fields:[
{k:'date',l:'Date',type:'date'},
{k:'componentName',l:'Component Name',type:'text'},
{k:'shift',l:'Shift',type:'text'},
{k:'shiftLenHrs',l:'Shift Length (SL)',u:'Hrs',type:'num'},
{k:'equMin',l:'Shift Length',u:'Equ. Min',type:'calc'},
{k:'goldStd',l:'Prod rate (Gold Std)',u:'Nos./min',type:'num'},
]},
{title:'Availability Rating (Target 90%)',color:AMBER,fields:[
{k:'waterTea',l:'Water / Tea Break',u:'Min',type:'num'},
{k:'meal',l:'Meal break',u:'Min',type:'num'},
{k:'breakdown',l:'Breakdown',u:'Min',type:'num'},
{k:'cleaning',l:'Cleaning',u:'Min',type:'num'},
{k:'changeOver',l:'Change over',u:'Min',type:'num'},
{k:'dta',l:'Down time (DTA)',u:'Min',type:'calc'},
{k:'availTime',l:'Available time (AT=SL−DTA)',u:'Min',type:'calc'},
{k:'availability',l:'Availability (AT/SL×100)',u:'Min',type:'calc'},
{k:'availRate',l:'Availability Rate',u:'%',type:'calc',hl:true},
]},
{title:'Performance Rating (Target 95%)',color:BLUE,fields:[
{k:'powerCutSlow',l:'Power Cut / Slow Speed Time',u:'Min',type:'num'},
{k:'headClean',l:'Head Clean',u:'Min',type:'num'},
{k:'nozzleClean',l:'Nozzle Clean',u:'Min',type:'num'},
{k:'mcStartShut',l:'M/C Start up / shut down',u:'Min',type:'num'},
{k:'dtp',l:'Perf. Down time (DTP)',u:'Min',type:'calc'},
{k:'apt',l:'Actual Prod time (APT=AT−DTP)',u:'Min',type:'calc'},
{k:'theoOutput',l:'Theoretical Output (APT×Gold Std)',u:'Nos.',type:'calc'},
{k:'actualOutput',l:'Actual Output (AO)',u:'Nos.',type:'num'},
{k:'perfRate',l:'Performance Rate',u:'Nos. or Kg',type:'calc'},
{k:'perfRate2',l:'Performance Rate',u:'%',type:'calc',hl:true},
]},
{title:'Quality Rating (Target 99.9%)',color:RED,fields:[
{k:'scrap',l:'Scrap',u:'Nos.',type:'num'},
{k:'rework',l:'Rework',u:'Nos. or Kg',type:'num'},
{k:'processLoss',l:'Process loss',u:'Kg',type:'num'},
{k:'totalRej',l:'Total Rej (TR)',u:'Nos.',type:'calc'},
{k:'netOutput',l:'Net output post Rej (AO−TR)',u:'Nos.',type:'calc'},
{k:'qualityRate',l:'Quality Rate',u:'Nos. or Kg',type:'calc'},
{k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]},
],
compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
const dta=n('waterTea')+n('meal')+n('breakdown')+n('cleaning')+n('changeOver');
const slMin=n('shiftLenHrs')*60;
const at=slMin-dta;
const avail=slMin>0?at/slMin*100:0;
const dtp=n('powerCutSlow')+n('headClean')+n('nozzleClean')+n('mcStartShut');
const apt=at-dtp;
const theo=apt*n('goldStd');
const ao=n('actualOutput');
const perf=theo>0?ao/theo*100:0;
const tr=n('scrap')+n('rework')+n('processLoss');
const net=ao-tr;
const qual=ao>0?net/ao*100:0;
const oee=avail*perf*qual/10000;
return {equMin:slMin,dta,availTime:at,availability:avail,availRate:avail,dtp,apt,theoOutput:theo,perfRate:perf,perfRate2:perf,totalRej:tr,netOutput:net,qualityRate:qual,qualityRate2:qual,oee};
},
},
// Autoclave / Sterilizer — availability-only, cycle-based. No Performance or
// Quality rating blocks. OEE = running cycle time ÷ potential time × 100.
autoclave:{
ready:true,
kpiCols:[{k:'availRate',l:'Avail %',t:90}],
sections:[
{title:'Shift Details',color:'#8aa0c0',fields:[
{k:'date',l:'Date',type:'date'},
{k:'shift',l:'Shift',type:'text'},
{k:'shiftLenHrs',l:'Shift Length (SL)',u:'Hrs',type:'num'},
{k:'equMin',l:'Shift Length',u:'Equ. Min',type:'calc'},
{k:'cycleRun',l:'Total Cycle Run per day',u:"No's",type:'num'},
{k:'loadUnload',l:'Loading / Unloading time',u:'Min',type:'num'},
{k:'goldStd',l:'Prod rate (Gold Std)',u:'Nos./min',type:'num'},
]},
{title:'Availability Rating (Target 90%)',color:AMBER,fields:[
{k:'boiler',l:'Boiler breakdown',u:'Min',type:'num'},
{k:'waterSys',l:'Water system breakdown',u:'Min',type:'num'},
{k:'bopt',l:'BOPT breakdown',u:'Min',type:'num'},
{k:'coolingTower',l:'Cooling tower breakdown',u:'Min',type:'num'},
{k:'others',l:'Others',u:'Min',type:'num'},
{k:'dta',l:'Total Down time analysis (DTA)',u:'Min',type:'calc'},
{k:'totalCycleTime',l:'Total per cycle time per day',u:'Min',type:'calc'},
{k:'runningCycle',l:'TOTAL running cycle time',u:'Min',type:'calc'},
{k:'availRate',l:'Availability (AT/SL×100)',u:'%',type:'calc',hl:true},
{k:'oee',l:'OEE (Target 85%)',u:'%',type:'calc',hl:true},
]},
{title:'Remarks',color:'#8aa0c0',fields:[
{k:'remarks',l:'Remarks',type:'text'},
]},
],
compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
const dta=n('boiler')+n('waterSys')+n('bopt')+n('coolingTower')+n('others');
const slMin=n('shiftLenHrs')*60;
const at=slMin-dta; // Available time
const totalCycleTime=n('loadUnload')*n('cycleRun');
const runningCycle=at-totalCycleTime;
const avail=slMin>0?at/slMin*100:0;
const oee=slMin>0?runningCycle/slMin*100:0;
return {equMin:slMin,dta,totalCycleTime,runningCycle,availRate:avail,availability:avail,oee};
},
},
};
// Tab list comes from the server (/oee/tabs) — key, label, ready.
let TABS=[];
function schemaFor(key){return OEE_SCHEMA[key]||null;}
function isReady(key){const s=schemaFor(key);return !!(s&&s.ready);}
function allCols(key){const s=schemaFor(key);if(!s)return [];return s.leaves||(s.sections?s.sections.reduce((a,x)=>a.concat(x.fields),[]):[]);}
function inputCols(key){return allCols(key).filter(c=>c.type!=='calc');}
function labelFor(key){const t=TABS.find(t=>t.key===key);return t?t.label:key;}
function num(v){const n=parseFloat(v);return isNaN(n)?0:n;}
function blankRow(key){const r={};allCols(key).forEach(c=>{if(c.type!=='calc')r[c.k]=c.type==='num'?0:'';});return r;}
function rowHasData(key,r){
return inputCols(key).some(c=>{
if(c.type==='num')return num(r[c.k])!==0;
return (r[c.k]||'').toString().trim()!=='';
});
}
// Format a computed value for display.
function fmtCalc(col,val){
if(val==null||isNaN(val))return '';
if(col.u==='%')return (Math.round(val*100)/100).toFixed(2);
const rounded=Math.round(val*1000)/1000;
return String(rounded);
}
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _mode='list'; // 'list' | 'form'
let _docs=[];let _listLoading=false;let _filterTab='';let _page=0,_pageSize=30,_rawCount=0;
let _editId=null; // doc id when editing, null for new
let _tab='ebb';let _month='';let _remark='';let _rows=[];
let _canceled=false;let _saving=false;let _formLoading=false;
let _allowedKeys=null; // null = all; else array of allowed tab keys
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
function today(){return new Date().toISOString().slice(0,10);}
function thisMonth(){return new Date().toISOString().slice(0,7);}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtMonth(m){if(!m)return '—';const p=String(m).split('-');if(p.length<2)return m;const mo=parseInt(p[1],10);return `${_MON[mo-1]||p[1]} ${p[0]}`;}
function visibleTabs(){return TABS.filter(t=>!_allowedKeys||_allowedKeys.includes(t.key));}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
// ── LIST ──────────────────────────────────────────────
async function loadList(resetPage){
if(resetPage)_page=0;
_listLoading=true;render();
const tq=_filterTab?`&tab=${encodeURIComponent(_filterTab)}`:'';
try{const r=await api('GET',`/oee?top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${tq}`);_docs=r.data||[];_rawCount=_docs.length;}
catch(e){toast(e.message,'err');_docs=[];_rawCount=0;}
_listLoading=false;render();
}
function newEntry(){
_editId=null;_tab=(visibleTabs()[0]||TABS[0]||{key:'ebb'}).key;_month=thisMonth();_remark='';_canceled=false;
_rows=[];
_mode='form';render();
}
async function openEntry(id){
_mode='form';_formLoading=true;_editId=id;render();
try{
const r=await api('GET',`/oee/${id}?company=${encodeURIComponent(_co)}`);
const d=r.data||{};
_tab=d.tab;_month=(d.month||'').slice(0,7);_remark=d.remark||'';_canceled=!!d.canceled;
_rows=Array.isArray(d.rows)?d.rows:[];
}catch(e){toast(e.message,'err');_mode='list';}
_formLoading=false;render();
}
async function save(){
if(!_month){toast('Month is required','err');return;}
const editing=_editId!=null;
if(editing&&!CAN_EDIT){toast('You are not permitted to edit','err');return;}
if(!editing&&!CAN_ADD){toast('You are not permitted to add','err');return;}
if(!isReady(_tab)){toast('This OEE form is not available yet','err');return;}
// Every row with data needs a Date.
let firstBad=null,badCount=0;
_rows.forEach((r,i)=>{if(rowHasData(_tab,r)&&!(r.date||'').toString().trim()){badCount++;if(firstBad==null)firstBad=i;}});
if(firstBad!=null){render();toast(`Date is required in all filled rows — ${badCount} missing (row ${firstBad+1}).`,'err');return;}
const used=_rows.filter(r=>rowHasData(_tab,r)&&(r.date||'').toString().trim());
if(!used.length){toast('Add at least one row with a Date before saving.','err');return;}
// Merge computed values into each saved row.
const schema=schemaFor(_tab);
const outRows=used.map(r=>({...r,...(schema.compute?schema.compute(r):{})}));
_saving=true;render();
const body={company:_co,tab:_tab,month:_month,remark:_remark,rows:outRows};
try{
if(editing){await api('PATCH',`/oee/${_editId}`,body);toast('OEE data updated');}
else{await api('POST','/oee',body);toast('OEE data saved');}
_mode='list';_saving=false;loadList(true);
}catch(e){toast(e.message||'Failed','err');_saving=false;render();}
}
async function cancelDoc(){
if(_editId==null||!CAN_EDIT)return;
if(!confirm('Cancel this OEE document? This cannot be undone.'))return;
_saving=true;render();
try{await api('POST',`/oee/${_editId}/cancel`,{company:_co});toast('Document cancelled');_mode='list';_saving=false;loadList(true);}
catch(e){toast(e.message||'Failed','err');_saving=false;render();}
}
// ── RENDER ────────────────────────────────────────────
function render(){try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
if(!CAN_VIEW){app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You are not assigned the "OEE" permission. Ask an admin to grant it (User Management → Approval Steps → Overall Equipment Efficiency).</div></div>';return;}
if(_mode==='form')renderForm(app);else renderList(app);
}
function renderList(app){
const c=mk('card');
const docsHtml=_docs.map(d=>{
const lbl=labelFor(d.tab);
return `<div class="mp-doc${d.canceled?' cx':''}" data-open="${d.id}">
<div class="mp-doc-badge"><div class="mp-doc-badge-t">${esc(lbl)}</div></div>
<div class="mp-doc-main">
<div class="mp-doc-title">${esc(fmtMonth(d.month))}</div>
<div class="mp-doc-sub">
<span>${esc(lbl)}</span><span>·</span>
<span>${d.createdBy?'By <b>'+esc(d.createdBy)+'</b>':''}</span>
<span>${d.canceled?'<span class="pill pill-cx pill-lg">CANCELLED</span>':'<span class="pill pill-ok pill-lg">ACTIVE</span>'}</span>
</div>
</div>
<div class="mp-doc-right">${d.rowCount?`<span class="mp-total"><b>${d.rowCount}</b> row${d.rowCount===1?'':'s'}</span>`:''}<span class="mp-doc-chev">›</span></div>
</div>`;
}).join('');
const tabOpts=`<option value="">All forms</option>`+visibleTabs().map(t=>`<option value="${esc(t.key)}" ${_filterTab===t.key?'selected':''}>${esc(t.label)}</option>`).join('');
c.innerHTML=`<div class="card-hd"><span>Overall Equipment Efficiency</span><span class="right">${_listLoading?'':`${_docs.length} on this page`}</span></div>
<div class="card-bd">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:14px">
<div class="fg" style="margin:0;min-width:150px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div class="fg" style="margin:0;min-width:170px"><label class="fl">Filter by Form</label><select class="fi" id="f-tab">${tabOpts}</select></div>
${CAN_ADD?`<button class="btn b-primary" id="btn-new" style="height:36px;margin-left:auto">+ New Entry</button>`:''}
</div>
${_listLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading…</div>`:
!_docs.length?`<div class="empty-msg">No OEE records${_filterTab?' for '+esc(labelFor(_filterTab)):''}.</div>`:
`<div class="mp-list">${docsHtml}</div>
<div class="mp-pager">
<button class="btn b-cancel" id="l-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button>
<span class="mp-count">Page ${_page+1}</span>
<button class="btn b-cancel" id="l-next" ${_rawCount>=_pageSize?'':'disabled'} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`}
</div>`;
app.appendChild(c);
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;loadList(true);});
q('#f-tab')&&(q('#f-tab').onchange=function(){_filterTab=this.value;loadList(true);});
q('#btn-new')&&(q('#btn-new').onclick=newEntry);
q('#l-prev')&&(q('#l-prev').onclick=()=>{if(_page>0){_page--;loadList();}});
q('#l-next')&&(q('#l-next').onclick=()=>{if(_rawCount>=_pageSize){_page++;loadList();}});
document.querySelectorAll('[data-open]').forEach(tr=>tr.onclick=()=>openEntry(parseInt(tr.dataset.open)));
},0);
}
function switchTab(key){
if(_editId!=null)return; // form is fixed while editing
if(key===_tab)return;
_tab=key;_rows=[];render();
}
// ── Summary KPI cells ─────────────────────────────────
function pct1(v){return (v==null||!isFinite(v))?'—':(Math.round(v*10)/10).toFixed(1);}
function kpi(v,target){
if(v==null||!isFinite(v))return '<span class="oee-kpi">—</span>';
const cls=v>=target?'good':(v>=target*0.9?'warn':'bad');
return `<span class="oee-kpi ${cls}">${pct1(v)}</span>`;
}
function oeeCell(v){
if(v==null||!isFinite(v))return '—';
const g=v>=85,w=v>=70;
const bg=g?'rgba(0,176,80,.15)':w?'rgba(245,166,35,.15)':'rgba(229,62,62,.15)';
const col=g?'#0aa457':w?'#b47500':'#c81e1e';
return `<span class="oee-oeecell" style="background:${bg};color:${col}">${pct1(v)}</span>`;
}
function defaultRowDate(){return _month?`${_month}-01`:today();}
const DEFAULT_KPIS=[{k:'availRate',l:'Avail %',t:90},{k:'perfRate2',l:'Perf %',t:95},{k:'qualityRate2',l:'Qual %',t:99.9}];
function kpiColsFor(key){const s=schemaFor(key);return (s&&s.kpiCols)||DEFAULT_KPIS;}
// ── Single-shift entry popup ──────────────────────────
function openRowModal(index,readOnly){
const s=schemaFor(_tab);if(!s||!s.sections)return;
const editing=index!=null&&index>=0;
const work=editing?JSON.parse(JSON.stringify(_rows[index])):blankRow(_tab);
if(!editing&&!(work.date||'').trim())work.date=defaultRowDate();
const fieldByKey={};s.sections.forEach(sec=>sec.fields.forEach(f=>fieldByKey[f.k]=f));
const fldHtml=(f)=>{
const label=`<label>${esc(f.l)}${f.u?`<span class="oee-fu">${esc(f.u)}</span>`:''}</label>`;
if(f.type==='calc')return `<div class="oee-fld">${label}<div class="oee-calcbox${f.hl?' hl':''}" data-calc="${f.k}">—</div></div>`;
let inp;
if(f.type==='date')inp=`<input data-f="${f.k}" type="date" value="${esc(String(work[f.k]||''))}" ${readOnly?'disabled':''}/>`;
else if(f.type==='text')inp=`<input data-f="${f.k}" type="text" placeholder="e.g. A / B / C" value="${esc(String(work[f.k]||''))}" ${readOnly?'disabled':''}/>`;
else inp=`<input data-f="${f.k}" type="number" step="any" value="${esc(String(work[f.k]!=null?work[f.k]:0))}" ${readOnly?'disabled':''}/>`;
return `<div class="oee-fld">${label}${inp}</div>`;
};
const ov=document.createElement('div');ov.className='oee-mv';
ov.innerHTML=`<div class="oee-mw">
<div class="oee-mh">
<div>
<div class="oee-mh-t">${editing?'Edit Shift':'Add Shift'}</div>
<div class="oee-mh-s">${esc(labelFor(_tab))} · ${esc(fmtMonth(_month))}</div>
</div>
<div class="oee-badge" title="Live OEE = Availability × Performance × Quality"><span class="oee-badge-l">OEE</span><span class="oee-badge-v" data-calc="oee">0</span><span class="oee-badge-l">%</span></div>
<button class="oee-mx" data-x title="Close">✕</button>
</div>
<div class="oee-mb">
${s.sections.map(sec=>`<div class="oee-sec" style="--sec:${sec.color}">
<div class="oee-sec-hd">${esc(sec.title)}</div>
<div class="oee-grid">${sec.fields.map(fldHtml).join('')}</div>
</div>`).join('')}
</div>
<div class="oee-mf">
${readOnly?'':`<button class="btn b-primary" data-save>${editing?'Update Shift':'Add Shift'}</button>`}
<button class="btn b-cancel${readOnly?'':' grow'}" data-cancel>${readOnly?'Close':'Cancel'}</button>
</div>
</div>`;
document.body.appendChild(ov);
const recompute=()=>{
const comp=s.compute(work);
ov.querySelectorAll('[data-calc]').forEach(el=>{
const k=el.dataset.calc;const f=k==='oee'?{u:'%'}:(fieldByKey[k]||{});
const v=fmtCalc(f,comp[k]);el.textContent=(v===''?'0':v);
});
};
ov.querySelectorAll('input[data-f]').forEach(el=>{
el.oninput=function(){
const k=this.dataset.f;const f=fieldByKey[k];
work[k]=f&&f.type==='num'?(parseFloat(this.value)||0):this.value;
if(k==='date')this.classList.remove('miss');
recompute();
};
});
recompute();
const close=()=>ov.remove();
ov.querySelector('[data-x]').onclick=close;
ov.querySelector('[data-cancel]').onclick=close;
const saveBtn=ov.querySelector('[data-save]');
if(saveBtn)saveBtn.onclick=()=>{
if(!(work.date||'').toString().trim()){
const de=ov.querySelector('input[data-f="date"]');if(de){de.classList.add('miss');de.focus();}
toast('Date is required for this shift','err');return;
}
if(editing)_rows[index]=work;else _rows.push(work);
close();render();
};
}
function renderForm(app){
const editing=_editId!=null;
const readOnly=editing?!CAN_EDIT:!CAN_ADD;
const ready=isReady(_tab);
const c=mk('card');
const filled=_rows.filter(r=>rowHasData(_tab,r)).length;
let body;
if(!ready){
body=`<div class="oee-stub">The <strong>${esc(labelFor(_tab))}</strong> format has not been set up yet.<br/>It will be added once the sheet is provided.</div>`;
}else{
const extra=schemaFor(_tab).summaryCols||[]; // optional per-form extra columns
const kpis=kpiColsFor(_tab); // which rate KPIs to show
const rowsHtml=_rows.map((row,i)=>{
const cc=schemaFor(_tab).compute(row);
const extraTd=extra.map(x=>`<td>${esc(row[x.k]||'—')}</td>`).join('');
const kpiTd=kpis.map(kc=>`<td class="num">${kpi(cc[kc.k],kc.t)}</td>`).join('');
return `<tr data-edit="${i}">
<td>${i+1}</td>
<td>${esc(row.date||'—')}</td>
${extraTd}
<td>${esc(row.shift||'—')}</td>
<td class="num">${row.shiftLenHrs||0}</td>
${kpiTd}
<td class="num">${oeeCell(cc.oee)}</td>
${readOnly?'':`<td class="num"><button class="oee-del" data-del="${i}" title="Remove shift">✕</button></td>`}
</tr>`;
}).join('');
const extraTh=extra.map(x=>`<th>${esc(x.l)}</th>`).join('');
const kpiTh=kpis.map(kc=>`<th class="num">${esc(kc.l)}</th>`).join('');
const cols=5+extra.length+kpis.length+(readOnly?0:1);
body=`<div class="oee-grid-wrap"><table class="oee-sum">
<thead><tr><th>#</th><th>Date</th>${extraTh}<th>Shift</th><th class="num">SL (Hrs)</th>${kpiTh}<th class="num">OEE %</th>${readOnly?'':'<th></th>'}</tr></thead>
<tbody>${_rows.length?rowsHtml:`<tr><td colspan="${cols}" style="text-align:center;color:var(--text3);padding:24px">No shifts added yet.${readOnly?'':' Click <strong>“+ Add Shift”</strong> to enter one.'}</td></tr>`}</tbody>
</table></div>
${readOnly?'':`<button type="button" class="mp-addrow" id="btn-addrow">+ Add Shift</button>`}`;
}
c.innerHTML=`<div class="card-hd mp-head">
<div class="mp-head-title">
<span class="mp-head-ico">📈</span>
<div>
<div class="mp-head-t">${editing?'OEE Data':'New OEE Entry'}${_canceled?' <span class="pill pill-cx" style="margin-left:4px">CANCELLED</span>':''}</div>
<div class="mp-head-s">${editing?`Document #${_editId} · ${esc(labelFor(_tab))}`:'Record Overall Equipment Efficiency for a production area'}</div>
</div>
</div>
<button class="btn b-cancel" id="btn-back" style="padding:6px 14px;font-size:11px">← Back to list</button>
</div>
<div class="card-bd">
${_formLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading…</div>`:`
<div class="mp-tabs">${visibleTabs().map(t=>{
const lock=editing&&t.key!==_tab;
return `<button class="mp-tab${_tab===t.key?' on':''}" data-tab="${t.key}" ${lock?'disabled':''} title="${lock?'Form is fixed while editing':''}">${esc(t.label)}${isReady(t.key)?'':`<span class="mp-tab-soon">SOON</span>`}</button>`;
}).join('')}</div>
<div class="mp-meta">
<div class="fg"><label class="fl">Month <span style="color:var(--red)">*</span></label><input class="fi" id="inp-month" type="month" value="${esc(_month)}" ${readOnly?'readonly':''} style="min-width:160px"/></div>
<div class="fg" style="flex:1;min-width:220px"><label class="fl">Remark</label><input class="fi" id="inp-remark" value="${esc(_remark)}" placeholder="Optional note for this sheet" ${readOnly?'readonly':''}/></div>
<div class="mp-meta-summary">${filled} shift${filled===1?'':'s'} entered</div>
</div>
${body}
<div class="mp-actions">
${(readOnly||!ready)?'':`<button class="btn b-primary" id="btn-save" ${_saving?'disabled':''}><span id="sp-save" style="display:${_saving?'inline-block':'none'}" class="sp"></span> ${editing?'Save Changes':'Save Entry'}</button>`}
${editing&&CAN_EDIT&&!_canceled?`<button class="btn b-danger" id="btn-cancel-doc" ${_saving?'disabled':''}>Cancel Document</button>`:''}
<button class="btn b-cancel${(readOnly||!ready)?'':' grow'}" id="btn-discard">${readOnly?'Close':'Discard'}</button>
</div>
`}
</div>`;
app.appendChild(c);
setTimeout(()=>{
q('#btn-back')&&(q('#btn-back').onclick=()=>{_mode='list';render();});
q('#btn-discard')&&(q('#btn-discard').onclick=()=>{_mode='list';render();});
if(_formLoading)return;
document.querySelectorAll('.mp-tab').forEach(b=>b.onclick=function(){if(!this.disabled)switchTab(this.dataset.tab);});
q('#inp-month')&&!readOnly&&(q('#inp-month').onchange=function(){_month=this.value;});
q('#inp-remark')&&!readOnly&&(q('#inp-remark').oninput=function(){_remark=this.value;});
if(!ready)return;
// Click a summary row → open the popup (edit, or view when read-only).
document.querySelectorAll('tr[data-edit]').forEach(tr=>tr.onclick=function(e){
if(e.target.closest('[data-del]'))return;
openRowModal(parseInt(this.dataset.edit),readOnly);
});
document.querySelectorAll('[data-del]').forEach(el=>el.onclick=function(e){
e.stopPropagation();_rows.splice(parseInt(this.dataset.del),1);render();
});
q('#btn-addrow')&&(q('#btn-addrow').onclick=()=>openRowModal(null,false));
q('#btn-save')&&(q('#btn-save').onclick=save);
q('#btn-cancel-doc')&&(q('#btn-cancel-doc').onclick=cancelDoc);
},0);
}
async function loadTabs(){
try{const r=await api('GET','/oee/tabs');TABS=(r.data||[]).map(t=>({key:String(t.key),label:t.label||String(t.key),ready:!!t.ready}));}
catch(_e){TABS=[{key:'ebb',label:'EBB Production',ready:true}];}
}
async function loadConfig(){
// Per-user form permission (Admin → user → OEE). Empty = all forms.
let userKeys=null;
try{const pr=await api('GET','/auth/profile');const ut=pr?.user?.oeeTabs;if(Array.isArray(ut)&&ut.length)userKeys=ut.map(String);}catch(_e){}
_allowedKeys=userKeys;
if(!visibleTabs().some(t=>t.key===_tab))_tab=(visibleTabs()[0]||TABS[0]||{key:'ebb'}).key;
}
async function init(){
try{
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
if(CAN_VIEW){ await Promise.all([loadTabs(),loadConfig()]); loadList(); } else render();
}catch(e){console.error('Init error:',e);render();}
}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>PPC Report — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:12px;max-width:1600px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:130px}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;height:34px}
.b-primary{background:var(--blue);color:#fff}
.b-primary:disabled{opacity:.5;cursor:not-allowed}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap;background:var(--surface3);position:sticky;top:0}
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;white-space:nowrap}
.lst td.num,.lst th.num{text-align:right;font-family:var(--mono)}
.lst tfoot td{padding:8px 10px;font-weight:700;color:var(--text);background:var(--surface3);border-top:2px solid var(--border2)}
.lst tfoot td:first-child{text-align:right}
.lst tfoot tr td{position:sticky;bottom:var(--sticky-bottom,0)}
.lst tfoot tr.stock-1a-row td{color:var(--teal)}
.lst tfoot tr.stock-1b-row td{color:var(--amber)}
.lst tfoot tr.balance-1a-row td,.lst tfoot tr.balance-1b-row td{color:var(--purple)}
.b-green{background:var(--green);color:#fff}
.lst tbody tr:hover td{background:rgba(26,111,255,.05)}
.mono{font-family:var(--mono)}
.pill{font-size:9px;font-weight:800;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.p-trade{background:rgba(13,187,138,.14);color:#0aa457}
.p-institute{background:rgba(159,90,253,.16);color:#b98bff}
.p-domestic{background:rgba(6,182,212,.14);color:var(--teal)}
.p-export{background:rgba(245,166,35,.14);color:var(--amber)}
.p-ref{background:rgba(26,111,255,.14);color:#5b9bff;font-family:var(--mono)}
.p-ref-ex{background:rgba(245,166,35,.14);color:#c78a1a;font-family:var(--mono)}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:14px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-err{background:var(--red);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:70vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
.item-hd{font-size:9px}
.item-hd .code{display:block;font-family:var(--mono);font-weight:700;color:var(--text)}
.item-hd .name{display:block;font-weight:400;text-transform:none;color:var(--text3);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<main id="app"></main>
<script>
'use strict';
let _tok=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)location.href='/';
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast t-err';e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function fmtD(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';return `${String(d.getDate()).padStart(2,'0')}-${MON[d.getMonth()]}-${d.getFullYear()}`;}
function today(){return new Date().toISOString().slice(0,10);}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
let _co=window.__DEFAULT_COMPANY__||'';
let _companies=[];
let _itemGroups=[];
// From/To start empty — leave both blank and "Run Report" shows every open
// (pending) order regardless of date; fill them in to narrow by DocDate.
const _f={from:'',to:'',itemGroup:'',salesType:'',customerType:''};
let _loading=false,_ran=false;
let _itemCodes=[],_rows=[];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
function grpOptions(sel){return '<option value="">All</option>'+_itemGroups.map(g=>`<option value="${esc(g.code)}" ${String(sel)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('');}
async function loadItemGroups(){
try{const r=await api('/ppc/item-groups?company='+encodeURIComponent(_co));_itemGroups=r.data||[];}
catch(_e){_itemGroups=[];}
}
async function runReport(){
if(_f.to&&_f.to>today()){toast('"To" date cannot be in the future');return;}
if(_f.from&&_f.to&&_f.from>_f.to){toast('"From" date cannot be after "To" date');return;}
_loading=true;_ran=true;render();
const qs=new URLSearchParams();
qs.set('company',_co);
if(_f.from)qs.set('from',_f.from);
if(_f.to)qs.set('to',_f.to);
if(_f.itemGroup)qs.set('itemGroup',_f.itemGroup);
if(_f.salesType)qs.set('salesType',_f.salesType);
if(_f.customerType)qs.set('customerType',_f.customerType);
try{
const r=await api('/ppc/invoice-report?'+qs.toString());
_itemCodes=(r.data&&r.data.itemCodes)||[];
_rows=(r.data&&r.data.rows)||[];
}catch(e){toast(e.message);_itemCodes=[];_rows=[];}
_loading=false;render();
}
function typePill(t){
if(!t)return '—';
const cls=/trade/i.test(t)?'p-trade':/institute/i.test(t)?'p-institute':'';
return `<span class="pill ${cls}">${esc(t)}</span>`;
}
function custTypePill(t){
// Blank means neither Web SO No field is set AND U_CustomerType isn't
// either — i.e. an order entered directly in SAP, not through the portal/
// website. Still a real, included row (see visibleRows()/server-side
// logic — nothing here ever drops it) — just can't be classified
// Domestic/Export, so it's labeled as such instead of a bare dash.
if(!t)return `<span class="pill" style="background:rgba(122,154,184,.14);color:var(--text2)">Direct (SAP)</span>`;
const cls=t==='DOMESTIC'?'p-domestic':t==='EXPORT'?'p-export':'';
return `<span class="pill ${cls}">${esc(t==='DOMESTIC'?'Domestic':t==='EXPORT'?'Export':t)}</span>`;
}
function refBadge(v,cls){
if(v==null||v==='')return '—';
return `<span class="pill ${cls}">${esc(v)}</span>`;
}
// Live client-side search — no re-fetch, just narrows what's already loaded.
// Customer search filters rows by name; Product search filters which item
// columns show AND drops any row left with nothing in those columns.
const _q={customer:'',product:''};
function visibleItemCodes(){
const p=_q.product.trim().toLowerCase();
if(!p)return _itemCodes;
return _itemCodes.filter(it=>it.code.toLowerCase().includes(p)||it.name.toLowerCase().includes(p));
}
function visibleRows(cols){
const c=_q.customer.trim().toLowerCase();
let rows=_rows;
if(c)rows=rows.filter(r=>(r.cardName||'').toLowerCase().includes(c));
if(_q.product.trim())rows=rows.filter(r=>cols.some(it=>r.qtyByItem[it.code]));
return rows;
}
// Per-item column sums across the given (already search-filtered) rows —
// shared by the on-screen totals row and the Excel export.
function columnTotals(cols,rows){
return cols.map(it=>rows.reduce((sum,r)=>sum+(Number(r.qtyByItem[it.code])||0),0));
}
function renderTable(){
if(_loading)return `<div class="empty-msg"><span class="sp"></span> Loading…</div>`;
if(!_ran)return `<div class="empty-msg">Pick your filters and click "Run Report".</div>`;
if(!_rows.length)return `<div class="empty-msg">No pending orders match these filters.</div>`;
const cols=visibleItemCodes();
const rows=visibleRows(cols);
if(!rows.length)return `<div class="empty-msg">No rows match your search.</div>`;
const itemThs=cols.map(it=>`<th class="num item-hd" title="${esc(it.code)} — ${esc(it.name)}"><span class="code">${esc(it.code)}</span><span class="name">(${esc(it.name)})</span></th>`).join('');
const rowsHtml=rows.map((r,i)=>{
const cells=cols.map(it=>{
const v=r.qtyByItem[it.code];
return `<td class="num">${v?esc(v):''}</td>`;
}).join('');
return `<tr>
<td>${i+1}</td>
<td>${esc(r.cardName)}</td>
<td>${esc(r.city)}</td>
<td>${typePill(r.orderType)}</td>
<td>${custTypePill(r.customerType)}</td>
<td>${fmtD(r.dispatchDate)}</td>
<td>${refBadge(r.salesOrderNo,'p-ref')}</td>
<td>${refBadge(r.webSoNo,'p-ref')}</td>
<td>${refBadge(r.webSoNoEx,'p-ref-ex')}</td>
${cells}
</tr>`;
}).join('');
const totals=columnTotals(cols,rows);
// Show only the stock row matching the active Customer Type filter — both
// when no filter is set (nothing to narrow to).
const showDomestic=_f.customerType!=='EXPORT';
const showExport=_f.customerType!=='DOMESTIC';
// Balance = Current Stock − Total Pending Qty, per item — what's left (or
// short) after covering every still-open order shown. Positive = enough
// stock on hand; negative = short by that much, needs producing.
const balance1A=cols.map((it,i)=>(it.stock1A||0)-totals[i]);
const balance1B=cols.map((it,i)=>(it.stock1B||0)-totals[i]);
// Warehouse-wise breakdown, shown as a native hover tooltip on the Current
// Stock cells — full per-warehouse detail without cluttering the table
// with a row per warehouse (see earlier decision: just 1A/1B as rows).
const whTitles=cols.map(it=>(it.byWarehouse&&it.byWarehouse.length)
?it.byWarehouse.map(w=>`${w.code} (${w.name}): ${w.qty}`).join('\n')
:'No stock in any warehouse');
const footRows=[
{cls:'totals-row', label:'Total Pending Qty', show:true, vals:totals},
{cls:'stock-1a-row', label:'Current Stock (Domestic)', show:showDomestic, vals:cols.map(it=>it.stock1A||0), titles:whTitles},
{cls:'stock-1b-row', label:'Current Stock (Export)', show:showExport, vals:cols.map(it=>it.stock1B||0), titles:whTitles},
{cls:'balance-1a-row', label:'Balance (Domestic)', show:showDomestic, vals:balance1A},
{cls:'balance-1b-row', label:'Balance (Export)', show:showExport, vals:balance1B},
].filter(r=>r.show);
const footHtml=footRows.map((r,i)=>{
const bottom=(footRows.length-1-i)*33;
const isBalance=r.cls.startsWith('balance-');
const cells=r.vals.map((v,j)=>{
const t=r.titles?` title="${esc(r.titles[j])}"`:'';
const style=isBalance&&v<0?' style="color:var(--red)"':'';
return `<td class="num"${style}${t}>${esc(v)}</td>`;
}).join('');
return `<tr class="${r.cls}" style="--sticky-bottom:${bottom}px"><td colspan="9">${esc(r.label)}</td>${cells}</tr>`;
}).join('');
return `<div class="tbl-wrap"><table class="lst">
<thead><tr><th>Sr.No</th><th>Name of Customer</th><th>City</th><th>Order Type</th><th>Customer Type</th><th>Committed Dispatch Date</th><th>Sale Order No</th><th>Web SO No (Domestic)</th><th>Web SO No (Export)</th>${itemThs}</tr></thead>
<tbody>${rowsHtml}</tbody>
<tfoot>${footHtml}</tfoot>
</table></div>`;
}
function exportExcel(){
const cols=visibleItemCodes();
const rows=visibleRows(cols);
if(!rows.length){toast('Nothing to export');return;}
const csvCell=v=>{const s=String(v==null?'':v);return /[",\r\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;};
const header=['Sr.No','Name of Customer','City','Order Type','Customer Type','Committed Dispatch Date','Sale Order No','Web SO No (Domestic)','Web SO No (Export)',...cols.map(it=>`${it.code} (${it.name})`)];
const lines=[header.map(csvCell).join(',')];
rows.forEach((r,i)=>{
lines.push([i+1,r.cardName,r.city,r.orderType,r.customerType||'',fmtD(r.dispatchDate),r.salesOrderNo||'',r.webSoNo||'',r.webSoNoEx||'',...cols.map(it=>r.qtyByItem[it.code]||0)].map(csvCell).join(','));
});
const totals=columnTotals(cols,rows);
lines.push(['','','Total Pending Qty','','','','','','',...totals].map(csvCell).join(','));
if(_f.customerType!=='EXPORT')lines.push(['','','Current Stock (Domestic)','','','','','','',...cols.map(it=>it.stock1A||0)].map(csvCell).join(','));
if(_f.customerType!=='DOMESTIC')lines.push(['','','Current Stock (Export)','','','','','','',...cols.map(it=>it.stock1B||0)].map(csvCell).join(','));
if(_f.customerType!=='EXPORT')lines.push(['','','Balance (Domestic)','','','','','','',...cols.map((it,i)=>(it.stock1A||0)-totals[i])].map(csvCell).join(','));
if(_f.customerType!=='DOMESTIC')lines.push(['','','Balance (Export)','','','','','','',...cols.map((it,i)=>(it.stock1B||0)-totals[i])].map(csvCell).join(','));
const blob=new Blob([''+lines.join('\r\n')],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`PPC_Report_${_f.from||'all'}_to_${_f.to||'all'}.csv`;
a.click();
URL.revokeObjectURL(a.href);
}
function resultCountText(){
const cols=visibleItemCodes(),rows=visibleRows(cols);
return `${rows.length.toLocaleString()} order${rows.length===1?'':'s'} · ${cols.length} item${cols.length===1?'':'s'}`;
}
// Search inputs only ever patch #results + the count badge directly — never
// a full render() — so typing never rebuilds (and loses focus on) the very
// input the user is typing into. Same principle used throughout this app's
// other live-search fields.
function refreshResults(){
const res=q('#results');if(res)res.innerHTML=renderTable();
const cnt=q('#result-count');if(cnt)cnt.textContent=_ran&&!_loading?resultCountText():'';
}
function render(){
const app=q('#app');
app.innerHTML=`
<div class="card"><div class="card-hd"><span>📋 PPC Report</span><span class="right" id="result-count">${_ran&&!_loading?resultCountText():''}</span></div>
<div class="card-bd">
<div class="filters">
${_companies.length>1?`<div><label class="fl">Company</label><select class="fi" id="f-co">${coOptions(_co)}</select></div>`:''}
<div><label class="fl">From</label><input class="fi" type="date" id="f-from" value="${_f.from}" max="${_f.to||today()}" title="Leave blank (with To) to show every open order regardless of date"/></div>
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}" min="${_f.from||''}" max="${today()}" title="Leave blank (with From) to show every open order regardless of date"/></div>
<div><label class="fl">Item Group</label><select class="fi" id="f-grp">${grpOptions(_f.itemGroup)}</select></div>
<div><label class="fl">Order Type</label><select class="fi" id="f-order">
<option value="">All</option>
<option value="Trade" ${_f.salesType==='Trade'?'selected':''}>Trade</option>
<option value="Institute" ${_f.salesType==='Institute'?'selected':''}>Institute</option>
</select></div>
<div><label class="fl">Customer Type</label><select class="fi" id="f-cust">
<option value="">All</option>
<option value="DOMESTIC" ${_f.customerType==='DOMESTIC'?'selected':''}>Domestic</option>
<option value="EXPORT" ${_f.customerType==='EXPORT'?'selected':''}>Export</option>
</select></div>
<button class="btn b-primary" id="f-go" ${_loading?'disabled':''}>${_loading?'<span class="sp"></span> Running…':'Run Report'}</button>
</div>
<div class="filters" style="margin-top:8px">
<div style="min-width:220px;flex:1"><label class="fl">Search Customer</label><input class="fi" id="q-customer" placeholder="Customer name…" value="${esc(_q.customer)}"/></div>
<div style="min-width:220px;flex:1"><label class="fl">Search Product</label><input class="fi" id="q-product" placeholder="Item code or name…" value="${esc(_q.product)}"/></div>
<button class="btn b-green" id="f-export" ${_ran&&_rows.length?'':'disabled'}>⬇ Download Excel</button>
</div>
<div style="margin-top:12px" id="results">${renderTable()}</div>
</div></div>`;
wire();
}
function wire(){
if(q('#f-co'))q('#f-co').onchange=async function(){_co=this.value;await loadItemGroups();render();};
q('#f-from').onchange=function(){_f.from=this.value;render();};
q('#f-to').onchange=function(){_f.to=this.value;render();};
q('#f-grp').onchange=function(){_f.itemGroup=this.value;};
q('#f-order').onchange=function(){_f.salesType=this.value;};
q('#f-cust').onchange=function(){_f.customerType=this.value;};
q('#f-go').onclick=runReport;
q('#q-customer').oninput=function(){_q.customer=this.value;refreshResults();};
q('#q-product').oninput=function(){_q.product=this.value;refreshResults();};
if(q('#f-export'))q('#f-export').onclick=exportExcel;
}
(async function init(){
try{const list=await window.fetchCompanyList();if(list.length){_companies=list;if(!_co)_co=list[0].value;}}catch(_e){}
await loadItemGroups();
render();
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+288
View File
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Production Dashboard — MITRA ERP</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/theme.css?v=3"/>
<style>
:root{
--bg:#f9fafb;--surface:#ffffff;--border:#e5e7eb;
--text:#111827;--text2:#374151;--text3:#6b7280;
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;
/* Validated data-viz palette (light surface #ffffff) */
--series-1:#2a78d6;
--series-2:#eb6834;
--good:#008300;--bad:#c81e1e;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:26px 30px 50px;max-width:1240px;margin:0 auto}
.hero{background:linear-gradient(135deg,#0f3d2e 0%,#0d9488 55%,#2563eb 100%);border-radius:16px;padding:24px 28px;color:#fff;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;box-shadow:0 4px 24px rgba(13,148,136,.22);margin-bottom:22px;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:-50px;right:-50px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.07)}
.hero-eyebrow{font-size:10px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:4px}
.hero-title{font-size:24px;font-weight:800}
.hero-sub{font-size:12.5px;color:rgba(255,255,255,.75);margin-top:4px}
.hero-right{font-size:11.5px;color:rgba(255,255,255,.8);text-align:right;z-index:1}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:12px;margin-bottom:22px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
.kpi-l{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.7px;color:var(--text3);margin-bottom:8px}
.kpi-v{font-family:var(--mono);font-size:21px;font-weight:600;color:var(--text);line-height:1.1}
.kpi-s{font-size:11px;color:var(--text3);margin-top:6px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;margin-bottom:18px;overflow:hidden}
.card-hd{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.card-title{font-size:13.5px;font-weight:700}
.card-sub{font-size:11px;color:var(--text3);margin-top:2px}
.card-bd{padding:16px 18px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
.fbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px 14px;margin-bottom:16px}
.fbar-l{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-right:4px}
.fchip{padding:5px 13px;border-radius:999px;font-size:11.5px;font-weight:700;cursor:pointer;border:1px solid var(--border);background:#fff;color:var(--text2);font-family:var(--font);transition:all .12s}
.fchip:hover{border-color:#c7cdd6}
.fchip.on{background:var(--series-1);color:#fff;border-color:transparent}
.fbar input[type=date]{padding:5px 8px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:11.5px;color:var(--text2)}
.fbar .sep{width:1px;height:22px;background:var(--border);margin:0 4px}
.fapply{padding:6px 16px;border-radius:8px;border:none;background:var(--series-1);color:#fff;font-size:11.5px;font-weight:700;font-family:var(--font);cursor:pointer}
.fapply:hover{opacity:.9}
.csearch{padding:6px 10px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:11.5px;color:var(--text);width:180px;outline:none}
.csearch:focus{border-color:var(--series-1);box-shadow:0 0 0 3px rgba(42,120,214,.12)}
.hbar-row{display:grid;grid-template-columns:minmax(120px,260px) 1fr 92px;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid #f3f4f6}
.hbar-row:last-child{border-bottom:none}
.hbar-name{font-size:11.5px;color:var(--text2);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hbar-track{height:14px;position:relative}
.hbar-fill{height:100%;border-radius:0 4px 4px 0;background:var(--series-1);min-width:2px;transition:opacity .12s}
.hbar-row:hover .hbar-fill{opacity:.8}
.hbar-val{font-family:var(--mono);font-size:11px;color:var(--text);text-align:right}
/* workflow funnel */
.stage-row{display:grid;grid-template-columns:minmax(150px,240px) 1fr 60px;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid #f3f4f6}
.stage-row:last-child{border-bottom:none}
.stage-name{font-size:11.5px;color:var(--text2);font-weight:600}
.stage-fill{height:14px;border-radius:0 4px 4px 0;background:var(--series-1);min-width:2px}
.stage-val{font-family:var(--mono);font-size:12px;color:var(--text);text-align:right}
.chart-wrap{position:relative}
.chart-wrap svg{display:block;width:100%;height:auto}
.tip{position:absolute;pointer-events:none;background:#111827;color:#fff;font-size:11px;line-height:1.5;padding:7px 10px;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.25);opacity:0;transition:opacity .08s;white-space:nowrap;z-index:20;transform:translate(-50%,-110%)}
.tip b{font-family:var(--mono);font-weight:600}
details.tbl{border-top:1px solid var(--border)}
details.tbl summary{padding:9px 18px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;user-select:none}
details.tbl summary:hover{color:var(--text2)}
details.tbl table{width:100%;border-collapse:collapse;font-size:11.5px}
details.tbl th{text-align:left;padding:6px 18px;color:var(--text3);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:1px solid var(--border)}
details.tbl td{padding:5px 18px;border-top:1px solid #f3f4f6;font-family:var(--mono);color:var(--text2)}
details.tbl td:first-child{font-family:var(--font)}
.loading{padding:60px;text-align:center;color:var(--text3);font-size:13px}
.err-note{padding:40px;text-align:center;color:var(--bad);font-size:13px}
</style>
</head>
<body>
<main id="main"><div class="loading">Loading production analytics…</div></main>
<script>
const _tok=sessionStorage.getItem('portal_token');
let _user=null;try{_user=JSON.parse(sessionStorage.getItem('portal_user')||'null');}catch(_e){}
const MONTHS=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function fmtN(v){return (Number(v)||0).toLocaleString('en-IN');}
function fmtQty(v){
const n=Number(v)||0,a=Math.abs(n);
if(a>=1e7)return (n/1e7).toFixed(2)+' Cr';
if(a>=1e5)return (n/1e5).toFixed(2)+' L';
return n.toLocaleString('en-IN');
}
let _from='',_to='',_qItem='',_qT=null,_refocus=null,_lastD=null;
function rangeDates(preset){
const now=new Date(),to=now.toISOString().slice(0,10);
let from;
if(preset==='ytd')from=now.getFullYear()+'-01-01';
else{const dd=new Date(now);dd.setMonth(dd.getMonth()-parseInt(preset));from=dd.toISOString().slice(0,10);}
return {from,to};
}
async function load(from,to){
try{
const iq=_qItem?`&itemQ=${encodeURIComponent(_qItem)}`:'';
const r=await fetch(`/api/production-dashboard/summary?from=${from}&to=${to}${iq}`,{headers:{Authorization:'Bearer '+_tok}});
const j=await r.json();if(!j.success)throw new Error(j.message||'Failed');
_lastD=j.data;_from=from;_to=to;
render(j.data);
}catch(e){document.getElementById('main').innerHTML='<div class="err-note">Could not load analytics: '+esc(e.message)+'</div>';}
}
function render(d){
const k=d.kpi,period=`${d.from} → ${d.to}`;
const now=new Date();
const kpis=[
{l:'Production Output — Period',v:fmtQty(k.outputQty),s:'units received from production'},
{l:'Receipt Documents',v:fmtN(k.receiptDocs),s:'receipts posted in period'},
{l:'Items Produced',v:fmtN(k.itemsProduced),s:'distinct products'},
{l:'Orders Created — Period',v:fmtN(k.ordersCreated),s:fmtQty(k.plannedQtyCreated)+' units planned'},
{l:'Orders Closed — Period',v:fmtN(k.ordersClosed),s:'of those created in period'},
{l:'Open Orders — Now',v:fmtN(k.openPlanned+k.openReleased),s:`${fmtN(k.openPlanned)} planned · ${fmtN(k.openReleased)} released`},
];
document.getElementById('main').innerHTML=`
<div class="hero">
<div style="z-index:1">
<div class="hero-eyebrow">Production Module</div>
<div class="hero-title">Production Dashboard</div>
<div class="hero-sub">Output, orders &amp; portal workflow — live from SAP Business One</div>
</div>
<div class="hero-right">As of ${now.toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'})}<br/>${esc(_user?.name||_user?.username||'')}</div>
</div>
<div class="kpis">${kpis.map(x=>`<div class="kpi"><div class="kpi-l">${x.l}</div><div class="kpi-v">${x.v}</div><div class="kpi-s">${x.s||''}</div></div>`).join('')}</div>
<div class="fbar">
<span class="fbar-l">Date range</span>
<button class="fchip" data-preset="1">1M</button>
<button class="fchip" data-preset="3">3M</button>
<button class="fchip on" data-preset="6">6M</button>
<button class="fchip" data-preset="12">12M</button>
<button class="fchip" data-preset="ytd">YTD</button>
<span class="sep"></span>
<input type="date" id="f-from" value="${d.from}"/> <span style="color:var(--text3);font-size:11px">to</span> <input type="date" id="f-to" value="${d.to}"/>
<button class="fapply" id="f-apply">Apply</button>
</div>
<div class="card">
<div class="card-hd"><div><div class="card-title">Monthly Production Output</div><div class="card-sub">Units received from production · ${period}</div></div></div>
<div class="card-bd"><div class="chart-wrap" id="line-wrap"></div></div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Month</th><th>Output Qty</th><th>Receipt Docs</th></tr></thead><tbody>
${d.monthly.map(r=>`<tr><td>${MONTHS[r.m-1]} ${r.y}</td><td>${fmtN(r.qty)}</td><td>${fmtN(r.docs)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="grid2">
<div class="card">
<div class="card-hd">
<div><div class="card-title">Top Produced Items</div><div class="card-sub">By quantity · ${period}</div></div>
<input class="csearch" id="q-item" placeholder="Search item…" value="${esc(_qItem)}" autocomplete="off"/>
</div>
<div class="card-bd">${d.topItems.length?hbars(d.topItems):'<div class="loading">No production in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Item</th><th>Qty</th></tr></thead><tbody>
${d.topItems.map(i=>`<tr><td>${esc(i.code)} — ${esc(i.name)}</td><td>${fmtN(i.qty)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="card">
<div class="card-hd"><div><div class="card-title">Portal Workflow — Pending by Stage</div><div class="card-sub">Portal-tracked production orders · as of now</div></div></div>
<div class="card-bd">
${stageRows(d.stages)}
${d.rejected?`<div class="stage-row"><div class="stage-name" style="color:var(--bad)">Rejected — awaiting Close</div><div><div class="stage-fill" style="background:var(--bad);width:${Math.min(100,d.rejected*12)}%"></div></div><div class="stage-val" style="color:var(--bad)">${fmtN(d.rejected)}</div></div>`:''}
</div>
</div>
</div>`;
drawLine(d.monthly);
wireFilters();
}
function stageRows(stages){
const max=Math.max(...stages.map(s=>s.count),1);
return stages.map(s=>`<div class="stage-row">
<div class="stage-name">${esc(s.label)}</div>
<div><div class="stage-fill" style="width:${Math.max(2,s.count/max*100)}%${s.count?'':';background:#e5e7eb'}"></div></div>
<div class="stage-val">${fmtN(s.count)}</div>
</div>`).join('');
}
function hbars(rows){
const max=Math.max(...rows.map(r=>r.qty),1);
return rows.map(r=>`<div class="hbar-row" title="${esc(r.code)} — ${esc(r.name)}: ${fmtN(r.qty)}">
<div class="hbar-name">${esc(r.name||r.code)}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${Math.max(1,r.qty/max*100)}%"></div></div>
<div class="hbar-val">${fmtQty(r.qty)}</div>
</div>`).join('');
}
function wireFilters(){
document.querySelectorAll('.fchip').forEach(c=>c.onclick=()=>{
document.querySelectorAll('.fchip').forEach(x=>x.classList.remove('on'));
c.classList.add('on');
const r=rangeDates(c.dataset.preset);
load(r.from,r.to);
});
document.getElementById('f-apply').onclick=()=>{
const from=document.getElementById('f-from').value,to=document.getElementById('f-to').value;
if(!from||!to){alert('Pick both From and To dates');return;}
if(from>to){alert('From date must be before To date');return;}
document.querySelectorAll('.fchip').forEach(x=>x.classList.remove('on'));
load(from,to);
};
const q=document.getElementById('q-item');
if(q)q.oninput=function(){
_qItem=this.value;
const pos=this.selectionStart;
clearTimeout(_qT);
_qT=setTimeout(()=>{_refocus={pos};load(_from,_to);},450);
};
if(_refocus){
const el=document.getElementById('q-item');
if(el){el.focus();try{el.setSelectionRange(_refocus.pos,_refocus.pos);}catch(_e){}}
_refocus=null;
}
}
// single-series line chart (title names the series — no legend needed)
function drawLine(monthly){
const wrap=document.getElementById('line-wrap');if(!wrap)return;
if(!monthly.length){wrap.innerHTML='<div class="loading">No production receipts in this period.</div>';return;}
const W=1080,H=280,PL=70,PR=16,PT=14,PB=30;
const iw=W-PL-PR,ih=H-PT-PB;
const maxV=Math.max(...monthly.map(r=>r.qty),1)*1.08;
const x=i=>PL+(monthly.length===1?iw/2:i*(iw/(monthly.length-1)));
const y=v=>PT+ih-(Math.max(v,0)/maxV*ih);
let grid='';
for(let g=0;g<=4;g++){
const gv=maxV*g/4,gy=y(gv);
grid+=`<line x1="${PL}" y1="${gy}" x2="${W-PR}" y2="${gy}" stroke="#eef0f3" stroke-width="1"/>
<text x="${PL-8}" y="${gy+3.5}" text-anchor="end" font-size="10" fill="#9ca3af" font-family="JetBrains Mono,monospace">${fmtQty(gv)}</text>`;
}
let xl='';
monthly.forEach((r,i)=>{ if(monthly.length<=8||i%2===0||i===monthly.length-1)
xl+=`<text x="${x(i)}" y="${H-8}" text-anchor="middle" font-size="10" fill="#9ca3af" font-family="Space Grotesk,sans-serif">${MONTHS[r.m-1]} ${String(r.y).slice(2)}</text>`;});
const pts=monthly.map((r,i)=>`${x(i).toFixed(1)},${y(r.qty).toFixed(1)}`).join(' ');
wrap.innerHTML=`
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Monthly production output">
${grid}${xl}
<polyline points="${pts}" fill="none" stroke="var(--series-1)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<line id="xh" x1="0" y1="${PT}" x2="0" y2="${PT+ih}" stroke="#c7cdd6" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
<circle id="dot" r="4" fill="var(--series-1)" stroke="#fff" stroke-width="2" opacity="0"/>
</svg>
<div class="tip" id="line-tip"></div>`;
const svg=wrap.querySelector('svg'),tip=document.getElementById('line-tip');
const xh=svg.querySelector('#xh'),dot=svg.querySelector('#dot');
svg.addEventListener('mousemove',ev=>{
const rect=svg.getBoundingClientRect();
const sx=(ev.clientX-rect.left)*(W/rect.width);
let idx=monthly.length===1?0:Math.round((sx-PL)/(iw/(monthly.length-1)));
idx=Math.max(0,Math.min(monthly.length-1,idx));
const r=monthly[idx],cx=x(idx);
xh.setAttribute('x1',cx);xh.setAttribute('x2',cx);xh.setAttribute('opacity','1');
dot.setAttribute('cx',cx);dot.setAttribute('cy',y(r.qty));dot.setAttribute('opacity','1');
tip.innerHTML=`<div style="font-weight:700;margin-bottom:3px">${MONTHS[r.m-1]} ${r.y}</div>Output <b>&nbsp;${fmtN(r.qty)}</b><br/>Receipts <b>&nbsp;${fmtN(r.docs)}</b>`;
tip.style.left=(cx/W*100)+'%';
tip.style.top=(y(r.qty)/H*100)+'%';
tip.style.opacity='1';
});
svg.addEventListener('mouseleave',()=>{tip.style.opacity='0';xh.setAttribute('opacity','0');dot.setAttribute('opacity','0');});
}
const _r0=rangeDates('6');
load(_r0.from,_r0.to);
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>My Profile — MITRA ERP Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<style>
:root{
--blue:#2563eb;--blue2:#1d4ed8;--blue-light:#eff6ff;
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
--bg:#f3f4f6;--surface:#fff;--border:#e5e7eb;
--font:'Space Grotesk',sans-serif;
--red:#dc2626;--green:#16a34a;--amber:#d97706;--purple:#7c3aed;
--radius:12px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased;margin-left:200px}
@media(max-width:768px){body{margin-left:0}}
/* ── Topbar ── */
.topbar{height:54px;background:#fff;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 24px;gap:12px;position:sticky;top:0;z-index:100}
.topbar-back{display:flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--text3);text-decoration:none;transition:color .15s}
.topbar-back:hover{color:var(--blue)}
.topbar-back svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.topbar-title{font-size:15px;font-weight:700;color:var(--text);margin-left:4px}
.topbar-sep{width:1px;height:18px;background:var(--border)}
/* ── Layout ── */
.page{max-width:820px;margin:32px auto;padding:0 20px 60px}
/* ── Profile hero ── */
.hero{background:linear-gradient(135deg,#1e40af 0%,#2563eb 55%,#3b82f6 100%);border-radius:16px;padding:32px;display:flex;align-items:center;gap:24px;margin-bottom:24px;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:-50px;right:-50px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.06)}
.hero::after{content:'';position:absolute;bottom:-60px;right:100px;width:160px;height:160px;border-radius:50%;background:rgba(255,255,255,.04)}
.hero-avatar{width:72px;height:72px;border-radius:18px;background:rgba(255,255,255,.18);border:2px solid rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:800;color:#fff;flex-shrink:0;z-index:1}
.hero-info{z-index:1}
.hero-name{font-size:22px;font-weight:800;color:#fff;margin-bottom:4px}
.hero-un{font-size:12px;color:rgba(255,255,255,.6);font-family:monospace;margin-bottom:8px}
.hero-badges{display:flex;gap:8px;flex-wrap:wrap}
.hero-badge{padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.2);letter-spacing:.3px}
.hero-badge.active{background:rgba(16,185,129,.25);border-color:rgba(16,185,129,.4)}
/* ── Cards ── */
.card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;overflow:hidden}
.card-hdr{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
.card-hdr-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.card-hdr-icon.blue{background:#eff6ff;color:#2563eb}
.card-hdr-icon.purple{background:#f5f3ff;color:#7c3aed}
.card-hdr-icon.amber{background:#fffbeb;color:#d97706}
.card-hdr svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.card-hdr-title{font-size:13px;font-weight:700;color:var(--text)}
.card-hdr-sub{font-size:11px;color:var(--text3);margin-top:1px}
.card-body{padding:20px}
/* ── Form ── */
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.form-row.single{grid-template-columns:1fr}
.field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:6px;display:block}
.field-input{width:100%;padding:10px 12px;border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-size:13px;font-family:var(--font);outline:none;transition:border .15s,box-shadow .15s;background:#fff}
.field-input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.field-input:disabled{background:#f9fafb;color:var(--text3);cursor:not-allowed}
.field-input.readonly{background:#f9fafb;color:var(--text2)}
.field-hint{font-size:11px;color:var(--text4);margin-top:4px}
/* ── Divider ── */
.divider{height:1px;background:var(--border);margin:20px 0}
/* ── Buttons ── */
.btn-row{display:flex;gap:10px;justify-content:flex-end;margin-top:4px}
.btn{padding:9px 20px;border-radius:9px;font-size:13px;font-weight:700;font-family:var(--font);cursor:pointer;transition:all .15s;border:none}
.btn-primary{background:var(--blue);color:#fff}
.btn-primary:hover:not(:disabled){background:var(--blue2)}
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
.btn-ghost{background:none;border:1.5px solid var(--border);color:var(--text3)}
.btn-ghost:hover{border-color:#d1d5db;color:var(--text)}
/* ── Info row (read-only meta) ── */
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}
.info-item{padding:12px 0;border-bottom:1px solid var(--border)}
.info-item:nth-last-child(-n+2){border-bottom:none}
.info-lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text4);margin-bottom:3px}
.info-val{font-size:13px;font-weight:600;color:var(--text)}
.info-item:nth-child(odd){padding-right:20px}
.info-item:nth-child(even){padding-left:20px;border-left:1px solid var(--border)}
/* ── Password strength ── */
.pw-meter{height:3px;border-radius:2px;margin-top:6px;background:var(--border);transition:background .2s}
.pw-meter.weak{background:#ef4444}
.pw-meter.fair{background:var(--amber)}
.pw-meter.good{background:var(--green)}
/* ── Toast ── */
.toast-c{position:fixed;top:64px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:10px 14px;border-radius:9px;font-size:12px;font-weight:600;box-shadow:0 4px 16px rgba(0,0,0,.12);display:flex;align-items:center;gap:7px;max-width:320px;animation:tIn .2s ease}
.toast.ok{background:var(--green);color:#fff}
.toast.err{background:var(--red);color:#fff}
@keyframes tIn{from{transform:translateX(16px);opacity:0}to{transform:translateX(0);opacity:1}}
/* ── Spinner ── */
.sp{width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .65s linear infinite;display:inline-block;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:600px){
.form-row{grid-template-columns:1fr}
.info-grid{grid-template-columns:1fr}
.info-item:nth-child(even){padding-left:0;border-left:none}
.info-item:nth-last-child(-n+2){border-bottom:1px solid var(--border)}
.info-item:last-child{border-bottom:none}
.hero{flex-direction:column;align-items:flex-start;gap:16px}
}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toastC"></div>
<div class="topbar">
<a href="/" class="topbar-back">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Dashboard
</a>
<div class="topbar-sep"></div>
<span class="topbar-title">My Profile</span>
</div>
<div class="page">
<!-- Hero -->
<div class="hero" id="hero">
<div class="hero-avatar" id="heroAvatar">?</div>
<div class="hero-info">
<div class="hero-name" id="heroName">Loading…</div>
<div class="hero-un" id="heroUn"></div>
<div class="hero-badges" id="heroBadges"></div>
</div>
</div>
<!-- Account Info (read-only) -->
<div class="card">
<div class="card-hdr">
<div class="card-hdr-icon blue">
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
</div>
<div>
<div class="card-hdr-title">Account Information</div>
<div class="card-hdr-sub">Read-only account details</div>
</div>
</div>
<div class="card-body">
<div class="info-grid" id="infoGrid">
<div class="info-item"><div class="info-lbl">Username</div><div class="info-val" id="iUsername">—</div></div>
<div class="info-item"><div class="info-lbl">Role</div><div class="info-val" id="iRole">—</div></div>
<div class="info-item"><div class="info-lbl">Member Since</div><div class="info-val" id="iCreated">—</div></div>
<div class="info-item"><div class="info-lbl">Last Login</div><div class="info-val" id="iLastLogin">—</div></div>
</div>
</div>
</div>
<!-- Edit Profile -->
<div class="card">
<div class="card-hdr">
<div class="card-hdr-icon purple">
<svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</div>
<div>
<div class="card-hdr-title">Edit Profile</div>
<div class="card-hdr-sub">Update your name and email address</div>
</div>
</div>
<div class="card-body">
<div class="form-row">
<div>
<label class="field-label">Full Name</label>
<input id="inpName" class="field-input" type="text" placeholder="Your full name"/>
</div>
<div>
<label class="field-label">Email Address</label>
<input id="inpEmail" class="field-input" type="email" placeholder="your@email.com"/>
</div>
</div>
<div class="btn-row">
<button class="btn btn-ghost" id="btnResetInfo">Reset</button>
<button class="btn btn-primary" id="btnSaveInfo">Save Changes</button>
</div>
</div>
</div>
<!-- Change Password -->
<div class="card">
<div class="card-hdr">
<div class="card-hdr-icon amber">
<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<div>
<div class="card-hdr-title">Change Password</div>
<div class="card-hdr-sub">Leave blank to keep your current password</div>
</div>
</div>
<div class="card-body">
<div class="form-row single" style="margin-bottom:16px">
<div>
<label class="field-label">Current Password</label>
<input id="inpCurPw" class="field-input" type="password" placeholder="Enter current password"/>
</div>
</div>
<div class="form-row">
<div>
<label class="field-label">New Password</label>
<input id="inpNewPw" class="field-input" type="password" placeholder="Min 6 characters"/>
<div class="pw-meter" id="pwMeter"></div>
</div>
<div>
<label class="field-label">Confirm New Password</label>
<input id="inpConPw" class="field-input" type="password" placeholder="Repeat new password"/>
</div>
</div>
<div class="btn-row">
<button class="btn btn-ghost" id="btnResetPw">Clear</button>
<button class="btn btn-primary" id="btnSavePw">Update Password</button>
</div>
</div>
</div>
<!-- SAP Account (per-user SAP Service-Layer login) -->
<div class="card">
<div class="card-hdr">
<div class="card-hdr-icon amber">
<svg viewBox="0 0 24 24"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
</div>
<div>
<div class="card-hdr-title">My SAP Account</div>
<div class="card-hdr-sub">Your SAP User ID &amp; Password — used to post your work to SAP under your name. Each SAP company has its own users, so the same SAP username can need a <b>different password per company</b> — set one login per company below.</div>
</div>
</div>
<div class="card-body">
<div id="sapList" style="margin-bottom:14px"></div>
<div class="form-row">
<div>
<label class="field-label">Company</label>
<select id="inpSapCompany" class="field-input"><option value="">Loading…</option></select>
</div>
<div>
<label class="field-label">SAP User ID</label>
<input id="inpSapUser" class="field-input" placeholder="e.g. your SAP login" autocomplete="off"/>
</div>
<div>
<label class="field-label">SAP Password</label>
<input id="inpSapPw" class="field-input" type="password" placeholder="Enter SAP password" autocomplete="new-password"/>
</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:6px">Your password is verified against the selected SAP company and stored encrypted. It is never shown back to you.</div>
<div class="btn-row">
<button class="btn btn-primary" id="btnSaveSap">Verify &amp; Save</button>
</div>
</div>
</div>
<div class="card">
<div class="card-hdr">
<div class="card-hdr-icon purple">
<svg viewBox="0 0 24 24"><path d="M3 17s2-4 6-4 6 4 10 0"/><path d="M2 21h20"/></svg>
</div>
<div>
<div class="card-hdr-title">My Signature</div>
<div class="card-hdr-sub">Upload your signature image — printed on Work Orders wherever you sign an approval step</div>
</div>
</div>
<div class="card-body">
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<div style="width:200px;height:80px;border:1px dashed var(--border,#cbd5e1);border-radius:8px;display:flex;align-items:center;justify-content:center;background:#fff;overflow:hidden">
<img id="sigPreview" alt="" style="max-width:100%;max-height:100%;object-fit:contain;display:none"/>
<span id="sigEmpty" style="font-size:11px;color:var(--text3)">No signature</span>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<input type="file" id="sigFile" accept="image/*" style="font-size:12px"/>
<div style="font-size:11px;color:var(--text3)">PNG/JPG with a transparent or white background works best.</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btnSaveSig">Save Signature</button>
<button class="btn btn-secondary" id="btnClearSig">Remove</button>
</div>
</div>
</div>
</div>
<script src="/company-list.js"></script>
<script>
'use strict';
const tok = sessionStorage.getItem('portal_token') || '';
const BACKEND = '';
let _origName = '', _origEmail = '';
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function toast(msg, type='ok'){
const c = document.getElementById('toastC');
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
c.appendChild(t);
setTimeout(()=>t.remove(), 3500);
}
function fmtDate(iso){
if(!iso) return '—';
return new Date(iso).toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
function initials(name){
return (name||'').split(' ').map(w=>w[0]||'').join('').slice(0,2).toUpperCase() || '?';
}
const ROLE_LABELS = {admin:'Admin',manager:'Manager',sr_manager:'Sr. Manager',sap_adder:'SAP Adder',system_admin:'System Admin',board_member:'Board Member',user:'User',project_approver:'Project Approver'};
async function loadProfile(){
try{
const r = await fetch(`${BACKEND}/api/auth/profile`,{headers:{Authorization:`Bearer ${tok}`}});
const d = await r.json();
if(!d.success) throw new Error(d.message||'Failed');
const u = d.user;
_origName = u.fullName || '';
_origEmail = u.email || '';
// Hero
document.getElementById('heroAvatar').textContent = initials(u.fullName);
document.getElementById('heroName').textContent = u.fullName || u.username;
document.getElementById('heroUn').textContent = '@' + u.username;
document.getElementById('heroBadges').innerHTML =
`<span class="hero-badge">${esc(ROLE_LABELS[u.role]||u.role)}</span>`+
`<span class="hero-badge active">&#9679; Active</span>`;
// Info grid
document.getElementById('iUsername').textContent = u.username;
document.getElementById('iRole').textContent = ROLE_LABELS[u.role] || u.role;
document.getElementById('iCreated').textContent = fmtDate(u.createdAt);
document.getElementById('iLastLogin').textContent = fmtDate(u.lastLogin);
// Form
document.getElementById('inpName').value = _origName;
document.getElementById('inpEmail').value = _origEmail;
} catch(e){
toast('Failed to load profile: ' + e.message, 'err');
}
}
// ── Password strength meter ──────────────────────────────────────────────────
document.getElementById('inpNewPw').addEventListener('input', function(){
const v = this.value;
const m = document.getElementById('pwMeter');
if(!v){ m.className='pw-meter'; return; }
let score = 0;
if(v.length >= 6) score++;
if(v.length >= 10) score++;
if(/[A-Z]/.test(v) && /[0-9]/.test(v)) score++;
if(/[^A-Za-z0-9]/.test(v)) score++;
m.className = 'pw-meter ' + ['','weak','fair','good','good'][score];
});
// ── Save profile info ────────────────────────────────────────────────────────
document.getElementById('btnResetInfo').addEventListener('click',()=>{
document.getElementById('inpName').value = _origName;
document.getElementById('inpEmail').value = _origEmail;
});
document.getElementById('btnSaveInfo').addEventListener('click', async()=>{
const fullName = document.getElementById('inpName').value.trim();
const email = document.getElementById('inpEmail').value.trim();
if(!fullName){ toast('Full name is required','err'); return; }
const btn = document.getElementById('btnSaveInfo');
btn.disabled = true;
btn.innerHTML = '<span class="sp"></span>Saving…';
try{
const r = await fetch(`${BACKEND}/api/auth/profile`,{
method:'PUT',
headers:{Authorization:`Bearer ${tok}`,'Content-Type':'application/json'},
body: JSON.stringify({fullName, email}),
});
const d = await r.json();
if(!d.success) throw new Error(d.message||'Failed');
_origName = fullName;
_origEmail = email;
// Update hero live
document.getElementById('heroName').textContent = fullName;
document.getElementById('heroAvatar').textContent = initials(fullName);
toast('Profile updated successfully');
}catch(e){
toast('Error: ' + e.message,'err');
}finally{
btn.disabled = false;
btn.textContent = 'Save Changes';
}
});
// ── Change password ──────────────────────────────────────────────────────────
document.getElementById('btnResetPw').addEventListener('click',()=>{
document.getElementById('inpCurPw').value = '';
document.getElementById('inpNewPw').value = '';
document.getElementById('inpConPw').value = '';
document.getElementById('pwMeter').className = 'pw-meter';
});
document.getElementById('btnSavePw').addEventListener('click', async()=>{
const currentPassword = document.getElementById('inpCurPw').value;
const newPassword = document.getElementById('inpNewPw').value;
const confirmPassword = document.getElementById('inpConPw').value;
if(!currentPassword){ toast('Enter your current password','err'); return; }
if(!newPassword) { toast('Enter a new password','err'); return; }
if(newPassword.length < 6){ toast('New password must be at least 6 characters','err'); return; }
if(newPassword !== confirmPassword){ toast('Passwords do not match','err'); return; }
const btn = document.getElementById('btnSavePw');
btn.disabled = true;
btn.innerHTML = '<span class="sp"></span>Updating…';
try{
const r = await fetch(`${BACKEND}/api/auth/profile`,{
method:'PUT',
headers:{Authorization:`Bearer ${tok}`,'Content-Type':'application/json'},
body: JSON.stringify({currentPassword, newPassword}),
});
const d = await r.json();
if(!d.success) throw new Error(d.message||'Failed');
toast('Password updated successfully');
document.getElementById('btnResetPw').click();
}catch(e){
toast('Error: ' + e.message,'err');
}finally{
btn.disabled = false;
btn.textContent = 'Update Password';
}
});
// ── SAP Account (per-user, PER-COMPANY SAP logins) ──────────────────────────
// SAP B1 companies each have their own OUSR table, so the same SAP username
// can need a different password per company — one login is stored per company.
let _sapCompanies = [];
let _sapLogins = {};
async function loadSapCompanyOptions(){
const sel = document.getElementById('inpSapCompany');
try{ _sapCompanies = await window.fetchCompanyList(); }catch(_e){ _sapCompanies = []; }
if(!_sapCompanies.length){ sel.innerHTML = '<option value="">No companies found</option>'; return; }
sel.innerHTML = _sapCompanies.map(c=>`<option value="${esc(c.value)}">${esc(c.label||c.value)}</option>`).join('');
}
function renderSapList(){
const el = document.getElementById('sapList');
const companies = Object.keys(_sapLogins);
if(!companies.length){
el.innerHTML = '<div style="font-size:12px;padding:9px 12px;border-radius:8px;background:#fffbeb;border:1px solid #fde68a;color:#92400e">&#9888; No SAP login set yet for any company. Pick a company below and add your SAP User ID &amp; Password before you can post to SAP.</div>';
return;
}
el.innerHTML = companies.map(c=>{
const info = _sapLogins[c];
const label = (_sapCompanies.find(x=>x.value===c)||{}).label || c;
return `<div style="display:flex;align-items:center;gap:10px;font-size:12px;padding:9px 12px;border-radius:8px;background:#f0fdf4;border:1px solid #bbf7d0;color:#166534;margin-bottom:6px">
<span style="flex:1">&#10003; <b>${esc(label)}</b> — acting as <b>${esc(info.user)}</b></span>
<button type="button" class="btn-sap-rm" data-company="${esc(c)}" style="border:none;background:none;color:#166534;cursor:pointer;font-size:11px;font-weight:700;text-decoration:underline">Remove</button>
</div>`;
}).join('');
el.querySelectorAll('.btn-sap-rm').forEach(btn=>btn.onclick=async()=>{
if(!confirm(`Remove your SAP login for ${btn.dataset.company}?`))return;
try{
const r = await fetch(`${BACKEND}/api/auth/sap-credentials?company=${encodeURIComponent(btn.dataset.company)}`,{method:'DELETE',headers:{Authorization:`Bearer ${tok}`}});
const d = await r.json();
if(!d.success) throw new Error(d.message||'Failed');
if(d.token) sessionStorage.setItem('portal_token', d.token);
toast('SAP login removed');
loadSapStatus();
}catch(e){ toast('Error: ' + e.message,'err'); }
});
}
async function loadSapStatus(){
try{
const r = await fetch(`${BACKEND}/api/auth/sap-credentials`,{headers:{Authorization:`Bearer ${tok}`}});
const d = await r.json();
_sapLogins = d.sapLogins || {};
renderSapList();
}catch(e){ document.getElementById('sapList').textContent = 'Could not load SAP status: ' + e.message; }
}
document.getElementById('btnSaveSap').addEventListener('click', async()=>{
const company = document.getElementById('inpSapCompany').value;
const sapUser = document.getElementById('inpSapUser').value.trim();
const sapPassword = document.getElementById('inpSapPw').value;
if(!company){ toast('Pick a company first','err'); return; }
if(!sapUser || !sapPassword){ toast('Enter both SAP User ID and Password','err'); return; }
const btn = document.getElementById('btnSaveSap');
btn.disabled = true; btn.innerHTML = '<span class="sp"></span>Verifying…';
try{
const r = await fetch(`${BACKEND}/api/auth/sap-credentials`,{
method:'PUT',
headers:{Authorization:`Bearer ${tok}`,'Content-Type':'application/json'},
body: JSON.stringify({company, sapUser, sapPassword}),
});
const d = await r.json();
if(!d.success) throw new Error(d.message||'Failed');
// A fresh token carrying the updated SAP-login map is returned — swap it
// in so the change takes effect immediately (this session posts as the
// new SAP user for that company).
if(d.token){ sessionStorage.setItem('portal_token', d.token); }
document.getElementById('inpSapPw').value = '';
toast(`SAP login for ${company} verified and saved`);
loadSapStatus();
}catch(e){
toast('Error: ' + e.message,'err');
}finally{
btn.disabled = false; btn.textContent = 'Verify & Save';
}
});
// ── Signature ────────────────────────────────────────────────────────────────
let _sigData = null; // null = unchanged, '' = clear, else new base64
function showSig(dataUri){
const img=document.getElementById('sigPreview'), empty=document.getElementById('sigEmpty');
if(dataUri){ img.src=dataUri; img.style.display=''; empty.style.display='none'; }
else { img.src=''; img.style.display='none'; empty.style.display=''; }
}
async function loadSignature(){
try{
const r=await fetch(`${BACKEND}/api/auth/signature`,{headers:{Authorization:`Bearer ${tok}`}});
const d=await r.json(); if(d.success) showSig(d.signature||'');
}catch(_e){}
}
document.getElementById('sigFile').addEventListener('change', function(){
const f=this.files&&this.files[0]; if(!f) return;
const rd=new FileReader();
rd.onload=e=>{ const img=new Image(); img.onload=()=>{
const maxW=360, scale=Math.min(1, maxW/img.width);
const cv=document.createElement('canvas'); cv.width=Math.round(img.width*scale); cv.height=Math.round(img.height*scale);
cv.getContext('2d').drawImage(img,0,0,cv.width,cv.height);
_sigData=cv.toDataURL('image/png'); showSig(_sigData);
}; img.src=e.target.result; };
rd.readAsDataURL(f);
});
document.getElementById('btnClearSig').addEventListener('click', ()=>{ _sigData=''; showSig(''); document.getElementById('sigFile').value=''; });
document.getElementById('btnSaveSig').addEventListener('click', async()=>{
if(_sigData===null){ toast('Choose a signature image first','err'); return; }
const btn=document.getElementById('btnSaveSig'); btn.disabled=true; btn.innerHTML='<span class="sp"></span>Saving…';
try{
const r=await fetch(`${BACKEND}/api/auth/profile`,{method:'PUT',headers:{Authorization:`Bearer ${tok}`,'Content-Type':'application/json'},body:JSON.stringify({signature:_sigData})});
const d=await r.json(); if(!d.success) throw new Error(d.message||'Failed');
_sigData=null; toast('Signature saved');
}catch(e){ toast('Error: '+e.message,'err'); }
finally{ btn.disabled=false; btn.textContent='Save Signature'; }
});
loadProfile();
loadSapCompanyOptions().then(loadSapStatus);
loadSignature();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Project Approvals | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700}.hdr h1 span{color:#7c3aed}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn:disabled{opacity:.6;cursor:not-allowed}
.btn-primary{background:#7c3aed;color:#fff}.btn-primary:hover:not(:disabled){background:#6d28d9}
.btn-outline{background:#fff;color:#7c3aed;border:1.5px solid #7c3aed}.btn-outline:hover{background:#f5f3ff}
.btn-success{background:#10b981;color:#fff}.btn-success:hover:not(:disabled){background:#059669}
.btn-info{background:#3b82f6;color:#fff}.btn-info:hover{background:#2563eb}
.btn-warn{background:#f59e0b;color:#fff}.btn-warn:hover{background:#d97706}
.btn-sm{height:30px;padding:0 12px;font-size:11px}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden;margin-bottom:18px}
.card-header{padding:14px 20px;border-bottom:2px solid #ede9fe;background:#faf5ff;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}
.card-header h2{font-size:14px;font-weight:700;color:#7c3aed}
.card-body{padding:20px}
.tbl-wrap{overflow-x:auto}
table.list-tbl{border-collapse:collapse;min-width:100%;font-size:12px}
.list-tbl th{background:#f5f3ff;color:#4b5563;font-weight:700;padding:10px 12px;text-align:left;border-bottom:2px solid #ddd6fe;font-size:10px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
.list-tbl td{padding:10px 12px;border-bottom:1px solid #f0f2f4;vertical-align:middle}
.empty{text-align:center;padding:48px;color:#9ca3af;font-size:13px}
.status-badge{display:inline-block;padding:3px 10px;border-radius:10px;font-size:10px;font-weight:700;letter-spacing:.3px}
.s-pending{background:#fef3c7;color:#92400e}
.s-purchase{background:#fce7f3;color:#9d174d}
.s-engineering{background:#dbeafe;color:#1d4ed8}
.s-qa{background:#ecfdf5;color:#065f46}
.s-qc{background:#fdf4ff;color:#7e22ce}
.s-legal{background:#fff7ed;color:#c2410c}
.s-owner{background:#e0e7ff;color:#4338ca}
.s-plant{background:#f0fdf4;color:#14532d}
.s-approved{background:#d1fae5;color:#065f46}
.s-rejected{background:#fee2e2;color:#991b1b}
/* quick approve panel */
.approve-panel{background:#fefce8;border:2px solid #fbbf24;border-radius:10px;padding:14px 16px;margin-top:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.approve-panel label{font-size:12px;font-weight:700;color:#92400e}
.approve-panel input[type=date]{border:1.5px solid #fbbf24;border-radius:8px;padding:6px 10px;font-size:12px;font-family:inherit;outline:none}
.approve-panel input[type=text]{border:1.5px solid #fbbf24;border-radius:8px;padding:6px 10px;font-size:12px;font-family:inherit;outline:none;width:180px}
.row-expand{background:#fefce8}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#10b981}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>Project</span> Approvals</h1>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select id="filterStatus" style="height:34px;padding:0 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:12px;font-family:inherit;outline:none" onchange="loadApprovals()">
<option value="">All In-Progress</option>
<option value="PENDING">Pending (Purchase)</option>
<option value="PURCHASE">Awaiting Engineering</option>
<option value="ENGINEERING">Awaiting QA</option>
<option value="QA_DONE">Awaiting QC</option>
<option value="QC_DONE">Awaiting Legal</option>
<option value="LEGAL">Awaiting Owner</option>
<option value="OWNER">Awaiting Plant Mgr</option>
<option value="PLANT">Awaiting Finance</option>
</select>
<button class="btn btn-outline" onclick="loadApprovals()">Refresh</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>Pending Approvals</h2>
<span id="countBadge" style="font-size:12px;color:#6b7280"></span>
</div>
<div class="tbl-wrap">
<table class="list-tbl">
<thead><tr>
<th>Code</th>
<th>Project Name</th>
<th>Current Step</th>
<th>Approver</th>
<th>Est. Cost (&#8377;)</th>
<th>Submitted By</th>
<th>Actions</th>
</tr></thead>
<tbody id="appBody"><tr><td colspan="7" class="empty">Loading&#8230;</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
const tok=sessionStorage.getItem('portal_token');
if(!tok){window.location.href='/';return;}
let user=null;
try{user=JSON.parse(sessionStorage.getItem('portal_user'));}catch(_){}
const isFullAdmin=user?.role==='admin'||user?.role==='sap_adder';
const userDept=user?.approvalDept||null;
// Returns true if this user can approve the given step key
function canApproveStep(stepKey){return isFullAdmin||(user?.role==='project_approver'&&userDept===stepKey);}
const hdrs={'Authorization':'Bearer '+tok,'Content-Type':'application/json'};
const STEPS=[
{key:'purchase', from:'PENDING', to:'PURCHASE', label:'Purchase', person:'Mr. GS Negi / Mr. Rahul'},
{key:'engineering', from:'PURCHASE', to:'ENGINEERING', label:'Engineering', person:'Mr. Piyush / Ms. Piya'},
{key:'qa', from:'ENGINEERING', to:'QA_DONE', label:'QA', person:'Satish Shukla'},
{key:'qc', from:'QA_DONE', to:'QC_DONE', label:'QC', person:'Manoj Srivastav / Divya'},
{key:'legal', from:'QC_DONE', to:'LEGAL', label:'Legal', person:'Mr. Jubin Ahuja'},
{key:'owner', from:'LEGAL', to:'OWNER', label:'Project Owner', person:'Mr. Piyush'},
{key:'plant', from:'OWNER', to:'PLANT', label:'Plant Manager', person:'CV Singh', hasName:true},
{key:'finance', from:'PLANT', to:'APPROVED', label:'Finance & Accounts', person:'Mrs. Babita / Mrs. Rittu'},
];
// active = not DRAFT/APPROVED/REJECTED
const ACTIVE_STATUSES=STEPS.map(s=>s.from);
async function loadApprovals(){
const filterSt=document.getElementById('filterStatus').value;
const statuses=filterSt?[filterSt]:ACTIVE_STATUSES;
const results=await Promise.all(statuses.map(s=>
fetch('/api/projects?status='+s,{headers:hdrs}).then(r=>r.json())
));
const rows=results.flatMap(j=>j.success?j.data:[]);
// deduplicate by id
const seen=new Set();
const unique=rows.filter(r=>{if(seen.has(r.id))return false;seen.add(r.id);return true;});
document.getElementById('countBadge').textContent=unique.length+' project'+(unique.length===1?'':'s');
const tbody=document.getElementById('appBody');
if(!unique.length){tbody.innerHTML='<tr><td colspan="7" class="empty">No projects pending approval</td></tr>';return;}
tbody.innerHTML=unique.map(p=>{
const step=STEPS.find(s=>s.from===p.status);
return`<tr id="row-${p.id}">
<td><b style="color:#7c3aed">${esc(p.project_code||'—')}</b></td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis">${esc(p.project_name)}</td>
<td>${step?`<span class="status-badge ${stCls(p.status)}">${esc(step.label)}</span>`:'—'}</td>
<td style="font-size:12px;color:#374151">${step?esc(step.person):'—'}</td>
<td style="text-align:right">&#8377; ${fmtNum(p.total_est_cost)}</td>
<td>${esc(p.submitted_by||'')}</td>
<td style="white-space:nowrap">
<button class="btn btn-info btn-sm" onclick="viewProject(${p.id})">View</button>
${canApproveStep(step?.key)?`<button class="btn btn-success btn-sm" onclick="toggleApprove(${p.id},'${step.key}',${step.hasName||false})">&#10003; Approve</button>`:''}
</td>
</tr>
<tr id="panel-${p.id}" style="display:none"><td colspan="7" style="padding:0 12px 12px">
<div class="approve-panel" id="apanel-${p.id}">
<label>Approval Date:</label>
<input type="date" id="dt-${p.id}" value="${today()}">
${step&&step.hasName?`<input type="text" id="nm-${p.id}" placeholder="Plant Manager name" value="CV Singh">`:'' }
<button class="btn btn-success btn-sm" onclick="doApprove(${p.id},'${step?.key}',true)">&#10003; Approve</button>
<button class="btn btn-outline btn-sm" style="border-color:#7c3aed;color:#7c3aed" onclick="doApprove(${p.id},'${step?.key}',false)">Skip / N/A</button>
<button class="btn btn-warn btn-sm" onclick="toggleApprove(${p.id})">Cancel</button>
</div>
</td></tr>`;
}).join('');
}
window.loadApprovals=loadApprovals;
loadApprovals();
function toggleApprove(id){
const panel=document.getElementById('panel-'+id);
panel.style.display=panel.style.display==='none'?'table-row':'none';
}
window.toggleApprove=toggleApprove;
function viewProject(id){window.location.href='/project-form?id='+id;}
window.viewProject=viewProject;
async function doApprove(id,stepKey,withDate){
const dt=withDate?document.getElementById('dt-'+id)?.value:null;
if(withDate&&!dt){showToast('Please enter the approval date','error');return;}
const nmEl=document.getElementById('nm-'+id);
const name=nmEl?nmEl.value:null;
const r=await fetch('/api/projects/'+id+'/approve',{method:'POST',headers:hdrs,body:JSON.stringify({step:stepKey,applicable:withDate,date:dt,name})});
const j=await r.json();
if(j.success){showToast(withDate?'Approved!':'Marked Not Applicable','success');setTimeout(loadApprovals,600);}
else showToast(j.message,'error');
}
window.doApprove=doApprove;
function stCls(s){return{PENDING:'s-pending',PURCHASE:'s-purchase',ENGINEERING:'s-engineering',QA_DONE:'s-qa',QC_DONE:'s-qc',LEGAL:'s-legal',OWNER:'s-owner',PLANT:'s-plant'}[s]||'s-pending';}
function fmtNum(v,dec=2){return Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:dec,maximumFractionDigits:dec});}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function today(){return new Date().toISOString().slice(0,10);}
function showToast(msg,type='info'){const t=document.getElementById('toast');t.textContent=msg;t.className='toast '+type+' show';setTimeout(()=>t.classList.remove('show'),3500);}
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+910
View File
@@ -0,0 +1,910 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Purchase Order — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#f5f3ef;--surface:#fff;--surface2:#f9f8f5;
--border:#e2ddd5;--border2:#ccc7bb;
--accent:#1a1a1a;--accent2:#c8793a;--accent3:#2d6a4f;
--text:#1a1a1a;--text-muted:#7a746c;--text-label:#4a4540;
--danger:#c0392b;--success:#2d6a4f;--warning:#c8793a;
--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.04);
--shadow-lg:0 8px 40px rgba(0,0,0,.14);--r:8px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);font-family:'DM Sans',sans-serif;color:var(--text);min-height:100vh;font-size:13px;}
/* ── TOP BAR ── */
.topbar{background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:50px;position:sticky;top:0;z-index:300;}
.tb-brand{display:flex;align-items:center;gap:10px;}
.tb-mark{width:28px;height:28px;background:var(--accent2);border-radius:5px;display:flex;align-items:center;justify-content:center;font-family:'Syne',sans-serif;font-weight:800;font-size:13px;color:#fff;letter-spacing:-.5px;}
.tb-title{font-family:'Syne',sans-serif;font-weight:700;font-size:13px;}
.tb-sub{font-family:'DM Mono',monospace;font-size:9px;color:rgba(255,255,255,.4);margin-top:1px;}
.tb-actions{display:flex;gap:6px;align-items:center;}
.user-chip{font-family:'DM Mono',monospace;font-size:9px;color:rgba(255,255,255,.4);margin-left:4px;}
/* ── BUTTONS ── */
.btn{display:inline-flex;align-items:center;gap:5px;padding:6px 13px;border-radius:5px;font-size:11px;font-weight:600;cursor:pointer;border:none;transition:all .15s;font-family:'DM Sans',sans-serif;white-space:nowrap;}
.btn-primary{background:var(--accent2);color:#fff;}.btn-primary:hover{background:#b5692d;}
.btn-success{background:var(--accent3);color:#fff;}.btn-success:hover{background:#245a42;}
.btn-ghost{background:transparent;color:rgba(255,255,255,.7);border:1px solid rgba(255,255,255,.2);}.btn-ghost:hover{color:#fff;border-color:rgba(255,255,255,.5);}
.btn-outline{background:transparent;color:var(--text-label);border:1px solid var(--border2);}.btn-outline:hover{border-color:var(--accent2);color:var(--accent2);}
.btn-sm{padding:4px 10px;font-size:10px;}
.btn:disabled{opacity:.5;cursor:not-allowed;}
/* ── BADGES ── */
.badge{padding:3px 8px;border-radius:20px;font-size:9px;font-weight:700;font-family:'DM Mono',monospace;letter-spacing:.07em;text-transform:uppercase;}
.badge-draft{background:rgba(255,255,255,.1);color:rgba(255,255,255,.6);border:1px solid rgba(255,255,255,.15);}
.badge-open{background:#edf7f1;color:var(--success);border:1px solid #a8d5bc;}
/* ── PAGE TABS ── */
.page-tabs{display:flex;gap:0;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:50px;z-index:200;padding:0 18px;}
.ptab{padding:10px 18px;font-size:11px;font-weight:600;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .12s;user-select:none;font-family:'Syne',sans-serif;letter-spacing:.04em;}
.ptab.active{color:var(--accent2);border-bottom-color:var(--accent2);}
.ptab:hover:not(.active){color:var(--text);}
.ptab-pane{display:none;}
.ptab-pane.active{display:block;}
/* ── MAIN LAYOUT ── */
.main{max-width:100%;padding:14px 18px 80px;}
/* ── FORM ELEMENTS ── */
.field{display:flex;flex-direction:column;gap:3px;}
.field label{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--text-muted);}
.req{color:var(--accent2);}
input,select,textarea{background:var(--surface);border:1.5px solid var(--border);border-radius:5px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:12px;padding:5px 8px;width:100%;outline:none;transition:border-color .15s;}
input:focus,select:focus,textarea:focus{border-color:var(--accent2);box-shadow:0 0 0 2px rgba(200,121,58,.1);}
input[readonly]{background:var(--surface2);color:var(--text-muted);cursor:default;}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237a746c'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 7px center;padding-right:22px;cursor:pointer;appearance:none;}
textarea{resize:vertical;min-height:56px;}
/* ── GRIDS ── */
.g2{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}
.g4{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;}
.s2{grid-column:span 2;}
/* ── HEADER CARD ── */
.hcard{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:14px 16px;margin-bottom:10px;position:relative;z-index:10;}
.hcard-row{display:grid;grid-template-columns:1fr auto;gap:16px;align-items:start;margin-bottom:10px;}
/* ── META STRIP ── */
.meta-strip{display:grid;grid-template-columns:repeat(3,auto);gap:6px 18px;text-align:right;align-items:start;}
.mi label{display:block;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--text-muted);margin-bottom:2px;}
.mi span.mv{font-family:'DM Mono',monospace;font-size:11px;font-weight:500;color:var(--accent2);}
.mi input[type="date"]{font-family:'DM Mono',monospace;font-size:11px;color:var(--accent2);border:none;background:transparent;padding:0;width:108px;}
.mi input[type="date"]:focus{outline:none;border-bottom:1px solid var(--accent2);box-shadow:none;}
/* ── LINES CARD ── */
.tcard{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);overflow:visible;}
.tabs{display:flex;border-bottom:1px solid var(--border);background:var(--surface2);}
.tab{padding:8px 16px;font-size:11px;font-weight:600;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;transition:all .12s;user-select:none;}
.tab.active{color:var(--accent2);border-bottom-color:var(--accent2);}
.tab-pane{display:none;padding:12px 14px;}
.tab-pane.active{display:block;}
/* ── TABLE ── */
.lines-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;}
.stitle{font-family:'Syne',sans-serif;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--text-label);}
.lines-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:5px;}
table{width:100%;border-collapse:collapse;font-size:11px;}
thead tr{background:var(--surface2);}
th{padding:6px 6px;text-align:left;font-size:9px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.07em;white-space:nowrap;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:5;background:var(--surface2);}
td{padding:0;border-bottom:1px solid var(--border);vertical-align:middle;position:relative;}
tbody tr:last-child td{border-bottom:none;}
tbody tr:hover td{background:#fdfaf6;}
td input,td select{background:transparent;border:none;border-radius:0;padding:5px 6px;font-size:11px;color:var(--text);width:100%;font-family:'DM Sans',sans-serif;height:28px;}
td input:focus,td select:focus{background:rgba(200,121,58,.05);box-shadow:inset 0 0 0 1.5px var(--accent2);outline:none;}
td select{appearance:none;background-image:none;cursor:pointer;}
td input[readonly]{background:var(--surface2);color:var(--text-muted);}
.nc{text-align:right;}
.nc input{text-align:right;font-family:'DM Mono',monospace;}
.li{display:block;width:26px;text-align:center;font-family:'DM Mono',monospace;font-size:10px;color:var(--text-muted);padding:5px 2px;}
.xbtn{display:block;width:24px;text-align:center;color:var(--text-muted);cursor:pointer;padding:4px 2px;font-size:12px;transition:color .12s;}
.xbtn:hover{color:var(--danger);}
/* ── TOTALS ── */
.totals-wrap{display:flex;justify-content:flex-end;padding:10px 12px;border-top:1px solid var(--border);background:var(--surface2);}
.tg{display:grid;grid-template-columns:180px 140px;gap:3px 8px;text-align:right;align-items:center;}
.tl{font-size:11px;color:var(--text-muted);}
.tv{font-family:'DM Mono',monospace;font-size:11px;color:var(--text);}
.tt-row .tl{font-weight:700;font-size:12px;color:var(--text);}
.tt-row .tv{font-size:14px;font-weight:600;color:var(--accent2);}
/* ── AUTOCOMPLETE ── */
.acw{position:relative;width:100%;}
.acd-fixed{position:fixed;min-width:240px;background:var(--surface);border:1.5px solid var(--accent2);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.18);z-index:99999;max-height:240px;overflow-y:auto;display:none;}
.acd-fixed.open{display:block;}
.aci{padding:7px 10px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:8px;border-bottom:1px solid var(--border);transition:background .08s;}
.aci:last-child{border-bottom:none;}
.aci:hover,.aci.hi{background:#fef6ed;}
.ac-name{font-size:11px;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ac-code{font-family:'DM Mono',monospace;font-size:10px;color:var(--accent2);flex-shrink:0;}
.ac-msg{padding:8px 10px;color:var(--text-muted);font-size:11px;font-style:italic;}
.spin{display:inline-block;width:11px;height:11px;border:2px solid var(--border);border-top-color:var(--accent2);border-radius:50%;animation:sp .6s linear infinite;flex-shrink:0;}
@keyframes sp{to{transform:rotate(360deg)}}
/* ── OVERLAY & TOAST ── */
.overlay{display:none;position:fixed;inset:0;background:rgba(245,243,239,.85);backdrop-filter:blur(3px);z-index:9000;align-items:center;justify-content:center;flex-direction:column;gap:10px;}
.overlay.show{display:flex;}
.spinner{width:36px;height:36px;border:3px solid var(--border2);border-top-color:var(--accent2);border-radius:50%;animation:sp .7s linear infinite;}
.ta{position:fixed;top:60px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none;}
.toast{padding:9px 13px;border-radius:6px;font-size:11px;font-weight:500;max-width:360px;box-shadow:var(--shadow-lg);display:flex;align-items:center;gap:7px;animation:tin .18s ease;pointer-events:all;}
@keyframes tin{from{opacity:0;transform:translateX(14px)}to{opacity:1}}
.t-ok{background:#edf7f1;color:var(--success);border:1px solid #a8d5bc;}
.t-err{background:#fdf0ee;color:var(--danger);border:1px solid #e8b0aa;}
.t-info{background:#fef6ed;color:var(--accent2);border:1px solid #e8c9a8;}
/* ── VIEW TAB TABLE ── */
.vtable-wrap{border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-top:12px;}
.vtable-wrap table{font-size:12px;}
.vtable-wrap th{font-size:9px;padding:7px 10px;}
.vtable-wrap td{padding:6px 10px;border-bottom:1px solid var(--border);}
.vtable-wrap tbody tr:hover td{background:#fdfaf6;cursor:default;}
.doc-status{padding:2px 7px;border-radius:10px;font-size:9px;font-weight:700;font-family:'DM Mono',monospace;text-transform:uppercase;}
.ds-open{background:#edf7f1;color:var(--success);}
.ds-closed{background:var(--surface2);color:var(--text-muted);}
.ds-draft{background:#fff8ed;color:var(--accent2);}
.view-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;}
.empty-state{text-align:center;padding:40px 20px;color:var(--text-muted);font-size:11px;}
hr.dv{border:none;border-top:1px solid var(--border);margin:10px 0;}
.resp{display:none;background:#1a1a1a;border-radius:6px;padding:12px;font-family:'DM Mono',monospace;font-size:10px;color:#a8d5bc;white-space:pre-wrap;word-break:break-all;max-height:280px;overflow-y:auto;margin-top:12px;line-height:1.7;}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<!-- LOADING OVERLAY -->
<div class="overlay" id="overlay">
<div class="spinner"></div>
<span style="font-size:11px;color:var(--text-muted);font-family:'DM Mono',monospace;">Posting to SAP B1…</span>
</div>
<!-- TOAST AREA -->
<div class="ta" id="ta"></div>
<!-- FIXED AUTOCOMPLETE PORTAL -->
<div class="acd-fixed" id="acPortal"></div>
<!-- TOP BAR -->
<div class="topbar">
<div class="tb-brand">
<div class="tb-mark">PO</div>
<div>
<div class="tb-title">Purchase Order</div>
<div class="tb-sub">Purchase Orders · SAP B1</div>
</div>
</div>
<div class="tb-actions">
<select id="companySel" style="background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.15);color:#fff;border-radius:5px;padding:4px 22px 4px 8px;font-size:11px;font-family:'DM Mono',monospace;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23ffffff80'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 6px center;appearance:none;cursor:pointer;" onchange="onCompanyChange()">
</select>
<span class="badge badge-draft" id="statusBadge">● DRAFT</span>
<button class="btn btn-ghost btn-sm" onclick="resetForm()">↺ Reset</button>
<button class="btn btn-primary btn-sm" id="postBtn" onclick="submitPO()">✓ Post PO</button>
<span class="user-chip" id="userChip"></span>
</div>
</div>
<!-- PAGE TABS -->
<div class="page-tabs">
<div class="ptab active" onclick="switchPage(this,'pgCreate')">Create Order</div>
<div class="ptab" onclick="switchPage(this,'pgView')">View Orders</div>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- CREATE TAB -->
<!-- ═══════════════════════════════════════════ -->
<div class="ptab-pane active" id="pgCreate">
<div class="main">
<!-- HEADER CARD -->
<div class="hcard">
<div class="hcard-row">
<!-- Left: Vendor + fields -->
<div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;margin-bottom:8px;">
<!-- Vendor Autocomplete -->
<div class="field">
<label>Vendor <span class="req">*</span></label>
<div class="acw">
<input type="text" id="vendorSearch" placeholder="Search vendor code or name…" autocomplete="off" oninput="vendorAc(this.value)" onfocus="if(this.value.length>1)vendorAc(this.value)">
</div>
<input type="hidden" id="cardCode">
<input type="hidden" id="cardName">
</div>
<div class="field">
<label>Vendor Ref. No.</label>
<input type="text" id="numAtCard" placeholder="e.g. INV-2024-001">
</div>
<div class="field">
<label>Delivery Date <span class="req">*</span></label>
<input type="date" id="docDueDate">
</div>
</div>
<hr class="dv" style="margin:8px 0;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:480px;">
<div class="field">
<label>Remarks</label>
<textarea id="comments" rows="2" placeholder="Order remarks / notes…"></textarea>
</div>
<div class="field">
<label>Branch</label>
<select id="bplId" onchange="onBranchChange()"><option value="">— Select —</option></select>
</div>
</div>
</div>
<!-- Right: Meta strip -->
<div class="meta-strip">
<div class="mi">
<label>Doc No.</label>
<span class="mv" id="docNumDisp">AUTO</span>
</div>
<div class="mi">
<label>Posting Date</label>
<input type="date" id="docDate">
</div>
<div class="mi">
<label>Document Date</label>
<input type="date" id="taxDate">
</div>
</div>
</div>
</div>
<!-- LINES CARD -->
<div class="tcard">
<div class="tabs">
<div class="tab active" onclick="switchTab(this,'tcContents')">Contents</div>
</div>
<div class="tab-pane active" id="tcContents">
<div class="lines-bar">
<span class="stitle">Document Lines</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-outline btn-sm" onclick="addLine()">+ Add Line</button>
<button class="btn btn-outline btn-sm" onclick="clearLines()">Clear</button>
</div>
</div>
<div class="lines-wrap">
<table id="linesTable">
<thead>
<tr>
<th style="width:28px;">#</th>
<th style="min-width:110px;">Item Code <span style="color:var(--accent2);">*</span></th>
<th style="min-width:160px;">Description</th>
<th style="min-width:60px;" class="nc">Qty <span style="color:var(--accent2);">*</span></th>
<th style="min-width:55px;">Unit</th>
<th style="min-width:100px;" class="nc">Unit Price <span style="color:var(--accent2);">*</span></th>
<th style="min-width:60px;" class="nc">Disc %</th>
<th style="min-width:120px;">Tax Code</th>
<th style="min-width:50px;" class="nc">Tax %</th>
<th style="min-width:100px;" class="nc">Line Total</th>
<th style="min-width:80px;">Warehouse</th>
<th style="width:26px;"></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<!-- TOTALS -->
<div class="totals-wrap">
<div class="tg">
<span class="tl">Before Discount</span>
<span class="tv" id="totPre">0.00</span>
<span class="tl">Discount</span>
<span class="tv" id="totDisc">0.00</span>
<span class="tl">Tax</span>
<span class="tv" id="totTax">0.00</span>
<span class="tl tt-row">Total Payment Due</span>
<span class="tv tt-row" id="totDoc">0.00</span>
</div>
</div>
</div>
</div>
<pre class="resp" id="respPanel"></pre>
</div>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- VIEW ORDERS TAB -->
<!-- ═══════════════════════════════════════════ -->
<div class="ptab-pane" id="pgView">
<div class="main">
<div class="hcard">
<div class="view-bar">
<div>
<span class="stitle">Purchase Orders</span>
<span style="font-size:10px;color:var(--text-muted);margin-left:8px;font-family:'DM Mono',monospace;" id="viewCount"></span>
</div>
<button class="btn btn-outline btn-sm" onclick="loadOrders()">↻ Refresh</button>
</div>
<div id="viewTableWrap">
<div class="empty-state">Click <strong>Refresh</strong> or switch to this tab to load orders.</div>
</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════
// AUTH & CONFIG
// ═══════════════════════════════════════════
const API = '';
const PO_ENDPOINT = '/api/sap/purchase-order';
let TOKEN = sessionStorage.getItem('portal_token') || '';
if (!TOKEN) { window.location.href = '/'; }
let lineSeq = 0;
const _timers = {};
let _taxCodes = [];
let _warehouses = [];
let _branches = [];
let _portalTarget = null;
let _ordersLoaded = false;
const ah = () => TOKEN ? { 'Authorization': `Bearer ${TOKEN}` } : {};
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const fmt = n => parseFloat(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const company = () => document.getElementById('companySel').value;
// ═══════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast t-${type}`;
const icon = { ok: '✓', err: '✕', info: 'ℹ' }[type] || 'ℹ';
el.innerHTML = `<span>${icon}</span><span>${esc(String(msg))}</span>`;
document.getElementById('ta').appendChild(el);
setTimeout(() => el.remove(), 5000);
}
// ═══════════════════════════════════════════
// PAGE / INNER TAB SWITCHING
// ═══════════════════════════════════════════
function switchPage(el, pid) {
document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ptab-pane').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById(pid).classList.add('active');
if (pid === 'pgView' && !_ordersLoaded) loadOrders();
}
function switchTab(el, pid) {
el.closest('.tcard').querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
el.closest('.tcard').querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById(pid).classList.add('active');
}
function onCompanyChange() {
loadBranches();
loadWarehouses();
loadTaxCodes();
if (_ordersLoaded) { _ordersLoaded = false; }
}
// ═══════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════
async function populateCompanySelect(){
try{
const list=await window.fetchCompanyList();
if(list.length){
const sel=document.getElementById('companySel');
const cur=sel.value;
sel.innerHTML=list.map(c=>`<option value="${esc(c.value)}">${esc(c.label)}</option>`).join('');
if(list.some(c=>c.value===cur))sel.value=cur;
}
}catch(e){}
}
async function initPage() {
await populateCompanySelect();
const today = new Date().toISOString().slice(0, 10);
document.getElementById('docDate').value = today;
document.getElementById('taxDate').value = today;
document.getElementById('docDueDate').value = today;
// Try to get username
try {
const r = await fetch(`${API}/api/auth/me`, { headers: ah() });
const d = await r.json();
if (d.success) document.getElementById('userChip').textContent = d.user?.name || d.user?.username || '';
} catch(e) {}
await Promise.all([loadBranches(), loadWarehouses(), loadTaxCodes()]);
addLine();
}
// ═══════════════════════════════════════════
// LOOKUPS
// ═══════════════════════════════════════════
async function loadTaxCodes() {
const fallback = [
{ Code: 'CG+SG@5', Rate: 5 },
{ Code: 'CG+SG@12', Rate: 12 },
{ Code: 'CG+SG@18', Rate: 18 },
{ Code: 'CG+SG@28', Rate: 28 },
{ Code: 'IGST@5', Rate: 5 },
{ Code: 'IGST@18', Rate: 18 },
{ Code: 'EXEMPT', Rate: 0 },
];
try {
const res = await fetch(`${API}/api/sap/lookup/tax-codes?company=${company()}`, { headers: ah() });
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
_taxCodes = (d.data && d.data.length) ? d.data : fallback;
} catch(e) {
console.warn('[PO] tax-codes fallback:', e.message);
_taxCodes = fallback;
}
document.querySelectorAll('.taxcode-sel').forEach(s => fillTaxSel(s));
}
async function loadWarehouses() {
try {
const res = await fetch(`${API}/api/sap/lookup/warehouses?company=${company()}`, { headers: ah() });
const d = await res.json();
_warehouses = d.data || [];
document.querySelectorAll('.whs-sel').forEach(s => fillWhsSel(s));
} catch(e) { console.warn('[PO] warehouses:', e.message); }
}
async function loadBranches() {
try {
const res = await fetch(`${API}/api/sap/lookup/branches?company=${company()}`, { headers: ah() });
const d = await res.json();
_branches = d.data || [];
const sel = document.getElementById('bplId');
sel.innerHTML = '<option value="">— Select Branch —</option>' +
_branches.map(b => `<option value="${esc(b.BPLId)}">${esc(b.BPLName)}</option>`).join('');
} catch(e) { console.warn('[PO] branches:', e.message); }
}
function onBranchChange() {}
function fillTaxSel(sel, defaultCode) {
const prev = defaultCode || sel.value;
sel.innerHTML = '<option value="">— Tax —</option>' +
_taxCodes.map(t =>
`<option value="${esc(t.Code)}" data-rate="${t.Rate || 0}">${esc(t.Code)}</option>`
).join('');
if (prev) {
const opt = sel.querySelector(`option[value="${CSS.escape(prev)}"]`);
if (opt) sel.value = prev;
}
sel.onchange = () => {
const opt = sel.options[sel.selectedIndex];
const rate = parseFloat(opt?.dataset?.rate || 0);
const row = sel.closest('tr');
if (row) {
const tp = row.querySelector('.taxpct-inp');
if (tp) { tp.value = rate; recalc(); }
}
};
}
function fillWhsSel(sel, defaultCode) {
const prev = defaultCode || sel.value;
sel.innerHTML = '<option value="">— WH —</option>' +
_warehouses.map(w =>
`<option value="${esc(w.WarehouseCode)}">${esc(w.WarehouseCode)} – ${esc(w.WarehouseName)}</option>`
).join('');
if (prev) {
const opt = sel.querySelector(`option[value="${CSS.escape(prev)}"]`);
if (opt) sel.value = prev;
}
}
// ═══════════════════════════════════════════
// AUTOCOMPLETE ENGINE
// ═══════════════════════════════════════════
const portal = document.getElementById('acPortal');
function positionPortal(inputEl) {
const r = inputEl.getBoundingClientRect();
portal.style.left = r.left + 'px';
portal.style.top = (r.bottom + 2) + 'px';
portal.style.width = Math.max(260, r.width) + 'px';
}
function openPortal(inputEl, items, onSelect) {
_portalTarget = { inputEl, onSelect };
portal.innerHTML = '';
if (!items.length) {
portal.innerHTML = '<div class="ac-msg">No results found</div>';
} else {
items.forEach(item => {
const div = document.createElement('div');
div.className = 'aci';
div.innerHTML = `<span class="ac-name">${esc(item.label)}</span><span class="ac-code">${esc(item.code)}</span>`;
div.addEventListener('mousedown', e => { e.preventDefault(); onSelect(item); closePortal(); });
portal.appendChild(div);
});
}
positionPortal(inputEl);
portal.classList.add('open');
}
function openPortalLoading(inputEl) {
portal.innerHTML = '<div class="aci"><div class="spin"></div><span class="ac-name" style="margin-left:6px;">Searching…</span></div>';
positionPortal(inputEl);
portal.classList.add('open');
}
function openPortalMsg(inputEl, msg) {
portal.innerHTML = `<div class="ac-msg">${esc(msg)}</div>`;
positionPortal(inputEl);
portal.classList.add('open');
}
function closePortal() {
portal.classList.remove('open');
portal.innerHTML = '';
_portalTarget = null;
}
document.addEventListener('mousedown', e => {
if (!portal.contains(e.target) && !e.target.closest('.acw')) closePortal();
});
window.addEventListener('scroll', () => {
if (_portalTarget && portal.classList.contains('open')) positionPortal(_portalTarget.inputEl);
}, true);
// ── Vendor Autocomplete ───────────────────
function vendorAc(q) {
clearTimeout(_timers.vendor);
if (!q || q.length < 2) { closePortal(); return; }
openPortalLoading(document.getElementById('vendorSearch'));
_timers.vendor = setTimeout(async () => {
try {
const res = await fetch(`${API}/api/sap/lookup/vendors?q=${encodeURIComponent(q)}&company=${company()}`, { headers: ah() });
const data = await res.json();
const list = (data.data || []).map(v => ({ label: v.CardName, code: v.CardCode, raw: v }));
if (!list.length) { openPortalMsg(document.getElementById('vendorSearch'), 'No vendors found'); return; }
openPortal(document.getElementById('vendorSearch'), list, item => {
document.getElementById('cardCode').value = item.raw.CardCode;
document.getElementById('cardName').value = item.raw.CardName;
document.getElementById('vendorSearch').value = `${item.raw.CardCode} — ${item.raw.CardName}`;
toast(`Vendor: ${item.raw.CardName}`, 'ok');
});
} catch(err) { openPortalMsg(document.getElementById('vendorSearch'), 'Error: ' + err.message); }
}, 300);
}
// ── Item Autocomplete ─────────────────────
function itemAcForInput(inputEl, onSelect) {
const q = inputEl.value;
clearTimeout(_timers['item_' + inputEl.id]);
if (!q || q.length < 2) { closePortal(); return; }
openPortalLoading(inputEl);
_timers['item_' + inputEl.id] = setTimeout(async () => {
try {
const res = await fetch(`${API}/api/sap/lookup/items?q=${encodeURIComponent(q)}&company=${company()}`, { headers: ah() });
const data = await res.json();
const list = (data.data || []).map(i => ({ label: i.ItemName, code: i.ItemCode, raw: i }));
if (!list.length) { openPortalMsg(inputEl, 'No items found'); return; }
openPortal(inputEl, list, item => onSelect(item.raw));
} catch(err) { openPortalMsg(inputEl, 'Error: ' + err.message); }
}, 300);
}
// ═══════════════════════════════════════════
// LINE MANAGEMENT
// ═══════════════════════════════════════════
function addLine(d = {}) {
lineSeq++;
const lid = lineSeq;
const tr = document.createElement('tr');
tr.id = `line-${lid}`;
const rowNum = document.querySelectorAll('#tbody tr').length + 1;
tr.innerHTML = `
<td><span class="li">${rowNum}</span></td>
<td style="min-width:110px;">
<input class="icode-inp" type="text" id="icode-${lid}" placeholder="Item Code"
value="${esc(d.itemCode || '')}"
oninput="itemAcForInput(this, function(i){selItem(${lid},i)})"
onfocus="if(this.value.length>=2)itemAcForInput(this,function(i){selItem(${lid},i)})">
</td>
<td style="min-width:160px;"><input class="desc-inp" type="text" id="desc-${lid}" placeholder="Description" value="${esc(d.desc || '')}"></td>
<td class="nc" style="min-width:60px;"><input class="qty-inp" type="number" id="qty-${lid}" value="${d.qty || 1}" min="0" step="0.001" oninput="recalc()"></td>
<td style="min-width:55px;"><input class="uom-inp" type="text" id="uom-${lid}" value="${esc(d.uom || '')}" placeholder="UOM" readonly style="text-align:center;font-size:10px;color:var(--text-muted);background:var(--surface2);"></td>
<td class="nc" style="min-width:100px;"><input class="price-inp" type="number" id="price-${lid}" value="${d.price || 0}" min="0" step="0.01" oninput="recalc()"></td>
<td class="nc" style="min-width:60px;"><input class="disc-inp" type="number" id="disc-${lid}" value="${d.disc || 0}" min="0" max="100" step="0.01" oninput="recalc()"></td>
<td style="min-width:120px;"><select class="taxcode-sel" id="tax-${lid}"></select></td>
<td class="nc" style="min-width:50px;"><input class="taxpct-inp" type="number" id="tp-${lid}" value="${d.taxPct || 0}" min="0" max="100" oninput="recalc()" style="color:var(--text-muted);font-size:10px;"></td>
<td class="nc" style="min-width:100px;"><input class="linetot-inp" type="text" id="lt-${lid}" readonly value="0.00" style="color:var(--accent2);font-weight:600;font-family:'DM Mono',monospace;"></td>
<td style="min-width:80px;"><select class="whs-sel" id="whs-${lid}"></select></td>
<td><span class="xbtn" onclick="removeLine(${lid})">✕</span></td>
`;
document.getElementById('tbody').appendChild(tr);
const taxSel = tr.querySelector('.taxcode-sel');
if (taxSel) fillTaxSel(taxSel, d.taxCode);
const whsSel = tr.querySelector('.whs-sel');
if (whsSel) fillWhsSel(whsSel, d.whs);
renumber();
recalc();
}
function selItem(lid, item) {
const icode = document.getElementById(`icode-${lid}`);
const desc = document.getElementById(`desc-${lid}`);
const uom = document.getElementById(`uom-${lid}`);
if (icode) icode.value = item.ItemCode;
if (desc) desc.value = item.ItemName || '';
if (uom) uom.value = item.InventoryUOM || '';
recalc();
toast(`Item: ${item.ItemCode} — ${item.ItemName}`, 'info');
}
function removeLine(lid) {
document.getElementById(`line-${lid}`)?.remove();
renumber();
recalc();
}
function clearLines() {
if (!confirm('Remove all lines?')) return;
document.getElementById('tbody').innerHTML = '';
lineSeq = 0;
recalc();
}
function renumber() {
document.querySelectorAll('#tbody tr').forEach((tr, i) => {
const s = tr.querySelector('.li');
if (s) s.textContent = i + 1;
});
}
// ═══════════════════════════════════════════
// RECALCULATE TOTALS
// ═══════════════════════════════════════════
function recalc() {
let sumPre = 0, sumDisc = 0, sumTax = 0;
document.querySelectorAll('#tbody tr').forEach(tr => {
const qty = parseFloat(tr.querySelector('.qty-inp')?.value || 0);
const price = parseFloat(tr.querySelector('.price-inp')?.value || 0);
const disc = parseFloat(tr.querySelector('.disc-inp')?.value || 0);
const taxPct = parseFloat(tr.querySelector('.taxpct-inp')?.value|| 0);
const gross = qty * price;
const discAmt = gross * disc / 100;
const lineNet = gross - discAmt;
const lineTax = lineNet * taxPct / 100;
const lineTotal= lineNet + lineTax;
const ltInp = tr.querySelector('.linetot-inp');
if (ltInp) ltInp.value = lineTotal.toFixed(2);
sumPre += gross;
sumDisc += discAmt;
sumTax += lineTax;
});
const total = sumPre - sumDisc + sumTax;
document.getElementById('totPre').textContent = fmt(sumPre);
document.getElementById('totDisc').textContent = fmt(sumDisc);
document.getElementById('totTax').textContent = fmt(sumTax);
document.getElementById('totDoc').textContent = fmt(total);
}
// ═══════════════════════════════════════════
// BUILD PAYLOAD
// ═══════════════════════════════════════════
function buildPayload() {
const cardCode = document.getElementById('cardCode').value.trim();
const docDate = document.getElementById('docDate').value;
const dueDate = document.getElementById('docDueDate').value;
const numAtCard = document.getElementById('numAtCard').value.trim();
const comments = document.getElementById('comments').value.trim();
const bplId = parseInt(document.getElementById('bplId').value) || undefined;
const lines = [];
document.querySelectorAll('#tbody tr').forEach((tr, idx) => {
const itemCode = tr.querySelector('.icode-inp')?.value?.trim() || '';
const desc = tr.querySelector('.desc-inp')?.value || '';
const qty = parseFloat(tr.querySelector('.qty-inp')?.value || 0);
const price = parseFloat(tr.querySelector('.price-inp')?.value|| 0);
const disc = parseFloat(tr.querySelector('.disc-inp')?.value || 0);
const taxCode = tr.querySelector('.taxcode-sel')?.value || '';
const whs = tr.querySelector('.whs-sel')?.value || '';
if (!itemCode && !desc && qty === 0 && price === 0) return; // skip empty
const line = {
ItemCode: itemCode,
ItemDescription: desc,
Quantity: qty,
UnitPrice: price,
DiscountPercent: disc,
TaxCode: taxCode || undefined,
};
if (whs) line.WarehouseCode = whs;
lines.push(line);
});
const payload = {
company: company(),
CardCode: cardCode,
DocDate: docDate,
DocDueDate: dueDate,
Comments: comments || undefined,
DocumentLines: lines,
};
if (numAtCard) payload.NumAtCard = numAtCard;
if (bplId) payload.BPL_IDAssignedToInvoice = bplId;
// Clean undefined
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k]; });
return payload;
}
// ═══════════════════════════════════════════
// SUBMIT
// ═══════════════════════════════════════════
async function submitPO() {
const cardCode = document.getElementById('cardCode').value.trim();
if (!cardCode) return toast('Select a vendor first.', 'err');
if (!document.getElementById('docDate').value) return toast('Posting Date is required.', 'err');
if (!document.getElementById('docDueDate').value) return toast('Delivery Date is required.', 'err');
const rows = document.querySelectorAll('#tbody tr');
if (!rows.length) return toast('Add at least one line item.', 'err');
let hasItem = false;
let valid = true;
rows.forEach(tr => {
const ic = tr.querySelector('.icode-inp')?.value?.trim();
const qty = parseFloat(tr.querySelector('.qty-inp')?.value || 0);
const pr = parseFloat(tr.querySelector('.price-inp')?.value || 0);
if (ic) hasItem = true;
if (ic && (qty <= 0 || pr < 0)) valid = false;
});
if (!hasItem) return toast('At least one line must have an Item Code.', 'err');
if (!valid) return toast('Check line Qty and Unit Price values.', 'err');
const payload = buildPayload();
document.getElementById('overlay').classList.add('show');
document.getElementById('postBtn').disabled = true;
try {
const res = await fetch(`${API}${PO_ENDPOINT}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...ah() },
body: JSON.stringify(payload),
});
const data = await res.json();
document.getElementById('overlay').classList.remove('show');
document.getElementById('postBtn').disabled = false;
if (res.ok && (data?.DocEntry || data?.DocNum)) {
const docNum = data.DocNum || data.DocEntry;
document.getElementById('statusBadge').textContent = '● OPEN';
document.getElementById('statusBadge').className = 'badge badge-open';
document.getElementById('docNumDisp').textContent = docNum;
toast(`Purchase Order #${docNum} created successfully!`, 'ok');
_ordersLoaded = false;
setTimeout(() => resetForm(true), 800);
} else {
const sapMsg = data?.error?.message?.value || data?.message || JSON.stringify(data).slice(0, 200);
toast(`SAP Error: ${sapMsg}`, 'err');
document.getElementById('respPanel').textContent = JSON.stringify(data, null, 2);
document.getElementById('respPanel').style.display = 'block';
}
} catch(err) {
document.getElementById('overlay').classList.remove('show');
document.getElementById('postBtn').disabled = false;
toast('Network error: ' + err.message, 'err');
}
}
// ═══════════════════════════════════════════
// RESET
// ═══════════════════════════════════════════
function resetForm(silent = false) {
if (!silent && !confirm('Reset the form? All unsaved data will be lost.')) return;
['vendorSearch','cardCode','cardName','numAtCard','comments'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
const today = new Date().toISOString().slice(0, 10);
['docDate','taxDate','docDueDate'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = today;
});
document.getElementById('bplId').value = '';
document.getElementById('tbody').innerHTML = '';
lineSeq = 0;
document.getElementById('statusBadge').textContent = '● DRAFT';
document.getElementById('statusBadge').className = 'badge badge-draft';
document.getElementById('docNumDisp').textContent = 'AUTO';
document.getElementById('respPanel').style.display = 'none';
closePortal();
addLine();
if (!silent) toast('Form reset.', 'info');
}
// ═══════════════════════════════════════════
// VIEW ORDERS
// ═══════════════════════════════════════════
async function loadOrders() {
const wrap = document.getElementById('viewTableWrap');
wrap.innerHTML = '<div class="empty-state"><div class="spin" style="width:20px;height:20px;margin:0 auto 8px;"></div>Loading orders…</div>';
try {
const url = `${API}/api/sap/documents/PurchaseOrders?company=${encodeURIComponent(company())}&$top=50&$orderby=DocEntry+desc`;
const res = await fetch(url, { headers: ah() });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const orders = data.value || data.data || data || [];
_ordersLoaded = true;
document.getElementById('viewCount').textContent = `${orders.length} record(s)`;
if (!orders.length) {
wrap.innerHTML = '<div class="empty-state">No purchase orders found for this company.</div>';
return;
}
const rows = orders.map(o => {
const st = o.DocumentStatus || '';
const stCls = st === 'bost_Open' ? 'ds-open' : st === 'bost_Close' ? 'ds-closed' : 'ds-draft';
const stLbl = st === 'bost_Open' ? 'Open' : st === 'bost_Close' ? 'Closed' : 'Draft';
const dueD = o.DocDueDate ? o.DocDueDate.slice(0, 10) : '—';
const docD = o.DocDate ? o.DocDate.slice(0, 10) : '—';
const total = parseFloat(o.DocTotal || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 });
return `<tr>
<td style="font-family:'DM Mono',monospace;font-size:11px;font-weight:600;color:var(--accent2);">${esc(o.DocNum || '')}</td>
<td style="font-family:'DM Mono',monospace;font-size:11px;">${esc(docD)}</td>
<td><span style="font-size:11px;">${esc(o.CardCode || '')} <span style="color:var(--text-muted);">— ${esc(o.CardName || '')}</span></span></td>
<td style="font-family:'DM Mono',monospace;font-size:11px;">${esc(dueD)}</td>
<td style="font-family:'DM Mono',monospace;font-size:12px;font-weight:600;text-align:right;">₹ ${total}</td>
<td><span class="doc-status ${stCls}">${stLbl}</span></td>
</tr>`;
}).join('');
wrap.innerHTML = `<div class="vtable-wrap"><table>
<thead><tr>
<th>Doc #</th>
<th>Doc Date</th>
<th>Vendor</th>
<th>Due Date</th>
<th style="text-align:right;">Doc Total</th>
<th>Status</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
} catch(err) {
wrap.innerHTML = `<div class="empty-state" style="color:var(--danger);">Failed to load orders: ${esc(err.message)}</div>`;
toast('Error loading orders: ' + err.message, 'err');
}
}
// ═══════════════════════════════════════════
// BOOT
// ═══════════════════════════════════════════
initPage();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+912
View File
@@ -0,0 +1,912 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0"/>
<title>Purchase Request — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--purple2:#7c3aed;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#047e7e;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.95);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 16px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:8px}
.nav-logo{background:var(--teal);color:#fff;font-family:var(--display);font-size:14px;letter-spacing:2px;padding:3px 9px;border-radius:5px;flex-shrink:0}
.nav-title{font-size:13px;font-weight:700;color:var(--text);flex:1}
.nav-sub{font-size:10px;color:var(--text3);font-family:var(--mono)}
.nav-right{display:flex;align-items:center;gap:8px}
.co-sel{background:var(--surface2);border:1px solid var(--border2);color:var(--text);border-radius:6px;padding:4px 10px;font-size:11px;font-family:var(--mono);cursor:pointer;outline:none}
.co-sel option{background:var(--ink2)}
.btn-logout{padding:4px 10px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-logout:hover{border-color:var(--red);color:var(--red)}
.user-chip{font-family:var(--mono);font-size:10px;color:var(--text3)}
main{padding:14px 16px 80px;max-width:1100px;margin:0 auto}
/* ── Tabs ── */
.tabs{display:flex;gap:4px;margin-bottom:14px;background:var(--surface3);border-radius:var(--radius);padding:4px}
.tab{padding:8px 16px;border-radius:7px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);transition:all .15s}
.tab:hover{color:var(--text2)}
.tab.on{background:var(--surface);color:var(--teal);border:1px solid var(--border2);box-shadow:0 2px 8px rgba(0,0,0,.3)}
/* ── Cards ── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:11px;font-weight:700;color:var(--text2);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.5px}
.card-hd .ico{font-size:14px}
.card-hd .right{margin-left:auto;font-size:10px;font-weight:400;color:var(--text3)}
.card-bd{padding:14px}
/* ── Fields ── */
.fg{margin-bottom:11px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.req{color:var(--teal)}
.fi{width:100%;padding:7px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:12px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px;transition:border-color .15s}
.fi:focus{border-color:var(--teal);box-shadow:0 0 0 2px rgba(4,126,126,.15)}
.fi::placeholder{color:var(--text3)}
.fi[readonly]{opacity:.6;cursor:default}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
textarea.fi{height:auto;min-height:60px;resize:vertical}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.fi-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
.fi-row4{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px}
@media(max-width:600px){.fi-row,.fi-row3,.fi-row4{grid-template-columns:1fr}}
/* ── Search dropdown ── */
.sw{position:relative}
.sdd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1.5px solid var(--teal);border-radius:7px;box-shadow:0 12px 40px rgba(0,0,0,.7);z-index:300;max-height:220px;overflow-y:auto;display:none;margin-top:3px}
.sdd.open{display:block}
.sdd-row{padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center;transition:background .08s}
.sdd-row:last-child{border-bottom:none}
.sdd-row:hover{background:rgba(4,126,126,.12)}
.sdd-code{font-family:var(--mono);font-size:11px;color:var(--teal);font-weight:600;min-width:90px;flex-shrink:0}
.sdd-name{font-size:11px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sdd-msg{padding:12px;text-align:center;color:var(--text3);font-size:11px;font-style:italic}
/* ── Line items table ── */
.lines-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
table{width:100%;border-collapse:collapse;font-size:11px}
thead tr{background:var(--surface3)}
th{padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;border-bottom:1px solid var(--border)}
td{padding:0;border-bottom:1px solid var(--border);vertical-align:middle}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:rgba(4,126,126,.04)}
td input,td select{background:transparent;border:none;padding:6px 7px;font-size:11px;color:var(--text);width:100%;font-family:var(--font);height:30px;outline:none}
td input:focus,td select:focus{background:rgba(4,126,126,.08);outline:none}
td input[readonly]{color:var(--text3)}
td select{appearance:none;cursor:pointer}
.nc{text-align:right}.nc input{text-align:right;font-family:var(--mono)}
.li-num{display:block;width:28px;text-align:center;font-family:var(--mono);font-size:10px;color:var(--text3);padding:6px 2px}
.xbtn{display:block;width:26px;text-align:center;color:var(--text3);cursor:pointer;padding:5px 2px;font-size:14px;transition:color .12s;line-height:1}.xbtn:hover{color:var(--red)}
.tfoot-row{background:var(--surface3);border-top:1px solid var(--border)}
.tfoot-row td{padding:6px 8px;font-size:10px;font-weight:700;color:var(--text3);font-family:var(--mono)}
/* ── Buttons ── */
.btn-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.btn{padding:8px 18px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:var(--teal);color:#fff}.b-primary:hover:not(:disabled){background:#035b5b;box-shadow:0 4px 14px rgba(4,126,126,.4)}
.b-secondary{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-secondary:hover{border-color:var(--teal);color:var(--teal)}
.b-danger{background:rgba(229,62,62,.1);color:var(--red);border:1px solid rgba(229,62,62,.25)}.b-danger:hover{background:var(--red);color:#fff}
.b-warn{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.25)}.b-warn:hover{background:rgba(245,166,35,.2)}
.b-green{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.25)}.b-green:hover{background:rgba(13,187,138,.2)}
.b-blue{background:rgba(26,111,255,.1);color:var(--blue);border:1px solid rgba(26,111,255,.25)}.b-blue:hover{background:rgba(26,111,255,.2)}
.btn-sm{padding:5px 11px;font-size:10px;border-radius:6px}
/* ── Status badges ── */
.badge{display:inline-block;padding:2px 8px;border-radius:5px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;font-family:var(--mono)}
.s-open{background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25)}
.s-closed{background:rgba(61,84,112,.2);color:var(--text3);border:1px solid var(--border2)}
.s-cancelled{background:rgba(229,62,62,.12);color:var(--red);border:1px solid rgba(229,62,62,.25)}
.s-draft{background:rgba(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
/* ── PR List cards — square-tile grid, same pattern as work-order.html ── */
.pr-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px}
.pr-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.pr-card:hover{border-color:var(--teal);box-shadow:0 6px 20px rgba(0,0,0,.35);transform:translateY(-2px)}
.pr-card-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
.pr-docnum{font-family:var(--mono);font-size:12.5px;color:var(--teal);font-weight:700}
.pr-icon{width:34px;height:34px;border-radius:9px;background:rgba(6,182,212,.12);color:var(--teal);display:flex;align-items:center;justify-content:center;margin:8px 0;font-size:16px}
.pr-comment{font-size:11px;color:var(--text2);margin-bottom:6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.pr-meta{font-size:10.5px;color:var(--text3);display:flex;flex-direction:column;gap:3px;margin-top:auto;padding-top:8px;border-top:1px solid var(--border)}
.pr-meta b{color:var(--text2)}
.pr-empty{text-align:center;padding:40px;color:var(--text3);font-size:13px}
/* ── Search bar ── */
.search-bar{display:flex;gap:8px;margin-bottom:12px}
.search-bar .fi{flex:1}
.search-bar select.fi{flex:0 0 160px}
/* ── Toast ── */
.toast-c{position:fixed;top:58px;right:12px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 14px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .2s ease;max-width:320px;pointer-events:auto;display:flex;align-items:center;gap:8px}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}.t-info{background:var(--teal);color:#fff}
/* ── Spinner ── */
.sp{width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.full-sp{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:8000;align-items:center;justify-content:center;flex-direction:column;gap:12px}
.full-sp.show{display:flex}
.full-sp .sp{width:36px;height:36px;border-width:3px}
.full-sp p{font-size:12px;color:rgba(255,255,255,.6);font-family:var(--mono)}
/* ── Document detail modal ── */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:14px;overflow-y:auto}
.modal-bg.show{display:flex}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:12px;width:100%;max-width:780px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.7);animation:mIn .18s ease}
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal-hd{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px}
.modal-hd-left{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
.modal-tabs{display:flex;gap:3px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
.modal-tab{padding:7px 14px;font-size:11px;font-weight:600;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .12s}
.modal-tab.on{color:var(--teal);border-bottom-color:var(--teal)}
.modal-tab-pane{display:none}.modal-tab-pane.on{display:block}
.dt-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}
.dt-row{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-bottom:1px solid rgba(28,46,72,.5);font-size:12px}
.dt-row:last-child{border-bottom:none}
.dt-l{color:var(--text3);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
.dt-v{color:var(--text);font-family:var(--mono);font-size:11px;text-align:right}
.modal-actions{display:flex;gap:8px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--border);background:var(--surface3);border-radius:0 0 12px 12px}
/* ── Line detail table inside modal ── */
.line-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:8px}
.line-table th{background:var(--surface3);padding:6px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border)}
.line-table td{padding:7px 8px;border-bottom:1px solid rgba(28,46,72,.4);color:var(--text2);vertical-align:top}
.line-table tbody tr:last-child td{border-bottom:none}
.line-table .num{text-align:right;font-family:var(--mono)}
/* ── Pagination ── */
.pg-bar{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:11px;color:var(--text3)}
.pg-bar button{padding:4px 10px;border-radius:5px;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);font-size:10px;cursor:pointer;font-family:var(--font)}.pg-bar button:hover:not(:disabled){border-color:var(--teal);color:var(--teal)}.pg-bar button:disabled{opacity:.35}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="full-sp" id="fullSp"><div class="sp"></div><p id="spMsg">Processing…</p></div>
<div class="toast-c" id="toastC"></div>
<!-- NAV -->
<nav>
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span class="nav-logo">PR</span>
<div style="min-width:0">
<div class="nav-title">Purchase Request</div>
<div class="nav-sub">Purchase Requests · SAP B1</div>
</div>
</div>
<div class="nav-right">
<span class="user-chip" id="userChip"></span>
<select class="co-sel" id="coSel" onchange="onCo()">
</select>
<button class="btn-logout" onclick="logout()">Logout</button>
</div>
</nav>
<main>
<div class="tabs" style="justify-content:space-between">
<div style="display:flex;gap:4px">
<button class="tab on" id="tab-create" onclick="switchTab('create')">New Request</button>
<button class="tab" id="tab-view" onclick="switchTab('view');loadList()">View / Search</button>
</div>
<select class="fi" id="coSel" onchange="onCo()" style="width:auto;height:32px;padding:4px 10px;font-size:11px;font-family:var(--mono)">
</select>
</div>
<!-- ══ CREATE TAB ══ -->
<div id="pane-create">
<!-- Header -->
<div class="card">
<div class="card-hd"><span class="ico"></span>Purchase Request Header
<span class="right">All dates required · Vendor optional</span>
</div>
<div class="card-bd">
<div class="fi-row4" style="margin-bottom:10px">
<div class="fg">
<span class="fl">Posting Date <span class="req">*</span></span>
<input class="fi" type="date" id="docDate">
</div>
<div class="fg">
<span class="fl">Required By <span class="req">*</span></span>
<input class="fi" type="date" id="docDueDate">
</div>
<div class="fg">
<span class="fl">Vendor Ref. No.</span>
<input class="fi" type="text" id="numAtCard" placeholder="e.g. PR-2025-001">
</div>
<div class="fg">
<span class="fl">Status</span>
<input class="fi" type="text" value="Draft" readonly>
</div>
</div>
<div class="fi-row4" style="margin-bottom:10px">
<div class="fg sw" id="vendorWrap">
<span class="fl">Vendor (Optional)</span>
<input class="fi" type="text" id="vendorSearch" placeholder="Search vendor code or name…" autocomplete="off" oninput="vendorAc(this.value)">
<input type="hidden" id="cardCode">
<div class="sdd" id="vendorDd"></div>
</div>
<div class="fg">
<span class="fl">Department <span class="req">*</span></span>
<select class="fi" id="uDepart">
<option value="">— Select Department —</option>
</select>
</div>
<div class="fg">
<span class="fl">STS Required</span>
<select class="fi" id="uStsReqd">
<option value="No">No</option>
<option value="Yes">Yes</option>
</select>
</div>
<div class="fg">
<span class="fl">Remarks / Purpose <span class="req">*</span></span>
<textarea class="fi" id="comments" rows="2" placeholder="Enter purpose or remarks for this purchase request…"></textarea>
</div>
</div>
</div>
</div>
<!-- Lines -->
<div class="card">
<div class="card-hd"><span class="ico"></span>Document Lines
<span class="right">
<button class="btn b-secondary btn-sm" onclick="addLine()">+ Add Line</button>
<button class="btn b-secondary btn-sm" style="margin-left:4px" onclick="clearLines()">Clear</button>
</span>
</div>
<div class="card-bd" style="padding:0">
<div class="lines-wrap">
<table>
<thead>
<tr>
<th style="width:30px">#</th>
<th style="min-width:120px">Item Code <span class="req">*</span></th>
<th style="min-width:160px">Description</th>
<th style="width:90px">Required Date</th>
<th style="width:70px;text-align:right">Qty <span class="req">*</span></th>
<th style="width:50px">Unit</th>
<th style="width:100px;text-align:right">Unit Price</th>
<th style="min-width:110px">Warehouse</th>
<th style="width:28px"></th>
</tr>
</thead>
<tbody id="linesBody"></tbody>
<tfoot>
<tr class="tfoot-row">
<td colspan="4" style="padding:7px 8px;font-size:10px;color:var(--text3)">TOTALS</td>
<td class="nc" style="padding:7px 8px"><span id="totQty">0</span></td>
<td></td>
<td class="nc" style="padding:7px 8px"><span id="totAmt">0.00</span></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="btn-bar">
<button class="btn b-primary" id="btnPost" onclick="submitPR()">
<span class="sp" id="spPost" style="display:none"></span> ✓ Submit for Approval
</button>
<button class="btn b-secondary" onclick="resetForm()">↺ Reset</button>
</div>
</div><!-- /pane-create -->
<!-- ══ VIEW TAB ══ -->
<div id="pane-view" style="display:none">
<div class="card">
<div class="card-hd"><span class="ico"></span>Search Purchase Requests</div>
<div class="card-bd">
<div class="search-bar">
<input class="fi" type="text" id="srchQ" placeholder="Search by doc no., vendor, remarks…" oninput="debounceSearch()">
<select class="fi" id="srchStatus" onchange="loadList()">
<option value="">All Status</option>
<option value="bost_Open">Open</option>
<option value="bost_Close">Closed</option>
</select>
<button class="btn b-primary btn-sm" onclick="loadList()">Search</button>
</div>
</div>
</div>
<div id="listWrap">
<div class="pr-empty">Click "View / Search" to load purchase requests.</div>
</div>
<div class="pg-bar" id="pgBar" style="display:none">
<button id="pgPrev" onclick="pgMove(-1)" disabled>← Prev</button>
<span id="pgInfo">Page 1</span>
<button id="pgNext" onclick="pgMove(1)">Next →</button>
</div>
</div><!-- /pane-view -->
</main>
<!-- ══ APPROVAL REMARKS DIALOG ══ -->
<div class="modal-bg" id="approvalDialog" style="z-index:600">
<div class="modal" style="max-width:480px">
<div class="modal-hd">
<div class="modal-hd-left">
<span style="font-size:16px"></span>
<span style="font-weight:700;font-size:13px">Request for Document Generation</span>
</div>
</div>
<div class="modal-bd" style="padding:16px">
<p style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.6">
Generating this document requires the authorisation of other users.<br>
Enter remarks relevant for the authoriser and choose <strong>OK</strong>.
</p>
<table style="width:100%;border-collapse:collapse;font-size:12px;margin-bottom:14px">
<thead>
<tr style="background:var(--surface3)">
<th style="padding:7px 10px;text-align:left;color:var(--text3);font-weight:700;width:30px">#</th>
<th style="padding:7px 10px;text-align:left;color:var(--text3);font-weight:700">Approval Template</th>
<th style="padding:7px 10px;text-align:left;color:var(--text3);font-weight:700">Remarks</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px 10px;color:var(--text2)">1</td>
<td style="padding:8px 10px;color:var(--text)">Purchase Indent</td>
<td style="padding:6px 10px">
<input class="fi" type="text" id="approvalRemarks" placeholder="Enter remarks for approver…" style="height:30px;font-size:12px">
</td>
</tr>
</tbody>
</table>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn b-secondary" onclick="closeApprovalDialog()">Cancel</button>
<button class="btn b-primary" id="btnApprovalOk" onclick="confirmApproval()">OK</button>
</div>
</div>
</div>
</div>
<!-- ══ DOCUMENT DETAIL MODAL ══ -->
<div class="modal-bg" id="docModal">
<div class="modal">
<div class="modal-hd">
<div class="modal-hd-left">
<span style="font-weight:700;font-size:13px">Purchase Request</span>
<span class="badge s-draft" id="mStatus">—</span>
<span style="font-family:var(--mono);font-size:12px;color:var(--teal)" id="mDocNum">#—</span>
</div>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-bd">
<div class="modal-tabs">
<div class="modal-tab on" onclick="mTab(this,'mt-info')">Details</div>
<div class="modal-tab" onclick="mTab(this,'mt-lines')">Lines</div>
</div>
<!-- Details pane -->
<div class="modal-tab-pane on" id="mt-info">
<div class="dt-grid">
<div>
<div class="dt-row"><span class="dt-l">Doc Entry</span><span class="dt-v" id="mDocEntry">—</span></div>
<div class="dt-row"><span class="dt-l">Doc No.</span><span class="dt-v" id="mDocNumD">—</span></div>
<div class="dt-row"><span class="dt-l">Posting Date</span><span class="dt-v" id="mDocDate">—</span></div>
<div class="dt-row"><span class="dt-l">Required By</span><span class="dt-v" id="mDueDate">—</span></div>
</div>
<div>
<div class="dt-row"><span class="dt-l">Vendor</span><span class="dt-v" id="mVendor">—</span></div>
<div class="dt-row"><span class="dt-l">Vendor Name</span><span class="dt-v" id="mVendorName">—</span></div>
<div class="dt-row"><span class="dt-l">Vendor Ref.</span><span class="dt-v" id="mRef">—</span></div>
<div class="dt-row"><span class="dt-l">Status</span><span class="dt-v" id="mStatusD">—</span></div>
</div>
</div>
<div class="dt-row" style="border-top:1px solid var(--border);margin-top:8px">
<span class="dt-l">Remarks</span>
<span class="dt-v" id="mComments" style="max-width:60%;word-break:break-word;text-align:right">—</span>
</div>
</div>
<!-- Lines pane -->
<div class="modal-tab-pane" id="mt-lines">
<table class="line-table">
<thead><tr>
<th>#</th><th>Item Code</th><th>Description</th>
<th>Required Date</th><th class="num">Qty</th><th>Unit</th><th class="num">Unit Price</th><th>Warehouse</th>
</tr></thead>
<tbody id="mLinesBody"></tbody>
</table>
</div>
</div>
<div class="modal-actions" id="mActions"></div>
</div>
</div>
<script>
'use strict';
const _tok = sessionStorage.getItem('portal_token');
if (!_tok) window.location.href = '/';
let _co = document.getElementById('coSel').value;
let _seq = 0;
let _whs = [];
let _curDoc = null;
let _srchTimer = null;
let _page = 0;
// Matches SAP Service Layer's own MaxPageSize (confirmed live: it silently
// caps a page at 20 rows regardless of a higher $top) — with PAGE_SIZE=30
// this was requesting 30, always getting 20 back, and "rows.length <
// PAGE_SIZE" (20 < 30) then wrongly read as "no more results", disabling
// Next even when more pages genuinely existed.
const PAGE_SIZE = 20;
const ah = () => ({ Authorization: `Bearer ${_tok}` });
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const dt = s => s ? s.slice(0,10) : '—';
// ── Toast ──────────────────────────────────────────────────────
function toast(msg, type='info'){
const el = document.createElement('div');
el.className = `toast t-${type}`;
el.innerHTML = `<span>${{ok:'✓',err:'✕',info:'ℹ'}[type]||'ℹ'}</span><span>${esc(String(msg))}</span>`;
document.getElementById('toastC').appendChild(el);
setTimeout(() => el.remove(), 5000);
}
function spin(show, msg='Processing…'){
document.getElementById('fullSp').classList.toggle('show', show);
document.getElementById('spMsg').textContent = msg;
}
// ── Auth — read from sessionStorage (same as sidebar, no server round-trip) ──
let _user = null;
try {
const u = sessionStorage.getItem('portal_user');
if (u) {
_user = JSON.parse(u);
document.getElementById('userChip').textContent = _user?.name || _user?.username || '';
}
} catch(_e) {}
// Close/Cancel/Create Cancellation/Delete are destructive-ish SAP-document
// actions — restricted to admin/system_admin only; everyone else can view
// and create Purchase Requests but not act on an existing one this way.
const CAN_MANAGE_PR_DOC = _user && (_user.role === 'admin' || _user.role === 'system_admin');
function logout(){ sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';; localStorage.removeItem('token');localStorage.removeItem('portal_user'); window.location.href='/'; }
function onCo(){ _co = document.getElementById('coSel').value; loadWarehouses(); loadDepartments(); }
async function populateCompanySelects(){
try{
const list=await window.fetchCompanyList();
if(!list.length)return;
document.querySelectorAll('#coSel').forEach(sel=>{
const cur=sel.value;
sel.innerHTML=list.map(c=>`<option value="${esc(c.value)}">${esc(c.label)}</option>`).join('');
if(list.some(c=>c.value===cur))sel.value=cur;
});
}catch(e){}
}
// ── Tab switching ──────────────────────────────────────────────
function switchTab(t){
['create','view'].forEach(id => {
document.getElementById('pane-'+id).style.display = id===t ? '' : 'none';
document.getElementById('tab-'+id).classList.toggle('on', id===t);
});
}
// ── Warehouses ─────────────────────────────────────────────────
async function loadWarehouses(){
try {
const r = await fetch(`/api/sap/lookup/warehouses?company=${_co}`, { headers: ah() });
const d = await r.json();
_whs = d.data || [];
document.querySelectorAll('.whs-sel').forEach(fillWhSel);
} catch {}
}
function fillWhSel(sel){
const cur = sel.value;
sel.innerHTML = '<option value="">— WH —</option>' +
_whs.map(w => `<option value="${esc(w.code)}"${w.code===cur?' selected':''}>${esc(w.code)} — ${esc(w.name)}</option>`).join('');
if (cur) sel.value = cur;
}
// ── Departments — live from SAP (OUDP), restricted per-user via Admin →
// Users → "PR Departments" (see routes/sap.js's /lookup/departments and
// /auth/profile's allowedDepartments; empty = no restriction, every
// department shown). Replaces what used to be a hardcoded, stale list.
async function loadDepartments(){
let allowed = [];
try {
const p = await fetch('/api/auth/profile', { headers: ah() });
const pd = await p.json();
if (Array.isArray(pd?.user?.allowedDepartments)) allowed = pd.user.allowedDepartments.map(String);
} catch {}
try {
const r = await fetch(`/api/sap/lookup/departments?company=${_co}`, { headers: ah() });
const d = await r.json();
let depts = d.data || [];
if (allowed.length) depts = depts.filter(dp => allowed.includes(dp.name));
const sel = document.getElementById('uDepart');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">— Select Department —</option>' +
depts.map(dp => `<option value="${esc(dp.name)}"${dp.name===cur?' selected':''}>${esc(dp.name)}</option>`).join('');
if (cur) sel.value = cur;
} catch {}
}
// ── Vendor autocomplete ────────────────────────────────────────
let _vendTimer = null;
function vendorAc(val){
document.getElementById('cardCode').value = '';
clearTimeout(_vendTimer);
const dd = document.getElementById('vendorDd');
if (val.length < 2){ dd.classList.remove('open'); dd.innerHTML=''; return; }
_vendTimer = setTimeout(async () => {
try {
const r = await fetch(`/api/sap/lookup/vendors?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() });
const d = await r.json();
const items = d.data || [];
dd.innerHTML = items.length
? items.map(v => `<div class="sdd-row" onclick="pickVendor('${esc(v.CardCode)}','${esc(v.CardName.replace(/'/g,"\'"))}')"><span class="sdd-code">${esc(v.CardCode)}</span><span class="sdd-name">${esc(v.CardName)}</span></div>`).join('')
: '<div class="sdd-msg">No vendors found</div>';
dd.classList.add('open');
} catch {}
}, 300);
}
function pickVendor(code, name){
document.getElementById('vendorSearch').value = `${code} — ${name}`;
document.getElementById('cardCode').value = code;
document.getElementById('vendorDd').classList.remove('open');
}
document.addEventListener('click', e => {
if (!document.getElementById('vendorWrap').contains(e.target))
document.getElementById('vendorDd').classList.remove('open');
});
// ── Item autocomplete per line ─────────────────────────────────
const _itemTimers = {};
function itemAc(inp, lineId){
const val = inp.value;
clearTimeout(_itemTimers[lineId]);
const row = document.getElementById('lr-'+lineId);
const dd = row?.querySelector('.item-dd');
if (!dd) return;
if (val.length < 2){ dd.classList.remove('open'); dd.innerHTML=''; return; }
_itemTimers[lineId] = setTimeout(async () => {
try {
const r = await fetch(`/api/sap/lookup/items?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() });
const d = await r.json();
const items = d.data || [];
dd.innerHTML = items.length
? items.map(i => `<div class="sdd-row" onclick="pickItem(${lineId},'${esc(i.ItemCode)}','${esc((i.ItemName||'').replace(/'/g,"\'"))}','${esc(i.UoM||'')}','${esc(i.Warehouse||'')}')"><span class="sdd-code">${esc(i.ItemCode)}</span><span class="sdd-name">${esc(i.ItemName)}</span></div>`).join('')
: '<div class="sdd-msg">No items found</div>';
dd.classList.add('open');
const rect = inp.getBoundingClientRect();
dd.style.top = (rect.bottom + window.scrollY + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = Math.max(rect.width, 260) + 'px';
} catch {}
}, 300);
}
function pickItem(lineId, code, name, uom, warehouse){
const row = document.getElementById('lr-'+lineId);
if (!row) return;
row.querySelector('.ic-inp').value = code;
row.querySelector('.desc-inp').value = name;
row.querySelector('.unit-inp').value = uom;
if (warehouse) {
const sel = row.querySelector('.whs-sel');
if (sel) sel.value = warehouse;
}
row.querySelector('.item-dd')?.classList.remove('open');
recalc();
}
// ── Line items ─────────────────────────────────────────────────
function addLine(){
const id = ++_seq;
const today = new Date().toISOString().slice(0,10);
const dueDate = document.getElementById('docDueDate').value || today;
const tr = document.createElement('tr');
tr.id = 'lr-'+id;
tr.innerHTML = `
<td><span class="li-num">${id}</span></td>
<td style="position:relative;min-width:120px">
<input class="ic-inp" type="text" placeholder="Item code…" autocomplete="off" oninput="itemAc(this,${id})" style="min-width:110px">
<div class="sdd item-dd" style="position:fixed;z-index:9999"></div>
</td>
<td><input class="desc-inp" type="text" placeholder="Description" readonly style="min-width:140px"></td>
<td><input class="rd-inp" type="date" value="${dueDate}" style="min-width:110px;font-size:10px;font-family:var(--mono)"></td>
<td class="nc"><input class="qty-inp" type="number" value="1" min="0" step="any" oninput="recalc()" style="width:65px"></td>
<td><input class="unit-inp" type="text" readonly style="width:45px;font-size:10px;color:var(--text3)"></td>
<td class="nc"><input class="price-inp" type="number" value="0" min="0" step="any" oninput="recalc()" style="width:90px"></td>
<td><select class="whs-sel fi" style="border:none;background:transparent;height:30px;padding:4px 6px;font-size:11px"></select></td>
<td><span class="xbtn" onclick="removeLine(${id})">×</span></td>`;
document.getElementById('linesBody').appendChild(tr);
// close dropdowns when clicking outside this row
document.addEventListener('click', function closeItemDd(e){
if (!tr.contains(e.target)){ tr.querySelector('.item-dd')?.classList.remove('open'); }
});
fillWhSel(tr.querySelector('.whs-sel'));
recalc();
}
function removeLine(id){ document.getElementById('lr-'+id)?.remove(); recalc(); }
function clearLines(){ document.getElementById('linesBody').innerHTML=''; _seq=0; recalc(); }
function recalc(){
let qty=0, amt=0;
document.querySelectorAll('#linesBody tr').forEach(row => {
qty += parseFloat(row.querySelector('.qty-inp')?.value||0);
amt += parseFloat(row.querySelector('.qty-inp')?.value||0) * parseFloat(row.querySelector('.price-inp')?.value||0);
});
document.getElementById('totQty').textContent = qty;
document.getElementById('totAmt').textContent = amt.toFixed(2);
}
// ── Reset ──────────────────────────────────────────────────────
function resetForm(){
const today = new Date().toISOString().slice(0,10);
document.getElementById('docDate').value = today;
document.getElementById('docDueDate').value = today;
document.getElementById('numAtCard').value = '';
document.getElementById('vendorSearch').value = '';
document.getElementById('cardCode').value = '';
document.getElementById('comments').value = '';
document.getElementById('uDepart').value = '';
document.getElementById('uStsReqd').value = 'No';
clearLines();
}
// ── Submit PR — validate then show approval dialog ─────────────
let _pendingPayload = null;
function submitPR(){
const lines = [];
let valid = true;
document.querySelectorAll('#linesBody tr').forEach(row => {
const ic = row.querySelector('.ic-inp')?.value.trim();
const qty = parseFloat(row.querySelector('.qty-inp')?.value || 0);
if (!ic || qty <= 0){ valid=false; return; }
const line = {
ItemCode: ic,
ItemDescription: row.querySelector('.desc-inp')?.value || '',
Quantity: qty,
UnitPrice: parseFloat(row.querySelector('.price-inp')?.value||0),
};
const rd = row.querySelector('.rd-inp')?.value;
const whs = row.querySelector('.whs-sel')?.value;
if (rd) line.RequiredDate = rd;
if (whs) line.WarehouseCode = whs;
lines.push(line);
});
if (!valid){ toast('Each line needs an item code and qty > 0','err'); return; }
if (!lines.length){ toast('Add at least one line item','err'); return; }
const docDate = document.getElementById('docDate').value;
const docDueDate = document.getElementById('docDueDate').value;
const cardCode = document.getElementById('cardCode').value.trim();
const numAtCard = document.getElementById('numAtCard').value.trim();
const comments = document.getElementById('comments').value.trim();
const depart = document.getElementById('uDepart').value;
const stsReqd = document.getElementById('uStsReqd').value;
if (!comments){ toast('Remarks / Purpose is required','err'); return; }
if (!depart){ toast('Select a Department','err'); return; }
_pendingPayload = { company: _co, DocumentLines: lines };
if (docDate) _pendingPayload.DocDate = docDate;
if (docDueDate) { _pendingPayload.DocDueDate = docDueDate; _pendingPayload.RequriedDate = docDueDate; }
if (cardCode) _pendingPayload.CardCode = cardCode;
if (numAtCard) _pendingPayload.NumAtCard = numAtCard;
if (comments) _pendingPayload.Comments = comments;
if (docDueDate) _pendingPayload.U_ReqDqte = docDueDate;
if (depart) { _pendingPayload.U_Depart = depart; _pendingPayload.U_Department = depart; }
_pendingPayload.U_STS_REQD = stsReqd;
// Show approval remarks dialog (like SAP desktop "Request for Document Generation")
document.getElementById('approvalRemarks').value = '';
document.getElementById('approvalDialog').classList.add('show');
setTimeout(() => document.getElementById('approvalRemarks').focus(), 100);
}
function closeApprovalDialog(){
document.getElementById('approvalDialog').classList.remove('show');
_pendingPayload = null;
}
async function confirmApproval(){
if (!_pendingPayload) return;
const remarks = document.getElementById('approvalRemarks').value.trim();
if (remarks) {
_pendingPayload.Comments = (_pendingPayload.Comments ? _pendingPayload.Comments + '\n' : '') + 'Approval Remarks: ' + remarks;
}
document.getElementById('btnApprovalOk').disabled = true;
document.getElementById('approvalDialog').classList.remove('show');
document.getElementById('btnPost').disabled = true;
document.getElementById('spPost').style.display = 'inline-block';
try {
const r = await fetch('/api/sap/purchase-request', {
method:'POST', headers:{...ah(),'Content-Type':'application/json'},
body: JSON.stringify(_pendingPayload),
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.message||'SAP error');
toast(`Purchase Request #${d.data?.DocEntry || ''} submitted — pending approval`, 'ok');
resetForm();
_pendingPayload = null;
} catch(err){ toast(err.message,'err'); }
finally {
document.getElementById('btnPost').disabled = false;
document.getElementById('spPost').style.display = 'none';
document.getElementById('btnApprovalOk').disabled = false;
}
}
// ── List / Search ──────────────────────────────────────────────
function debounceSearch(){
clearTimeout(_srchTimer);
_srchTimer = setTimeout(() => { _page=0; loadList(); }, 400);
}
async function loadList(){
const wrap = document.getElementById('listWrap');
wrap.innerHTML = '<div class="pr-empty">Loading…</div>';
const q = document.getElementById('srchQ').value.trim();
const status = document.getElementById('srchStatus').value;
const skip = _page * PAGE_SIZE;
let url = `/api/sap/purchase-requests?company=${_co}&$top=${PAGE_SIZE}&$skip=${skip}`;
if (q) url += `&q=${encodeURIComponent(q)}`;
if (status) url += `&status=${encodeURIComponent(status)}`;
try {
const r = await fetch(url, { headers: ah() });
const d = await r.json();
const rows = d.data || [];
if (!rows.length){ wrap.innerHTML='<div class="pr-empty">No purchase requests found.</div>'; document.getElementById('pgBar').style.display='none'; return; }
wrap.innerHTML = `<div class="pr-list">${rows.map(pr => `
<div class="pr-card" onclick="openDoc(${pr.DocEntry})">
<div class="pr-card-top">
<span class="pr-docnum">#${esc(String(pr.DocNum||pr.DocEntry))}</span>
${statusBadge(pr.DocumentStatus)}
</div>
<div class="pr-icon">📝</div>
${pr.Comments ? `<div class="pr-comment" title="${esc(pr.Comments)}">${esc(pr.Comments)}</div>` : ''}
<div class="pr-meta">
<span>Date <b>${dt(pr.DocDate)}</b></span>
<span>Required <b>${dt(pr.DocDueDate)}</b></span>
${pr.CardCode ? `<span>Vendor <b>${esc(pr.CardCode)}</b></span>` : ''}
</div>
</div>`).join('')}</div>`;
// Pagination
const pgBar = document.getElementById('pgBar');
pgBar.style.display = 'flex';
document.getElementById('pgPrev').disabled = _page === 0;
document.getElementById('pgNext').disabled = rows.length < PAGE_SIZE;
document.getElementById('pgInfo').textContent = `Page ${_page+1}`;
} catch(err){ wrap.innerHTML=`<div class="pr-empty" style="color:var(--red)">${esc(err.message)}</div>`; }
}
function pgMove(dir){ _page = Math.max(0, _page+dir); loadList(); }
function statusBadge(s){
if (s==='bost_Open') return `<span class="badge s-open">Open</span>`;
if (s==='bost_Close') return `<span class="badge s-closed">Closed</span>`;
if (s==='bost_Cancelled') return `<span class="badge s-cancelled">Cancelled</span>`;
return `<span class="badge s-draft">${esc(s||'Draft')}</span>`;
}
// ── Document detail ────────────────────────────────────────────
async function openDoc(id){
spin(true,'Loading document…');
try {
const r = await fetch(`/api/sap/purchase-requests/${id}?company=${_co}`, { headers: ah() });
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.message||'Not found');
_curDoc = d.data;
renderModal(_curDoc);
document.getElementById('docModal').classList.add('show');
} catch(err){ toast(err.message,'err'); }
finally { spin(false); }
}
function renderModal(doc){
const s = doc.DocumentStatus;
document.getElementById('mStatus').className = 'badge ' + (s==='bost_Open'?'s-open':s==='bost_Close'?'s-closed':'s-draft');
document.getElementById('mStatus').textContent = s==='bost_Open'?'Open':s==='bost_Close'?'Closed':'Draft';
document.getElementById('mDocNum').textContent = '#' + (doc.DocNum||doc.DocEntry);
document.getElementById('mDocEntry').textContent = doc.DocEntry;
document.getElementById('mDocNumD').textContent = doc.DocNum;
document.getElementById('mDocDate').textContent = dt(doc.DocDate);
document.getElementById('mDueDate').textContent = dt(doc.DocDueDate);
document.getElementById('mVendor').textContent = doc.CardCode || '—';
document.getElementById('mVendorName').textContent= doc.CardName || '—';
document.getElementById('mRef').textContent = doc.NumAtCard || '—';
document.getElementById('mStatusD').textContent = s || '—';
document.getElementById('mComments').textContent = doc.Comments || '—';
// Lines
const lines = doc.DocumentLines || [];
document.getElementById('mLinesBody').innerHTML = lines.length
? lines.map((l,i) => `<tr>
<td>${i+1}</td>
<td style="font-family:var(--mono);color:var(--teal)">${esc(l.ItemCode||'—')}</td>
<td>${esc(l.ItemDescription||'—')}</td>
<td style="font-family:var(--mono);font-size:10px">${dt(l.RequiredDate)}</td>
<td class="num">${l.Quantity||0}</td>
<td>${esc(l.UoMCode||l.unitOfMeasure||'—')}</td>
<td class="num">${parseFloat(l.UnitPrice||0).toFixed(2)}</td>
<td>${esc(l.WarehouseCode||'—')}</td>
</tr>`).join('')
: '<tr><td colspan="8" style="text-align:center;padding:16px;color:var(--text3)">No lines</td></tr>';
// Actions
const isOpen = s === 'bost_Open';
const isClosed = s === 'bost_Close';
const actions = document.getElementById('mActions');
actions.innerHTML = `
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-green btn-sm" onclick="docAction('close')">✓ Close</button>` : ''}
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-warn btn-sm" onclick="docAction('cancel')">✕ Cancel</button>` : ''}
${isClosed ? `<button class="btn b-blue btn-sm" onclick="docAction('reopen')">↩ Reopen</button>` : ''}
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-secondary btn-sm" onclick="docAction('create-cancellation')">Create Cancellation</button>` : ''}
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-danger btn-sm" onclick="docAction('delete')">Delete</button>` : ''}
`;
}
function mTab(el, paneId){
document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('on'));
document.querySelectorAll('.modal-tab-pane').forEach(p => p.classList.remove('on'));
el.classList.add('on');
document.getElementById(paneId).classList.add('on');
}
function closeModal(){ document.getElementById('docModal').classList.remove('show'); _curDoc=null; }
document.getElementById('docModal').addEventListener('click', e => {
if (e.target === document.getElementById('docModal')) closeModal();
});
// ── Document actions ───────────────────────────────────────────
async function docAction(action){
if (!_curDoc) return;
const id = _curDoc.DocEntry;
const labels = { close:'Close', cancel:'Cancel', reopen:'Reopen', delete:'Delete', 'create-cancellation':'Create Cancellation' };
const label = labels[action] || action;
if (['delete','cancel'].includes(action)){
if (!confirm(`Are you sure you want to ${label} Purchase Request #${_curDoc.DocNum}?`)) return;
}
spin(true, `${label}ing…`);
try {
let r, d;
if (action === 'delete'){
r = await fetch(`/api/sap/purchase-requests/${id}?company=${_co}`, { method:'DELETE', headers: ah() });
d = await r.json();
} else {
r = await fetch(`/api/sap/purchase-requests/${id}/${action}?company=${_co}`, { method:'POST', headers: ah() });
d = await r.json();
}
if (!r.ok || !d.success) throw new Error(d.message||'Action failed');
toast(`Purchase Request ${label}d successfully`, 'ok');
closeModal();
loadList();
} catch(err){ toast(err.message,'err'); }
finally { spin(false); }
}
// ── Init ───────────────────────────────────────────────────────
(async () => {
await populateCompanySelects();
_co = document.getElementById('coSel').value;
const today = new Date().toISOString().slice(0,10);
document.getElementById('docDate').value = today;
document.getElementById('docDueDate').value = today;
await loadWarehouses();
await loadDepartments();
addLine();
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+199
View File
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>PWO Source Report — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:12px;max-width:1400px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.note{font-size:11px;color:var(--text3);line-height:1.6;margin-bottom:10px;padding:8px 10px;background:var(--surface3);border-radius:8px;border:1px solid var(--border2)}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:130px}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;height:34px}
.b-primary{background:var(--blue);color:#fff}
.b-primary:disabled{opacity:.5;cursor:not-allowed}
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap;background:var(--surface3);position:sticky;top:0}
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;white-space:nowrap}
.lst tbody tr:hover td{background:rgba(26,111,255,.05)}
.mono{font-family:var(--mono)}
.pill{font-size:9px;font-weight:800;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.p-portal{background:rgba(13,187,138,.14);color:#0aa457}
.p-direct{background:rgba(229,62,62,.14);color:#e06666}
.p-mixed{background:rgba(245,166,35,.14);color:#c78a1a}
.p-status{background:var(--surface3);color:var(--text2);border:1px solid var(--border2)}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:14px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-err{background:var(--red);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:70vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<main id="app"></main>
<script>
'use strict';
let _tok=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)location.href='/';
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast t-err';e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function today(){return new Date().toISOString().slice(0,10);}
function monthAgo(){const d=new Date();d.setMonth(d.getMonth()-1);return d.toISOString().slice(0,10);}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
let _co=window.__DEFAULT_COMPANY__||'';
let _companies=[];
const _f={from:monthAgo(),to:today()};
let _loading=false,_ran=false,_rows=[];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
async function runReport(){
if(!_f.from||!_f.to){toast('Pick a From and To date');return;}
if(_f.to>today()){toast('"To" date cannot be in the future');return;}
if(_f.from>_f.to){toast('"From" date cannot be after "To" date');return;}
_loading=true;_ran=true;render();
const qs=new URLSearchParams();
qs.set('company',_co);qs.set('from',_f.from);qs.set('to',_f.to);
try{
const r=await api('/pwo-source-audit/report?'+qs.toString());
_rows=r.data||[];
}catch(e){toast(e.message);_rows=[];}
_loading=false;render();
}
function pill(v){
if(v==null)return '<span class="pill p-status">—</span>';
const cls=v==='Portal'?'p-portal':v==='Mixed'?'p-mixed':'p-direct';
return `<span class="pill ${cls}">${esc(v)}</span>`;
}
function statusPill(s){return `<span class="pill p-status">${esc(s||'—')}</span>`;}
function stageOptions(sel){
const opts=[['','All'],['Portal','Portal'],['Direct (SAP)','Direct (SAP)'],['Mixed','Mixed'],[NONE,'Not reached yet']];
return opts.map(([v,l])=>`<option value="${esc(v)}" ${sel===v?'selected':''}>${esc(l)}</option>`).join('');
}
// Live client-side filters — no re-fetch, just narrow what's already loaded.
// "__none__" stands for the stage not being reached yet (field is null) —
// a real select value is needed since HTML options can't carry a literal null.
const NONE='__none__';
const _q={text:'',created:'',released:'',issued:'',received:''};
function stageMatch(val,filter){ return filter===''?true:filter===NONE?val==null:val===filter; }
function visibleRows(){
const t=_q.text.trim().toLowerCase();
let rows=_rows;
if(t)rows=rows.filter(r=>
String(r.absoluteEntry).includes(t)||
String(r.docNum).includes(t)||
(r.itemCode||'').toLowerCase().includes(t)||
(r.itemName||'').toLowerCase().includes(t)
);
rows=rows.filter(r=>
stageMatch(r.creationSource,_q.created)&&
stageMatch(r.releaseSource,_q.released)&&
stageMatch(r.issueSource,_q.issued)&&
stageMatch(r.receiptSource,_q.received)
);
return rows;
}
function refreshResults(){
const res=q('#results');if(res)res.innerHTML=renderTable();
const cnt=q('#result-count');if(cnt)cnt.textContent=_ran&&!_loading?`${visibleRows().length.toLocaleString()} order${visibleRows().length===1?'':'s'}`:'';
}
function renderTable(){
if(_loading)return `<div class="empty-msg"><span class="sp"></span> Loading…</div>`;
if(!_ran)return `<div class="empty-msg">Pick a date range and click "Run Report".</div>`;
if(!_rows.length)return `<div class="empty-msg">No Production Orders in this date range.</div>`;
const rows=visibleRows();
if(!rows.length)return `<div class="empty-msg">No rows match your search.</div>`;
const rowsHtml=rows.map(r=>`<tr>
<td class="mono">${esc(r.absoluteEntry)}</td>
<td class="mono">${esc(r.docNum)}</td>
<td class="mono">${esc(r.itemCode)}</td>
<td>${esc(r.itemName)}</td>
<td>${statusPill(r.status)}</td>
<td>${pill(r.creationSource)}</td>
<td>${pill(r.releaseSource)}</td>
<td>${pill(r.issueSource)}</td>
<td>${pill(r.receiptSource)}</td>
</tr>`).join('');
return `<div class="tbl-wrap"><table class="lst">
<thead><tr><th>AbsEntry</th><th>PWO No.</th><th>Item Code</th><th>Item Name</th><th>Status</th><th>Created</th><th>Released</th><th>Issued</th><th>Received</th></tr></thead>
<tbody>${rowsHtml}</tbody>
</table></div>`;
}
function render(){
const app=q('#app');
app.innerHTML=`
<div class="card"><div class="card-hd"><span>🧾 PWO Source Report</span><span class="right" id="result-count">${_ran&&!_loading?`${visibleRows().length.toLocaleString()} order${visibleRows().length===1?'':'s'}`:''}</span></div>
<div class="card-bd">
<div class="note">Shows, per Production Order, whether each of Create / Release / Issue / Receipt was done through this portal or directly in SAP B1. Only reliable going forward from when each stage's tracking was added — older activity has no marker either way and shows as "Direct (SAP)" even if it went through the portal. "Mixed" means a stage was split across multiple documents (e.g. a partial issue) with some tagged and some not.</div>
<div class="filters">
${_companies.length>1?`<div><label class="fl">Company</label><select class="fi" id="f-co">${coOptions(_co)}</select></div>`:''}
<div><label class="fl">From</label><input class="fi" type="date" id="f-from" value="${_f.from}" max="${_f.to||today()}"/></div>
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}" min="${_f.from||''}" max="${today()}"/></div>
<button class="btn b-primary" id="f-go" ${_loading?'disabled':''}>${_loading?'<span class="sp"></span> Running…':'Run Report'}</button>
</div>
<div class="filters" style="margin-top:8px">
<div style="min-width:240px;flex:1"><label class="fl">Search</label><input class="fi" id="q-text" placeholder="Item code, item name, PWO No. or AbsEntry…" value="${esc(_q.text)}"/></div>
<div><label class="fl">Created</label><select class="fi" id="q-created">${stageOptions(_q.created)}</select></div>
<div><label class="fl">Released</label><select class="fi" id="q-released">${stageOptions(_q.released)}</select></div>
<div><label class="fl">Issued</label><select class="fi" id="q-issued">${stageOptions(_q.issued)}</select></div>
<div><label class="fl">Received</label><select class="fi" id="q-received">${stageOptions(_q.received)}</select></div>
</div>
<div style="margin-top:12px" id="results">${renderTable()}</div>
</div></div>`;
wire();
}
function wire(){
if(q('#f-co'))q('#f-co').onchange=function(){_co=this.value;};
q('#f-from').onchange=function(){_f.from=this.value;render();};
q('#f-to').onchange=function(){_f.to=this.value;render();};
q('#f-go').onclick=runReport;
q('#q-text').oninput=function(){_q.text=this.value;refreshResults();};
q('#q-created').onchange=function(){_q.created=this.value;refreshResults();};
q('#q-released').onchange=function(){_q.released=this.value;refreshResults();};
q('#q-issued').onchange=function(){_q.issued=this.value;refreshResults();};
q('#q-received').onchange=function(){_q.received=this.value;refreshResults();};
}
(async function init(){
try{const list=await window.fetchCompanyList();if(list.length){_companies=list;if(!_co)_co=list[0].value;}}catch(_e){}
render();
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+991
View File
@@ -0,0 +1,991 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Receipt from Production — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<script src="/lucide.min.js?v=399"></script>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--green2:#059669;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--green);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--green);background:rgba(13,187,138,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:720px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .ico{font-size:15px}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--green);box-shadow:0 0 0 2px rgba(13,187,138,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:420px){.fi-row{grid-template-columns:1fr}}
.fi-mono{font-family:var(--mono);font-size:12px;text-transform:uppercase}
/* Order cards — square-tile grid, same pattern as issue-production.html */
.po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.po-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:10px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.po-card:hover{border-color:var(--green);box-shadow:0 6px 18px rgba(13,187,138,.14);transform:translateY(-2px)}
.po-card.selected{border-color:var(--green);background:rgba(13,187,138,.10);box-shadow:0 0 0 2px rgba(13,187,138,.25)}
.po-top{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;flex-wrap:wrap}
.po-code{font-family:var(--mono);font-size:10px;color:var(--green);font-weight:600}
.po-icon{width:26px;height:26px;border-radius:7px;background:rgba(13,187,138,.12);color:var(--green);display:flex;align-items:center;justify-content:center;margin:5px 0;font-size:13px}
.po-name{font-size:11px;font-weight:600;color:var(--text);line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.po-meta{font-size:9.5px;color:var(--text3);display:flex;flex-direction:column;gap:2px;margin-top:auto;padding-top:5px;border-top:1px solid var(--border)}
.po-meta b{color:var(--text2)}
/* Detail / receipt popup */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
.modal-bg.show{display:flex}
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:980px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:space-between}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
.order-info{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;font-size:11px}
@media(max-width:680px){.order-info{grid-template-columns:1fr 1fr}}
@media(max-width:420px){.order-info{grid-template-columns:1fr}}
.oi-item{display:flex;flex-direction:column;gap:1px}
.oi-l{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
.oi-v{font-family:var(--mono);font-size:11px;color:var(--text)}
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-receipt{background:var(--green);color:#fff;flex:1}.b-receipt:hover:not(:disabled){background:var(--green2);box-shadow:0 4px 16px rgba(13,187,138,.4)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-new{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.3);flex:1}.b-new:hover{background:rgba(13,187,138,.2)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.suc-box{text-align:center;padding:28px 16px}
.suc-icon{font-size:44px;margin-bottom:10px}
.suc-title{font-family:var(--display);font-size:26px;color:var(--green);margin-bottom:6px}
.suc-pill{display:inline-block;font-family:var(--mono);font-size:13px;padding:5px 16px;border-radius:6px;background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25);margin:8px 0 12px}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
/* Return Components table */
.ret-tbl{width:100%;border-collapse:separate;border-spacing:0}
.ret-tbl th{padding:8px 8px;font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--text3);text-align:left;border-bottom:2px solid var(--border2)}
.ret-tbl td{padding:10px 8px;vertical-align:middle;border-bottom:1px solid var(--border)}
.ret-tbl tr:nth-child(even) td{background:var(--surface3)}
.ret-tbl tr:hover td{background:rgba(245,166,35,.06)}
.ret-item{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--teal)}
.ret-desc{font-size:12px;color:var(--text2)}
.ret-issued{text-align:right;font-family:var(--mono);font-size:12px;color:var(--text2)}
.ret-batches{display:flex;flex-direction:column;gap:6px}
.ret-batch-row{display:flex;align-items:center;gap:8px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:5px 8px}
.ret-batch-num{font-family:var(--mono);font-size:11px;font-weight:700;color:var(--teal);min-width:66px}
.ret-batch-avail{font-size:9.5px;color:var(--text3);white-space:nowrap;min-width:70px}
.ret-batch-row .fi{height:26px;font-size:11px;text-align:right;width:64px;margin-left:auto}
.ret-total{display:inline-block;font-family:var(--mono);font-size:13px;font-weight:700;color:var(--amber);background:rgba(245,166,35,.1);border-radius:6px;padding:4px 9px}
/* Multi-warehouse receipt rows */
.recv-row{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.recv-remove{width:30px;height:30px;flex:none;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--red);cursor:pointer;font-size:13px}
.recv-remove:hover{background:var(--red);color:#fff;border-color:var(--red)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
<a href="/issue-production" class="nav-link">Issue</a>
<a href="/receipt-production" class="nav-link active">Receipt</a>
<a href="/grpo" class="nav-link">GRPO</a><a href="/approvals" class="nav-link">Approvals</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<div class="modal-bg" id="rcpt-detail-modal"><div class="modal" id="rcpt-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null,_wh=[],_branches=[],_varieties=[];
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
// True only if the logged-in user has their own SAP login set (the JWT
// carries a non-empty sapUser claim). Used to fail fast — with a clear
// message — before attempting any SAP action, instead of the request
// silently failing deep inside the SAP call.
function hasSapLogin(){try{const p=JSON.parse(atob(_tok.split('.')[1]));return !!(p&&p.sapLogins&&p.sapLogins[_co]&&p.sapLogins[_co].pwdEnc);}catch(_e){return false;}}
function requireSapLogin(){if(!hasSapLogin()){toast('Set your SAP User ID & Password first — open Profile → "My SAP Account".','err');return false;}return true;}
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
// Mirrors middleware/auth.js's hasStepPerm — this page is normally only
// reached via production.html's already-gated button, but a direct/bookmarked
// URL should still be blocked here too (the server enforces this regardless).
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
// Being assigned the step (any perm) is enough to perform the action.
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
const CAN_RECEIPT_PO=hasStepAssigned('production_order:receipt');
const CAN_RETURN_COMP=hasStepAssigned('production_order:return_components');
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
let _requireFullQty=true; // RECEIPT_REQUIRE_FULL_QTY from .env (see server.js /api/config) — fetched in init()
let _autoclaveEnabled=false; // RECEIPT_AUTOCLAVE_REJECTION flag
let _autoclaveGroups=[]; // RECEIPT_AUTOCLAVE_ITEM_GROUPS — ItmsGrpCod list that counts as FG
let _isFgItem=false; // is THIS order's item in one of those FG groups (looked up per order)
let _autoclaveQty=''; // single Autoclave Rejection qty — informational only, never sent to SAP
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _order=null;
let _loadingOrder=false;
let _submitting=false;
let _success=null;
let _readyOrders=[];
let _readyLoading=false;
let _modalOpen=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
// Receipt fields
let _recvLines=[]; // [{warehouse, qty, transType}] — receipt can be split across multiple warehouses, each with its own transaction type
let _whTranMap={}; // admin mapping: warehouse code → 'C'|'R'. Transaction Type auto-derives from this (user can't pick it).
// Transaction Type for a warehouse — from the admin map, default Complete.
function whTran(wh){return _whTranMap[wh]==='R'?'Reject':'Complete';}
let _batchReq=true; // "Batch Number Required" checkbox → OIGN.U_IS_BATCH_REQ (1/0)
let _batchNumber='';
let _batchLocked=false;
let _mfgDate='';
let _expDate='';
let _datesLocked=false;
let _variety='';
let _postDate='';
// Return Components (defective components back to stock after Issuance)
let _retLines=[];
let _retSubmitting=false;
let _showReturn=false;
let _retBatchReq=true; // its own "Batch Number Required" checkbox — when on, every returned line needs a batch number
function today(){return new Date().toISOString().slice(0,10);}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
// Short date for cards — helps tell apart duplicate document numbers that
// SAP re-uses across fiscal years (the doc-number series is date-based).
function fmtDate(iso){if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';return `${String(d.getUTCDate()).padStart(2,'0')} ${_MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;}
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
// Whether a next page of Released orders exists — from the server total when
// known, else from whether a full RAW (pre-filter) page came back.
function pagerHasNext(){ if(_total!=null) return (_page+1)*_pageSize<_total; return _rawCount>=_pageSize; }
function renderPager(){
if(_readyLoading) return '';
const hasNext=pagerHasNext();
const totalPages=_total!=null?Math.max(1,Math.ceil(_total/_pageSize)):null;
const label=_total!=null?`Page ${_page+1} of ${totalPages} · ${_total} released`:`Page ${_page+1}`;
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn" id="rp-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${_page===0?';opacity:.45;cursor:not-allowed':''}">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">${label}</span>
<button class="btn" id="rp-next" ${hasNext?'':'disabled'} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${hasNext?'':';opacity:.45;cursor:not-allowed'}">Next ›</button>
</div>`;
}
// Released orders that have components issued but aren't fully received yet
// — the default "ready to receive" list shown when not deep-linked.
// readyToReceive=1: server-side filters to fully-issued-not-fully-received
// orders when Admin → System Settings → "Require full issuance before
// Receipt/Close" is on (partial-issue orders excluded entirely — no point
// showing a card that would just 409). When that setting is off, the server
// leaves the list unfiltered by issuance — falls back to the same
// full-or-partial inclusion this always had.
async function loadReadyOrders(resetPage){
if(resetPage)_page=0;
_readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{
const r=await api('GET',`/sap/production-orders?status=Released&readyToReceive=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}`);
_rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>(o._issueStatus==='full'||o._issueStatus==='partial')&&o._receiptStatus!=='done');
_total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render();
}
async function loadWH(){try{const r=await api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_wh=r.data;}catch(_e){}}
async function loadWhTranMap(){try{const r=await api('GET',`/warehouse-trantype?company=${encodeURIComponent(_co)}`);_whTranMap=(r&&r.success&&r.data)?r.data:{};}catch(_e){_whTranMap={};}}
async function loadBranches(){try{const r=await api('GET',`/sap/lookup/branches?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_branches=r.data;}catch(_e){}}
async function loadVarieties(){try{const r=await api('GET',`/sap/lookup/costing-codes?dim=1&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_varieties=r.data;}catch(_e){}}
async function loadOrder(absEntry){
_modalOpen=true;_loadingOrder=true;_order=null;render();
try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
_order=r.data||r;
await loadWhTranMap(); // ensure the warehouse→type map is current for this company
// Set defaults from order
const remaining=Math.max(0,(_order.PlannedQuantity||0)-(_order.CompletedQuantity||0));
_recvLines=[{warehouse:_order.Warehouse||(_wh[0]?.code||''),qty:remaining,transType:whTran(_order.Warehouse||(_wh[0]?.code||''))}];
// By-products / process loss: a Production Order can have component
// lines with a NEGATIVE Planned Qty (e.g. ICO10791 "PROCESS LOSS PROD")
// — these are real SAP by-product rows that need their OWN receipt line
// (its BaseLine, not the FG's), so default them in as extra rows here.
// Warehouse defaults to whatever SAP already has on that component line.
(_order.ProductionOrderLines||[]).forEach(l=>{
if((l.PlannedQuantity||0)<0){
const wh=l.Warehouse||_order.Warehouse||(_wh[0]?.code||'');
_recvLines.push({warehouse:wh,qty:Math.abs(l.PlannedQuantity),transType:whTran(wh),
isByProduct:true,itemCode:l.ItemNo||'',itemName:l.ItemName||'',lineNum:l.LineNumber});
}
});
// Batch Number comes from the order's own Remarks field — that's where
// it's captured at creation time (production.html carries the source
// Work Order's batch into Remarks, since Production Orders have no
// dedicated batch field of their own). Falls back to manual entry only
// if this order somehow has no Remarks (e.g. created outside the app).
_batchReq=true; // default: batch required (matches prior behavior)
_batchNumber=(_order.Remarks||'').trim();
_batchLocked=!!_batchNumber;
// MFG/EXP Date come from the Batch Issuance Intimation that started this
// order's Work Order (carried through onto our local production-order
// link record — SAP itself has no such fields). Falls back to manual
// entry if this order has no local link (e.g. created outside the app).
_mfgDate=(_order._localMfgDate||'').trim();
_expDate=(_order._localExpDate||'').trim();
_datesLocked=!!(_mfgDate||_expDate);
_rcptDateFmt=null; // redetect MFG/EXP format fresh for this order
// Component lines with something actually issued — the candidates for a
// defective-quantity return. Resources can't be returned to stock.
_retLines=(_order.ProductionOrderLines||[])
.filter(l=>l.ItemType!=='pit_Resource'&&(l.IssuedQuantity||0)>0)
.map(l=>({lineNum:l.LineNumber,itemCode:l.ItemNo||'',itemName:l.ItemName||'',
issuedQty:l.IssuedQuantity||0,returnQty:0,warehouse:l.Warehouse||_order.Warehouse||'',
batchNumber:'',batchLocked:false,batches:null}));
_showReturn=false;
// Autoclave Rejection (FG only): reset rows, then decide if the section
// applies by looking up THIS item's SAP Item Group against the configured
// FG group list. Non-blocking — the modal renders immediately.
_autoclaveQty='';_isFgItem=false;
}catch(e){toast(e.message,'err');}
_loadingOrder=false;render();
if(_retLines.length) loadReturnBatches();
if(_autoclaveEnabled&&_autoclaveGroups.length&&_order?.ItemNo) loadFgFlag(_order.ItemNo);
loadRecvUoms();
}
// Unit (Inventory UOM) shown next to each Warehouse/Qty/Transaction Type
// row's quantity — the FG's own UOM (_order.InventoryUOM) is usually already
// present, but by-product rows (e.g. ICO10791) need their own item's UOM
// looked up. Non-blocking, runs after the modal is already showing.
async function loadRecvUoms(){
const codes=[...new Set(_recvLines.map(rl=>rl.isByProduct?rl.itemCode:(_order?.ItemNo||'')).filter(Boolean))];
if(!codes.length)return;
try{
const r=await api('GET',`/sap/lookup/item-uoms?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
if(r.success&&r.data){
_recvLines.forEach(rl=>{
const code=rl.isByProduct?rl.itemCode:(_order?.ItemNo||'');
if(r.data[code])rl.uom=r.data[code];
});
render();
}
}catch(_e){/* non-fatal — rows just show without a unit label */}
}
async function loadFgFlag(itemCode){
try{
const r=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(itemCode)}&company=${encodeURIComponent(_co)}`);
const grp=r.data?.[itemCode];
_isFgItem=grp!=null&&_autoclaveGroups.includes(String(grp));
if(_isFgItem)render();
}catch(_e){/* lookup failed — just don't show the section */}
}
// A return goes back into whatever batch the component is CURRENTLY sitting
// in, in that warehouse right now (OIBT) — same source Issue for Production
// uses to auto-select batches, just reused here per return line. Runs after
// the modal is already showing so it doesn't block the initial order load.
async function loadReturnBatches(){
await Promise.all(_retLines.map(async(line)=>{
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(line.itemCode)}&warehouse=${encodeURIComponent(line.warehouse)}&company=${encodeURIComponent(_co)}`);
const data=r.data||[];
if(data.length===1){
line.batchNumber=data[0].BatchNumber;
line.batchLocked=true;
}else if(data.length>1){
// More than one batch currently in stock — show them all as
// separate rows (like Issue for Production does) so the user picks
// how much comes back from each, instead of one free-text field.
line.batches=data.map(b=>({BatchNumber:b.BatchNumber,Quantity:b.Quantity,returnQty:0}));
}
}catch(_e){/* item not batch-managed, or lookup failed — leave free-text */}
}));
render();
}
function fmtQty(n){return(Math.round((parseFloat(n)||0)*1000)/1000).toString();}
function recvTotal(){return _recvLines.filter(rl=>!rl.isByProduct).reduce((s,rl)=>s+(parseFloat(rl.qty)||0),0);}
// Content of the "Total: X NOS ✓ matches remaining" line — factored out so
// it can be surgically refreshed (see refreshRecvTotal()) when a qty input's
// onchange updates the total without a full renderModal() (which would drop
// focus / reset the modal's scroll on every edit).
function recvTotalHtml(){
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
return `Total: <strong style="color:var(--text);font-family:var(--mono)">${fmtQty(recvTotal())}</strong> ${esc(_order?.InventoryUOM||'PCS')}${_requireFullQty?` <span style="color:${qtyMatches?'var(--green)':'var(--red)'};font-weight:700">${qtyMatches?'✓ matches remaining':`✗ must equal remaining (${fmtQty(remainingQty())})`}</span>`:''}`;
}
function refreshRecvTotal(){
const el=document.getElementById('recv-total');if(el)el.innerHTML=recvTotalHtml();
// Post Receipt's disabled state also depends on the total (see renderModal's
// qtyMatches) — keep it in sync without a full re-render too.
const btn=document.getElementById('btn-receipt');
if(btn){
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
btn.disabled=_submitting||!qtyMatches;
}
}
// MFG/EXP — 6 selectable formats (2 full-date + 4 month-only). Entry is
// picker-only (see rcptDateField()/openRcptDatePicker() below) — the field
// itself is read-only, so a malformed date can never be typed at all; these
// regexes exist to (a) validate historical free-typed data from before this
// redesign, and (b) let parseMEDate() work on whatever string is already
// saved. Same format set as Batch Issuance/Work Order — kept in sync there.
const MONTH_ABBR=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function pad2(n){return String(n).padStart(2,'0');}
const DATE_FORMATS=[
{id:'DMY', label:'DD-MMM-YYYY', kind:'full', re:/^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/},
{id:'DMYn',label:'DD-MM-YYYY', kind:'full', re:/^(\d{1,2})-(\d{1,2})-(\d{4})$/},
{id:'MY', label:'MMM-YYYY', kind:'month', re:/^([A-Za-z]{3})[-/](\d{4})$/},
{id:'MYn', label:'MM-YYYY', kind:'month', re:/^(\d{1,2})-(\d{4})$/},
{id:'YM', label:'YYYY-MMM', kind:'month', re:/^(\d{4})-([A-Za-z]{3})$/},
{id:'YMn', label:'YYYY-MM', kind:'month', re:/^(\d{4})-(\d{1,2})$/},
];
function fmtById(id){return DATE_FORMATS.find(f=>f.id===id)||DATE_FORMATS[0];}
function detectFormat(v){
const t=(v||'').trim();
if(!t)return null;
for(const f of DATE_FORMATS) if(f.re.test(t)) return f.id;
return null;
}
function formatByPicked(iso,fmtId){
const parts=iso.split('-').map(Number);
const y=parts[0],mo=parts[1],d=parts[2];
const mon3=MONTH_ABBR[mo-1];
switch(fmtId){
case 'DMY': return `${pad2(d)}-${mon3}-${y}`;
case 'DMYn':return `${pad2(d)}-${pad2(mo)}-${y}`;
case 'MY': return `${mon3}-${y}`;
case 'MYn': return `${pad2(mo)}-${y}`;
case 'YM': return `${y}-${mon3}`;
case 'YMn': return `${y}-${pad2(mo)}`;
default: return `${pad2(d)}-${mon3}-${y}`;
}
}
function parseMEDate(v){
const t=(v||'').trim();
const fmtId=detectFormat(t);
if(!fmtId)return null;
const f=fmtById(fmtId),m=t.match(f.re);
const monFromName=s=>MONTH_ABBR.findIndex(x=>x.toLowerCase()===s.toLowerCase());
let y,mon,d=1;
switch(fmtId){
case 'DMY': d=parseInt(m[1]);mon=monFromName(m[2]);y=parseInt(m[3]);break;
case 'DMYn':d=parseInt(m[1]);mon=parseInt(m[2])-1;y=parseInt(m[3]);break;
case 'MY': mon=monFromName(m[1]);y=parseInt(m[2]);break;
case 'MYn': mon=parseInt(m[1])-1;y=parseInt(m[2]);break;
case 'YM': y=parseInt(m[1]);mon=monFromName(m[2]);break;
case 'YMn': y=parseInt(m[1]);mon=parseInt(m[2])-1;break;
}
if(mon==null||mon<0||mon>11)return null;
return new Date(y,mon,d);
}
function validMfgExp(v){return!v||!v.trim()||!!parseMEDate(v);}
// Converts our MFG/EXP string (any of the 6 formats, day defaults to 1st for
// month-only ones) into the ISO date SAP's BatchNumbers ManufacturingDate/
// ExpiryDate fields expect. Returns null if it doesn't parse — those fields
// are then simply omitted from the post rather than sent malformed.
function mfgExpToISO(v){
const dt=parseMEDate(v);
if(!dt)return null;
return `${dt.getFullYear()}-${pad2(dt.getMonth()+1)}-${pad2(dt.getDate())}`;
}
// Picker-only entry (same pattern as Batch Issuance/Work Order) for the two
// plain MFG/EXP fields here — 'mfg' reads/writes _mfgDate, 'exp' _expDate.
// Both fields always share ONE format (changing either dropdown changes
// both) — reset to null (→ redetected) wherever _mfgDate/_expDate get
// reset to a fresh receipt (see closeRcptModal() and wherever a new order
// is loaded into the modal).
let _rcptDateFmt=null;
function rcptCurFmt(){
if(!_rcptDateFmt)_rcptDateFmt=detectFormat(_mfgDate)||detectFormat(_expDate)||'DMY';
return _rcptDateFmt;
}
function reformatDate(value,newFmtId){
const dt=parseMEDate(value);
if(!dt)return value;
const iso=`${dt.getFullYear()}-${pad2(dt.getMonth()+1)}-${pad2(dt.getDate())}`;
return formatByPicked(iso,newFmtId);
}
function rcptDateField(which,value,locked){
if(locked)return `<input class="fi fi-mono" id="inp-${which}date" value="${esc(value)}" readonly style="background:var(--bg2);cursor:not-allowed"/>`;
const fmtId=rcptCurFmt();
return `<div style="display:flex;gap:4px;align-items:center">
<select class="fi" data-rdfmt="1" style="width:100px;flex-shrink:0;font-size:11px;padding:0 16px 0 6px">
${DATE_FORMATS.map(f=>`<option value="${f.id}" ${fmtId===f.id?'selected':''}>${f.label}</option>`).join('')}
</select>
<input class="fi fi-mono" id="inp-${which}date" value="${esc(value)}" placeholder="Pick a date" readonly autocomplete="off" style="flex:1;min-width:0;cursor:pointer;background:var(--surface2)" data-rdpopen="${which}"/>
<button type="button" class="date-pick-btn" data-rdp="${which}" title="Pick date" style="flex-shrink:0;width:34px;height:34px;border:1px solid var(--border2);border-radius:7px;background:var(--surface);color:var(--text3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer"><i data-lucide="calendar" style="width:13px;height:13px"></i></button>
</div>`;
}
function getDatePickerEl(){
let el=document.getElementById('inline-date-input');
if(!el){
el=document.createElement('input');el.id='inline-date-input';
el.style.cssText='position:fixed;z-index:9000;width:130px;height:30px;opacity:.01;border:none';
document.body.appendChild(el);
}
return el;
}
function openRcptDatePicker(which,btn){
const fmtId=rcptCurFmt();
const kind=fmtById(fmtId).kind;
const el=getDatePickerEl();
el.type=kind==='full'?'date':'month';
el.min='';
if(which==='exp'){
const md=parseMEDate(_mfgDate);
if(md){
const pad=n=>String(n).padStart(2,'0');
if(kind==='full'){
const nd=new Date(Date.UTC(md.getFullYear(),md.getMonth(),md.getDate()+1));
el.min=`${nd.getUTCFullYear()}-${pad(nd.getUTCMonth()+1)}-${pad(nd.getUTCDate())}`;
}else{
const nm=new Date(Date.UTC(md.getFullYear(),md.getMonth()+1,1));
el.min=`${nm.getUTCFullYear()}-${pad(nm.getUTCMonth()+1)}`;
}
}
}
const r=btn.getBoundingClientRect();
const z=parseFloat(getComputedStyle(document.body).zoom)||1;
el.style.left=(r.left/z)+'px';
el.style.top=((r.bottom+4)/z)+'px';
el.value='';
el.onchange=()=>{
if(!el.value)return;
const v=formatByPicked(el.value,fmtId);
if(which==='mfg')_mfgDate=v; else _expDate=v;
render();
};
el.focus();
if(el.showPicker)el.showPicker();
}
function remainingQty(){return Math.max(0,(_order?.PlannedQuantity||0)-(_order?.CompletedQuantity||0));}
function returnLineTotal(l){
return l.batches&&l.batches.length>1?l.batches.reduce((s,b)=>s+(parseFloat(b.returnQty)||0),0):(parseFloat(l.returnQty)||0);
}
async function doReturnComponents(){
if(!_order){toast('Load an order first','err');return;}
const toReturn=_retLines.filter(l=>returnLineTotal(l)>0);
if(!toReturn.length){toast('Enter a return quantity for at least one component','err');return;}
for(const l of toReturn){
const total=returnLineTotal(l);
if(total>l.issuedQty){toast(`${l.itemCode}: return qty (${total}) exceeds issued qty (${l.issuedQty})`,'err');return;}
// Multi-batch lines always carry a real batch number (picked from stock);
// only the free-text single-batch case can be left blank.
const multi=l.batches&&l.batches.length>1;
if(_retBatchReq&&!multi&&!l.batchNumber.trim()){toast(`${l.itemCode}: batch number is required`,'err');return;}
}
if(!requireSapLogin())return;
if(!confirm(`Return ${toReturn.length} component(s) to stock as defective?`))return;
_retSubmitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const payload={
company:_co,
branchId:bplId,
DocumentLines:toReturn.map(l=>{
const total=returnLineTotal(l);
const dl={BaseEntry:_order.AbsoluteEntry,BaseLine:l.lineNum,BaseType:202,Quantity:total,WarehouseCode:l.warehouse};
if(l.batches&&l.batches.length>1){
dl.BatchNumbers=l.batches.filter(b=>b.returnQty>0).map(b=>({BatchNumber:b.BatchNumber,Quantity:b.returnQty}));
}else if(l.batchNumber.trim()){
dl.BatchNumbers=[{BatchNumber:l.batchNumber.trim(),Quantity:total}];
}
return dl;
}),
};
if(_postDate){payload.DocDate=_postDate;payload.DocDueDate=_postDate;}
try{
const r=await api('POST','/sap/return-components',payload);
toast(`Components returned — Doc #${r.data?.DocNum||r.data?.DocEntry||''}`);
await loadOrder(_order.AbsoluteEntry); // refresh issued quantities
}catch(e){toast(e.message||'Failed','err');}
_retSubmitting=false;render();
}
async function doReceipt(){
if(!_order){toast('Load an order first','err');return;}
const lines=_recvLines.filter(rl=>(parseFloat(rl.qty)||0)>0);
if(!lines.length){toast('Quantity must be > 0','err');return;}
if(lines.some(rl=>!rl.warehouse)){toast('Select a warehouse for every line','err');return;}
// Batch/date fields are required ONLY when "Batch Number Required" is on.
if(_batchReq){
if(!_batchNumber.trim()){toast('Batch number is required','err');return;}
if(!_mfgDate.trim()){toast('Mfr Date is required when batch is required','err');return;}
if(!_expDate.trim()){toast('Expiration Date is required when batch is required','err');return;}
if(!validMfgExp(_mfgDate)){toast(`Invalid Mfr Date "${_mfgDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
if(!validMfgExp(_expDate)){toast(`Invalid Expiration Date "${_expDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
}
if(_requireFullQty&&Math.abs(recvTotal()-remainingQty())>0.0001){
toast(`Total receipt quantity (${fmtQty(recvTotal())}) must equal the remaining planned quantity (${fmtQty(remainingQty())})`,'err');return;
}
if(!requireSapLogin())return;
_submitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const mfgISO=mfgExpToISO(_mfgDate);
const expISO=mfgExpToISO(_expDate);
const payload={
company:_co,
itemCode:_order.ItemNo||'', // lets the backend locate the batch master to fix its dates
branchId:bplId,
batchReq:_batchReq, // → OIGN.U_IS_BATCH_REQ (1/0) on the header
batchNo:_batchReq?_batchNumber.trim():'', // → OIGN.U_BTCHNO header UDF
DocumentLines:lines.map(rl=>rl.isByProduct?({
// Process-loss/by-product line (e.g. ICO10791) — a real, DIFFERENT
// component of the Production Order (its own BaseLine), not another
// warehouse split of the FG. No batch tracking (these items aren't
// batch-managed), no Reject/Complete classification (that's a FG
// production-quality concept, not applicable to a waste/scrap item).
BaseEntry:_order.AbsoluteEntry,
BaseLine:rl.lineNum,
BaseType:202,
Quantity:parseFloat(rl.qty)||0,
WarehouseCode:rl.warehouse,
TransactionType:'C',
isByProduct:true,
}):({
BaseEntry:_order.AbsoluteEntry,
BaseLine:0,
BaseType:202,
Quantity:parseFloat(rl.qty)||0,
WarehouseCode:rl.warehouse,
TransactionType:whTran(rl.warehouse)==='Reject'?'R':'C',
CostingCode:_variety||undefined,
// Only carry batch details when the user asked for batch tracking.
...(_batchReq?{BatchNumbers:[{
BatchNumber:_batchNumber.trim(),
Quantity:parseFloat(rl.qty)||0,
...(mfgISO?{ManufacturingDate:mfgISO}:{}),
...(expISO?{ExpiryDate:expISO}:{}),
}]}:{})
}))
};
if(_postDate){payload.DocDate=_postDate;payload.DocDueDate=_postDate;}
// Autoclave Rejection (FG only) — informational, saved to app DB only on a
// successful SAP post. Sent alongside, but the backend keeps it out of the
// SAP document entirely.
if(_autoclaveEnabled&&_isFgItem){
const qty=parseFloat(_autoclaveQty)||0;
if(qty>0){payload.autoclaveRejections=[qty];payload.itemCode=_order.ItemNo||'';}
}
try{
const r=await api('POST','/sap/receipt-production',payload);
_success={docEntry:r.data?.DocEntry||'',docNum:r.data?.DocNum||''};
toast('Receipt from Production posted!');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
// ═══════════════════════════════════════
function render(){
try{_doRender();}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}
if(window.lucide&&lucide.createIcons)lucide.createIcons();
}
function closeRcptModal(){_modalOpen=false;_order=null;_success=null;_recvLines=[];_batchReq=true;_batchNumber='';_batchLocked=false;_mfgDate='';_expDate='';_datesLocked=false;_rcptDateFmt=null;_variety='';_autoclaveQty='';_isFgItem=false;_retBatchReq=true;render();loadReadyOrders();}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
// Return Components lives on this page too — a user holding ONLY that
// permission still gets in; each form below is gated individually.
if(!CAN_RECEIPT_PO&&!CAN_RETURN_COMP){
app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You\'re not assigned the "Receipt from Production" approval step. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
document.getElementById('rcpt-detail-modal')?.classList.remove('show');
return;
}
// Company + Branch
const c1=mk('card');
const brOpts=_branches.map(b=>`<option value="${b.BPLId}" ${b.BPLId===2?'selected':''}>${esc(b.BPLName)} (${b.BPLId})</option>`).join('')||'<option value="2">FACTORY (2)</option>';
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> Receipt from Production</div>
<div class="card-bd">
<div class="fi-row">
<div class="fg"><label class="fl">Company</label><select class="fi" id="sel-co">
${coOptions(_co)}
</select></div>
<div class="fg"><label class="fl">Branch</label><select class="fi" id="sel-branch">${brOpts}</select></div>
</div>
</div>`;
app.appendChild(c1);
// Order input (manual AbsoluteEntry — still supported alongside the picker)
// Orders ready to receive — square-tile grid, opens the detail popup on click.
const c2b=mk('card');
const gridBody=_readyLoading
?'<div class="empty-msg"><span class="sp" style="border-color:rgba(13,187,138,.25);border-top-color:var(--green)"></span> Loading released orders...</div>'
:!_readyOrders.length?'<div class="empty-msg">No issued orders are awaiting receipt right now.</div>'
:`<div class="po-grid">${_readyOrders.map(o=>`<div class="po-card${_order&&_order.AbsoluteEntry===o.AbsoluteEntry?' selected':''}" data-ready="${o.AbsoluteEntry}">
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${o._issueStatus==='partial'?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(245,166,35,.12);color:var(--amber)">PARTIAL</span>':''}</div>
<div class="po-icon">📥</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</div>
<div class="po-meta"><span>PWO No#<b>${o.DocumentNumber||'-'}</b>${o.PostingDate?` · ${fmtDate(o.PostingDate)}`:''}</span><span>Qty <b>${o.PlannedQuantity}</b> · Done <b>${o.CompletedQuantity||0}</b> · WH <b>${esc(o.Warehouse||'-')}</b></span></div>
${(o.WoNo||o.BatchNumber)?`<div class="po-meta"><span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span></div>`:''}
</div>`).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px">
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--green);color:#fff;white-space:nowrap">Search</button>
</div>`;
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Receive<span class="right">${_readyOrders.length}</span></div>
<div class="card-bd">
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">Only Released orders that are fully issued (all components) are listed here, per Admin → System Settings → "Require full issuance before Receipt/Close". A partially-issued order isn't shown — issue the rest first, then it appears here.</div>
${searchRow}${gridBody}${renderPager()}</div>`;
app.appendChild(c2b);
renderModal();
// Wire (page-level controls)
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=async function(){_co=this.value;await Promise.all([loadWH(),loadBranches(),loadVarieties(),loadWhTranMap()]);loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;});
q('#rp-search')&&(q('#rp-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadReadyOrders(true);}});
q('#rp-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}});
q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}});
document.querySelectorAll('[data-ready]').forEach(c=>{c.onclick=()=>loadOrder(parseInt(c.dataset.ready));});
},0);
}
// ═══════════════════════════════════════
// DETAIL / RECEIPT POPUP
// ═══════════════════════════════════════
function renderModal(){
const bg=document.getElementById('rcpt-detail-modal');
const inner=document.getElementById('rcpt-modal-inner');
if(!_modalOpen){bg.classList.remove('show');return;}
bg.classList.add('show');
// Success
if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Receipt from Production — Posted</span><button class="modal-close" id="rcpt-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Receipt Posted!</div>
<p style="font-size:13px;color:var(--text3)">Finished goods received for Production Order #${esc(String(_order?.DocumentNumber||_order?.AbsoluteEntry||''))}</p>
<div class="suc-pill">DocEntry #${esc(String(_success.docEntry))} | DocNum #${esc(String(_success.docNum))}</div>
<div class="alert al-green" style="max-width:400px;margin:10px auto;text-align:left">
<strong>${esc(_order?.ItemNo||'')}</strong> — ${fmtQty(recvTotal())} ${esc(_order?.InventoryUOM||'PCS')} received into inventory.<br/>
${_batchReq?`Batch: <strong>${esc(_batchNumber)}</strong> | `:''}Warehouse: <strong>${esc(_recvLines.map(rl=>rl.warehouse).filter(Boolean).join(', '))}</strong>${_batchReq&&(_mfgDate||_expDate)?`<br/>Mfr: <strong>${esc(_mfgDate||'-')}</strong> | Exp: <strong>${esc(_expDate||'-')}</strong>`:''}
</div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
<button class="btn" id="btn-close-order" style="flex:1;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)">Close Order</button>
<button class="btn b-new" id="btn-new">Done</button>
</div>
</div></div>`;
setTimeout(()=>{
q('#rcpt-modal-x').onclick=closeRcptModal;
q('#btn-new').onclick=closeRcptModal;
q('#btn-close-order')&&(q('#btn-close-order').onclick=function(){
window.location.href=`/close-production#order=${_order.AbsoluteEntry}&company=${encodeURIComponent(_co)}`;
});
},0);
return;
}
// Loading
if(_loadingOrder||!_order){
inner.innerHTML=`<div class="modal-hd"><span>Loading Order…</span><button class="modal-close" id="rcpt-modal-x">✕</button></div>
<div class="modal-bd"><div class="empty-msg"><span class="sp" style="border-color:rgba(13,187,138,.25);border-top-color:var(--green)"></span> Loading order details...</div></div>`;
setTimeout(()=>{q('#rcpt-modal-x').onclick=closeRcptModal;},0);
return;
}
const d=_order;
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
// Autoclave Rejection (FG items only, flag-gated). Purely informational —
// single manual value, separate from all receipt quantities, never sent to SAP.
const autoclaveHtml=(_autoclaveEnabled&&_isFgItem&&CAN_RECEIPT_PO)?`
<div class="fg" style="border-top:1px solid var(--border);margin-top:12px;padding-top:10px">
<label class="fl" style="display:flex;align-items:center;gap:6px">Autoclave Rejection <span style="font-weight:400;text-transform:none;color:var(--text3)">— FG only, for records; not sent to SAP, no effect on quantities</span></label>
<input class="fi fi-mono" id="inp-autoclave" type="number" min="0" step="1" value="${esc(String(_autoclaveQty))}" placeholder="Rejection Qty" style="text-align:right"/>
</div>`:'';
const fgRowCount=_recvLines.filter(rl=>!rl.isByProduct).length;
const receiptHtml=CAN_RECEIPT_PO?`
<div class="fg" style="margin-top:12px"><label class="fl">Warehouse, Quantity &amp; Transaction Type * <span style="font-weight:400;text-transform:none;color:var(--text3)">(Transaction Type comes automatically from the warehouse — set by admin)</span></label>
<div id="recv-lines">${_recvLines.map((rl,i)=>{
// Transaction Type is auto-derived from the warehouse's admin mapping
// (read-only) — the user only picks the warehouse & quantity.
const tt=whTran(rl.warehouse);
// By-product/process-loss rows (e.g. ICO10791, a negative-qty
// component already on the Production Order) are a DIFFERENT item
// from the FG being received — shown read-only in an Item column so
// they're not mistaken for another warehouse split of the FG. They
// can't be removed (they're a real, mandatory SAP component line).
const itemCell=rl.isByProduct
?`<div class="fi" style="flex:1.1;display:flex;flex-direction:column;justify-content:center;line-height:1.25;background:var(--bg2,var(--surface2));cursor:not-allowed" title="${esc(rl.itemName)} — process loss/by-product, from the Production Order">
<span style="font-family:var(--mono);font-weight:700">${esc(rl.itemCode)}</span>
<span style="font-size:9.5px;color:var(--text3)">${esc(rl.itemName)}</span>
</div>`
:`<div class="fi" style="flex:1.1;display:flex;align-items:center;font-weight:700;background:var(--bg2,var(--surface2));cursor:not-allowed;font-family:var(--mono)">${esc(d.ItemNo||'')}</div>`;
const unit=rl.uom||(rl.isByProduct?'':(d.InventoryUOM||''));
return `
<div class="recv-row">
${itemCell}
<select class="fi" data-rl="${i}" data-rf="warehouse" style="flex:1.4">${_wh.map(w=>`<option value="${esc(w.code)}" ${rl.warehouse===w.code?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}${_whTranMap[w.code]?` [${_whTranMap[w.code]==='R'?'Reject':'Complete'}]`:''}</option>`).join('')}</select>
<div style="position:relative;width:110px">
<input class="fi fi-mono" data-rl="${i}" data-rf="qty" type="number" min="${rl.isByProduct?'0.000001':'0.001'}" step="${rl.isByProduct?'0.000001':'0.001'}" value="${rl.qty}" style="text-align:right;width:100%;${unit?'padding-right:38px':''}"/>
${unit?`<span style="position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:9.5px;color:var(--text3);pointer-events:none">${esc(unit)}</span>`:''}
</div>
<div class="fi" data-rl-tt="${i}" style="flex:1;display:flex;align-items:center;font-weight:700;background:var(--bg2,var(--surface2));cursor:not-allowed;color:${tt==='Reject'?'var(--red)':'var(--green)'}">${tt}</div>
${(!rl.isByProduct&&fgRowCount>1)?`<button type="button" class="recv-remove" data-rl="${i}" title="Remove">✕</button>`:''}
</div>`;}).join('')}</div>
<button type="button" class="btn b-cancel" id="btn-add-wh" style="margin-top:6px;padding:6px 14px;font-size:12px">+ Add Warehouse</button>
<div id="recv-total" style="margin-top:6px;font-size:11px;color:var(--text3)">${recvTotalHtml()}</div>
</div>
<div class="fg" style="margin-top:4px;border-top:1px solid var(--border);padding-top:10px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--text);font-weight:600">
<input type="checkbox" id="chk-batchreq" ${_batchReq?'checked':''} style="width:16px;height:16px;accent-color:var(--green);cursor:pointer"/>
Batch Number Required <span style="font-weight:400;color:var(--text3)">— when on, batch no. + Mfr/Exp dates are captured (saved to SAP as U_IS_BATCH_REQ)</span>
</label>
</div>
${_batchReq?`
<div class="fg"><label class="fl">Batch Number * <span style="font-weight:400;text-transform:none;color:var(--text3)">${_batchLocked?'(from order Remarks)':'(enter manually — no Remarks on this order)'}</span></label>
<input class="fi fi-mono" id="inp-batch" value="${esc(_batchNumber)}" placeholder="e.g. BATCH-09-04-2026" autocomplete="off" ${_batchLocked?'readonly':''} style="${_batchLocked?'background:var(--bg2);cursor:not-allowed':''}"/>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Mfr Date * <span style="font-weight:400;text-transform:none;color:var(--text3)">${_datesLocked?'(from Batch Issuance Intimation)':'(enter manually)'}</span></label>
${rcptDateField('mfg',_mfgDate,_datesLocked)}
</div>
<div class="fg"><label class="fl">Expiration Date * <span style="font-weight:400;text-transform:none;color:var(--text3)">${_datesLocked?'(from Batch Issuance Intimation)':'(enter manually)'}</span></label>
${rcptDateField('exp',_expDate,_datesLocked)}
</div>
</div>`:''}
<div class="fg"><label class="fl">Posting Date</label><input class="fi" id="inp-postdate" type="date" value="${_postDate||today()}"/></div>
<div class="alert al-amber" style="margin-top:4px">${_batchReq?(_batchLocked?'Batch number is taken from the production order\'s Remarks (set when it was created) and will be created in SAP B1.':'Enter the batch number for the finished product being received into inventory. This batch will be created in SAP B1.'):'Batch tracking is OFF for this receipt — no batch number or Mfr/Exp dates will be recorded in SAP.'}</div>
${autoclaveHtml}
<div class="btn-bar" style="margin-top:8px">
<button class="btn b-receipt" id="btn-receipt" ${_submitting||!qtyMatches?'disabled':''}><span id="sp-receipt" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Post Receipt</button>
</div>`:'';
// Return Components — corrective action, own permission
let returnHtml='';
if(CAN_RETURN_COMP&&_retLines.length){
const rows=_retLines.map((l,i)=>{
const multi=l.batches&&l.batches.length>1;
const qtyCell=multi
?`<span id="ret-total-${i}" class="ret-total">${fmtQty(returnLineTotal(l))}</span>`
:`<input class="fi fi-mono" data-ret="${i}" data-rf="returnQty" type="number" min="0" max="${l.issuedQty}" step="0.001" value="${l.returnQty}" style="height:30px;font-size:12px;text-align:right;width:80px"/>`;
const batchCell=multi
?`<div class="ret-batches">${l.batches.map((b,bi)=>`
<div class="ret-batch-row">
<span class="ret-batch-num">${esc(b.BatchNumber)}</span>
<span class="ret-batch-avail">avail ${fmtQty(b.Quantity)}</span>
<input class="fi fi-mono" data-ret="${i}" data-batch="${bi}" data-brf="returnQty" type="number" min="0" max="${b.Quantity}" step="0.001" value="${b.returnQty}"/>
</div>`).join('')}</div>`
:`<input class="fi fi-mono" data-ret="${i}" data-rf="batchNumber" value="${esc(l.batchNumber)}" placeholder="Batch" title="${l.batchLocked?'Current batch in warehouse':''}" ${l.batchLocked?'readonly':''} style="height:30px;font-size:12px;width:110px${l.batchLocked?';background:var(--bg2);cursor:not-allowed':''}"/>`;
return `<tr>
<td class="ret-item">${esc(l.itemCode)}</td>
<td class="ret-desc">${esc(l.itemName)}</td>
<td class="ret-issued">${fmtQty(l.issuedQty)}</td>
<td>${qtyCell}</td>
<td><select class="fi" data-ret="${i}" data-rf="warehouse" style="height:30px;font-size:12px;padding:2px 20px 2px 6px;width:160px" title="${esc(l.warehouse)}">${_wh.map(w=>`<option value="${esc(w.code)}" ${l.warehouse===w.code?'selected':''}>${esc(w.code)} — ${esc(w.name||'')}</option>`).join('')}</select></td>
<td>${batchCell}</td>
</tr>`;
}).join('');
returnHtml=`<div style="border-top:1px solid var(--border);margin-top:14px;padding-top:12px">
<div class="fl" style="display:flex;align-items:center;gap:6px">Return Components <span style="font-weight:400;text-transform:none;color:var(--text3)">— defective quantities back to stock</span></div>
${_showReturn?`
<div class="alert al-amber" style="margin-top:6px">Enter the DEFECTIVE quantity to return for each affected component. Returned quantities go back into the selected warehouse and reduce the order's issued quantity in SAP.</div>
<div class="fg" style="margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--text);font-weight:600">
<input type="checkbox" id="chk-ret-batchreq" ${_retBatchReq?'checked':''} style="width:16px;height:16px;accent-color:var(--green);cursor:pointer"/>
Batch Number Required <span style="font-weight:400;color:var(--text3)">— when on, every returned line must have a batch number before submitting</span>
</label>
</div>
<div style="overflow-x:auto"><table class="ret-tbl">
<thead><tr>
<th>Item</th><th>Description</th><th style="text-align:right">Issued</th><th>Return Qty</th><th>Warehouse</th><th>Batch</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="btn-bar" style="margin-top:10px">
<button class="btn" id="btn-return" ${_retSubmitting||!_retLines.some(l=>returnLineTotal(l)>0)?'disabled':''} style="flex:1;background:var(--amber);color:#fff"><span id="sp-return" style="display:${_retSubmitting?'inline-block':'none'}" class="sp"></span> Return Components</button>
<button class="btn b-cancel" id="btn-ret-hide">Hide</button>
</div>`
:`<button class="btn" id="btn-ret-show" style="width:100%;margin-top:6px;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)">Return Components (defective items)</button>`}
</div>`;
}
inner.innerHTML=`
<div class="modal-hd">
<span style="font-family:var(--mono);color:var(--green)">${esc(d.ItemNo)}</span>
<span style="color:var(--text3);font-size:11px">Order #${d.DocumentNumber||d.AbsoluteEntry}</span>
<button class="modal-close" id="rcpt-modal-x" style="margin-left:auto">✕</button>
</div>
<div class="modal-bd">
<div class="order-info">
<div class="oi-item"><span class="oi-l">Product</span><span class="oi-v" style="color:var(--green)">${esc(d.ItemNo)}</span></div>
<div class="oi-item"><span class="oi-l">Description</span><span class="oi-v" style="font-family:var(--font)">${esc(d.ProductDescription||'-')}</span></div>
<div class="oi-item"><span class="oi-l">Planned</span><span class="oi-v">${d.PlannedQuantity}</span></div>
<div class="oi-item"><span class="oi-l">Completed</span><span class="oi-v">${d.CompletedQuantity||0}</span></div>
<div class="oi-item"><span class="oi-l">Remaining</span><span class="oi-v" style="color:var(--amber)">${Math.max(0,(d.PlannedQuantity||0)-(d.CompletedQuantity||0))}</span></div>
<div class="oi-item"><span class="oi-l">Warehouse</span><span class="oi-v">${esc(d.Warehouse||'-')}</span></div>
</div>
${receiptHtml}
${returnHtml}
</div>`;
setTimeout(()=>{
q('#rcpt-modal-x').onclick=closeRcptModal;
q('#btn-add-wh')&&(q('#btn-add-wh').onclick=function(){
// Insert before any by-product rows so the FG's own warehouse splits
// stay grouped together at the top of the table.
const insertAt=_recvLines.findIndex(rl=>rl.isByProduct);
const newRow={warehouse:_wh[0]?.code||'',qty:0,transType:whTran(_wh[0]?.code||'')};
if(insertAt===-1)_recvLines.push(newRow); else _recvLines.splice(insertAt,0,newRow);
renderModal();
});
document.querySelectorAll('.recv-remove').forEach(el=>{
el.onclick=function(){_recvLines.splice(parseInt(this.dataset.rl),1);renderModal();};
});
document.querySelectorAll('[data-rl]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.rl),f=this.dataset.rf;
const rl=_recvLines[i];if(!rl)return;
if(f==='qty'){rl.qty=parseFloat(this.value)||0;refreshRecvTotal();return;} // qty: no full re-render (keep focus) — just patch the Total line
rl.warehouse=this.value;
rl.transType=whTran(this.value);
// update the Transaction Type cell for this row immediately
const tt=rl.transType;
const ttEl=document.querySelector(`[data-rl-tt="${i}"]`);
if(ttEl){ttEl.textContent=tt;ttEl.style.color=(tt==='Reject'?'var(--red)':'var(--green)');}
};
});
q('#chk-batchreq')&&(q('#chk-batchreq').onchange=function(){_batchReq=this.checked;renderModal();});
q('#chk-ret-batchreq')&&(q('#chk-ret-batchreq').onchange=function(){_retBatchReq=this.checked;renderModal();});
q('#inp-batch')&&!_batchLocked&&(q('#inp-batch').oninput=function(){_batchNumber=this.value.toUpperCase();this.value=this.value.toUpperCase();});
document.querySelectorAll('[data-rdp]').forEach(btn=>btn.onclick=function(){openRcptDatePicker(this.dataset.rdp,this);});
document.querySelectorAll('[data-rdpopen]').forEach(inp=>inp.onclick=function(){openRcptDatePicker(this.dataset.rdpopen,this);});
document.querySelectorAll('[data-rdfmt]').forEach(sel=>sel.onchange=function(){
_rcptDateFmt=this.value;
_mfgDate=reformatDate(_mfgDate,this.value);
_expDate=reformatDate(_expDate,this.value);
render();
});
q('#inp-postdate')&&(q('#inp-postdate').onchange=function(){_postDate=this.value;});
// Autoclave Rejection wiring — single field, updates state in place so
// it keeps focus (no re-render on typing).
q('#inp-autoclave')&&(q('#inp-autoclave').oninput=function(){_autoclaveQty=this.value;});
q('#btn-receipt')&&(q('#btn-receipt').onclick=doReceipt);
// Return Components wiring
q('#btn-ret-show')&&(q('#btn-ret-show').onclick=()=>{_showReturn=true;renderModal();});
q('#btn-ret-hide')&&(q('#btn-ret-hide').onclick=()=>{_showReturn=false;renderModal();});
q('#btn-return')&&(q('#btn-return').onclick=doReturnComponents);
document.querySelectorAll('[data-ret]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.ret);
const l=_retLines[i];if(!l)return;
if(this.dataset.brf==='returnQty'){
const bi=parseInt(this.dataset.batch);
const b=l.batches?.[bi];if(!b)return;
b.returnQty=Math.min(parseFloat(this.value)||0,b.Quantity);
this.value=b.returnQty;
l.returnQty=returnLineTotal(l);
const totalEl=q(`#ret-total-${i}`);if(totalEl)totalEl.textContent=fmtQty(l.returnQty);
}else if(this.dataset.rf==='returnQty'){
l.returnQty=parseFloat(this.value)||0;
}else{
l[this.dataset.rf]=this.value;
}
const btn=q('#btn-return');if(btn)btn.disabled=_retSubmitting||!_retLines.some(x=>returnLineTotal(x)>0);
};
});
},0);
}
async function init(){
try{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
_postDate=today();
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{
const cfg=await api('GET','/config');
if(cfg&&typeof cfg.receiptRequireFullQty==='boolean')_requireFullQty=cfg.receiptRequireFullQty;
if(cfg&&typeof cfg.receiptAutoclaveRejection==='boolean')_autoclaveEnabled=cfg.receiptAutoclaveRejection;
if(cfg&&Array.isArray(cfg.receiptAutoclaveItemGroups))_autoclaveGroups=cfg.receiptAutoclaveItemGroups.map(String);
}catch(_e){}
await Promise.all([loadWH(),loadBranches(),loadVarieties(),loadWhTranMap()]);
render();
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry) await loadOrder(entry);
}else{
// No specific order in the URL — show what's ready to receive by default.
loadReadyOrders();
}
}catch(e){console.error('Init error:',e);render();}
}
init();
// Hash-only link changes (e.g. the sidebar "Pending Actions" bell linking
// here for a different order while already on this page) don't reload the
// page, so init() alone would never pick up the new order — re-check on
// hashchange too. See issue-production.html for the fuller explanation.
window.addEventListener('hashchange',()=>{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry)loadOrder(entry);
}
});
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Receipts History — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--green);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--green);background:rgba(13,187,138,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1100px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:600}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--green);box-shadow:0 0 0 2px rgba(13,187,138,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
.fi-mono{font-family:var(--mono);font-size:12px}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);max-width:280px}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
/* Receipts table */
.rh-wrap{overflow-x:auto}
table.rh{width:100%;border-collapse:separate;border-spacing:0;font-size:11.5px;min-width:820px}
table.rh th{text-align:left;padding:8px 10px;font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap}
table.rh td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
table.rh tr:hover td{background:rgba(13,187,138,.05)}
.rh-mono{font-family:var(--mono)}
.rh-doc{font-family:var(--mono);font-weight:700;color:var(--green)}
.rh-pwo{font-family:var(--mono);color:var(--teal)}
.rh-item{font-family:var(--mono);color:var(--text)}
.rh-desc{color:var(--text2);max-width:280px;white-space:normal}
.tt-pill{font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px}
.tt-c{background:rgba(13,187,138,.12);color:var(--green)}
.tt-r{background:rgba(229,62,62,.12);color:var(--red)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
<a href="/issue-production" class="nav-link">Issue</a>
<a href="/receipt-production" class="nav-link">Receipt</a>
<a href="/receipts-history" class="nav-link active">Receipts History</a>
<a href="/close-production" class="nav-link">Close</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _rows=[];
let _loading=false;
let _search='';
let _page=0,_pageSize=20,_total=null,_rawCount=0;
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtDate(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';return `${String(d.getUTCDate()).padStart(2,'0')} ${_MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;}
function fmtQty(n){return(Math.round((parseFloat(n)||0)*1000)/1000).toString();}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
async function load(resetPage){
if(resetPage)_page=0;
_loading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{
const r=await api('GET',`/sap/receipts-history?top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}`);
_rows=r.data||[];_rawCount=_rows.length;_total=(typeof r.total==='number')?r.total:null;
}catch(e){toast(e.message,'err');_rows=[];_rawCount=0;_total=null;}
_loading=false;render();
}
function hasNext(){ if(_total!=null) return (_page+1)*_pageSize<_total; return _rawCount>=_pageSize; }
function render(){try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
const c1=mk('card');
c1.innerHTML=`<div class="card-hd"><span>Receipts History</span><span class="right">Posted Receipt from Production documents</span></div>
<div class="card-bd">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<div class="fg" style="margin:0;min-width:150px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div class="fg" style="margin:0;flex:1;min-width:240px"><label class="fl">Search</label>
<input class="fi fi-mono" id="rh-search" value="${esc(_search)}" placeholder="PWO No, receipt doc no, item code / description or batch…"/></div>
<button class="btn" id="rh-search-btn" style="background:var(--green);color:#fff;height:36px">Search</button>
</div>
<div style="font-size:10px;color:var(--text3);margin-top:6px">Newest first. Search matches PWO No, receipt document number, item code/description, or batch number.</div>
</div>`;
app.appendChild(c1);
const c2=mk('card');
let body;
if(_loading){
body=`<div class="empty-msg"><span class="sp" style="border-color:rgba(13,187,138,.25);border-top-color:var(--green)"></span> Loading receipts…</div>`;
}else if(!_rows.length){
body=`<div class="empty-msg">No receipts found${_search.trim()?` for "${esc(_search.trim())}"`:''}.</div>`;
}else{
const trs=_rows.map(r=>`<tr>
<td class="rh-doc">${esc(r.receiptDocNum)}</td>
<td class="rh-mono" style="white-space:nowrap">${fmtDate(r.docDate)}</td>
<td class="rh-pwo">${esc(r.pwoNum||r.pwoAbsEntry||'-')}</td>
<td class="rh-item">${esc(r.itemCode)}</td>
<td class="rh-desc">${esc(r.description||'-')}</td>
<td class="rh-mono" style="text-align:right">${fmtQty(r.quantity)}</td>
<td><span class="tt-pill ${r.transType==='R'?'tt-r':'tt-c'}">${r.transType==='R'?'REJECT':'COMPLETE'}</span></td>
<td class="rh-mono">${esc(r.warehouse||'-')}</td>
<td class="rh-mono" style="color:var(--amber)">${esc(r.batch||'-')}</td>
</tr>`).join('');
body=`<div class="rh-wrap"><table class="rh">
<thead><tr>
<th>Receipt Doc No</th><th>Date</th><th>PWO No</th><th>Item</th><th>Description</th>
<th style="text-align:right">Qty</th><th>Type</th><th>Whs</th><th>Batch</th>
</tr></thead>
<tbody>${trs}</tbody>
</table></div>`;
}
const totalPages=_total!=null?Math.max(1,Math.ceil(_total/_pageSize)):null;
const pager=_loading?'':`<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn" id="rh-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${_page===0?';opacity:.45;cursor:not-allowed':''}">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">${_total!=null?`Page ${_page+1} of ${totalPages} · ${_total} line${_total===1?'':'s'}`:`Page ${_page+1}`}</span>
<button class="btn" id="rh-next" ${hasNext()?'':'disabled'} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${hasNext()?'':';opacity:.45;cursor:not-allowed'}">Next ›</button>
</div>`;
c2.innerHTML=`<div class="card-hd"><span>Receipts</span><span class="right">${_rows.length} on this page</span></div><div class="card-bd">${body}${pager}</div>`;
app.appendChild(c2);
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;load(true);});
q('#rh-search')&&(q('#rh-search').oninput=function(){_search=this.value;});
q('#rh-search')&&(q('#rh-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();load(true);}});
q('#rh-search-btn')&&(q('#rh-search-btn').onclick=()=>load(true));
q('#rh-prev')&&(q('#rh-prev').onclick=()=>{if(_page>0){_page--;load();}});
q('#rh-next')&&(q('#rh-next').onclick=()=>{if(hasNext()){_page++;load();}});
},0);
}
async function init(){
try{
const hash=window.location.hash;
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
render();
load();
}catch(e){console.error('Init error:',e);render();}
}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+964
View File
@@ -0,0 +1,964 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/company-config.js"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Customer Registration — MITRA ERP Portal</title>
<meta name="description" content="Register as a new customer. Fill in your business details, addresses and upload required documents."/>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Sora:wght@400;600;700;800&display=swap" rel="stylesheet"/>
<style>
:root{
--blue:#0070f2;--dark:#0f1923;--light:#f4f6fb;--border:#d1d7e0;
--green:#00b96b;--orange:#f59e0b;--red:#ef4444;--white:#fff;
--radius:10px;--shadow:0 4px 24px rgba(0,0,0,.10);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'IBM Plex Sans',sans-serif;background:#edf1f7;color:#1a2535;min-height:100vh}
.topbar{background:var(--dark);color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 28px;height:56px;position:sticky;top:0;z-index:100;box-shadow:0 2px 20px rgba(0,0,0,.25)}
.topbar-brand{display:flex;align-items:center;gap:12px}
.sap-logo{background:var(--blue);color:#fff;font-family:'Sora',sans-serif;font-weight:800;font-size:13px;padding:5px 11px;border-radius:6px;letter-spacing:1.5px}
.topbar-title{font-size:14px;font-weight:500;color:#94a3b8}
.topbar-right{display:flex;align-items:center;gap:10px}
.topbar-badge{background:rgba(0,112,242,.15);color:#60a5fa;border:1px solid rgba(0,112,242,.25);padding:3px 12px;border-radius:20px;font-size:11px;font-weight:700;letter-spacing:.4px}
.page{max-width:820px;margin:0 auto;padding:32px 20px 60px}
.steps{display:flex;align-items:center;margin-bottom:32px}
.step{display:flex;align-items:center;gap:9px}
.step-line{flex:1;height:2px;background:var(--border);margin:0 8px;transition:background .3s}
.step-line.done{background:var(--green)}
.step-circle{width:32px;height:32px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#8fa3bc;background:#fff;flex-shrink:0;transition:all .3s}
.step.active .step-circle{background:var(--blue);border-color:var(--blue);color:#fff;box-shadow:0 0 0 4px #d0e5fc}
.step.done .step-circle{background:var(--green);border-color:var(--green);color:#fff}
.step-label{font-size:12px;font-weight:600;color:#8fa3bc;white-space:nowrap}
.step.active .step-label{color:var(--blue)}
.step.done .step-label{color:var(--green)}
.card{background:#fff;border-radius:var(--radius);box-shadow:var(--shadow);border:1px solid var(--border);padding:28px 32px;margin-bottom:20px;overflow:hidden}
.card-title{font-family:'Sora',sans-serif;font-size:17px;font-weight:700;margin-bottom:4px}
.card-sub{font-size:13px;color:#6b7a8d;margin-bottom:22px;padding-bottom:16px;border-bottom:1px solid var(--border)}
.sec{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--blue);margin:24px 0 12px;padding-bottom:6px;border-bottom:1px solid #e8eef6}
.grid{display:grid;gap:16px;width:100%}
.g2{grid-template-columns:1fr 1fr}
.g3{grid-template-columns:1fr 1fr 1fr}
.span2{grid-column:span 2}
.span3{grid-column:span 3}
.field{display:flex;flex-direction:column;gap:5px;min-width:0}
.label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:#3d4f61}
.label .req{color:var(--red);margin-left:2px}
.label .auto-tag{color:var(--green);margin-left:4px;font-size:10px;font-weight:600;background:#e6f4eb;padding:1px 5px;border-radius:3px}
input,select,textarea{
padding:10px 12px;border:1.5px solid var(--border);border-radius:var(--radius);
font-size:13.5px;font-family:inherit;background:#fff;color:#1a2535;
transition:border .2s,box-shadow .2s;outline:none;
text-transform:uppercase;width:100%
}
select,input[type=email],input[type=password],.no-upper{text-transform:none}
input:focus,select:focus,textarea:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,112,242,.1)}
input[readonly],select[disabled]{background:#f4f6fb;color:#8fa3bc;cursor:not-allowed}
textarea{resize:vertical;min-height:80px;text-transform:uppercase}
.ferr{font-size:11px;color:var(--red);margin-top:2px}
.btn-row{display:flex;gap:12px;margin-top:28px;justify-content:flex-end}
.btn{padding:11px 26px;border-radius:var(--radius);border:none;font-family:inherit;font-size:14px;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:8px}
.btn-primary{background:var(--blue);color:#fff}
.btn-primary:hover{background:#0057c2;box-shadow:0 4px 16px rgba(0,112,242,.35);transform:translateY(-1px)}
.btn-secondary{background:#fff;color:#1a2535;border:1.5px solid var(--border)}
.btn-secondary:hover{border-color:var(--blue);color:var(--blue)}
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
/* ── ADDRESS BLOCK ── */
.addr-block{padding:18px 20px;background:#f8f9fc;border-radius:10px;border:1px solid var(--border);margin-bottom:14px}
.addr-block-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.addr-block-label{font-size:12px;font-weight:700;color:var(--blue);display:flex;align-items:center;gap:6px}
.addr-block-num{background:var(--blue);color:#fff;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
.addr-remove-btn{padding:4px 12px;font-size:12px;border:1.5px solid var(--red);color:var(--red);border-radius:6px;cursor:pointer;background:none;font-weight:600;transition:all .2s}
.addr-remove-btn:hover{background:var(--red);color:#fff}
.add-addr-btn{display:inline-flex;align-items:center;gap:7px;padding:8px 16px;font-size:12px;font-weight:600;border:1.5px solid var(--blue);color:var(--blue);border-radius:var(--radius);cursor:pointer;background:#fff;transition:all .2s;margin-bottom:16px}
.add-addr-btn:hover{background:#e8f1fd}
/* ── FROZEN ADDRESS NAME (matches vendor) ── */
.addr-name-wrap{position:relative}
.addr-name-frozen{background:#f4f6fb!important;color:#8fa3bc!important;cursor:not-allowed!important;border-color:rgba(0,112,242,.25)!important;padding-right:120px!important}
.addr-name-frozen:focus{box-shadow:none!important;border-color:rgba(0,112,242,.25)!important}
.addr-name-badges{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:5px;pointer-events:none}
.badge-autofill{background:#e6f4eb;color:var(--green);font-size:9.5px;font-weight:700;padding:2px 7px;border-radius:4px;letter-spacing:.4px;white-space:nowrap}
.badge-frozen{background:#e8eef6;color:#8fa3bc;font-size:9.5px;font-weight:700;padding:2px 7px;border-radius:4px;display:flex;align-items:center;gap:3px;white-space:nowrap}
.badge-frozen svg{width:10px;height:10px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.badge{display:inline-flex;align-items:center;padding:3px 11px;border-radius:20px;font-size:11px;font-weight:700}
.b-b2b{background:#e8f1fd;color:var(--blue)}
.b-b2c{background:#e6f4eb;color:var(--green)}
.alert{padding:12px 16px;border-radius:var(--radius);font-size:13px;margin-bottom:18px;display:flex;align-items:flex-start;gap:10px;line-height:1.5}
.a-info{background:#e8f1fd;border:1px solid #b3d0fa;color:#003f8a}
.a-warn{background:#fff9ed;border:1px solid #fcd98a;color:#7a4f00}
.a-ok{background:#e6f4eb;border:1px solid #b7dfc5;color:#0e5027}
.a-err{background:#fce8e8;border:1px solid #f5b8b8;color:#700000}
/* ── ATTACHMENTS ── */
.attach-zone{
border:2px dashed var(--border);border-radius:var(--radius);
padding:20px 16px;background:#f8f9fc;text-align:center;
cursor:pointer;transition:all .2s;position:relative;
min-height:110px;display:flex;flex-direction:column;
justify-content:center;align-items:center
}
.attach-zone:hover,.attach-zone.drag{border-color:var(--blue);background:#e8f1fd}
.attach-zone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;z-index:2}
.attach-zone .az-icon{font-size:28px;margin-bottom:8px;line-height:1}
.attach-zone .az-label{font-size:12px;font-weight:600;color:#3d4f61}
.attach-zone .az-sub{font-size:11px;color:#8fa3bc;margin-top:4px}
.attach-preview{display:flex;align-items:center;gap:10px;padding:10px 12px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);margin-top:8px;word-break:break-all}
.attach-preview .ap-thumb{width:38px;height:38px;border-radius:6px;background:#e8f1fd;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.attach-preview .ap-info{flex:1;min-width:0}
.attach-preview .ap-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.attach-preview .ap-size{font-size:11px;color:#8fa3bc;margin-top:2px}
.attach-preview .ap-rm{padding:3px 9px;font-size:11px;border:1.5px solid var(--red);color:var(--red);border-radius:4px;cursor:pointer;background:none;flex-shrink:0;font-weight:600;transition:all .15s}
.attach-preview .ap-rm:hover{background:var(--red);color:#fff}
/* ── MSME ── */
.msme-toggle{padding:16px 20px;border:2px solid var(--border);border-radius:10px;margin-bottom:14px;background:#fff;width:100%;transition:all .25s}
.msme-toggle.active{border-color:var(--green);background:#f0fdf6}
/* ── REVIEW TABLE ── */
.dtable{width:100%;border-collapse:collapse;font-size:13.5px}
.dtable tr{border-bottom:1px solid #f0f2f5}
.dtable tr:last-child{border:none}
.dtable td{padding:10px 6px;vertical-align:top}
.dtable td:first-child{color:#6b7a8d;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;width:38%;padding-top:12px}
.dtable td.val{font-weight:500}
/* ── SUCCESS ── */
.success-wrap{text-align:center;padding:52px 24px}
.success-icon{font-size:60px;margin-bottom:20px}
.success-wrap h2{font-family:'Sora',sans-serif;font-size:24px;font-weight:700;color:var(--green);margin-bottom:10px}
.success-wrap p{font-size:14px;color:#6b7a8d;max-width:440px;margin:0 auto 18px;line-height:1.6}
.ref-box{display:inline-block;background:#f4f6fb;border:1px solid var(--border);border-radius:8px;padding:10px 24px;font-family:'IBM Plex Mono',monospace;font-size:15px;font-weight:600;color:#0070f2;margin:16px 0}
.sp{width:16px;height:16px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;display:inline-block;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:66px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast{padding:12px 18px;border-radius:var(--radius);font-size:13.5px;font-weight:500;box-shadow:var(--shadow);animation:slin .3s ease;display:flex;align-items:center;gap:10px;max-width:380px;line-height:1.4}
.t-ok{background:var(--green);color:#fff}
.t-err{background:var(--red);color:#fff}
@keyframes slin{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.type-cards{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:8px}
.type-card{display:flex;align-items:center;gap:12px;padding:16px 20px;border:2px solid var(--border);border-radius:10px;cursor:pointer;transition:all .2s;background:#fff;user-select:none}
.type-card.selected{border-color:var(--blue);background:#e8f1fd}
.type-card input[type=radio]{width:18px;height:18px;accent-color:var(--blue);flex-shrink:0}
.type-card-title{font-weight:700;font-size:14px;color:#1a2535;margin-bottom:3px}
.type-card.selected .type-card-title{color:var(--blue)}
.type-card-desc{font-size:12px;color:#8fa3bc}
.progress-bar-wrap{background:#e8eef6;border-radius:4px;height:4px;margin-bottom:32px;overflow:hidden}
.progress-bar{height:100%;border-radius:4px;background:linear-gradient(90deg,var(--blue),var(--green));transition:width .4s ease}
@media(max-width:640px){
.g2,.g3{grid-template-columns:1fr}
.span2,.span3{grid-column:span 1}
.type-cards{grid-template-columns:1fr}
.card{padding:20px 16px}
.topbar{padding:0 16px}
.topbar-title{display:none}
}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div id="root"></div>
<script>
'use strict';
const BACKEND = (() => {
return localStorage.getItem('BACKEND_URL') || '/api';
})();
let S = { step: 1, fd: {}, toasts: [] };
let _companies=[{value:window.__DEFAULT_COMPANY__,label:window.__DEFAULT_COMPANY__}]; // live list
const STATES_LIST = ['','Andhra Pradesh','Arunachal Pradesh','Assam','Bihar','Chandigarh','Chhattisgarh','Delhi','Goa','Gujarat','Haryana','Himachal Pradesh','Jammu & Kashmir','Jharkhand','Karnataka','Kerala','Madhya Pradesh','Maharashtra','Manipur','Meghalaya','Mizoram','Nagaland','Odisha','Punjab','Rajasthan','Sikkim','Tamil Nadu','Telangana','Tripura','Uttar Pradesh','Uttarakhand','West Bengal'];
const COUNTRIES_LIST = ['India','United States','United Kingdom','UAE','Singapore','Germany','Japan','Australia'];
const STATE_SHORT = {'Andhra Pradesh':'AP','Arunachal Pradesh':'AR','Assam':'AS','Bihar':'BH','Chandigarh':'CH','Chhattisgarh':'CG','Delhi':'DL','Goa':'GA','Gujarat':'GJ','Haryana':'HR','Himachal Pradesh':'HP','Jammu & Kashmir':'JK','Jharkhand':'JH','Karnataka':'KA','Kerala':'KL','Madhya Pradesh':'MP','Maharashtra':'MH','Manipur':'MN','Meghalaya':'ML','Mizoram':'MZ','Nagaland':'NL','Odisha':'OD','Punjab':'PB','Rajasthan':'RJ','Sikkim':'SK','Tamil Nadu':'TN','Telangana':'TG','Tripura':'TR','Uttar Pradesh':'UP','Uttarakhand':'UK','West Bengal':'WB'};
// ── Helpers ─────────────────────────────────────────────
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${(sel||window.__DEFAULT_COMPANY__)===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
function mkEl(tag,cls=''){const e=document.createElement(tag);if(cls)e.className=cls;return e;}
function toast(msg,type='ok'){
const id=Date.now();
S.toasts.push({id,msg,type});
renderToasts();
setTimeout(()=>{S.toasts=S.toasts.filter(t=>t.id!==id);renderToasts();},4500);
}
function renderToasts(){
let tc=document.querySelector('.toast-c');
if(!tc){tc=document.createElement('div');tc.className='toast-c';document.body.appendChild(tc);}
tc.innerHTML=S.toasts.map(t=>`<div class="toast t-${t.type==='ok'?'ok':'err'}">${t.type==='ok'?'✓':'✗'} ${esc(t.msg)}</div>`).join('');
}
async function api(method,path,body=null){
const h={'Content-Type':'application/json'};
const opt={method:method.toUpperCase(),headers:h};
if(body) opt.body=JSON.stringify(body);
let r;
try{ r=await fetch(BACKEND+path,opt); }
catch(e){ throw new Error('Cannot reach server. Please check your connection or contact support.'); }
let d;
try{ d=await r.json(); }catch{ throw new Error('Server error ('+r.status+')'); }
if(!r.ok){ throw new Error(d?.message||d?.errors?.[0]?.msg||'Error '+r.status); }
return d;
}
// ── Address auto-fill (FROZEN — read-only, auto-updates) ──
// Mirrors vendor form: addrName is always computed, never user-editable
function wireAddrAutoFill(container, prefix, idx, cardNameInputId){
const addrNameEl=container.querySelector(`#${prefix}-addrName-${idx}`);
const stateEl=container.querySelector(`#${prefix}-state-${idx}`);
if(!addrNameEl||!stateEl) return;
function autoFill(){
const cardNameEl=cardNameInputId?document.getElementById(cardNameInputId):null;
const bizName=((cardNameEl?cardNameEl.value:'')||S?.fd?.cardName||'').toUpperCase().trim();
const stateFull=stateEl.value||'';
const stateShort=STATE_SHORT[stateFull]||stateFull.toUpperCase().substring(0,2);
addrNameEl.value=bizName?(stateShort?`${bizName} - ${stateShort}`:bizName).substring(0,50).toUpperCase():'';
}
stateEl.addEventListener('change',autoFill);
if(cardNameInputId){
const cn=document.getElementById(cardNameInputId);
if(cn) cn.addEventListener('input',autoFill);
}
if(stateEl.value) autoFill();
}
function wireAddrBlock(container,prefix,idx,cardNameInputId){
const block=container.querySelector(`#${prefix}-block-wrap-${idx}`);
if(!block) return;
// Only uppercase non-readonly inputs
block.querySelectorAll('input:not([readonly])').forEach(inp=>makeUpperCase(inp));
wireAddrAutoFill(container,prefix,idx,cardNameInputId);
}
function makeUpperCase(el){
if(!el||el.readOnly) return;
el.addEventListener('input',function(){
const p=this.selectionStart;
this.value=this.value.toUpperCase();
try{this.setSelectionRange(p,p);}catch{}
});
if(el.value) el.value=el.value.toUpperCase();
}
// ── Address block HTML — FROZEN address name (matches vendor style) ──
function buildAddrBlockHTML(prefix,label,idx,saved,opts={}){
const{removable=false}=opts;
const savedState=saved?.state||'';
const savedCountry=saved?.country||'India';
const savedAddr=(saved?.addrName||'').toUpperCase();
const STATES_OPTS=STATES_LIST.map(v=>`<option value="${esc(v)}" ${savedState===v?'selected':''}>${v||'-- Select State --'}</option>`).join('');
const COUNTRY_OPTS=COUNTRIES_LIST.map(v=>`<option value="${esc(v)}" ${savedCountry===v?'selected':''}>${v}</option>`).join('');
return `
<div class="addr-block" id="${prefix}-block-wrap-${idx}">
<div class="addr-block-header">
<span class="addr-block-label">
<span class="addr-block-num">${idx}</span>
${label}${idx>1?' Address #'+idx:''}
</span>
${removable?`<button type="button" class="addr-remove-btn" onclick="document.getElementById('${prefix}-block-wrap-${idx}').remove()">✗ Remove</button>`:''}
</div>
<div class="grid g2" style="gap:14px">
<div class="field span2">
<label class="label">Address Name <span class="req">*</span>
<span style="font-size:9px;color:#8fa3bc;text-transform:none;font-weight:400;margin-left:4px">(auto-filled · read-only)</span>
</label>
<div class="addr-name-wrap">
<input id="${prefix}-addrName-${idx}"
class="addr-name-frozen"
value="${esc(savedAddr)}"
placeholder="AUTO-FILLED: BUSINESS NAME - STATE CODE"
readonly tabindex="-1" style="user-select:none"/>
<div class="addr-name-badges">
<span class="badge-autofill">AUTO-FILL</span>
<span class="badge-frozen">
<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>FROZEN
</span>
</div>
</div>
</div>
<div class="field span2">
<label class="label">Street / PO Box <span class="req">*</span></label>
<input id="${prefix}-street-${idx}" value="${esc((saved?.street||'').toUpperCase())}" placeholder="HOUSE/PLOT NO, STREET NAME"/>
</div>
<div class="field span2">
<label class="label">Block / Area</label>
<input id="${prefix}-block-${idx}" value="${esc((saved?.block||'').toUpperCase())}" placeholder="BLOCK / AREA / LOCALITY"/>
</div>
<div class="field">
<label class="label">City <span class="req">*</span></label>
<input id="${prefix}-city-${idx}" value="${esc((saved?.city||'').toUpperCase())}" placeholder="CITY NAME"/>
</div>
<div class="field">
<label class="label">Pin Code</label>
<input id="${prefix}-zip-${idx}" value="${esc((saved?.zip||'').toUpperCase())}" placeholder="PIN CODE"/>
</div>
<div class="field">
<label class="label">State <span class="req">*</span></label>
<select id="${prefix}-state-${idx}" style="text-transform:none">${STATES_OPTS}</select>
</div>
<div class="field">
<label class="label">Country</label>
<select id="${prefix}-country-${idx}" style="text-transform:none">${COUNTRY_OPTS}</select>
</div>
<div class="field span2">
<label class="label">GSTIN <span style="font-size:10px;color:#6b7a8d;font-weight:400;text-transform:none">(State-wise GSTIN for this address — optional)</span></label>
<input id="${prefix}-gstin-${idx}" value="${esc((saved?.gstin||'').toUpperCase())}" placeholder="E.G. 06ABCDE1234F1Z5" maxlength="15"/>
</div>
</div>
</div>`;
}
function collectAddrBlocks(container,prefix){
const addrs=[];
container.querySelectorAll(`[id^="${prefix}-block-wrap-"]`).forEach(div=>{
const n=div.id.replace(`${prefix}-block-wrap-`,'');
if(isNaN(n)) return;
const addrName=(div.querySelector(`#${prefix}-addrName-${n}`)?.value||'').trim().toUpperCase();
const street=(div.querySelector(`#${prefix}-street-${n}`)?.value||'').trim().toUpperCase();
if(!addrName&&!street) return;
addrs.push({
addrName, street,
block:(div.querySelector(`#${prefix}-block-${n}`)?.value||'').trim().toUpperCase(),
city:(div.querySelector(`#${prefix}-city-${n}`)?.value||'').trim().toUpperCase(),
zip:(div.querySelector(`#${prefix}-zip-${n}`)?.value||'').trim().toUpperCase(),
state:div.querySelector(`#${prefix}-state-${n}`)?.value||'',
country:div.querySelector(`#${prefix}-country-${n}`)?.value||'India',
gstin:(div.querySelector(`#${prefix}-gstin-${n}`)?.value||'').trim().toUpperCase(),
});
});
return addrs;
}
// ── ATTACHMENT HELPERS (matches vendor pattern exactly) ──
function renderAttachPreviewDirect(el, arr){
if(!el) return;
el.innerHTML = arr.map((a,i) => `
<div class="attach-preview">
<div class="ap-thumb">${a.type?.startsWith('image/') ? `<img src="${a.data}" style="width:100%;height:100%;object-fit:cover;border-radius:4px">` : a.type==='application/pdf'?'':''}</div>
<div class="ap-info">
<div class="ap-name">${esc(a.name)}</div>
<div class="ap-size">${(a.size/1024).toFixed(1)} KB · ${a.type||'file'}</div>
</div>
<button class="ap-rm" onclick="removeAttach('${el.id.replace('ap-','')}',${i},this.closest('.attach-preview'))">✗ Remove</button>
</div>`).join('');
}
window.removeAttach = function(key, idx, el){
if(!S.fd.attachments) return;
const arr = Array.isArray(S.fd.attachments[key]) ? S.fd.attachments[key] : [S.fd.attachments[key]];
arr.splice(idx,1);
S.fd.attachments[key] = arr.length ? (key==='other' ? arr : arr[0]) : null;
if(el) el.remove();
};
// Mirrors vendor: handleAttach is global, uses document.querySelector directly
window.handleAttach = function(key, input){
Array.from(input.files||[]).forEach(file=>{
if(file.size > 5*1024*1024){ toast(file.name+' is too large (max 5MB)','err'); return; }
const r = new FileReader();
r.onload = e => {
const obj = {name:file.name, size:file.size, type:file.type, data:e.target.result};
if(!S.fd.attachments) S.fd.attachments = {};
const previewEl = document.querySelector('#ap-'+key);
if(key==='other'){
if(!Array.isArray(S.fd.attachments.other)) S.fd.attachments.other = [];
S.fd.attachments.other.push(obj);
renderAttachPreviewDirect(previewEl, S.fd.attachments.other);
} else {
S.fd.attachments[key] = obj;
renderAttachPreviewDirect(previewEl, [obj]);
}
};
r.readAsDataURL(file);
});
input.value = '';
};
// ── MAIN RENDER ──────────────────────────────────────────
function render(){
const root=document.getElementById('root');
root.innerHTML='';
root.appendChild(renderTopbar());
const pg=mkEl('div','page');
const pct=S.step===1?33:S.step===2?66:100;
pg.innerHTML=`<div class="progress-bar-wrap"><div class="progress-bar" style="width:${pct}%"></div></div>`;
pg.appendChild(buildSteps());
if(S.step===1) pg.appendChild(renderStep1());
else if(S.step===2) pg.appendChild(renderStep2());
else pg.appendChild(renderStep3());
root.appendChild(pg);
}
function renderTopbar(){
const tb=mkEl('div','topbar');
tb.innerHTML=`
<div class="topbar-brand">
<span class="sap-logo">SAP</span>
<span class="topbar-title">Business One — Customer Registration</span>
</div>
<div class="topbar-right">
<span class="topbar-badge">CUSTOMER FORM</span>
</div>`;
return tb;
}
function buildSteps(){
const labels=['Your Details','Review','Confirmation'];
const wrap=mkEl('div','steps');
labels.forEach((lb,i)=>{
const n=i+1;
const s=mkEl('div','step'+(n<S.step?' done':n===S.step?' active':''));
s.innerHTML=`<div class="step-circle">${n<S.step?'✓':n}</div><span class="step-label">${lb}</span>`;
wrap.appendChild(s);
if(i<labels.length-1){
const line=document.createElement('div');
line.className='step-line'+(n<S.step?' done':'');
wrap.appendChild(line);
}
});
return wrap;
}
// ── STEP 1: THE FORM ────────────────────────────────────
function renderStep1(){
const card=mkEl('div','card');
const isB2B=(S.fd.customerType||'B2B')==='B2B';
const savedBill=S.fd.allBillAddresses?.length?S.fd.allBillAddresses:[{addrName:S.fd.billAddressName||'',street:S.fd.billStreet||'',block:S.fd.billBlock||'',city:S.fd.billCity||'',zip:S.fd.billZip||'',state:S.fd.billState||'',country:S.fd.billCountry||'India',gstin:S.fd.gstin||''}];
const savedShip=S.fd.allShipAddresses?.length?S.fd.allShipAddresses:[{addrName:S.fd.shipAddressName||'',street:S.fd.shipStreet||'',block:S.fd.shipBlock||'',city:S.fd.shipCity||'',zip:S.fd.shipZip||'',state:S.fd.shipState||'',country:S.fd.shipCountry||'India'}];
card.innerHTML=`
<div class="card-title">Customer Registration Form</div>
<div class="card-sub">Fill in your business details below. All fields marked <span style="color:var(--red)">*</span> are required.</div>
<div class="sec">Company / Database</div>
<div class="grid g2" style="margin-bottom:8px">
<div class="field">
<label class="label">Submit to Company <span class="req">*</span></label>
<select id="f-company">${coOptions(S.fd.company)}</select>
</div>
</div>
<div class="sec">Customer Type</div>
<div class="type-cards">
<label class="type-card ${isB2B?'selected':''}">
<input type="radio" name="custType" value="B2B" ${isB2B?'checked':''}/>
<div><div class="type-card-title">B2B — Business</div><div class="type-card-desc">Company, Firm, LLP, Partnership</div></div>
</label>
<label class="type-card ${!isB2B?'selected':''}">
<input type="radio" name="custType" value="B2C" ${!isB2B?'checked':''}/>
<div><div class="type-card-title">B2C — Individual</div><div class="type-card-desc">Individual / Retail Customer</div></div>
</label>
</div>
<div class="sec">Business Information</div>
<div class="grid g2">
<div class="field">
<label class="label">Company / Customer Name <span class="req">*</span></label>
<input id="f-cardName" placeholder="${isB2B?'E.G. MAHAVIR TRANSPORT PVT LTD':'E.G. RAJESH KUMAR'}" value="${esc((S.fd.cardName||'').toUpperCase())}"/>
<span class="ferr" id="e-cardName"></span>
</div>
<div class="field">
<label class="label">Foreign / Alternate Name <span style="font-size:10px;color:#8fa3bc;font-weight:400;text-transform:none">(optional)</span></label>
<input id="f-foreignName" placeholder="FOREIGN / ALTERNATE NAME" value="${esc((S.fd.foreignName||'').toUpperCase())}"/>
</div>
<div class="field">
<label class="label">Type of Business</label>
<select id="f-typeOfBusiness" style="text-transform:none">
${['Company','Individual','Partnership','LLP','Proprietorship'].map(v=>`<option value="${v}" ${(S.fd.typeOfBusiness||(isB2B?'Company':'Individual'))===v?'selected':''}>${v}</option>`).join('')}
</select>
</div>
<div class="field">
<label class="label">Industry <span class="req">*</span></label>
<select id="f-industry" style="text-transform:none">
${['','Transport','Manufacturing','Trading','Retail','Distribution','FMCG','Healthcare','Construction','IT Services','Others'].map(v=>`<option value="${v}" ${(S.fd.industry||'')===v?'selected':''}>${v||'-- Select Industry --'}</option>`).join('')}
</select>
<span class="ferr" id="e-industry"></span>
</div>
</div>
<div class="sec">Primary Contact Person</div>
<div class="grid g3">
<div class="field">
<label class="label">First Name <span class="req">*</span></label>
<input id="f-contactFirst" placeholder="FIRST NAME" value="${esc((S.fd.contactFirst||'').toUpperCase())}"/>
<span class="ferr" id="e-contactFirst"></span>
</div>
<div class="field">
<label class="label">Last Name <span class="req">*</span></label>
<input id="f-contactLast" placeholder="LAST NAME" value="${esc((S.fd.contactLast||'').toUpperCase())}"/>
<span class="ferr" id="e-contactLast"></span>
</div>
<div class="field">
<label class="label">Title / Position</label>
<input id="f-contactTitle" placeholder="E.G. OWNER / MANAGER" value="${esc((S.fd.contactTitle||'').toUpperCase())}"/>
</div>
<div class="field">
<label class="label">Mobile Number <span class="req">*</span></label>
<input id="f-mobile" placeholder="+91 99999 99999" value="${esc(S.fd.mobile||'')}" style="text-transform:none" class="no-upper"/>
<span class="ferr" id="e-mobile"></span>
</div>
<div class="field">
<label class="label">Email Address <span class="req">*</span></label>
<input id="f-email" type="email" placeholder="contact@company.com" value="${esc(S.fd.email||'')}" style="text-transform:none"/>
<span class="ferr" id="e-email"></span>
</div>
<div class="field">
<label class="label">Alternate Email <span style="font-size:10px;color:#8fa3bc;font-weight:400;text-transform:none">(optional)</span></label>
<input id="f-contactEmail" type="email" placeholder="alt@company.com" value="${esc(S.fd.contactEmail||'')}" style="text-transform:none"/>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:24px">
<div class="sec" style="margin:0;flex:1">Billing Address(es)</div>
</div>
<div class="alert a-info" style="margin-top:12px;margin-bottom:10px">
ℹ <strong>Address Name</strong> is auto-filled as <strong>BUSINESS NAME - STATE CODE</strong> and is read-only.
</div>
<div id="bill-addr-container" style="margin-top:4px">
${savedBill.map((a,i)=>buildAddrBlockHTML('fb','Billing',i+1,a,{removable:i>0})).join('')}
</div>
<button type="button" class="add-addr-btn" id="add-bill-addr">+ Add Another Billing Address</button>
<div class="sec" style="margin-top:8px">Shipping Address(es)</div>
<div style="margin-bottom:14px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:12px 16px;background:#f8f9fc;border:1.5px solid var(--border);border-radius:8px;width:fit-content">
<input type="checkbox" id="sameAsBill" ${S.fd.sameAsBill!==false?'checked':''} style="width:18px;height:18px;padding:0;accent-color:var(--blue)"/>
<span style="font-size:13px;font-weight:600">Same as first Billing Address</span>
</label>
</div>
<div id="ship-addr-section" style="${S.fd.sameAsBill!==false?'display:none':''}">
<div id="ship-addr-container">
${savedShip.map((a,i)=>buildAddrBlockHTML('fs','Shipping',i+1,a,{removable:i>0})).join('')}
</div>
<button type="button" class="add-addr-btn" id="add-ship-addr">+ Add Another Shipping Address</button>
</div>
<div class="sec">Financial & Tax Information</div>
<div style="display:grid;grid-template-columns:160px 1fr 200px;gap:16px;width:100%">
<div class="field">
<label class="label">Currency</label>
<select id="f-currency" style="text-transform:none">
${['Indian Rupee','US Dollar','Euro','British Pound','UAE Dirham'].map(v=>`<option ${(S.fd.currency||'Indian Rupee')===v?'selected':''}>${v}</option>`).join('')}
</select>
</div>
<div class="field">
<label class="label">GSTIN ${isB2B?'<span class="req">*</span>':''} ${!isB2B?'<span style="font-size:10px;color:#8fa3bc;font-weight:400;text-transform:none">(optional for B2C)</span>':''}</label>
<div style="display:flex;gap:8px;align-items:stretch">
<input id="f-gstin" placeholder="E.G. 06ABCDE1234F1Z5" value="${esc((S.fd.gstin||'').toUpperCase())}" style="flex:1;min-width:0" maxlength="15"/>
<button type="button" id="gstin-fetch" style="padding:0 14px;height:41px;background:var(--blue);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:700;white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:5px">
<span id="gstin-sp" style="display:none" class="sp"></span>Fetch PAN
</button>
</div>
<span style="font-size:11px;color:#8fa3bc;margin-top:3px">Auto-extracts PAN from GSTIN</span>
<span class="ferr" id="e-gstin"></span>
</div>
<div class="field">
<label class="label">PAN Number <span class="req">*</span></label>
<div style="position:relative">
<input id="f-pan" placeholder="E.G. ABCDE1234F" value="${esc((S.fd.pan||'').toUpperCase())}" maxlength="10" style="padding-right:58px"/>
<span id="pan-badge" style="display:${S.fd.panAutoFetched?'flex':'none'};position:absolute;right:9px;top:50%;transform:translateY(-50%);background:#e6f4eb;color:var(--green);font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;align-items:center;pointer-events:none">AUTO</span>
</div>
<span class="ferr" id="e-pan"></span>
</div>
</div>
<div class="sec">MSME / Udyam Registration</div>
<div class="msme-toggle ${S.fd.hasMsme?'active':''}" id="msme-wrap">
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<input type="checkbox" id="f-hasMsme" ${S.fd.hasMsme?'checked':''} style="width:20px;height:20px;accent-color:var(--green)"/>
<div>
<div style="font-weight:700;font-size:14px">Registered under MSME / Udyam Scheme</div>
<div style="font-size:12px;color:#6b7a8d;margin-top:3px">Check this if your business has an MSME / Udyam registration certificate</div>
</div>
</label>
<div id="msme-fields" style="${S.fd.hasMsme?'':'display:none'}">
<div class="grid g3" style="margin-top:16px">
<div class="field span2">
<label class="label">Udyam Registration Number <span class="req">*</span></label>
<input id="f-msmeNo" placeholder="E.G. UDYAM-HR-18-0040140" value="${esc((S.fd.msmeNo||'').toUpperCase())}" maxlength="19"/>
<span class="ferr" id="e-msmeNo"></span>
</div>
<div class="field">
<label class="label">MSME Type <span class="req">*</span></label>
<select id="f-msmeType" style="text-transform:none">${['','MICRO','SMALL','MEDIUM','LARGE'].map(v=>`<option value="${v}" ${(S.fd.msmeType||'')===v?'selected':''}>${v||'-- Select --'}</option>`).join('')}</select>
</div>
<div class="field">
<label class="label">Enterprise Type</label>
<select id="f-msmeBType" style="text-transform:none">${['','MANUFACTURING','SERVICES','TRADING'].map(v=>`<option value="${v}" ${(S.fd.msmeBType||'')===v?'selected':''}>${v||'-- Select --'}</option>`).join('')}</select>
</div>
</div>
</div>
</div>
<div class="sec">Required Documents</div>
<div class="alert a-warn" style="margin-bottom:16px">
Please upload <strong>clear scanned copies</strong> or photos. Max <strong>5MB</strong> per file. Accepted formats: <strong>PDF, JPG, PNG</strong>.
</div>
<div class="grid g2" style="align-items:start">
<div class="field">
<label class="label">PAN Card <span class="req">*</span></label>
<div class="attach-zone" id="az-pan">
<input type="file" id="f-att-pan" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('pan',this)"/>
<div class="az-icon">🪪</div>
<div class="az-label">Click or drag to upload PAN Card</div>
<div class="az-sub">PDF, JPG, PNG — max 5MB</div>
</div>
<div id="ap-pan"></div>
<span class="ferr" id="e-att-pan"></span>
</div>
<div class="field">
<label class="label">Aadhaar Card <span class="req">*</span></label>
<div class="attach-zone" id="az-aadhaar">
<input type="file" id="f-att-aadhaar" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('aadhaar',this)"/>
<div class="az-icon">🪪</div>
<div class="az-label">Click or drag to upload Aadhaar Card</div>
<div class="az-sub">PDF, JPG, PNG — max 5MB</div>
</div>
<div id="ap-aadhaar"></div>
<span class="ferr" id="e-att-aadhaar"></span>
</div>
<div class="field">
<label class="label">Cancelled Cheque / Bank Proof <span class="req">*</span></label>
<div class="attach-zone" id="az-cheque">
<input type="file" id="f-att-cheque" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('cheque',this)"/>
<div class="az-icon"></div>
<div class="az-label">Click or drag to upload Cancelled Cheque</div>
<div class="az-sub">PDF, JPG, PNG — max 5MB</div>
</div>
<div id="ap-cheque"></div>
<span class="ferr" id="e-att-cheque"></span>
</div>
<div class="field" id="az-msme-wrap" style="${S.fd.hasMsme?'':'display:none'}">
<label class="label">MSME / Udyam Certificate <span class="req">*</span></label>
<div class="attach-zone" id="az-msme">
<input type="file" id="f-att-msme" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('msme',this)"/>
<div class="az-icon"></div>
<div class="az-label">Click or drag MSME Certificate</div>
<div class="az-sub">PDF, JPG, PNG — max 5MB</div>
</div>
<div id="ap-msme"></div>
<span class="ferr" id="e-att-msme"></span>
</div>
<div class="field">
<label class="label">Other Documents <span style="font-size:10px;color:#8fa3bc;font-weight:400;text-transform:none">(optional)</span></label>
<div class="attach-zone" id="az-other">
<input type="file" id="f-att-other" accept=".pdf,.jpg,.jpeg,.png" multiple onchange="handleAttach('other',this)"/>
<div class="az-icon"></div>
<div class="az-label">Click or drag Other Documents</div>
<div class="az-sub">Multiple files allowed — PDF, JPG, PNG</div>
</div>
<div id="ap-other"></div>
</div>
</div>
<div class="sec">Additional Remarks</div>
<div class="field">
<label class="label">Remarks <span class="req">*</span></label>
<textarea id="f-remarks" placeholder="ANY ADDITIONAL INFORMATION ABOUT YOUR BUSINESS...">${esc((S.fd.remarks||'').toUpperCase())}</textarea>
<span class="ferr" id="e-remarks"></span>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="next1">Review Details →</button>
</div>`;
let billCount=savedBill.length, shipCount=savedShip.length;
// Wire address blocks
savedBill.forEach((_,i)=>wireAddrBlock(card.querySelector('#bill-addr-container'),'fb',i+1,'f-cardName'));
savedShip.forEach((_,i)=>wireAddrBlock(card.querySelector('#ship-addr-container'),'fs',i+1,'f-cardName'));
// Customer type toggle
card.querySelectorAll('input[name="custType"]').forEach(r=>{
r.onchange=function(){
S.fd.customerType=this.value;
document.querySelectorAll('.type-card').forEach(c=>c.classList.remove('selected'));
this.closest('.type-card').classList.add('selected');
render();
};
});
// Same as billing
card.querySelector('#sameAsBill').onchange=function(){
card.querySelector('#ship-addr-section').style.display=this.checked?'none':'';
S.fd.sameAsBill=!this.checked;
};
// Add address buttons
card.querySelector('#add-bill-addr').onclick=()=>{
billCount++;
const c=card.querySelector('#bill-addr-container');
const d=document.createElement('div');
d.innerHTML=buildAddrBlockHTML('fb','Billing',billCount,{},{removable:true});
c.appendChild(d.firstElementChild);
wireAddrBlock(c,'fb',billCount,'f-cardName');
};
card.querySelector('#add-ship-addr').onclick=()=>{
shipCount++;
const c=card.querySelector('#ship-addr-container');
const d=document.createElement('div');
d.innerHTML=buildAddrBlockHTML('fs','Shipping',shipCount,{},{removable:true});
c.appendChild(d.firstElementChild);
wireAddrBlock(c,'fs',shipCount,'f-cardName');
};
// GSTIN → PAN
const gEl=card.querySelector('#f-gstin'),pEl=card.querySelector('#f-pan');
const pBadge=card.querySelector('#pan-badge'),fBtn=card.querySelector('#gstin-fetch');
function extractPan(g){return(g||'').length===15?g.substring(2,12):null;}
gEl.oninput=function(){
this.value=this.value.toUpperCase();
const p=extractPan(this.value);
if(p&&this.value.length===15){pEl.value=p;pBadge.style.display='flex';S.fd.pan=p;S.fd.panAutoFetched=true;}
else if(S.fd.panAutoFetched){pEl.value='';pBadge.style.display='none';S.fd.panAutoFetched=false;}
};
pEl.oninput=function(){this.value=this.value.toUpperCase();if(S.fd.panAutoFetched){pBadge.style.display='none';S.fd.panAutoFetched=false;}};
fBtn.onclick=()=>{
const g=(gEl.value||'').trim().toUpperCase();
if(g.length!==15){card.querySelector('#e-gstin').textContent='Enter a valid 15-digit GSTIN first';return;}
card.querySelector('#e-gstin').textContent='';
const p=extractPan(g);
pEl.value=p;pBadge.style.display='flex';S.fd.pan=p;S.fd.panAutoFetched=true;
toast('PAN extracted: '+p);
};
// MSME toggle
const mCk=card.querySelector('#f-hasMsme'),mF=card.querySelector('#msme-fields');
const mW=card.querySelector('#msme-wrap'),mA=card.querySelector('#az-msme-wrap');
mCk.onchange=function(){
mF.style.display=this.checked?'':'none';
mA&&(mA.style.display=this.checked?'':'none');
mW.classList.toggle('active',this.checked);
S.fd.hasMsme=this.checked;
};
// Restore existing attachment previews on re-render
if(!S.fd.attachments) S.fd.attachments={};
['pan','aadhaar','cheque','msme','other'].forEach(key=>{
const att=S.fd.attachments[key];
if(!att) return;
const el=document.querySelector('#ap-'+key);
if(el) renderAttachPreviewDirect(el, Array.isArray(att)?att:[att]);
});
// Drag-drop on attach zones
card.querySelectorAll('.attach-zone').forEach(z=>{
z.addEventListener('dragover',e=>{e.preventDefault();z.classList.add('drag');});
z.addEventListener('dragleave',()=>z.classList.remove('drag'));
z.addEventListener('drop',e=>{
e.preventDefault();z.classList.remove('drag');
const k=z.id.replace('az-','');
const inp=z.querySelector('input[type=file]');
if(inp&&e.dataTransfer.files.length){inp.files=e.dataTransfer.files;window.handleAttach(k,inp);}
});
});
// ── FIX: Validation — removed userId/userType checks (fields not in form)
card.querySelector('#next1').onclick=()=>{
const v=id=>(card.querySelector('#'+id)?.value||'').trim().toUpperCase();
const err=(id,msg)=>{const el=card.querySelector('#e-'+id);if(el)el.textContent=msg;};
const clr=id=>{const el=card.querySelector('#e-'+id);if(el)el.textContent='';};
const custType=card.querySelector('input[name="custType"]:checked')?.value||'B2B';
const sameAsBill=card.querySelector('#sameAsBill').checked;
let ok=true;
// Force uppercase on all text inputs
card.querySelectorAll('input:not([type=email]):not([type=file]):not([type=checkbox]):not([type=radio]):not([readonly])').forEach(inp=>{
if(inp.value) inp.value=inp.value.toUpperCase();
});
const remarksEl=card.querySelector('#f-remarks');
if(remarksEl) remarksEl.value=remarksEl.value.toUpperCase();
// Required business fields
if(!v('f-cardName')){err('cardName','Company / Customer Name is required');ok=false;}else clr('cardName');
if(!card.querySelector('#f-industry')?.value){err('industry','Industry is required');ok=false;}else clr('industry');
if(!v('f-contactFirst')){err('contactFirst','First Name is required');ok=false;}else clr('contactFirst');
if(!v('f-contactLast')){err('contactLast','Last Name is required');ok=false;}else clr('contactLast');
if(!v('f-remarks')){err('remarks','Remarks are required');ok=false;}else clr('remarks');
const mob=(card.querySelector('#f-mobile')?.value||'').trim();
if(!mob){err('mobile','Mobile Number is required');ok=false;}
else if(!/^[0-9+\s\-]{8,15}$/.test(mob)){err('mobile','Please enter a valid mobile number');ok=false;}
else clr('mobile');
const emailVal=(card.querySelector('#f-email')?.value||'').trim();
if(!emailVal){err('email','Email address is required');ok=false;}
else if(!/\S+@\S+\.\S+/.test(emailVal)){err('email','Please enter a valid email address');ok=false;}
else clr('email');
const panVal=v('f-pan');
if(!panVal){err('pan','PAN Number is required');ok=false;}
else if(!/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(panVal)){err('pan','Invalid PAN format (e.g. ABCDE1234F)');ok=false;}
else clr('pan');
if(custType==='B2B'){
const g=v('f-gstin');
if(!g){err('gstin','GSTIN is required for B2B customers');ok=false;}
else if(!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(g)){err('gstin','Please enter a valid GSTIN (e.g. 06ABCDE1234F1Z5)');ok=false;}
else clr('gstin');
} else {
clr('gstin');
}
const hasMsme=card.querySelector('#f-hasMsme')?.checked;
if(hasMsme){
if(!v('f-msmeNo')){err('msmeNo','Udyam Registration Number is required');ok=false;}
else if(!/^UDYAM-[A-Z]{2}-\d{2}-\d{7}$/.test(v('f-msmeNo'))){err('msmeNo','Format: UDYAM-HR-18-0040140');ok=false;}
else clr('msmeNo');
if(!S.fd.attachments?.msme){err('att-msme','Please upload your MSME certificate');ok=false;}
}
if(!S.fd.attachments?.pan){err('att-pan','PAN card document is required');ok=false;}
if(!S.fd.attachments?.aadhaar){err('att-aadhaar','Aadhaar card document is required');ok=false;}
if(!S.fd.attachments?.cheque){err('att-cheque','Cancelled cheque is required');ok=false;}
const firstBill=card.querySelector('#fb-block-wrap-1');
if(firstBill){
if(!firstBill.querySelector('#fb-street-1')?.value?.trim()){toast('Please fill Street in the billing address','err');ok=false;}
if(!firstBill.querySelector('#fb-city-1')?.value?.trim()){toast('Please fill City in the billing address','err');ok=false;}
if(!firstBill.querySelector('#fb-state-1')?.value){toast('Please select State in the billing address','err');ok=false;}
}
if(!ok){
const fe=card.querySelector('.ferr:not(:empty)');
if(fe) fe.scrollIntoView({behavior:'smooth',block:'center'});
return;
}
const allBill=collectAddrBlocks(card.querySelector('#bill-addr-container'),'fb');
const allShip=sameAsBill?[]:collectAddrBlocks(card.querySelector('#ship-addr-container'),'fs');
const pb=allBill[0]||{},ps=sameAsBill?pb:(allShip[0]||pb);
S.fd={
company:card.querySelector('#f-company')?.value||window.__DEFAULT_COMPANY__,
customerType:custType,
cardName:v('f-cardName'),foreignName:v('f-foreignName'),
typeOfBusiness:card.querySelector('#f-typeOfBusiness')?.value||'',
industry:card.querySelector('#f-industry')?.value||'',
contactFirst:v('f-contactFirst'),contactLast:v('f-contactLast'),contactTitle:v('f-contactTitle'),
contactEmail:(card.querySelector('#f-contactEmail')?.value||'').trim(),
mobile:mob,email:emailVal,
billAddressName:pb.addrName||'',billStreet:pb.street||'',billBlock:pb.block||'',
billCity:pb.city||'',billZip:pb.zip||'',billState:pb.state||'',billCountry:pb.country||'India',
sameAsBill,
shipAddressName:ps.addrName||'',shipStreet:ps.street||'',shipBlock:ps.block||'',
shipCity:ps.city||'',shipZip:ps.zip||'',shipState:ps.state||'',shipCountry:ps.country||'India',
allBillAddresses:allBill,allShipAddresses:sameAsBill?allBill:allShip,
currency:card.querySelector('#f-currency')?.value||'Indian Rupee',
gstin:v('f-gstin'),pan:v('f-pan'),panAutoFetched:S.fd.panAutoFetched||false,
remarks:v('f-remarks'),
hasMsme:hasMsme||false,
msmeNo:hasMsme?v('f-msmeNo'):'',
msmeType:hasMsme?card.querySelector('#f-msmeType')?.value||'':'',
msmeBType:hasMsme?card.querySelector('#f-msmeBType')?.value||'':'',
attachments:S.fd.attachments||{},
};
S.step=2;window.scrollTo(0,0);render();
};
return card;
}
// ── STEP 2: REVIEW ──────────────────────────────────────
function renderStep2(){
const fd=S.fd;
const card=mkEl('div','card');
const billHtml=(fd.allBillAddresses||[]).map((a,i)=>`
<tr><td>Billing${i>0?' #'+(i+1):''}</td>
<td class="val">${esc(a.addrName||'—')}<br/>
<span style="font-size:12px;color:#6b7a8d">${esc(a.street||'')}${a.block?', '+esc(a.block):''}, ${esc(a.city||'')} ${esc(a.state||'')} ${esc(a.zip||'')}, ${esc(a.country||'')}</span>
${a.gstin?`<br/><span style="font-size:11px;color:#6b7a8d">GSTIN: ${esc(a.gstin)}</span>`:''}
</td></tr>`).join('');
const shipHtml=fd.sameAsBill
?'<tr><td>Shipping</td><td class="val" style="color:#6b7a8d">Same as Billing Address</td></tr>'
:(fd.allShipAddresses||[]).map((a,i)=>`
<tr><td>Shipping${i>0?' #'+(i+1):''}</td>
<td class="val">${esc(a.addrName||'—')}<br/>
<span style="font-size:12px;color:#6b7a8d">${esc(a.street||'')}${a.block?', '+esc(a.block):''}, ${esc(a.city||'')} ${esc(a.state||'')} ${esc(a.zip||'')}</span></td></tr>`).join('');
card.innerHTML=`
<div class="card-title">Review Your Details</div>
<div class="card-sub">Please review carefully before submitting. Once submitted, our team will contact you.</div>
<div class="alert a-info">ℹ After submission, our staff will verify your details and create your account in SAP B1. You will be contacted once approved.</div>
<table class="dtable">
<tr><td>Customer Type</td><td class="val"><span class="badge ${fd.customerType==='B2B'?'b-b2b':'b-b2c'}">${esc(fd.customerType)}</span></td></tr>
<tr><td>Company Name</td><td class="val"><strong>${esc(fd.cardName)}</strong></td></tr>
${fd.foreignName?`<tr><td>Foreign Name</td><td class="val">${esc(fd.foreignName)}</td></tr>`:''}
<tr><td>Industry</td><td class="val">${esc(fd.industry||'—')}</td></tr>
<tr><td>Type of Business</td><td class="val">${esc(fd.typeOfBusiness)}</td></tr>
<tr><td>Contact Person</td><td class="val">${esc(fd.contactFirst+' '+fd.contactLast)}${fd.contactTitle?` <span style="color:#6b7a8d;font-size:12px">(${esc(fd.contactTitle)})</span>`:''}</td></tr>
<tr><td>Mobile</td><td class="val">${esc(fd.mobile)}</td></tr>
<tr><td>Email</td><td class="val">${esc(fd.email)}</td></tr>
${fd.contactEmail?`<tr><td>Alt Email</td><td class="val">${esc(fd.contactEmail)}</td></tr>`:''}
${billHtml}${shipHtml}
<tr><td>Currency</td><td class="val">${esc(fd.currency)}</td></tr>
<tr><td>GSTIN</td><td class="val">${fd.gstin?`<code style="background:#f4f6fb;padding:2px 10px;border-radius:5px;font-family:'IBM Plex Mono'">${esc(fd.gstin)}</code>`:'— (B2C Customer)'}</td></tr>
<tr><td>PAN Number</td><td class="val"><code style="background:#f4f6fb;padding:2px 10px;border-radius:5px;font-family:'IBM Plex Mono'">${esc(fd.pan)}</code>${fd.panAutoFetched?' <span style="background:#e6f4eb;color:var(--green);font-size:10px;font-weight:700;padding:1px 6px;border-radius:3px">AUTO</span>':''}</td></tr>
<tr><td>MSME</td><td class="val">${fd.hasMsme?`<span style="color:var(--green);font-weight:600">✓ ${esc(fd.msmeNo)}</span> <span style="font-size:12px;color:#6b7a8d">${fd.msmeType}${fd.msmeBType?' / '+fd.msmeBType:''}</span>`:'Not registered'}</td></tr>
<tr><td>Documents</td><td class="val">${Object.entries(fd.attachments||{}).filter(([,v])=>v&&(Array.isArray(v)?v.length:true)).map(([k,v])=>{const arr=Array.isArray(v)?v:[v];return arr.map(a=>`<span style="display:inline-flex;background:#e8f1fd;color:var(--blue);padding:2px 9px;border-radius:4px;font-size:11px;font-weight:600;margin:2px 3px 2px 0">${k.toUpperCase()}: ${esc(a.name)}</span>`).join('');}).join('')||'<span style="color:#8fa3bc">None uploaded</span>'}</td></tr>
<tr><td>Remarks</td><td class="val" style="font-size:13px;color:#475569">${esc(fd.remarks||'—')}</td></tr>
</table>
<div class="btn-row">
<button class="btn btn-secondary" id="s2back">← Edit Details</button>
<button class="btn btn-primary" id="s2submit">
<span id="s2sp" style="display:none" class="sp"></span>
✓ Confirm & Submit Application
</button>
</div>`;
card.querySelector('#s2back').onclick=()=>{S.step=1;window.scrollTo(0,0);render();};
card.querySelector('#s2submit').onclick=async()=>{
const btn=card.querySelector('#s2submit'),sp=card.querySelector('#s2sp');
btn.disabled=true;sp.style.display='inline-block';
try{
const r=await api('POST','/customers/submit',S.fd);
S.submissionId=r.id;
toast('Application submitted successfully!');
S.step=3;window.scrollTo(0,0);render();
}catch(e){
toast(e.message,'err');
btn.disabled=false;sp.style.display='none';
}
};
return card;
}
// ── STEP 3: SUCCESS ─────────────────────────────────────
function renderStep3(){
const card=mkEl('div','card');
const refNo=S.submissionId?`REG-${String(S.submissionId).padStart(5,'0')}`:'Submitted';
card.innerHTML=`
<div class="success-wrap">
<div class="success-icon"></div>
<h2>Application Submitted!</h2>
<p>Thank you, <strong>${esc((S.fd.contactFirst||'')+' '+(S.fd.contactLast||''))}</strong>!
Your customer registration for <strong>${esc(S.fd.cardName)}</strong> has been received.</p>
<div class="ref-box">${esc(refNo)}</div>
<p>Our team will review your details and contact you at <strong>${esc(S.fd.mobile)}</strong> or <strong>${esc(S.fd.email)}</strong> once your account is created in SAP B1.</p>
<p style="font-size:12px;color:#94a3b8;margin-top:12px">Please keep your reference number for follow-up.</p>
<div style="margin-top:32px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<button class="btn btn-secondary" id="newapp">+ Register Another Customer</button>
<button class="btn btn-primary" onclick="window.print()">Print Confirmation</button>
</div>
</div>`;
card.querySelector('#newapp').onclick=()=>{S.fd={};S.step=1;S.submissionId=null;window.scrollTo(0,0);render();};
return card;
}
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}render();})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+484
View File
@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reports | MITRA ERP Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}.hdr h1 span{color:#047e7e}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#047e7e;color:#fff}.btn-primary:hover{background:#035b5b}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed;opacity:.7}
.btn-outline{background:#fff;color:#047e7e;border:1.5px solid #047e7e}.btn-outline:hover{background:#f0fafa}
.btn-outline:disabled{opacity:.5;cursor:not-allowed}
.btn-grp{display:flex;gap:8px;flex-wrap:wrap}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed;background:#fff}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#047e7e;box-shadow:0 0 0 3px rgba(4,126,126,.08)}
.summary-chips{display:none;gap:8px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb}
.chip{background:#fff;border:1.5px solid #c8e6e6;border-radius:10px;padding:6px 14px;font-size:11px;font-weight:600;color:#047e7e;min-width:100px}
.chip span{font-size:15px;font-weight:700;display:block;color:#1a1a2e;margin-top:1px}
.chip.blue{border-color:#bfdbfe;color:#1d4ed8}
.chip.green{border-color:#bbf7d0;color:#166534}
.chip.amber{border-color:#fde68a;color:#92400e}
.chip.red{border-color:#fecaca;color:#991b1b}
.chip.purple{border-color:#e9d5ff;color:#6d28d9}
.tbl-wrap{overflow-x:auto;overflow-y:auto;max-height:54vh}
.tbl-wrap::-webkit-scrollbar{height:7px;width:7px}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
table{border-collapse:collapse;min-width:100%;width:100%}
thead th{background:#f0f4f8;color:#4b5563;font-weight:700;padding:9px 11px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #d1dae3;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
thead th.num{text-align:right}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s}
tbody tr:hover{background:#edfafa}
tbody td{padding:8px 11px;white-space:nowrap;color:#374151;font-size:12px}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
tbody td.trunc{max-width:200px;overflow:hidden;text-overflow:ellipsis}
.empty{text-align:center;padding:56px 20px;color:#9ca3af;font-size:13px}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#10b981}.status-dot.err{background:#ef4444}
.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:12px;font-size:10px;font-weight:700}
.badge-released{background:#dcfce7;color:#166534}
.badge-planned{background:#dbeafe;color:#1e40af}
.over-due{color:#dc2626;font-weight:700}
.report-tabs{display:flex;gap:4px;padding:12px 20px 0;background:#fff;border-bottom:1px solid #e8eaed;overflow-x:auto}
.report-tabs::-webkit-scrollbar{height:0}
.tab-btn{padding:8px 16px;border:none;background:none;cursor:pointer;font-family:inherit;font-size:12px;font-weight:700;color:#6b7280;border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;transition:all .15s}
.tab-btn.active{color:#047e7e;border-bottom-color:#047e7e}
.tab-btn:hover:not(.active){color:#1a1a2e}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div id="sidebar-placeholder"></div>
<div class="sb-main">
<div class="pg">
<div class="hdr">
<h1>Reports <span id="reportTitle">— Open Production Orders</span></h1>
<div class="btn-grp">
<button class="btn btn-outline" id="btnExport" disabled onclick="exportCsv()">Export CSV</button>
</div>
</div>
<div class="card">
<!-- ── Tabs ── -->
<div class="report-tabs">
<button class="tab-btn active" onclick="switchReport('prod',this)">Open Production Orders</button>
<button class="tab-btn" onclick="switchReport('inv',this)">FG Inventory</button>
<button class="tab-btn" onclick="switchReport('bom',this)">BOM Cost</button>
<button class="tab-btn" onclick="switchReport('sales',this)">Sales Report</button>
</div>
<!-- ── Filters: Production Orders ── -->
<div id="filters-prod" class="filter-bar">
<div class="fg"><label>From Date</label><input type="date" id="prod-from"></div>
<div class="fg"><label>To Date</label><input type="date" id="prod-to"></div>
<div class="fg">
<label>Status</label>
<select id="prod-status">
<option value="all">All (Released + Planned)</option>
<option value="Released">Released only</option>
<option value="Planned">Planned only</option>
</select>
</div>
<div class="fg">
<label>Product Segment</label>
<select id="prod-segment">
<option value="all">All Segments</option>
<option>BLOOD BAG</option><option>FG Blood Bag</option><option>SFG Blood bag</option>
<option>CAPD</option><option>FG CAPD</option><option>FG CAPD ACCESSORIES</option>
<option>Equipment</option><option>FG Equipment</option><option>Consumable</option>
<option>NEEDLE</option><option>MOULDING</option><option>PLASTIC</option>
<option>FG Stent</option><option>FG TT Device</option><option>Other</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadData()">Load Data</button>
</div>
<!-- ── Filters: FG Inventory ── -->
<div id="filters-inv" class="filter-bar" style="display:none">
<div class="fg"><label>From Date</label><input type="date" id="inv-from"></div>
<div class="fg"><label>To Date</label><input type="date" id="inv-to"></div>
<div class="fg">
<label>Warehouse Code</label>
<input type="text" id="inv-whs" value="01" style="width:100px" placeholder="e.g. 01">
</div>
<div class="fg">
<label>Item Group</label>
<select id="inv-grp">
<option>FG BLOOD BAG</option>
<option>FG CAPD</option>
<option>FG CAPD Accessories</option>
<option>FG EQUIPMENT</option>
<option>SEMI FINISHED</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadData()">Load Data</button>
</div>
<!-- ── Filters: BOM Cost ── -->
<div id="filters-bom" class="filter-bar" style="display:none">
<div class="fg">
<label>Item Group</label>
<select id="bom-grp">
<option>FG BLOOD BAG</option>
<option>FG CAPD</option>
<option>FG CAPD Accessories</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadData()">Load Data</button>
</div>
<!-- ── Filters: Sales Report ── -->
<div id="filters-sales" class="filter-bar" style="display:none">
<div class="fg">
<label>As Of Date <span style="font-size:9px;color:#9ca3af;font-weight:400">(reference date)</span></label>
<input type="date" id="sales-asof">
</div>
<div class="fg">
<label>Period</label>
<select id="sales-period">
<option value="all">All (Daily + Monthly + Yearly)</option>
<option value="daily">Daily</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadData()">Load Data</button>
</div>
<!-- ── Summary chips ── -->
<div class="summary-chips" id="chips"></div>
<!-- ── Table ── -->
<div class="tbl-wrap" id="tblWrap">
<div class="empty">Select filters and click <strong>Load Data</strong></div>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Ready</span>
</div>
</div>
</div>
</div>
<script>
(function(){
const tok = ()=> sessionStorage.getItem('portal_token')||'';
const authH = ()=>({'Content-Type':'application/json','Authorization':'Bearer '+tok()});
const fmtN = v => (parseFloat(v)||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});
const fmtC = v => '₹'+fmtN(v);
let currentReport = 'prod';
let currentData = [];
const TITLES = {
prod: '— Open Production Orders',
inv: '— FG Inventory',
bom: '— BOM Cost',
sales: '— Sales Report',
};
// ── Set defaults ─────────────────────────────────────────────────────────
(function(){
const today = new Date();
const y15 = new Date(); y15.setDate(y15.getDate()-14);
const m1 = new Date(today.getFullYear(), today.getMonth(), 1);
const yday = new Date(); yday.setDate(yday.getDate()-1);
document.getElementById('prod-from').value = y15.toISOString().split('T')[0];
document.getElementById('prod-to').value = today.toISOString().split('T')[0];
document.getElementById('inv-from').value = m1.toISOString().split('T')[0];
document.getElementById('inv-to').value = today.toISOString().split('T')[0];
document.getElementById('sales-asof').value = yday.toISOString().split('T')[0];
})();
function setStatus(msg, type=''){
document.getElementById('statusMsg').textContent = msg;
document.getElementById('statusDot').className = 'status-dot'+(type?' '+type:'');
}
function switchReport(name, el){
currentReport = name;
currentData = [];
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
el.classList.add('active');
document.getElementById('reportTitle').textContent = TITLES[name]||'';
['prod','inv','bom','sales'].forEach(r=>{
document.getElementById('filters-'+r).style.display = r===name ? 'flex' : 'none';
});
document.getElementById('chips').style.display = 'none';
document.getElementById('chips').innerHTML = '';
document.getElementById('tblWrap').innerHTML = '<div class="empty">Click <strong>Load Data</strong> to fetch</div>';
document.getElementById('btnExport').disabled = true;
setStatus('Ready');
}
// ── Generic loader ────────────────────────────────────────────────────────
async function loadData(){
document.getElementById('btnExport').disabled = true;
document.getElementById('tblWrap').innerHTML = '<div class="empty">Loading…</div>';
document.getElementById('chips').style.display= 'none';
setStatus('Fetching from SAP B1…','loading');
try {
let url, data;
if (currentReport === 'prod') { url = buildProdUrl(); }
else if (currentReport === 'inv') { url = buildInvUrl(); }
else if (currentReport === 'bom') { url = buildBomUrl(); }
else if (currentReport === 'sales') { url = buildSalesUrl(); }
const r = await fetch(url, {headers: authH()});
const j = await r.json();
if (!j.success) throw new Error(j.message);
currentData = j.data || [];
renderReport(j);
setStatus(`${currentData.length} record${currentData.length!==1?'s':''} loaded`, 'ok');
document.getElementById('btnExport').disabled = currentData.length === 0;
} catch(e) {
setStatus(e.message, 'err');
document.getElementById('tblWrap').innerHTML = `<div class="empty" style="color:#ef4444">${e.message}</div>`;
}
}
function buildProdUrl(){
const from = document.getElementById('prod-from').value;
const to = document.getElementById('prod-to').value;
const status = document.getElementById('prod-status').value;
const segment = document.getElementById('prod-segment').value;
if (!from||!to) throw new Error('Select from and to dates');
return `/api/reports/open-production-orders?from=${from}&to=${to}&status=${status}&segment=${encodeURIComponent(segment)}`;
}
function buildInvUrl(){
const from = document.getElementById('inv-from').value;
const to = document.getElementById('inv-to').value;
const whs = document.getElementById('inv-whs').value.trim() || '01';
const grp = document.getElementById('inv-grp').value;
if (!from||!to) throw new Error('Select from and to dates');
return `/api/reports/fg-inventory?from=${from}&to=${to}&warehouse=${encodeURIComponent(whs)}&itemGroup=${encodeURIComponent(grp)}`;
}
function buildBomUrl(){
const grp = document.getElementById('bom-grp').value;
return `/api/reports/bom-cost?itemGroup=${encodeURIComponent(grp)}`;
}
function buildSalesUrl(){
const asOf = document.getElementById('sales-asof').value;
const period = document.getElementById('sales-period').value;
if (!asOf) throw new Error('Select a reference date');
return `/api/reports/sales?asOf=${asOf}&period=${period}`;
}
// ── Dispatch renderer ─────────────────────────────────────────────────────
function renderReport(j){
if (currentReport === 'prod') renderProd(j.data);
else if (currentReport === 'inv') renderInv(j.data);
else if (currentReport === 'bom') renderBom(j.data, j.itemGroup);
else if (currentReport === 'sales') renderSales(j.data);
}
// ── Chips helper ──────────────────────────────────────────────────────────
function showChips(items){
const box = document.getElementById('chips');
box.innerHTML = items.map(({label,value,cls})=>
`<div class="chip ${cls||''}"><div>${label}</div><span>${value}</span></div>`
).join('');
box.style.display = 'flex';
}
// ── Render table helper ───────────────────────────────────────────────────
function renderTable(cols, rows){
if (!rows.length){
document.getElementById('tblWrap').innerHTML = '<div class="empty">No data found</div>';
return;
}
const thead = '<thead><tr>' + cols.map(c=>`<th class="${c.css||''}">${c.l}</th>`).join('') + '</tr></thead>';
const tbody = '<tbody>' + rows.map(row =>
'<tr>' + cols.map(c => {
let v = row[c.k] ?? '';
if (c.render) return c.render(v, row);
if (c.css === 'num' && v !== '') v = fmtN(v);
return `<td class="${c.css||''}" title="${String(v).replace(/"/g,'&quot;')}">${v}</td>`;
}).join('') + '</tr>'
).join('') + '</tbody>';
document.getElementById('tblWrap').innerHTML = `<table>${thead}${tbody}</table>`;
}
// ── PRODUCTION ORDERS ─────────────────────────────────────────────────────
function renderProd(rows){
if (!rows.length){
document.getElementById('tblWrap').innerHTML = '<div class="empty">No open production orders found</div>';
return;
}
const today = new Date(); today.setHours(0,0,0,0);
const released = rows.filter(r=>r.Status==='Released').length;
const planned = rows.filter(r=>r.Status==='Planned').length;
const overdue = rows.filter(r=>{
if(!r.DueDate) return false;
const [d,m,y]=r.DueDate.split('-'); return new Date(`${y}-${m}-${d}`) < today;
}).length;
const tp = rows.reduce((s,r)=>s+(parseFloat(r.PlannedQuantity)||0),0);
const tc = rows.reduce((s,r)=>s+(parseFloat(r.CompletedQty)||0),0);
const pct = tp>0 ? ((tc/tp)*100).toFixed(1) : '0.0';
showChips([
{label:'Total Orders', value:rows.length, cls:'blue'},
{label:'Released', value:released, cls:'green'},
{label:'Planned', value:planned},
{label:'Overdue', value:overdue, cls:'amber'},
{label:'Completion', value:pct+'%', cls:'red'},
]);
const cols = [
{k:'DocNum', l:'Doc No'},
{k:'SeriesName', l:'Series'},
{k:'Type', l:'Type'},
{k:'Status', l:'Status', render:(v)=>{
const bc=v==='Released'?'badge-released':'badge-planned';
return `<td><span class="badge ${bc}">${v}</span></td>`;
}},
{k:'ProductCode', l:'Product Code'},
{k:'ProductSegment', l:'Segment'},
{k:'ProductName', l:'Product Name', css:'trunc'},
{k:'PlannedQuantity', l:'Planned Qty', css:'num'},
{k:'CompletedQty', l:'Completed', css:'num'},
{k:'PostDate', l:'Post Date'},
{k:'StartDate', l:'Start Date'},
{k:'DueDate', l:'Due Date', render:(v,row)=>{
let cls='';
if(v){const [d,m,y]=v.split('-');if(new Date(`${y}-${m}-${d}`)<today)cls='over-due';}
return `<td class="${cls}">${v||''}</td>`;
}},
];
renderTable(cols, rows);
}
// ── FG INVENTORY ──────────────────────────────────────────────────────────
function renderInv(rows){
if (!rows.length){
document.getElementById('tblWrap').innerHTML = '<div class="empty">No inventory data found</div>';
return;
}
const totOp = rows.reduce((s,r)=>s+(parseFloat(r.OpeningBalanceQTY)||0),0);
const totPrd = rows.reduce((s,r)=>s+(parseFloat(r.ReceivedFromPrd)||0),0);
const totBal = rows.reduce((s,r)=>s+(parseFloat(r.Balance)||0),0);
const totLiv = rows.reduce((s,r)=>s+(parseFloat(r.QtyInWhse)||0),0);
showChips([
{label:'Items', value:rows.length, cls:'blue'},
{label:'Opening Qty', value:fmtN(totOp)},
{label:'Received (Prd)', value:fmtN(totPrd), cls:'green'},
{label:'Balance', value:fmtN(totBal), cls:'amber'},
{label:'Live Stock', value:fmtN(totLiv), cls:'purple'},
]);
const cols = [
{k:'Itemcode', l:'Item Code'},
{k:'ItemName', l:'Item Name', css:'trunc'},
{k:'ItemGroupName', l:'Group'},
{k:'OpeningBalanceQTY', l:'Opening Qty', css:'num'},
{k:'AvgRate1', l:'Avg Rate (Op)', css:'num'},
{k:'ReceivedFromPrd', l:'Recv. from Prod', css:'num'},
{k:'ReceivedStockQTY', l:'Stock Recv.', css:'num'},
{k:'AvgRate2', l:'Avg Rate (In)', css:'num'},
{k:'ARCreditMemoQty', l:'AR Credit Memo', css:'num'},
{k:'IssueStock', l:'Issue Stock', css:'num'},
{k:'AvgRate3', l:'Avg Rate (Out)', css:'num'},
{k:'StockTransferQty', l:'Stock Transfer', css:'num'},
{k:'Balance', l:'Balance', css:'num'},
{k:'QtyInWhse', l:'Live Stock', css:'num'},
];
renderTable(cols, rows);
}
// ── BOM COST ──────────────────────────────────────────────────────────────
function renderBom(rows, grp){
if (!rows.length){
document.getElementById('tblWrap').innerHTML = '<div class="empty">No BOM items found</div>';
return;
}
const minP = Math.min(...rows.map(r=>parseFloat(r.Price)||0).filter(v=>v>0));
const maxP = Math.max(...rows.map(r=>parseFloat(r.Price)||0));
const avgP = rows.reduce((s,r)=>s+(parseFloat(r.Price)||0),0)/rows.length;
showChips([
{label:'Total Items', value:rows.length, cls:'blue'},
{label:'Item Group', value:grp||'—'},
{label:'Min Price', value:fmtC(minP), cls:'green'},
{label:'Max Price', value:fmtC(maxP), cls:'red'},
{label:'Avg Price', value:fmtC(avgP), cls:'amber'},
]);
const cols = [
{k:'ItemCode', l:'Item Code'},
{k:'ItemName', l:'Item Name', css:'trunc'},
{k:'Price', l:'Price (₹)', css:'num'},
];
renderTable(cols, rows);
}
// ── SALES REPORT ──────────────────────────────────────────────────────────
function renderSales(rows){
if (!rows.length){
document.getElementById('tblWrap').innerHTML = '<div class="empty">No sales data found</div>';
return;
}
const yearly = rows.find(r=>(r.Period||'').toLowerCase().startsWith('yearly'));
showChips([
{label:'Blood Bag (TM)', value:fmtC(yearly?.TM_BloodBag || 0), cls:'blue'},
{label:'CAPD (PD)', value:fmtC(yearly?.PD_CAPD || 0), cls:'green'},
{label:'Export Direct', value:fmtC(yearly?.ExportDirect || 0), cls:'amber'},
{label:'Export Indirect', value:fmtC(yearly?.ExportIndirect || 0)},
{label:'Yearly Total', value:fmtC(yearly?.Total || 0), cls:'purple'},
]);
const cols = [
{k:'Period', l:'Period', css:'trunc'},
{k:'TM_BloodBag', l:'Blood Bag (TM)', css:'num'},
{k:'PD_CAPD', l:'CAPD (PD)', css:'num'},
{k:'ExportDirect', l:'Export Direct', css:'num'},
{k:'ExportIndirect', l:'Export Indirect', css:'num'},
{k:'Total', l:'Total', css:'num'},
];
renderTable(cols, rows);
}
// ── Export CSV ────────────────────────────────────────────────────────────
function exportCsv(){
if (!currentData.length) return;
const keys = Object.keys(currentData[0]);
const lines = [
keys.join(','),
...currentData.map(r=>keys.map(k=>{
const v = String(r[k]??'').replace(/"/g,'""');
return v.includes(',') ? `"${v}"` : v;
}).join(','))
];
const blob = new Blob([lines.join('\r\n')],{type:'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${currentReport}_report_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setStatus('CSV exported','ok');
}
window.loadData = loadData;
window.exportCsv = exportCsv;
window.switchReport = switchReport;
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+768
View File
@@ -0,0 +1,768 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Salary Cost Centre | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}.hdr h1 span{color:#16a34a}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#16a34a;color:#fff}.btn-primary:hover{background:#15803d}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed}
.btn-outline{background:#fff;color:#16a34a;border:1.5px solid #16a34a}.btn-outline:hover{background:#f0fdf4}
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.08)}
.summary-chips{display:flex;gap:8px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb}
.chip{background:#fff;border:1.5px solid #bbf7d0;border-radius:10px;padding:6px 14px;font-size:11px;font-weight:600;color:#16a34a;min-width:90px}
.chip span{font-size:15px;font-weight:700;display:block;color:#1a1a2e;margin-top:1px}
.tabs{display:flex;justify-content:space-evenly;border-bottom:2px solid #e8eaed;background:#fff}
.tab{flex:1;text-align:center;padding:14px 10px;font-size:12px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap;transition:all .15s}
.tab:hover{color:#16a34a;background:#f0fdf4}
.tab.active{color:#16a34a;border-bottom-color:#16a34a;background:#f0fdf4}
.tab-badge{display:inline-block;background:#dcfce7;color:#16a34a;border-radius:10px;padding:1px 7px;font-size:10px;font-weight:700;margin-left:5px}
.tab.active .tab-badge{background:#16a34a;color:#fff}
.tab-content{display:none}.tab-content.active{display:block}
.tbl-wrap{overflow-x:auto;max-height:52vh}
.tbl-wrap::-webkit-scrollbar{height:6px;width:6px}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
table{border-collapse:collapse;min-width:100%;font-size:12px}
thead th{background:#f0fdf4;color:#374151;font-weight:700;padding:9px 12px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #bbf7d0;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s}
tbody tr:hover{background:#f0fdf4}
tbody td{padding:8px 12px;color:#374151;white-space:nowrap}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
.empty{text-align:center;padding:48px 20px;color:#9ca3af;font-size:13px}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#16a34a}.status-dot.err{background:#ef4444}.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#16a34a}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
/* Upload pane */
.upload-zone{border:2px dashed #bbf7d0;border-radius:12px;padding:40px 20px;text-align:center;cursor:pointer;transition:all .2s;background:#f0fdf4;margin:20px}
.upload-zone:hover,.upload-zone.drag{border-color:#16a34a;background:#dcfce7}
.upload-zone input{display:none}
.upload-zone .icon{font-size:40px;margin-bottom:12px}
.upload-zone p{color:#374151;font-size:14px;font-weight:600}
.upload-zone small{color:#9ca3af;font-size:12px}
.upload-results{padding:0 20px 20px}
.sheet-badge{display:inline-flex;align-items:center;gap:6px;background:#dcfce7;color:#15803d;border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;margin:4px}
.sheet-badge.skipped{background:#fef3c7;color:#92400e}
/* Configure sub-tabs */
.cfg-subtabs{display:flex;gap:0;border-bottom:2px solid #e8eaed;background:#f8fafb;padding:0 20px}
.cfg-subtab{padding:10px 16px;font-size:12px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap;background:none;border-top:none;border-left:none;border-right:none;font-family:inherit}
.cfg-subtab:hover{color:#16a34a}
.cfg-subtab.active{color:#16a34a;border-bottom-color:#16a34a}
.cfg-subpane{display:none}.cfg-subpane.active{display:block}
/* Dept mapping table */
.dept-map-wrap{padding:16px 20px;max-height:55vh;overflow-y:auto}
.dept-map-table{width:100%;border-collapse:collapse;font-size:12px}
.dept-map-table th{background:#f0fdf4;font-size:10px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;padding:8px 12px;border-bottom:2px solid #bbf7d0;text-align:left;position:sticky;top:0}
.dept-map-table td{padding:8px 12px;border-bottom:1px solid #f0f2f4}
.dept-map-table tr:hover td{background:#f0fdf4}
.dept-map-select{height:32px;border:1.5px solid #dde1e7;border-radius:7px;padding:0 8px;font-size:12px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;min-width:200px;transition:border-color .15s}
.dept-map-select:focus{border-color:#16a34a}
.dept-map-select.mapped{border-color:#16a34a;background:#f0fdf4;color:#15803d;font-weight:600}
.dept-map-select.unmapped{border-color:#f59e0b;background:#fffbeb}
/* Configure pane */
.cfg-layout{display:flex;min-height:400px}
.cfg-sidebar{width:220px;flex-shrink:0;border-right:1px solid #e8eaed;overflow-y:auto;max-height:60vh}
.cfg-sheet-item{padding:12px 16px;cursor:pointer;border-bottom:1px solid #f3f4f6;transition:background .1s;font-size:12px}
.cfg-sheet-item:hover{background:#f0fdf4}
.cfg-sheet-item.active{background:#dcfce7;border-left:3px solid #16a34a}
.cfg-sheet-item .sname{font-weight:700;color:#1a1a2e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px}
.cfg-sheet-item .snet{font-size:11px;color:#6b7280;margin-top:2px}
.cfg-panel{flex:1;padding:20px;overflow-y:auto;max-height:60vh}
.cfg-panel h3{font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:4px}
.cfg-panel .cfg-sub{font-size:11px;color:#6b7280;margin-bottom:16px}
.cc-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px}
.cc-item{display:flex;flex-direction:column;gap:4px}
.cc-item label{font-size:10px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cc-item input{height:34px;border:1.5px solid #dde1e7;border-radius:7px;padding:0 8px 0 10px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s;width:100%}
.cc-item input:focus{border-color:#16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.08)}
.cc-item.bb-capd input{border-color:#f59e0b;background:#fffbeb}
.total-bar{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;font-size:12px;font-weight:700;margin-bottom:16px;background:#f8fafb;border:1.5px solid #e8eaed}
.total-bar.ok{background:#dcfce7;border-color:#bbf7d0;color:#15803d}
.total-bar.over{background:#fee2e2;border-color:#fca5a5;color:#dc2626}
.total-bar.under{background:#fef3c7;border-color:#fde68a;color:#92400e}
.bb-split{background:#fffbeb;border:1.5px solid #fde68a;border-radius:8px;padding:14px;margin-bottom:16px}
.bb-split h4{font-size:11px;font-weight:700;color:#92400e;text-transform:uppercase;letter-spacing:.4px;margin-bottom:10px}
.bb-split-row{display:flex;align-items:center;gap:10px;font-size:12px}
.bb-split-row input{width:70px;height:30px;border:1.5px solid #fde68a;border-radius:6px;padding:0 8px;font-size:13px;font-family:inherit;outline:none}
.bb-split-row input:focus{border-color:#f59e0b}
.bb-split-total{font-size:11px;font-weight:700;margin-left:8px}
.bb-split-total.ok{color:#16a34a}.bb-split-total.bad{color:#ef4444}
/* Summary pane */
.summary-table-wrap{padding:20px}
.summary-section-title{font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:16px 0 8px}
.grand-total-row td{font-weight:700;background:#f0fdf4;color:#15803d}
.pct-bar{display:inline-block;height:6px;background:#16a34a;border-radius:3px;vertical-align:middle;margin-left:6px;opacity:.6}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=25"></script>
<div class="pg">
<div class="hdr">
<h1><span>Salary</span> Cost Centre</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-outline" id="btnExport" disabled>Export Excel</button>
</div>
</div>
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>Period (Month)</label>
<input type="month" id="periodInput">
</div>
<button class="btn btn-primary" id="btnLoad">Load</button>
</div>
<div class="summary-chips" id="summaryChips" style="display:none">
<div class="chip">Sheets<span id="cntSheets">—</span></div>
<div class="chip">Employees<span id="cntEmps">—</span></div>
<div class="chip">Total Gross<span id="cntGross">—</span></div>
<div class="chip">Total Net<span id="cntNet">—</span></div>
</div>
<div class="tabs">
<div class="tab active" data-tab="upload">Upload</div>
<div class="tab" data-tab="data">Data <span class="tab-badge" id="tbData">0</span></div>
<div class="tab" data-tab="configure">Configure</div>
<div class="tab" data-tab="summary">Summary</div>
</div>
<!-- UPLOAD TAB -->
<div class="tab-content active" id="pane-upload">
<div style="padding:20px">
<div class="fg" style="max-width:200px;margin-bottom:16px">
<label>Upload Period</label>
<input type="month" id="uploadPeriod">
</div>
<div class="upload-zone" id="dropZone">
<input type="file" id="fileInput" accept=".xlsx,.xls">
<div class="icon"></div>
<p>Drop salary Excel here or click to browse</p>
<small>All sheets will be parsed automatically</small>
</div>
<div style="text-align:center;margin-top:4px">
<button class="btn btn-primary" id="btnUpload" disabled>Upload &amp; Parse</button>
</div>
</div>
<div class="upload-results" id="uploadResults" style="display:none">
<div style="font-size:12px;font-weight:700;color:#374151;margin-bottom:10px">Sheets Detected:</div>
<div id="sheetBadges"></div>
</div>
</div>
<!-- DATA TAB -->
<div class="tab-content" id="pane-data">
<div style="padding:12px 20px;border-bottom:1px solid #e8eaed;display:flex;align-items:center;gap:12px">
<div class="fg">
<label>Sheet</label>
<select id="sheetSelect" style="min-width:200px"><option value="">— select sheet —</option></select>
</div>
<div style="font-size:12px;color:#6b7280;margin-top:18px" id="rowCountLabel"></div>
</div>
<div class="tbl-wrap">
<div class="empty" id="dataEmpty">Select a period and sheet to view data</div>
<table id="dataTable" style="display:none">
<thead><tr>
<th>Sr</th><th>Emp Code</th><th>Name</th><th>Department</th>
<th style="text-align:right">Gross</th><th style="text-align:right">Net Pay</th>
</tr></thead>
<tbody id="dataTbody"></tbody>
</table>
</div>
</div>
<!-- CONFIGURE TAB -->
<div class="tab-content" id="pane-configure">
<div class="cfg-subtabs">
<button class="cfg-subtab active" data-sub="deptmap">Dept &rarr; Cost Centre</button>
<button class="cfg-subtab" data-sub="sheetalloc">Sheet % Allocation</button>
</div>
<!-- Dept Mapping sub-pane -->
<div class="cfg-subpane active" id="sub-deptmap">
<div style="padding:12px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:12px;color:#6b7280">Map each department / cost centre value from Excel to one of the 13 cost centres. Auto-saves on change.</div>
<button class="btn btn-outline" id="btnReloadDepts" style="height:30px;font-size:11px">Refresh</button>
</div>
<div class="dept-map-wrap" id="deptMapWrap">
<div class="empty">Load a period to see department values</div>
</div>
</div>
<!-- Sheet Allocation sub-pane -->
<div class="cfg-subpane" id="sub-sheetalloc">
<div class="cfg-layout">
<div class="cfg-sidebar" id="cfgSidebar">
<div class="empty" style="padding:20px;font-size:12px">Load a period first</div>
</div>
<div class="cfg-panel" id="cfgPanel">
<div class="empty">Select a salary sheet from the left to configure % allocations (used as fallback for unmapped departments)</div>
</div>
</div>
</div>
</div>
<!-- SUMMARY TAB -->
<div class="tab-content" id="pane-summary">
<div class="summary-table-wrap" id="summaryContent">
<div class="empty">Load a period to view cost centre summary</div>
</div>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select a period and click Load</span>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
const tok = sessionStorage.getItem('portal_token');
if (!tok) { window.location.href = '/'; return; }
const hdrs = { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' };
const COST_CENTRES = [
'Administration overhead','BB & CAPD','BLOOD BAG','Boiler','CAPD','Diff','Equipment',
'FACTORY OVERHEAD','POWER & FUEL','QA','QC','R&D',
'Selling & distribution overhead','STENT',
];
let currentPeriod = '';
let sheetsData = [];
let configData = {};
let bbRatioData = {};
let selectedCfgSheet = null;
let summaryData = null;
let parsedFile = null;
let deptMappingsData = {}; // { dept_name: cost_centre }
// ── Init ──────────────────────────────────────────────────────────────────
(function(){
const now = new Date();
const val = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2,'0');
document.getElementById('periodInput').value = val;
document.getElementById('uploadPeriod').value = val;
})();
// ── Tabs ──────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.getElementById('pane-' + t.dataset.tab).classList.add('active');
if (t.dataset.tab === 'configure' && currentPeriod) { renderCfgSidebar(); loadDeptMappings(); }
if (t.dataset.tab === 'summary' && currentPeriod) loadSummary();
}));
// Configure sub-tabs
document.querySelectorAll('.cfg-subtab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.cfg-subtab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.cfg-subpane').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.getElementById('sub-' + t.dataset.sub).classList.add('active');
if (t.dataset.sub === 'deptmap' && currentPeriod) loadDeptMappings();
}));
document.getElementById('btnReloadDepts').addEventListener('click', () => loadDeptMappings());
// ── Load ──────────────────────────────────────────────────────────────────
document.getElementById('btnLoad').addEventListener('click', () => {
currentPeriod = document.getElementById('periodInput').value;
if (!currentPeriod) { showToast('Select a period','error'); return; }
loadSheets();
});
async function loadSheets() {
setStatus('Loading…','loading');
try {
const r = await fetch(`/api/salary/sheets?period=${currentPeriod}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { setStatus(r.message,'err'); return; }
sheetsData = r.data || [];
renderChips();
renderSheetSelect();
renderCfgSidebar();
await loadConfig();
setStatus(`Loaded ${sheetsData.length} sheets for ${currentPeriod}`,'ok');
document.getElementById('btnExport').disabled = sheetsData.length === 0;
document.getElementById('tbData').textContent = sheetsData.reduce((s,x) => s + (x.row_count||0), 0);
document.getElementById('summaryChips').style.display = 'flex';
} catch(e) { setStatus('Error: '+e.message,'err'); }
}
function renderChips() {
const totalEmps = sheetsData.reduce((s,x)=>s+(x.row_count||0),0);
const totalGross = sheetsData.reduce((s,x)=>s+(parseFloat(x.total_gross)||0),0);
const totalNet = sheetsData.reduce((s,x)=>s+(parseFloat(x.total_net)||0),0);
document.getElementById('cntSheets').textContent = sheetsData.length;
document.getElementById('cntEmps').textContent = totalEmps;
document.getElementById('cntGross').textContent = fmtNum(totalGross);
document.getElementById('cntNet').textContent = fmtNum(totalNet);
}
function renderSheetSelect() {
const sel = document.getElementById('sheetSelect');
sel.innerHTML = '<option value="">— select sheet —</option>' +
sheetsData.map(s => `<option value="${esc(s.sheet_name)}">${esc(s.sheet_name)} (${s.row_count} rows)</option>`).join('');
}
// ── Dept Mapping ──────────────────────────────────────────────────────────
async function loadDeptMappings() {
const wrap = document.getElementById('deptMapWrap');
wrap.innerHTML = '<div class="empty">Loading…</div>';
try {
const r = await fetch(`/api/salary/dept-mappings?period=${currentPeriod}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { wrap.innerHTML = `<div class="empty">${esc(r.message)}</div>`; return; }
deptMappingsData = {};
(r.mappings || []).forEach(m => { deptMappingsData[m.dept_name] = m.cost_centre || ''; });
const depts = r.depts || [];
if (!depts.length) { wrap.innerHTML = '<div class="empty">No department data found for this period. Upload salary Excel first.</div>'; return; }
const opts = ['<option value="">— Not mapped —</option>',
...COST_CENTRES.map(cc => `<option value="${esc(cc)}">${esc(cc)}</option>`)
].join('');
wrap.innerHTML = `
<table class="dept-map-table">
<thead><tr>
<th>Department / Cost Centre (from Excel)</th>
<th style="text-align:right">Employees</th>
<th style="text-align:right">Total Net Pay</th>
<th>Map to Cost Centre</th>
<th>Status</th>
</tr></thead>
<tbody>${depts.map(d => {
const mapped = deptMappingsData[d.dept_name] || '';
return `<tr>
<td><b>${esc(d.dept_name)}</b></td>
<td style="text-align:right">${d.emp_count}</td>
<td style="text-align:right">${fmtNum(parseFloat(d.total_net)||0)}</td>
<td><select class="dept-map-select ${mapped?'mapped':'unmapped'}" data-dept="${esc(d.dept_name)}">
${opts}
</select></td>
<td id="dms-${esc(d.dept_name.replace(/[^a-z0-9]/gi,'_'))}">
${mapped ? `<span style="color:#16a34a;font-weight:700">&#10003; ${esc(mapped)}</span>` : '<span style="color:#f59e0b">&#9679; Unmapped</span>'}
</td>
</tr>`;
}).join('')}</tbody>
</table>`;
// Set current values
wrap.querySelectorAll('.dept-map-select').forEach(sel => {
const dept = sel.dataset.dept;
sel.value = deptMappingsData[dept] || '';
sel.addEventListener('change', () => saveDeptMapping(dept, sel.value, sel));
});
} catch(e) { wrap.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`; }
}
async function saveDeptMapping(deptName, costCentre, selEl) {
try {
const r = await fetch('/api/salary/dept-mapping', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ dept_name: deptName, cost_centre: costCentre }),
}).then(r => r.json());
if (r.success) {
deptMappingsData[deptName] = costCentre;
if (selEl) {
selEl.className = 'dept-map-select ' + (costCentre ? 'mapped' : 'unmapped');
const key = deptName.replace(/[^a-z0-9]/gi,'_');
const statusEl = document.getElementById('dms-' + key);
if (statusEl) statusEl.innerHTML = costCentre
? `<span style="color:#16a34a;font-weight:700">&#10003; ${esc(costCentre)}</span>`
: '<span style="color:#f59e0b">&#9679; Unmapped</span>';
}
showToast(`Saved: ${deptName} → ${costCentre || 'unmapped'}`, 'success');
} else { showToast(r.message, 'error'); }
} catch(e) { showToast(e.message, 'error'); }
}
// ── Config ────────────────────────────────────────────────────────────────
async function loadConfig() {
const r = await fetch('/api/salary/config',{headers:hdrs}).then(r=>r.json());
if (!r.success) return;
configData = {};
(r.config || []).forEach(c => {
if (!configData[c.sheet_name]) configData[c.sheet_name] = {};
configData[c.sheet_name][c.cost_centre] = parseFloat(c.alloc_pct) || 0;
});
bbRatioData = {};
(r.bbRatios || []).forEach(b => { bbRatioData[b.sheet_name] = b; });
}
function renderCfgSidebar() {
const sb = document.getElementById('cfgSidebar');
if (!sheetsData.length) {
sb.innerHTML = '<div class="empty" style="padding:20px;font-size:12px">Load a period first</div>';
return;
}
sb.innerHTML = sheetsData.map(s => `
<div class="cfg-sheet-item${selectedCfgSheet===s.sheet_name?' active':''}" data-sheet="${esc(s.sheet_name)}">
<div class="sname" title="${esc(s.sheet_name)}">${esc(s.sheet_name)}</div>
<div class="snet">Net: ${fmtNum(parseFloat(s.total_net)||0)} &bull; ${s.row_count} emp</div>
</div>`).join('');
sb.querySelectorAll('.cfg-sheet-item').forEach(el =>
el.addEventListener('click', () => {
selectedCfgSheet = el.dataset.sheet;
sb.querySelectorAll('.cfg-sheet-item').forEach(x => x.classList.remove('active'));
el.classList.add('active');
renderCfgPanel(selectedCfgSheet);
})
);
}
function renderCfgPanel(sheetName) {
const panel = document.getElementById('cfgPanel');
const alloc = configData[sheetName] || {};
const bb = bbRatioData[sheetName] || { bb_pct: 70, capd_pct: 30 };
const sheet = sheetsData.find(s => s.sheet_name === sheetName);
const netAmt = sheet ? parseFloat(sheet.total_net) || 0 : 0;
const ccInputs = COST_CENTRES.map(cc => {
const pct = alloc[cc] || 0;
const isBB = cc === 'BB & CAPD';
return `<div class="cc-item${isBB?' bb-capd':''}">
<label title="${esc(cc)}">${esc(cc)}</label>
<input type="number" min="0" max="100" step="0.01" value="${pct}" data-cc="${esc(cc)}"
placeholder="0" style="text-align:right">
</div>`;
}).join('');
panel.innerHTML = `
<h3>${esc(sheetName)}</h3>
<div class="cfg-sub">Net Pay: <b>${fmtNum(netAmt)}</b> &bull; Configure % allocation to each cost centre</div>
<div class="total-bar" id="cfgTotalBar">Total: 0%</div>
<div class="cc-grid">${ccInputs}</div>
<div class="bb-split" id="bbSplitBox">
<h4>BB &amp; CAPD Split Ratio</h4>
<div class="bb-split-row">
<span>BLOOD BAG:</span>
<input type="number" id="bbPct" min="0" max="100" step="0.01" value="${parseFloat(bb.bb_pct)||70}">
<span>%</span>
<span style="margin:0 8px">|</span>
<span>CAPD:</span>
<input type="number" id="capdPct" min="0" max="100" step="0.01" value="${parseFloat(bb.capd_pct)||30}">
<span>%</span>
<span class="bb-split-total" id="bbSplitTotal"></span>
</div>
<div style="font-size:11px;color:#92400e;margin-top:6px">
Applied when BB &amp; CAPD % &gt; 0. For Probationer, leave BB &amp; CAPD % as 0 until decided.
</div>
</div>
<button class="btn btn-primary" id="btnSaveCfg">&#10003; Save Configuration</button>`;
// Live total update
function updateTotal() {
const inputs = panel.querySelectorAll('input[data-cc]');
let total = 0;
inputs.forEach(i => { total += parseFloat(i.value) || 0; });
total = Math.round(total * 100) / 100;
const bar = document.getElementById('cfgTotalBar');
bar.textContent = `Total: ${total}%`;
bar.className = 'total-bar ' + (total === 100 ? 'ok' : total > 100 ? 'over' : 'under');
}
function updateBBTotal() {
const bb = parseFloat(document.getElementById('bbPct').value) || 0;
const cap = parseFloat(document.getElementById('capdPct').value) || 0;
const tot = Math.round((bb + cap) * 100) / 100;
const el = document.getElementById('bbSplitTotal');
el.textContent = `= ${tot}%`;
el.className = 'bb-split-total ' + (tot === 100 ? 'ok' : 'bad');
}
panel.querySelectorAll('input[data-cc]').forEach(i => i.addEventListener('input', updateTotal));
document.getElementById('bbPct') .addEventListener('input', updateBBTotal);
document.getElementById('capdPct').addEventListener('input', updateBBTotal);
updateTotal(); updateBBTotal();
document.getElementById('btnSaveCfg').addEventListener('click', () => saveCfg(sheetName));
}
async function saveCfg(sheetName) {
const panel = document.getElementById('cfgPanel');
const inputs = panel.querySelectorAll('input[data-cc]');
const entries = [];
inputs.forEach(i => {
entries.push({ sheet_name: sheetName, cost_centre: i.dataset.cc, alloc_pct: parseFloat(i.value) || 0 });
});
const bbPct = parseFloat(document.getElementById('bbPct').value) || 70;
const capdPct = parseFloat(document.getElementById('capdPct').value) || 30;
try {
const r = await fetch('/api/salary/config', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ entries, bbRatios: [{ sheet_name: sheetName, bb_pct: bbPct, capd_pct: capdPct }] }),
}).then(r => r.json());
if (r.success) {
if (!configData[sheetName]) configData[sheetName] = {};
entries.forEach(e => { configData[sheetName][e.cost_centre] = e.alloc_pct; });
bbRatioData[sheetName] = { bb_pct: bbPct, capd_pct: capdPct };
showToast('Configuration saved','success');
} else { showToast(r.message,'error'); }
} catch(e) { showToast(e.message,'error'); }
}
// ── Data tab ──────────────────────────────────────────────────────────────
document.getElementById('sheetSelect').addEventListener('change', async function() {
const sheet = this.value;
if (!sheet || !currentPeriod) return;
try {
const r = await fetch(`/api/salary/rows?period=${currentPeriod}&sheet=${encodeURIComponent(sheet)}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { showToast(r.message,'error'); return; }
renderDataTable(r.data || []);
document.getElementById('rowCountLabel').textContent = `${(r.data||[]).length} employees`;
} catch(e) { showToast(e.message,'error'); }
});
function renderDataTable(rows) {
const tbl = document.getElementById('dataTable');
const empty = document.getElementById('dataEmpty');
if (!rows.length) { tbl.style.display='none'; empty.style.display='block'; empty.textContent='No data'; return; }
document.getElementById('dataTbody').innerHTML = rows.map(r => `<tr>
<td>${esc(r.sr_no||'')}</td>
<td>${esc(r.emp_code||'')}</td>
<td>${esc(r.emp_name||'')}</td>
<td>${esc(r.department||'')}</td>
<td class="num">${fmtNum(parseFloat(r.gross_amt)||0)}</td>
<td class="num">${fmtNum(parseFloat(r.net_pay)||0)}</td>
</tr>`).join('');
tbl.style.display = 'table'; empty.style.display = 'none';
}
// ── Summary ───────────────────────────────────────────────────────────────
async function loadSummary() {
if (!currentPeriod) return;
setStatus('Computing summary…','loading');
try {
const r = await fetch(`/api/salary/summary?period=${currentPeriod}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { setStatus(r.message,'err'); return; }
summaryData = r.data;
renderSummary(summaryData);
document.getElementById('btnExport').disabled = false;
setStatus('Summary computed','ok');
} catch(e) { setStatus(e.message,'err'); }
}
function renderSummary(data) {
const el = document.getElementById('summaryContent');
if (!data) { el.innerHTML = '<div class="empty">No data</div>'; return; }
const total = data.total || {};
const grandTotal = Object.values(total).reduce((s,v)=>s+(v||0),0);
const ccRows = COST_CENTRES.map(cc => {
const amt = total[cc] || 0;
const pct = grandTotal > 0 ? (amt / grandTotal * 100).toFixed(1) : '0.0';
const barW = grandTotal > 0 ? Math.round(amt / grandTotal * 80) : 0;
return `<tr>
<td><b>${esc(cc)}</b></td>
<td class="num">${fmtNum(amt)}</td>
<td class="num">${pct}%<span class="pct-bar" style="width:${barW}px"></span></td>
</tr>`;
}).join('');
// Sheet breakdown
const sheetRows = Object.entries(data.by_sheet || {}).map(([sname, info]) => {
const allocated = Object.values(info.allocation).reduce((s,v)=>s+(v||0),0);
const unalloc = (info.total_net||0) - allocated;
const hasUnalloc = Math.abs(unalloc) > 1;
const unmapped = info.unmapped_depts || {};
const unmappedEntries = Object.entries(unmapped);
const detailRows = hasUnalloc && unmappedEntries.length ? unmappedEntries.map(([dept, d]) => {
const pctLabel = d.pct_applied > 0 ? ` (${d.pct_applied.toFixed(0)}% sheet allocation applied)` : ' (no allocation — map this dept)';
return `<tr style="background:#fef9f9;font-size:11px">
<td style="padding-left:32px;color:#888">&#8627; ${esc(dept)}</td>
<td class="num" style="color:#888">${d.emp}</td>
<td class="num" style="color:#888">${fmtNum(d.net)}</td>
<td colspan="2" style="color:#f59e0b;font-size:10px">${pctLabel}</td>
</tr>`;
}).join('') : '';
return `<tr>
<td>${esc(sname)}</td>
<td class="num">${info.row_count || 0}</td>
<td class="num">${fmtNum(info.total_net||0)}</td>
<td class="num">${fmtNum(allocated)}</td>
<td class="num" style="color:${hasUnalloc?'#ef4444':'#16a34a'}">${fmtNum(unalloc)}</td>
</tr>${detailRows}`;
}).join('');
el.innerHTML = `
<div class="summary-section-title">Cost Centre Allocation — ${currentPeriod}</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Cost Centre</th><th style="text-align:right">Net Salary</th><th style="text-align:right">% of Total</th></tr></thead>
<tbody>
${ccRows}
<tr class="grand-total-row">
<td>GRAND TOTAL</td>
<td class="num">${fmtNum(grandTotal)}</td>
<td class="num">100%</td>
</tr>
</tbody>
</table>
</div>
<div class="summary-section-title" style="margin-top:24px">Sheet-wise Breakdown</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Sheet / Salary Type</th><th style="text-align:right">Employees</th><th style="text-align:right">Total Net</th><th style="text-align:right">Allocated</th><th style="text-align:right">Unallocated</th></tr></thead>
<tbody>${sheetRows}</tbody>
</table>
</div>`;
}
// ── Upload ────────────────────────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const btnUpload = document.getElementById('btnUpload');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag'); handleFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => handleFile(fileInput.files[0]));
function handleFile(file) {
if (!file) return;
dropZone.querySelector('p').textContent = file.name;
dropZone.querySelector('small').textContent = `Size: ${(file.size/1024).toFixed(1)} KB`;
btnUpload.disabled = false;
const reader = new FileReader();
reader.onload = e => {
try {
const wb = XLSX.read(e.target.result, { type: 'array' });
const sheets = {};
wb.SheetNames.forEach(name => {
sheets[name] = XLSX.utils.sheet_to_json(wb.Sheets[name], { header: 1, defval: '' });
});
parsedFile = { sheets };
dropZone.querySelector('small').textContent = `${wb.SheetNames.length} sheets found: ${wb.SheetNames.join(', ')}`;
} catch(err) { showToast('Error reading file: '+err.message,'error'); btnUpload.disabled = true; }
};
reader.readAsArrayBuffer(file);
}
btnUpload.addEventListener('click', async () => {
const period = document.getElementById('uploadPeriod').value;
if (!period) { showToast('Select upload period','error'); return; }
if (!parsedFile) { showToast('No file loaded','error'); return; }
btnUpload.disabled = true;
setStatus('Uploading…','loading');
try {
const r = await fetch('/api/salary/upload', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ period, sheets: parsedFile.sheets }),
}).then(r => r.json());
if (!r.success) { showToast(r.message,'error'); setStatus(r.message,'err'); return; }
const results = r.data || [];
const badges = results.map(x =>
`<span class="sheet-badge${x.skipped?' skipped':''}">
${esc(x.sheet)} &bull; ${x.rows} rows${x.skipped?' (skipped — no Net Pay column)':''}
</span>`).join('');
document.getElementById('sheetBadges').innerHTML = badges;
document.getElementById('uploadResults').style.display = 'block';
showToast(`Uploaded ${results.filter(x=>!x.skipped).length} sheets successfully`,'success');
setStatus('Upload complete','ok');
// Auto-load sheets if period matches
if (period === document.getElementById('periodInput').value) {
currentPeriod = period;
await loadSheets();
await loadDeptMappings();
}
} catch(e) { showToast(e.message,'error'); setStatus(e.message,'err'); }
finally { btnUpload.disabled = false; }
});
// ── Export ────────────────────────────────────────────────────────────────
document.getElementById('btnExport').addEventListener('click', () => {
if (!summaryData && currentPeriod) { loadSummary().then(exportExcel); return; }
exportExcel();
});
function exportExcel() {
if (!summaryData) { showToast('Load summary first','error'); return; }
try {
const wb = XLSX.utils.book_new();
const total = summaryData.total || {};
const grandTotal = Object.values(total).reduce((s,v)=>s+(v||0),0);
// Summary sheet
const summRows = [['Cost Centre','Net Salary','% of Total'],
...COST_CENTRES.map(cc => [cc, parseFloat((total[cc]||0).toFixed(2)), grandTotal>0?parseFloat((total[cc]||0/grandTotal*100).toFixed(2)):0]),
['GRAND TOTAL', parseFloat(grandTotal.toFixed(2)), 100]
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summRows), 'Summary');
// Sheet-wise breakdown
Object.entries(summaryData.by_sheet || {}).forEach(([sname, info]) => {
const rows = [
['Cost Centre', 'Amount'],
...COST_CENTRES.map(cc => [cc, parseFloat(((info.allocation||{})[cc]||0).toFixed(2))]),
['Total Net', parseFloat((info.total_net||0).toFixed(2))],
];
const wsName = sname.replace(/[:\\\/\?\*\[\]]/g,'').trim().slice(0,31) || 'Sheet';
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), wsName);
});
XLSX.writeFile(wb, `Salary_CostCentre_${currentPeriod}.xlsx`);
showToast('Excel downloaded','success');
} catch(e) { showToast('Export failed: '+e.message,'error'); }
}
// ── Helpers ───────────────────────────────────────────────────────────────
function setStatus(msg, st) {
document.getElementById('statusMsg').textContent = msg;
document.getElementById('statusDot').className = 'status-dot' + (st ? ' '+st : '');
}
function showToast(msg, type='info') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `toast ${type} show`;
setTimeout(() => t.classList.remove('show'), 3500);
}
function fmtNum(v, dec=2) {
return Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:dec,maximumFractionDigits:dec});
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
</script>
</body>
</html>
+441
View File
@@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>SAP Approvals — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--purple:#9f5afd;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh}
main{padding:12px;max-width:800px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);border-radius:var(--radius) var(--radius) 0 0}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}
.fi:focus{border-color:var(--blue)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:500px){.fi-row{grid-template-columns:1fr}}
.status-tabs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px}
.st-tab{padding:6px 14px;border-radius:8px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;border:1px solid var(--border2);background:none;font-family:var(--font);transition:all .12s}
.st-tab:hover{color:var(--text2)}.st-tab.on{color:var(--blue);border-color:var(--blue);background:rgba(26,111,255,.06)}
.ar-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.ar-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 14px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.ar-card:hover{border-color:var(--blue);box-shadow:0 6px 18px rgba(26,111,255,.14);transform:translateY(-2px)}
.ar-card .ar-desc{margin-top:6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.ar-card .ar-meta{margin-top:auto;padding-top:8px;border-top:1px solid var(--border);flex-direction:column;gap:3px}
.ar-top{display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap}
.ar-code{font-family:var(--mono);font-size:12px;color:var(--blue);font-weight:600}
.ar-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px}
.ar-badge.pending{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
.ar-badge.approved{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.25)}
.ar-badge.rejected{background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.2)}
.ar-badge.type{background:rgba(159,90,253,.08);color:var(--purple);border:1px solid rgba(159,90,253,.2)}
.ar-badge.draft{background:rgba(26,111,255,.08);color:var(--blue);border:1px solid rgba(26,111,255,.2)}
.ar-desc{font-size:12px;font-weight:600;color:var(--text)}
.ar-meta{font-size:10px;color:var(--text3);display:flex;gap:8px;flex-wrap:wrap;margin-top:2px}
.ar-meta b{color:var(--text2)}
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:8px 16px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:5px}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-approve{background:var(--green);color:#fff;flex:1}.b-approve:hover:not(:disabled){background:#09a077}
.b-reject{background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25);flex:1}.b-reject:hover:not(:disabled){background:rgba(229,62,62,.15)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
.modal-bg.show{display:flex}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:650px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.5);animation:mIn .2s ease}
@keyframes mIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff}
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
.dt-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(28,46,72,.3);font-size:12px}
.dt-row .dt-l{color:var(--text3);font-weight:600;font-size:10px;text-transform:uppercase;min-width:35%}
.dt-row .dt-v{color:var(--text);text-align:right;font-family:var(--mono);font-size:11px}
.stage-card{background:var(--ink3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:4px}
.stage-top{display:flex;align-items:center;gap:6px;font-size:11px;flex-wrap:wrap}
.comment-area{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:12px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;resize:vertical;min-height:60px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<div class="modal-bg" id="ar-modal"><div class="modal" id="modal-inner"></div></div>
<main id="app"></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated at bootstrap
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _status='Pending';
let _objectType='';
let _dateFrom='';
let _dateTo='';
let _searchCode='';
let _originatorId='';
let _requests=[];
let _loading=false;
let _skip=0;
// Matches SAP Service Layer's own MaxPageSize (confirmed live: it silently
// caps a page at 20 rows regardless of a higher $top, same as Purchase
// Request's search list) — asking for 30 always got 20 back, and
// "_requests.length>=30" then NEVER held, so Next never even appeared.
const PAGE_SIZE=20;
let _showFilters=false;
let _myApprovalTypes=[]; // Admin → Users → "SAP Approval Types" for the CURRENT user — [] = no restriction (every type)
const OBJ_MAP={'112':'Draft','13':'AR Invoice','14':'AR Credit Memo','18':'AP Invoice','19':'AP Credit Note','22':'Purchase Order','20':'Goods Receipt PO','21':'Goods Return','59':'Goods Issue','60':'Goods Receipt','46':'Blanket Agreement','17':'Sales Order','15':'Delivery','16':'Return','1470000113':'Purchase Request','202':'Production Order','67':'Inventory Transfer Request'};
const OBJ_ENDPOINT={'13':'Invoices','14':'CreditNotes','18':'PurchaseInvoices','19':'PurchaseCreditNotes','22':'PurchaseOrders','20':'PurchaseDeliveryNotes','21':'PurchaseReturns','17':'Orders','15':'DeliveryNotes','16':'Returns','1470000113':'PurchaseRequests','202':'ProductionOrders','59':'InventoryGenExits','60':'InventoryGenEntries','67':'InventoryTransferRequests','46':'BlanketAgreements'};
const STATUS_MAP={'arsPending':'Pending','arsApproved':'Approved','arsNotApproved':'Rejected','arsGenerated':'Generated','arsGeneratedByAuthorizer':'Generated by Auth','arsRejected':'Rejected'};
const LINE_STATUS_MAP={'ardPending':'Pending','ardApproved':'Approved','ardNotApproved':'Rejected','ardGenerated':'Generated','ardGeneratedByAuthorizer':'Generated by Auth','ardRejected':'Rejected'};
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
function fmtDate(d){if(!d)return'-';return new Date(d).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'});}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4000);}
async function loadRequests(){
_loading=true;render();
try{
let qs=`company=${encodeURIComponent(_co)}&top=${PAGE_SIZE}&skip=${_skip}`;
if(_status)qs+=`&status=${_status}`;
if(_objectType)qs+=`&objectType=${encodeURIComponent(_objectType)}`;
if(_dateFrom)qs+=`&dateFrom=${_dateFrom}`;
if(_dateTo)qs+=`&dateTo=${_dateTo}`;
if(_searchCode)qs+=`&code=${_searchCode}`;
if(_originatorId)qs+=`&originatorId=${_originatorId}`;
const r=await api('GET',`/sap/approval-requests?${qs}`);
_requests=r.data||[];
}catch(e){toast(e.message,'err');_requests=[];}
_loading=false;render();
}
function render(){
const app=document.getElementById('app');app.innerHTML='';
const c1=mk('card');
// Restricted to whatever Admin → Users → "SAP Approval Types" allows this
// user (see routes/sap.js's GET /approval-requests, which enforces the
// same restriction server-side regardless of what this dropdown offers) —
// [] = no restriction, every type shown, today's behavior.
const ALL_DOC_TYPE_OPTS=[{v:'1470000113',l:'Purchase Request'},{v:'22',l:'Purchase Order'},{v:'20',l:'GRPO'},{v:'18',l:'AP Invoice'},{v:'19',l:'AP Credit Note'},{v:'21',l:'Goods Return'},{v:'13',l:'AR Invoice'},{v:'14',l:'AR Credit Memo'},{v:'16',l:'Return'},{v:'17',l:'Sales Order'},{v:'15',l:'Delivery'},{v:'202',l:'Production Order'},{v:'59',l:'Goods Issue'},{v:'60',l:'Goods Receipt'}];
const docTypeOpts=[{v:'',l:'All Types'},...(_myApprovalTypes.length?ALL_DOC_TYPE_OPTS.filter(o=>_myApprovalTypes.includes(o.v)):ALL_DOC_TYPE_OPTS)];
c1.innerHTML=`<div class="card-hd">SAP Approval Requests<span class="right"><button class="btn b-cancel" id="btn-toggle-filter" style="padding:4px 10px;font-size:10px">${_showFilters?'Hide Filters':'Filters'}</button></span></div>
<div class="card-bd">
<div class="fg"><label class="fl">Company</label><select class="fi" id="sel-co">
${coOptions(_co)}
</select></div>
<div class="fg"><label class="fl">Status</label>
<div class="status-tabs">
${['Pending','Approved','Rejected','Generated','Cancelled',''].map(s=>`<button class="st-tab${_status===s?' on':''}" data-st="${s}">${s||'All'}</button>`).join('')}
</div>
</div>
<div id="adv-filters" style="display:${_showFilters?'block':'none'}">
<div class="fi-row">
<div class="fg"><label class="fl">Document Type</label><select class="fi" id="f-objtype">
${docTypeOpts.map(o=>`<option value="${o.v}" ${_objectType===o.v?'selected':''}>${o.l}</option>`).join('')}
</select></div>
<div class="fg"><label class="fl">Request Code</label><input class="fi fi-mono" id="f-code" value="${esc(_searchCode)}" placeholder="e.g. 59557"/></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Date From</label><input class="fi" id="f-datefrom" type="date" value="${_dateFrom}"/></div>
<div class="fg"><label class="fl">Date To</label><input class="fi" id="f-dateto" type="date" value="${_dateTo}"/></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Originator ID</label><input class="fi fi-mono" id="f-originator" value="${esc(_originatorId)}" placeholder="SAP User ID"/></div>
<div class="fg" style="display:flex;align-items:flex-end;gap:6px;padding-bottom:10px">
<button class="btn b-primary" id="btn-apply" style="flex:1;height:36px;font-size:12px">Apply Filters</button>
<button class="btn b-cancel" id="btn-clear-filters" style="height:36px;font-size:11px">Clear</button>
</div>
</div>
</div>
</div>`;
app.appendChild(c1);
const c2=mk('card');
let listHtml='';
if(_loading){
listHtml='<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading...</div>';
}else if(!_requests.length){
listHtml='<div class="empty-msg">No approval requests found</div>';
}else{
listHtml=`<div class="ar-grid">${_requests.map(r=>{
const st=STATUS_MAP[r.Status]||r.Status;
const stCls=st==='Pending'?'pending':st==='Approved'?'approved':'rejected';
const objType=OBJ_MAP[String(r.ObjectType)]||'Doc '+r.ObjectType;
return`<div class="ar-card" data-ar="${r.Code}">
<div class="ar-top">
<span class="ar-code">#${r.Code}</span>
<span class="ar-badge ${stCls}">${st}</span>
${r.IsDraft==='Y'?'<span class="ar-badge draft">Draft</span>':''}
</div>
<span class="ar-badge type" style="align-self:flex-start;margin-top:6px">${esc(objType)}</span>
<div class="ar-desc">Entry: ${r.ObjectEntry} · Stage: ${r.CurrentStage||'-'}</div>
<div class="ar-meta">
<span>Created <b>${fmtDate(r.CreationDate)}</b></span>
${r.Remarks?`<span title="${esc(r.Remarks)}">Note: <b>${esc(r.Remarks.substring(0,30))}</b></span>`:''}
</div>
</div>`;
}).join('')}</div>`;
}
const pending=_requests.filter(r=>r.Status==='arsPending').length;
c2.innerHTML=`<div class="card-hd">Requests<span class="right">${_requests.length} found${pending?' | '+pending+' pending':''}</span></div>
<div class="card-bd">${listHtml}
${!_loading&&_requests.length>=PAGE_SIZE?`<div style="display:flex;gap:6px;margin-top:8px;justify-content:center">
${_skip>0?'<button class="btn b-cancel" id="btn-prev" style="font-size:11px">Prev</button>':''}
<button class="btn b-cancel" id="btn-next" style="font-size:11px">Next</button>
</div>`:''}
</div>`;
app.appendChild(c2);
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;_skip=0;loadRequests();});
document.querySelectorAll('[data-st]').forEach(b=>{b.onclick=function(){_status=this.dataset.st;_skip=0;loadRequests();};});
q('#btn-toggle-filter')&&(q('#btn-toggle-filter').onclick=function(){_showFilters=!_showFilters;render();});
q('#btn-apply')&&(q('#btn-apply').onclick=function(){
_objectType=q('#f-objtype')?.value||'';
_searchCode=q('#f-code')?.value?.trim()||'';
_dateFrom=q('#f-datefrom')?.value||'';
_dateTo=q('#f-dateto')?.value||'';
_originatorId=q('#f-originator')?.value?.trim()||'';
_skip=0;loadRequests();
});
q('#btn-clear-filters')&&(q('#btn-clear-filters').onclick=function(){
_objectType='';_searchCode='';_dateFrom='';_dateTo='';_originatorId='';_skip=0;loadRequests();
});
document.querySelectorAll('[data-ar]').forEach(c=>{c.onclick=function(){openDetail(parseInt(this.dataset.ar));};});
q('#btn-prev')&&(q('#btn-prev').onclick=function(){_skip=Math.max(0,_skip-PAGE_SIZE);loadRequests();});
q('#btn-next')&&(q('#btn-next').onclick=function(){_skip+=PAGE_SIZE;loadRequests();});
},0);
}
function fmtAmt(v,c){return(c||'INR')+' '+Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});}
async function fetchLinkedDoc(d){
// For drafts with no ObjectEntry, use DraftEntry
const entry=d.ObjectEntry||d.DraftEntry;
if(!entry)return null;
const isDraft=d.IsDraft==='Y';
const endpoint=isDraft?'Drafts':OBJ_ENDPOINT[String(d.ObjectType)];
if(!endpoint)return null;
try{
const r=await api('GET',`/sap/documents/${endpoint}/${entry}?company=${encodeURIComponent(_co)}`);
return r.data||null;
}catch(_e){
// If not draft endpoint, try Drafts with DraftEntry
if(!isDraft&&d.DraftEntry){
try{const r2=await api('GET',`/sap/documents/Drafts/${d.DraftEntry}?company=${encodeURIComponent(_co)}`);return r2.data||null;}catch(_e2){}
}
return null;
}
}
function renderDocDetail(doc,objType){
if(!doc)return'<div class="empty-msg">Document not accessible</div>';
const lines=doc.DocumentLines||doc.StockTransferLines||doc.ProductionOrderLines||[];
const hasAttach=doc.AttachmentEntry&&doc.AttachmentEntry>0;
let headerHtml=`
<div class="dt-row"><span class="dt-l">Doc Number</span><span class="dt-v">${doc.DocNum||doc.DocEntry||'-'}</span></div>
<div class="dt-row"><span class="dt-l">Date</span><span class="dt-v">${fmtDate(doc.DocDate||doc.PostingDate)}</span></div>
${doc.CardName?`<div class="dt-row"><span class="dt-l">Business Partner</span><span class="dt-v" style="font-family:var(--font)">${esc(doc.CardName)} (${esc(doc.CardCode||'')})</span></div>`:''}
${doc.DocTotal!=null?`<div class="dt-row"><span class="dt-l">Total</span><span class="dt-v">${fmtAmt(doc.DocTotal,doc.DocCurrency)}</span></div>`:''}
${doc.Comments?`<div class="dt-row"><span class="dt-l">Comments</span><span class="dt-v" style="font-family:var(--font);max-width:60%;text-align:right">${esc(doc.Comments)}</span></div>`:''}
${doc.DocumentStatus?`<div class="dt-row"><span class="dt-l">Status</span><span class="dt-v">${doc.DocumentStatus==='bost_Close'?'Closed':'Open'}</span></div>`:''}
`;
let linesHtml='';
if(lines.length){
linesHtml=`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Lines (${lines.length})</div>`;
linesHtml+=`<div class="stage-card" style="font-weight:700;color:var(--text3);font-size:9px;text-transform:uppercase;background:var(--surface3)"><div style="display:flex;gap:6px"><span style="min-width:70px">Item</span><span style="flex:1">Description</span><span style="min-width:50px;text-align:right">Qty</span><span style="min-width:40px">Whse</span><span style="min-width:70px;text-align:right">Price</span><span style="min-width:70px;text-align:right">Total</span></div></div>`;
linesHtml+=lines.map(l=>`<div class="stage-card"><div style="display:flex;gap:6px;font-size:11px;flex-wrap:wrap">
<span style="font-family:var(--mono);color:var(--blue);min-width:70px">${esc(l.ItemCode||l.ItemNo||'')}</span>
<span style="flex:1;color:var(--text2)">${esc(l.ItemDescription||l.ItemName||'')}</span>
<span style="font-family:var(--mono);min-width:50px;text-align:right">${l.Quantity||l.PlannedQuantity||''}</span>
<span style="color:var(--text3);min-width:40px">${esc(l.WarehouseCode||l.Warehouse||'')}</span>
<span style="font-family:var(--mono);color:var(--text3);min-width:70px;text-align:right">${l.UnitPrice?fmtAmt(l.UnitPrice):'-'}</span>
<span style="font-family:var(--mono);min-width:70px;text-align:right">${l.LineTotal?fmtAmt(l.LineTotal):''}</span>
</div></div>`).join('');
}
return headerHtml+linesHtml+(hasAttach?`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3)">Attachment Entry: ${doc.AttachmentEntry}</div>`:'');
}
async function openDetail(code){
const modal=document.getElementById('ar-modal'),inner=document.getElementById('modal-inner');
modal.classList.add('show');
inner.innerHTML='<div style="padding:40px;text-align:center;color:var(--text3)"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span></div>';
try{
const r=await api('GET',`/sap/approval-requests/${code}?company=${encodeURIComponent(_co)}`);
const d=r.data;
const st=STATUS_MAP[d.Status]||d.Status;
const stCls=st==='Pending'?'pending':st==='Approved'?'approved':'rejected';
const objType=OBJ_MAP[String(d.ObjectType)]||'Doc '+d.ObjectType;
const isPending=d.Status==='arsPending';
const lines=d.ApprovalRequestLines||[];
const decisions=d.ApprovalRequestDecisions||[];
const stagesHtml=lines.length?lines.map((l,i)=>{
const lst=LINE_STATUS_MAP[l.Status]||l.Status;
const lCls=lst==='Pending'?'color:var(--amber)':lst==='Approved'?'color:var(--green)':'color:var(--red)';
return`<div class="stage-card"><div class="stage-top">
<span style="font-weight:700;color:var(--text)">Stage ${l.StageCode}</span>
<span style="${lCls};font-weight:700;font-size:10px">${lst}</span>
<span style="color:var(--text3);font-size:10px">User: ${l.UserID||'-'}</span>
${l.UpdateDate?`<span style="color:var(--text3);font-size:10px;margin-left:auto">${fmtDate(l.UpdateDate)}</span>`:''}
</div></div>`;
}).join(''):'<div class="empty-msg">No stages</div>';
const decisionsHtml=decisions.length?decisions.map(dec=>{
const dst=dec.Status==='ardApproved'?'Approved':dec.Status==='ardNotApproved'?'Rejected':'Pending';
const dCls=dst==='Approved'?'color:var(--green)':dst==='Rejected'?'color:var(--red)':'color:var(--amber)';
return`<div class="stage-card"><div class="stage-top">
<span style="${dCls};font-weight:700">${dst}</span>
<span style="color:var(--text3);font-size:10px">User: ${dec.ApproverUserName||dec.ApproverUserID||'-'}</span>
${dec.Remarks?`<span style="color:var(--text2);font-size:10px;flex:1">"${esc(dec.Remarks)}"</span>`:''}
</div></div>`;
}).join(''):'';
// Fetch linked document
const linkedDoc=await fetchLinkedDoc(d);
const docHtml=renderDocDetail(linkedDoc,objType);
// Fetch attachment if linked doc has one
let attachHtml='';
if(linkedDoc&&linkedDoc.AttachmentEntry&&linkedDoc.AttachmentEntry>0){
try{
const att=await api('GET',`/sap/attachments/${linkedDoc.AttachmentEntry}?company=${encodeURIComponent(_co)}`);
const attLines=att.data?.Attachments2_Lines||[];
if(attLines.length){
attachHtml=`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Attachments (${attLines.length})</div>`+
attLines.map((a,ai)=>`<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--surface3);border:1px solid var(--border);border-radius:6px;margin-bottom:4px"><span style="font-size:14px"></span><span style="font-size:11px;color:var(--text);flex:1">${esc(a.FileName||'')}.${esc(a.FileExtension||'')}</span><button style="padding:3px 8px;border-radius:4px;font-size:10px;font-weight:700;background:rgba(26,111,255,.08);color:var(--blue);border:1px solid rgba(26,111,255,.25);cursor:pointer" onclick="viewAtt(${linkedDoc.AttachmentEntry},${a.LineNum||ai})">View</button></div>`).join('');
}
}catch(_e){}
}
inner.innerHTML=`<div class="modal-hd"><div style="font-weight:700;font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>Approval #${d.Code}</span>
<span class="ar-badge ${stCls}">${st}</span>
<span class="ar-badge type">${esc(objType)}</span>
${d.IsDraft==='Y'?'<span class="ar-badge draft">Draft</span>':''}
</div><button class="modal-close" id="m-close">X</button></div>
<div class="modal-bd">
<div style="display:flex;gap:4px;margin-bottom:12px;background:var(--surface3);border-radius:8px;padding:4px">
<button class="btn m-tab on" data-mt="tab-doc" style="flex:1;padding:6px;font-size:11px;border-radius:6px">Document</button>
<button class="btn m-tab" data-mt="tab-approval" style="flex:1;padding:6px;font-size:11px;border-radius:6px;background:none;color:var(--text3)">Approval Info</button>
</div>
<div id="tab-doc">
${docHtml}
${attachHtml}
</div>
<div id="tab-approval" style="display:none">
<div class="dt-row"><span class="dt-l">Code</span><span class="dt-v">${d.Code}</span></div>
<div class="dt-row"><span class="dt-l">Object Type</span><span class="dt-v">${esc(objType)} (${d.ObjectType})</span></div>
<div class="dt-row"><span class="dt-l">Object Entry</span><span class="dt-v">${d.ObjectEntry}</span></div>
<div class="dt-row"><span class="dt-l">Draft</span><span class="dt-v">${d.IsDraft==='Y'?'Yes':'No'}</span></div>
<div class="dt-row"><span class="dt-l">Current Stage</span><span class="dt-v">${d.CurrentStage||'-'}</span></div>
<div class="dt-row"><span class="dt-l">Originator</span><span class="dt-v">${d.OriginatorID||'-'}</span></div>
<div class="dt-row"><span class="dt-l">Created</span><span class="dt-v">${fmtDate(d.CreationDate)} ${d.CreationTime||''}</span></div>
<div class="dt-row"><span class="dt-l">Remarks</span><span class="dt-v" style="font-family:var(--font);max-width:60%;text-align:right">${esc(d.Remarks||'-')}</span></div>
<div style="margin-top:14px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Approval Stages (${lines.length})</div>
${stagesHtml}
${decisionsHtml?`<div style="margin-top:14px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Decisions</div>${decisionsHtml}`:''}
</div>
${isPending?`<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">
<div class="fg"><label class="fl">Comment (optional)</label><textarea class="comment-area" id="ar-comment" placeholder="Add approval note..."></textarea></div>
<div class="fg"><label class="fl">SAP Password <span style="font-weight:400;text-transform:none;color:var(--text3)">— leave blank to use your saved SAP login (Admin → Users / Profile → My SAP Account), if you have one</span></label><input id="ar-pwd" type="password" class="comment-area" style="height:34px" placeholder="Only needed if you don't have a saved SAP login" autocomplete="off"/></div>
<div class="btn-bar">
<button class="btn b-approve" id="btn-ap">Approve</button>
<button class="btn b-reject" id="btn-rj">Reject</button>
</div>
</div>`:''}
</div>`;
q('#m-close').onclick=()=>modal.classList.remove('show');
// Tab switching
inner.querySelectorAll('.m-tab').forEach(tb=>{
tb.onclick=function(){
inner.querySelectorAll('.m-tab').forEach(t=>{t.classList.remove('on');t.style.background='none';t.style.color='var(--text3)';});
this.classList.add('on');this.style.background='var(--surface)';this.style.color='var(--blue)';
const target=this.dataset.mt;
inner.querySelector('#tab-doc').style.display=target==='tab-doc'?'block':'none';
inner.querySelector('#tab-approval').style.display=target==='tab-approval'?'block':'none';
};
});
if(isPending){
q('#btn-ap')&&(q('#btn-ap').onclick=async function(){
const comment=(q('#ar-comment')?.value||'').trim();
const sapPassword=(q('#ar-pwd')?.value||''); // may be blank — server falls back to this user's saved SAP login
this.disabled=true;this.textContent='...';
try{
const patch={ApprovalRequestDecisions:[{Status:'ardApproved',Remarks:comment||'Approved via portal'}],sapPassword};
const r=await api('PATCH',`/sap/approval-requests/${code}?company=${encodeURIComponent(_co)}`,patch);
const posted=r.data?.posted;
if(posted&&posted.posted)toast('Approved — posted to SAP');
else if(posted&&posted.message)toast(posted.message,'err');
else toast('Approved!');
modal.classList.remove('show');loadRequests();
}catch(e){toast(e.message,'err');this.disabled=false;this.textContent='Approve';}
});
q('#btn-rj')&&(q('#btn-rj').onclick=async function(){
const comment=(q('#ar-comment')?.value||'').trim();
const sapPassword=(q('#ar-pwd')?.value||''); // may be blank — server falls back to this user's saved SAP login
this.disabled=true;this.textContent='...';
try{
const patch={ApprovalRequestDecisions:[{Status:'ardNotApproved',Remarks:comment||'Rejected via portal'}],sapPassword};
await api('PATCH',`/sap/approval-requests/${code}?company=${encodeURIComponent(_co)}`,patch);
toast('Rejected');modal.classList.remove('show');loadRequests();
}catch(e){toast(e.message,'err');this.disabled=false;this.textContent='Reject';}
});
}
}catch(e){inner.innerHTML=`<div style="padding:30px;text-align:center;color:var(--red)">${esc(e.message)}</div>`;}
}
window.viewAtt=function(entry,lineId){
fetch(BE+`/sap/attachments/${entry}/download/${lineId}?company=${encodeURIComponent(_co)}`,{headers:{'Authorization':'Bearer '+_tok}})
.then(r=>{if(!r.ok)throw new Error('File not accessible');return r.blob();})
.then(blob=>{window.open(URL.createObjectURL(blob),'_blank');})
.catch(e=>{toast('Cannot open: '+e.message,'err');});
};
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{const p=await api('GET','/auth/profile');if(Array.isArray(p?.user?.sapApprovalTypes))_myApprovalTypes=p.user.sapApprovalTypes.map(String);}catch(_e){}
loadRequests();})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+722
View File
@@ -0,0 +1,722 @@
// sidebar.js — Shared sidebar navigation for all portal pages
(function(){
// Favicon (company logo) — set here once rather than in every page's
// <head>, since this script is already loaded on every page including the
// login screen. Runs before the auth check below so it applies even when
// logged out. Removes any pre-existing icon links first so this always wins.
(function setFavicon(){
document.querySelectorAll('link[rel~="icon"]').forEach(el=>el.remove());
const link=document.createElement('link');
link.rel='icon';link.type='image/png';link.href='/logo.png';
document.head.appendChild(link);
})();
// Session-only auth: login lives ONLY in sessionStorage (see auth-guard.js),
// never localStorage, so a new tab intentionally needs its own login.
const tok=sessionStorage.getItem('portal_token');
let userStr=null;try{userStr=sessionStorage.getItem('portal_user');}catch(_e){}
if(!tok||!userStr) return;
// sidebar.js renders straight into document.body (prepend below), completely
// independent of whatever #root shows — so on pages like index.html (which
// don't load auth-guard.js and branch login-vs-dashboard themselves), a
// stale-but-still-present EXPIRED token used to render a fully functional
// sidebar right alongside the login screen. Token presence alone isn't
// enough; check the JWT's own exp claim before rendering anything.
function jwtExpired(t){
try{
const payload=JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
return !payload.exp || (Date.now()>=payload.exp*1000);
}catch(_e){ return true; }
}
if(jwtExpired(tok)){
try{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;}catch(_e){}
return;
}
// Refresh cached user (modules AND approval-step grants) from the server so
// admin-side access changes apply without forcing a logout — reload once if
// anything access-relevant actually changed.
//
// approvalSteps/modules/role are baked into the JWT ITSELF at login, and
// every server-side permission check (requireApprovalStep/requireWorkflowPerm
// in middleware/auth.js) decodes that token directly — no DB lookup. So it's
// not enough to update the cached portal_user for the UI; the bearer token
// used on every /api call must also be replaced, via POST /api/auth/refresh,
// or a newly granted Approval Step keeps failing server-side ("You are not
// assigned...") even after the sidebar/UI already shows it as granted.
(function refreshUserModules(){
fetch('/api/auth/profile',{headers:{Authorization:'Bearer '+tok}})
.then(r=>r.json())
.then(d=>{
if(!d||!d.success||!d.user) return;
const fresh=d.user;
const cached=JSON.parse(userStr);
const same=JSON.stringify(fresh.modules||null)===JSON.stringify(cached.modules||null)
&& JSON.stringify(fresh.approvalSteps||null)===JSON.stringify(cached.approvalSteps||null)
&& JSON.stringify(fresh.allowedCompanies||null)===JSON.stringify(cached.allowedCompanies||null)
&& fresh.role===cached.role && fresh.active!==false;
if(fresh.active===false){ try{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;}catch(_e){} location.replace('/'); return; }
if(!same){
// Mint a fresh token (carries the new approvalSteps/modules/role)
// BEFORE reloading, so the reloaded page's very first API call
// already authorizes correctly.
fetch('/api/auth/refresh',{method:'POST',headers:{Authorization:'Bearer '+tok}})
.then(r=>r.json())
.then(rd=>{
const merged=Object.assign({},cached,{modules:fresh.modules,approvalSteps:fresh.approvalSteps,allowedCompanies:fresh.allowedCompanies,role:fresh.role,fullName:fresh.fullName,name:fresh.fullName||cached.name});
try{
if(rd&&rd.success&&rd.token) sessionStorage.setItem('portal_token',rd.token);
sessionStorage.setItem('portal_user',JSON.stringify(merged));
document.cookie='portal_user='+encodeURIComponent(JSON.stringify(merged))+';path=/;SameSite=Lax';
if(rd&&rd.success&&rd.token) document.cookie='portal_token='+encodeURIComponent(rd.token)+';path=/;SameSite=Lax';
}catch(_e){}
if(!sessionStorage.getItem('portal_modules_reloaded')){
sessionStorage.setItem('portal_modules_reloaded','1');
location.reload();
}
})
.catch(()=>{});
} else {
sessionStorage.removeItem('portal_modules_reloaded');
}
})
.catch(()=>{});
})();
// Ensure lucide is loaded before building sidebar
function withLucide(cb){
if(window.lucide&&lucide.icons&&lucide.createElement){cb();return;}
var s=document.createElement('script');
s.src='/lucide.min.js?v=399';
s.onload=cb;
s.onerror=cb; // build even if lucide fails (icons will be blank)
document.head.appendChild(s);
}
function sbIco(name,size=15){
if(window.lucide&&lucide.icons&&lucide.createElement){
var p=name.replace(/(^[a-z]|-[a-z0-9])/g,function(m){return m.replace('-','').toUpperCase();});
var d=lucide.icons[p];
if(d){try{
var svg=lucide.createElement([d[0],Object.assign({},d[1],{width:size,height:size}),d[2]]);
svg.style.flexShrink='0';
return svg.outerHTML;
}catch(e){}}
}
return'';
}
withLucide(buildSidebar);
function buildSidebar(){
// Read the role up-front (the shared `user` variable is only declared further
// down — referencing it inside the LINKS initializer would TDZ-crash).
let _role=null;try{_role=(JSON.parse(sessionStorage.getItem('portal_user')||'null')||{}).role;}catch(_e){}
const LINKS=[
{href:'/',lucide:'layout-dashboard',label:'Home',module:'home'},
// Board Dashboard link is for NON-board-member users granted the module —
// board members skip it (they're auto-redirected there on login anyway).
...(_role!=='board_member'?[{href:'/board-dashboard',lucide:'trending-up',label:'Board Dashboard',module:'board-dashboard'}]:[]),
// Inventory
{type:'group',label:'Inventory',lucide:'boxes'},
{href:'/bom', lucide:'list-tree', label:'BOM', module:'bom', sub:true},
{href:'/items', lucide:'package-plus', label:'Create Item', module:'items', sub:true,exact:true},
{href:'/items?list=1', lucide:'list', label:'View Items', module:'items', sub:true,exact:true},
// Production
{type:'group',label:'Production',lucide:'factory'},
{href:'/production-dashboard',lucide:'gauge', label:'Dashboard', module:'production-dashboard', sub:true},
{href:'/production', lucide:'file-plus-2', label:'Production Order', module:'production-create', sub:true},
{href:'/issue-production', lucide:'package-open', label:'Issue', module:'production-issue', sub:true},
{href:'/receipt-production',lucide:'package-check', label:'Receipt', module:'production-receipt', sub:true},
{href:'/verify-work-order', lucide:'clipboard-check', label:'Verify Work Order',module:'work-order-verify', sub:true},
{href:'/verify-production', lucide:'clipboard-check', label:'Verify', module:'production-verify', sub:true},
{href:'/receipts-history', lucide:'history', label:'Receipts History', module:'production-receipt', sub:true},
{href:'/close-production', lucide:'circle-check-big',label:'Close', module:'production-close', sub:true},
{href:'/requirements', lucide:'clipboard-list', label:'Requirements', module:'production-requirements', sub:true},
{href:'/batch-issuance', lucide:'layers', label:'Batch Issuance', module:'production-batch-issuance', sub:true},
{href:'/work-order', lucide:'file-cog', label:'Work Order', module:'production-work-order', sub:true},
{href:'/man-power', lucide:'users', label:'Man Power', module:'man-power', sub:true},
{href:'/oee', lucide:'activity', label:'OEE', module:'oee', sub:true},
{href:'/ppc-report', lucide:'table-2', label:'PPC Report', module:'ppc-report', sub:true},
{href:'/pwo-source-audit', lucide:'shield-check', label:'PWO Source Report',module:'pwo-source-audit', sub:true},
// Purchase
{type:'group',label:'Purchase',lucide:'shopping-cart'},
{href:'/purchase-request', lucide:'file-text', label:'Purchase Request', module:'purchase-request', sub:true},
{href:'/purchase-quotation',lucide:'file-pen', label:'Quotation', module:'purchase-quotation', sub:true},
{href:'/purchase-order', lucide:'shopping-bag', label:'Purchase Order', module:'purchase-order', sub:true},
{href:'/grpo', lucide:'truck', label:'Goods Receipt PO', module:'purchase-grpo', sub:true},
// Approval
{type:'group',label:'Approvals',lucide:'badge-check'},
{href:'/approvals', lucide:'stamp', label:'External Approval',module:'approvals', sub:true},
{href:'/sap-approvals', lucide:'shield-check', label:'SAP Approval', module:'sap-approvals', sub:true},
// Forms
{type:'group',label:'Forms',lucide:'clipboard-pen'},
{href:'/vendor-register', lucide:'building-2', label:'Vendor', module:'vendors', sub:true},
{href:'/register', lucide:'user-plus', label:'Customer', module:'customers', sub:true},
// Documents
{href:'/documents', lucide:'folder-open', label:'Documents', module:'documents'},
// Projects
{type:'group',label:'Projects',lucide:'briefcase'},
{href:'/project-form?new=1', lucide:'folder-plus', label:'New Project', module:'projects-new', sub:true,exact:true},
{href:'/project-form?list=1',lucide:'folders', label:'View Projects', module:'projects-view', sub:true,exact:true},
{href:'/project-approvals', lucide:'clipboard-check',label:'Project Approvals',module:'projects-approvals', sub:true,exact:true},
// Finance
{type:'group',label:'Finance',lucide:'landmark'},
{href:'/costing-pl', lucide:'calendar-range',label:'Monthly Accounts', module:'finance-monthly', sub:true,exact:true},
{href:'/costing-comparison', lucide:'trending-up', label:'P&L Comparison', module:'finance-comparison', sub:true,exact:true},
{href:'/cost-sheet', lucide:'file-spreadsheet',label:'Cost Sheet', module:'finance-costsheet', sub:true,exact:true},
{href:'/balance-sheet', lucide:'scale', label:'Balance Sheet', module:'finance-balancesheet',sub:true,exact:true},
{href:'/cash-flow', lucide:'banknote', label:'Cash Flow', module:'finance-cashflow', sub:true,exact:true},
{href:'/salary', lucide:'hand-coins', label:'Salary', module:'salary', sub:true,exact:true},
// GSTR
{type:'group',label:'GST Reports',lucide:'receipt'},
{href:'/gstr1', lucide:'file-output', label:'GSTR-1', module:'gstr1', sub:true},
{href:'/gstr2', lucide:'file-input', label:'GSTR-2 Purchase', module:'gstr2', sub:true},
{href:'/itc04', lucide:'repeat', label:'ITC-04 Job Work', module:'itc04', sub:true},
// Masters
{type:'group',label:'Masters',lucide:'database'},
{href:'/business-master', lucide:'building', label:'Business Master', module:'business-master', sub:true},
// Reports
{type:'group',label:'Reports',lucide:'pie-chart'},
{href:'/reports', lucide:'bar-chart-3', label:'Reports', module:'reports', sub:true},
{href:'/general-ledger', lucide:'book-open', label:'General Ledger', module:'general-ledger', sub:true},
// Admin — the '/admin' link itself moved to the fixed bottom cluster
// (near Profile/Guide, always visible without scrolling — see sb-bottom
// below); these two stay in the regular scrollable list.
{href:'/item-group-classification', lucide:'layers-3', label:'Item Group Rules', module:'item-group-classification', adminOnly:true},
{href:'/audit-logs', lucide:'scroll-text', label:'Audit Logs', module:'audit'},
{href:'/mail-logs', lucide:'mail', label:'Mail Log', module:'mail_log'},
];
const path=window.location.pathname;
const fullPath=window.location.pathname+window.location.search;
let user=null;
try{const u=sessionStorage.getItem('portal_user');if(u)user=JSON.parse(u);}catch(_e){}
const isAdmin=user?.role==='admin'||user?.role==='sap_adder'; // full module bypass (SAP Adder kept its original admin-like visibility)
const isSubAdmin=user?.role==='system_admin'; // System Admin: sees ONLY granted modules (but may open admin-only pages he's been granted)
const userModules=user?.modules||null;
const themeLink=document.createElement('link');themeLink.rel='stylesheet';themeLink.href='/theme.css?v=3';document.head.appendChild(themeLink);
const oldNav=document.querySelector('nav');
if(oldNav)oldNav.remove();
const style=document.createElement('style');
style.textContent=`
/* ── Sidebar shell ── */
.sb{
position:fixed;left:0;top:0;bottom:0;
background:#ffffff;
border-right:1px solid #eef1f5;
box-shadow:1px 0 0 rgba(15,23,42,.02),2px 0 12px rgba(15,23,42,.03);
z-index:400;display:flex;flex-direction:column;
overflow-x:hidden;overflow-y:auto;
font-feature-settings:"tnum";
}
.sb::-webkit-scrollbar{width:0}
/* ── Logo ── */
.sb-logo{
padding:18px 16px 16px;
border-bottom:1px solid #f1f4f8;
flex-shrink:0;display:flex;align-items:center;gap:11px
}
.sb-logo-mark{
width:40px;height:40px;
display:flex;align-items:center;justify-content:center;
flex-shrink:0;
}
.sb-logo-mark img,.sb-logo-mark svg{width:100%;height:100%;object-fit:contain;display:block}
.sb-logo-text{font-family:'Space Grotesk',sans-serif;font-size:13.5px;font-weight:700;color:#0f172a;letter-spacing:.1px;line-height:1.25}
.sb-logo-text small{display:block;font-size:10px;font-weight:500;color:#94a3b8;letter-spacing:.3px;margin-top:2px;text-transform:uppercase}
/* ── Pending-actions bell ── */
.sb-bell-row{padding:8px 10px 0}
.sb-bell{
width:100%;display:flex;align-items:center;gap:9px;
padding:8px 10px;border-radius:9px;border:1px solid #eef1f5;background:#f8fafc;
color:#475569;font-size:12.5px;font-weight:600;font-family:'Space Grotesk',sans-serif;
cursor:pointer;transition:all .15s;position:relative;
}
.sb-bell:hover{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
.sb-bell svg{flex-shrink:0;color:#64748b}
.sb-bell:hover svg{color:#2563eb}
.sb-bell.has-pending svg{color:#d97706}
.sb-bell-badge{
margin-left:auto;background:#dc2626;color:#fff;font-size:10px;font-weight:800;
border-radius:10px;padding:1px 7px;line-height:1.5;flex-shrink:0;
}
.sb-bell-panel{
display:none;position:fixed;background:#fff;border:1px solid #e2e8f0;border-radius:12px;
box-shadow:0 16px 40px rgba(15,23,42,.16);z-index:900;width:320px;max-height:420px;
overflow-y:auto;font-family:'Space Grotesk',sans-serif;
}
.sb-bell-panel.show{display:block}
.sb-bp-hd{padding:12px 16px;border-bottom:1px solid #f1f4f8;font-size:12px;font-weight:700;color:#0f172a;position:sticky;top:0;background:#fff}
.sb-bp-empty{padding:28px 16px;text-align:center;font-size:12px;color:#94a3b8}
.sb-bp-item{display:block;padding:10px 16px;border-bottom:1px solid #f8fafc;text-decoration:none;transition:background .12s}
.sb-bp-item:hover{background:#f8fafc}
.sb-bp-item:last-child{border-bottom:none}
.sb-bp-mod{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#2563eb;margin-bottom:2px}
.sb-bp-title{font-size:12.5px;font-weight:700;color:#0f172a}
.sb-bp-detail{font-size:11px;color:#64748b;margin-top:2px;line-height:1.4}
/* ── Nav links ── */
.sb-links{flex:1;overflow-y:auto;overflow-x:hidden;padding:10px 10px}
.sb-links::-webkit-scrollbar{width:0}
.sb-link{
display:flex;align-items:center;gap:9px;
padding:8px 10px;
color:#64748b;
text-decoration:none;font-size:13px;font-weight:500;
white-space:nowrap;
border-radius:8px;
font-family:'Space Grotesk',sans-serif;
margin:1.5px 0;
transition:background .15s,color .15s;
}
.sb-link:hover{color:#0f172a;background:#f8fafc}
.sb-link.active{color:#1d4ed8;background:#eff6ff;font-weight:600}
.sb-link svg{color:#94a3b8;flex-shrink:0;transition:color .15s}
.sb-link:hover svg{color:#334155}
.sb-link.active svg{color:#2563eb}
.sb-link .sb-lbl{font-size:13px}
/* ── Section divider ── */
.sb-divider{height:1px;background:#f1f4f8;margin:8px 10px}
/* ── Group headers ── */
.sb-group{
display:flex;align-items:center;gap:8px;
padding:7px 10px;
color:#94a3af;
font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;
white-space:nowrap;margin-top:14px;
font-family:'Space Grotesk',sans-serif;
cursor:pointer;user-select:none;
border-radius:8px;
transition:all .15s;
}
.sb-group:hover{color:#475569;background:#f8fafc}
.sb-group.open{color:#1e293b}
.sb-group svg{flex-shrink:0;opacity:.85;color:#94a3b8}
.sb-chev{
margin-left:auto;font-size:11px;line-height:1;
transition:transform .2s ease;display:inline-block;
color:#cbd5e1;
}
.sb-group.open .sb-chev{transform:rotate(90deg);color:#2563eb}
/* ── Collapsible sub-group ── */
.sb-sub-group{overflow:hidden;max-height:0;transition:max-height .28s cubic-bezier(.4,0,.2,1)}
.sb-sub-group.open{max-height:600px}
.sb-sub{padding-left:24px!important;font-size:12.5px!important}
.sb-sub svg{color:#94a3b8!important}
.sb-sub.active svg{color:#2563eb!important}
.sb-sub .sb-lbl{font-size:12px!important}
/* ── Bottom section ── */
.sb-bottom{
padding:12px 10px;
border-top:1px solid #f1f4f8;
flex-shrink:0;
}
.sb-user{
display:flex;align-items:center;gap:10px;
padding:9px 10px;
background:#f8fafc;
border:1px solid #eef1f5;
border-radius:11px;
cursor:pointer;
transition:all .15s;
}
.sb-user svg{color:#cbd5e1;flex-shrink:0;transition:transform .15s}
.sb-user:hover svg{transform:translateX(2px);color:#94a3b8}
.sb-user:hover{background:#eff6ff;border-color:#bfdbfe}
.sb-avatar{
width:31px;height:31px;border-radius:9px;
background:linear-gradient(135deg,#2563eb 0%,#4338ca 100%);
box-shadow:0 2px 6px rgba(37,99,235,.22);
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:700;color:#fff;flex-shrink:0;
}
.sb-user-info{min-width:0;flex:1}
.sb-role{display:block;font-size:9.5px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.6px}
.sb-name{display:block;font-size:12.5px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
.sb-logout{
width:100%;padding:7px 10px;border-radius:8px;margin-top:4px;
font-size:11.5px;font-weight:600;
border:none;
color:#94a3b8;background:transparent;
cursor:pointer;font-family:'Space Grotesk',sans-serif;
transition:all .15s;
text-align:left;display:flex;align-items:center;gap:7px;
}
.sb-logout:hover{background:#fef2f2;color:#dc2626}
.sb-logout svg{flex-shrink:0}
.sb-help{
width:100%;padding:7px 10px;border-radius:8px;margin-top:6px;
font-size:11.5px;font-weight:600;
border:none;text-decoration:none;
color:#94a3b8;background:transparent;
cursor:pointer;font-family:'Space Grotesk',sans-serif;
transition:all .15s;
text-align:left;display:flex;align-items:center;gap:7px;
}
.sb-help:hover{background:#eff6ff;color:#2563eb}
.sb-help svg{flex-shrink:0}
.sb-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:399;display:none;backdrop-filter:blur(2px)}
.sb-overlay.show{display:block}
/* ── Toggle button (desktop) ── */
.sb-toggle-btn{
background:none;border:none;cursor:pointer;
color:#9ca3af;font-size:16px;line-height:1;
padding:4px 5px;border-radius:6px;margin-left:auto;flex-shrink:0;
transition:color .15s,background .15s;
}
.sb-toggle-btn:hover{color:#374151;background:#f3f4f6}
@media(min-width:769px){
.sb{width:210px;transition:width .25s ease;overflow:hidden}
.sb .sb-lbl{display:inline}
.sb .sb-user,.sb .sb-logout,.sb .sb-help{display:flex}
.sb-logout{display:block!important}
body{margin-left:210px!important;transition:margin-left .25s ease}
.mob-bar{display:none!important}
.sb-overlay{display:none!important}
/* Collapsed = icon-only rail: links stay visible & clickable as icons,
only the text labels/chevrons hide. Hover tooltips come from the
title attributes set at render time. */
body.sb-hidden .sb{width:56px}
body.sb-hidden{margin-left:56px!important}
body.sb-hidden .sb-lbl,
body.sb-hidden .sb-chev,
body.sb-hidden .sb-logo-text,
body.sb-hidden .sb-logo-mark,
body.sb-hidden .sb-bell-panel,
body.sb-hidden .sb-user-info,
body.sb-hidden .sb-user>svg{display:none!important}
body.sb-hidden .sb-logo{justify-content:center;padding:12px 0}
body.sb-hidden .sb-toggle-btn{color:#6b7280;margin:0;font-size:18px;padding:6px;width:auto}
body.sb-hidden .sb-bell-row{display:flex;justify-content:center}
body.sb-hidden .sb-bell{justify-content:center;padding:8px}
body.sb-hidden .sb-links{padding:10px 8px}
body.sb-hidden .sb-link{justify-content:center;padding:9px 0;gap:0}
body.sb-hidden .sb-sub{padding-left:0!important}
body.sb-hidden .sb-group{justify-content:center;padding:8px 0;gap:0;margin-top:10px}
body.sb-hidden .sb-user{justify-content:center;padding:6px 0}
body.sb-hidden .sb-logout{display:flex!important;justify-content:center}
body.sb-hidden .sb-help{display:flex!important;justify-content:center}
}
@media(max-width:768px){
.sb{width:260px;transform:translateX(-100%);transition:transform .25s ease}
.sb.open{transform:translateX(0)}
.sb .sb-lbl{display:inline}
body{margin-left:0!important;padding-bottom:56px!important}
.mob-bar{position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;border-top:1px solid #e5e7eb;z-index:300;display:flex;align-items:center;justify-content:space-around;padding:0 4px;box-shadow:0 -2px 8px rgba(0,0,0,.04)}
.mob-bar a{display:flex;flex-direction:column;align-items:center;gap:2px;color:#6b7280;text-decoration:none;font-size:9px;font-weight:600;font-family:'Space Grotesk',sans-serif;padding:6px 4px;border-radius:8px;transition:all .12s;flex:1;text-align:center}
.mob-bar a .mb-ico{display:flex;align-items:center;justify-content:center}
.mob-bar a .mb-ico svg{color:#6b7280}
.mob-bar a.active{color:#2563eb;background:#eff6ff}
.mob-bar a.active .mb-ico svg{color:#2563eb}
.mob-bar .mb-more{color:#6b7280;cursor:pointer;border:none;background:none;font-family:'Space Grotesk',sans-serif;font-size:9px;font-weight:600;display:flex;flex-direction:column;align-items:center;gap:2px;padding:6px 4px;flex:1}
.mob-bar .mb-more .mb-ico{display:flex;align-items:center;justify-content:center}
.mob-bar .mb-more .mb-ico svg{color:#6b7280}
}
`;
document.head.appendChild(style);
// ── Helpers ──────────────────────────────────────────────────────────────────
// Backward-compat: a user granted the OLD broad key (e.g. 'production',
// before it was split into per-page sub-modules) still gets every new
// sub-key under it, so nobody silently loses access when this ships.
const LEGACY_BROAD_MODULES={production:'production-',purchase:'purchase-',costing:'finance-',projects:'projects-'};
const hasModule=(m)=>{
if(isAdmin||m==='home')return true;
if(!userModules)return false; // no modules explicitly granted → no access (was: unchecked = full access)
if(userModules.includes(m))return true;
for(const broad in LEGACY_BROAD_MODULES){
if(userModules.includes(broad)&&m.indexOf(LEGACY_BROAD_MODULES[broad])===0)return true;
}
return false;
};
// Mirrors the same exception in auth-guard.js: a pure Store reviewer for
// Pre-PWO Store Review has no reason to hold the broad 'production-create'
// module (that would ALSO grant full Production Order creation) — holding
// either Pre-PWO approval step (any perm) is enough to show the sidebar
// link too, otherwise auth-guard.js lets them into the page but they'd
// have no way to find it.
const hasPrePwoStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(user?.approvalSteps)?user.approvalSteps:[];
const keys=['production_order:prepwo_share','production_order:prepwo_review'];
return steps.some(s=>{
if(typeof s==='string')return keys.includes(s);
return s&&keys.includes(s.step)&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
// Same idea for OEE: holding 'oee:entry' (any perm) is enough to see the
// sidebar link even without the broader 'oee' module.
const hasOeeStepAccess=(()=>{
if(isAdmin)return true;
const steps=Array.isArray(user?.approvalSteps)?user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s==='oee:entry';
return s&&s.step==='oee:entry'&&Array.isArray(s.perms)&&s.perms.length>0;
});
})();
const canSeeLink=(l)=>hasModule(l.module)
||(l.href==='/production'&&hasPrePwoStepAccess)
||(l.href==='/oee'&&hasOeeStepAccess);
// Prefix match must land on a real path boundary (path is exactly l.href,
// or continues with '/') — plain startsWith() would wrongly match e.g.
// '/production-dashboard' against the shorter '/production', marking both
// "Production Order" (/production) and "Dashboard" (/production-dashboard)
// active at once. `path` is window.location.pathname (no query string).
function isActive(l){
if(l.exact) return fullPath===l.href;
if(path===l.href) return true;
if(l.href==='/') return false;
return path.startsWith(l.href) && path[l.href.length]==='/';
}
// ── Filter links: groups only if at least one child is visible ────────────────
const filteredLinks=[];
for(let i=0;i<LINKS.length;i++){
const l=LINKS[i];
if(l.adminOnly&&!isAdmin&&!isSubAdmin) continue; // admin-only links: sub-admins still need the module granted (checked below)
if(l.type==='group'){
let hasChild=false;
for(let j=i+1;j<LINKS.length;j++){
const c=LINKS[j];
if(c.type==='group'||!c.sub) break;
if((!c.adminOnly||isAdmin||isSubAdmin)&&canSeeLink(c)){hasChild=true;break;}
}
if(hasChild) filteredLinks.push(l);
continue;
}
if(!canSeeLink(l)) continue;
filteredLinks.push(l);
}
// ── Build grouped structure ───────────────────────────────────────────────────
// Each entry is either a standalone item or {group, children[]}
const sections=[];
let cur=null;
for(const l of filteredLinks){
if(l.type==='group'){
cur={group:l,children:[]};
sections.push(cur);
} else if(cur&&l.sub){
cur.children.push(l);
} else {
cur=null;
sections.push({item:l});
}
}
// ── Render ────────────────────────────────────────────────────────────────────
const linksHtml=sections.map(s=>{
if(s.item){
const l=s.item;
const a=isActive(l);
return`<a href="${l.href}" class="sb-link${a?' active':''}" title="${l.label}">${sbIco(l.lucide||'circle',15)}<span class="sb-lbl">${l.label}</span></a>`;
}
const hasActiveChild=s.children.some(isActive);
const openCls=hasActiveChild?' open':'';
const childrenHtml=s.children.map(l=>{
const a=isActive(l);
return`<a href="${l.href}" class="sb-link sb-sub${a?' active':''}" title="${l.label}">${sbIco(l.lucide||'circle',13)}<span class="sb-lbl">${l.label}</span></a>`;
}).join('');
return`<div class="sb-group${openCls}" data-sb-group title="${s.group.label}">${sbIco(s.group.lucide||'folder',13)}<span class="sb-lbl">${s.group.label}</span><span class="sb-chev">&#8250;</span></div><div class="sb-sub-group${openCls}">${childrenHtml}</div>`;
}).join('');
// ── Build sidebar DOM ─────────────────────────────────────────────────────────
const sb=document.createElement('div');sb.className='sb';sb.id='sidebar';
const initials=((user?.name||user?.username||'?').trim().split(' ').map(w=>w[0]||'').join('').toUpperCase().slice(0,2))||'U';
const company=user?.company||'MIPL';
sb.innerHTML=`
<div class="sb-logo">
<div class="sb-logo-mark"><img class="sb-logo-img" src="/logo.png" alt="Logo" onerror="this.style.display='none';this.nextElementSibling.style.display='block'"/><svg class="sb-logo-svg" style="display:none" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label="Logo"><g stroke-linejoin="round" stroke-linecap="round"><polygon points="66,16 78,16 90,84 78,84" fill="#7cc36f" stroke="#7cc36f" stroke-width="6"/><polygon points="42,22 54,22 66,84 54,84" fill="#23a06e" stroke="#23a06e" stroke-width="6"/><polygon points="26,54 6,86 44,86" fill="#1866a1" stroke="#1866a1" stroke-width="6"/></g></svg></div>
<div class="sb-logo-text">${company}<small>MITRA ERP Portal</small></div>
<button class="sb-toggle-btn" id="sb-toggle-btn" title="Collapse sidebar">&#9776;</button>
</div>
<div class="sb-bell-row">
<button class="sb-bell" id="sb-bell" title="Pending actions">${sbIco('bell',16)}<span class="sb-lbl">Pending Actions</span><span class="sb-bell-badge" id="sb-bell-badge" style="display:none">0</span></button>
</div>
<div class="sb-bell-panel" id="sb-bell-panel"></div>
<div class="sb-links">${linksHtml}</div>
<div class="sb-bottom">
${(isAdmin||(isSubAdmin&&hasModule('admin')))?`<a href="/admin" class="sb-help">${sbIco('settings',15)}<span class="sb-lbl">Admin</span></a>`:''}
<a href="/guide" class="sb-help">${sbIco('life-buoy',15)}<span class="sb-lbl">Guide / Help</span></a>
<a href="/profile" class="sb-user" style="text-decoration:none">
<div class="sb-avatar">${initials}</div>
<div class="sb-user-info">
<span class="sb-role">${({system_admin:'System Admin',board_member:'Board Member',sap_adder:'SAP Adder',sr_manager:'Sr. Manager',project_approver:'Project Approver'})[user?.role]||(user?.role||'').replace(/_/g,' ')}</span>
<span class="sb-name">${user?.name||user?.username||''}</span>
</div>
${sbIco('chevron-right',13)}
</a>
<button class="sb-logout" id="sb-logout" title="Log Out">${sbIco('log-out',13)}<span class="sb-lbl"> Log Out</span></button>
</div>`;
document.body.prepend(sb);
// ── Wire group toggle clicks ──────────────────────────────────────────────────
sb.querySelectorAll('[data-sb-group]').forEach(groupEl=>{
groupEl.addEventListener('click',()=>{
const subGroup=groupEl.nextElementSibling;
const opening=!groupEl.classList.contains('open');
groupEl.classList.toggle('open',opening);
subGroup.classList.toggle('open',opening);
});
});
// ── Mobile bottom bar ─────────────────────────────────────────────────────────
const mobLinks=[
{href:'/',lucide:'layout-dashboard',label:'Home'},
{href:'/items',lucide:'boxes',label:'Items'},
{href:'/production',lucide:'factory',label:'Prod'},
{href:'/approvals',lucide:'badge-check',label:'Approve'},
{href:'/documents',lucide:'folder-open',label:'Docs'},
];
const mobBar=document.createElement('div');mobBar.className='mob-bar';
mobBar.innerHTML=mobLinks.map(l=>{
const a=path===l.href||(l.href!=='/'&&path.startsWith(l.href));
return`<a href="${l.href}"${a?' class="active"':''}><span class="mb-ico">${sbIco(l.lucide,18)}</span>${l.label}</a>`;
}).join('')+`<button class="mb-more" id="mob-more"><span class="mb-ico">${sbIco('menu',18)}</span>More</button>`;
document.body.appendChild(mobBar);
// ── Overlay ───────────────────────────────────────────────────────────────────
const overlay=document.createElement('div');overlay.className='sb-overlay';overlay.id='sb-overlay';
document.body.appendChild(overlay);
const toggleMob=()=>{sb.classList.toggle('open');overlay.classList.toggle('show');};
document.getElementById('mob-more').onclick=toggleMob;
overlay.onclick=toggleMob;
sb.querySelectorAll('.sb-link').forEach(l=>{l.addEventListener('click',()=>{if(window.innerWidth<=768){sb.classList.remove('open');overlay.classList.remove('show');}});});
document.getElementById('sb-logout').onclick=()=>{
if(!confirm('Log out of your account?'))return;
sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';;localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';
};
// ── Desktop sidebar hide / show ───────────────────────────────────────────────
const SB_KEY='sb_hidden';
function setSidebarHidden(hidden){
document.body.classList.toggle('sb-hidden',hidden);
try{localStorage.setItem(SB_KEY,hidden?'1':'0');}catch(_e){}
}
// Restore saved state — collapsed (icon rail) BY DEFAULT; only stays
// expanded when the user explicitly expanded it before ('0' saved).
try{if(localStorage.getItem(SB_KEY)!=='0') setSidebarHidden(true);}catch(_e){}
// Toggle button works both ways click always flips the state
const toggleSb=()=>setSidebarHidden(!document.body.classList.contains('sb-hidden'));
document.getElementById('sb-toggle-btn').onclick=(e)=>{e.stopPropagation();toggleSb();};
// Clicking anywhere on the collapsed strip also opens it
document.querySelector('.sb-logo').addEventListener('click',()=>{
if(document.body.classList.contains('sb-hidden')) toggleSb();
});
// ── Pending-actions bell ─────────────────────────────────────────────────
// Aggregates "pending on me" across Work Order / Production Order / BOM
// Requests / (admin) Password Reset requests — see routes/notifications.js
// for exactly what counts as pending for which role/permission.
(function initBell(){
const bellBtn=document.getElementById('sb-bell');
const badge=document.getElementById('sb-bell-badge');
const panel=document.getElementById('sb-bell-panel');
if(!bellBtn||!panel)return;
let _items=[];
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function renderPanel(){
if(!_items.length){
panel.innerHTML='<div class="sb-bp-hd">Pending Actions</div><div class="sb-bp-empty">Nothing pending — you\'re all caught up.</div>';
return;
}
panel.innerHTML='<div class="sb-bp-hd">Pending Actions ('+_items.length+')</div>'+_items.map(it=>
`<a class="sb-bp-item" href="${it.link}"><div class="sb-bp-mod">${esc(it.label)}</div><div class="sb-bp-title">${esc(it.title)}</div><div class="sb-bp-detail">${esc(it.detail)}</div></a>`
).join('');
}
async function loadPending(){
try{
const r=await fetch('/api/notifications/pending',{headers:{Authorization:'Bearer '+tok}});
const d=await r.json();
_items=(d&&d.success&&d.data)||[];
}catch(_e){_items=[];}
if(_items.length){
badge.textContent=_items.length>99?'99+':String(_items.length);
badge.style.display='inline-block';
bellBtn.classList.add('has-pending');
}else{
badge.style.display='none';
bellBtn.classList.remove('has-pending');
}
renderPanel();
}
function positionPanel(){
const r=bellBtn.getBoundingClientRect();
panel.style.top=(r.bottom+6)+'px';
panel.style.left=Math.max(8,r.left)+'px';
}
function closePanel(){panel.classList.remove('show');}
function openPanel(){positionPanel();panel.classList.add('show');loadPending();}
bellBtn.onclick=(e)=>{
e.stopPropagation();
if(panel.classList.contains('show'))closePanel();else openPanel();
};
document.addEventListener('click',(e)=>{
if(panel.classList.contains('show')&&!panel.contains(e.target)&&!bellBtn.contains(e.target))closePanel();
});
window.addEventListener('resize',()=>{if(panel.classList.contains('show'))positionPanel();});
// Load once up front so the badge count is visible without the user
// having to click the bell first.
loadPending();
})();
// ── AI Chatbot widget ──────────────────────────────────────────────────────
// chandan (function loadChatbot(){
// if(document.getElementById('cb-fab')) return;
// const s=document.createElement('script');
// s.src='/chatbot.js';
// document.body.appendChild(s);
// })();
} // end buildSidebar
// ── _injectLucide: called by index.html after building module cards ─────────
window._injectLucide=(function(){
function toP(s){return s.replace(/(^[a-z]|-[a-z])/g,function(m){return m.replace('-','').toUpperCase();});}
return function injectIcons(){
var ic=window.lucide&&lucide.icons;
var fn=window.lucide&&lucide.createElement;
if(!ic||!fn) return;
document.querySelectorAll('[data-lucide]').forEach(function(el){
var name=el.getAttribute('data-lucide');
var def=ic[toP(name)];
if(!def) return;
try{
var ws=parseInt(el.style.width)||16;
var hs=parseInt(el.style.height)||16;
var svg=fn([def[0],Object.assign({},def[1],{width:ws,height:hs}),def[2]]);
svg.style.flexShrink='0';
if(el.parentNode) el.parentNode.replaceChild(svg,el);
}catch(e){}
});
};
})();
})();
File diff suppressed because it is too large Load Diff
+792
View File
@@ -0,0 +1,792 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Vendor Registration — MITRA ERP Portal</title>
<meta name="description" content="Register as a new vendor/supplier. Fill in your business details, bank information, and upload required documents."/>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Playfair+Display:wght@700;800&display=swap" rel="stylesheet"/>
<style>
:root{
--navy:#0a0f1e;--navy2:#0d1428;--navy3:#111b35;
--indigo:#2d3a8c;--indigo2:#3d4db0;
--accent:#e8a820;--accent2:#c07810;
--green:#10b981;--red:#ef4444;--blue:#3b82f6;--purple:#8b5cf6;
--border:#1e2d50;--border2:#263759;
--text:#e8eef8;--text2:#8fa3c0;--text3:#3d5070;
--surface:#0f1c32;--surface2:#162038;--surface3:#0b1422;
--radius:8px;--radius-lg:12px;
--font:'Outfit',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Playfair Display',serif;
--shadow:0 8px 32px rgba(0,0,0,.5);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--navy);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 70% 50% at 15% 20%,rgba(232,168,32,.06) 0%,transparent 55%),radial-gradient(ellipse 50% 60% at 85% 80%,rgba(45,58,140,.08) 0%,transparent 55%);pointer-events:none;z-index:0}
body::after{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");pointer-events:none;z-index:0;opacity:.5}
.topbar{background:rgba(10,15,30,.95);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 28px;height:58px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 20px rgba(0,0,0,.4)}
.topbar-brand{display:flex;align-items:center;gap:12px}
.sap-logo{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;font-family:var(--display);font-size:13px;padding:5px 12px;border-radius:6px;letter-spacing:1px;font-style:italic}
.topbar-title{font-size:13px;font-weight:500;color:var(--text2);letter-spacing:.2px}
.topbar-right{display:flex;align-items:center;gap:10px}
.vend-badge{background:rgba(232,168,32,.12);color:var(--accent);border:1px solid rgba(232,168,32,.3);padding:4px 14px;border-radius:20px;font-size:11px;font-weight:700;letter-spacing:.5px}
.page{position:relative;z-index:1;max-width:860px;margin:0 auto;padding:32px 20px 80px}
.steps-wrap{display:flex;align-items:center;margin-bottom:36px}
.step{display:flex;align-items:center;gap:10px}
.step-line{flex:1;height:2px;background:var(--border);margin:0 10px;transition:background .4s}
.step-line.done{background:var(--green)}
.step-circle{width:34px;height:34px;border-radius:50%;border:2px solid var(--border2);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:var(--text3);background:var(--surface);flex-shrink:0;transition:all .3s}
.step.active .step-circle{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 0 0 4px rgba(232,168,32,.2)}
.step.done .step-circle{background:var(--green);border-color:var(--green);color:#fff}
.step-label{font-size:12px;font-weight:600;color:var(--text3);white-space:nowrap;letter-spacing:.2px}
.step.active .step-label{color:var(--accent)}
.step.done .step-label{color:var(--green)}
.progress-wrap{background:var(--border);border-radius:3px;height:3px;margin-bottom:36px;overflow:hidden}
.progress-bar{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--accent),var(--indigo2));transition:width .5s ease}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:28px 32px;margin-bottom:20px;position:relative;overflow:hidden}
.card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,var(--accent),var(--indigo2),transparent)}
.card-title{font-family:var(--display);font-size:18px;font-weight:700;color:var(--text);margin-bottom:4px;letter-spacing:.3px}
.card-sub{font-size:12.5px;color:var(--text2);margin-bottom:22px;padding-bottom:16px;border-bottom:1px solid var(--border)}
.card-sub span{color:var(--red)}
.sec{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:var(--accent);margin:24px 0 13px;padding-bottom:7px;border-bottom:1px solid rgba(232,168,32,.2);display:flex;align-items:center;gap:8px}
.sec::before{content:'';display:inline-block;width:3px;height:13px;background:var(--accent);border-radius:2px;flex-shrink:0}
.grid{display:grid;gap:16px}
.g2{grid-template-columns:1fr 1fr}
.g3{grid-template-columns:1fr 1fr 1fr}
.g4{grid-template-columns:1fr 1fr 1fr 1fr}
.span2{grid-column:span 2}
.span3{grid-column:span 3}
.span4{grid-column:span 4}
.field{display:flex;flex-direction:column;gap:5px;min-width:0}
.label{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text2)}
.label .req{color:var(--red);margin-left:2px}
input,select,textarea{padding:9px 12px;border:1.5px solid var(--border2);border-radius:var(--radius);font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;transition:border .2s,box-shadow .2s;width:100%;}
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(232,168,32,.12)}
input[readonly],select[disabled]{background:var(--surface3);color:var(--text3);cursor:not-allowed}
select option{background:var(--surface2)}
textarea{resize:vertical;min-height:80px}
.ferr{font-size:11px;color:var(--red);margin-top:2px}
.no-upper{text-transform:none!important}
input:not([type=email]):not([type=password]):not([type=file]):not([type=checkbox]):not([type=radio]):not([type=number]):not(.no-upper){text-transform:uppercase}
textarea{text-transform:uppercase}
select{text-transform:none}
.type-cards{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:8px}
.type-card{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border:2px solid var(--border2);border-radius:10px;cursor:pointer;transition:all .2s;background:var(--surface);user-select:none}
.type-card.selected{border-color:var(--accent);background:rgba(232,168,32,.07)}
.type-card input[type=radio]{width:16px;height:16px;accent-color:var(--accent);flex-shrink:0;margin-top:2px}
.type-card-title{font-weight:700;font-size:13px;color:var(--text);margin-bottom:2px}
.type-card.selected .type-card-title{color:var(--accent)}
.type-card-desc{font-size:11px;color:var(--text2)}
.addr-block{background:var(--surface3);border:1px solid var(--border);border-radius:10px;padding:18px 20px;margin-bottom:14px;position:relative}
.addr-block-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.addr-block-lbl{font-size:11.5px;font-weight:700;color:var(--accent);display:flex;align-items:center;gap:7px}
.addr-block-num{background:var(--accent);color:#fff;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}
.addr-remove-btn{padding:4px 12px;font-size:11px;border:1.5px solid var(--red);color:var(--red);border-radius:6px;cursor:pointer;background:none;font-weight:600;transition:all .15s}
.addr-remove-btn:hover{background:var(--red);color:#fff}
.add-addr-btn{display:inline-flex;align-items:center;gap:7px;padding:8px 16px;font-size:12px;font-weight:600;border:1.5px solid var(--accent);color:var(--accent);border-radius:var(--radius);cursor:pointer;background:rgba(232,168,32,.05);transition:all .2s;margin-bottom:16px}
.add-addr-btn:hover{background:rgba(232,168,32,.12)}
.addr-name-wrap{position:relative}
.bank-block{background:var(--surface3);border:1px solid var(--border);border-radius:10px;padding:18px 20px;margin-bottom:14px;position:relative}
.bank-block-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.bank-block-lbl{font-size:11.5px;font-weight:700;color:var(--accent);display:flex;align-items:center;gap:7px}
.bank-block-num{background:var(--accent);color:#fff;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}
.bank-remove-btn{padding:4px 12px;font-size:11px;border:1.5px solid var(--red);color:var(--red);border-radius:6px;cursor:pointer;background:none;font-weight:600;transition:all .15s}
.bank-remove-btn:hover{background:var(--red);color:#fff}
.add-bank-btn{display:inline-flex;align-items:center;gap:7px;padding:8px 16px;font-size:12px;font-weight:600;border:1.5px solid var(--accent);color:var(--accent);border-radius:var(--radius);cursor:pointer;background:rgba(232,168,32,.05);transition:all .2s;margin-bottom:16px}
.add-bank-btn:hover{background:rgba(232,168,32,.12)}
.btn-row{display:flex;gap:12px;margin-top:28px;justify-content:flex-end}
.btn{padding:11px 26px;border-radius:var(--radius);border:none;font-family:var(--font);font-size:13.5px;font-weight:700;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:8px}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none!important}
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff}
.btn-primary:hover:not(:disabled){box-shadow:0 6px 20px rgba(232,168,32,.4);transform:translateY(-1px)}
.btn-secondary{background:var(--surface2);color:var(--text);border:1.5px solid var(--border2)}
.btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
.attach-zone{border:2px dashed var(--border2);border-radius:var(--radius);padding:22px 16px;background:var(--surface2);text-align:center;cursor:pointer;transition:all .2s;position:relative;min-height:110px;display:flex;flex-direction:column;justify-content:center;align-items:center}
.attach-zone:hover,.attach-zone.drag{border-color:var(--accent);background:rgba(232,168,32,.05)}
.attach-zone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;z-index:2}
.az-icon{font-size:26px;margin-bottom:8px;line-height:1}
.az-label{font-size:12px;font-weight:600;color:var(--text2)}
.az-sub{font-size:10.5px;color:var(--text3);margin-top:3px}
.attach-preview{display:flex;align-items:center;gap:10px;padding:9px 12px;background:var(--surface3);border:1px solid var(--border);border-radius:var(--radius);margin-top:7px}
.ap-thumb{width:36px;height:36px;border-radius:6px;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}
.ap-info{flex:1;min-width:0}
.ap-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text)}
.ap-size{font-size:10.5px;color:var(--text3);margin-top:2px}
.ap-rm{padding:3px 9px;font-size:10.5px;border:1.5px solid var(--red);color:var(--red);border-radius:4px;cursor:pointer;background:none;flex-shrink:0;font-weight:600;transition:all .15s}
.ap-rm:hover{background:var(--red);color:#fff}
.alert{padding:11px 15px;border-radius:var(--radius);font-size:12.5px;margin-bottom:18px;display:flex;align-items:flex-start;gap:9px;line-height:1.5}
.a-info{background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);color:#93c5fd}
.a-warn{background:rgba(232,168,32,.08);border:1px solid rgba(232,168,32,.2);color:#fcd34d}
.badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700}
.b-sup{background:rgba(139,92,246,.12);color:var(--purple);border:1px solid rgba(139,92,246,.25)}
.b-serv{background:rgba(16,185,129,.12);color:var(--green);border:1px solid rgba(16,185,129,.25)}
.b-both{background:rgba(232,168,32,.12);color:var(--accent);border:1px solid rgba(232,168,32,.25)}
.rev-table{width:100%;border-collapse:collapse;font-size:13px}
.rev-table tr{border-bottom:1px solid var(--border)}
.rev-table tr:last-child{border:none}
.rev-table td{padding:10px 6px;vertical-align:top}
.rev-table td:first-child{color:var(--text2);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;width:36%;padding-top:12px}
.rev-table td.val{font-weight:500;color:var(--text)}
.success-wrap{text-align:center;padding:56px 24px}
.success-icon{font-size:64px;margin-bottom:22px;animation:popIn .6s cubic-bezier(.175,.885,.32,1.275)}
@keyframes popIn{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}
.success-wrap h2{font-family:var(--display);font-size:26px;font-weight:800;color:var(--accent);margin-bottom:10px}
.success-wrap p{font-size:13.5px;color:var(--text2);max-width:440px;margin:0 auto 16px;line-height:1.6}
.ref-box{display:inline-block;background:rgba(232,168,32,.1);border:1px solid rgba(232,168,32,.3);border-radius:8px;padding:10px 28px;font-family:var(--mono);font-size:16px;font-weight:600;color:var(--accent);margin:14px 0}
.sp{width:15px;height:15px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .65s linear infinite;display:inline-block;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:70px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{padding:11px 16px;border-radius:var(--radius);font-size:13px;font-weight:600;box-shadow:var(--shadow);animation:tIn .3s ease;display:flex;align-items:center;gap:9px;max-width:360px;line-height:1.4;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}
.t-err{background:var(--red);color:#fff}
.t-warn{background:var(--accent);color:#fff}
@keyframes tIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.gstin-row{display:flex;gap:8px;align-items:stretch}
.gstin-row input{flex:1;min-width:0}
.fetch-btn{padding:0 14px;height:38px;background:var(--indigo);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:700;white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:5px;font-family:var(--font);transition:background .15s}
.fetch-btn:hover{background:var(--indigo2)}
.pan-wrap{position:relative}
.pan-auto{position:absolute;right:9px;top:50%;transform:translateY(-50%);background:rgba(16,185,129,.12);color:var(--green);font-size:9.5px;font-weight:700;padding:2px 7px;border-radius:4px;pointer-events:none;white-space:nowrap;display:none}
.toggle-block{padding:14px 18px;border:2px solid var(--border);border-radius:10px;margin-bottom:14px;background:var(--surface2);width:100%;transition:all .25s}
.toggle-block.active{border-color:var(--accent);background:rgba(232,168,32,.04)}
.toggle-block>label{display:flex;align-items:center;gap:12px;cursor:pointer}
.toggle-block input[type=checkbox]{width:19px;height:19px;accent-color:var(--accent);padding:0;flex-shrink:0}
.toggle-title{font-weight:700;font-size:14px;color:var(--text)}
.toggle-sub{font-size:11.5px;color:var(--text2);margin-top:2px}
/* address name frozen */
.addr-name-frozen{background:var(--surface3)!important;color:var(--text2)!important;cursor:not-allowed!important;border-color:rgba(232,168,32,.25)!important;padding-right:90px!important}
.addr-name-frozen:focus{box-shadow:none!important;border-color:rgba(232,168,32,.25)!important}
.addr-name-badges{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:5px;pointer-events:none}
.badge-autofill{background:rgba(16,185,129,.15);color:#10b981;font-size:9.5px;font-weight:700;padding:2px 7px;border-radius:4px;letter-spacing:.4px;white-space:nowrap}
.badge-frozen{background:rgba(61,84,112,.2);color:var(--text3);font-size:9.5px;font-weight:700;padding:2px 7px;border-radius:4px;display:flex;align-items:center;gap:3px;white-space:nowrap}
.badge-frozen svg{width:10px;height:10px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
@media(max-width:640px){
.g2,.g3,.g4,.type-cards{grid-template-columns:1fr}
.span2,.span3,.span4{grid-column:span 1}
.card{padding:20px 16px}
.topbar{padding:0 16px}
.topbar-title{display:none}
}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<div class="topbar">
<div class="topbar-brand">
<span class="sap-logo">SAP</span>
<span class="topbar-title">Business One — Vendor Registration</span>
</div>
<div class="topbar-right">
<span class="vend-badge">VENDOR FORM</span>
</div>
</div>
<div id="root"></div>
<script>
'use strict';
const BACKEND = (() => localStorage.getItem('BACKEND_URL') || '/api')();
let S = { step:1, fd:{}, submissionId:null, toasts:[] };
let _companies=[{value:window.__DEFAULT_COMPANY__,label:window.__DEFAULT_COMPANY__}]; // live list
const STATES_LIST = ['','Andhra Pradesh','Arunachal Pradesh','Assam','Bihar','Chandigarh','Chhattisgarh','Delhi','Goa','Gujarat','Haryana','Himachal Pradesh','Jammu & Kashmir','Jharkhand','Karnataka','Kerala','Madhya Pradesh','Maharashtra','Manipur','Meghalaya','Mizoram','Nagaland','Odisha','Punjab','Rajasthan','Sikkim','Tamil Nadu','Telangana','Tripura','Uttar Pradesh','Uttarakhand','West Bengal'];
const COUNTRIES_LIST = ['India','United States','United Kingdom','UAE','Singapore','Germany','Japan','Australia'];
const STATE_SHORT = {'Andhra Pradesh':'AP','Arunachal Pradesh':'AR','Assam':'AS','Bihar':'BH','Chandigarh':'CH','Chhattisgarh':'CG','Delhi':'DL','Goa':'GA','Gujarat':'GJ','Haryana':'HR','Himachal Pradesh':'HP','Jammu & Kashmir':'JK','Jharkhand':'JH','Karnataka':'KA','Kerala':'KL','Madhya Pradesh':'MP','Maharashtra':'MH','Manipur':'MN','Meghalaya':'ML','Mizoram':'MZ','Nagaland':'NL','Odisha':'OD','Punjab':'PB','Rajasthan':'RJ','Sikkim':'SK','Tamil Nadu':'TN','Telangana':'TG','Tripura':'TR','Uttar Pradesh':'UP','Uttarakhand':'UK','West Bengal':'WB'};
// ── Utils ─────────────────────────────────────────────────
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${(sel||window.__DEFAULT_COMPANY__)===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
function toast(msg,type='ok'){const id=Date.now();S.toasts.push({id,msg,type});renderToasts();setTimeout(()=>{S.toasts=S.toasts.filter(t=>t.id!==id);renderToasts();},4500);}
function renderToasts(){let tc=document.querySelector('.toast-c');if(!tc){tc=document.createElement('div');tc.className='toast-c';document.body.appendChild(tc);}tc.innerHTML=S.toasts.map(t=>`<div class="toast t-${t.type==='ok'?'ok':t.type==='warn'?'warn':'err'}">${t.type==='ok'?'✓':'✗'} ${esc(t.msg)}</div>`).join('');}
async function api(method,path,body=null){const h={'Content-Type':'application/json'};const opt={method:method.toUpperCase(),headers:h};if(body)opt.body=JSON.stringify(body);let r;try{r=await fetch(BACKEND+path,opt);}catch(e){throw new Error('Cannot reach server.');}let d;try{d=await r.json();}catch{throw new Error('Server error ('+r.status+')');}if(!r.ok)throw new Error(d?.message||d?.errors?.[0]?.msg||'Error '+r.status);return d;}
// ── Address helpers ───────────────────────────────────────
function buildAddrBlockHTML(prefix,label,idx,saved,removable){
saved=saved||{};
const SOPTS=STATES_LIST.map(v=>`<option value="${esc(v)}" ${(saved.state||''===v)?'selected':''}>${v||'-- Select State --'}</option>`).join('');
const COPTS=COUNTRIES_LIST.map(v=>`<option value="${esc(v)}" ${(saved.country||'India')===v?'selected':''}>${v}</option>`).join('');
return `<div class="addr-block" id="${prefix}-block-wrap-${idx}">
<div class="addr-block-hdr">
<div class="addr-block-lbl"><span class="addr-block-num">${idx}</span>${label}${idx>1?' Address #'+idx:''}</div>
${removable?`<button type="button" class="addr-remove-btn" onclick="document.getElementById('${prefix}-block-wrap-${idx}').remove()">✗ Remove</button>`:''}
</div>
<div class="grid g2" style="gap:13px">
<div class="field span2">
<label class="label">Address Name <span class="req">*</span><span style="font-size:9px;color:var(--text3);text-transform:none;font-weight:400;margin-left:4px">(auto-filled · read-only)</span></label>
<div class="addr-name-wrap" style="position:relative">
<input id="${prefix}-addrName-${idx}" class="addr-name-frozen" value="${esc((saved.addrName||'').toUpperCase())}" placeholder="AUTO-FILLED: VENDOR NAME – STATE CODE" readonly tabindex="-1" style="user-select:none"/>
<div class="addr-name-badges"><span class="badge-autofill">AUTO-FILL</span><span class="badge-frozen"><svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>FROZEN</span></div>
</div>
</div>
<div class="field span2"><label class="label">Street / PO Box <span class="req">*</span></label><input id="${prefix}-street-${idx}" value="${esc((saved.street||'').toUpperCase())}" placeholder="PLOT NO., STREET NAME, LOCALITY"/></div>
<div class="field span2"><label class="label">Block / Area</label><input id="${prefix}-block-${idx}" value="${esc((saved.block||'').toUpperCase())}" placeholder="INDUSTRIAL AREA / SECTOR"/></div>
<div class="field"><label class="label">City <span class="req">*</span></label><input id="${prefix}-city-${idx}" value="${esc((saved.city||'').toUpperCase())}" placeholder="CITY NAME"/></div>
<div class="field"><label class="label">Pin Code</label><input id="${prefix}-zip-${idx}" value="${esc((saved.zip||'').toUpperCase())}" placeholder="110001"/></div>
<div class="field"><label class="label">State <span class="req">*</span></label><select id="${prefix}-state-${idx}">${SOPTS}</select></div>
<div class="field"><label class="label">Country</label><select id="${prefix}-country-${idx}">${COPTS}</select></div>
<div class="field span2">
<label class="label">GSTIN <span style="font-size:9.5px;color:var(--text3);text-transform:none;font-weight:400">(State-wise — optional)</span></label>
<input id="${prefix}-gstin-${idx}" value="${esc((saved.gstin||'').toUpperCase())}" placeholder="E.G. 06ABCDE1234F1Z5" maxlength="15"/>
</div>
</div>
</div>`;
}
function wireAddrAutoFill(container,prefix,idx,cardNameInputId){
const addrNameEl=container.querySelector(`#${prefix}-addrName-${idx}`);
const stateEl=container.querySelector(`#${prefix}-state-${idx}`);
if(!addrNameEl||!stateEl)return;
function autoFill(){
const cn=cardNameInputId?document.getElementById(cardNameInputId):null;
const bizName=((cn?cn.value:'')||S?.fd?.cardName||'').toUpperCase().trim();
const stateFull=stateEl.value||'';
const stateShort=STATE_SHORT[stateFull]||stateFull.toUpperCase().substring(0,2);
addrNameEl.value=bizName?(stateShort?`${bizName} - ${stateShort}`:bizName).substring(0,50).toUpperCase():'';
}
stateEl.addEventListener('change',autoFill);
if(cardNameInputId){const cn=document.getElementById(cardNameInputId);if(cn)cn.addEventListener('input',autoFill);}
if(stateEl.value)autoFill();
}
function collectAddrBlocks(container,prefix){
const addrs=[];
container.querySelectorAll(`[id^="${prefix}-block-wrap-"]`).forEach(div=>{
const n=div.id.replace(`${prefix}-block-wrap-`,'');
if(isNaN(n))return;
const addrName=(div.querySelector(`#${prefix}-addrName-${n}`)?.value||'').trim().toUpperCase();
const street=(div.querySelector(`#${prefix}-street-${n}`)?.value||'').trim().toUpperCase();
if(!addrName&&!street)return;
addrs.push({addrName,street,block:(div.querySelector(`#${prefix}-block-${n}`)?.value||'').trim().toUpperCase(),city:(div.querySelector(`#${prefix}-city-${n}`)?.value||'').trim().toUpperCase(),zip:(div.querySelector(`#${prefix}-zip-${n}`)?.value||'').trim().toUpperCase(),state:div.querySelector(`#${prefix}-state-${n}`)?.value||'',country:div.querySelector(`#${prefix}-country-${n}`)?.value||'India',gstin:(div.querySelector(`#${prefix}-gstin-${n}`)?.value||'').trim().toUpperCase()});
});
return addrs;
}
// ── Render ────────────────────────────────────────────────
function render(){
const root=document.getElementById('root');root.innerHTML='';
const pg=document.createElement('div');pg.className='page';
const pct=S.step===1?33:S.step===2?66:100;
pg.innerHTML=`<div class="progress-wrap"><div class="progress-bar" style="width:${pct}%"></div></div>`;
pg.appendChild(buildSteps());
if(S.step===1)pg.appendChild(renderStep1());
else if(S.step===2)pg.appendChild(renderStep2());
else pg.appendChild(renderStep3());
root.appendChild(pg);
}
function buildSteps(){
const labels=['Vendor Details','Review','Confirmation'];
const wrap=document.createElement('div');wrap.className='steps-wrap';
labels.forEach((lb,i)=>{
const n=i+1;const s=document.createElement('div');
s.className='step'+(n<S.step?' done':n===S.step?' active':'');
s.innerHTML=`<div class="step-circle">${n<S.step?'✓':n}</div><span class="step-label">${lb}</span>`;
wrap.appendChild(s);
if(i<labels.length-1){const line=document.createElement('div');line.className='step-line'+(n<S.step?' done':'');wrap.appendChild(line);}
});
return wrap;
}
// ── STEP 1 ────────────────────────────────────────────────
function renderStep1(){
const fd=S.fd;
const card=document.createElement('div');card.className='card';
const vt=fd.vendorType||'SUPPLIER';
const savedBill=fd.allBillAddresses?.length?fd.allBillAddresses:[{addrName:fd.billAddressName||'',street:fd.billStreet||'',block:fd.billBlock||'',city:fd.billCity||'',zip:fd.billZip||'',state:fd.billState||'',country:fd.billCountry||'India',gstin:fd.gstin||''}];
const savedShip=fd.allShipAddresses?.length?fd.allShipAddresses:[{addrName:fd.shipAddressName||'',street:fd.shipStreet||'',block:fd.shipBlock||'',city:fd.shipCity||'',zip:fd.shipZip||'',state:fd.shipState||'',country:fd.shipCountry||'India'}];
card.innerHTML=`
<div class="card-title">Vendor Registration Form</div>
<div class="card-sub">Fill in your business and banking details. Fields marked <span>*</span> are required. Vendor code prefix: <code style="font-family:var(--mono);color:var(--accent)">VENDA</code></div>
<div class="sec">Company / Database</div>
<div class="grid g2" style="margin-bottom:8px">
<div class="field">
<label class="label">Submit to Company <span class="req">*</span></label>
<select id="f-company">
${coOptions(fd.company)}
</select>
</div>
</div>
<div class="sec">Vendor Type</div>
<div class="type-cards">
<label class="type-card ${vt==='SUPPLIER'?'selected':''}"><input type="radio" name="vendType" value="SUPPLIER" ${vt==='SUPPLIER'?'checked':''}/>
<div><div class="type-card-title">Supplier</div><div class="type-card-desc">Goods / Raw materials / Products</div></div></label>
<label class="type-card ${vt==='SERVICE'?'selected':''}"><input type="radio" name="vendType" value="SERVICE" ${vt==='SERVICE'?'checked':''}/>
<div><div class="type-card-title">Service Provider</div><div class="type-card-desc">Labour / Consulting / Maintenance</div></div></label>
<label class="type-card ${vt==='BOTH'?'selected':''}"><input type="radio" name="vendType" value="BOTH" ${vt==='BOTH'?'checked':''}/>
<div><div class="type-card-title">Both</div><div class="type-card-desc">Goods & services</div></div></label>
</div>
<div class="sec">Business Information</div>
<div class="grid g2">
<div class="field"><label class="label">Company / Vendor Name <span class="req">*</span></label><input id="f-cardName" placeholder="E.G. SHREE GANESH INDUSTRIES PVT LTD" value="${esc((fd.cardName||'').toUpperCase())}"/><span class="ferr" id="e-cardName"></span></div>
<div class="field"><label class="label">Foreign / Trade Name</label><input id="f-foreignName" placeholder="ALTERNATE TRADE NAME" value="${esc((fd.foreignName||'').toUpperCase())}"/></div>
<div class="field"><label class="label">Type of Business <span class="req">*</span></label>
<select id="f-typeOfBusiness">${['Company','Individual','Partnership','LLP','Proprietorship','Trust','Society'].map(v=>`<option ${(fd.typeOfBusiness||'Company')===v?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="field"><label class="label">Industry / Sector <span class="req">*</span></label>
<select id="f-industry">${['','Manufacturing','Trading','Transport & Logistics','Food & Beverage','Chemicals','Pharma','Agriculture','IT Services','Printing & Packaging','Engineering','Textiles','Construction','Others'].map(v=>`<option value="${v}" ${(fd.industry||'')===v?'selected':''}>${v||'-- Select --'}</option>`).join('')}</select>
<span class="ferr" id="e-industry"></span></div>
</div>
<div class="sec">Primary Contact Person</div>
<div class="grid g3">
<div class="field"><label class="label">First Name <span class="req">*</span></label><input id="f-contactFirst" placeholder="RAMESH" value="${esc((fd.contactFirst||'').toUpperCase())}"/><span class="ferr" id="e-contactFirst"></span></div>
<div class="field"><label class="label">Last Name <span class="req">*</span></label><input id="f-contactLast" placeholder="GUPTA" value="${esc((fd.contactLast||'').toUpperCase())}"/><span class="ferr" id="e-contactLast"></span></div>
<div class="field"><label class="label">Designation</label><input id="f-contactTitle" placeholder="PROPRIETOR / SALES HEAD" value="${esc((fd.contactTitle||'').toUpperCase())}"/></div>
<div class="field"><label class="label">Mobile Number <span class="req">*</span></label><input id="f-mobile" placeholder="+91 99999 99999" value="${esc(fd.mobile||'')}" class="no-upper"/><span class="ferr" id="e-mobile"></span></div>
<div class="field"><label class="label">Email Address <span class="req">*</span></label><input id="f-email" type="email" placeholder="vendor@company.com" value="${esc(fd.email||'')}" style="text-transform:none"/><span class="ferr" id="e-email"></span></div>
<div class="field"><label class="label">Alternate Contact</label><input id="f-altContact" placeholder="+91 88888 88888" value="${esc(fd.altContact||'')}" class="no-upper"/></div>
</div>
<div style="display:flex;align-items:center;margin-top:24px"><div class="sec" style="margin:0;flex:1">Billing Address(es)</div></div>
<div class="alert a-info" style="margin-top:12px;margin-bottom:10px">ℹ <strong>Address Name</strong> is auto-filled as <strong>VENDOR NAME – STATE CODE</strong>.</div>
<div id="bill-addr-container" style="margin-top:4px">${savedBill.map((a,i)=>buildAddrBlockHTML('fb','Billing',i+1,a,i>0)).join('')}</div>
<button type="button" class="add-addr-btn" id="add-bill-addr">+ Add Another Billing Address</button>
<div class="sec" style="margin-top:8px">Shipping Address(es)</div>
<div style="margin-bottom:14px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:12px 16px;background:var(--surface2);border:1.5px solid var(--border2);border-radius:8px;width:fit-content">
<input type="checkbox" id="sameAsBill" ${fd.sameAsBill!==false?'checked':''} style="width:18px;height:18px;padding:0;accent-color:var(--accent)"/>
<span style="font-size:13px;font-weight:600;color:var(--text)">Same as first Billing Address</span>
</label>
</div>
<div id="ship-addr-section" style="${fd.sameAsBill!==false?'display:none':''}">
<div id="ship-addr-container">${savedShip.map((a,i)=>buildAddrBlockHTML('fs','Shipping',i+1,a,i>0)).join('')}</div>
<button type="button" class="add-addr-btn" id="add-ship-addr">+ Add Another Shipping Address</button>
</div>
<div class="sec">Tax & Compliance</div>
<div class="grid g3">
<div class="field span2"><label class="label">GSTIN <span class="req">*</span></label>
<div class="gstin-row"><input id="f-gstin" placeholder="E.G. 06ABCDE1234F1Z5" value="${esc((fd.gstin||'').toUpperCase())}" maxlength="15"/>
<button type="button" id="gstin-fetch" class="fetch-btn"><span id="gstin-sp" style="display:none" class="sp"></span>Auto PAN</button></div>
<div style="font-size:10.5px;color:var(--text3);margin-top:3px">Auto-extracts PAN from 15-digit GSTIN</div>
<span class="ferr" id="e-gstin"></span></div>
<div class="field"><label class="label">PAN Number <span class="req">*</span></label>
<div class="pan-wrap"><input id="f-pan" placeholder="ABCDE1234F" value="${esc((fd.pan||'').toUpperCase())}" maxlength="10" style="padding-right:52px"/>
<span id="pan-badge" class="pan-auto" style="display:${fd.panAutoFetched?'block':'none'}">AUTO</span></div>
<span class="ferr" id="e-pan"></span></div>
<div class="field"><label class="label">TAN Number <span style="font-size:9.5px;color:var(--text3);text-transform:none;font-weight:400">(if applicable)</span></label>
<input id="f-tan" placeholder="BLRA12345B" value="${esc((fd.tan||'').toUpperCase())}" maxlength="10"/></div>
<div class="field"><label class="label">Currency</label>
<select id="f-currency">${['Indian Rupee','US Dollar','Euro','British Pound','UAE Dirham'].map(v=>`<option ${(fd.currency||'Indian Rupee')===v?'selected':''}>${v}</option>`).join('')}</select></div>
</div>
<!-- ══════════════════════════════════════════════════════
MSME / UDYAM — FIXED FIELDS
U_MSME_Type : Micro | Small | Medium | Large
U_MSME_BType : Manufacturing | Service | Trading | Others
U_Fssai : FSSAI License Number (new field)
═══════════════════════════════════════════════════════ -->
<div class="sec">MSME / Udyam Registration</div>
<div class="toggle-block ${fd.hasMsme?'active':''}" id="msme-wrap">
<label><input type="checkbox" id="f-hasMsme" ${fd.hasMsme?'checked':''}/>
<div><div class="toggle-title">Registered under MSME / Udyam</div>
<div class="toggle-sub">Check if this vendor holds an MSME / Udyam registration certificate</div></div></label>
<div id="msme-fields" style="${fd.hasMsme?'':'display:none'}">
<div class="grid g3" style="margin-top:16px">
<!-- Row 1: Udyam No + MSME Type -->
<div class="field span2">
<label class="label">Udyam Registration Number <span class="req">*</span></label>
<input id="f-msmeNo" placeholder="UDYAM-HR-18-0040140" value="${esc((fd.msmeNo||'').toUpperCase())}" maxlength="19"/>
<span class="ferr" id="e-msmeNo"></span>
</div>
<div class="field">
<label class="label">MSME Type <span class="req">*</span>
<span style="font-size:9px;color:var(--text3);text-transform:none;font-weight:400;margin-left:4px">(U_MSME_Type)</span>
</label>
<!-- FIX: SAP UDF U_MSME_Type values = Micro, Small, Medium, Large -->
<select id="f-msmeType">
${['','MICRO','SMALL','MEDIUM','LARGE'].map(v=>`<option value="${v}" ${(fd.msmeType||'')===v?'selected':''}>${v||'-- Select --'}</option>`).join('')}
</select>
<span class="ferr" id="e-msmeType"></span>
</div>
<!-- Row 2: Business Type + Certificate upload hint -->
<div class="field span2">
<label class="label">MSME Business Type <span class="req">*</span>
<span style="font-size:9px;color:var(--text3);text-transform:none;font-weight:400;margin-left:4px">(U_MSME_BType)</span>
</label>
<!-- FIX: U_MSME_BType field — was missing entirely -->
<select id="f-msmeBType">
${['','Manufacturing','Service','Trading','Others'].map(v=>`<option value="${v}" ${(fd.msmeBType||'')===v?'selected':''}>${v||'-- Select --'}</option>`).join('')}
</select>
<span class="ferr" id="e-msmeBType"></span>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════
FSSAI LICENSE — NEW FIELD (U_Fssai)
Shown always (not only for MSME) — applies to food vendors
═══════════════════════════════════════════════════════ -->
<div class="sec">FSSAI License</div>
<div class="grid g2">
<div class="field">
<label class="label">FSSAI License No
<span style="font-size:9.5px;color:var(--text3);text-transform:none;font-weight:400">(U_Fssai — if applicable)</span>
</label>
<input id="f-fssaiNo" placeholder="E.G. 10020042001234" value="${esc((fd.fssaiNo||'').toUpperCase())}" maxlength="14" style="font-family:var(--mono);font-size:12px"/>
<div style="font-size:10.5px;color:var(--text3);margin-top:2px">14-digit FSSAI license number for food / beverage vendors</div>
</div>
</div>
<div class="sec">Bank Account Details</div>
<div class="alert a-info">ℹ Provide at least one bank account. IFSC code will be stored in SAP's <code style="font-family:var(--mono)">SwiftNum</code> field (OCRB).</div>
<div id="bank-container">${buildBankBlockHTML(1,fd.bankAccounts?.[0]||{},false)}</div>
<button type="button" class="add-bank-btn" id="add-bank-btn">+ Add Another Bank Account</button>
<div class="sec">Required Documents</div>
<div class="alert a-warn">Upload clear scanned copies or photos. Max <strong>5MB</strong> per file. PDF, JPG, PNG accepted.</div>
<div class="grid g2" style="align-items:start">
<div class="field">
<label class="label">PAN Card <span class="req">*</span></label>
<div class="attach-zone" id="az-pan"><input type="file" id="f-att-pan" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('pan',this)"/>
<div class="az-icon">🪪</div><div class="az-label">Click or drag PAN Card</div><div class="az-sub">PDF, JPG, PNG — max 5MB</div></div>
<div id="ap-pan"></div><span class="ferr" id="e-att-pan"></span>
</div>
<div class="field">
<label class="label">Cancelled Cheque <span class="req">*</span></label>
<div class="attach-zone" id="az-cheque"><input type="file" id="f-att-cheque" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('cheque',this)"/>
<div class="az-icon"></div><div class="az-label">Click or drag Cancelled Cheque</div><div class="az-sub">PDF, JPG, PNG — max 5MB</div></div>
<div id="ap-cheque"></div><span class="ferr" id="e-att-cheque"></span>
</div>
<div class="field">
<label class="label">GST Certificate <span class="req">*</span></label>
<div class="attach-zone" id="az-gst"><input type="file" id="f-att-gst" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('gst',this)"/>
<div class="az-icon"></div><div class="az-label">Click or drag GST Certificate</div><div class="az-sub">PDF, JPG, PNG — max 5MB</div></div>
<div id="ap-gst"></div><span class="ferr" id="e-att-gst"></span>
</div>
<div class="field" id="az-msme-wrap" style="${fd.hasMsme?'':'display:none'}">
<label class="label">MSME Certificate <span class="req">*</span></label>
<div class="attach-zone" id="az-msme"><input type="file" id="f-att-msme" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('msme',this)"/>
<div class="az-icon"></div><div class="az-label">Click or drag MSME Certificate</div><div class="az-sub">PDF, JPG, PNG — max 5MB</div></div>
<div id="ap-msme"></div><span class="ferr" id="e-att-msme"></span>
</div>
<div class="field" id="az-fssai-wrap" style="${fd.fssaiNo?'':'display:none'}">
<label class="label">FSSAI Certificate <span style="font-size:9.5px;color:var(--text3);text-transform:none;font-weight:400">(if FSSAI entered above)</span></label>
<div class="attach-zone" id="az-fssai"><input type="file" id="f-att-fssai" accept=".pdf,.jpg,.jpeg,.png" onchange="handleAttach('fssai',this)"/>
<div class="az-icon"></div><div class="az-label">Click or drag FSSAI Certificate</div><div class="az-sub">PDF, JPG, PNG — max 5MB</div></div>
<div id="ap-fssai"></div>
</div>
<div class="field">
<label class="label">Other Documents <span style="font-size:9.5px;color:var(--text3);text-transform:none;font-weight:400">(optional)</span></label>
<div class="attach-zone" id="az-other"><input type="file" id="f-att-other" accept=".pdf,.jpg,.jpeg,.png" multiple onchange="handleAttach('other',this)"/>
<div class="az-icon"></div><div class="az-label">Other Supporting Documents</div><div class="az-sub">Multiple files allowed</div></div>
<div id="ap-other"></div>
</div>
</div>
<div class="sec">Additional Information</div>
<div class="field">
<label class="label">Remarks <span class="req">*</span></label>
<textarea id="f-remarks" placeholder="DESCRIBE PRODUCTS / SERVICES, DELIVERY CAPABILITIES, LEAD TIME, ETC...">${esc((fd.remarks||'').toUpperCase())}</textarea>
<span class="ferr" id="e-remarks"></span>
</div>
<div class="btn-row"><button class="btn btn-primary" id="btn-next1">Review Details →</button></div>`;
// Wire vendor type
card.querySelectorAll('input[name="vendType"]').forEach(r=>{
r.onchange=function(){S.fd.vendorType=this.value;card.querySelectorAll('.type-card').forEach(c=>c.classList.remove('selected'));this.closest('.type-card').classList.add('selected');};
});
// Wire address auto-fill
let billCount=savedBill.length,shipCount=savedShip.length;
savedBill.forEach((_,i)=>wireAddrAutoFill(card.querySelector('#bill-addr-container'),'fb',i+1,'f-cardName'));
savedShip.forEach((_,i)=>wireAddrAutoFill(card.querySelector('#ship-addr-container'),'fs',i+1,'f-cardName'));
card.querySelector('#sameAsBill').onchange=function(){card.querySelector('#ship-addr-section').style.display=this.checked?'none':'';S.fd.sameAsBill=!this.checked;};
card.querySelector('#add-bill-addr').onclick=()=>{billCount++;const c=card.querySelector('#bill-addr-container');const d=document.createElement('div');d.innerHTML=buildAddrBlockHTML('fb','Billing',billCount,{},true);c.appendChild(d.firstElementChild);wireAddrAutoFill(c,'fb',billCount,'f-cardName');};
card.querySelector('#add-ship-addr').onclick=()=>{shipCount++;const c=card.querySelector('#ship-addr-container');const d=document.createElement('div');d.innerHTML=buildAddrBlockHTML('fs','Shipping',shipCount,{},true);c.appendChild(d.firstElementChild);wireAddrAutoFill(c,'fs',shipCount,'f-cardName');};
// GSTIN → PAN
const gEl=card.querySelector('#f-gstin'),pEl=card.querySelector('#f-pan'),pBadge=card.querySelector('#pan-badge');
gEl.oninput=function(){this.value=this.value.toUpperCase();if(this.value.length===15){const pan=this.value.substring(2,12);pEl.value=pan;pBadge.style.display='block';S.fd.panAutoFetched=true;S.fd.pan=pan;}else if(S.fd.panAutoFetched){pEl.value='';pBadge.style.display='none';S.fd.panAutoFetched=false;}};
pEl.oninput=function(){this.value=this.value.toUpperCase();if(S.fd.panAutoFetched){pBadge.style.display='none';S.fd.panAutoFetched=false;}};
card.querySelector('#gstin-fetch').onclick=()=>{const g=(gEl.value||'').trim().toUpperCase();if(g.length!==15){card.querySelector('#e-gstin').textContent='Enter a valid 15-digit GSTIN first';return;}const pan=g.substring(2,12);pEl.value=pan;pBadge.style.display='block';S.fd.panAutoFetched=true;toast('PAN extracted: '+pan);};
// MSME toggle
const msmeCk=card.querySelector('#f-hasMsme'),msmeF=card.querySelector('#msme-fields'),msmeW=card.querySelector('#msme-wrap'),msmeAz=card.querySelector('#az-msme-wrap');
msmeCk.onchange=function(){msmeF.style.display=this.checked?'':'none';msmeW.classList.toggle('active',this.checked);if(msmeAz)msmeAz.style.display=this.checked?'':'none';S.fd.hasMsme=this.checked;};
// FSSAI — show attachment zone when number entered
const fssaiInp=card.querySelector('#f-fssaiNo'),fssaiAz=card.querySelector('#az-fssai-wrap');
fssaiInp.oninput=function(){this.value=this.value.toUpperCase();if(fssaiAz)fssaiAz.style.display=this.value.trim().length>0?'':'none';};
// Bank accounts
let bankCount=fd.bankAccounts?.length||1;
if(fd.bankAccounts?.length>1){const bc=card.querySelector('#bank-container');for(let i=2;i<=fd.bankAccounts.length;i++){const d=document.createElement('div');d.innerHTML=buildBankBlockHTML(i,fd.bankAccounts[i-1]||{},true);bc.appendChild(d.firstElementChild);}}
card.querySelector('#add-bank-btn').onclick=()=>{bankCount++;const bc=card.querySelector('#bank-container');const d=document.createElement('div');d.innerHTML=buildBankBlockHTML(bankCount,{},true);bc.appendChild(d.firstElementChild);};
// Restore attachments
if(!S.fd.attachments)S.fd.attachments={};
['pan','cheque','gst','msme','fssai','other'].forEach(key=>{if(S.fd.attachments[key])renderAttachPreview(card,key,S.fd.attachments[key]);});
// Drag-drop
card.querySelectorAll('.attach-zone').forEach(z=>{
z.addEventListener('dragover',e=>{e.preventDefault();z.classList.add('drag');});
z.addEventListener('dragleave',()=>z.classList.remove('drag'));
z.addEventListener('drop',e=>{e.preventDefault();z.classList.remove('drag');const k=z.id.replace('az-','');const inp=z.querySelector('input[type=file]');if(inp&&e.dataTransfer.files.length){inp.files=e.dataTransfer.files;window.handleAttach(k,inp);}});
});
// NEXT / VALIDATE
card.querySelector('#btn-next1').onclick=()=>{
const v=id=>(card.querySelector('#'+id)?.value||'').trim().toUpperCase();
const err=(id,msg)=>{const el=card.querySelector('#e-'+id);if(el)el.textContent=msg;};
const clr=id=>{const el=card.querySelector('#e-'+id);if(el)el.textContent='';};
const emailVal=(card.querySelector('#f-email')?.value||'').trim();
const sameAsBill=card.querySelector('#sameAsBill').checked;
let ok=true;
card.querySelectorAll('input:not([type=email]):not([type=file]):not([type=checkbox]):not([type=radio]):not([type=number]):not(.no-upper)').forEach(inp=>{if(!inp.readOnly&&inp.value)inp.value=inp.value.toUpperCase();});
card.querySelectorAll('textarea').forEach(t=>{if(t.value)t.value=t.value.toUpperCase();});
if(!v('f-cardName')){err('cardName','Vendor name is required');ok=false;}else clr('cardName');
if(!card.querySelector('#f-industry')?.value){err('industry','Industry is required');ok=false;}else clr('industry');
if(!v('f-contactFirst')){err('contactFirst','First name is required');ok=false;}else clr('contactFirst');
if(!v('f-contactLast')){err('contactLast','Last name is required');ok=false;}else clr('contactLast');
const mob=(card.querySelector('#f-mobile')?.value||'').trim();
if(!mob){err('mobile','Mobile number is required');ok=false;}
else if(!/^[0-9+\s\-]{8,15}$/.test(mob)){err('mobile','Enter a valid mobile number');ok=false;}
else clr('mobile');
if(!emailVal){err('email','Email is required');ok=false;}
else if(!/\S+@\S+\.\S+/.test(emailVal)){err('email','Enter a valid email address');ok=false;}
else clr('email');
const gstin=v('f-gstin');
if(!gstin){err('gstin','GSTIN is required');ok=false;}
else if(!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(gstin)){err('gstin','Invalid GSTIN format (e.g. 06ABCDE1234F1Z5)');ok=false;}
else clr('gstin');
const pan=v('f-pan');
if(!pan){err('pan','PAN is required');ok=false;}
else if(!/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(pan)){err('pan','Invalid PAN format (e.g. ABCDE1234F)');ok=false;}
else clr('pan');
if(!v('f-remarks')){err('remarks','Remarks are required');ok=false;}else clr('remarks');
const firstBillDiv=card.querySelector('#fb-block-wrap-1');
if(firstBillDiv){
if(!firstBillDiv.querySelector('#fb-street-1')?.value?.trim()){toast('Please fill Street in the billing address','err');ok=false;}
if(!firstBillDiv.querySelector('#fb-city-1')?.value?.trim()){toast('Please fill City in the billing address','err');ok=false;}
if(!firstBillDiv.querySelector('#fb-state-1')?.value){toast('Please select State in the billing address','err');ok=false;}
}
// MSME validation — all 3 fields now required
const hasMsme=card.querySelector('#f-hasMsme')?.checked;
if(hasMsme){
if(!v('f-msmeNo')){err('msmeNo','MSME number is required');ok=false;}
else if(!/^UDYAM-[A-Z]{2}-\d{2}-\d{7}$/.test(v('f-msmeNo'))){err('msmeNo','Format: UDYAM-HR-18-0040140');ok=false;}
else clr('msmeNo');
if(!card.querySelector('#f-msmeType')?.value){err('msmeType','MSME Type is required');ok=false;}else clr('msmeType');
if(!card.querySelector('#f-msmeBType')?.value){err('msmeBType','MSME Business Type is required');ok=false;}else clr('msmeBType');
if(!S.fd.attachments?.msme){err('att-msme','MSME certificate is required');ok=false;}
}
if(!S.fd.attachments?.pan){err('att-pan','PAN card document is required');ok=false;}
if(!S.fd.attachments?.cheque){err('att-cheque','Cancelled cheque is required');ok=false;}
if(!S.fd.attachments?.gst){err('att-gst','GST certificate is required');ok=false;}
const firstBank=card.querySelector('#bank-block-1');
if(firstBank){
if(!(firstBank.querySelector('#bank-accNo-1')?.value||'').trim()){toast('Bank account number is required','err');ok=false;}
if(!(firstBank.querySelector('#bank-ifsc-1')?.value||'').trim()){toast('IFSC code is required','err');ok=false;}
if(!(firstBank.querySelector('#bank-name-1')?.value||'').trim()){toast('Bank name is required','err');ok=false;}
}
if(!ok){const fe=card.querySelector('.ferr:not(:empty)');if(fe)fe.scrollIntoView({behavior:'smooth',block:'center'});return;}
// Collect banks
const bankAccounts=[];
card.querySelectorAll('[id^="bank-block-"]').forEach(div=>{
const n=div.id.replace('bank-block-','');if(isNaN(n))return;
const accNo=(div.querySelector(`#bank-accNo-${n}`)?.value||'').trim().toUpperCase();
if(!accNo)return;
bankAccounts.push({bankName:(div.querySelector(`#bank-name-${n}`)?.value||'').trim().toUpperCase(),accNo,ifsc:(div.querySelector(`#bank-ifsc-${n}`)?.value||'').trim().toUpperCase(),accountType:div.querySelector(`#bank-type-${n}`)?.value||'Current',branch:(div.querySelector(`#bank-branch-${n}`)?.value||'').trim().toUpperCase(),swiftCode:(div.querySelector(`#bank-swift-${n}`)?.value||'').trim().toUpperCase(),isPrimary:n==='1'});
});
const allBill=collectAddrBlocks(card.querySelector('#bill-addr-container'),'fb');
const allShip=sameAsBill?[]:collectAddrBlocks(card.querySelector('#ship-addr-container'),'fs');
const pb=allBill[0]||{},ps=sameAsBill?pb:(allShip[0]||pb);
S.fd={...S.fd,
company:card.querySelector('#f-company')?.value||window.__DEFAULT_COMPANY__,
vendorType:card.querySelector('input[name="vendType"]:checked')?.value||'SUPPLIER',
cardName:v('f-cardName'),foreignName:v('f-foreignName'),
typeOfBusiness:card.querySelector('#f-typeOfBusiness')?.value||'',
industry:card.querySelector('#f-industry')?.value||'',
contactFirst:v('f-contactFirst'),contactLast:v('f-contactLast'),contactTitle:v('f-contactTitle'),
mobile:mob,email:emailVal,altContact:(card.querySelector('#f-altContact')?.value||'').trim(),
billAddressName:pb.addrName||'',billStreet:pb.street||'',billBlock:pb.block||'',billCity:pb.city||'',billZip:pb.zip||'',billState:pb.state||'',billCountry:pb.country||'India',
sameAsBill,
shipAddressName:ps.addrName||'',shipStreet:ps.street||'',shipBlock:ps.block||'',shipCity:ps.city||'',shipZip:ps.zip||'',shipState:ps.state||'',shipCountry:ps.country||'India',
allBillAddresses:allBill,allShipAddresses:sameAsBill?allBill:allShip,
gstin,pan,tan:v('f-tan'),currency:card.querySelector('#f-currency')?.value||'Indian Rupee',
hasMsme:hasMsme||false,
msmeNo:hasMsme?v('f-msmeNo'):'',
msmeType:hasMsme?card.querySelector('#f-msmeType')?.value||'':'',
msmeBType:hasMsme?card.querySelector('#f-msmeBType')?.value||'':'', // ← NEW
fssaiNo:v('f-fssaiNo'), // ← NEW
remarks:v('f-remarks'),bankAccounts,attachments:S.fd.attachments||{},
};
S.step=2;window.scrollTo(0,0);render();
};
return card;
}
function buildBankBlockHTML(idx,saved={},removable=false){
return `<div class="bank-block" id="bank-block-${idx}">
<div class="bank-block-hdr">
<div class="bank-block-lbl"><span class="bank-block-num">${idx}</span>Bank Account ${idx>1?'#'+idx:''}</div>
${removable?`<button type="button" class="bank-remove-btn" onclick="document.getElementById('bank-block-${idx}').remove()">✗ Remove</button>`:''}
</div>
<div class="grid g2" style="gap:13px">
<div class="field"><label class="label">Bank Name <span class="req">*</span></label><input id="bank-name-${idx}" placeholder="E.G. STATE BANK OF INDIA" value="${esc((saved.bankName||'').toUpperCase())}"/></div>
<div class="field"><label class="label">Branch Name</label><input id="bank-branch-${idx}" placeholder="E.G. NEHRU PLACE BRANCH" value="${esc((saved.branch||'').toUpperCase())}"/></div>
<div class="field"><label class="label">Account Number <span class="req">*</span></label><input id="bank-accNo-${idx}" placeholder="ACCOUNT NUMBER" value="${esc((saved.accNo||'').toUpperCase())}" style="font-family:var(--mono);font-size:12px"/></div>
<div class="field">
<label class="label">IFSC Code <span class="req">*</span>
<span style="font-size:9px;color:var(--text3);text-transform:none;font-weight:400;margin-left:4px">→ SAP SwiftNum (OCRB)</span>
</label>
<input id="bank-ifsc-${idx}" placeholder="SBIN0001234" value="${esc((saved.ifsc||'').toUpperCase())}" maxlength="11" style="font-family:var(--mono);font-size:12px"/>
</div>
<div class="field"><label class="label">Account Type</label>
<select id="bank-type-${idx}">${['Current','Savings','Cash Credit','Overdraft'].map(v=>`<option value="${v}" ${(saved.accountType||'Current')===v?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="field"><label class="label">SWIFT Code <span style="font-size:9.5px;color:var(--text3);text-transform:none;font-weight:400">(for foreign payments)</span></label>
<input id="bank-swift-${idx}" placeholder="SBININBB" value="${esc((saved.swiftCode||'').toUpperCase())}" maxlength="11" style="font-family:var(--mono);font-size:12px"/></div>
</div>
</div>`;
}
function renderAttachPreview(container,key,att){
const el=container.querySelector('#ap-'+key);if(!el)return;
const arr=Array.isArray(att)?att:[att];
el.innerHTML=arr.map((a,i)=>`<div class="attach-preview">
<div class="ap-thumb">${a.type?.startsWith('image/')?`<img src="${a.data}" style="width:100%;height:100%;object-fit:cover;border-radius:4px">`:a.type==='application/pdf'?'':''}</div>
<div class="ap-info"><div class="ap-name">${esc(a.name)}</div><div class="ap-size">${(a.size/1024).toFixed(1)} KB · ${a.type||'file'}</div></div>
<button class="ap-rm" onclick="removeAttach('${key}',${i},this.closest('.attach-preview'))">✗</button>
</div>`).join('');
}
window.removeAttach=function(key,idx,el){
if(!S.fd.attachments)return;
const arr=Array.isArray(S.fd.attachments[key])?S.fd.attachments[key]:[S.fd.attachments[key]];
arr.splice(idx,1);S.fd.attachments[key]=arr.length?(key==='other'||key==='fssai'?arr:arr[0]):null;
if(el)el.remove();
};
window.handleAttach=function(key,input){
Array.from(input.files||[]).forEach(file=>{
if(file.size>5*1024*1024){toast(file.name+' exceeds 5MB','err');return;}
const r=new FileReader();
r.onload=e=>{
const obj={name:file.name,size:file.size,type:file.type,data:e.target.result};
if(!S.fd.attachments)S.fd.attachments={};
if(key==='other'||key==='fssai'){
if(!Array.isArray(S.fd.attachments[key]))S.fd.attachments[key]=[];
S.fd.attachments[key].push(obj);
const previewEl=document.querySelector('#ap-'+key);
if(previewEl)renderAttachPreviewDirect(previewEl,S.fd.attachments[key]);
}else{
S.fd.attachments[key]=obj;
const previewEl=document.querySelector('#ap-'+key);
if(previewEl)renderAttachPreviewDirect(previewEl,[obj]);
}
};
r.readAsDataURL(file);
});
input.value='';
};
function renderAttachPreviewDirect(el,arr){
el.innerHTML=arr.map((a,i)=>`<div class="attach-preview">
<div class="ap-thumb">${a.type?.startsWith('image/')?`<img src="${a.data}" style="width:100%;height:100%;object-fit:cover;border-radius:4px">`:a.type==='application/pdf'?'':''}</div>
<div class="ap-info"><div class="ap-name">${esc(a.name)}</div><div class="ap-size">${(a.size/1024).toFixed(1)} KB</div></div>
<button class="ap-rm" onclick="this.closest('.attach-preview').remove()">✗</button>
</div>`).join('');
}
// ── STEP 2: REVIEW ────────────────────────────────────────
function renderStep2(){
const fd=S.fd;const card=document.createElement('div');card.className='card';
const vtLabel={SUPPLIER:'Supplier',SERVICE:'Service Provider',BOTH:'Supplier + Service'}[fd.vendorType]||fd.vendorType;
const vtBadge={SUPPLIER:'b-sup',SERVICE:'b-serv',BOTH:'b-both'}[fd.vendorType]||'b-sup';
const billHtml=(fd.allBillAddresses||[]).map((a,i)=>`<tr><td>Billing${i>0?' #'+(i+1):''}</td><td class="val"><strong>${esc(a.addrName||'—')}</strong><br/><span style="font-size:12px;color:var(--text2)">${esc(a.street||'')}${a.block?', '+esc(a.block):''}, ${esc(a.city||'')} ${esc(a.state||'')} ${esc(a.zip||'')}, ${esc(a.country||'')}</span>${a.gstin?`<br/><span style="font-size:11px;color:var(--text3)">GSTIN: ${esc(a.gstin)}</span>`:''}</td></tr>`).join('');
const shipHtml=fd.sameAsBill?'<tr><td>Shipping</td><td class="val" style="color:var(--text3)">Same as Billing Address</td></tr>':(fd.allShipAddresses||[]).map((a,i)=>`<tr><td>Shipping${i>0?' #'+(i+1):''}</td><td class="val"><strong>${esc(a.addrName||'—')}</strong><br/><span style="font-size:12px;color:var(--text2)">${esc(a.street||'')}${a.block?', '+esc(a.block):''}, ${esc(a.city||'')} ${esc(a.state||'')} ${esc(a.zip||'')}</span></td></tr>`).join('');
const banksHtml=(fd.bankAccounts||[]).map((b,i)=>`<tr><td>Bank A/C ${i+1}</td><td class="val"><strong>${esc(b.bankName)}</strong>${b.branch?`, ${esc(b.branch)}`:''}<br/><span style="font-family:var(--mono);font-size:12px">${esc(b.accNo)}</span> · IFSC: <span style="font-family:var(--mono);font-size:12px">${esc(b.ifsc)}</span> · ${esc(b.accountType)}${b.swiftCode?` · SWIFT: <span style="font-family:var(--mono);font-size:12px">${esc(b.swiftCode)}</span>`:''}</td></tr>`).join('');
card.innerHTML=`
<div class="card-title">Review Your Details</div>
<div class="card-sub">Please review all information carefully before submitting.</div>
<div class="alert a-info">ℹ After submission, our purchase/accounts team will verify and create vendor code <code style="font-family:var(--mono);color:var(--accent)">VENDA*</code> in SAP B1.</div>
<table class="rev-table">
<tr><td>Vendor Type</td><td class="val"><span class="badge ${vtBadge}">${vtLabel}</span></td></tr>
<tr><td>Vendor Name</td><td class="val"><strong>${esc(fd.cardName)}</strong>${fd.foreignName?` <span style="font-size:12px;color:var(--text2)">(${esc(fd.foreignName)})</span>`:''}</td></tr>
<tr><td>Business Type</td><td class="val">${esc(fd.typeOfBusiness)}</td></tr>
<tr><td>Industry</td><td class="val">${esc(fd.industry)}</td></tr>
<tr><td>Contact</td><td class="val">${esc(fd.contactFirst+' '+fd.contactLast)}${fd.contactTitle?` <span style="font-size:12px;color:var(--text2)">(${esc(fd.contactTitle)})</span>`:''}</td></tr>
<tr><td>Mobile</td><td class="val">${esc(fd.mobile)}${fd.altContact?` / ${esc(fd.altContact)}`:''}</td></tr>
<tr><td>Email</td><td class="val">${esc(fd.email)}</td></tr>
${billHtml}${shipHtml}
<tr><td>GSTIN</td><td class="val"><code style="font-family:var(--mono);font-size:12px">${esc(fd.gstin)}</code></td></tr>
<tr><td>PAN (TaxId0)</td><td class="val"><code style="font-family:var(--mono);font-size:12px">${esc(fd.pan)}</code>${fd.panAutoFetched?'&nbsp;<span style="background:rgba(16,185,129,.1);color:var(--green);font-size:10px;padding:1px 6px;border-radius:3px;font-weight:700">AUTO</span>':''}</td></tr>
${fd.tan?`<tr><td>TAN</td><td class="val"><code style="font-family:var(--mono);font-size:12px">${esc(fd.tan)}</code></td></tr>`:''}
<tr><td>Currency</td><td class="val">${esc(fd.currency)}</td></tr>
${fd.hasMsme?`<tr><td>MSME</td><td class="val">✓ <span style="font-family:var(--mono);font-size:12px">${esc(fd.msmeNo)}</span><br/><span style="font-size:12px;color:var(--text2)">Type: ${esc(fd.msmeType)} &nbsp;·&nbsp; Business: ${esc(fd.msmeBType)}</span></td></tr>`:''}
${fd.fssaiNo?`<tr><td>FSSAI (U_Fssai)</td><td class="val"><code style="font-family:var(--mono);font-size:12px">${esc(fd.fssaiNo)}</code></td></tr>`:''}
${banksHtml}
<tr><td>Documents</td><td class="val">${Object.entries(fd.attachments||{}).filter(([,v])=>v&&(Array.isArray(v)?v.length:true)).map(([k,v])=>{const arr=Array.isArray(v)?v:[v];return arr.map(a=>`<span style="display:inline-flex;background:rgba(232,168,32,.1);color:var(--accent);padding:2px 9px;border-radius:4px;font-size:11px;font-weight:600;margin:2px 3px 2px 0">${k.toUpperCase()}: ${esc(a.name)}</span>`).join('');}).join('')||'<span style="color:var(--text3)">None</span>'}</td></tr>
<tr><td>Remarks</td><td class="val" style="color:var(--text2)">${esc(fd.remarks)}</td></tr>
</table>
<div class="btn-row">
<button class="btn btn-secondary" id="s2-back">← Edit Details</button>
<button class="btn btn-primary" id="s2-submit"><span id="s2-sp" style="display:none" class="sp"></span>✓ Confirm & Submit</button>
</div>`;
card.querySelector('#s2-back').onclick=()=>{S.step=1;window.scrollTo(0,0);render();};
card.querySelector('#s2-submit').onclick=async()=>{
const btn=card.querySelector('#s2-submit'),sp=card.querySelector('#s2-sp');
btn.disabled=true;sp.style.display='inline-block';
try{const r=await api('POST','/vendors/submit',S.fd);S.submissionId=r.id;toast('Vendor application submitted successfully!');S.step=3;window.scrollTo(0,0);render();}
catch(e){toast(e.message,'err');btn.disabled=false;sp.style.display='none';}
};
return card;
}
// ── STEP 3: SUCCESS ───────────────────────────────────────
function renderStep3(){
const card=document.createElement('div');card.className='card';
const refNo=S.submissionId?`VEND-${String(S.submissionId).padStart(5,'0')}`:'Submitted';
card.innerHTML=`<div class="success-wrap">
<div class="success-icon"></div>
<h2>Application Received!</h2>
<p>Thank you, <strong>${esc((S.fd.contactFirst||'')+' '+(S.fd.contactLast||''))}</strong>!<br/>Your vendor registration for <strong>${esc(S.fd.cardName)}</strong> has been submitted successfully.</p>
<div class="ref-box">${esc(refNo)}</div>
<p>Our purchase / accounts team will verify your documents and contact you at<br/>
<strong>${esc(S.fd.mobile)}</strong> or <strong>${esc(S.fd.email)}</strong>.<br/>
Your vendor code will be assigned as <code style="font-family:var(--mono);color:var(--accent)">VENDA*</code> in SAP B1.</p>
<p style="font-size:11.5px;color:var(--text3);margin-top:12px">Keep reference <strong style="color:var(--accent)">${esc(refNo)}</strong> for follow-up.</p>
<div style="margin-top:32px;display:flex;gap:14px;justify-content:center;flex-wrap:wrap">
<button class="btn btn-secondary" id="new-app">+ Register Another Vendor</button>
<button class="btn btn-primary" onclick="window.print()">Print Confirmation</button>
</div>
</div>`;
card.querySelector('#new-app').onclick=()=>{S.fd={};S.step=1;S.submissionId=null;window.scrollTo(0,0);render();};
return card;
}
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}render();})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+322
View File
@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Verify Production — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--mono);font-weight:700;font-size:14px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1200px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}select.fi{cursor:pointer}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer}.btn:disabled{opacity:.45;cursor:not-allowed}
.b-primary{background:var(--blue);color:#fff}.b-verify{background:var(--green);color:#04231a}.b-verify:hover:not(:disabled){filter:brightness(1.08)}
.hint{font-size:11px;color:var(--amber);background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.22);border-radius:8px;padding:8px 11px;margin-bottom:12px}
.po{display:flex;gap:14px;align-items:flex-start;padding:12px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;margin-bottom:8px}
.po-main{flex:1;min-width:0}
.po-t{font-size:14px;font-weight:700;color:var(--text)}
.po-sub{font-size:11px;color:var(--text3);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap}
.po-sub b{color:var(--text2);font-family:var(--mono)}
.po-right{display:flex;flex-direction:column;gap:6px;align-items:flex-end;min-width:230px}
.po-right .fi{height:32px}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
.chip{font-size:9px;font-weight:700;color:var(--text2);background:var(--surface3);border:1px solid var(--border2);border-radius:6px;padding:2px 8px;text-transform:uppercase;letter-spacing:.3px}
.pill{font-size:9px;font-weight:800;padding:2px 8px;border-radius:20px;text-transform:uppercase}
.p-ok{background:rgba(13,187,138,.14);color:#0aa457}.p-pend{background:rgba(245,166,35,.14);color:#c78a1a}
.vinfo{font-size:11px;color:var(--green);text-align:right}
.vinfo b{color:var(--text2)}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.tabs{display:flex;gap:6px;margin-bottom:12px}
.tab{padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);cursor:pointer}
.tab.on{background:var(--blue);color:#fff;border-color:var(--blue)}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
.po{cursor:pointer;transition:border-color .15s}
.po:hover{border-color:var(--blue)}
.modal-ov{display:none;position:fixed;inset:0;z-index:500;background:rgba(4,8,14,.7);backdrop-filter:blur(3px);align-items:flex-start;justify-content:center;padding:26px 12px;overflow:auto}
.modal-ov.show{display:flex}
.modal-w{width:100%;max-width:900px;background:var(--surface);border:1px solid var(--border2);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
.modal-hd2{display:flex;align-items:center;gap:10px;padding:13px 16px;background:var(--surface3);border-bottom:1px solid var(--border)}
.modal-hd2-t{font-size:14px;font-weight:700}
.modal-x{margin-left:auto;width:28px;height:28px;border-radius:8px;border:1px solid var(--border2);background:none;color:var(--text2);cursor:pointer}
.modal-x:hover{border-color:var(--red);color:var(--red)}
.modal-bd2{padding:14px 16px;max-height:calc(100vh - 170px);overflow:auto}
.dt-grid{display:grid;grid-template-columns:1fr 1fr;gap:2px 18px;font-size:12px;margin-bottom:14px}
.dt-row{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px solid var(--border)}
.dt-l{color:var(--text3);font-size:10.5px;text-transform:uppercase;letter-spacing:.3px}
.dt-v{color:var(--text);font-weight:600;text-align:right}
.sec-t{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin:12px 0 8px}
.modal-ft{display:flex;gap:8px;padding:13px 16px;border-top:1px solid var(--border);background:var(--surface3);align-items:center}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/verify-production" class="nav-link active">Verify Production</a></div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<div class="modal-ov" id="detail-modal"><div class="modal-w" id="detail-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}
if(!_tok)location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();location.href='/';};
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){ if(typeof s==='string'){if(s===fullKey)return true;} else if(s&&s.step===fullKey){return Array.isArray(s.perms)&&s.perms.includes(perm);} }
return false;
}
const CAN_VIEW=hasStepPerm('production_order:verify','view');
const CAN_VERIFY=hasStepPerm('production_order:verify','approve');
let _co=window.__DEFAULT_COMPANY__;let _companies=[{value:_co,label:_co}];
let _rows=[],_loading=false,_tab='pending',_hasSig=false;
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m,t='ok'){const c=q('#toast-c');const e=document.createElement('div');e.className='toast t-'+t;e.textContent=m;c.appendChild(e);setTimeout(()=>e.remove(),4200);}
function fmt(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';const p=n=>String(n).padStart(2,'0');return `${p(d.getDate())}-${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;}
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
async function api(m,p,b){
const h={'Content-Type':'application/json'};if(_tok)h.Authorization='Bearer '+_tok;
const o={method:m,headers:h};if(b)o.body=JSON.stringify(b);
const r=await fetch(BE+p,o);if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));return d;
}
async function load(){
_loading=true;render();
try{const r=await api('GET',`/production-orders/for-verification?company=${encodeURIComponent(_co)}`);_rows=r.data||[];}
catch(e){toast(e.message,'err');_rows=[];}
_loading=false;render();
}
async function doVerify(id,fromModal){
const rem=(q(fromModal?'#md-rem':'#rem-'+id)||{}).value||'';
const btn=q(fromModal?'#md-vb':'#vb-'+id);if(btn){btn.disabled=true;btn.innerHTML='<span class="sp"></span>';}
try{
await api('POST',`/production-orders/${id}/verify`,{company:_co,remarks:rem});
toast('Production order verified');
closeDetail();
load();
}catch(e){toast(e.message,'err');if(btn){btn.disabled=false;btn.textContent='✔ Verify';}}
}
// ── Full production order detail (same QA comparison as the Production page) ──
async function fetchWoApprovedMap(p){
if(!p.workOrderId)return null;
try{
const r=await api('GET',`/work-orders/${p.workOrderId}`);
const w=r.data||r;const map={};
(w.rawMaterials||[]).forEach(m=>{const c=(m.itemCode||'').toUpperCase();if(c)map[c]=parseFloat(m.qtyReq)||0;});
(w.packingMaterials||[]).forEach(m=>{const c=(m.itemCode||'').toUpperCase();if(c)map[c]=parseFloat(m.qtyReqUnits)||0;});
return map;
}catch(_e){return null;}
}
function buildQaComparison(lines,woApproved){
const EPS=0.001,r3=n=>Math.round((Number(n)||0)*1000)/1000;
const items=(lines||[]).filter(l=>l.ItemType!=='pit_Resource');
const totalIssued=items.reduce((s,l)=>s+(Number(l.IssuedQuantity)||0),0);
const issuedPhase=totalIssued>EPS;
let issueMismatch=0,woMismatch=0,matchCount=0;
const rows=items.map((l,idx)=>{
const planned=Number(l.PlannedQuantity)||0,issued=Number(l.IssuedQuantity)||0;
const code=(l.ItemNo||'').toUpperCase();
const approved=woApproved&&woApproved[code]!=null?Number(woApproved[code]):null;
const iDiff=issued-planned;
const issuedBad=issuedPhase&&Math.abs(iDiff)>EPS;if(issuedBad)issueMismatch++;
const woBad=approved!=null&&Math.abs(planned-approved)>EPS;if(woBad)woMismatch++;
if(issuedPhase&&!issuedBad&&!woBad)matchCount++;
const zebra=idx%2?'background:rgba(255,255,255,.015)':'';
const rowBg=issuedBad?'background:rgba(229,62,62,.07)':(woBad?'background:rgba(245,166,35,.07)':zebra);
const accent=issuedBad?'inset 3px 0 0 var(--red)':(woBad?'inset 3px 0 0 var(--amber)':'inset 3px 0 0 transparent');
const issuedColor=!issuedPhase?'var(--text2)':(Math.abs(iDiff)<=EPS?'var(--green)':(iDiff<0?'var(--amber)':'var(--red)'));
const woCell=approved==null?'<span style="color:var(--text3)">—</span>'
:(woBad?`<span style="display:inline-block;background:var(--amber);color:#1a1200;font-weight:700;padding:1px 8px;border-radius:5px">${r3(approved)} <span style="color:var(--red)">⚠</span></span>`:`<span style="color:var(--text2)">${r3(approved)}</span>`);
let pill;
if(!issuedPhase) pill='<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(122,154,184,.12);color:var(--text2)">PENDING</span>';
else if(issuedBad) pill=`<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(229,62,62,.13);color:#c81e1e">${iDiff<0?'SHORT':'OVER'} ${(iDiff>0?'+':'')+r3(iDiff)}</span>`;
else if(woBad) pill='<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(245,166,35,.18);color:#9a6700">WO DIFF</span>';
else pill='<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(13,187,138,.16);color:#0a8f57">MATCH</span>';
const td='padding:8px 10px;border-bottom:1px solid var(--border)';
return `<tr style="${rowBg}">
<td style="${td};box-shadow:${accent}"><div style="font-family:var(--mono);color:var(--blue);font-size:11px;font-weight:600">${esc(l.ItemNo)}</div><div style="font-size:10px;color:var(--text3);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(l.ItemName||'')}</div></td>
<td style="${td};text-align:right;font-family:var(--mono);color:var(--text2)">${r3(planned)}</td>
<td style="${td};text-align:right;font-family:var(--mono)">${woCell}</td>
<td style="${td};text-align:right;font-family:var(--mono);font-weight:700;color:${issuedColor}">${r3(issued)}</td>
<td style="${td};text-align:right">${pill}</td>
</tr>`;
}).join('');
const chip=(txt,color,bg)=>`<span style="display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:999px;background:${bg};color:${color}">${txt}</span>`;
const chips=[chip(`${items.length} MATERIALS`,'var(--text2)','rgba(122,154,184,.12)')];
if(!issuedPhase) chips.push(chip('AWAITING ISSUANCE','#1d4ed8','rgba(26,111,255,.12)'));
else{ chips.push(chip(`✓ ${matchCount} MATCH`,'#0a8f57','rgba(13,187,138,.16)'));
if(issueMismatch) chips.push(chip(`✗ ${issueMismatch} ISSUE DIFF`,'#c81e1e','rgba(229,62,62,.13)')); }
if(woMismatch) chips.push(chip(`⚠ ${woMismatch} WO DIFF`,'#9a6700','rgba(245,166,35,.18)'));
const statusLine=!issuedPhase
? 'Issued quantities populate after Issue for Production.'
: (issueMismatch>0
? `<b style="color:#c81e1e">${issueMismatch}</b> component(s) issued differently from plan${woMismatch?`, <b style="color:#9a6700">${woMismatch}</b> differ from the Work Order`:''} — review the highlighted rows.`
: (woMismatch>0
? `Issued matches plan; <b style="color:#9a6700">${woMismatch}</b> component(s) differ from the approved Work Order (amber ⚠).`
: 'Issued matches the approved BOM'+(woApproved?' and Work Order':'')+' on every component.'));
const barColor=!issuedPhase?'var(--blue)':(issueMismatch>0?'var(--red)':(woMismatch>0?'var(--amber)':'var(--green)'));
const header=`<div style="display:flex;align-items:flex-start;gap:10px;padding:11px 12px;border-bottom:1px solid var(--border);background:var(--surface3)">
<div style="width:3px;align-self:stretch;border-radius:3px;background:${barColor};flex-shrink:0"></div>
<div style="flex:1;min-width:0">
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:6px">${chips.join('')}</div>
<div style="font-size:11px;color:var(--text2);line-height:1.45">${statusLine}</div>
${woApproved?'':'<div style="font-size:10px;color:var(--text3);margin-top:4px">Work-Order comparison unavailable (no linked Work Order).</div>'}
</div>
</div>`;
const th='padding:7px 10px;color:var(--text3);font-size:8.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;border-bottom:1px solid var(--border2)';
const table=items.length?`<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
<thead><tr style="background:var(--surface2)">
<th style="${th};text-align:left">Item</th><th style="${th};text-align:right">BOM Planned</th><th style="${th};text-align:right">WO Approved</th><th style="${th};text-align:right">Issued</th><th style="${th};text-align:right">Status</th>
</tr></thead><tbody>${rows}</tbody></table></div>`
:'<div style="padding:16px;color:var(--text3);font-size:12px;text-align:center">No material components on this order.</div>';
const legend=`<div style="display:flex;gap:14px;flex-wrap:wrap;padding:8px 12px;border-top:1px solid var(--border);font-size:9px;color:var(--text3)">
<span><b style="color:#0a8f57">Match</b> issued = plan</span>
<span><b style="color:#c81e1e">Short/Over</b> issued ≠ plan</span>
<span><b style="color:#9a6700">WO Diff ⚠</b> plan ≠ Work Order</span>
</div>`;
return `<div style="border:1px solid var(--border2);border-radius:12px;overflow:hidden;background:var(--ink2)">${header}${table}${legend}</div>`;
}
function closeDetail(){q('#detail-modal').classList.remove('show');}
async function openDetail(p){
const modal=q('#detail-modal'),inner=q('#detail-modal-inner');
modal.classList.add('show');
inner.innerHTML=`<div class="modal-hd2"><div class="modal-hd2-t">Loading…</div><button class="modal-x" onclick="closeDetail()">✕</button></div><div class="modal-bd2" style="text-align:center;padding:40px"><span class="sp"></span></div>`;
try{
const verified=!!p.verifiedAt;
let sap=null,compHtml='';
if(p.sapAbsEntry){
try{
const r=await api('GET',`/sap/production-orders/${p.sapAbsEntry}?company=${encodeURIComponent(_co)}`);
sap=r.data||r;
const lines=sap.ProductionOrderLines||[];
const woApproved=await fetchWoApprovedMap(p);
compHtml=buildQaComparison(lines,woApproved);
}catch(e){compHtml=`<div style="color:var(--text3);font-size:12px;padding:10px">Could not load SAP line detail: ${esc(e.message)}</div>`;}
}
const dt=(l,v)=>`<div class="dt-row"><span class="dt-l">${esc(l)}</span><span class="dt-v">${v==null||v===''?'—':esc(String(v))}</span></div>`;
const header=sap||{};
inner.innerHTML=`
<div class="modal-hd2">
<div class="modal-hd2-t">${esc(p.itemCode||'')} <span style="color:var(--text3);font-weight:400">— ${esc(p.itemName||header.ProductDescription||'')}</span></div>
<button class="modal-x" onclick="closeDetail()">✕</button>
</div>
<div class="modal-bd2">
<div class="dt-grid">
${dt('SAP Doc #',p.sapDocNum||header.DocumentNumber)}
${dt('AbsEntry',p.sapAbsEntry)}
${dt('Work Order',p.woNo||(p.workOrderId?('#'+p.workOrderId):'—'))}
${dt('Batch Number',p.batchNumber)}
${dt('Planned Qty',p.plannedQty||header.PlannedQuantity)}
${dt('Completed Qty',header.CompletedQuantity)}
${dt('Warehouse',p.warehouse||header.Warehouse)}
${dt('FG Warehouse',p.fgWarehouse)}
${dt('MFG Date',p.mfgDate)}
${dt('EXP Date',p.expDate)}
${dt('Stage',p.currentStep||('#'+p.stage))}
${dt('Status',p.status)}
${dt('Created By',p.createdByName||p.createdBy)}
${dt('Created At',fmt(p.createdAt))}
</div>
<div class="sec-t">Material Issuance — BOM vs Work Order vs Issued</div>
${compHtml||'<div style="color:var(--text3);font-size:12px">Not linked to a SAP production order.</div>'}
${p.remarks?`<div class="sec-t">Remarks</div><div style="font-size:12px;color:var(--text2)">${esc(p.remarks)}</div>`:''}
${verified?`<div class="sec-t">Verification</div><div style="font-size:12px;color:var(--green)">Verified by <b>${esc(p.verifiedByName||p.verifiedBy)}</b> on ${esc(fmt(p.verifiedAt))}${p.verifiedRemarks?` — “${esc(p.verifiedRemarks)}”`:''}</div>`:''}
</div>
${!verified&&CAN_VERIFY?`<div class="modal-ft">
<input class="fi" id="md-rem" placeholder="Remarks (optional)" style="flex:1"/>
<button class="btn b-verify" id="md-vb" onclick="doVerify(${p.id},true)">✔ Verify This Production Order</button>
</div>`:''}
`;
}catch(e){inner.innerHTML=`<div class="modal-hd2"><div class="modal-hd2-t">Error</div><button class="modal-x" onclick="closeDetail()">✕</button></div><div class="modal-bd2" style="color:var(--red)">${esc(e.message)}</div>`;}
}
window.closeDetail=closeDetail;
function poCard(p){
const verified=!!p.verifiedAt;
const title=`${p.sapDocNum?('Doc #'+esc(p.sapDocNum)):('PO #'+p.id)}`;
const sub=`<span>${esc(p.itemCode||'')} ${esc(p.itemName||'')}</span>${p.plannedQty?`<span>·</span><span>Qty <b>${esc(p.plannedQty)}</b></span>`:''}${p.woNo?`<span>·</span><span>WO <b>${esc(p.woNo)}</b></span>`:''}${p.batchNumber?`<span>·</span><span>Batch <b>${esc(p.batchNumber)}</b></span>`:''}`;
const chips=`<span class="chip">Stage: ${esc(p.currentStep||('#'+p.stage))}</span><span class="chip">${esc(p.status||'')}</span>`;
const right=verified
? `<span class="pill p-ok">Verified</span><div class="vinfo">by <b>${esc(p.verifiedByName||p.verifiedBy)}</b><br/>${esc(fmt(p.verifiedAt))}</div>${p.verifiedRemarks?`<div style="font-size:10.5px;color:var(--text3)">“${esc(p.verifiedRemarks)}”</div>`:''}`
: (CAN_VERIFY
? `<button class="btn b-verify" data-view="${p.id}">View &amp; Verify</button>`
: `<button class="btn b-primary" data-view="${p.id}">View Details</button><span class="pill p-pend">Pending</span>`);
return `<div class="po" data-open="${p.id}">
<div class="po-main"><div class="po-t">${title}</div><div class="po-sub">${sub}</div><div class="chips">${chips}</div></div>
<div class="po-right">${right}</div>
</div>`;
}
function render(){
const app=document.getElementById('app');
if(!CAN_VIEW){app.innerHTML='<div class="card"><div class="card-bd empty-msg">You are not assigned the "Verify" permission. Ask an admin to grant Approval Steps → Production Order → Verify.</div></div>';return;}
const pending=_rows.filter(p=>!p.verifiedAt);
const done=_rows.filter(p=>p.verifiedAt);
const list=_tab==='pending'?pending:done;
app.innerHTML=`<div class="card">
<div class="card-hd"><span>✔ Verify Production Orders</span><span class="right">${_loading?'':`${pending.length} pending`}</span></div>
<div class="card-bd">
${CAN_VERIFY&&!_hasSig?`<div class="hint">You don't have a signature image on file — upload one under <b>Profile → My Signature</b> so it prints on the Work Order's "Verified By" column.</div>`:''}
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px;flex-wrap:wrap">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<button class="btn b-primary" id="btn-reload" style="height:34px">Reload</button>
</div>
<div class="tabs">
<button class="tab ${_tab==='pending'?'on':''}" data-tab="pending">Pending (${pending.length})</button>
<button class="tab ${_tab==='done'?'on':''}" data-tab="done">Verified (${done.length})</button>
</div>
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`:
!list.length?`<div class="empty-msg">${_tab==='pending'?'No issued production orders awaiting verification.':'Nothing verified yet.'}</div>`:
list.map(poCard).join('')}
</div></div>`;
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;load();});
q('#btn-reload')&&(q('#btn-reload').onclick=load);
document.querySelectorAll('.tab').forEach(b=>b.onclick=function(){_tab=this.dataset.tab;render();});
document.querySelectorAll('[data-open]').forEach(el=>el.onclick=function(){
const p=_rows.find(x=>x.id===parseInt(this.dataset.open));if(p)openDetail(p);
});
}
window.doVerify=doVerify;
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{const r=await api('GET','/auth/signature');_hasSig=!!(r&&r.signature);}catch(_e){}
if(CAN_VIEW)load();else render();
}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Verify Work Order — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--mono);font-weight:700;font-size:14px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:12px;max-width:1200px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}select.fi{cursor:pointer}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer}.btn:disabled{opacity:.45;cursor:not-allowed}
.b-primary{background:var(--blue);color:#fff}.b-verify{background:var(--green);color:#04231a}.b-verify:hover:not(:disabled){filter:brightness(1.08)}
.b-receive{background:var(--teal);color:#04231f}.b-receive:hover:not(:disabled){filter:brightness(1.08)}
.hint{font-size:11px;color:var(--amber);background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.22);border-radius:8px;padding:8px 11px;margin-bottom:12px}
.po{display:flex;gap:14px;align-items:flex-start;padding:12px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;margin-bottom:8px}
.po-main{flex:1;min-width:0}
.po-t{font-size:14px;font-weight:700;color:var(--text)}
.po-sub{font-size:11px;color:var(--text3);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap}
.po-sub b{color:var(--text2);font-family:var(--mono)}
.po-right{display:flex;flex-direction:column;gap:6px;align-items:flex-end;min-width:230px}
.po-right .fi{height:32px}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
.chip{font-size:9px;font-weight:700;color:var(--text2);background:var(--surface3);border:1px solid var(--border2);border-radius:6px;padding:2px 8px;text-transform:uppercase;letter-spacing:.3px}
.pill{font-size:9px;font-weight:800;padding:2px 8px;border-radius:20px;text-transform:uppercase}
.p-ok{background:rgba(13,187,138,.14);color:#0aa457}.p-pend{background:rgba(245,166,35,.14);color:#c78a1a}
.vinfo{font-size:11px;color:var(--green);text-align:right}
.vinfo b{color:var(--text2)}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.tabs{display:flex;gap:6px;margin-bottom:12px}
.tab{padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);cursor:pointer}
.tab.on{background:var(--blue);color:#fff;border-color:var(--blue)}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
.po{cursor:pointer;transition:border-color .15s}
.po:hover{border-color:var(--blue)}
.modal-ov{display:none;position:fixed;inset:0;z-index:500;background:rgba(4,8,14,.7);backdrop-filter:blur(3px);align-items:flex-start;justify-content:center;padding:26px 12px;overflow:auto}
.modal-ov.show{display:flex}
.modal-w{width:100%;max-width:900px;background:var(--surface);border:1px solid var(--border2);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
.modal-hd2{display:flex;align-items:center;gap:10px;padding:13px 16px;background:var(--surface3);border-bottom:1px solid var(--border)}
.modal-hd2-t{font-size:14px;font-weight:700}
.modal-x{margin-left:auto;width:28px;height:28px;border-radius:8px;border:1px solid var(--border2);background:none;color:var(--text2);cursor:pointer}
.modal-x:hover{border-color:var(--red);color:var(--red)}
.modal-bd2{padding:14px 16px;max-height:calc(100vh - 170px);overflow:auto}
.dt-grid{display:grid;grid-template-columns:1fr 1fr;gap:2px 18px;font-size:12px;margin-bottom:14px}
.dt-row{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px solid var(--border)}
.dt-l{color:var(--text3);font-size:10.5px;text-transform:uppercase;letter-spacing:.3px}
.dt-v{color:var(--text);font-weight:600;text-align:right}
.sec-t{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin:12px 0 8px}
.modal-ft{display:flex;gap:8px;padding:13px 16px;border-top:1px solid var(--border);background:var(--surface3);align-items:center}
.wo-th{text-align:left;padding:6px 8px;font-size:8.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);font-weight:700;border-bottom:1px solid var(--border2);white-space:nowrap}
.wo-td{padding:6px 8px;border-bottom:1px solid var(--border);font-size:11px;color:var(--text2)}
.wo-td.mono{font-family:var(--mono);color:var(--text)}
.btn-mini{padding:4px 9px;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--teal);font-size:10px;font-weight:700;cursor:pointer;white-space:nowrap}
.btn-mini:hover:not(:disabled){border-color:var(--teal);background:rgba(6,182,212,.1)}
.btn-mini:disabled{opacity:.5;cursor:not-allowed}
.fi-mini{width:100%;min-width:76px;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:10.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none}
.fi-mini:focus{border-color:var(--blue)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/verify-work-order" class="nav-link active">Verify Work Order</a></div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<div class="modal-ov" id="detail-modal"><div class="modal-w" id="detail-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}
if(!_tok)location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();location.href='/';};
// This screen's permission gates — each material ROW is stamped/edited
// independently (different items can be issued/received/verified on
// different days by different people), so each of the three sign-offs plus
// the Weighing Balance ID/AR No. edit right is its OWN approval step:
// work_order:issue edit → fill Weighing Balance ID / AR No. on a row
// work_order:issue approve → stamp "Issued By/Date" on a row
// work_order:receive approve → stamp "Received By" on a row
// work_order:verify approve → stamp "Verified By" on a row
// 'production_order:verify' is kept (currently unused) for possible future
// use — the server still accepts it on the legacy order-level endpoints.
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){ if(typeof s==='string'){if(s===fullKey)return true;} else if(s&&s.step===fullKey){return Array.isArray(s.perms)&&s.perms.includes(perm);} }
return false;
}
// True if ANY perm at all is assigned on this step — mirrors the server's
// hasStepAssigned(), used so "Mark Issued" also works for whoever is
// assigned Issue for Production (production_order:issuance), not only
// someone separately granted work_order:issue.
function hasStepAssignedClient(fullKey){
return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));
}
const CAN_VIEW=hasStepPerm('work_order:verify','view')||hasStepPerm('work_order:receive','view')||hasStepPerm('work_order:issue','view')||hasStepAssignedClient('production_order:issuance');
const CAN_EDIT_FIELDS=hasStepPerm('work_order:issue','edit');
const CAN_MARK_ISSUED=hasStepPerm('work_order:issue','approve')||hasStepAssignedClient('production_order:issuance');
const CAN_MARK_RECEIVED=hasStepPerm('work_order:receive','approve');
const CAN_MARK_VERIFIED=hasStepPerm('work_order:verify','approve');
let _co=window.__DEFAULT_COMPANY__;let _companies=[{value:_co,label:_co}];
let _rows=[],_loading=false,_hasSig=false,_curPO=null;
// This user's allowed Item Groups for issuing (Admin → user → Issue Items).
// Empty = no restriction. Same rule the real SAP issuance enforces.
let _issueGroups=[];
let _balanceIds=[]; // Admin → System Settings — allowed Weighing Balance IDs; empty = free text
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m,t='ok'){const c=q('#toast-c');const e=document.createElement('div');e.className='toast t-'+t;e.textContent=m;c.appendChild(e);setTimeout(()=>e.remove(),4200);}
function fmt(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';const p=n=>String(n).padStart(2,'0');return `${p(d.getDate())}-${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;}
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
async function api(m,p,b){
const h={'Content-Type':'application/json'};if(_tok)h.Authorization='Bearer '+_tok;
const o={method:m,headers:h};if(b)o.body=JSON.stringify(b);
const r=await fetch(BE+p,o);if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));return d;
}
async function load(){
_loading=true;render();
try{const r=await api('GET',`/production-orders/for-verification?company=${encodeURIComponent(_co)}`);_rows=r.data||[];}
catch(e){toast(e.message,'err');_rows=[];}
_loading=false;render();
}
// One-click per-row stamp — no typing, current logged-in user + right now.
async function markRow(woId,section,idx,which,btn){
if(btn){btn.disabled=true;btn.innerHTML='<span class="sp"></span>';}
try{
await api('POST',`/work-orders/${woId}/row/${section}/${idx}/mark`,{which});
await refreshDetail();
}catch(e){toast(e.message,'err');if(btn){btn.disabled=false;}}
}
// Per-row Weighing Balance ID / AR No. edit — saved on blur/Enter.
async function saveField(woId,section,idx,field,value,input){
try{
await api('PATCH',`/work-orders/${woId}/row/${section}/${idx}`,{[field]:value});
if(input){input.style.borderColor='var(--green)';setTimeout(()=>{if(input)input.style.borderColor='';},900);}
}catch(e){toast(e.message,'err');}
}
async function refreshDetail(){ if(_curPO) await openDetail(_curPO); }
// ── Full Work Order detail (same layout as the Work Order page's own popup:
// header fields + Raw Material/Packing Material tables, or a single
// Components table when there's no real raw material) ──────────────────────
function num3wo(v){ if(v==null||v==='')return '—'; const n=Number(v); if(!isFinite(n)||n===0)return '—'; return String(Math.round(n*1000)/1000); }
function issSigDisp(sig){ return sig&&sig.name ? `${esc(sig.name)}<div style="font-size:9.5px;color:var(--text3)">${esc(fmt(sig.at))}</div>` : '<span style="color:var(--text3)">—</span>'; }
function rowSig(row,which){ const at=row[which+'At']; if(!at) return null; return {name:row[which+'ByName']||row[which+'By'], at}; }
// Enforced order per row: Issued → Received → Verified (matches the server's
// ROW_STAMP_PREREQ check) — the preceding stamp must exist first.
const ROW_STAMP_PREREQ={received:'issued',verified:'received'};
// A row's Issued/Received/Verified cell: signer+date if already stamped, a
// one-click "Mark …" button if this user may stamp it AND its prerequisite
// stamp already exists, a "waiting on …" note if not, else a dash.
function stampCell(row,canMark,which,label){
const sig=rowSig(row,which);
if(sig)return issSigDisp(sig);
const prereq=ROW_STAMP_PREREQ[which];
if(prereq&&!row[prereq+'At'])return `<span style="color:var(--text3);font-size:9.5px">Waiting on ${prereq}</span>`;
if(canMark)return `<button class="btn-mini" onclick="markRow(${row.__woId},'${row.__section}',${row.__idx},'${which}',this)">${esc(label)}</button>`;
return '<span style="color:var(--text3)">—</span>';
}
// Weighing Balance ID / AR No. — editable inline by whoever holds 'edit' on
// work_order:issue (the person physically issuing knows these at that
// moment); read-only text for everyone else.
function fieldInput(row,field,placeholder){
if(!CAN_EDIT_FIELDS)return esc(row[field]||'');
if(field==='weighingBalanceId'&&_balanceIds.length){
const opts=_balanceIds.map(id=>`<option value="${esc(id)}" ${row[field]===id?'selected':''}>${esc(id)}</option>`).join('');
return `<select class="fi-mini" onchange="saveField(${row.__woId},'${row.__section}',${row.__idx},'${field}',this.value,this)"><option value="">— Select —</option>${opts}</select>`;
}
return `<input class="fi-mini" value="${esc(row[field]||'')}" placeholder="${esc(placeholder)}" onchange="saveField(${row.__woId},'${row.__section}',${row.__idx},'${field}',this.value,this)"/>`;
}
// This user's Item Group restriction (Admin → user → Issue Items) — same
// rule the real SAP issuance enforces. Empty _issueGroups = no restriction.
function itemGroupAllowed(itemCode,groupMap){
if(!_issueGroups.length)return true;
const grp=groupMap&&groupMap[(itemCode||'').trim()];
return grp!=null&&_issueGroups.includes(String(grp));
}
const ISS_THS='<th class="wo-th">Qty Issued</th><th class="wo-th">Issued By /Date</th><th class="wo-th">Received By</th><th class="wo-th">Verified By</th>';
function issCells(row,iss,groupMap){
// Raw Material rows are never SAP Production Order lines themselves — they're
// the exploded recipe of a Solution/SFG item, which IS the PO line — so
// their Qty Issued can't be read live off iss.qtyByItem like Packing
// Material's can. It's prorated and PERSISTED on the row itself server-side
// (see routes/sap.js's /issue-production), same as work-order-print.html
// and workOrderPdf.js already do.
const qtyVal=row.__section==='raw'?row.qtyIssued:(iss.qtyByItem&&iss.qtyByItem[(row.itemCode||'').trim()]);
const qty=`<td class="wo-td">${num3wo(qtyVal)}</td>`;
const canIssue=CAN_MARK_ISSUED&&itemGroupAllowed(row.itemCode,groupMap);
return qty+`<td class="wo-td">${stampCell(row,canIssue,'issued','Mark Issued')}</td><td class="wo-td">${stampCell(row,CAN_MARK_RECEIVED,'received','Mark Received')}</td><td class="wo-td">${stampCell(row,CAN_MARK_VERIFIED,'verified','Mark Verified')}</td>`;
}
function buildWoMaterials(w,iss,groupMap){
iss=iss||{qtyByItem:{}};
const woId=w.id;
// Tag each row with its own (section,index) so the inline handlers above
// know exactly which row to patch/stamp.
const rawRows=(w.rawMaterials||[]).map((r,idx)=>({...r,__woId:woId,__section:'raw',__idx:idx}));
const packRows=(w.packingMaterials||[]).map((r,idx)=>({...r,__woId:woId,__section:'pack',__idx:idx}));
const rawGroupMap=new Map();
// solBatchSize can legitimately be 0 — check null/undefined, not truthiness.
rawRows.forEach(r=>{const k=r.solCode||'';if(!rawGroupMap.has(k))rawGroupMap.set(k,{name:r.solName||'',batchSize:r.solBatchSize!=null?r.solBatchSize:'',rows:[]});rawGroupMap.get(k).rows.push(r);});
const raw=[...rawGroupMap.values()].map(g=>`
<div style="font-size:11.5px;font-weight:700;color:var(--text2);margin:8px 0 3px">${g.name?esc(g.name):'Raw Materials'}${g.batchSize!==''?` · Batch Size ${esc(g.batchSize)} Ltr`:''}</div>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
<thead><tr style="background:var(--surface2)"><th class="wo-th">Item Code</th><th class="wo-th">Raw Materials</th><th class="wo-th">Spec</th><th class="wo-th">Qty Req./100 ml</th><th class="wo-th">UOM</th><th class="wo-th">Ovg.</th><th class="wo-th">Qty Req.</th><th class="wo-th">Weighing Balance ID</th><th class="wo-th">AR No.</th>${ISS_THS}</tr></thead>
<tbody>${g.rows.map(r=>`<tr><td class="wo-td mono">${esc(r.itemCode)}</td><td class="wo-td">${esc(r.rawMaterial)}</td><td class="wo-td">${esc(r.spec)}</td><td class="wo-td">${esc(r.stdQty)}</td><td class="wo-td">${esc(r.uom)}</td><td class="wo-td">${esc(r.ovg)}</td><td class="wo-td">${esc(r.qtyReq)}</td><td class="wo-td">${fieldInput(r,'weighingBalanceId','Balance ID')}</td><td class="wo-td">${fieldInput(r,'arNo','AR No.')}</td>${issCells(r,iss,groupMap)}</tr>`).join('')}</tbody></table></div>`).join('');
const singleTableMode=!(w.rawMaterials&&w.rawMaterials.length);
const pack=packRows.map(r=>{
const stdDisp=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
const qtyDisp=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
const arTd=`<td class="wo-td">${fieldInput(r,'arNo','AR No.')}</td>`;
return singleTableMode
?`<tr><td class="wo-td mono">${esc(r.itemCode)}</td><td class="wo-td">${esc(r.packingMaterial)}</td><td class="wo-td">${stdDisp}</td><td class="wo-td">${esc(r.ovgPercent)}</td><td class="wo-td">${qtyDisp}</td>${arTd}${issCells(r,iss,groupMap)}</tr>`
:`<tr><td class="wo-td mono">${esc(r.itemCode)}</td><td class="wo-td">${esc(r.packingMaterial)}</td><td class="wo-td">${esc(r.artworkNo)}</td><td class="wo-td">${stdDisp}</td><td class="wo-td">${esc(r.ovgPercent)}</td><td class="wo-td">${qtyDisp}</td>${arTd}${issCells(r,iss,groupMap)}</tr>`;
}).join('');
if(singleTableMode){
return `<div class="sec-t">Components</div>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
<thead><tr style="background:var(--surface2)"><th class="wo-th">Item Code</th><th class="wo-th">Components</th><th class="wo-th">Std. Qty/Unit</th><th class="wo-th">Ovg.</th><th class="wo-th">Qty Req.</th><th class="wo-th">AR No.</th>${ISS_THS}</tr></thead>
<tbody>${pack||'<tr><td colspan="10" style="color:var(--text3);padding:10px">—</td></tr>'}</tbody></table></div>`;
}
return `<div class="sec-t">Raw Material</div>
${raw||'<div style="color:var(--text3);font-size:12px">—</div>'}
<div class="sec-t">Packing Material</div>
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
<thead><tr style="background:var(--surface2)"><th class="wo-th">Item Code</th><th class="wo-th">Packing Materials</th><th class="wo-th">Artwork No.</th><th class="wo-th">Std. Qty/Unit</th><th class="wo-th">Ovg.%</th><th class="wo-th">Qty Req.(Units)</th><th class="wo-th">AR No.</th>${ISS_THS}</tr></thead>
<tbody>${pack||'<tr><td colspan="11" style="color:var(--text3);padding:10px">—</td></tr>'}</tbody></table></div>`;
}
function closeDetail(){q('#detail-modal').classList.remove('show');_curPO=null;}
// `row` is a slim Work-Order-shaped record from GET /production-orders/for-verification
// (row.id IS the Work Order's own id — this screen lists APPROVED Work
// Orders directly now, independent of their linked Production Order's own
// stage; row.po carries that Production Order's info ONLY for display, when
// one happens to exist yet).
async function openDetail(row){
_curPO=row;
const modal=q('#detail-modal'),inner=q('#detail-modal-inner');
modal.classList.add('show');
inner.innerHTML=`<div class="modal-hd2"><div class="modal-hd2-t">Loading…</div><button class="modal-x" onclick="closeDetail()">✕</button></div><div class="modal-bd2" style="text-align:center;padding:40px"><span class="sp"></span></div>`;
try{
let w=null,woErr='';
try{const r=await api('GET',`/work-orders/${row.id}`);w=r.data||r;}
catch(e){woErr=e.message;}
// Qty issued stays SAP-derived (linked Production Order's live issued
// qty, if any exists yet); Issued/Received/Verified are per-row stamps
// read straight off each material row (see buildWoMaterials). Keyed
// purely by Work Order id — computeIssuance() on the server already
// handles "no Production Order yet" gracefully (empty qtyByItem).
let iss={qtyByItem:{}};
try{const r=await api('GET',`/work-orders/${row.id}/issuance-info?company=${encodeURIComponent(_co)}`);iss=r.data||iss;}catch(_e){}
// Item group per item code (Admin → user → Issue Items restriction) — only
// needed when this user actually has a restriction, to hide "Mark Issued"
// on items outside their allowed groups.
let groupMap={};
if(w&&_issueGroups.length){
const codes=[...new Set([...(w.rawMaterials||[]),...(w.packingMaterials||[])].map(r=>r.itemCode).filter(Boolean))];
if(codes.length){ try{const r=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);groupMap=r.data||{};}catch(_e){} }
}
const dt=(l,v)=>`<div class="dt-row"><span class="dt-l">${esc(l)}</span><span class="dt-v">${v==null||v===''?'—':esc(String(v))}</span></div>`;
const titleName=w?[w.productName,w.productDesc].filter(Boolean).join(' — '):(row.itemName||'');
const woBody = w
? `<div class="dt-grid">
${dt('Work Order #',w.woNo)}
${dt('Reference',w.reference)}
${dt('Generic Name',w.genericName)}
${dt('Batch Number',w.batchNumber)}
${dt('Batch Size',w.batchSize)}
${dt('Total Units',w.totalUnits)}
${dt('MFG. Date',w.mfgDate)}
${dt('EXP. Date',w.expDate)}
${dt('Pack Size',w.packSize)}
${dt('Type',w.type)}
${dt('Market',w.market)}
${dt('WO Status',w.status)}
</div>
${buildWoMaterials(w,iss,groupMap)}`
: `<div style="color:var(--text3);font-size:12px;padding:10px">${woErr?esc('Could not load this Work Order: '+woErr):'Could not load this Work Order.'}</div>`;
inner.innerHTML=`
<div class="modal-hd2">
<div class="modal-hd2-t">${esc(row.itemCode||'')} <span style="color:var(--text3);font-weight:400">— ${esc(titleName)}</span></div>
<button class="modal-x" onclick="closeDetail()">✕</button>
</div>
<div class="modal-bd2">
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">${row.po?`Linked Production Order: <b style="color:var(--text2)">${esc(row.po.sapDocNum?('Doc #'+row.po.sapDocNum):('#'+row.po.id))}</b> · Stage <b style="color:var(--text2)">${esc(row.po.currentStep||('#'+row.po.stage))}</b>`:'No Production Order created for this Work Order yet.'}</div>
${woBody}
</div>
`;
}catch(e){inner.innerHTML=`<div class="modal-hd2"><div class="modal-hd2-t">Error</div><button class="modal-x" onclick="closeDetail()">✕</button></div><div class="modal-bd2" style="color:var(--red)">${esc(e.message)}</div>`;}
}
window.closeDetail=closeDetail;
window.markRow=markRow;
window.saveField=saveField;
// Order-level status is no longer a single yes/no — each material row has
// its own Issued/Received/Verified state — so the card is just a summary +
// an entry point into the per-row table. Every APPROVED Work Order shows
// here regardless of its linked Production Order's own stage (or whether
// one even exists yet) — the "Stage" chip is informational only.
function poCard(w){
const title=esc(w.woNo||('WO #'+w.id));
const sub=`<span>${esc(w.itemCode||'')} ${esc(w.itemName||'')}</span>${w.plannedQty?`<span>·</span><span>Qty <b>${esc(w.plannedQty)}</b></span>`:''}${w.batchNumber?`<span>·</span><span>Batch <b>${esc(w.batchNumber)}</b></span>`:''}`;
const poChip=w.po?`<span class="chip">${esc(w.po.sapDocNum?('Doc #'+w.po.sapDocNum):('PO #'+w.po.id))} · ${esc(w.po.currentStep||('#'+w.po.stage))}</span>`:`<span class="chip">No Production Order yet</span>`;
const chips=`<span class="chip">${esc(w.status||'')}</span>${poChip}`;
return `<div class="po" data-open="${w.id}">
<div class="po-main"><div class="po-t">${title}</div><div class="po-sub">${sub}</div><div class="chips">${chips}</div></div>
<div class="po-right"><button class="btn b-primary" data-view="${w.id}">View Materials</button></div>
</div>`;
}
function render(){
const app=document.getElementById('app');
if(!CAN_VIEW){app.innerHTML='<div class="card"><div class="card-bd empty-msg">You are not assigned any Work Order sign-off permission. Ask an admin to grant Approval Steps → Work Order → Issued By / Received By / Verify Work Order.</div></div>';return;}
app.innerHTML=`<div class="card">
<div class="card-hd"><span>✔ Verify Work Order</span><span class="right">${_loading?'':`${_rows.length} approved`}</span></div>
<div class="card-bd">
${(CAN_MARK_ISSUED||CAN_MARK_RECEIVED||CAN_MARK_VERIFIED)&&!_hasSig?`<div class="hint">You don't have a signature image on file — upload one under <b>Profile → My Signature</b> so it prints on the Work Order's "Issued/Received/Verified By" columns.</div>`:''}
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px;flex-wrap:wrap">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<button class="btn b-primary" id="btn-reload" style="height:34px">Reload</button>
</div>
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`:
!_rows.length?`<div class="empty-msg">No approved work orders yet.</div>`:
_rows.map(poCard).join('')}
</div></div>`;
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;load();});
q('#btn-reload')&&(q('#btn-reload').onclick=load);
document.querySelectorAll('[data-open]').forEach(el=>el.onclick=function(){
const p=_rows.find(x=>x.id===parseInt(this.dataset.open));if(p)openDetail(p);
});
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{const r=await api('GET','/auth/signature');_hasSig=!!(r&&r.signature);}catch(_e){}
try{const pr=await api('GET','/auth/profile');_issueGroups=Array.isArray(pr?.user?.issueItemGroups)?pr.user.issueItemGroups.map(String):[];}catch(_e){}
try{const cfg=await api('GET','/config');if(Array.isArray(cfg?.woWeighingBalanceIds))_balanceIds=cfg.woWeighingBalanceIds;}catch(_e){}
if(CAN_VIEW)load();else render();
}
init();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+308
View File
@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>Work Order Header Profiles — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="/lucide.min.js?v=399"></script>
<style>
:root{
--blue:#2563eb;--blue2:#1d4ed8;--blue-light:#eff6ff;
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
--bg:#f9fafb;--surface:#fff;--surface2:#f9fafb;--surface3:#f3f4f6;--border:#e5e7eb;--border2:#d1d5db;
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',ui-monospace,monospace;
--red:#dc2626;--green:#16a34a;--amber:#d97706;--radius:10px;
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:24px;max-width:980px;margin:0 auto}
.page-head{margin-bottom:18px}
.page-title{font-size:22px;font-weight:800;color:var(--text);display:flex;align-items:center;gap:10px}
.page-sub{font-size:13px;color:var(--text3);margin-top:3px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:16px}
.card-hd{padding:14px 18px;border-bottom:1px solid var(--border);font-size:14px;font-weight:700;background:var(--surface2);border-radius:10px 10px 0 0;display:flex;align-items:center;gap:8px}
.card-bd{padding:18px}
.empty-msg{padding:30px;text-align:center;color:var(--text3);font-size:13px}
.prof-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.prof-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:14px 16px;cursor:pointer;transition:all .15s}
.prof-card:hover{border-color:var(--blue);box-shadow:0 6px 20px rgba(37,99,235,.14);transform:translateY(-2px)}
.prof-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.prof-name{font-size:14px;font-weight:700}
.badge{font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.badge-default{background:#e6f5e6;color:#16a34a;border:1px solid #bbf0bb}
.prof-co{font-size:12px;color:var(--text2);margin-top:8px;font-weight:600}
.prof-meta{font-size:11px;color:var(--text3);margin-top:6px}
.btn{padding:9px 18px;border-radius:9px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:7px;justify-content:center}
.btn:disabled{opacity:.5;cursor:not-allowed}
.b-primary{background:var(--blue);color:#fff;box-shadow:var(--shadow)}
.b-primary:hover:not(:disabled){background:var(--blue2)}
.b-cancel{background:var(--surface);color:var(--text2);border:1px solid var(--border)}
.b-cancel:hover{background:var(--surface2)}
.b-danger{background:rgba(220,38,38,.08);color:var(--red);border:1px solid rgba(220,38,38,.25)}
.b-danger:hover{background:rgba(220,38,38,.16)}
.b-amber{background:rgba(217,119,6,.08);color:var(--amber);border:1px solid rgba(217,119,6,.25)}
.fg{display:flex;flex-direction:column;gap:6px}
.fl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text3)}
.fi{width:100%;padding:9px 12px;border:1.5px solid var(--border);border-radius:8px;font-size:13px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none;height:38px}
.fi:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}
.chk-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;max-height:260px;overflow-y:auto;padding:2px}
.chk-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1.5px solid var(--border);border-radius:8px;background:var(--surface2);cursor:pointer;font-size:12px;font-weight:600;color:var(--text2)}
.chk-item:has(input:checked){border-color:var(--blue);background:var(--blue-light);color:var(--blue2)}
.chk-item input{accent-color:var(--blue)}
.footer-row{display:flex;gap:10px;justify-content:flex-end;margin-top:20px;padding-top:16px;border-top:1px solid var(--border)}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:20px;overflow-y:auto}
.modal-bg.show{display:flex}
.modal{background:var(--surface);border-radius:14px;max-width:680px;width:100%;margin-top:30px;box-shadow:0 20px 60px rgba(0,0,0,.25)}
.modal-hd{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;font-size:15px;font-weight:700}
.modal-close{margin-left:auto;background:none;border:none;cursor:pointer;color:var(--text3);padding:4px}
.modal-bd{padding:20px}
.toast-c{position:fixed;top:16px;right:16px;z-index:999;display:flex;flex-direction:column;gap:8px}
.toast{padding:10px 16px;border-radius:9px;font-size:13px;font-weight:600;box-shadow:var(--shadow);color:#fff;background:var(--text)}
.toast.t-err{background:var(--red)}
.toast.t-ok{background:var(--green)}
</style>
</head>
<body>
<div id="app"></div>
<div class="toast-c" id="toast-c"></div>
<div class="modal-bg" id="prof-modal"><div class="modal" id="prof-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null;
const BACKEND='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch{}})();
if(!_tok)window.location.href='/';
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
const CAN_EDIT=hasStepPerm('work_order:approved_mgr_qa','edit');
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}];
let _profiles=[],_loading=true;
let _itemGroups=[],_itemGroupsLoading=false;
let _editing=null; // profile being edited in the modal, or a blank one for "new"
let _modalOpen=false;
let _saving=false;
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BACKEND+p,o);d=await r.json();}catch(e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=q('#toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=msg;tc.appendChild(el);setTimeout(()=>el.remove(),4000);}
function blankProfile(){return{name:'',itemGroups:[],companyName:'',companyAddress:'',formNo:'',effectiveDate:'',reviewDate:'',logo:''};}
async function loadProfiles(){
_loading=true;render();
try{const r=await api('GET','/wo-header-profiles');_profiles=r.data||[];}
catch(e){toast(e.message,'err');_profiles=[];}
_loading=false;render();
}
async function loadItemGroups(){
if(_itemGroups.length||_itemGroupsLoading)return;
_itemGroupsLoading=true;
try{const r=await api('GET',`/sap/lookup/item-groups?company=${encodeURIComponent(_co)}`);_itemGroups=(r.data||[]).map(g=>({code:String(g.code),name:g.name||String(g.code)}));}
catch(e){toast('Could not load SAP item groups: '+e.message,'err');}
_itemGroupsLoading=false;
if(_modalOpen)renderModal();
}
function render(){
const app=q('#app');
app.innerHTML=`<main>
<div class="page-head">
<div class="page-title"><i data-lucide="file-badge-2"></i> Work Order Header Profiles</div>
<div class="page-sub">Company details, FORM NO. and logo printed at the top of the Work Order PDF — one profile per product group (Blood Bag, CAPD, Accessories…), plus a Default fallback.</div>
</div>
<div id="body"></div>
</main>`;
q('#body').innerHTML = _loading
? `<div class="card"><div class="empty-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span> Loading…</div></div>`
: renderList();
wireList();
renderModal();
if(window.lucide)lucide.createIcons();
}
function renderList(){
if(!_profiles.length)return `<div class="card"><div class="empty-msg">No header profiles yet.${CAN_EDIT?' Click "+ New Profile" to create the first one.':''}</div></div>`;
const grid=`<div class="prof-grid">${_profiles.map(p=>`
<div class="prof-card" data-open="${p.id}">
<div class="prof-top">
<span class="prof-name">${esc(p.name)}</span>
${p.isDefault?'<span class="badge badge-default">Default</span>':''}
</div>
<div class="prof-co">${esc(p.companyName||'—')}</div>
<div class="prof-meta">${esc(p.formNo||'No FORM NO.')}</div>
<div class="prof-meta">${p.itemGroups.length?`${p.itemGroups.length} item group(s) mapped`:'No item groups mapped (only used if Default)'}</div>
</div>`).join('')}</div>`;
return `<div class="card"><div class="card-hd">Profiles<span style="margin-left:auto;font-size:11px;color:var(--text3);font-weight:400">${_profiles.length} total</span></div>
<div class="card-bd">${grid}
${CAN_EDIT?`<div style="margin-top:14px"><button class="btn b-primary" id="btn-new-profile" style="width:100%">+ New Profile</button></div>`:''}
</div></div>`;
}
function wireList(){
document.querySelectorAll('[data-open]').forEach(c=>c.onclick=()=>openEdit(parseInt(c.dataset.open)));
if(q('#btn-new-profile'))q('#btn-new-profile').onclick=()=>openEdit(null);
}
function openEdit(id){
if(!CAN_EDIT && id==null)return;
const p=id==null?blankProfile():{...(_profiles.find(x=>x.id===id)||blankProfile())};
_editing=p;_editing._id=id;
_modalOpen=true;
loadItemGroups();
renderModal();
}
function closeModal(){_modalOpen=false;_editing=null;renderModal();}
function renderModal(){
const bg=q('#prof-modal'),inner=q('#prof-modal-inner');
if(!bg||!inner)return;
if(!_modalOpen||!_editing){bg.classList.remove('show');return;}
bg.classList.add('show');
const p=_editing;
const isNew=p._id==null;
const readOnly=!CAN_EDIT;
const groupsHtml=_itemGroupsLoading
? `<div style="font-size:12px;color:var(--text3);padding:8px">Loading SAP item groups…</div>`
: _itemGroups.length
? `<div class="chk-grid">${_itemGroups.map(g=>`<label class="chk-item"><input type="checkbox" data-grp="${esc(g.code)}" ${p.itemGroups.includes(g.code)?'checked':''} ${readOnly?'disabled':''}/> ${esc(g.name)}</label>`).join('')}</div>`
: `<div style="font-size:12px;color:var(--text3);padding:8px">No item groups found for ${esc(_co)}.</div>`;
inner.innerHTML=`
<div class="modal-hd">${isNew?'+ New':'Edit'} Header Profile<button class="modal-close" id="pm-x"><i data-lucide="x" style="width:16px;height:16px"></i></button></div>
<div class="modal-bd">
<div class="grid2">
<div class="fg"><label class="fl">Profile Name *</label><input class="fi" id="pm-name" value="${esc(p.name)}" placeholder="e.g. Blood Bag" ${readOnly?'readonly':''}/></div>
<div class="fg"><label class="fl">Company (for item group list)</label><select class="fi" id="pm-co"></select></div>
</div>
<div class="grid2">
<div class="fg"><label class="fl">Company Name</label><input class="fi" id="pm-coname" value="${esc(p.companyName)}" ${readOnly?'readonly':''}/></div>
<div class="fg"><label class="fl">Company Address</label><input class="fi" id="pm-coaddr" value="${esc(p.companyAddress)}" ${readOnly?'readonly':''}/></div>
</div>
<div class="grid2">
<div class="fg"><label class="fl">FORM NO.</label><input class="fi" id="pm-formno" value="${esc(p.formNo)}" style="font-family:var(--mono)" ${readOnly?'readonly':''}/></div>
<div class="fg"></div>
</div>
<div class="grid2">
<div class="fg"><label class="fl">Effective Date</label><input class="fi" id="pm-effdate" value="${esc(p.effectiveDate)}" placeholder="DD-MM-YYYY" style="font-family:var(--mono)" ${readOnly?'readonly':''}/></div>
<div class="fg"><label class="fl">Review Date</label><input class="fi" id="pm-revdate" value="${esc(p.reviewDate)}" placeholder="DD-MM-YYYY" style="font-family:var(--mono)" ${readOnly?'readonly':''}/></div>
</div>
<div class="fg" style="margin-bottom:14px">
<label class="fl">Logo</label>
<div style="display:flex;align-items:center;gap:10px">
<img id="pm-logo-prev" src="${esc(p.logo||'')}" alt="" style="height:40px;max-width:120px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:#fff;${p.logo?'':'display:none'}"/>
${readOnly?'':`<input type="file" id="pm-logo-file" accept="image/*" style="font-size:12px"/><button type="button" class="btn b-cancel" id="pm-logo-clear" style="padding:6px 12px;font-size:11px">Remove</button>`}
</div>
</div>
<div class="fg">
<label class="fl">SAP Item Groups mapped to this profile</label>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px">Any Work Order whose product's SAP item group is checked here uses this profile. ${p.isDefault?'This profile is currently the Default — it also applies to any item group not mapped to another profile.':''}</div>
${groupsHtml}
</div>
<div class="footer-row">
${(!isNew&&!p.isDefault&&CAN_EDIT)?`<button class="btn b-amber" id="pm-set-default">Set as Default</button>`:''}
${(!isNew&&!p.isDefault&&CAN_EDIT)?`<button class="btn b-danger" id="pm-delete">Delete</button>`:''}
<span style="flex:1"></span>
<button class="btn b-cancel" id="pm-cancel">${readOnly?'Close':'Cancel'}</button>
${CAN_EDIT?`<button class="btn b-primary" id="pm-save" ${_saving?'disabled':''}>${_saving?'<span class="sp"></span> Saving…':'Save'}</button>`:''}
</div>
</div>`;
const coSel=q('#pm-co');
if(coSel){
coSel.innerHTML=_companies.map(c=>`<option value="${esc(c.value)}" ${c.value===_co?'selected':''}>${esc(c.label)}</option>`).join('');
coSel.onchange=function(){_co=this.value;_itemGroups=[];loadItemGroups();};
}
if(window.lucide)lucide.createIcons();
wireModal();
}
function wireModal(){
const p=_editing;if(!p)return;
if(q('#pm-x'))q('#pm-x').onclick=closeModal;
if(q('#pm-cancel'))q('#pm-cancel').onclick=closeModal;
if(q('#pm-name'))q('#pm-name').oninput=function(){p.name=this.value;};
if(q('#pm-coname'))q('#pm-coname').oninput=function(){p.companyName=this.value;};
if(q('#pm-coaddr'))q('#pm-coaddr').oninput=function(){p.companyAddress=this.value;};
if(q('#pm-formno'))q('#pm-formno').oninput=function(){p.formNo=this.value;};
if(q('#pm-effdate'))q('#pm-effdate').oninput=function(){p.effectiveDate=this.value;};
if(q('#pm-revdate'))q('#pm-revdate').oninput=function(){p.reviewDate=this.value;};
document.querySelectorAll('[data-grp]').forEach(cb=>cb.onchange=function(){
const code=this.dataset.grp;
if(this.checked){if(!p.itemGroups.includes(code))p.itemGroups.push(code);}
else{p.itemGroups=p.itemGroups.filter(c=>c!==code);}
});
if(q('#pm-logo-file'))q('#pm-logo-file').onchange=function(){
const f=this.files&&this.files[0];if(!f)return;
const rd=new FileReader();
rd.onload=e=>{
const img=new Image();
img.onload=()=>{
const maxW=260,scale=Math.min(1,maxW/img.width);
const cv=document.createElement('canvas');
cv.width=Math.round(img.width*scale);cv.height=Math.round(img.height*scale);
cv.getContext('2d').drawImage(img,0,0,cv.width,cv.height);
p.logo=cv.toDataURL('image/png');
const prev=q('#pm-logo-prev');if(prev){prev.src=p.logo;prev.style.display='';}
};
img.src=e.target.result;
};
rd.readAsDataURL(f);
};
if(q('#pm-logo-clear'))q('#pm-logo-clear').onclick=()=>{p.logo='';const img=q('#pm-logo-prev');if(img)img.style.display='none';};
if(q('#pm-set-default'))q('#pm-set-default').onclick=async()=>{
if(!confirm(`Make "${p.name}" the Default profile?`))return;
try{await api('POST',`/wo-header-profiles/${p._id}/set-default`,{});toast('Default profile updated');closeModal();await loadProfiles();}
catch(e){toast(e.message,'err');}
};
if(q('#pm-delete'))q('#pm-delete').onclick=async()=>{
if(!confirm(`Delete profile "${p.name}"?`))return;
try{await api('DELETE',`/wo-header-profiles/${p._id}`);toast('Profile deleted');closeModal();await loadProfiles();}
catch(e){toast(e.message,'err');}
};
if(q('#pm-save'))q('#pm-save').onclick=save;
}
async function save(){
const p=_editing;
if(!p.name.trim()){toast('Profile name is required','err');return;}
_saving=true;renderModal();
try{
const payload={name:p.name.trim(),itemGroups:p.itemGroups,companyName:p.companyName,companyAddress:p.companyAddress,formNo:p.formNo,effectiveDate:p.effectiveDate,reviewDate:p.reviewDate,logo:p.logo};
if(p._id==null)await api('POST','/wo-header-profiles',payload);
else await api('PUT',`/wo-header-profiles/${p._id}`,payload);
toast('Profile saved');
_saving=false;closeModal();await loadProfiles();
}catch(e){toast(e.message,'err');_saving=false;renderModal();}
}
(async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
loadProfiles();
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>
+388
View File
@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Work Order — Print</title>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,Helvetica,sans-serif;background:#8a94a6;color:#000;font-size:10px}
#doc{padding:14px}
.loading{padding:40px;text-align:center;color:#fff;font-size:14px}
.toolbar{position:sticky;top:0;display:flex;gap:8px;justify-content:center;padding:8px;background:#0d1626;z-index:5}
.toolbar button{padding:7px 16px;border:none;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.tb-print{background:#1a6fff;color:#fff}
.tb-close{background:#243650;color:#c8d6e8}
.page{width:277mm;min-height:auto;background:#fff;margin:0 auto 16px;padding:6mm;border:1px solid #999}
table{border-collapse:collapse;width:100%}
td,th{border:1px solid #000;padding:2px 4px;vertical-align:middle;font-size:9px}
.mono{font-family:'Courier New',monospace}
.l{text-align:left}
/* Header band */
table.hdr td{text-align:center}
table.hdr .logo{width:120px}
table.hdr .logo img{max-height:46px;max-width:112px;object-fit:contain}
.co-name{font-size:16px;font-weight:800;letter-spacing:.3px}
.co-addr{font-size:10px}
table.hdr .k{font-weight:700;background:#f2f2f2;white-space:nowrap}
.title-bar{border:1px solid #000;border-top:none;text-align:center;font-weight:800;font-size:11px;padding:3px;background:#f2f2f2}
/* Info box */
table.info{border-top:none}
table.info .k{font-weight:700;background:#f2f2f2;white-space:nowrap;width:130px}
/* Materials */
.sol-cap{font-weight:700;font-size:9.5px;padding:3px 2px 1px;text-align:center;background:#f2f2f2;border:1px solid #000;border-bottom:none}
table.mt{margin-top:6px}
table.mt th{background:#f2f2f2;font-weight:700;text-align:center;font-size:8.5px;line-height:1.1}
table.mt td{text-align:center;font-size:8.5px;height:24px}
table.mt td.l{text-align:left}
.sec-gap{height:8px}
.sig-cell{text-align:center;vertical-align:middle}
.sig-cell img.sig-img{max-height:34px;max-width:110px;object-fit:contain;display:block;margin:0 auto 1px}
.sig-name{font-size:8px;font-weight:700}
.sig-dt{font-size:7.5px;color:#333}
/* Sign-off block */
table.signoff{margin-top:8px}
table.signoff .k{font-weight:700;width:230px;background:#f2f2f2;height:34px}
table.signoff .sig{height:34px}
table.signoff .sig img{max-height:30px;max-width:150px;object-fit:contain;vertical-align:middle}
table.signoff .sn{font-size:8.5px;font-weight:700}
table.signoff .sd{font-size:8px;color:#333}
@media print{
body{background:#fff}
.toolbar{display:none}
#doc{padding:0}
.page{width:auto;margin:0;border:none;padding:0}
.page + .page{page-break-before:always}
}
@page{size:A4 landscape;margin:8mm}
</style>
</head>
<body>
<div class="toolbar">
<button class="tb-print" onclick="window.print()">🖨 Print / Save as PDF</button>
<button class="tb-close" onclick="window.close()">Close</button>
</div>
<div id="doc"><div class="loading">Loading work order…</div></div>
<script>
'use strict';
const BE='/api';
let _tok=null;
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)window.location.href='/';
const _params=new URLSearchParams(location.search);
const _id=_params.get('id');
const _co=window.__DEFAULT_COMPANY__||'';
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtD(iso){if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';return `${String(d.getDate()).padStart(2,'0')}-${_MON[d.getMonth()]}-${d.getFullYear()}`;}
function num3(v){if(v==null||v==='')return '';const n=Number(v);if(!isFinite(n)||n===0)return '';return String(Math.round(n*1000)/1000);}
// Raw Material's "Qty Req./100 ml" / "Qty Req." auto-scale to mg/g/Kg/Ton
// for display (same convention as work-order.html — see that file for the
// full rationale). Read-only here: this page never edits anything, it only
// needs to reproduce the same scaled numbers work-order.html shows/saves.
function round3(n){return Math.round((Number(n)||0)*1000)/1000;}
const UNIT_FAMILIES_W={'mg':1,'gm':1000,'Kg':1e6,'Ton':1e9};
function toStockQty(grams,uom){const u=(uom||'').toUpperCase().trim();
return (u.startsWith('KG')||u.startsWith('LTR')||u.startsWith('LITR')||u==='L'||u==='LT'||u==='KL')?grams/1000:grams;}
function fromStockQty(qty,uom){const u=(uom||'').toUpperCase().trim();
return (u.startsWith('KG')||u.startsWith('LTR')||u.startsWith('LITR')||u==='L'||u==='LT'||u==='KL')?qty*1000:qty;}
// "Kg" is skipped by the auto-scale ladder — SOP paperwork here writes
// formulation weights in gm even into six figures (e.g. "680720 gm", not
// "680.72 Kg"), only stepping up to Ton beyond that. Only used for Qty
// Req.'s auto-scale — Qty Req./100 ml uses the user's own chosen unit
// (r.stdQtyDispUnit) when set, via scaleGramsToUnit() below.
function scaleGrams(grams){
const totalBase=(Number(grams)||0)*1000;
const entries=Object.entries(UNIT_FAMILIES_W).filter(([u])=>u!=='Kg').sort((a,b)=>b[1]-a[1]);
for(const[u,mult] of entries){ if(totalBase>=mult) return {value:round3(totalBase/mult),label:u}; }
const smallest=entries[entries.length-1];
return {value:round3(totalBase/smallest[1]),label:smallest[0]};
}
// Same conversion, but to an explicitly given unit — backs the user's own
// Qty Req./100 ml unit choice (work-order.html's weight unit dropdown),
// saved on the row as stdQtyDispUnit.
function scaleGramsToUnit(grams,unit){
const mult=UNIT_FAMILIES_W[unit]!=null?UNIT_FAMILIES_W[unit]:1000;
return round3((Number(grams)||0)*1000/mult);
}
// Admin-configured per-item SOP display factor — see work-order.html's
// potencyFactor() for the full rationale. Applies ONLY to stdQty ("Qty
// Req./100 ml"); qtyReq ("Qty Req.") is never adjusted.
function potencyFactor(itemCode){
const p=parseFloat((_cfg.woRawPotencyFactors||{})[itemCode]);
return (p>0)?p/100:1;
}
// srcField lets a caller display a DIFFERENT row value (e.g. the persisted
// "qtyIssued") using 'field''s own unit/scaling conventions — used by the Qty
// Issued column for Raw Material rows (see rawTables() below).
function rawScaledDisp(r,field,noUnit,srcField){
const vf=srcField||field;
if(r.directRaw)return esc(r[vf]);
const dispField=field==='stdQty'?'stdQtyDispUnit':'qtyReqDispUnit';
const raw=parseFloat(r[vf])||0;
const grams=field==='stdQty'?raw*potencyFactor(r.itemCode):fromStockQty(raw,r.uom);
const unit=r[dispField]||scaleGrams(grams).label;
const value=scaleGramsToUnit(grams,unit);
return noUnit?String(value):`${value} ${esc(unit)}`;
}
// Scaled unit label alone, for the UOM column — shows Qty Req./100 ml's
// unit (the user's own chosen one, if set) instead of the item's static SAP
// stock UOM.
function rawScaledUnit(r){
if(r.directRaw)return esc(r.uom);
const grams=(parseFloat(r.stdQty)||0)*potencyFactor(r.itemCode);
return esc(r.stdQtyDispUnit||scaleGrams(grams).label);
}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
let _cfg={},_wo=null,_iss={qtyByItem:{}},_sig={},_newItemCode='';
let _woQtyIssuedSource='issue_for_production'; // Admin → System Settings — see issueRowCells()
// ── header / info / signoff (repeated per page) ────────────────────
function headerBand(){
const logo=_cfg.woLogo?`<img src="${_cfg.woLogo}"/>`:'';
return `<table class="hdr">
<tr>
<td class="logo" rowspan="2">${logo}</td>
<td colspan="6"><div class="co-name">${esc(_cfg.woCompanyName||'')}</div><div class="co-addr">${esc(_cfg.woCompanyAddress||'')}</div></td>
</tr>
<tr>
<td class="k">FORM NO.</td><td>${esc(_cfg.woFormNo||'')}</td>
<td class="k">EFFECTIVE DATE</td><td>${esc(_cfg.woEffectiveDate||'')}</td>
<td class="k">REVIEW DATE</td><td>${esc(_cfg.woReviewDate||'')}</td>
</tr>
</table>`;
}
function titleBar(suffix){return `<div class="title-bar">PRODUCTION WORK ORDER${suffix||''}</div>`;}
function infoBox(){
const w=_wo;
const pn=[w.productName,w.productDesc].filter(Boolean).join(' — ');
return `<table class="info">
<tr><td class="k">Product Name &amp; Description</td><td colspan="5" class="l">${esc(pn)}</td></tr>
<tr><td class="k">Generic Name</td><td colspan="5" class="l">${esc(w.genericName||'')}</td></tr>
<tr><td class="k">Batch Number</td><td class="l">${esc(w.batchNumber||'')}</td><td class="k">Batch Size</td><td class="l">${esc(w.batchSize||'')}</td><td class="k">Total Units</td><td class="l">${esc(w.totalUnits||'')}</td></tr>
<tr><td class="k">MFG. Date</td><td class="l">${esc(w.mfgDate||'')}</td><td class="k">EXP. Date</td><td class="l">${esc(w.expDate||'')}</td><td class="k">Pack Size</td><td class="l">${esc(w.packSize||'')}</td></tr>
<tr><td class="k">Reference</td><td class="l">${esc(w.reference||'')}</td><td class="k">Product code</td><td class="l">${esc(w.productCode||'')}${_newItemCode&&_newItemCode.trim().toUpperCase()!==(w.productCode||'').trim().toUpperCase()?` (${esc(_newItemCode)})`:''}</td><td class="k">Type</td><td class="l">${esc(w.type||'')}</td></tr>
<tr><td class="k">Market</td><td colspan="5" class="l">${esc(w.market||'')}</td></tr>
</table>`;
}
// bottom five sign-offs, auto-filled from the WO workflow log. 'aliases'
// covers a step whose display name changed later (e.g. "Checked By
// Production" → "Checked By Store In-Charge") — older Work Orders'
// workflowLog entries were recorded with the OLD text at the time (an
// immutable historical record), so the lookup must still match it.
const STEP_LABELS=[
{step:'Prepared By QA', label:'Prepared By (QA)'},
{step:'Checked By QC', label:'Checked By (QC)'},
{step:'Checked By Store In-Charge', aliases:['Checked By Production'], label:'Checked By (Store In-Charge)'},
{step:'Checked By (Manager Production)',label:'Checked By (Manager Production)'},
{step:'Approved By (Manager QA)', label:'Approved By (Manager QA)'},
];
function logFor(step,aliases){
const log=Array.isArray(_wo.workflowLog)?_wo.workflowLog:[];
// last non-rejected, non-auto entry matching this step
for(let i=log.length-1;i>=0;i--){const l=log[i];if((l.step===step||(aliases||[]).includes(l.step))&&l.action!=='rejected'&&l.action!=='auto-skipped')return l;}
return null;
}
function signInline(user,name,at){
const img=user&&_sig[user]?`<img src="${_sig[user]}"/> `:'';
const nm=name?`<span class="sn">${esc(name)}</span>`:'';
const dt=at?` <span class="sd">${fmtD(at)}</span>`:'';
return img+nm+dt;
}
function signoffs(){
// A step this WO auto-skipped (e.g. "Checked By QC" while workOrderSkipQc
// is on) is left off the printed list — matches the stepper on the Work
// Order page itself and the server-generated PDF.
const log=Array.isArray(_wo.workflowLog)?_wo.workflowLog:[];
const skipped=new Set(log.filter(l=>l.action==='auto-skipped').map(l=>l.step));
const visible=STEP_LABELS.filter(s=>!skipped.has(s.step)&&!(s.aliases||[]).some(a=>skipped.has(a)));
return `<table class="signoff">${visible.map(s=>{
const l=logFor(s.step,s.aliases);
return `<tr><td class="k">${s.label}</td><td class="sig">${l?signInline(l.by,l.byName||l.by,l.at):''}</td></tr>`;
}).join('')}</table>`;
}
// ── issuance columns — Qty stays SAP-derived per item; Issued/Received/
// Verified are PER-ROW one-click stamps (see verify-work-order.html) since
// different items can be issued/received/verified on different days. ───────
function qi(code){return num3(_iss.qtyByItem?.[(code||'').trim()]);}
function rowSig(row,which){ const at=row[which+'At']; if(!at)return null; return {name:row[which+'ByName']||row[which+'By'],user:row[which+'By'],at}; }
function sigTd(sig){
if(!sig||!sig.name)return `<td></td>`;
const img=sig.user&&_sig[sig.user]?`<img class="sig-img" src="${_sig[sig.user]}"/>`:'';
return `<td class="sig-cell">${img}<div class="sig-name">${esc(sig.name)}</div><div class="sig-dt">${fmtD(sig.at)}</div></td>`;
}
function issueHeadCells(){return `<th>Qty issued</th><th>Issued by /date</th><th>Received by</th><th>Verified by</th>`;}
// Raw Material rows are never SAP Production Order lines themselves (they're
// the exploded recipe of a Solution/SFG item, which IS the PO line), so their
// Qty Issued always comes from the server-PERSISTED row.qtyIssued (per Admin
// → System Settings → "Raw Material Qty Issued Source"). Packing
// Material/Components rows ARE real PO lines, so under the default
// 'issue_for_production' they read live off qi()/iss.qtyByItem; only under
// 'mark_issued' do they ALSO switch to the persisted row.qtyIssued.
function issueRowCells(row,isRaw){
const usePersisted=isRaw||_woQtyIssuedSource==='mark_issued';
const qty=usePersisted?(row.qtyIssued?(isRaw?rawScaledDisp(row,'qtyReq',false,'qtyIssued'):num3(row.qtyIssued)):''):qi(row.itemCode);
return `<td>${qty}</td>${sigTd(rowSig(row,'issued'))}${sigTd(rowSig(row,'received'))}${sigTd(rowSig(row,'verified'))}`;
}
// ── material tables ────────────────────────────────────────────────
function rawTables(){
const map=new Map();
// solBatchSize can legitimately be 0 — check null/undefined, not truthiness.
(_wo.rawMaterials||[]).forEach(r=>{const k=r.solCode||'';if(!map.has(k))map.set(k,{name:r.solName||'',batchSize:r.solBatchSize!=null?r.solBatchSize:'',rows:[]});map.get(k).rows.push(r);});
return [...map.values()].map(g=>{
const cap=g.name?`<div class="sol-cap">${esc(g.name)}${g.batchSize!==''?` — Batch Size ${esc(g.batchSize)} Ltr`:''}</div>`:'';
return cap+`<table class="mt">
<thead><tr><th>Item code</th><th>Raw materials</th><th>Spec</th><th>Qty Req./100 ml</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>Weighing Balance ID</th><th>AR No.</th>${issueHeadCells()}</tr></thead>
<tbody>${g.rows.map((r)=>`<tr>
<td class="mono">${esc(r.itemCode)}</td><td class="l">${esc(r.rawMaterial)}</td><td>${esc(r.spec)}</td><td>${rawScaledDisp(r,'stdQty',true)}</td><td>${rawScaledUnit(r)}</td><td>${esc(r.ovg)}</td><td>${rawScaledDisp(r,'qtyReq')}</td><td>${esc(r.weighingBalanceId)}</td><td>${esc(r.arNo)}</td>${issueRowCells(r,true)}
</tr>`).join('')}</tbody></table>`;
}).join('<div class="sec-gap"></div>');
}
function packTable(){
const rows=_wo.packingMaterials||[];
const body=rows.length?rows.map((r)=>{
const std=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
const qty=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
return `<tr><td class="mono">${esc(r.itemCode)}</td><td class="l">${esc(r.packingMaterial)}</td><td>${esc(r.artworkNo)}</td><td>${std}</td><td>${esc(r.uom||'-')}</td><td>${esc(r.ovgPercent)}</td><td>${qty}</td><td>${esc(r.arNo||'')}</td>${issueRowCells(r)}</tr>`;
}).join(''):`<tr><td colspan="12">—</td></tr>`;
return `<table class="mt">
<thead><tr><th>Item code</th><th>Packing Materials</th><th>Artwork No.</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.%</th><th>Qty Req.(Units)</th><th>AR No.</th>${issueHeadCells()}</tr></thead>
<tbody>${body}</tbody></table>`;
}
function componentsTable(){
const rows=_wo.packingMaterials||[]; // components-only mode stores rows here
const body=rows.length?rows.map((r)=>{
const std=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
const qty=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
return `<tr><td class="mono">${esc(r.itemCode)}</td><td class="l">${esc(r.packingMaterial)}</td><td>${std}</td><td>${esc(r.uom||'-')}</td><td>${esc(r.ovgPercent)}</td><td>${qty}</td><td>${esc(r.arNo||'')}</td>${issueRowCells(r)}</tr>`;
}).join(''):`<tr><td colspan="11">—</td></tr>`;
return `<table class="mt">
<thead><tr><th>Item code</th><th>Components</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>AR No.</th>${issueHeadCells()}</tr></thead>
<tbody>${body}</tbody></table>`;
}
function page(suffix,tablesHtml){
return `<div class="page">${headerBand()}${titleBar(suffix)}${infoBox()}${tablesHtml}${signoffs()}</div>`;
}
async function loadSignatures(){
const users=new Set();
(Array.isArray(_wo.workflowLog)?_wo.workflowLog:[]).forEach(l=>{if(l.by)users.add(l.by);});
[...(_wo.rawMaterials||[]),...(_wo.packingMaterials||[])].forEach(r=>{
['issuedBy','receivedBy','verifiedBy'].forEach(k=>{if(r[k])users.add(r[k]);});
});
await Promise.all([...users].map(async u=>{
try{const r=await api(`/users/signature/by-username/${encodeURIComponent(u)}`);if(r.signature)_sig[u]=r.signature;}catch(_e){}
}));
}
function render(){
const w=_wo;
// Authoritative flag, not inferred from rawMaterials.length — a
// componentsOnly save also writes a legacy-compatible copy of the same
// components into rawMaterials, so its length alone can't be used to tell
// components-only orders apart from real split Raw/Packing ones.
const componentsMode=!!w.componentsOnly;
let html;
if(componentsMode){
html=page('',componentsTable());
}else{
html=page(' (RAW MATERIAL)',rawTables())+page(' (PACKING MATERIAL)',packTable());
}
document.getElementById('doc').innerHTML=html;
document.title=`WO ${w.woNo||_id} — ${w.productCode||''}`;
}
// Work Order Header Profiles (Blood Bag / CAPD / Accessories / …) — resolve
// which profile applies from the WO's product's SAP item group, and use its
// Company Name/Address/FORM NO./dates/Logo instead of the old single flat
// System Settings values. Falls back to whichever profile is marked
// Default; if the profile list can't be loaded at all (e.g. very first
// request before the one-time migration seed has run), _cfg's own wo*
// fields are left untouched — appSettingsStore.js still returns the same
// legacy values as a last-resort fallback.
async function applyHeaderProfile(){
try{
const [profRes,grpRes]=await Promise.all([
api('/wo-header-profiles'),
api(`/sap/lookup/item-groups-for?codes=${encodeURIComponent(_wo.productCode||'')}&company=${encodeURIComponent(_co)}`),
]);
const profiles=profRes.data||[];
if(!profiles.length)return;
const groupCode=String((grpRes.data||{})[_wo.productCode]??'');
const match=profiles.find(p=>groupCode&&p.itemGroups.includes(groupCode))||profiles.find(p=>p.isDefault)||profiles[0];
if(match){
// Only override a field the matched profile actually set — an empty
// profile field (e.g. no logo ever uploaded to it) must NOT blank out
// the perfectly good value /config already put in _cfg (the legacy
// global System Settings fallback, which is also what the server-side
// "Download PDF" uses directly — that's why the PDF always had the
// logo while this print page silently lost it).
if(match.companyName) _cfg.woCompanyName=match.companyName;
if(match.companyAddress) _cfg.woCompanyAddress=match.companyAddress;
if(match.formNo) _cfg.woFormNo=match.formNo;
if(match.effectiveDate) _cfg.woEffectiveDate=match.effectiveDate;
if(match.reviewDate) _cfg.woReviewDate=match.reviewDate;
if(match.logo) _cfg.woLogo=match.logo;
}
}catch(_e){ /* fall back to whatever /config already put in _cfg */ }
}
// Logo (and signature) <img> tags can still be mid-load/decode the instant
// render() returns — a plain setTimeout() guess used to run print() before
// they'd actually painted, so the logo came out blank in the print/PDF-via-
// print-dialog output (the separate "Download PDF" path embeds the image
// server-side and never had this race). Wait for every <img> in the
// document to genuinely finish (or fail) loading instead of guessing.
function waitForImages(){
const imgs=[...document.querySelectorAll('#doc img')];
if(!imgs.length)return Promise.resolve();
return Promise.all(imgs.map(img=>{
if(img.complete&&img.naturalWidth>0)return Promise.resolve();
return new Promise(res=>{
img.addEventListener('load',res,{once:true});
img.addEventListener('error',res,{once:true});
setTimeout(res,2000); // safety net — never block printing indefinitely
});
}));
}
async function init(){
try{
const [cfg,wo]=await Promise.all([api('/config'),api(`/work-orders/${_id}?company=${encodeURIComponent(_co)}`)]);
_cfg=cfg||{};_wo=wo.data||wo;
if(_cfg.woQtyIssuedSource)_woQtyIssuedSource=_cfg.woQtyIssuedSource;
await applyHeaderProfile();
try{const is=await api(`/work-orders/${_id}/issuance-info?company=${encodeURIComponent(_co)}`);_iss=is.data||_iss;}catch(_e){}
// U_NewItemCode (OITM) for the header Product Code only — printed as
// "(code)" right after it, when SAP has a value.
try{const ni=await api(`/sap/lookup/new-item-codes?codes=${encodeURIComponent(_wo.productCode||'')}&company=${encodeURIComponent(_co)}`);
_newItemCode=(ni.data&&ni.data[_wo.productCode])||'';
}catch(_e){}
await loadSignatures();
render();
await waitForImages();
try{window.print();}catch(_e){}
}catch(e){
document.getElementById('doc').innerHTML=`<div class="loading">Could not load work order: ${esc(e.message)}</div>`;
}
}
if(!_id){document.getElementById('doc').innerHTML='<div class="loading">No work order id.</div>';}
else init();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
param([string]$PayloadJson)
$ErrorActionPreference = 'Stop'
try {
$p = $PayloadJson | ConvertFrom-Json
# ── Connect ──────────────────────────────────────────────────────
$co = New-Object -ComObject SAPbobsCOM.Company
$co.Server = $p.Server
$co.CompanyDB = $p.CompanyDB
$co.UserName = $p.UserName
$co.Password = $p.Password
$co.DbUserName = $p.DbUserName
$co.DbPassword = $p.DbPassword
$co.DbServerType = 11 # confirmed working for this SAP B1 installation
$co.UseTrusted = $false
[Console]::Error.WriteLine("[DI-API] Connecting as $($p.UserName) to $($p.CompanyDB)")
$ret = $co.Connect()
if ($ret -ne 0) {
$err = $co.GetLastErrorDescription()
Write-Output ("{`"success`":false,`"error`":`"DI Login failed [$ret]: $err`"}")
exit 1
}
# ── Purchase Request ─────────────────────────────────────────────
$pr = $co.GetBusinessObject(1470000113)
# Header — use payload values, fall back to sensible defaults
$pr.DocDate = if ($p.DocDate) { [DateTime]$p.DocDate } else { Get-Date }
$pr.DocDueDate = if ($p.DocDueDate) { [DateTime]$p.DocDueDate } else { (Get-Date).AddDays(2) }
$pr.RequriedDate = if ($p.RequriedDate) { [DateTime]$p.RequriedDate } else { (Get-Date).AddDays(2) }
if ($p.Comments) { $pr.Comments = $p.Comments }
if ($p.CardCode) { $pr.CardCode = $p.CardCode }
if ($p.NumAtCard) { $pr.NumAtCard = $p.NumAtCard }
if ($p.RequesterEmail) { $pr.RequesterEmail = $p.RequesterEmail }
# UDFs
try { if ($p.U_ReqDqte) { $pr.UserFields.Fields.Item("U_ReqDqte").Value = $p.U_ReqDqte } } catch {}
try { if ($p.U_Depart) { $pr.UserFields.Fields.Item("U_Depart").Value = $p.U_Depart } } catch {}
try { if ($p.U_Department) { $pr.UserFields.Fields.Item("U_Department").Value = $p.U_Department } } catch {}
try { if ($p.U_STS_REQD) { $pr.UserFields.Fields.Item("U_STS_REQD").Value = $p.U_STS_REQD } } catch {}
# ── Lines ─────────────────────────────────────────────────────────
$first = $true
foreach ($line in $p.DocumentLines) {
if (-not $first) { $pr.Lines.Add() | Out-Null }
$pr.Lines.ItemCode = $line.ItemCode
if ($line.ItemDescription) { $pr.Lines.ItemDescription = $line.ItemDescription }
if ($line.Quantity) { $pr.Lines.Quantity = [double]$line.Quantity }
if ($null -ne $line.UnitPrice) { $pr.Lines.UnitPrice = [double]$line.UnitPrice }
if ($line.WarehouseCode) { $pr.Lines.WarehouseCode = $line.WarehouseCode }
if ($line.RequiredDate) { $pr.Lines.RequiredDate = [DateTime]$line.RequiredDate }
$first = $false
}
# ── Add Document ─────────────────────────────────────────────────
[Console]::Error.WriteLine("[PR] Adding Purchase Request...")
$addRet = $pr.Add()
if ($addRet -ne 0) {
$err = $co.GetLastErrorDescription()
$co.Disconnect()
Write-Output ("{`"success`":false,`"error`":`"PR Add failed [$addRet]: $err`"}")
exit 1
}
$docEntry = $co.GetNewObjectKey()
[Console]::Error.WriteLine("[PR] SUCCESS : $docEntry")
$co.Disconnect()
Write-Output ("{`"success`":true,`"DocEntry`":$docEntry}")
} catch {
Write-Output ("{`"success`":false,`"error`":`"$($_.Exception.Message -replace '`"','')`"}")
}
+4109
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB