sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+22 -1
View File
@@ -54,6 +54,14 @@ TCS_GSP_FILE_TYPE=JSON
# ─── JWT Secret ─────────────────────────────────────────── # ─── JWT Secret ───────────────────────────────────────────
JWT_SECRET=sap-portal-super-secret-key-change-in-production JWT_SECRET=sap-portal-super-secret-key-change-in-production
# ─── Central Auth SSO (Mitra Industry) ───────────────────
# CENTRAL_AUTH_API_URL is the Django BACKEND (port 8016), not the Next.js
# frontend (port 8017) users actually log into — the backend is what verifies
# the access token server-to-server. This app is registered there as
# CENTRAL_AUTH_APP_NAME exactly ("SAP ERP", with the space) — must match.
CENTRAL_AUTH_API_URL=http://14.99.235.114:8016
CENTRAL_AUTH_APP_NAME=SAP ERP
# ─── ITC-04 Job Work Challan (TCS GSP) ────────────────── # ─── ITC-04 Job Work Challan (TCS GSP) ──────────────────
ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880 ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880
ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880 ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880
@@ -79,7 +87,7 @@ SAP_ATTACHMENT_SERVER_PATH=\\192.9.205.132\COAUpload
# SMTP_HOST is set, notifications are skipped (logged only, nothing crashes). # SMTP_HOST is set, notifications are skipped (logged only, nothing crashes).
# The recipient list per step/module, and the on/off switch, are configured # The recipient list per step/module, and the on/off switch, are configured
# from Admin Settings (services/appSettingsStore.js) — not here. # from Admin Settings (services/appSettingsStore.js) — not here.
SMTP_HOST=smtp.office3651.com SMTP_HOST=smtp.office365.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=no-reply@mitraindustries.com SMTP_USER=no-reply@mitraindustries.com
@@ -87,3 +95,16 @@ SMTP_PASSWORD=fzxkjxqkzvydjbfb
SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com> SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com>
# Base URL used to build clickable links in notification emails. # Base URL used to build clickable links in notification emails.
APP_BASE_URL=http://localhost:5000 APP_BASE_URL=http://localhost:5000
# ─── Sales Order module (replaces the msale portal) ─────────────
# API key for the external job that pushes invoice PDFs to /api/sales-ext
# (header MS-API-KEY). Leave unset to keep that endpoint disabled.
#SALES_EXT_API_KEY=
# One-time migration source (scripts/migrate-msale.js) — the LIVE msale MySQL copy.
#MSALE_MYSQL_HOST=localhost
#MSALE_MYSQL_PORT=3306
#MSALE_MYSQL_USER=root
#MSALE_MYSQL_PASSWORD=
#MSALE_MYSQL_DATABASE=u709187725_msale_db
# Extra folders to look for COA/attachment files (comma separated), besides SAP_ATTACHMENT_PATH
#SALES_COA_PATHS=
+110
View File
@@ -0,0 +1,110 @@
PORT=5000
NODE_ENV=production
# NOTE: The following app settings moved OUT of .env and are now edited from
# the Admin panel (System Settings tab), persisted in the app DB table
# ZAPP_SETTINGS (see services/appSettingsStore.js): BOM approval levels, skip
# "Checked By QC", enabled Production Order types, receipt full-qty gate,
# autoclave-rejection toggle, and the FG item-group list.
# ─── APP SQL Connection ───────────────────────────
APP_SQL_HOST=192.9.205.132
APP_SQL_SERVER_NAME=SAPSVR
APP_SQL_PORT=1433
APP_SQL_USER=SAP-ERP-USER
APP_SQL_PASSWORD=Admin@1234
APP_SQL_DATABASE=SAP-ERP-DEMO
# ─── SAP SQL Direct Connection ───────────────────────────
SQL_HOST=192.9.205.132
SQL_SERVER_NAME=SAPSVR
SQL_PORT=1433
SQL_USER=sa
SQL_PASSWORD=Edp2sYstem
SQL_DATABASE=Test_MI-NewDB2
# SQL_ENCRYPT=true ← uncomment only if SQL Server enforces encryption
# ─── SAP B1 Service Layer ─────────────────────────────────
SAP_B1_SERVER=https://192.9.205.132:50000
SAP_B1_COMPANY=Test_MI-NewDB2
SAP_B1_USER=manager
SAP_B1_PASSWORD=Mipl@123
SAP_DI_DB_TYPE=11
# ─── SAP B1 Purchase Request restricted user ─────────────────
# Must be a user listed in "Purchase Indent" approval template originators
# AND must NOT have "Approval Bypass" authorization in SAP B1 General Authorizations.
# When set, portal PRs will trigger SAP's approval template (AuthorizationStatus: dasPending).
SAP_B1_PR_USER=pur03
SAP_B1_PR_PASSWORD=12345
SAP_B1_PR_EMAIL=edp@mitraindustries.com
SAP_B1_PR_APPROVAL_TEMPLATE=Purchase Indent
# ─── TCS GSP (GSTR-1 Upload) ─────────────────────────────
# Excel upload URL: salesInvUpldForEXCEL/{CLIENT_CODE}/{MAPPING_CD}/{TEMPLATE_CD}/{GSTIN}/EXCEL/{period}?oprFlag=SALES
# (mirrors working JSON URL: salesInvUpld/{CLIENT_CODE}/{MAPPING_CD}/{TEMPLATE_CD}/{GSTIN}/JSON/{period}?oprFlag=SALES)
TCS_GSP_BASE_URL=https://g31.tcsgsp.in
TCS_GSP_USERNAME=API@mitrauser.com
TCS_GSP_PASSWORD=March@2026
TCS_GSP_GSTIN=06AAACM4564C1ZT
TCS_GSP_CLIENT_CODE=AAACM4564C
TCS_GSP_UPLOAD_GSTIN=06AAACM4564C1ZT
TCS_GSP_MAPPING_CD=MFHSIDTN0000005
TCS_GSP_TEMPLATE_CD=SalesInvoiceData802
# typeOfFileUpload path segment — required; GSTR1 is standard value for sales return
TCS_GSP_FILE_TYPE=JSON
# ─── JWT Secret ───────────────────────────────────────────
JWT_SECRET=sap-portal-super-secret-key-change-in-production
# ─── Central Auth SSO (Mitra Industry) ───────────────────
# CENTRAL_AUTH_API_URL is the Django BACKEND (port 8016), not the Next.js
# frontend (port 8017) users actually log into — the backend is what verifies
# the access token server-to-server. This app is registered there as
# CENTRAL_AUTH_APP_NAME exactly ("SAP ERP", with the space) — must match.
CENTRAL_AUTH_API_URL=http://14.99.235.114:8016
CENTRAL_AUTH_APP_NAME=SAP ERP
# ─── ITC-04 Job Work Challan (TCS GSP) ──────────────────
ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880
ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880
ITC04_JW2M_MAPPING_CD=MFHGSTTN0000881
ITC04_JW2M_TEMPLATE_CD=ITC04JW2MTemplate881
# ─── AI Chatbot (Google Gemini) ──────────────────────────
# Get free key from: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=AIzaSyBW7sseRh5BdU_dtmNQqIEGjG3lMS7eWiw
# ─── SAP B1 Attachment Path (UNC Share) ───────────────────
SAP_ATTACHMENT_PATH=\\192.9.205.132\COAUpload
SAP_ATTACHMENT_SERVER_PATH=\\192.9.205.132\COAUpload
# ─── Windows Network Share Credentials ────────────────────
#SMB_USER=USER42
#SMB_PASSWORD=Asdf#2025#42!
# SMB_DOMAIN=WORKGROUP ← uncomment if Strategy B still fails
# ─── SMTP Email Notifications (Production module stage alerts) ────
# Fill in real SMTP credentials to enable stage-change emails (Work Order,
# Production Order, Issue for Production, Verify Work Order, Batch Issuance
# Intimation) — see services/mailer.js / services/notifyStore.js. Until
# SMTP_HOST is set, notifications are skipped (logged only, nothing crashes).
# The recipient list per step/module, and the on/off switch, are configured
# from Admin Settings (services/appSettingsStore.js) — not here.
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=no-reply@mitraindustries.com
SMTP_PASSWORD=fzxkjxqkzvydjbfb
SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com>
# Base URL used to build clickable links in notification emails.
APP_BASE_URL=http://localhost:5000
# ─── Sales Order module (replaces the msale portal) ─────────────
# API key for the external job that pushes invoice PDFs to /api/sales-ext
# (header MS-API-KEY). Leave unset to keep that endpoint disabled.
#SALES_EXT_API_KEY=
# One-time migration source (scripts/migrate-msale.js) — the LIVE msale MySQL copy.
#MSALE_MYSQL_HOST=localhost
#MSALE_MYSQL_PORT=3306
#MSALE_MYSQL_USER=root
#MSALE_MYSQL_PASSWORD=
#MSALE_MYSQL_DATABASE=u709187725_msale_db
# Extra folders to look for COA/attachment files (comma separated), besides SAP_ATTACHMENT_PATH
#SALES_COA_PATHS=
+22 -1
View File
@@ -54,6 +54,14 @@ TCS_GSP_FILE_TYPE=JSON
# ─── JWT Secret ─────────────────────────────────────────── # ─── JWT Secret ───────────────────────────────────────────
JWT_SECRET=sap-portal-super-secret-key-change-in-production JWT_SECRET=sap-portal-super-secret-key-change-in-production
# ─── Central Auth SSO (Mitra Industry) ───────────────────
# CENTRAL_AUTH_API_URL is the Django BACKEND (port 8016), not the Next.js
# frontend (port 8017) users actually log into — the backend is what verifies
# the access token server-to-server. This app is registered there as
# CENTRAL_AUTH_APP_NAME exactly ("SAP ERP", with the space) — must match.
CENTRAL_AUTH_API_URL=http://14.99.235.114:8016
CENTRAL_AUTH_APP_NAME=SAP ERP
# ─── ITC-04 Job Work Challan (TCS GSP) ────────────────── # ─── ITC-04 Job Work Challan (TCS GSP) ──────────────────
ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880 ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880
ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880 ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880
@@ -79,7 +87,7 @@ SAP_ATTACHMENT_SERVER_PATH=\\192.9.205.132\COAUpload
# SMTP_HOST is set, notifications are skipped (logged only, nothing crashes). # SMTP_HOST is set, notifications are skipped (logged only, nothing crashes).
# The recipient list per step/module, and the on/off switch, are configured # The recipient list per step/module, and the on/off switch, are configured
# from Admin Settings (services/appSettingsStore.js) — not here. # from Admin Settings (services/appSettingsStore.js) — not here.
SMTP_HOST=smtp.office3651.com SMTP_HOST=smtp.office365.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=no-reply@mitraindustries.com SMTP_USER=no-reply@mitraindustries.com
@@ -87,3 +95,16 @@ SMTP_PASSWORD=fzxkjxqkzvydjbfb
SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com> SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com>
# Base URL used to build clickable links in notification emails. # Base URL used to build clickable links in notification emails.
APP_BASE_URL=http://localhost:5000 APP_BASE_URL=http://localhost:5000
# ─── Sales Order module (replaces the msale portal) ─────────────
# API key for the external job that pushes invoice PDFs to /api/sales-ext
# (header MS-API-KEY). Leave unset to keep that endpoint disabled.
#SALES_EXT_API_KEY=
# One-time migration source (scripts/migrate-msale.js) — the LIVE msale MySQL copy.
#MSALE_MYSQL_HOST=localhost
#MSALE_MYSQL_PORT=3306
#MSALE_MYSQL_USER=root
#MSALE_MYSQL_PASSWORD=
#MSALE_MYSQL_DATABASE=u709187725_msale_db
# Extra folders to look for COA/attachment files (comma separated), besides SAP_ATTACHMENT_PATH
#SALES_COA_PATHS=
+110
View File
@@ -0,0 +1,110 @@
PORT=5000
NODE_ENV=production
# NOTE: The following app settings moved OUT of .env and are now edited from
# the Admin panel (System Settings tab), persisted in the app DB table
# ZAPP_SETTINGS (see services/appSettingsStore.js): BOM approval levels, skip
# "Checked By QC", enabled Production Order types, receipt full-qty gate,
# autoclave-rejection toggle, and the FG item-group list.
# ─── APP SQL Connection ───────────────────────────
APP_SQL_HOST=192.9.205.132
APP_SQL_SERVER_NAME=SAPSVR
APP_SQL_PORT=1433
APP_SQL_USER=SAP-ERP-USER
APP_SQL_PASSWORD=Admin@1234
APP_SQL_DATABASE=SAP-ERP-DEMO
# ─── SAP SQL Direct Connection ───────────────────────────
SQL_HOST=192.9.205.132
SQL_SERVER_NAME=SAPSVR
SQL_PORT=1433
SQL_USER=sa
SQL_PASSWORD=Edp2sYstem
SQL_DATABASE=Test_MI-NewDB2
# SQL_ENCRYPT=true ← uncomment only if SQL Server enforces encryption
# ─── SAP B1 Service Layer ─────────────────────────────────
SAP_B1_SERVER=https://192.9.205.132:50000
SAP_B1_COMPANY=Test_MI-NewDB2
SAP_B1_USER=manager
SAP_B1_PASSWORD=Mipl@123
SAP_DI_DB_TYPE=11
# ─── SAP B1 Purchase Request restricted user ─────────────────
# Must be a user listed in "Purchase Indent" approval template originators
# AND must NOT have "Approval Bypass" authorization in SAP B1 General Authorizations.
# When set, portal PRs will trigger SAP's approval template (AuthorizationStatus: dasPending).
SAP_B1_PR_USER=pur03
SAP_B1_PR_PASSWORD=12345
SAP_B1_PR_EMAIL=edp@mitraindustries.com
SAP_B1_PR_APPROVAL_TEMPLATE=Purchase Indent
# ─── TCS GSP (GSTR-1 Upload) ─────────────────────────────
# Excel upload URL: salesInvUpldForEXCEL/{CLIENT_CODE}/{MAPPING_CD}/{TEMPLATE_CD}/{GSTIN}/EXCEL/{period}?oprFlag=SALES
# (mirrors working JSON URL: salesInvUpld/{CLIENT_CODE}/{MAPPING_CD}/{TEMPLATE_CD}/{GSTIN}/JSON/{period}?oprFlag=SALES)
TCS_GSP_BASE_URL=https://g31.tcsgsp.in
TCS_GSP_USERNAME=API@mitrauser.com
TCS_GSP_PASSWORD=March@2026
TCS_GSP_GSTIN=06AAACM4564C1ZT
TCS_GSP_CLIENT_CODE=AAACM4564C
TCS_GSP_UPLOAD_GSTIN=06AAACM4564C1ZT
TCS_GSP_MAPPING_CD=MFHSIDTN0000005
TCS_GSP_TEMPLATE_CD=SalesInvoiceData802
# typeOfFileUpload path segment — required; GSTR1 is standard value for sales return
TCS_GSP_FILE_TYPE=JSON
# ─── JWT Secret ───────────────────────────────────────────
JWT_SECRET=sap-portal-super-secret-key-change-in-production
# ─── Central Auth SSO (Mitra Industry) ───────────────────
# CENTRAL_AUTH_API_URL is the Django BACKEND (port 8016), not the Next.js
# frontend (port 8017) users actually log into — the backend is what verifies
# the access token server-to-server. This app is registered there as
# CENTRAL_AUTH_APP_NAME exactly ("SAP ERP", with the space) — must match.
CENTRAL_AUTH_API_URL=http://14.99.235.114:8016
CENTRAL_AUTH_APP_NAME=SAP ERP
# ─── ITC-04 Job Work Challan (TCS GSP) ──────────────────
ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880
ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880
ITC04_JW2M_MAPPING_CD=MFHGSTTN0000881
ITC04_JW2M_TEMPLATE_CD=ITC04JW2MTemplate881
# ─── AI Chatbot (Google Gemini) ──────────────────────────
# Get free key from: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=AIzaSyBW7sseRh5BdU_dtmNQqIEGjG3lMS7eWiw
# ─── SAP B1 Attachment Path (UNC Share) ───────────────────
SAP_ATTACHMENT_PATH=\\192.9.205.132\COAUpload
SAP_ATTACHMENT_SERVER_PATH=\\192.9.205.132\COAUpload
# ─── Windows Network Share Credentials ────────────────────
#SMB_USER=USER42
#SMB_PASSWORD=Asdf#2025#42!
# SMB_DOMAIN=WORKGROUP ← uncomment if Strategy B still fails
# ─── SMTP Email Notifications (Production module stage alerts) ────
# Fill in real SMTP credentials to enable stage-change emails (Work Order,
# Production Order, Issue for Production, Verify Work Order, Batch Issuance
# Intimation) — see services/mailer.js / services/notifyStore.js. Until
# SMTP_HOST is set, notifications are skipped (logged only, nothing crashes).
# The recipient list per step/module, and the on/off switch, are configured
# from Admin Settings (services/appSettingsStore.js) — not here.
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=no-reply@mitraindustries.com
SMTP_PASSWORD=fzxkjxqkzvydjbfb
SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com>
# Base URL used to build clickable links in notification emails.
APP_BASE_URL=http://localhost:5000
# ─── Sales Order module (replaces the msale portal) ─────────────
# API key for the external job that pushes invoice PDFs to /api/sales-ext
# (header MS-API-KEY). Leave unset to keep that endpoint disabled.
#SALES_EXT_API_KEY=
# One-time migration source (scripts/migrate-msale.js) — the LIVE msale MySQL copy.
#MSALE_MYSQL_HOST=localhost
#MSALE_MYSQL_PORT=3306
#MSALE_MYSQL_USER=root
#MSALE_MYSQL_PASSWORD=
#MSALE_MYSQL_DATABASE=u709187725_msale_db
# Extra folders to look for COA/attachment files (comma separated), besides SAP_ATTACHMENT_PATH
#SALES_COA_PATHS=
+21
View File
@@ -54,6 +54,14 @@ TCS_GSP_FILE_TYPE=JSON
# ─── JWT Secret ─────────────────────────────────────────── # ─── JWT Secret ───────────────────────────────────────────
JWT_SECRET=sap-portal-super-secret-key-change-in-production JWT_SECRET=sap-portal-super-secret-key-change-in-production
# ─── Central Auth SSO (Mitra Industry) ───────────────────
# CENTRAL_AUTH_API_URL is the Django BACKEND (port 8016), not the Next.js
# frontend (port 8017) users actually log into — the backend is what verifies
# the access token server-to-server. This app is registered there as
# CENTRAL_AUTH_APP_NAME exactly ("SAP ERP", with the space) — must match.
CENTRAL_AUTH_API_URL=http://14.99.235.114:8016
CENTRAL_AUTH_APP_NAME=SAP ERP
# ─── ITC-04 Job Work Challan (TCS GSP) ────────────────── # ─── ITC-04 Job Work Challan (TCS GSP) ──────────────────
ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880 ITC04_M2JW_MAPPING_CD=MFHGSTTN0000880
ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880 ITC04_M2JW_TEMPLATE_CD=ITC04M2JWTemplate880
@@ -87,3 +95,16 @@ SMTP_PASSWORD=fzxkjxqkzvydjbfb
SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com> SMTP_FROM=SAP ERP Portal <no-reply@mitraindustries.com>
# Base URL used to build clickable links in notification emails. # Base URL used to build clickable links in notification emails.
APP_BASE_URL=http://localhost:5000 APP_BASE_URL=http://localhost:5000
# ─── Sales Order module (replaces the msale portal) ─────────────
# API key for the external job that pushes invoice PDFs to /api/sales-ext
# (header MS-API-KEY). Leave unset to keep that endpoint disabled.
#SALES_EXT_API_KEY=
# One-time migration source (scripts/migrate-msale.js) — the LIVE msale MySQL copy.
#MSALE_MYSQL_HOST=localhost
#MSALE_MYSQL_PORT=3306
#MSALE_MYSQL_USER=root
#MSALE_MYSQL_PASSWORD=
#MSALE_MYSQL_DATABASE=u709187725_msale_db
# Extra folders to look for COA/attachment files (comma separated), besides SAP_ATTACHMENT_PATH
#SALES_COA_PATHS=
+1
View File
@@ -1,3 +1,4 @@
dist/ dist/
node_modules/ node_modules/
dist-1/ dist-1/
storage/
+231
View File
@@ -0,0 +1,231 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R /F5 7 0 R /F6 8 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
9 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
>>
endobj
17 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260924194505+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260924194505+05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (SAP-ERP Portal \204 DNS & HTTPS Setup Guide) /Trapped /False
>>
endobj
18 0 obj
<<
/Count 8 /Kids [ 4 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R ] /Type /Pages
>>
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1599
>>
stream
GatU3969)P&AI=/oP'5OAMf_NSf=0CZ]Z"Y:pkYk`N_j.`g/`m$'Y>\gQJhHZTopp"%g,$I]N>Z"U?,a^XE8BY;s8q7"I+\07\AeKD@BPS6u3*TUV3q80$ar%)O,2^?^-U&)"ITp12DUGioY#17t8=243iJ'[>uo$bOCZr;`VGAGY`SO"!bfY*0jn(^o<fFp<H3Qhc"iK,Ljo-%'$UN5'r>,L#$`nS;XaGC4QO`qkE%fD`S/^\VAP1\6URep2#$[hb%=b*m+&L2"B[R`gqFJ;"bDB)+56<E75<i"b%,\1U>rb+,9-(dh2eF@`]t]R'`V+p"s2gj1h5?N1=q1.dfPeNInXE@A6krB,glV^p"J=GqJ3$)ChX@Nj0qjrg[=Kq/$QW,Z/p'lL`\Y@3UFie_S2#gLh%l"@Nm$(.gZV:))e-LJYPZ#sLgD.=O%)V9AK+;$HF5<`FF];GW^U4fem73[N?5(Q!R&4df[#8@I86H>.<GcoA%msiFdRp7\@^<'AR?)*JO8SBCX0'g3B"9Ah-7mle*#YX+^`Pru<\ASV*@)VS%#4EBt;KGsQ7D\,#f6OI,b#Fd76fK3qYc^P;#%!*hi[A9=hsGID?+U<(_dKtM=%)rB;0J_[*<rm+oV1^[cLtWSH:!.BLJ8mQZ,=J5V6S-EKLKCH(e[QN/lbauGpm6gq8DfOHA7S%4B"ReGRouhe%D^S.V#/J=A7O,Phi@.moL'&J,DcZ5JgXX/tm,8MG+9Cae"`.DGI<1&po5M*cXU#cHo'%&eh&XFcH_Foa[t;E>%Weo4''sgV7W+`lV]oj^oKkVDi=-.I\@u_;:U$XD;ML9Q$`V[Un<T##4^Wc%-kQm,X7M'>a>,@rlUD5)O\s_Am;Sk[]jQ]i"?H<[)j);Z7Fe;sOc\Y]6rdc''Zf11P&TXKLK%en%4Lr*\L\nbGK.3OiCVru%j6nU<XTkP/]pH#j<2kgQ0pX/_TX9s#KCZ5]>Ri_1)DOOu)``P27rBMVZRHr*U>nO_tfU,0lUqM?WK3!@ko9o+ii^"B?;PI!8FoE2;+&FkPp4nY<dJtCfW*'hjqlM+=g-Fpt8Q-,^Ye-^L(`1l)k+`*aH-7P=5D<p,Zdb,,:^>\6pJ1jdG\d-B8/TPQ3(pM\%d$Ec%?9<e9]g[:%).;'/,2'44^%GN![O_Pu4Ut@A_nr]#bL.sUnQ1d*<5Y@cDX,0-fb&Wg).I<L4L:b,[O])\"6!Rn`PT5'pd]qt6(&SBdGf7(s%JhK[Re-222E.dU;hY[n=2$?*5g&/(FPogqo7mT.jt"Z>@n7C\$sQfghRm@35me`=b5+9D6n!_VeEt`jc"@_ZKU7tZ"YX";C*VqY`)@0.)Be2_+Wk%pH5ps@?!0]'N#-m%DM*()Z^0!iLlOj(`#_&e%htKpL^Wt7^Yp=ncfLg-$"[bGTC'Hh6%?dq(J6b24DtXgjuYL;:SnYq-XVZ)%k"Fo(^QB#(!tAaV]n'-4e@'6BjrI^(_;g7q'))C=HUhVi=oDj!p!ZrEOA63dY[NjJo`QH>Gad3QA3W+:_,`L5!Q$_7PMc;.1-o2!hZ)gK-j82$U0`2(C'FQAgs9hqlUb?>AHY5E32lDZ~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1499
>>
stream
Gatm;?$#!`'Re<2\?h`$8LDj=Z4/d3Cq;^q9UaB=e",au7C<;4K-aP2@HjuaPmkT=Ftb(O0EurqX)m\F+U%QIrB2e75)eFuV[u0%UG7EVM.(K:+%I.)0#I;*UdkJ)FXcsoI?HuiN9bGMKL(uK6Qe5R"\B%HNY7f#TI1PcB=NZ[_5Ka-$=Vp:.!5N\+k%22OAX_a?QU?J:<Bc./2X\"a\*J!L!c/Aj"Gd#1*q7+.1&SS`_b[>$2`SL5tGB['Yn**Ajt!eiN:Nj=1W'R:=uV8'DUMjGQj(+#9Gkp^jet\;sqI1f?[\hed58I;F6#_^#;,Y+t'4ScUb4]m.0k[3Sh'S2<RL+*]>co7WuWhL_kSJEWG%PJR_`9HTCEs%*q#u')_H0"[C^dXA8a"PAGiCleuNDWJi1[]nPbaa52,Q"5(-g/LFDCPKuYrj=+EaHZ\jBJJuNhWRdC'K;*N]K3i@i(iMkNC!'"]8SRb8U-bNBg"@je,V>f\%F1*%*gO/<E14-U^X&<!hN[>%8hXI$hCJ9+R);-\Emrt8A/+-Qd$_ET[gB>E`.&'J7[t_f9:Y4E3&Q68`;*Yr"sJsS\7`Stm!P3Bp_&$S`hNJD!$bm%(O@lO6Wk+o79P#&Z5UMmPWc76h_l\SWo4rJ'`@lWLoIu4gmI600Yino@R^l\(8SJ*F9X29;,V-/Vic#Gfe\D-GFC>2JJk5'JPQHnQ&sR`YMn\dG.B5rmpI=1.F4W26/^%8hnJ]lZKn%Dr4$WTC38nB?!FgLd]J^#d`>]Z;V+fSO-0-g.&DrL#o%:R&GLL(dXD<j+&<@+/&,q\<.p9FeTuo=L=ilu5@0]t@5r.oO-2;5?4O?6ggP624j`=M0T1Dg<AG^`NFA$f:^=/df>-P?d,-;/0g"D*Ft2RkGq,gP6g_6^bGa)M2dk0jTXr;J>?HQ_,%n78Wp8<6K(20;'%+F?aeqg-E!+4mgNY`t*6[j+dXd$)(#UqtM&TCr>["hXCpZWQ)/1d;kV/+qIaEA(CD9"?Wf9S<hmOu0"TIOeN*#=W&33q$lVA2Tm<&ls-]R,Z8l05Jo+g9mq&J8!kkj@gYXSM9W;0P0'FgfN8Rr(._#2]ab_RG8<d4tu%V]NTd7#u<4,)'2ob1`@a0(+4\#sYPEo)jtg`]=Si,,<J#^fMGBqc?2S9:Wj2\go-gqu5&K"E^#WYc0T8iAp;XJX3&2j_\(=u34,NST?M3_D-JB7AQP:b<*2h9MW1&D#A1)f0$"2+GA9:b_\7<oOc:?3:ur*a<k]PeO<e@_.II%[+L/S_6Z9h&0mP5rUi4`G&oIO^$.kF)nE@0C&S1OS;6?XV9Hf1nn^i-+?P>+j<o%NbS#"K,+>F`a,^cN+oFR]66+@Q#=E[mrc&9NLB4J[c"dgbeMK/F:7Cj[d[:k_=68tQ@0@N:eR!`O"4^c):9ucZp74P-Z8N,Rpqq,2"5A;Q/^8LM2h3ffj[1ReI*=&#8>nL**5Va9VRM<r_RS(5;T&gJH~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1672
>>
stream
Gau0D@8GoC&H0nX(i_U9XOo@Q&kXZ>K"YH@"A96!]DE-GRDdJ=-$Ij_>EJ9nhq2Yl6JJ?@/4HMVSp9DBS!iFPnF8s&hlHl=8YGs7!25M0!=?FNP&pDgCjk1be"'@^o4bF!TsqN"76qW^*A2]W-iH10*Y!IFe-@W8,2*Pnh/D7.8UF,AOp[8C5U(OXV=oF7b`mYl81ErBEQtd4LFYRN8oofRI!C=OUNe**>_Bl#>2j][Wc#@>GBP,)#0@,9je)<OjXH$%4FQ3a&6XLa0BZ'f^@fWm06IerA6<N\hcpKu;LgjQ\aMG"M!t""69Im<l-eo9SR!)s6JOYXV_OS_1SBLlX^P+p+oHD/&]YaCe23\`r\6rB<nBB,+';Op^!M58:O%=X]&UVpR3Bp+$Z3E#ekl\"^>BC;!1:hn(F9lpI"XS\-;<M,Ui`bfPrT(3X>Pl_haa:g-J1IO6XDRf$qJe$7u/gsb>N]J1pU@qC4sO,E4!GOs"!.5Uk4OS%RYX$SlI;/EU?TQURJ6'Tm;ZL]U[$UrTF]L6^sB_e8q]9qi5eH`.QBfh3P]^Q"*uDo23X&#2Y%3Ff_>:O'2-90+;ZeoJFgg\4b;E0m2p;pWmR31>ES"MkoDb\(#bR`,A;'G=kn/UqW++\?o2kfgfO<QA(^Ddn/A5YZY$)ql!>YITONhTcO*HNO5s`hDfauC_0-pO[d&=!SbYm4s=>o5]53W(n&JYL)<_.eM_BCO@(>B%h!fi5u,M6-iG):Nb#>\0g*J\_mX.`=A0K>Ze*PKKYHK$is;sN>dF:EOeH4'9uDo-6+Uo,SsCrJ91Chr[?b]Q_;#4&,-7^b/5U1`(eF6FMHRe7no]sc/\t>^:_i+'CX/g'em>I4RDLrKN'o"n$:!5)F[g\q9%tda$8\ih[O8r>9!aDm*B'(?EY7sgc[TLYJm#3>:WAgDaq6NB)bQG](ra(h*'TC<;B%d&MBAN/c5h4tp-Ci$c?!AI/sJZG_"n,bdVM-"Mi=&jM@G*1iIYF[8U^%kio$<7P9p,B8q)C4;q(fNbqT[Ll5UoDe\HEE;>`-#pQ+%6!rTUK;8Ldab<`:GY:AfMh6*@9EFL*"bf7?a3L=3o#);09$Pn7Y[nA,=K.f!aPHVTEqb3!^BQ_[-LnY?O=kYi!9HX'F!?N6$0tE,oYEG[4a19Xn]u$V\.U;g+i@Z"O%X23rTjdH1_S5#PaYF>2mYFVrI.Qk6rLL.=-S%qng1JSbf9352Z_CT;^=\_;*3`TY<Sn"tMj!"H4(mJ;g^$lXX?O/5\Y;(@Fjin4c7]t;c7M5rC%S]lUt9C1>^)*/4ZTWC'h\9Zl*Or:l%E+F^/*2aN!aiD71h2\]1sn^+7)[5V*a;;35%:F,s4r`Za4ETNHF3*_Jd9@KA$KH1,%Ht"P<6*+LogRbqB;DnH&,$+t@VM'OX4E!,>&R%TN9KW!auc'%BRtPP.%nB`=!babba*;pl(T@Y/&H+4#2^&e+es'&'?BJM$,*q_UlR"Fn%;0/(g`+`(:iq&H+4hJ$SRoe?1U6Rafl>#u1m6C.Id6oK#""tp%iE5:critY)@+JR>cS\$(JR%)h[;V<JJkV3a`6uH<M=mt'e!S>W@OjRJ=SasPT,td0VY'9iaiS`P'9^"coZ#&t7JWn^%]1CK:,[&VF3-*FW<o<cB/R[mo:"-)r~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 202
>>
stream
GapqB5n8K#&-^EDVt`o-;.^0#aO3IXDN)7oV+!3e7BD@&hL`cFA2\r"AJgZKo5&_\X-)ig/>69e.8&e+Fum6Jr7b29+BbclW_*RtW&!2*Rc\q/A4b]DWhB5B82I1kDK4sYdp)TT/7RcrKJ=AUpG,J8DjUd@(l(/uRD_QK$cu?u,(dL3S6[('-^N+<kgf=\0a658%:Cm%~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2356
>>
stream
Gau`TCJWt](B)O^\3a@.4ju-o@u&)W.to7mA-F,L4HC$#U5E60C@!EG<B51#rSB"ccAV*9%+5Cf6,g#(3qqENj+3+QT)B[B!7qgQhK[uS&3YR\2uuFVd#3q`4S#L"__]@+Rk5`->3AZ5$j1kf5B]0@]4cKJpq`@;?@W\G6iS>13Bb5@^"U[J6lDH__$c5/h_ta7L.#e?^tAD-i0Pi1NhAb9Akss)R_niqcS^uXUsNh:5CJ.I$Ud2t\,M:MDn5#A/$?#-%pe4G0n*=rl5DTdq]i"XE_U%A-jnO]p!Z*DP-I,l@Za[RM\f0>.3KU4j^:IFF:OgOoJ7'"Mb2'#Lf\%/Kmjf#krD4uZ53kW<<&Vaa(%-Up%T3=KQEjW>#jM0Mc`bUms#HXR!,.KQ)5i&%/QBp)*sn]q[#sYp-U"$KJQ%R'-+-2f;6NT;KS`UCGqs7AWk&:aE_WH[14E2B.TqF1WY/h`hsq=O<eubaVfSKTIVe7GiO]i*:47u$FD@K7Q8[E["Sh%-T'\IVXo;SX,nu5.>kdHBO9X3h\DYJ7X0JJ@[oH)BjoFY/%ZtO3cj20cHll5I/8jlW-<+kg@6.rVl=ps9"#L;1F_K@.OJLVHh`uQXA.mYOeC(m;1#MXE*I5LDW&e`ThhIT[6`[<N$mW8-Cdf@OOTrqgFe(-niUKNFG:N9$W#28$``\oK/uH]_LT*JQD?nTXrOqb$/Pq$M,pTT>Q&'76#^\K&&AnEpt:0(F+d:8Q,>N+H&>^UNlWtDRW[+XKR.'PDI,&jWdV,pZ/(>S/fY-4Q3P%do!8#ZnCZtg7$!o9]=[sVj.LhPHgD8lCZ"it>HUgP1#s%4+<@uTefi6bL,Yk?CV#ORbkfbQG*4TH2_>=oe)g#/LFphlX1J=@-n49se"UQ.LaP]N,-M%l!<7EIec7<R;Cu?r9<K["9;/&XQ?3JE5T6)Pa1[/jS!6S]r7('+URT;I'Ku)lIuScL?a'LYlU:-paJ?bX"6!3ho826h6I"ML%no3*Ht%_U/&^R*`"-QQhgqCGi>T7*MDU$Wap_D`):$Tl.G!s0@Q=gsV.l6b9("<#E:0n(\`QRAr-f#CBQI(U<&YAqRglTDAd`9fCf38#G*!9Q33(ufe"39">V[9p(1>:(%kXQKeEq1Y>*iq4Z^/)0)T,\/B[k.']]E=>d&[`"fMBM9f\1SWq?/(553k[sG^DFr"UEf8]R)%qDUM)+m^rCV]1/j&p6WE98Rdf0BhE#oc+4BU8eS$U+B@J+<Y7kBMZU_A\-Y6M2T%1p?n24&.4!CJ'FB*qboh*m[RZh>UQe\kPn,!ZBBHB63es?#1O;=d?C?6H3uq&IHEM>JGb3rBmN15Z5$`L3(.l*q];99^=o>%C2]SP'RM7kUEU)BCP`J;CO:(NP.Xe.t=T""MbE;QnA.mp]9,;@K3C2!a1XV9iWq[)CkZ=SXP'i2i6ZkkuRMZ=_ad&VP6?/oSkR&`:DN)qf@T)GR-:P8o4Esc2>2P\\$$7i@A!6"i4T7sY4?j4t^Ee9*j7.dSBa!T)g^e`6bRol\_9LMShD8:RO0KY7;E,Wke)D-i;futg"Z/2e[D*+mU`u/>)k>phVHH1e%n2%@Q$GN(*V3ZKlSMWTr`At(3;-22_!u,OE4,Ptl2B.<Is?=o\`s]cf3c>-V-c)"X&:%54>LC+W]M&'Y28[$*+7FUeqmb+*5epNk[-0]2.?D'+tk^eb,%XdGY*!7PAd>`%6@U._,<m>106>Fb;k-eBP?G<f43+`\/lg,+q:Yn1crN:?^K!AQl1m>h19/aV=6%#k#NtX(bp-:#?gl)k/WWbi#$m4Ysi;/pW\LOs/rFrHajhBAc08:gAIP2Ze_?Jk8JT/A.pq/=j<[52-f-s/LToTG)NFD8A,D/hV#N-OZ"JDUga\`)O*cC<,s2i.QrB)$.ls^_N]`UZDsmN)csWYPrM"gZ4;U/2qC[XI%RDB[mh=[15[tI:$:V%XDj07HT0Mb7:pd8G4a'(3@j#?D/.8Z3)1&,\r`l+Ne`iH\;D9$ric.'JR^P/JfoU-'lZrN=U$!)b:oe^@c6B8_3[_r<n+)#G5YDA;Q:?Y4^9O;cCk<D@,=6im@i_:I2N^Wg'%%BWQ^*MJT?r7af#YtO*ToBXb5fmmT-?9pfE`KIeV`TpP7Z&=98gMk^C^0e.j0(TL^6u.Yd]mmOPY>2;b[RD!ng"Dsds)c9?\WW9frjnFu>PV-H_*J+5IP@]$J;.HCQV57d\=[)*t&G,Zf>I7FP`Oj=KqhQV,!qj)ku91q[9mB:W7M5#u1J/`/NN;mi`pB"LAl1?$s0>#d@F@8a@H\6`VdP+4AMg2?<M(tep6;('^Fu$8f$_*"SNrBRa%mrA~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1437
>>
stream
Gau0C>>O!E'Ro4H*$MGKZG^t-EE"EmBi39p2I!955$4h^p4Ts.1M/`mahQ^h^;[WJ!beilL[iU!I-3R-9JGrjbMQ/$n4*KTPmO;k_%]R5F!.uD0>YcjM-6Hm4jC4tJH_WXBs-haGRm#SVIB<''/[o&%+ba/Z!GOs5hr!)9X@aZC+]W,.^;g,>,.O[;UQKp1q7.O,\[U+7NnKm-IX4aP![%D1+1J.IXp>m"D>lQ^[]uiQ9Ap+HHmJ)]X2OEZ6P!n\Y3N8_/o?aGha!A)+oSH.QsV?6YUGfToXdTGEdGQT#O_0@52<jWSU9G>+i2H-uI>7S6A8Jo-b#=SekhF,[$^cNquZT&r%!2dkE>11^r\SNSY]j1HnTE^a#!fXDt<dQ\o%kP!%8=SnX=KgCheU/D7Q'5Z^$.Pt0Sq,179tbC(;9,=6.VD[`UN7asI9d>ig\Lq55EoU>1B3g2=[2&2B^0]rjAb:&RL_d3k.lhpTr,3Y8LN(?P##n^^O^M(KW8hLAH&t^qs0XkpZM/RZ%:FRD`IUq!7H5/KTJ)Ib9nAZsM.n\tZ<QplrF=@C@9>C>g>jL,sUhf:9oeo>oj1%d;]=T#,8q`^u'P>">WX.t1])SQi(OI;i!bW<PA@r[jCY7.AD5]\u;q_(KR=$o5*;`Zm.cAVn'i.g,75>5+<8'HUGOpOE-]A+>gE4H$Z?V)b!_tt@FB'4N\hN>J?-pd9@C5[o'L."3ih(>c#,,-&?4o;j@I6'MCo:%8&mM=K_3ZY\:n\FOiVg3A:3D($"mDVH1"(3n_D("[Na0IUo_lhF;iF75&)[5^GCo_V:^T2=THt@38DgmMf)s9A>f.Y82Y;cZm42p+Yb45!o:BPs6Q7"]S(J.p9!KDnRArbW]JH**IJg+%Y4LhB4CFK$Kd'=(C/W&d%e_lWD3d#*E-d]^Otp!RT0fQF8!g_a!/UhK5ZL-'Aj!1F6?3TF09JLtlt>jg3W!k$0qFJQWcGN%C`50X9#u`%Tk/L7i](B@5bOcp.Rl'>p6=*:7aWNN@[meIo:d93?n-DU3s=ffm_]Q*ZkLL5Ls,Z#(Jf[a7*pXV>^j`?Q.Fkcip$ej9VXK.ru"qM)I>G.I*7Wd3Ce:T)7sAbUU$7q`1Eics6Tm*#XWYOL8F=toP5"3mOEP%M7?[ZUIQMrE-?eVp]>KP-'*:s&WA*!nqa)6q$#V:%&?&Yh[&;*@mX85j"mEEOu,^1aS*@"SEOuoV:<8H\!%,WEBFmsKaD0E4bCJXCVPobp-DJ9)ICOQlR?4QR$&`6Wkka[4@^_\2^m'"L&T@s=@)S:.JRQ#W3-P.T8SBE&o,!GU=IepjW5.gMdm;W^%W]<*(7H7iK]3(8?N:Q!\r$*g%Ko8/E&<W`k*\#:bT&nm0k(BjN9eos7:cE"\P(iNZB<uSA^e]&*LaZe)GrbL=LJe83$DUJl8lb~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1345
>>
stream
Gatm:gMYb*&:Ml+$j(P0/8S<f?h3?=gYDqe`7kRb4r@BUdPth`OU=\!p##5"ZCL]QNLLB1"L':pcC?n-L&q)A"R^b?!rSA$qi:a\+f'/b@P<K.1OM_hmSAH-Qc[&7_l+ZoTKi(3.#DX[/UN$jN;<R"@6F%C&U8;[KVKC6m0T=$Cr-T'/La'n6"337TafA:11ahpZS3B72lZ4_Q)lL'FY9NS9k;AUR%;(c6D$R?*R\:HU;,nPKITNaT\9_n,rW2kqmf;H[g7BUW*AIBJU63"(RWrj?rX6;Qj!O-#*iR">`=6aL;#K,@n!Zn?qd3@2p;9RhuL@\gu07sc2(EZY64(j7_4dV?56cn9?:C`&GUdUc*O0!6iMVI$..V`MVeRaE`hn,!*1Z(8qUHk,r!]5eV]h2/=@,Ugd3S$07Z-O)@[7o,nm[LU`j'%$o`_f<4&/I#cdVSlht8_$8'VrN>b.0kogm)4\Pnf/m4%(:s5Rk>RFA]7\h<;"=KOU1cmc#=[.@eX=a,UNWqpVTgg2r&"Le;TV?05B1Bf"nsYA5<$%]9"Ja<_H(c>4^Fi''RHLf9g?@C`(C2FJi`*HK/KlhTXK('eTYulL1lC1Ve'N_CAL1?OBG.V6dQiQ0n2Z?I+Ao60^?1=&Bi4nmVUK=]dmd5N5@ApSmm6T9%puR"Kre=WO5e_I)=(G4?DdGpe/G;`CY/Os(@=(7\_u0iAu%J;)0.K)4F[h9h,GOp5W5Bb8T*mfFCL&0\mlR;XrZ]D[\^C?L?jsQ1]]l*c/J<<eQ$o&]@jQq%Y<n0JpJ(F6lpa:2RspZC,"7MHd_0i@L9!AeIaWlUF1j-4"Bk5=fUY3rc%'3,=n0JBi3Y/<n\Eb0#a+r)qnP8U:-=[1)'=Zl3T"c(&u9B/sgFEDSEhf6&p"e(T%Si.;VU1$)qc<RVcrVMskth4\o!0eald6M[,75O&/M4(iH9sDK6dBW&KAXC)BQdG'&U3I;^_(CSW$mJFT6f0AJA<(SoGWU<#&Rh_Q$BUGXFKh^UD3Xt/1bXNc#;`LPiRmd@Y0Zb*!'8fl];=t-WE+);$S0A'!Mq#P]KFa.p8<?1`8d]bEK12I:Xl_FoWFr:s4K2C*EdEGmbHRh&5WK5f5#7m?PaD;UeLlJuq_6XldLAi6CT3c@=jEU7YhdE[i$BhE59M:?)n?m#9k']%n4FqL%aL9Eqj\"oUUmWVqe$!he3AYdCTYW*^da-kjNM1^Ajpi,CO5jB1oQQ=!YJ=--#=__iL!Ee-H@G^LMoWR'$3X]r/iUrJIn,K6Z]LCRJ'I+O>Lb]^?adc:%SkLGI@!&`f)mK4ccHQbHK,WV1oG^rG:tRfnh2@71+N~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2371
>>
stream
Gatm<>BAf6&q801kfV(&>D_CP+^=iZ&8!;f-A3]3j]#*B1:@2/fTJaf#P.=ZbJpj0L.dXb9G1mXl;-5Chdh:p+05;[AH8@"E:_`abD,6_/D"iLqA"b6M]WbdG,5ulVX=.:"Fetl*7)UuE8%NiX63\pfPY)C4YU-]Z[4=ojTY'dBe]W^1DF8MjXCeMXTWaCS/(X[L_XDTThG1'Q60Q3%AplgiIBeK/HDs*!aq>CYJkdP3'<=+T#`G$h$['k!F[730MPn^Pt5#\P*CJ>Z32:OM1?ebJrmu3+:3flZGC`s[N3/-P"Smh:11mt:pQ3+PDg?*1&J]D>HS]kj7EcO'W"ZScS\G7"4t!kdg^-'[OiIKTtfO*lEadZ"MNA9a4[UMH@FI9Zpl_<h#O;D\m=(\oNSKUHHYp;g=bn^XYm8^jWE11<i10>c/&=ml`C%:cemFUD=lC0rTR<PFt[jJ$*-e+(9?eOH&3&,GAC^uBnr(adB,R*CBW`Ob.<J$:G5j'=gjYjQK&oZcO&GO@Ls<,lkNn5?,Nu*F+hl]B"p!Lj&q.b(;l/>]5:V<T[q-]4H*>%(,#(?cS/(&Lfft:O4M`?+3'gf?H/K@_LI_%9ZRa2-YW'u6lCoTF!VK;5_"8i8XTZl;WCAP(mFa)b0L=`-WR`#<5P0I'o('hIUI/H5qPeZ7]qDZ0FD*%7#Wa+ees6J_C'-mnUSUFSl`b^6/r(_RMnL#<=L$2T/^&M/T/B\9t7*aE#PVCUo_HkTHWKWs&ooAg0+P)K=-7/(XoZp(G83OY=97"(9nQSB_dn^;[dKBJ8mi\[Vo9g9jCg5k!`e4=#LjM8Q48FU+pnJ26Z@*^=@YHnQU0T?p//a'9,KB,YAhDUpAl$ZX]:a'+i=n'd?(B*JF';"?"ci#Ih$BjiOqmRjfE8CtBU<?(>:tgG@/7k9Gk1HM[J(-o9tPp#G@k6;//'OZ%A_W0/If+o5*\LerlJ65coJg-@=gVuJFlUMgHq\ia`dG:4.U'6kK-7u7(gn2U:BgDqaub]>M.D8IjM"'5q/V]$JQ<'-SWF:3/A,ms`[:o`fNmF39hR/XXRNX1uAr'1bI&Xk,_H%@\CP=Tbt;_cfV%*/b$&g7lW"YcrkY/V0c:WZX/5a!Zon]N@,3?S'Mr4iB=X9=_(]df.H,_c@F3XdHMNMS]Gln)uIS'55p[i4mYI[grEG93LQ=6OBI[G3gc&+%ui?QiS=BK&<(@"Nns*D[m2ZS8*)h\\P8Y<Wfn%'*mGgYce??WqE76+FNDoM4TZ,i#$YI'A"aI/g;m#;!_;'d+BK>]mJo+^5iqj1$T!$)#2OcP72s=3>oTX;_CA=iX@L##3;T,*$a0?,-\F86Ml\K9HK4%l@1$#daloBNVC"X&/*F6T0e:F3<bsfjO^*cqoFq=bdhe"4j"$dBP,df1''f8F/5$XM?hT/56HYQW6172?^2E,uiYJNAR-L0sm[7Eh6+<6b<sqL_hPYbtSMZ1rgcff\OHV5D2t%+f"#gp3&E`4:d11l#U4lq/cDoEH4;Bn+6MAYQ!/N]+_^D*^!BO@#bINo*UgBZK-_l<>7oh6?.:tLDP!DLp0SA0WM^!HOJA0l-G1O(7'6.a!u8f@)@`@'k!qt(6-&sK]R$lND)`OE%GKe*srqHH)BjH(_\6t<<Nj/_LTB=s1ZF#5;1!S9a[tii#5m%8<o[I(GL,^S2PE)CQ?"D&UO72OPSRpbLOE-^Ct1OiB0:d4H?"'rJ0@r<,m9JgLS[RIbPk7<6W+>QM:.&ZE0A6:BI:F,?:GC%=h1'O"l`rfi_rAJhUaskVlp+MF]A#9T:>CD@0U"MA*j_J$=,W'H)Z_S7B'UlAt5[Kg_r)(:GRO2%1kT'q4`mAaRoRSR<"+1fF'&Q_Mn?W^^^`2_6\*aj@#2Fq1h!3moc'@Z_kFC:R`V_IuJXL@LG*[UD\\T+fEMCkG;[=_0]T-2j>EBe'Xso1*=(X/n1,1\6i;GZ>L]ANc7(=ES(DQ_OsK4#Lm^+@oIN/@Gu;4E4%5JikR@(\g6ILtc3O36.o][]7g"n<@HDj0GPC3Z9WkjKh"[IPJi^,h!PM:#>$KJ+2XUL!bV>s5`n^"M8R-b[o$F;=sqdg2bn`:RO;*c(1]57=-sl[sP5K2:PR-^GCS]H:m#K[AMGM:R.8fmE+pMSi3^A8K?;[e[8UsZ?i1A?YdX73CnFVeiot3-S<B?#d-GY6!FjX`cT0R[@mNUbl=s:NnCTWIG_VKJ^qKR<X)TjDd-LOZtgPfh0$.Ah'We_coWZ'[+c4!+9)N6qX;0S7.!"Jk/OY^js1HtH3L1"O=IP;[k$)]@X?QNApI$TQGWUjol)YjjX.KRf#a'pgHmICoW+4t29)fPa4H[2E:H07SK#T~>endstream
endobj
xref
0 27
0000000000 65535 f
0000000061 00000 n
0000000142 00000 n
0000000249 00000 n
0000000361 00000 n
0000000566 00000 n
0000000643 00000 n
0000000748 00000 n
0000000831 00000 n
0000000946 00000 n
0000001151 00000 n
0000001357 00000 n
0000001563 00000 n
0000001769 00000 n
0000001975 00000 n
0000002181 00000 n
0000002387 00000 n
0000002457 00000 n
0000002768 00000 n
0000002876 00000 n
0000004567 00000 n
0000006158 00000 n
0000007922 00000 n
0000008215 00000 n
0000010663 00000 n
0000012192 00000 n
0000013629 00000 n
trailer
<<
/ID
[<59239bdb7236caef8d2ae77a53deb3e4><59239bdb7236caef8d2ae77a53deb3e4>]
% ReportLab generated PDF document -- digest (opensource)
/Info 17 0 R
/Root 16 0 R
/Size 27
>>
startxref
16092
%%EOF
+231
View File
@@ -0,0 +1,231 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R /F5 7 0 R /F6 8 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
9 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/PageMode /UseNone /Pages 18 0 R /Type /Catalog
>>
endobj
17 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260924194053+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260924194053+05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (SAP-ERP Portal \204 DNS & HTTPS Setup Guide) /Trapped /False
>>
endobj
18 0 obj
<<
/Count 8 /Kids [ 4 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R ] /Type /Pages
>>
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1505
>>
stream
Gat=*gN):5&:N^lqItl3'#g"ef^m*0REJWo@KKQA%s`/2&X9OH7lIU<f5F<r?s!]6M8Mc(pZZR$c4L3b+)9\s*8ll<>^`;2)9`m^4lX<U%W:H^[fL>.r@r;Z)_`*6+n8jq_`0J_+RNU^F1?;tF.(>cC'3Vs%\]<h[]hq*kSG)]-I^/ma!j]IY2Tk](`UB;_[M!HDtEdH"uBSf$M_1F.DLE//'L3qpl-`f#<p08A,X9#C]E>^;nq\Nb^<?rD4]JDD8jE]ZJo%s@@AjZjtbnf@A*jkBsr_l.OuD"]S*1+f*08bOc`&n,D_80mLNGZI(Fj9Ld<l=D-'*Rp$XR`iag.ch4!Ym6_W$pgC]Z4=_XJE7T<$:+lU!F$^&@B#>pd;Zoi9#_RL?rm_;Qd@$4Juc$(%Yk"2EA(9VuaP?#"i>#\_S3kdDZ:.2tTgl>fq5(=&5#ke5Xf7C:`3Hu\nN.iko174U6%"^W-lHgrP5aXKRr!8,AqCM4:[oh[:XVamlXAusGL:;1T[$!G9e]CR<J-=As@mJh1,rY]uQ[or,icnr;Q^CNh=F<G3o<^^23T`]'+:N3bP@tq\A'>L<+`mT?.LaW)#Ji(OHJVe0p3of'$5eH+aPCjOl0PN>J3A\+2/=qZ<dCD"rlk+A.J&DVqm7/ZO8E0Q%Tb>U;pEMVGU+7*KLODX76[Z-pUscd))U^QDTPc&TM14e1C$/nM8r9LEB@*F(J,V[&W42i3bN3q66946[-.$HV4>2a:c*8QSDLT7*J*Z=%WXof;tt][5Fo5^n2/oFK;XXf8HTfV9t$(h%&J?M[k<n7%%C`:2(qWDVb)rZcEu?"r$>mR2C4Nqa^(,,j9BK1_)4&n"4o?U.cVo-4hq_WfZm@F3n%5eddQ6j+1oaO>O.!DeYl9VX0SVYHC7NNiAua"UahFZ`-qY=*geb6GL7M6]^u/toHr-<A4legrA:JR2nJ87gnk@M4_XFh^[PtcF(^AI_`f],B=_dX-^iB>;IsVRQ%IlgZUu.2/7rU*g,6R;`[RJPT\lh?;0^pG.H7+I[H*qJW6bs4@!+N])P"L5SXs?;jTLYWLVAW5^eb&X(*'^djSbYN8$)g&]k>,XCLDLKYVnY@;8#5lL2O4GO.cd4q)7qad+Wu>P&?o['_J>V(]K<#qo%sQSIqpk1pm,Y9l3-pYRhNj8VK6I\[R8gPj'p3=2fV<HVlYELqENGkNq)#-h+4"`fS$*h,f3QB5=ifH[EH2[5c'6HWB^:H";r1KLACgf.tfR9DNg1XX&SFQ05^6]kl>g&L&#(3N/gP#/CK^'"1J(YY_nX/=Q^qS&#^HjYHhW)i6"4L2)^7fL=%!23d6A89bl8dcETc>+$ch/qn[-Kc\MFAN9;Vd*G2.%d.@*GqA!BRQ47B#gAX0`(MXtQ>qWSF1#o>OrV/S:9&0I+n"X#'-gTS1Y3l+8]Gc<8gBI#)U]!'Q8)]X$q?)\eW='0J+b5l;N=S'<YZr1n8=4An[uK]+'sY0n/q''=)J~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1499
>>
stream
Gatm;?$#!`'Re<2\?h`$8LDj=Z4/d3Cq;^q9UaB=e",au7C<;4K-aP2@HjuaPmkT=Ftb(O0EurqX)m\F+U%QIrB2e75)eFuV[u0%UG7EVM.(K:+%I.)0#I;*UdkJ)FXcsoI?HuiN9bGMKL(uK6Qe5R"\B%HNY7f#TI1PcB=NZ[_5Ka-$=Vp:.!5N\+k%22OAX_a?QU?J:<Bc./2X\"a\*J!L!c/Aj"Gd#1*q7+.1&SS`_b[>$2`SL5tGB['Yn**Ajt!eiN:Nj=1W'R:=uV8'DUMjGQj(+#9Gkp^jet\;sqI1f?[\hed58I;F6#_^#;,Y+t'4ScUb4]m.0k[3Sh'S2<RL+*]>co7WuWhL_kSJEWG%PJR_`9HTCEs%*q#u')_H0"[C^dXA8a"PAGiCleuNDWJi1[]nPbaa52,Q"5(-g/LFDCPKuYrj=+EaHZ\jBJJuNhWRdC'K;*N]K3i@i(iMkNC!'"]8SRb8U-bNBg"@je,V>f\%F1*%*gO/<E14-U^X&<!hN[>%8hXI$hCJ9+R);-\Emrt8A/+-Qd$_ET[gB>E`.&'J7[t_f9:Y4E3&Q68`;*Yr"sJsS\7`Stm!P3Bp_&$S`hNJD!$bm%(O@lO6Wk+o79P#&Z5UMmPWc76h_l\SWo4rJ'`@lWLoIu4gmI600Yino@R^l\(8SJ*F9X29;,V-/Vic#Gfe\D-GFC>2JJk5'JPQHnQ&sR`YMn\dG.B5rmpI=1.F4W26/^%8hnJ]lZKn%Dr4$WTC38nB?!FgLd]J^#d`>]Z;V+fSO-0-g.&DrL#o%:R&GLL(dXD<j+&<@+/&,q\<.p9FeTuo=L=ilu5@0]t@5r.oO-2;5?4O?6ggP624j`=M0T1Dg<AG^`NFA$f:^=/df>-P?d,-;/0g"D*Ft2RkGq,gP6g_6^bGa)M2dk0jTXr;J>?HQ_,%n78Wp8<6K(20;'%+F?aeqg-E!+4mgNY`t*6[j+dXd$)(#UqtM&TCr>["hXCpZWQ)/1d;kV/+qIaEA(CD9"?Wf9S<hmOu0"TIOeN*#=W&33q$lVA2Tm<&ls-]R,Z8l05Jo+g9mq&J8!kkj@gYXSM9W;0P0'FgfN8Rr(._#2]ab_RG8<d4tu%V]NTd7#u<4,)'2ob1`@a0(+4\#sYPEo)jtg`]=Si,,<J#^fMGBqc?2S9:Wj2\go-gqu5&K"E^#WYc0T8iAp;XJX3&2j_\(=u34,NST?M3_D-JB7AQP:b<*2h9MW1&D#A1)f0$"2+GA9:b_\7<oOc:?3:ur*a<k]PeO<e@_.II%[+L/S_6Z9h&0mP5rUi4`G&oIO^$.kF)nE@0C&S1OS;6?XV9Hf1nn^i-+?P>+j<o%NbS#"K,+>F`a,^cN+oFR]66+@Q#=E[mrc&9NLB4J[c"dgbeMK/F:7Cj[d[:k_=68tQ@0@N:eR!`O"4^c):9ucZp74P-Z8N,Rpqq,2"5A;Q/^8LM2h3ffj[1ReI*=&#8>nL**5Va9VRM<r_RS(5;T&gJH~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1672
>>
stream
Gau0D@8GoC&H0nX(i_U9XOo@Q&kXZ>K"YH@"A96!]DE-GRDdJ=-$Ij_>EJ9nhq2Yl6JJ?@/4HMVSp9DBS!iFPnF8s&hlHl=8YGs7!25M0!=?FNP&pDgCjk1be"'@^o4bF!TsqN"76qW^*A2]W-iH10*Y!IFe-@W8,2*Pnh/D7.8UF,AOp[8C5U(OXV=oF7b`mYl81ErBEQtd4LFYRN8oofRI!C=OUNe**>_Bl#>2j][Wc#@>GBP,)#0@,9je)<OjXH$%4FQ3a&6XLa0BZ'f^@fWm06IerA6<N\hcpKu;LgjQ\aMG"M!t""69Im<l-eo9SR!)s6JOYXV_OS_1SBLlX^P+p+oHD/&]YaCe23\`r\6rB<nBB,+';Op^!M58:O%=X]&UVpR3Bp+$Z3E#ekl\"^>BC;!1:hn(F9lpI"XS\-;<M,Ui`bfPrT(3X>Pl_haa:g-J1IO6XDRf$qJe$7u/gsb>N]J1pU@qC4sO,E4!GOs"!.5Uk4OS%RYX$SlI;/EU?TQURJ6'Tm;ZL]U[$UrTF]L6^sB_e8q]9qi5eH`.QBfh3P]^Q"*uDo23X&#2Y%3Ff_>:O'2-90+;ZeoJFgg\4b;E0m2p;pWmR31>ES"MkoDb\(#bR`,A;'G=kn/UqW++\?o2kfgfO<QA(^Ddn/A5YZY$)ql!>YITONhTcO*HNO5s`hDfauC_0-pO[d&=!SbYm4s=>o5]53W(n&JYL)<_.eM_BCO@(>B%h!fi5u,M6-iG):Nb#>\0g*J\_mX.`=A0K>Ze*PKKYHK$is;sN>dF:EOeH4'9uDo-6+Uo,SsCrJ91Chr[?b]Q_;#4&,-7^b/5U1`(eF6FMHRe7no]sc/\t>^:_i+'CX/g'em>I4RDLrKN'o"n$:!5)F[g\q9%tda$8\ih[O8r>9!aDm*B'(?EY7sgc[TLYJm#3>:WAgDaq6NB)bQG](ra(h*'TC<;B%d&MBAN/c5h4tp-Ci$c?!AI/sJZG_"n,bdVM-"Mi=&jM@G*1iIYF[8U^%kio$<7P9p,B8q)C4;q(fNbqT[Ll5UoDe\HEE;>`-#pQ+%6!rTUK;8Ldab<`:GY:AfMh6*@9EFL*"bf7?a3L=3o#);09$Pn7Y[nA,=K.f!aPHVTEqb3!^BQ_[-LnY?O=kYi!9HX'F!?N6$0tE,oYEG[4a19Xn]u$V\.U;g+i@Z"O%X23rTjdH1_S5#PaYF>2mYFVrI.Qk6rLL.=-S%qng1JSbf9352Z_CT;^=\_;*3`TY<Sn"tMj!"H4(mJ;g^$lXX?O/5\Y;(@Fjin4c7]t;c7M5rC%S]lUt9C1>^)*/4ZTWC'h\9Zl*Or:l%E+F^/*2aN!aiD71h2\]1sn^+7)[5V*a;;35%:F,s4r`Za4ETNHF3*_Jd9@KA$KH1,%Ht"P<6*+LogRbqB;DnH&,$+t@VM'OX4E!,>&R%TN9KW!auc'%BRtPP.%nB`=!babba*;pl(T@Y/&H+4#2^&e+es'&'?BJM$,*q_UlR"Fn%;0/(g`+`(:iq&H+4hJ$SRoe?1U6Rafl>#u1m6C.Id6oK#""tp%iE5:critY)@+JR>cS\$(JR%)h[;V<JJkV3a`6uH<M=mt'e!S>W@OjRJ=SasPT,td0VY'9iaiS`P'9^"coZ#&t7JWn^%]1CK:,[&VF3-*FW<o<cB/R[mo:"-)r~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 202
>>
stream
GapqB5n8K#&-^EDVt`o-;.^0#aO3IXDN)7oV+!3e7BD@&hL`cFA2\r"AJgZKo5&_\X-)ig/>69e.8&e+Fum6Jr7b29+BbclW_*RtW&!2*Rc\q/A4b]DWhB5B82I1kDK4sYdp)TT/7RcrKJ=AUpG,J8DjUd@(l(/uRD_QK$cu?u,(dL3S6[('-^N+<kgf=\0a658%:Cm%~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2356
>>
stream
Gau`TCJWt](B)O^\3a@.4ju-o@u&)W.to7mA-F,L4HC$#U5E60C@!EG<B51#rSB"ccAV*9%+5Cf6,g#(3qqENj+3+QT)B[B!7qgQhK[uS&3YR\2uuFVd#3q`4S#L"__]@+Rk5`->3AZ5$j1kf5B]0@]4cKJpq`@;?@W\G6iS>13Bb5@^"U[J6lDH__$c5/h_ta7L.#e?^tAD-i0Pi1NhAb9Akss)R_niqcS^uXUsNh:5CJ.I$Ud2t\,M:MDn5#A/$?#-%pe4G0n*=rl5DTdq]i"XE_U%A-jnO]p!Z*DP-I,l@Za[RM\f0>.3KU4j^:IFF:OgOoJ7'"Mb2'#Lf\%/Kmjf#krD4uZ53kW<<&Vaa(%-Up%T3=KQEjW>#jM0Mc`bUms#HXR!,.KQ)5i&%/QBp)*sn]q[#sYp-U"$KJQ%R'-+-2f;6NT;KS`UCGqs7AWk&:aE_WH[14E2B.TqF1WY/h`hsq=O<eubaVfSKTIVe7GiO]i*:47u$FD@K7Q8[E["Sh%-T'\IVXo;SX,nu5.>kdHBO9X3h\DYJ7X0JJ@[oH)BjoFY/%ZtO3cj20cHll5I/8jlW-<+kg@6.rVl=ps9"#L;1F_K@.OJLVHh`uQXA.mYOeC(m;1#MXE*I5LDW&e`ThhIT[6`[<N$mW8-Cdf@OOTrqgFe(-niUKNFG:N9$W#28$``\oK/uH]_LT*JQD?nTXrOqb$/Pq$M,pTT>Q&'76#^\K&&AnEpt:0(F+d:8Q,>N+H&>^UNlWtDRW[+XKR.'PDI,&jWdV,pZ/(>S/fY-4Q3P%do!8#ZnCZtg7$!o9]=[sVj.LhPHgD8lCZ"it>HUgP1#s%4+<@uTefi6bL,Yk?CV#ORbkfbQG*4TH2_>=oe)g#/LFphlX1J=@-n49se"UQ.LaP]N,-M%l!<7EIec7<R;Cu?r9<K["9;/&XQ?3JE5T6)Pa1[/jS!6S]r7('+URT;I'Ku)lIuScL?a'LYlU:-paJ?bX"6!3ho826h6I"ML%no3*Ht%_U/&^R*`"-QQhgqCGi>T7*MDU$Wap_D`):$Tl.G!s0@Q=gsV.l6b9("<#E:0n(\`QRAr-f#CBQI(U<&YAqRglTDAd`9fCf38#G*!9Q33(ufe"39">V[9p(1>:(%kXQKeEq1Y>*iq4Z^/)0)T,\/B[k.']]E=>d&[`"fMBM9f\1SWq?/(553k[sG^DFr"UEf8]R)%qDUM)+m^rCV]1/j&p6WE98Rdf0BhE#oc+4BU8eS$U+B@J+<Y7kBMZU_A\-Y6M2T%1p?n24&.4!CJ'FB*qboh*m[RZh>UQe\kPn,!ZBBHB63es?#1O;=d?C?6H3uq&IHEM>JGb3rBmN15Z5$`L3(.l*q];99^=o>%C2]SP'RM7kUEU)BCP`J;CO:(NP.Xe.t=T""MbE;QnA.mp]9,;@K3C2!a1XV9iWq[)CkZ=SXP'i2i6ZkkuRMZ=_ad&VP6?/oSkR&`:DN)qf@T)GR-:P8o4Esc2>2P\\$$7i@A!6"i4T7sY4?j4t^Ee9*j7.dSBa!T)g^e`6bRol\_9LMShD8:RO0KY7;E,Wke)D-i;futg"Z/2e[D*+mU`u/>)k>phVHH1e%n2%@Q$GN(*V3ZKlSMWTr`At(3;-22_!u,OE4,Ptl2B.<Is?=o\`s]cf3c>-V-c)"X&:%54>LC+W]M&'Y28[$*+7FUeqmb+*5epNk[-0]2.?D'+tk^eb,%XdGY*!7PAd>`%6@U._,<m>106>Fb;k-eBP?G<f43+`\/lg,+q:Yn1crN:?^K!AQl1m>h19/aV=6%#k#NtX(bp-:#?gl)k/WWbi#$m4Ysi;/pW\LOs/rFrHajhBAc08:gAIP2Ze_?Jk8JT/A.pq/=j<[52-f-s/LToTG)NFD8A,D/hV#N-OZ"JDUga\`)O*cC<,s2i.QrB)$.ls^_N]`UZDsmN)csWYPrM"gZ4;U/2qC[XI%RDB[mh=[15[tI:$:V%XDj07HT0Mb7:pd8G4a'(3@j#?D/.8Z3)1&,\r`l+Ne`iH\;D9$ric.'JR^P/JfoU-'lZrN=U$!)b:oe^@c6B8_3[_r<n+)#G5YDA;Q:?Y4^9O;cCk<D@,=6im@i_:I2N^Wg'%%BWQ^*MJT?r7af#YtO*ToBXb5fmmT-?9pfE`KIeV`TpP7Z&=98gMk^C^0e.j0(TL^6u.Yd]mmOPY>2;b[RD!ng"Dsds)c9?\WW9frjnFu>PV-H_*J+5IP@]$J;.HCQV57d\=[)*t&G,Zf>I7FP`Oj=KqhQV,!qj)ku91q[9mB:W7M5#u1J/`/NN;mi`pB"LAl1?$s0>#d@F@8a@H\6`VdP+4AMg2?<M(tep6;('^Fu$8f$_*"SNrBRa%mrA~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1437
>>
stream
Gau0C>>O!E'Ro4H*$MGKZG^t-EE"EmBi39p2I!955$4h^p4Ts.1M/`mahQ^h^;[WJ!beilL[iU!I-3R-9JGrjbMQ/$n4*KTPmO;k_%]R5F!.uD0>YcjM-6Hm4jC4tJH_WXBs-haGRm#SVIB<''/[o&%+ba/Z!GOs5hr!)9X@aZC+]W,.^;g,>,.O[;UQKp1q7.O,\[U+7NnKm-IX4aP![%D1+1J.IXp>m"D>lQ^[]uiQ9Ap+HHmJ)]X2OEZ6P!n\Y3N8_/o?aGha!A)+oSH.QsV?6YUGfToXdTGEdGQT#O_0@52<jWSU9G>+i2H-uI>7S6A8Jo-b#=SekhF,[$^cNquZT&r%!2dkE>11^r\SNSY]j1HnTE^a#!fXDt<dQ\o%kP!%8=SnX=KgCheU/D7Q'5Z^$.Pt0Sq,179tbC(;9,=6.VD[`UN7asI9d>ig\Lq55EoU>1B3g2=[2&2B^0]rjAb:&RL_d3k.lhpTr,3Y8LN(?P##n^^O^M(KW8hLAH&t^qs0XkpZM/RZ%:FRD`IUq!7H5/KTJ)Ib9nAZsM.n\tZ<QplrF=@C@9>C>g>jL,sUhf:9oeo>oj1%d;]=T#,8q`^u'P>">WX.t1])SQi(OI;i!bW<PA@r[jCY7.AD5]\u;q_(KR=$o5*;`Zm.cAVn'i.g,75>5+<8'HUGOpOE-]A+>gE4H$Z?V)b!_tt@FB'4N\hN>J?-pd9@C5[o'L."3ih(>c#,,-&?4o;j@I6'MCo:%8&mM=K_3ZY\:n\FOiVg3A:3D($"mDVH1"(3n_D("[Na0IUo_lhF;iF75&)[5^GCo_V:^T2=THt@38DgmMf)s9A>f.Y82Y;cZm42p+Yb45!o:BPs6Q7"]S(J.p9!KDnRArbW]JH**IJg+%Y4LhB4CFK$Kd'=(C/W&d%e_lWD3d#*E-d]^Otp!RT0fQF8!g_a!/UhK5ZL-'Aj!1F6?3TF09JLtlt>jg3W!k$0qFJQWcGN%C`50X9#u`%Tk/L7i](B@5bOcp.Rl'>p6=*:7aWNN@[meIo:d93?n-DU3s=ffm_]Q*ZkLL5Ls,Z#(Jf[a7*pXV>^j`?Q.Fkcip$ej9VXK.ru"qM)I>G.I*7Wd3Ce:T)7sAbUU$7q`1Eics6Tm*#XWYOL8F=toP5"3mOEP%M7?[ZUIQMrE-?eVp]>KP-'*:s&WA*!nqa)6q$#V:%&?&Yh[&;*@mX85j"mEEOu,^1aS*@"SEOuoV:<8H\!%,WEBFmsKaD0E4bCJXCVPobp-DJ9)ICOQlR?4QR$&`6Wkka[4@^_\2^m'"L&T@s=@)S:.JRQ#W3-P.T8SBE&o,!GU=IepjW5.gMdm;W^%W]<*(7H7iK]3(8?N:Q!\r$*g%Ko8/E&<W`k*\#:bT&nm0k(BjN9eos7:cE"\P(iNZB<uSA^e]&*LaZe)GrbL=LJe83$DUJl8lb~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1345
>>
stream
Gatm:gMYb*&:Ml+$j(P0/8S<f?h3?=gYDqe`7kRb4r@BUdPth`OU=\!p##5"ZCL]QNLLB1"L':pcC?n-L&q)A"R^b?!rSA$qi:a\+f'/b@P<K.1OM_hmSAH-Qc[&7_l+ZoTKi(3.#DX[/UN$jN;<R"@6F%C&U8;[KVKC6m0T=$Cr-T'/La'n6"337TafA:11ahpZS3B72lZ4_Q)lL'FY9NS9k;AUR%;(c6D$R?*R\:HU;,nPKITNaT\9_n,rW2kqmf;H[g7BUW*AIBJU63"(RWrj?rX6;Qj!O-#*iR">`=6aL;#K,@n!Zn?qd3@2p;9RhuL@\gu07sc2(EZY64(j7_4dV?56cn9?:C`&GUdUc*O0!6iMVI$..V`MVeRaE`hn,!*1Z(8qUHk,r!]5eV]h2/=@,Ugd3S$07Z-O)@[7o,nm[LU`j'%$o`_f<4&/I#cdVSlht8_$8'VrN>b.0kogm)4\Pnf/m4%(:s5Rk>RFA]7\h<;"=KOU1cmc#=[.@eX=a,UNWqpVTgg2r&"Le;TV?05B1Bf"nsYA5<$%]9"Ja<_H(c>4^Fi''RHLf9g?@C`(C2FJi`*HK/KlhTXK('eTYulL1lC1Ve'N_CAL1?OBG.V6dQiQ0n2Z?I+Ao60^?1=&Bi4nmVUK=]dmd5N5@ApSmm6T9%puR"Kre=WO5e_I)=(G4?DdGpe/G;`CY/Os(@=(7\_u0iAu%J;)0.K)4F[h9h,GOp5W5Bb8T*mfFCL&0\mlR;XrZ]D[\^C?L?jsQ1]]l*c/J<<eQ$o&]@jQq%Y<n0JpJ(F6lpa:2RspZC,"7MHd_0i@L9!AeIaWlUF1j-4"Bk5=fUY3rc%'3,=n0JBi3Y/<n\Eb0#a+r)qnP8U:-=[1)'=Zl3T"c(&u9B/sgFEDSEhf6&p"e(T%Si.;VU1$)qc<RVcrVMskth4\o!0eald6M[,75O&/M4(iH9sDK6dBW&KAXC)BQdG'&U3I;^_(CSW$mJFT6f0AJA<(SoGWU<#&Rh_Q$BUGXFKh^UD3Xt/1bXNc#;`LPiRmd@Y0Zb*!'8fl];=t-WE+);$S0A'!Mq#P]KFa.p8<?1`8d]bEK12I:Xl_FoWFr:s4K2C*EdEGmbHRh&5WK5f5#7m?PaD;UeLlJuq_6XldLAi6CT3c@=jEU7YhdE[i$BhE59M:?)n?m#9k']%n4FqL%aL9Eqj\"oUUmWVqe$!he3AYdCTYW*^da-kjNM1^Ajpi,CO5jB1oQQ=!YJ=--#=__iL!Ee-H@G^LMoWR'$3X]r/iUrJIn,K6Z]LCRJ'I+O>Lb]^?adc:%SkLGI@!&`f)mK4ccHQbHK,WV1oG^rG:tRfnh2@71+N~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2131
>>
stream
Gat=+>BALZ&:Vs/fZ?dL1PpaD\Y[a[8k>F-#uErac^A3r&[h\9[cC&$+TMGBHk@]ZctQ#iCe9!Lp=n-#$T7jlg&F>Hn7_sUG:pBH9GZR_NXq<YkYFs&T")bb7a%cVoqhtrB%q%L)a9k2A]Lp`SYcdTkI52c%QJpNiH9O0b7g-)=u@n!`2LNT[p`6\Sl'GjOWp>d<3:i#N%a-U/2(E%%$SGbnuqiAB7Sm<(f8DAhl:JQlHl;0k#!@%GHXt<'p(\Y,`5o:`AFYt/>Af9c4&&MVBAScJaa3m.$LlC)a#qL]GCF<M1ub1'Ga":V]-)6`RHn]F0k*f?/KK"@e+l=EBNEXd=0PcC4=?Y.tl%nr!S4I:U.rW\+f903mDg]/Tj+VVVa]LST`bg2sdMUZZ<)6n!./Pr^?<qf_e5dmo[50Y4)CA>]6g4<c437*jhJ#,J53B.O8d2`mAE=U7FN^`ebINKa3m&DS>;a47uWcIugC_mbdE>g5bK!L#9.0AS[S"L>_N)f^]Cnh1Pn]H3:)>r.RNiKL!J\+o0e^MTtHX;FT[Tr?@Ii4$D_]Po>R"QC.8_%JK[8V0)iEBSZctBm@Ql/*n362GpVc0(]Kd!=9i:Qst5.N+32M%RtL&"G/:7H9NNN_^BYSZ!RNhDKpm!:K":A^Dgk9C2hKVQ(183A3?1)QVtm8#DMr\6:`AfJ<alOT_kSJ]#_J3JH0dpMa0\768To:O+M#(g4G[O"S5U-78[b778Voo>=>KP^hLc+iN+04'g7KH2_(L%>HkmW`.Jo:m6[aB-s%lb*q]e'dpSc@G;GfPr:9dMe3rK2f]NBbE>/s7I#*QLSfZ1AEB(e?*+!'t6b4CSUqt,`91T>/,8=)poeFk1.PKNUFrs4!jG;7JaUsK57P+Ej*;'Q8Fk.KAmQTL%.jR^9FLiW8P-fW.BKXK:RSGAn_49LB5XD9;S%GFt3Wu2%kC?/Vktf7eQ?$TsrX'k^Z/?Af-om:i$'d5DM@$FNE,ubF3L<0,;p[?r,5cHi>cVs?J)]"Fh/Ou6;D=*^+&SE&d-":LagN*\L#;.-b`Pf\qnQBPT-Ze['nQg-V2-mJHf"DS"0b5&(aI%fjM4_q3'n2j`iks;^'cS[V=PP-C9t2>1kp\u0p(3c>7&@N8e)Q+-G1d`YTPJpTESqr0o@Nd2?4'$]G[Amb^c#p<lI]6'rPi?VHc*X(a+d#;0=,e<b!d1nA=f:OtNtui.:,^Fon/8l4q^4j)i0Q)Q*,8FG&CBScmM`l7<rX97aN\07rQ.i#O;&AW3]Nj-7Gq?;Tm@_Nd;:J1^O[a5#.@^+l3HT;u"r>a*ETAjHX?+f'T>^4ZFGk,(?ac[,Q[ACg];q(`rI:]?j>&,tfWgts0hdE7rGF<C1YkIFh*Pa8;k/O^SU-@ic^][A#.du(i`k_+P+@%!f^BVKL+#p.Gd^hsZ;$%?EYMO2bJ<KO%Q7`d9Y4@DO``W2go(qWpuWuUmejGtV\\4J$u*)<c3_\EeXBi@*r(,-d>-P2@iM5QjD3EY@_X`sPPX4Q4K[PrS]*"HS5mUQ8UdBsU6'f$O.i[LE_;[e(5at?H6?764Q9_7gZAr9.mDVe.7HNPCQfVUG2!plHK8r)".$@sCGl53g/?%#Ie;Lk\5TI_LIHW`tF$-5/N8PSF50BI6!OKA_0e$>UGg`LXYGjuBhmAZ@M57Dj`BpbQ*h+2I<q*M:^iK@es[rM)$@;:]0egppBW-8C<[dEUj@t8D>MUko$f6kVkN%UHbMQAWj*h#h,6%0ojp+C`M#-#Ppn!J8b@W!\W1W35e_JBL;?Y#16`_Cp>B&%$=,,?hW2eM#,X:;#V+oVp-%TF`sG>U3gd,4Weq#l]!J&e0WS-H(^\.`eMEV#""=gk]DYku"4fiZpJ5`ckM.SJpO0&_I\?,B+DKVM@d#p8Q_Y0h,Uf<2-2VHlV*4kNA-(@\;.n94GNV0Wra,,:4(_*2]6oibZ;I'[(,03>n0)QOfph2\E=2P;)F):jJe`3gL^)md!=G[@ul>FkWoef%TIh@4q3?Ie6unHT<12h0`1rq1=*mq`6(Eu]-3,*CbbdKhf(c"@%C!3nT>lSojMZ[9GK]'>>H@#0luVWT[,Yj94(d2qCKYH\d80Ab"SE:Q5Ug:0$~>endstream
endobj
xref
0 27
0000000000 65535 f
0000000061 00000 n
0000000142 00000 n
0000000249 00000 n
0000000361 00000 n
0000000566 00000 n
0000000643 00000 n
0000000748 00000 n
0000000831 00000 n
0000000946 00000 n
0000001151 00000 n
0000001357 00000 n
0000001563 00000 n
0000001769 00000 n
0000001975 00000 n
0000002181 00000 n
0000002387 00000 n
0000002457 00000 n
0000002768 00000 n
0000002876 00000 n
0000004473 00000 n
0000006064 00000 n
0000007828 00000 n
0000008121 00000 n
0000010569 00000 n
0000012098 00000 n
0000013535 00000 n
trailer
<<
/ID
[<ac6ce4a88018a004412fc82099a0fa5d><ac6ce4a88018a004412fc82099a0fa5d>]
% ReportLab generated PDF document -- digest (opensource)
/Info 17 0 R
/Root 16 0 R
/Size 27
>>
startxref
15758
%%EOF
+269
View File
@@ -0,0 +1,269 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R /F5 7 0 R /F6 8 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/BaseFont /ZapfDingbats /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
9 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
15 0 obj
<<
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
16 0 obj
<<
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
17 0 obj
<<
/Contents 30 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 20 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
18 0 obj
<<
/PageMode /UseNone /Pages 20 0 R /Type /Catalog
>>
endobj
19 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260924205436+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260924205436+05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (SAP-ERP Portal \204 DNS & HTTPS Setup Guide) /Trapped /False
>>
endobj
20 0 obj
<<
/Count 10 /Kids [ 4 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R ] /Type /Pages
>>
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1599
>>
stream
GatU3969)P&AI=/oP'5OAMf_NSf=0CZ]Z"Y:pkYk`N_j.`g/`m$'Y>\gQJhHZTopp"%g,$I]N>Z"U?,a^XE8BY;s8q7"I+\07\AeKD@BPS6u3*TUV3q80$ar%)O,2^?^-U&)"ITp12DUGioY#17t8=243iJ'[>uo$bOCZr;`VGAGY`SO"!bfY*0jn(^o<fFp<H3Qhc"iK,Ljo-%'$UN5'r>,L#$`nS;XaGC4QO`qkE%fD`S/^\VAP1\6URep2#$[hb%=b*m+&L2"B[R`gqFJ;"bDB)+56<E75<i"b%,\1U>rb+,9-(dh2eF@`]t]R'`V+p"s2gj1h5?N1=q1.dfPeNInXE@A6krB,glV^p"J=GqJ3$)ChX@Nj0qjrg[=Kq/$QW,Z/p'lL`\Y@3UFie_S2#gLh%l"@Nm$(.gZV:))e-LJYPZ#sLgD.=O%)V9AK+;$HF5<`FF];GW^U4fem73[N?5(Q!R&4df[#8@I86H>.<GcoA%msiFdRp7\@^<'AR?)*JO8SBCX0'g3B"9Ah-7mle*#YX+^`Pru<\ASV*@)VS%#4EBt;KGsQ7D\,#f6OI,b#Fd76fK3qYc^P;#%!*hi[A9=hsGID?+U<(_dKtM=%)rB;0J_[*<rm+oV1^[cLtWSH:!.BLJ8mQZ,=J5V6S-EKLKCH(e[QN/lbauGpm6gq8DfOHA7S%4B"ReGRouhe%D^S.V#/J=A7O,Phi@.moL'&J,DcZ5JgXX/tm,8MG+9Cae"`.DGI<1&po5M*cXU#cHo'%&eh&XFcH_Foa[t;E>%Weo4''sgV7W+`lV]oj^oKkVDi=-.I\@u_;:U$XD;ML9Q$`V[Un<T##4^Wc%-kQm,X7M'>a>,@rlUD5)O\s_Am;Sk[]jQ]i"?H<[)j);Z7Fe;sOc\Y]6rdc''Zf11P&TXKLK%en%4Lr*\L\nbGK.3OiCVru%j6nU<XTkP/]pH#j<2kgQ0pX/_TX9s#KCZ5]>Ri_1)DOOu)``P27rBMVZRHr*U>nO_tfU,0lUqM?WK3!@ko9o+ii^"B?;PI!8FoE2;+&FkPp4nY<dJtCfW*'hjqlM+=g-Fpt8Q-,^Ye-^L(`1l)k+`*aH-7P=5D<p,Zdb,,:^>\6pJ1jdG\d-B8/TPQ3(pM\%d$Ec%?9<e9]g[:%).;'/,2'44^%GN![O_Pu4Ut@A_nr]#bL.sUnQ1d*<5Y@cDX,0-fb&Wg).I<L4L:b,[O])\"6!Rn`PT5'pd]qt6(&SBdGf7(s%JhK[Re-222E.dU;hY[n=2$?*5g&/(FPogqo7mT.jt"Z>@n7C\$sQfghRm@35me`=b5+9D6n!_VeEt`jc"@_ZKU7tZ"YX";C*VqY`)@0.)Be2_+Wk%pH5ps@?!0]'N#-m%DM*()Z^0!iLlOj(`#_&e%htKpL^Wt7^Yp=ncfLg-$"[bGTC'Hh6%?dq(J6b24DtXgjuYL;:SnYq-XVZ)%k"Fo(^QB#(!tAaV]n'-4e@'6BjrI^(_;g7q'))C=HUhVi=oDj!p!ZrEOA63dY[NjJo`QH>Gad3QA3W+:_,`L5!Q$_7PMc;.1-o2!hZ)gK-j82$U0`2(C'FQAgs9hqlUb?>AHY5E32lDZ~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1499
>>
stream
Gatm;?$#!`'Re<2\?h`$8LDj=Z4/d3Cq;^q9UaB=e",au7C<;4K-aP2@HjuaPmkT=Ftb(O0EurqX)m\F+U%QIrB2e75)eFuV[u0%UG7EVM.(K:+%I.)0#I;*UdkJ)FXcsoI?HuiN9bGMKL(uK6Qe5R"\B%HNY7f#TI1PcB=NZ[_5Ka-$=Vp:.!5N\+k%22OAX_a?QU?J:<Bc./2X\"a\*J!L!c/Aj"Gd#1*q7+.1&SS`_b[>$2`SL5tGB['Yn**Ajt!eiN:Nj=1W'R:=uV8'DUMjGQj(+#9Gkp^jet\;sqI1f?[\hed58I;F6#_^#;,Y+t'4ScUb4]m.0k[3Sh'S2<RL+*]>co7WuWhL_kSJEWG%PJR_`9HTCEs%*q#u')_H0"[C^dXA8a"PAGiCleuNDWJi1[]nPbaa52,Q"5(-g/LFDCPKuYrj=+EaHZ\jBJJuNhWRdC'K;*N]K3i@i(iMkNC!'"]8SRb8U-bNBg"@je,V>f\%F1*%*gO/<E14-U^X&<!hN[>%8hXI$hCJ9+R);-\Emrt8A/+-Qd$_ET[gB>E`.&'J7[t_f9:Y4E3&Q68`;*Yr"sJsS\7`Stm!P3Bp_&$S`hNJD!$bm%(O@lO6Wk+o79P#&Z5UMmPWc76h_l\SWo4rJ'`@lWLoIu4gmI600Yino@R^l\(8SJ*F9X29;,V-/Vic#Gfe\D-GFC>2JJk5'JPQHnQ&sR`YMn\dG.B5rmpI=1.F4W26/^%8hnJ]lZKn%Dr4$WTC38nB?!FgLd]J^#d`>]Z;V+fSO-0-g.&DrL#o%:R&GLL(dXD<j+&<@+/&,q\<.p9FeTuo=L=ilu5@0]t@5r.oO-2;5?4O?6ggP624j`=M0T1Dg<AG^`NFA$f:^=/df>-P?d,-;/0g"D*Ft2RkGq,gP6g_6^bGa)M2dk0jTXr;J>?HQ_,%n78Wp8<6K(20;'%+F?aeqg-E!+4mgNY`t*6[j+dXd$)(#UqtM&TCr>["hXCpZWQ)/1d;kV/+qIaEA(CD9"?Wf9S<hmOu0"TIOeN*#=W&33q$lVA2Tm<&ls-]R,Z8l05Jo+g9mq&J8!kkj@gYXSM9W;0P0'FgfN8Rr(._#2]ab_RG8<d4tu%V]NTd7#u<4,)'2ob1`@a0(+4\#sYPEo)jtg`]=Si,,<J#^fMGBqc?2S9:Wj2\go-gqu5&K"E^#WYc0T8iAp;XJX3&2j_\(=u34,NST?M3_D-JB7AQP:b<*2h9MW1&D#A1)f0$"2+GA9:b_\7<oOc:?3:ur*a<k]PeO<e@_.II%[+L/S_6Z9h&0mP5rUi4`G&oIO^$.kF)nE@0C&S1OS;6?XV9Hf1nn^i-+?P>+j<o%NbS#"K,+>F`a,^cN+oFR]66+@Q#=E[mrc&9NLB4J[c"dgbeMK/F:7Cj[d[:k_=68tQ@0@N:eR!`O"4^c):9ucZp74P-Z8N,Rpqq,2"5A;Q/^8LM2h3ffj[1ReI*=&#8>nL**5Va9VRM<r_RS(5;T&gJH~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1672
>>
stream
Gau0D@8GoC&H0nX(i_U9XOo@Q&kXZ>K"YH@"A96!]DE-GRDdJ=-$Ij_>EJ9nhq2Yl6JJ?@/4HMVSp9DBS!iFPnF8s&hlHl=8YGs7!25M0!=?FNP&pDgCjk1be"'@^o4bF!TsqN"76qW^*A2]W-iH10*Y!IFe-@W8,2*Pnh/D7.8UF,AOp[8C5U(OXV=oF7b`mYl81ErBEQtd4LFYRN8oofRI!C=OUNe**>_Bl#>2j][Wc#@>GBP,)#0@,9je)<OjXH$%4FQ3a&6XLa0BZ'f^@fWm06IerA6<N\hcpKu;LgjQ\aMG"M!t""69Im<l-eo9SR!)s6JOYXV_OS_1SBLlX^P+p+oHD/&]YaCe23\`r\6rB<nBB,+';Op^!M58:O%=X]&UVpR3Bp+$Z3E#ekl\"^>BC;!1:hn(F9lpI"XS\-;<M,Ui`bfPrT(3X>Pl_haa:g-J1IO6XDRf$qJe$7u/gsb>N]J1pU@qC4sO,E4!GOs"!.5Uk4OS%RYX$SlI;/EU?TQURJ6'Tm;ZL]U[$UrTF]L6^sB_e8q]9qi5eH`.QBfh3P]^Q"*uDo23X&#2Y%3Ff_>:O'2-90+;ZeoJFgg\4b;E0m2p;pWmR31>ES"MkoDb\(#bR`,A;'G=kn/UqW++\?o2kfgfO<QA(^Ddn/A5YZY$)ql!>YITONhTcO*HNO5s`hDfauC_0-pO[d&=!SbYm4s=>o5]53W(n&JYL)<_.eM_BCO@(>B%h!fi5u,M6-iG):Nb#>\0g*J\_mX.`=A0K>Ze*PKKYHK$is;sN>dF:EOeH4'9uDo-6+Uo,SsCrJ91Chr[?b]Q_;#4&,-7^b/5U1`(eF6FMHRe7no]sc/\t>^:_i+'CX/g'em>I4RDLrKN'o"n$:!5)F[g\q9%tda$8\ih[O8r>9!aDm*B'(?EY7sgc[TLYJm#3>:WAgDaq6NB)bQG](ra(h*'TC<;B%d&MBAN/c5h4tp-Ci$c?!AI/sJZG_"n,bdVM-"Mi=&jM@G*1iIYF[8U^%kio$<7P9p,B8q)C4;q(fNbqT[Ll5UoDe\HEE;>`-#pQ+%6!rTUK;8Ldab<`:GY:AfMh6*@9EFL*"bf7?a3L=3o#);09$Pn7Y[nA,=K.f!aPHVTEqb3!^BQ_[-LnY?O=kYi!9HX'F!?N6$0tE,oYEG[4a19Xn]u$V\.U;g+i@Z"O%X23rTjdH1_S5#PaYF>2mYFVrI.Qk6rLL.=-S%qng1JSbf9352Z_CT;^=\_;*3`TY<Sn"tMj!"H4(mJ;g^$lXX?O/5\Y;(@Fjin4c7]t;c7M5rC%S]lUt9C1>^)*/4ZTWC'h\9Zl*Or:l%E+F^/*2aN!aiD71h2\]1sn^+7)[5V*a;;35%:F,s4r`Za4ETNHF3*_Jd9@KA$KH1,%Ht"P<6*+LogRbqB;DnH&,$+t@VM'OX4E!,>&R%TN9KW!auc'%BRtPP.%nB`=!babba*;pl(T@Y/&H+4#2^&e+es'&'?BJM$,*q_UlR"Fn%;0/(g`+`(:iq&H+4hJ$SRoe?1U6Rafl>#u1m6C.Id6oK#""tp%iE5:critY)@+JR>cS\$(JR%)h[;V<JJkV3a`6uH<M=mt'e!S>W@OjRJ=SasPT,td0VY'9iaiS`P'9^"coZ#&t7JWn^%]1CK:,[&VF3-*FW<o<cB/R[mo:"-)r~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 202
>>
stream
GapqB5n8K#&-^EDVt`o-;.^0#aO3IXDN)7oV+!3e7BD@&hL`cFA2\r"AJgZKo5&_\X-)ig/>69e.8&e+Fum6Jr7b29+BbclW_*RtW&!2*Rc\q/A4b]DWhB5B82I1kDK4sYdp)TT/7RcrKJ=AUpG,J8DjUd@(l(/uRD_QK$cu?u,(dL3S6[('-^N+<kgf=\0a658%:Cm%~>endstream
endobj
25 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2606
>>
stream
Gatm<gN):E&q0LUA2n%-gcZcQ6<rl:I\8DYbD)"i7_QJG`&`QNi+e0"9iVKYIm9>I+U%SogEqM+\l>9F4F&]_4E0iNg$a.ln2Z0N0-5@%KQHVa-\1\!L>gNe7pc"%]BctUX5cRVdrX6FG(B&?D9$_2Z]59N+*??4))sp_m@OQT(.Ym3=./;>\3puoG\o4pqdeXG-;k$P0_PAsr%eb"-88iMn2ZOS_dIdG&r%hIV(h+/5CA"N4+n[^f"^bH'K"FD6//[%FpS-d^FVKi$&34[C&o]S=WHOlJMgnVg':B=4A25I0$ERM(JfQsL]m2[?rMr)%3eu]#"4R7Z]rKAf6iEqF"\7eC\0+<%h((Yq-Cm7`+kj2km*"h+f)qcFcK79Hp+I40p`6WEF:'!@8YGQF3f#g+=?u7!jf;J.FfUT>XsNAJ_&oB(,:OXU%Dj4<t$^k@^2&1?.l@C@D^NOa%SEWj[*pk!%B$_@V6@Ekm%GGj>,_hq-A#lp2:-RN68Oo(l/N9@0#d'K.Eh`qB,2Te\a_q4KC$].tZ`l;D;3B,"IUUB8.fd9eOf8lP/Q)h4J(fgM'aeoGu+OY@e_6n/UYiWI-f6[ZQq5V:I?BBAYFipm@&B%HnXsTFq!q5CNu[4)XQ:`*GaIk#%]g@bs$r')?2Cc5/es7P&s/,3V/4;N='+R*crK'`0Cn)Aa^NBbol"hC;Zc79U&c^m.T47@5O2b*5)g+'JljF\k76VV&i]6mdj)JkSeP2U5H_>_7N3/s0i`W0IYe^pAu++];<B/4j[NU9bd=_8aoJ(nR?1Df>(K_qXZuE.)kO4h_m0YD.7rnULD%UPf4f9IY'D`?6Q6Y([%4nO3+P9OU\?R?(5&6KojjNqI!`KZ/'4HJ[BscZ..@s)t.S`Yua(=FuKmN]'J3'-[Fk[ioMeB9oN_Z'U/FjG'SZeBB>LJ4u[GXf'WV_TeE(>%m\@GVKQ?h"ASSG4bTG31PBpr3,'%lsGJ]'#/HT0S@=l1J8%fMYD!b4mq.2:-RcKC[9t5.7i?I5?/s;C8[5/$5lc8<'=2?OsN`O2`A"2L"S4tR8G;<:IWWOCH-OR[+[1"@#-^R3DW?_8\8=0>;-:MgJO.I.+h!%OlGS&;br/ofpUjPSh.]7Bb)`,T2juK>clG]_ca%p#DGIp%2sAT3^/'p?>,T?$K"XVI_2r\BR'gs?eh,_N-ots:C,foB7K:f,r5lQe$#t:n#\IHX4mT-.S:>s4N14,6>fhL5Z(G3K`S"ob;"nd`NdXL]^IdfS'\nH71C4rJ0K]!HAb/-S!ms9G(4@g[]i5h@*25.n838m1$IM$!-3<QQQFctrd_iGQ7CI,SMS=X*8bgE8)!53cW04FNUj+s/<;nfY\baL`A]s5092+=F+YQpC?X53C!1oMm')YIi_rof;1&oK;B)&,Z(iXn`KQhS*=!Oi\Z_F)#]_Yk+DYGCLrq`Un"C%[hpYsP6oM=dAQc'"ha%>aa.`OQ7G"hTe6sjC&oTl6>-,,OCRM8hGB[Y"6``?pQYj^#JV?1-i4o5kRi<>#B=UQ2S0b<o-74pL8>U^\jj^<8"N6f`m%@5/^IRHrS03uSLd?d#V8sQG,h(as"7-8I[ubd%DfPJ>:^)X`LJ8bV%3[=G%qeASaIFbthU$aVDX6-W\'oW"q^-9%5+Q.$oQ9pS>%UnrTV"a^2P&.g<h-EV_tD8nN%r2#AGkQ<$h$sBVGku(Tf)C<bVuTTQ-/DLX?i&u7pf5$8k8'Y)t`V$L0$_U0?W'LN2l+/d*p7;DoGnrs!lNK@g.mTE-s\`J(iJol1LsTdEUe;i6u@e]_aZ$Q2NCUXGc<VLJJiM4s9sQ\*l9>kN9f>]J`O[61=!k2"E"m1<JQn\#:eUaiHRfXE=t&.`"Mg?1G8b\4$R>I!O*H#5JQU"e4l^:7EaASVUQ)cWI>b'6e!h7tkTpX)[Vcda!45ERL+"S04YRrZ)plgiOApgnQ-f+B@:4?BSWu;t!.f/c#S4N*6edUpHZI3qK=!"o-I26.#![^>E]l(U%<0J$dDMLk3'f:=6)S@)Iu1U0hcZ;*/KbIG5KtQ.0i7EdTU/?oes8[7HrW`5Ndo&ng!1)9$+>A*_CjT2nNo"10Um6/,[^?di*Y<KTC539u.85c(Pr]1/3189f_*rAe99Wr6d^1"IS7fpi>`Ic(to7MQV\e&dNs!KcnV#AMg;<?Da<V!WCKjgO/:)3pDP.^r/+>Ius*C*7#H.nXX/.l]9,G5qnVaPXDm3k.Z4[-Z^3\NR&.SMk^2,Ejk[Q+Vf<mEVo+B**jscqD)e1I[=_Zp/=R.W9jgl;g"F[s":m9,g&FrON!\`0D%/_/\l7-7h(kk;\/36hp1-;ZF[8AR<jG:>nt6-O-k?Ouu@5SRbium&h\1rBn,@_'(]qZC,ZKPn&S&'p#R?Yo:dt,c*:+Hd3qirgB"QT&MT.@NAf.);6nu>o/9/RP3m3C@;R*7k;4h]qn58,#f5k_Q^$):be.a</0EP->7?[DJ\f!?,p`SIWZf]6rbQ2[Gq?kdd\3p7\TR`TG6/%C_KM>hL9UWLuX)Q.=;*gU'-6u#0ZBmm&"+A!H&m!ag(TA0qs'CR>TN"N1AnO3kmo&_9q/e"F$.~>endstream
endobj
26 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 577
>>
stream
GatUo;/ao;'SYH9/'\MH6`RC3Velu3@U4bN-;d6cGj((IghqkBBcYZ^Iufd?XQq4,Jg+@Fp%:>;O)4n=51,@e!mYl"^(_+kq$KGCL&;;L4PE9!mn>*fb@E.YR7nJo;AIbO0,BRK[B$\/<%PE*&b"H09b\:sV$[N%O]T@c+mU+Y>I["9aE^9[N#[+UBh*Dp:"u;ipl?6[E+dA2BNMF\DG"O+MAbU'OFKe"WhoUj/>%7HgI=kUOc9O3<"kj8?n+`ZVN(pBdXb6gDuk"5+jNu?q>I16*r_HZHiLC&X)!c^h6*j,SfTUL9/Y!A#?6bcPKm<QJTQ[_>PB:3`1PVo0cG'oEc9=X[:/K-]+c3n@o[%8WBhgOHBZ(I[u$G,*?V]_1N<'MerNL=c&]\bXXcG<W%Z<pA`;oK)]:M_f9/X-+)"ADQ_uM7C?Z57V^F@]9ZX2rU$_7n_$fhfQN+1C=hXV7?fTS#o`(gWq\uN(-S_7ZL.5YV>+<*r!oI,&1G!#oRpRhpNh%'%kj[m(U"$und\HN5s*8aB`-P_)KImXani"QKk_(Mpc8$^LPMt]gBd!6]dWcWE~>endstream
endobj
27 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1706
>>
stream
Gb"/'gMYb*&:Ml+$j&!<G%4+.-/12D!a2uuK;Mjk@k@=>5f$!uXbITkP)eqEhk=r%aJnPBfVa^[LlofiS2i\OL4FmuP5gR)!.TsD^*Q$L(f,[,Q3ZGsGX&q;IR2,EIa<&i:=k2+caE\2n)6>*g*."5\caS9lT'CoQIkZ_^]QU15b"Qmj'+^+kNKiQ-YA<TqSl*BfX9Wi<LRq<HH%(:;C@YRU7+B2_G*Lp)1)PID_QWW6M_'SLVe)CF]^;.j5c-l6hC.$Z52-`Z1634%s[l`Q^qV[&E!9)4ZgZ)jUQ?rbbSasRg:&k#rIWM/tU71$"jU/*C(ZuII!.6>42Z(8@)!DiJPpeXQQ!9%3T'rM0'Ab9(=Sd=&j]RTOqZLA&'O^->A`IgVIWM>o3*<DECuJ4H?A!Xa-(L.hZnRP'Z$J<?ns7Z7!(uKOPSM?Q%8"'LEm^"(B@q1;!Xrb(S7uO^\ef+%"TH<J`[^J_"jrQp)a\Co(:7&s5MT@7_1^qTNDN-@0h=baM^p!%]Bu1VlIf,uOq\JW"VbSlKo23mZKH',6!nVXV[:=B6$Ak#>hjP_jpmBVbGbHt==2D7B!6,\oJ%C#*9ph0-"ug)kD0/oAB^dlNC=PCF7Y7>_M@ZW%H1aP,NBaDW<F-:/o*R`-L$l+qa%KPmJ**X"Je\VQo+g^>$M'(LQ0^qK5[?4;aIZLR`*?X$.PQ+BU5^5.\&g.35Um6%S\R9/S&KQIkjEF4=I4e`$;E=;mdp@*#3c\^-Zb$cW]Y)mfW4>S2o=k;4GaU:OO58uED?u>J:"(nI4iK9&^#p`_'<fh[cYsP0D$rf=%`mAD+NOul#Xt._QY8`$U0Sr!n4ClfpK(8h2UF1fVI&BIjRSMNBo]_IQEmCJAgZqogcp!*=r*<:]K.4b)J`h14eqjJ-D"7QQZWca>k<.P7:T$ZE7(WE;E-%].,$]*_DI=DX[nB<%$UqOG66Za'K6hq@"1'*&qI_Bi&V">:35b@J1tu6/?<aM5NhfdZYuJkRR03D5(T1^D<=3OrN#pV0EO"Hs+TpVA*;f6t08A)9*FJKZhCA-OSH-tY!$X]-B>B#CJ<&ZOo,4dEl7`<H;L,`IC6?=j2[E[JpV7+uh[0-eon1!-$&m*(i'P$d?E\"PDSH8p2S[m$]["c>Bb5#$.5cp+eH$6rXmkJ+pdCbD+BqCFUT&,gZ%])GXesW1\@bTO[Qs&fEs^W@OKJ#3!ZFY($DChP"L!0=Y_@K4jk,j<lY>kJ)IbUu$a*pe1?N=S\iF_%mR30Z1Gk+*dlN%h33,%+BY%Nq(oG52ek'S<WNrWZ\i]9WO`'FUUlNRiGO"bbf8lL,[u"WgF8u"U9:&/$,E8H3_BF@Kc?Tj=3Uth.mGZ94j)]sNaNmmt7e:Y_19G.jZ0+]DIm(S&Ji&IOBmd*;5E*XXLOriP,-Ns'BORs"NV><5&PL3@cls,#H9<0U$QW)^S6t<fj$8dCqaho+m"DhgI"/t38WjG&!nl.kOqZT?2-]@<Rnodk%.TK,"c![a)*`\f`:*^E@ld+9h=>M3A*t]+"uDqZ8(A0icFcIj]Q=rt.gGD',qau:N4\Fdaf'%qkWY2%WcP-14&r7d2><K&6Cre=li%g^hGM5#4Ih9'(s,e973NFf4D`d0"A3nIULA5e)UoLnEK*GpdI3G%!l2hh/Sho4'*9+i)Oh\]'r7<,d?;WdeTW*+\G-lNB\10~>endstream
endobj
28 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1345
>>
stream
Gatm:gMYb*&:Ml+$j(P0/8S<f?h3?=gYDqe`7kRb4r@BUdPth`OU=\!p##5"ZCL]QNLLB1"L':pcC?n-L&q)A"R^b?!rSA$qi:a\+f'/b@P<K.1OM_hmSAH-Qc[&7_l+ZoTKi(3.#DX[/UN$jN;<R"@6F%C&U8;[KVKC6m0T=$Cr-T'/La'n6"337TafA:11ahpZS3B72lZ4_Q)lL'FY9NS9k;AUR%;(c6D$R?*R\:HU;,nPKITNaT\9_n,rW2kqmf;H[g7BUW*AIBJU63"(RWrj?rX6;Qj!O-#*iR">`=6aL;#K,@n!Zn?qd3@2p;9RhuL@\gu07sc2(EZY64(j7_4dV?56cn9?:C`&GUdUc*O0!6iMVI$..V`MVeRaE`hn,!*1Z(8qUHk,r!]5eV]h2/=@,Ugd3S$07Z-O)@[7o,nm[LU`j'%$o`_f<4&/I#cdVSlht8_$8'VrN>b.0kogm)4\Pnf/m4%(:s5Rk>RFA]7\h<;"=KOU1cmc#=[.@eX=a,UNWqpVTgg2r&"Le;TV?05B1Bf"nsYA5<$%]9"Ja<_H(c>4^Fi''RHLf9g?@C`(C2FJi`*HK/KlhTXK('eTYulL1lC1Ve'N_CAL1?OBG.V6dQiQ0n2Z?I+Ao60^?1=&Bi4nmVUK=]dmd5N5@ApSmm6T9%puR"Kre=WO5e_I)=(G4?DdGpe/G;`CY/Os(@=(7\_u0iAu%J;)0.K)4F[h9h,GOp5W5Bb8T*mfFCL&0\mlR;XrZ]D[\^C?L?jsQ1]]l*c/J<<eQ$o&]@jQq%Y<n0JpJ(F6lpa:2RspZC,"7MHd_0i@L9!AeIaWlUF1j-4"Bk5=fUY3rc%'3,=n0JBi3Y/<n\Eb0#a+r)qnP8U:-=[1)'=Zl3T"c(&u9B/sgFEDSEhf6&p"e(T%Si.;VU1$)qc<RVcrVMskth4\o!0eald6M[,75O&/M4(iH9sDK6dBW&KAXC)BQdG'&U3I;^_(CSW$mJFT6f0AJA<(SoGWU<#&Rh_Q$BUGXFKh^UD3Xt/1bXNc#;`LPiRmd@Y0Zb*!'8fl];=t-WE+);$S0A'!Mq#P]KFa.p8<?1`8d]bEK12I:Xl_FoWFr:s4K2C*EdEGmbHRh&5WK5f5#7m?PaD;UeLlJuq_6XldLAi6CT3c@=jEU7YhdE[i$BhE59M:?)n?m#9k']%n4FqL%aL9Eqj\"oUUmWVqe$!he3AYdCTYW*^da-kjNM1^Ajpi,CO5jB1oQQ=!YJ=--#=__iL!Ee-H@G^LMoWR'$3X]r/iUrJIn,K6Z]LCRJ'I+O>Lb]^?adc:%SkLGI@!&`f)mK4ccHQbHK,WV1oG^rG:tRfnh2@71+N~>endstream
endobj
29 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1815
>>
stream
Gb!#\D,8nW&H9tY(a5Fg.^JS[hGg%6.D;J(1tP.i,5KDkD\=/;Y$#ZK8GD._B'krc:'n9L/s]=CHZs(nH03ct>]B1QmGE-pn9t_8O"Rp`9P<8f/:csgS4sNs&)?HpYf@a0ms<@Hp0$U7!Ns>\b]V],f*1:?4XV3L$iJ?j"JFo_\>NQ#l+qIu-9Q671?!LhTJblc^_oP3f7T848`^pON5QW0nf4A>cZAOln?7<MN%t/eMN3^)q@?9WP'at(e`<GDDRoPX%mM=0aGGg1@Y``Ym%rmlH7raA.KkiG"e;qqJS/0Uj:2N*@$RL:f"MU4Y&1;i/;^o4Wr&8K>OZ&X!A.cW'AtWkai[_!PKqUD)hIN\^dr#P65'VD"Bm=Di14GJB.R(VO?t2r80nG<)41RdBKm<C9>]WB]ID24ZW?HE`A_I7P:?l.+l#1d#^d[D3D8PUOO,%8RT,43%[=%H\dfZ5f4?"PGs2hrIeX8Qi0p)d*j^66qj&mJ/q[)C;(jXd6!1pS;coAK^(L]lY-cm"Y@+Trf-^A*^$qQ?M+q#CcEsH^<['oQoQ!s!RNIjtoLd7]<=*2M@>+T5&?!Sq[?fB*jRkjA`=4.HV3EOAjE$HJ754^-KWif&1R3f#(W\afPK([toCL'Y#SXQ.kT5S'C)2&\bo+`i/rfXdisk___KVDH!V+?8$XCI-#5NlKkDd7[i,@$W^q^"]F+hn2@1%XWCElj<?^RPYL!_S"?QQ`*i(,i;GN=Gqq-0to=K#5c+ATua\2^*ScUR)mhggNCbb&<<Qt&DE&/e[fPWcqpdoU:FjZg[g^+k^bFaHVY_+Q2UmO4-1;<MLP\U0Q,f\I&*-^92&81pm-i?m)e`A:UE%-B<DnPuiL6XI:a:]neN"nGo9A1ZBSeV@QI+eV7<h=>-KpB(1_J4:.88JI"cUNmP*N6.:W%=),2HLb24XO^hQ^o-F=>R#i2@QC"L<!$njlu_MLn2M1P!?G.M"I1Gp7<DL$D7RW]g-^cu6-1=m&@\#gqQUW>/%0eh$E!%+V;f&Ak#-L!8"$e]PdS!<^@Q+\']<VUoe,lm0<N0<CnpJ1?5aJQd1Hur@5n$4)d_G<TaEj4_f5*cD,GJF?iNUTKP05LX58!-_Pe\-+$C#d#lIal314$AaZ1AB8j7]CH@J>39JZ^)+,1d[@Ai*mVkd^UGh+=$XL$#mD2Q<d*.DPTcf4B#4(!iVn*PM65W8)?GKefF.J(@,s#gI-M7hFLI]a1W%).QreuA!FIc$o^]mf@fY.hC@!$\aBm"'>ufaA`M]MMp1(BOc@\<D[)Q7(2OM;DDVA`?`UgH90jB,V`*(F\oe.\H'9cc#*>c#kKF*)<S*Fl'l^52>2Ro:4YfOnLBR]=N`DTXP?rl5IOZVI;i]lK#FQdIH_Xad;Ob3\-t*L/WU:fcG^U4nluFjZaRG[Y:&GWbN3jEMQFaN@8sI9,<pn=#B&%&`mk]<8>j;XL*Y_R/m$)-p/8Bjt[nrU)%3APGUN`X.dUdDF_JU(FK`V*,LI)p0e1t>rMM;@u.k*Z].M[mG0!+U%:XY$_C)4Q@\?+[dDAZ'PZ0$c`",+LRe"=BjpPeb'a82[c:cuT0C$p,fck0K>-@V49A0-#OO\X]cKP#o-*I1/n+J)DVG:CqVZ_%0#9h"h_kkE;:SdLPpt-LgeXV#.[?2Gnh_$1>`0VJ]\mf_m$,ER.I8cr8p24V[qbVRc.)O2oa3L[H&&CcXHBbI_4eE<eTX(,YlMi&2WL@NKqtDXMW4fMBNn%i+629,`=.$M,Bol^2Y/B\$`oU5qj:IcG-]Z5(Mm,J+(kihrW?I1YD`~>endstream
endobj
30 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3026
>>
stream
Gatm=D/\/g')nJ01!#]kMaW\<=gkZh"'d50k0Q*E`-Zh9(^OZ/[ae!G.T19Wmr*rd-oi6]^tHu@NC8YoQY0p%+W[fWa3fBl.DY*Q>^g\.EFCU-a0RmqI"Rp1aj1W54gWVFCq&fDd.M`dVqpn43uk[KgZeSF=K^8`4+4?GG/rusO=s@J9uh)!Kh7/+\DVIB$eM3a>*E2*Z\6Gs&1ZfMpa-cK@.G<b^X2+mi6Q`OGb'Qh[N9n0?)QHf26sVZ*PL:2PQd6^KW5<@j;!ucK%2VO(eCO2!,<BE.'dn"G_?SYc=%d/i&=TdmD9C".9NmG_-H':fo#nHVp*jEZgV,4j#LCaqX.c]j!;:h"j_BEgZuFiiAt!EBZjb%=o,+0\S-`;]QSWbbNesf+/QCA?/Ou-0)],&D0N#K4;+L\?,qG5Fg(1Nbjmk@`LcbWIJ<^/2V)enh.,'GgO#c@2_8Y)lri$+3sS=EGak#`gP8C]EkEVr'>P'q\poW'+$+8m91-dVZiPO*l;_iTZatH'64[N)nMSRU8@Lq96&2rHmI#`f)$Ye*>ISR`4j.h0VT+ttAUCd-[_()4n)jjJD17`gHBdM09+S<N>d>k>^#dR+&k<Lj1rVRRmFOB<kMkTGS28:XBD,=rF`t@RSfa+E("#rL]>>k]hk-3,A\a!TOsM<.=L?]L*tm[2:A/(#_+^dRVWo;8'2a>&1Pn,(aI!od8A<.P"eW]>2%FX1T:P5E?%2C89!AbMg>,Yu8r/4,Plf>/S;S,.j9drV%C]TIS-K?_P)jW.dS6<kr)`j,[b(Ycm&VH@^=nV-H#PeWAi?)\M,5J%7/nVc5K(g;p+FP"^?+^*Ep6)]'t%i7G[7=/8RUqBg>sn`J$Ro/^@tF5^Z"ulYbtI81%NDEL%.88WQspP]\PYU,`sf6AV(LU%/?4lH8Yg5YGTc(U0V[u,qNB*k?B+%+5kLh^O*%Lmr>,Ca)>UR*0h\^Lb<-H&Xs"o;L=^?MAM?X(ep-#P#OsS'eV?O1"6+`\/UJ`\thYsme?6A%#QSAm`.72Pl5<@5Pjs*2&\3j1-(o>"DO0E4aKMc)W=e4g?GtQ>F7.@7h"s?F5g0bTcGOHp9Rue?,d@WY&a4L;/jd)9[WG=A!+^'_-O*0X(>FqYrn#BN!5Oj3&/)"*Oq,!XC@i)RO.aP+Y`_JljrG1&lQt:is9-F<_,gbM7FXEGV3`\[q`b2El27#p,:#m@dcb-LP5Xi%knBUnl<P+4'26,kY=A@$LrM]_C6Z69*N80TL-l#6-M!r$[`b]K.LU`no7iMq"S5\RcblK?un)jPqX[iYeBP"VKVE14j30mo,pdR."S@a1^iHG@IYItb-lj%"q<[JOVF=Lq`%g15si!"U5FRUc=8135h5C6&'M=8Vm[pbF6lqP850UO!+&lrAuT@79EnrZYs^0Dq3J^%ZW)R=;Ur.nXG$hY>bL*/!b$$m.X.,$"$3?0HtmsU31Q;IK"WkJ:o@,66T]!CSRI+=+(]`AeHY6E"s@9`$=5?m!nnYV+QIX+QI8TldEUb;lK7'aBmB5'Go@N6rg\ALJ%2'In*,#fT>A"klhsBLJ2@pSB#hg4=XfVUZ-!HbBdCnR"lX\cJ601O8@T[aQV8-0HV^MS*QKSY'eHX7M^W4C@^uct3FGXB^'oL-UuZW[>H&$R>1C)k!!SE[RgEe[\_ob1M"#@+/bi!"58LdX1;+,K;BTb,$Og(8W%qZ&iSjcb;XY9(jLC'^@XP?mfX8>TYe!U0+3)CK8999)G<^Bl%IS*D7u>^hO7gSu;>8YLkHUD&B8sDC,aq]5pmJrH86JM_eqpSBrL!IAND<]q&HmqEWP2,o@E:3(en?=f\1"Gp.1\rlY.^`]fp*=kGVI$?pGa!i2%,OG<@dE2oSHAh-k^9[BNgT9XCBBqKHiPS3L-G/coP^%hiM1^^<il\DXW7@j6pe&G/8u":/)N5Q^A$2j\<*ie2V9sqb)k_5eLKE-,5Co#6="5M:S.^+fQD[8?c.mh4gZsn6C#79k7]S;'Ug?fa@hYj*V[FP(c>b9Zs.?.`[Difb9(S`"t6RC::Fa$p1MjP,dgV'rRJ@<<XLC&#Wq!=_hq>N:8i,af@u]]92#5]<pk7gU.s)'ir-ZRuKl;;MhQ!a>1e.isaVO8.k)d*M@<Agrpu/TK14a=D<H5M.[./&J?!Vq@Fo:LQX"7cIJ2g9]Xj\8-FQ*9s+-s_B03DBiE%$-D>kZ[*d2Ha?g`O&,RO)gNaFuos^FYHM!"]%CKjh#mWk`cugNk_Hb`Zh<R/Ti(mBER1KL3"Cu`dMpq4#5\`j^]^C#]rC+Ma-Y:qm+MBp*';+OXesjH)W-^\M/5[E*X#PsQ!9]mNEqVu"Or%nE%@k+?F1d>dn)?)7iWn&`%4%KZ[o.u#?qjYokt+%QM5O8[DWf#[A2WsEc;]p97lD"X6M!u6AgeN]@OI$kN)<ZQb(A(XqWM;EfG5sr=KZ-Ya>#K6Hg_"W/C;5=(69I79'o*Y"0Nh:nk9b.;#!NI4g`k0J9I43F[puk?"*>XhW$.iT0`]N]]W["0J"j"6=F'!"0^Vg,"sF:8mY::AQ#73AOYUdEgV7;704'"fX;d8/pF`A]?)@Onn;K2*GTX6V\^)4Eu$#Io0udq8GDhM&g(uPA/2aL3e#`50t`^O.?6,VFWXHnc=R0`et@::9S]7UMfct;2IHlMZ.()'k>cGX=TK2YJoR*s@Eh_0CpnNL4!sb^rqV"Poq;6_iV+!bEb0-FdeZ:]*YWeJ6[!OdhKWlr-8q`;=fAuCC(BMFeSof!e/0fFQskDue/[$mWaWIeC(/Oc):Gu]bsD<F%,:0/6%#bggeFiZ%;"aLV_LT#B#o<DC&,W5_;DJJ^E.@JFr<_Z5P%_+c_[q,8>bA2pM0>]D7?!0f/GXB>:+GC]=pl_3"SiR(bllk@5`4Sm55*VmHF;gK'uM!prsgB+91ZDgOK5=U/cU7lhdd73+l2B,+p^7M)gj!ob&c?<ZbC.<Fd0Vj,9C^YpKf;D;$c3`-f^q,,R-oo_'1=:Atc*,Q/&~>endstream
endobj
xref
0 31
0000000000 65535 f
0000000061 00000 n
0000000142 00000 n
0000000249 00000 n
0000000361 00000 n
0000000566 00000 n
0000000643 00000 n
0000000748 00000 n
0000000831 00000 n
0000000946 00000 n
0000001151 00000 n
0000001357 00000 n
0000001563 00000 n
0000001769 00000 n
0000001975 00000 n
0000002181 00000 n
0000002387 00000 n
0000002593 00000 n
0000002799 00000 n
0000002869 00000 n
0000003180 00000 n
0000003303 00000 n
0000004994 00000 n
0000006585 00000 n
0000008349 00000 n
0000008642 00000 n
0000011340 00000 n
0000012008 00000 n
0000013806 00000 n
0000015243 00000 n
0000017150 00000 n
trailer
<<
/ID
[<ded3f19de6762a35c86b06ccd44a9a2b><ded3f19de6762a35c86b06ccd44a9a2b>]
% ReportLab generated PDF document -- digest (opensource)
/Info 19 0 R
/Root 18 0 R
/Size 31
>>
startxref
20268
%%EOF
+174
View File
@@ -0,0 +1,174 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R /F5 9 0 R /F6 11 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/BaseFont /Symbol /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
10 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/BaseFont /ZapfDingbats /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
12 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
>>
endobj
14 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260924210138+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260924210138+05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (SAP-ERP Portal \204 Internet Access Setup Guide) /Trapped /False
>>
endobj
15 0 obj
<<
/Count 5 /Kids [ 5 0 R 7 0 R 8 0 R 10 0 R 12 0 R ] /Type /Pages
>>
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1565
>>
stream
GatU2>BcPr&:Vs/Qq+&i'8/*u7rOUJ:7L:p5.g^M(DUIb/Ke)GUtoCmT7k$?RlERa@%0\C)U3YNA`Vl[mPc_RIUCRS)b)BOop/_DKs^hM2sZC:0A(hs(41KaN<5!TaI>n:+jPZ*40m^K5Q^8*#*;bK@c77?]&/P_KO-E.=:Bq,7\eLC3P"skUnuKscY4$q0tf5UThA83,#*bGZk@L[?./QgX^;Z568.n^_st3qbX=si"TI0a_i>#I5N@#_H[i!-2g'WBnGOGUB!"3]LcL-Y4enJgPqoZ#;8!cF[:[pGZm\m*W%gUX;GD#$W+/]ZX\8o7^3c]k[h`VVi3L:hSM[b:[8c7T?K\?1[Q2rKgfZVrAO\XLW@CQ'>!=E(]iUVqc"Pp@1T*=p;4;Q9AErf\XjZrKB@(X>3Mn9EN)RZN[*c2Y8UD";fp]_BriVe)L@/Wd-\3A5]oMY?YYc%N]03AQh1On.KpPX5q==6RFCtpf'BTHa1;tH8eWU'fM84BL:0C-Egn,lad(eP=Ooh't'ZP,C29)elm40Q-i'ujm%:/9kk2P.lF)EqFe:fW:epD*l?g`Jj%O;UbPcL?+c'[@jelQ'YnhQKgd)*(,ZHb0E%f8g8YIR0HVQMMPZ6hQR`CKMWLKe:*iR7@;(P5j5Cn!PB9B6S=PEt@CF\7"j4iR#L.`([qE=3R,1Z3O'g=T"t;*g'h4/l;"VO_iSUtOi*Fu=QS?8#@nQW0/YrX<`_D(\!J,'`6\Y.?<4f8#2CK\g=JjG:#*bp@@^>qK&dTGm8#Cll+N;iAfc(ImJ0!7XHOZkuW=J5Bfg!_uamef&AbPKQ&X$\:6p:Z<XYD4m!2!]Gu!rVL"ma'F7*ZX\P)rJ;e5n)mhcT"I.B]SM7iA0Hk-14J2")u=A/"it*dG^bGcSHKAE2H#qPc^o%n%(L+2E]i2@NkuX]kE1gPqY/suI/_cl'>[Q<!kI69jNt-t]<^WBbMl+JLgX!5_UIgk>$=cB%^&?!267LMMtP)*KX<3t=[2-V2?fn_3Vsnbr5%tockYB2A=rBO%a8nO9[B2SXD8H*m@T"mQ>PorV>_9in]s*p[--YPRhfZipIN31SHpgQY/+pD_V9?882M2eAdt2g'P8P2je6c=NEo9t;/nE9pfOT6Ao+*WV\Q.l<Gp.1igTjtO3R`l">@b(-&[3sOmPt.[JKFbH0>g[01>6\EWO]kUT.?T00<F?-@9<?8KSUaC1(5_UJ<[J<6(rl)(M-(&q,Ag0?<^MUCHtm?!jY_eQ3GH=AlUsoF,Elr5G<\WX?T)"pnM#mt?-2[V>;1fU2-P4fe]aJ2W"(+V+6ic(GH8^1T#IT'jSJ?]._',si?Jk29Z/jp.EL9([k?PB!WW6h.1^h:,.W1Z.pcQfeu>H81)V+-'IL,C&p^Erb(,!K6_\k+fgN[i&n2*%OFFM/8,G8\tt-aD!ST+?#MQa$#DJ'65YcLgWIG4^u23I(u5rB@,jkd&Dn4iGU[AYh%->*Q^P!p.ncq9MZ#%h/5d%Z6Q'>%4G>$">mCb!NV62r\Pa*eIksq[pH@doS]IcTA;jNrrP+a<p9~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 999
>>
stream
Gatm99lldp'YO<EFQF.*Q*D/k8<oCO9!S\gb8YNpAIKe?CRC5sL3U,@INO2qZ-+RH+$t[='"mYaY?qrO8k7sg70?fU3<'sA@`[/WOt]1NM,3rh_b%5M1X3ccMe<IOC`]HnRa?-JGF2q4,`WF*/4sQ5iR_$V<&5G#1?8m10LTU[_,ppR>]\r0`%>I:<k@6fi=GihlaC'Mgk\niruh?g&c%oVf>tlp6RE8kJIW&NTkNj#/Y`ka74#&L"rsK&oJV]^4<)[mL)#'6Y3X8;ib8WVonTLX>dV*D#]$1SE4(>g*"4d9%DM$@0?`/S(UEn;Mu*g`GU&**i7eQk/&L&i?9o0(iIU=jk@>)<1m/2;<OPk'$@=,D2Crt1msJRYKZH+#K"b9%\`$<UFPK>N.ag]SqFmr9L]*D]J8P6g7Js'KJ6(=eemQ&*lK[NI0<i<@'BUO)k76<rf(#;bpm2eW0K:e,*LX;n\6N@6[3@_B&8CibrP1a)\'Bo9T+UbfjA6c@g'E7Hc.24G&cCYA_!cV3GOWT[+*A!EkS_;P5aK+cnA:$#)"C'r$LJTcNah4U)s`0[AT<2:4$/CF=dJ,R/"LoFGd:mn(pBA@O*fuK);fQ4PO`he,(8+5qMYt,^/KqaHT)r@kqri<;n!_EB]1!i'Wkh_A_SkHV;\fMI+[EP(55f7kSd[.6qnV,U=aEbM35("gL3%\7=?n=Pkid2nn[O0DlGZbpd+SUNSa5`4%]>0G$A,=Rb$9aHXY;meN2Z9j]I^T2A?ud*:4V8=E;$ae(g17p"q>/-bkL^[#bG;PT/jg;P"l'(OPlH=N'##i?DikKL00'T>h8ior#TE2FipA+_1*\8OC>6qL^HuRL2n>koj"M/G>LkLCj6JXb=#d0#GX6"X=qgGU49^S-aH+a:h;apaa[oDn;KeVM-EK1%@05j&XZmicDNU2s5D=@4c9K4fPGI%e!&R)g?n^<:%m*cjK(]aCKD/l#gJ4SF2C2;EgWK%rTW4p&~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1476
>>
stream
Gatm;>u0KM'Rf.Ggm<mpOhl5-,bt\P[]O^!7-UkT^0%B>HgJu-SoJD_K_3mjcId-,Ca]=dJk1^\j4lHRShp0"iQHG[dgG"kL5TDen,nJfn3[gTHj@F)-i]_3h8j8!]O3K(?oFR>BG-"Y%j.!S2@h0!&3oZZl"0mtMA'7Dn$e]N>i?G!plXAV#j_bj#HA3f64sDWprlW=<D6pS>JQ"l2Ha?a`?9&O?mR'C+[O[Q5V88Kak7QM?QoErUAQ$=LQMA[!9ju`8S%@M4lU`aA?0#s@!=11SqZC=QRZBbEr\s(s(31J!!gCVk%9T`@if2+ZEY,*\pLqiEWiA[fr<q.5d"XOhig^mq]rrX7Lo,8E%fJ!MZQMR%20YUfMq3?\/'GL:$5IPa(R%D"a0E=ne=8N!&[$bZK2g8Z]%1&eV^@8QTVb7j]o'U*Q4ObW>ih%Z'l*V64/WYb(@l`d4";LAs-kJC*6e[;)#9XAeZo/#5g2K#+F[6Gt$>n,+:,EHXbf`MErD"#upA',-N*OF<8+_GW;Uf?9k%Ur4*KuX\[l=d)M61p;CT6!1AQpl,rDdZBBYM_\57):040S1u"rWg^%I5cVQu")Z7\\cIK%Q[]2@<TXeM!,^EH02ofl!$,8.&!J-^E%p^$DdLK-SR/L]p?sFKgJ]jPT\:/[!$u<&F0e>]TTI1[fLT$<OBaZDD98CS3]A);OgE0$jn@l"7%Hnoj<K@YU4odR2%GgOc^eQ00'5ci_/]:gT@"$DmA_dhZ"s+L#27A`d-fe5VrpPq;TEV`Yh=g9PWb@iq',ei=-7^'XKG($K4nek>8sg07O;q[;&\!8;\ip0l0RIHAI:s.0^UHBXP2m/7;^[EK1qIGZOM:G+Fo.IMA>"`(C$h"`X)bs$(U$mP@Y8rE'XF"P@4AZB/e7)cKSn#jf?mKWF'^WGdVZ[!=QB('?!$D#A"HtiYBh-DDE5(9`O,%`*!)",.CBb$jphBj\5B3,XO0LWVT8BlPji&b!,hhT;k1'KVI!M4PVT)3?"O[oT!u'$cHs>Q_b?gATbkGoH'?omcg\cXIp"0P]kT&qoM8[bYr!DZhTO+L(%\oZ/.;4m*nN:"g?/!$N>[KeJXe5kY+7]p]:_eo]XQs,@(>4KPkB_?\s+/YarDXO"dlAoak@Hr_Ydm`5C?&J2Eeu!Y'i`g6gEJ(-8hkl0MECV>G!n"!+UH!<0*tHbAGZ%Yuj$ulkqGuK-D%"\^CaYalbNf0r-3S7t.!"+?lY*VYcRrTTAT28N8i<n)-&HJ^:KZTC9Ns],XuaR\4DKA'gZZ-aP$_qXZXimMd@KjZPu+h_t('D)GS!.qY"@YI-tVS\2^;=MGfP'Aa]$OCcDB)m6VHKpcH2^Vm:jmSW=J8ja_5g2%>gM--O1?`SV<.V,_I<3/1K0f-IM]A)eYdBmfHSNY_bSdDjZl*lm+_t3<A19BC]m*PGmNk3)PV?O:p3T]9c/c0:<PQ(W(X2F?~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2164
>>
stream
Gau0DgN)%,&:O:Sm!,B@3?qD6lIIdJXrOpB3fXheqSX2;">)GEaT.uFrd>U@7F];Bm&%3]*g9^m@eZD!$X7#u\"u<A'u98Gk[.P%fs$:JEX`g#\+"*Op>aAs<dI-N[%2aW_njF`*7%&8Hu6C2Om?+W=LR<#`m&H$E/&^CNLM(B<p7C)b>U$0><1ok#a[4KhmS;Z),&*c,2"33^bdB)YQ/8a0"=[PRdcCb%i;JF#8Zc.A7+m(:ir#TMU'_TG@PX*1)N-WWR'n0@!X0>4uPn@/Cr1YM1C-#PD$rk7.O(S8Kc8'99V!L,niOq-)6N#i*R0$=]*92q\Wfi5sU-SqZ>Bu"J^\[:E^iToMV:CGYeK[,QrdAL-)J7:@"[Cg-'l,;NZ$X(?ehtl$)VY7@Ni%c-6[bBooLE5%L/f1+n)UN/HE2eA(4!8@?3HSE=5o'AP/!ku.ULdL8k1FH%Z#,aGe':3f#9_if%T*)H99jLkR$B[_LJ2DcDTObUd'1?r<jb1R(%\`6^U>@)/F3A!n;.=DX:kgY(GPD3KSMs1YW7ieO7h&2X2PM3a'YR'uh\ZW9J:8[\>[shqW`n7LH_m[e2b<%X0P65#.n<kU``+_J9q;@BK:a,h\[.PYsa-sqd;s..!,-:`</ol>E5b<Xn_taeqcF\%EGIbLr.DG_LEJ^`):AH-rkPiFLpt-dc0chP^L1#sGcQ&"](l_%_U[aIs%D<@`_kCMna?\?G-q!W5DgR;YG][_31cug\0[d8W\O0P@LoQ&iUd2X<$h%el@S0.&+-WX0QTWQ6Na/?U#dFLf,T@,3,YT#g?-P&tN\_UN(V1[ZYI@L9n@@XANdn=UC+^bWg@m`6c!VHi\gmoi7)=c''=-d!Cf]3n>_\p^UXG1']/_2t82375R#OFS[6u\eSe1#!D5)E\HTWOf.aF'^j^R7XkT+WH_e&:J.^p*F,[u%k.Mu-dn[:9hj4iEuLJQG>SYK7*WB?N(S\Uu@-sUrS:A'?I,#b*#o!f(V'N(#ap^OUWVbtA6qG*\m,I<#^n=Kb+Hh>>K.+DXQm%a6Y7rQr3cf@0rbd+p!\e:.9:`W+j90#Qb-j6Q5aL#6(S[D;Jb+S$Gf[&K,dfQXN"k+_LOLpt:RdTsW?4mQ;(8=@"4?!D_mMW(6WeaEe:pc%07DLT'G$1ZGaRKDa9J.KHM388nS5CN$-^;Xl^L`4IJ`DakIo%+_+J:,iL`7OPV/ZdX%*&F7<;++j'7t:\cW.CnST%O`4j+nXC0#Vob]=2+.u!TjD1?Oe#M"<-%C%AJFWRTl,U@#RXH^-K&N<_NoLFpI:"2[C;U@C$H]f6/Sd\sT+q\XG0al`-fq,PJNl&t/TLmp;Lk4fe[Xj74/,f><ndYefH.GFZY[H-bdotL7SZ4tQEbkm2+5;tq'-bJ18V=F,;i3.gH0E4,n:pF$f*8*6eY>o.%sI>8eL`qW&[qJ:r6c-:UZ.o9:h1P.rf]Se(%AYe<slml;=GKAY;WJ>;)S2OS+"4?MSau<SF1oH$e_)47O4%7jAgtq=@.MVrH\Vn)h$.:B!Tc-=_,$@I*rko%))\O(UQ?<ZIEsd=LJ7@:''P./6=hCHOVceHDQ'.!LG1Yar?9-[+M]Md\c]B>"2R,h^;PRI/f&)Z>Aa)5F'1^IZPn=mgBa)rn_PF'O&u`.5c*pnrW%#6QUf?L)OPF4>'PURjLsgQ$SbS6ukCXHi341jrBf)(0.7YHX%*rkNbJD."f4R>Kli$lIM]B=5,Tj6bGR;`en?4J[aP>'ra\!O@9afB<`E*8+1>e2;DqhLi.@)=%uuV'`:_VA3qUg[eH;KAU3ZM.0\Q;X]1:l3lP<9A-D%=2l\gldMhl))>2ZO'2abic-^BQ!h-3Ae!0-a)5gU.'p4<')(,5'[kV-':h%P#Ym-?q'LM%OFk-@f($*'>EIM_U!rUal&9*NG$:p1E:me;l9gqi@Wsd@Cb\o]la@+RLG$hUenX@E2cEARC`P?pk^jaD?^7nsVg"5+ZK(Beeqhl5nh%>pHfs0419q0*qN>-VVSWtk_V^N>m+eT?=.C2U\O405B;j(Q`4OZ.aLR3KhQUSQ5*jtjkDq3q/1MXQgKCeG?IY>2f.$!6lq[2hA#="?3]+A&kL'E(#d+aoHLiG<OBM"N"ha)%/LHPj5p:iS1-ido[!X?Z%+o~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1496
>>
stream
Gb!#[9lo;`&A@ZcqA))FKHq,MHR&65,jTJQ6fl6tOBQ*OdYp:E1<[F.O!Fj^m=]WD@%`HEURn(O?GC@ZnECfIJ!1N'[hXic"V??karm'r'3O),a49*f&9KFtRHK@cBaD%Zlp]rJ6MJl=D_d4uirH_7%&3!/s/tnn`=2T[B!<b!M\(K]D@qVH")#H7JQl63i=)$>e;B_YHsnKn_*niL-^A)J>_Bn`^+E/sO;X9r[PD,q?,>efXF@R6I\Z(!ac?=`AGtZ]H"s&1k!mDcS)[!s2qRd*IhMWg=REp";^C"-ZBa,L<[+B9AatG"#i%\/,"o@\;]ooM^r0Od"=`O]">1*4JS#0S)YdWS6`8"MJtp'rpJ>X)g4L+D0Qt"H_n#\WDe<KDem)Y;#*>,/;R#/uJ"M4:4mC;m)TOM)il2PJ/M,AW4.N%up3ET(,0)O;itp-gHjT`eo0sf2#D\hb\]6's`uUYM%V/K)8.ZmYQs'bH/E4SYM.VACI1!5DRS:_/3WTi4'W4sb_sLn=bAVSM3W%nL1/=q8/Q7s01l2G`EHFItd>%&IDLV%H;='(RMB1GemfN!Rpg&Cl`G_gYk::!F2he?^X<;V6d82:f@o%e-'b`So@]uo;5ju3S[[:7;B9j60I8t1mSre+/8PD"_;l-^XQ>/d?,YZi7+5PerAIl1GbSCDF85[a:DorDElt*:[(,(5f4F8UNj8T_eP:Y!i+.]DqGc(=_RKA+Za$W]`&'Q0OnWFgmmTE8_RT-qCNc[RaOBd^=,)/-/.V-g.TL^S)Y%5^c;q1G8`O+K)Si3T,kS!^um;WH\IdesXIZh^c3m;ZHDkt`B-$)&$qZP#dP#1Um#5Qt5Kp[D8PSD^()m"6f8W9mRC;nL)pQG&uW'Ju('&COqpQ/TS>IjK.cufk>51/]r87'8UUo'?,[3JSkha6`6Pt&*`_GX8sZE,SZJQ.c58c8];UM9?AQon+'-gg2oYe%6?-Q;P!e:Kp9fP=\Men"["*\+,t.kEZCjA2q^.nhoQ#Z61["8UUcQLb83e@+'rnU,+cqG9=JT=A8c(6"Q<,dN2'VR?IXLHE.t(N?"BnWRoVi"O-ObU]a@mbf<hMEiMb;Qi@)A]g2He\_MYYC\uI>84K\+,%9MU"UXp[C%2ga!D2'n>tt4Y"lS`c\5BqTn1i6^tqph2@/'a3->8uPO%ac5lW[#\+JhCFNKDP4L&sbon&W+a%9fE0gW,ij>Aq;=VPY2,fP_mKk]g<$<QiU1)a&Z5Y=p!kkq4]^R8(N"C96\/Y-1:-e>_mi''<Hm?Ij[k9#1[7'NFDIgit,g=O7*JS=I#@1ifO(rg(^:r)Yp*J6Q6b6t7ATBnP*dUNLEn)pbTd=ISKL6VB:btX7D.[.G3@6VKILP:)%^N@5l<f<fd[+r0`/K\;a'&_&d^OF*(>!4.laf%%>',RF+[O:o`h@n<?D$aQVH*C+I;;%b2B^.O)kZ/9&1K8#qnM$`#_mu"=B8>[J_;HEAD7%^~>endstream
endobj
xref
0 21
0000000000 65535 f
0000000061 00000 n
0000000143 00000 n
0000000250 00000 n
0000000362 00000 n
0000000477 00000 n
0000000682 00000 n
0000000787 00000 n
0000000992 00000 n
0000001197 00000 n
0000001274 00000 n
0000001480 00000 n
0000001564 00000 n
0000001770 00000 n
0000001840 00000 n
0000002155 00000 n
0000002241 00000 n
0000003898 00000 n
0000004988 00000 n
0000006556 00000 n
0000008812 00000 n
trailer
<<
/ID
[<72adbca2be4f8c3d1f5a7c237b0cd8bd><72adbca2be4f8c3d1f5a7c237b0cd8bd>]
% ReportLab generated PDF document -- digest (opensource)
/Info 14 0 R
/Root 13 0 R
/Size 21
>>
startxref
10400
%%EOF
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
git init
git add .
git commit -m "first commit"
git remote add origin http://10.9.11.210:3000/admin/sap-erp.git
git push -u origin main
+3 -1
View File
@@ -138,7 +138,9 @@ module.exports = async function auditLogger(req, res, next) {
try { try {
let u = {}; let u = {};
try { const tok = getToken(req); if (tok) u = jwt.verify(tok, SECRET) || {}; } catch (_e) {} try { const tok = getToken(req); if (tok) u = jwt.verify(tok, SECRET) || {}; } catch (_e) {}
let username = u.username || u.name || (req.body && req.body.username) || ''; // req.auditActor: set by routes whose callers aren't ERP users (Sales
// customer portal "customer:<code>", Sales external API).
let username = u.username || u.name || req.auditActor || (req.body && (req.body.username || (String(req.originalUrl || '').startsWith('/api/sales-portal/login') && req.body.cardCode ? `customer:${req.body.cardCode}` : ''))) || '';
let role = u.role || ''; let role = u.role || '';
if (!username && u.id) { try { const full = await require('../services/hanaUsers').findById(u.id); if (full) { username = full.username; role = role || full.role; } } catch (_e) {} } if (!username && u.id) { try { const full = await require('../services/hanaUsers').findById(u.id); if (full) { username = full.username; role = role || full.role; } } catch (_e) {} }
if (!username) username = '(anonymous)'; if (!username) username = '(anonymous)';
+118
View File
@@ -765,6 +765,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.6", "version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
@@ -2021,6 +2031,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -2708,6 +2728,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -3162,6 +3189,29 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"dev": true,
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3364,6 +3414,58 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/mysql2": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/native-duplexpair": { "node_modules/native-duplexpair": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@@ -4183,6 +4285,22 @@
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/sql-escaper": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"dev": true,
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/standardwebhooks": { "node_modules/standardwebhooks": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+119
View File
@@ -27,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"mysql2": "^3.24.5",
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
} }
}, },
@@ -1216,6 +1217,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.6", "version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
@@ -2487,6 +2498,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3174,6 +3195,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -3628,6 +3656,29 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"dev": true,
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3830,6 +3881,58 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/mysql2": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/native-duplexpair": { "node_modules/native-duplexpair": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@@ -4649,6 +4752,22 @@
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/sql-escaper": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"dev": true,
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/standardwebhooks": { "node_modules/standardwebhooks": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+1
View File
@@ -27,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"mysql2": "^3.24.5",
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
} }
} }
+141 -28
View File
@@ -163,6 +163,13 @@ textarea.fi{height:auto;min-height:60px;resize:vertical;line-height:1.5;padding-
<main> <main>
<div id="app"></div> <div id="app"></div>
</main> </main>
<datalist id="uom-list">
<option value="NOS"></option>
<option value="KG"></option>
<option value="BOX"></option>
<option value="LITRE"></option>
<option value="METER"></option>
</datalist>
<script> <script>
'use strict'; 'use strict';
@@ -185,6 +192,9 @@ let _listSkip = 0;
let _listTop = 20; let _listTop = 20;
let _listQ = ''; let _listQ = '';
let _listTotal = 0; let _listTotal = 0;
let _listGroupCode = ''; // '' = all Item Groups
let _listStatus = ''; // '' = all, 'active', 'inactive'
let _listWarehouse = ''; // '' = all warehouses
const ITEM_TYPE_CONFIG = { const ITEM_TYPE_CONFIG = {
finished: { prefix: 'FG', label: 'Finished Goods', groupName: 'FINISH GOODS', materialType: 'mt_FinishedGoods', hasSeries: false, defaultManual: true, padLen: 6 }, finished: { prefix: 'FG', label: 'Finished Goods', groupName: 'FINISH GOODS', materialType: 'mt_FinishedGoods', hasSeries: false, defaultManual: true, padLen: 6 },
@@ -477,11 +487,16 @@ function setMode(mode, itemCode=null){
_activeTab = 'general'; _activeTab = 'general';
render(); render();
} else if(itemCode){ } else if(itemCode){
loadItemForEdit(itemCode, mode==='view'); loadItemForEdit(itemCode, mode==='view', mode==='duplicate');
} }
} }
async function loadItemForEdit(itemCode, readonly=false){ // Duplicate: same fields as Edit's own load, minus the Item Code (which
// gets a fresh auto-generated one, same as a normal New Item of that
// category) — so the user only has to change what's actually different,
// typically just the Description, instead of re-entering the whole item
// from scratch.
async function loadItemForEdit(itemCode, readonly=false, duplicate=false){
try{ try{
toast('Loading item...','warn'); toast('Loading item...','warn');
const res = await api('GET', `/sap/items/${encodeURIComponent(itemCode)}/detail?company=${encodeURIComponent(_co)}`); const res = await api('GET', `/sap/items/${encodeURIComponent(itemCode)}/detail?company=${encodeURIComponent(_co)}`);
@@ -556,15 +571,48 @@ async function loadItemForEdit(itemCode, readonly=false){
AdditionalIdentifier: d.SWW||'', AdditionalIdentifier: d.SWW||'',
}; };
const grpName = (_itemGroups.find(g=>String(g.code)===String(d.ItemsGroupCode))||{}).name||''; // Primary signal: the item's OWN code prefix (RM/PK/ICO/FG) — set by
// Create Item's own auto-numbering, so it's a direct, reliable match to
// the category that created it. The SAP Item Group is a SEPARATE
// classification that doesn't always line up with it — e.g. ICO16020 is
// filed under Item Group "R&M Other" in SAP, nothing containing
// "Consumable" at all, so group-name matching alone wrongly showed
// Custom/Other for a real ICO item. Group-name matching (case-insensitive
// — SAP's names aren't guaranteed ALL CAPS) is kept as the fallback for
// codes that don't follow any of these prefixes at all.
const codeUp = (d.ItemCode||'').toUpperCase();
if(/^RM\d/.test(codeUp)) _selectedTypeKey='rm';
else if(/^PK\d/.test(codeUp)) _selectedTypeKey='pk';
else if(/^ICO\d/.test(codeUp)) _selectedTypeKey='ico';
else if(/^FG\d/.test(codeUp)) _selectedTypeKey='finished';
else{
const grpName = ((_itemGroups.find(g=>String(g.code)===String(d.ItemsGroupCode))||{}).name||'').toUpperCase();
if(grpName.includes('RAW')) _selectedTypeKey='rm'; if(grpName.includes('RAW')) _selectedTypeKey='rm';
else if(grpName.includes('PACK')) _selectedTypeKey='pk'; else if(grpName.includes('PACK')) _selectedTypeKey='pk';
else if(grpName.includes('CONSUMABLE')) _selectedTypeKey='ico'; else if(grpName.includes('CONSUMABLE')) _selectedTypeKey='ico';
else if(grpName.includes('FINISH')) _selectedTypeKey='finished'; else if(grpName.includes('FINISH')) _selectedTypeKey='finished';
else _selectedTypeKey='custom'; else _selectedTypeKey='custom';
}
_selectedWhRow = d.DefaultWarehouse||null; _selectedWhRow = d.DefaultWarehouse||null;
_activeTab = 'general'; _activeTab = 'general';
if(duplicate){
_mode='create';_currentItemCode=null;
_form.ItemCode='';
// Cleared, not copied — SAP expects these unique per item, same reason
// Item Code itself always gets a fresh one below.
_form.BarCode='';
const badge=q('#mode-badge');
if(badge){badge.className='mode-badge create';badge.textContent='Create Mode';}
render();
const config=ITEM_TYPE_CONFIG[_selectedTypeKey];
if(config&&config.hasSeries) await refreshItemCode();
toast(`Duplicated from ${itemCode} — update the Description (and anything else that should differ), then Save`,'ok');
setTimeout(()=>{ const nm=q('#inp-name'); if(nm){nm.focus();nm.select();} },0);
return;
}
render(); render();
toast(`Loaded: ${itemCode}`, 'ok'); toast(`Loaded: ${itemCode}`, 'ok');
}catch(e){ }catch(e){
@@ -578,10 +626,15 @@ async function loadItemForEdit(itemCode, readonly=false){
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
async function loadItemList(){ async function loadItemList(){
try{ try{
const res = await api('GET', `/sap/items?company=${encodeURIComponent(_co)}&q=${encodeURIComponent(_listQ)}&skip=${_listSkip}&top=${_listTop}`); const qs=new URLSearchParams({company:_co,q:_listQ,skip:String(_listSkip),top:String(_listTop)});
if(_listGroupCode) qs.set('groupCode',_listGroupCode);
if(_listStatus) qs.set('status',_listStatus);
if(_listWarehouse) qs.set('warehouse',_listWarehouse);
const res = await api('GET', `/sap/items?${qs.toString()}`);
_itemList = res.data||[]; _itemList = res.data||[];
_listTotal = res.count||0; _listTotal = res.count||0;
renderList(); renderList();
populateListFilterOptions();
// Restore focus to search box after render, preserving cursor // Restore focus to search box after render, preserving cursor
const searchEl = q('#list-search'); const searchEl = q('#list-search');
if(searchEl) { if(searchEl) {
@@ -593,6 +646,21 @@ async function loadItemList(){
toast('Failed to load items','err'); toast('Failed to load items','err');
} }
} }
// The list toolbar (incl. these two <select>s) is only ever BUILT once (see
// the `if(!q('#list-toolbar-root'))` guard in renderList() below) — kept
// that way so repeated re-renders while the user is typing in the search
// box never steal its focus. But _itemGroups/_wh (services/... lookups)
// load asynchronously and can still be empty at that first build (e.g.
// landing straight on ?list=1 before loadLookups() resolves), which would
// otherwise leave these two dropdowns permanently stuck on "All" with no
// real options. Refreshing just their <option> lists (not the whole
// toolbar) on every list load is cheap and doesn't touch the search input.
function populateListFilterOptions(){
const gSel=q('#sel-list-group');
if(gSel) gSel.innerHTML=`<option value="">All Item Groups</option>`+_itemGroups.map(g=>`<option value="${esc(g.code)}" ${String(_listGroupCode)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('');
const wSel=q('#sel-list-wh');
if(wSel) wSel.innerHTML=`<option value="">All Warehouses</option>`+_wh.map(w=>`<option value="${esc(w.code)}" ${String(_listWarehouse)===String(w.code)?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('');
}
function renderList(){ function renderList(){
const app = q('#app'); const app = q('#app');
@@ -616,8 +684,25 @@ function renderList(){
<input type="text" id="list-search" placeholder="Search item code or name..." <input type="text" id="list-search" placeholder="Search item code or name..."
value="${esc(_listQ)}" autocomplete="off" spellcheck="false"/> value="${esc(_listQ)}" autocomplete="off" spellcheck="false"/>
</div> </div>
<button class="btn b-primary" id="btn-search" style="padding:8px 16px;font-size:12px"><i data-lucide="search" style="width:14px;height:14px"></i> Search</button>
<button class="btn b-success" id="btn-new-item" style="padding:8px 16px;font-size:12px"><i data-lucide="plus" style="width:14px;height:14px"></i> New Item</button> <button class="btn b-success" id="btn-new-item" style="padding:8px 16px;font-size:12px"><i data-lucide="plus" style="width:14px;height:14px"></i> New Item</button>
</div> </div>
<div class="list-toolbar" id="list-filter-root" style="margin-top:8px">
<select class="fi" id="sel-list-group" style="height:32px;font-size:11.5px;max-width:220px">
<option value="">All Item Groups</option>
${_itemGroups.map(g=>`<option value="${esc(g.code)}" ${String(_listGroupCode)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('')}
</select>
<select class="fi" id="sel-list-status" style="height:32px;font-size:11.5px;max-width:150px">
<option value="">All Status</option>
<option value="active" ${_listStatus==='active'?'selected':''}>Active</option>
<option value="inactive" ${_listStatus==='inactive'?'selected':''}>Inactive</option>
</select>
<select class="fi" id="sel-list-wh" style="height:32px;font-size:11.5px;max-width:200px">
<option value="">All Warehouses</option>
${_wh.map(w=>`<option value="${esc(w.code)}" ${String(_listWarehouse)===String(w.code)?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('')}
</select>
<button class="btn b-cancel" id="btn-clear-filters" style="padding:6px 14px;font-size:11.5px">Clear Filters</button>
</div>
<div id="list-container" class="item-list"></div> <div id="list-container" class="item-list"></div>
<div id="list-pagination" class="pagination"></div> <div id="list-pagination" class="pagination"></div>
</div>`; </div>`;
@@ -631,6 +716,32 @@ function renderList(){
clearTimeout(window._searchDebounce); clearTimeout(window._searchDebounce);
window._searchDebounce = setTimeout(loadItemList, 400); window._searchDebounce = setTimeout(loadItemList, 400);
}; };
// Explicit Search button — same effect as the debounced typing above,
// but fires immediately (no 400ms wait, and no need to keep typing) —
// also handy after picking a filter without touching the search box.
q('#btn-search').onclick = function(){
clearTimeout(window._searchDebounce);
_listSkip = 0;
loadItemList();
};
q('#list-search').addEventListener('keydown', function(e){
if(e.key==='Enter'){ clearTimeout(window._searchDebounce); _listSkip=0; loadItemList(); }
});
q('#sel-list-group').onchange = function(){ _listGroupCode=this.value; _listSkip=0; loadItemList(); };
q('#sel-list-status').onchange = function(){ _listStatus=this.value; _listSkip=0; loadItemList(); };
q('#sel-list-wh').onchange = function(){ _listWarehouse=this.value; _listSkip=0; loadItemList(); };
q('#btn-clear-filters').onclick = function(){
_listQ=''; _listGroupCode=''; _listStatus=''; _listWarehouse=''; _listSkip=0;
// The toolbar is only ever built once (see the `if(!q('#list-toolbar-
// root'))` guard above) — later renders just refresh the results, so
// clearing state alone wouldn't visually reset these controls; set
// their DOM values directly too.
if(q('#list-search'))q('#list-search').value='';
if(q('#sel-list-group'))q('#sel-list-group').value='';
if(q('#sel-list-status'))q('#sel-list-status').value='';
if(q('#sel-list-wh'))q('#sel-list-wh').value='';
loadItemList();
};
q('#btn-new-item').onclick = ()=>setMode('create'); q('#btn-new-item').onclick = ()=>setMode('create');
} }
@@ -664,6 +775,7 @@ function renderListItems(){
<span class="po-actions"> <span class="po-actions">
<button class="btn-icon" data-action="view" data-code="${esc(item.ItemCode)}" title="View"><i data-lucide="eye"></i></button> <button class="btn-icon" data-action="view" data-code="${esc(item.ItemCode)}" title="View"><i data-lucide="eye"></i></button>
<button class="btn-icon" data-action="edit" data-code="${esc(item.ItemCode)}" title="Edit"><i data-lucide="pencil"></i></button> <button class="btn-icon" data-action="edit" data-code="${esc(item.ItemCode)}" title="Edit"><i data-lucide="pencil"></i></button>
<button class="btn-icon" data-action="duplicate" data-code="${esc(item.ItemCode)}" title="Duplicate — copies every field except Item Code (auto-generated fresh)"><i data-lucide="copy"></i></button>
</span> </span>
</div> </div>
</div>`; </div>`;
@@ -849,7 +961,7 @@ function render(){
} else if(_mode==='edit'){ } else if(_mode==='edit'){
actions.innerHTML = `<div class="card-bd"><div class="btn-bar"> actions.innerHTML = `<div class="card-bd"><div class="btn-bar">
<button class="btn b-primary" id="btn-update"><span id="sp-update" style="display:none" class="sp"></span> Update Item</button> <button class="btn b-primary" id="btn-update"><span id="sp-update" style="display:none" class="sp"></span> Update Item</button>
<button class="btn b-success" id="btn-save-as-new">Save as New</button> <button class="btn b-success" id="btn-save-as-new">⧉ Duplicate</button>
<button class="btn b-danger" id="btn-cancel-item">Cancel Item</button> <button class="btn b-danger" id="btn-cancel-item">Cancel Item</button>
<button class="btn b-cancel" id="btn-view-list">Back to List</button> <button class="btn b-cancel" id="btn-view-list">Back to List</button>
</div></div>`; </div></div>`;
@@ -857,7 +969,7 @@ function render(){
// view mode // view mode
actions.innerHTML = `<div class="card-bd"><div class="btn-bar"> actions.innerHTML = `<div class="card-bd"><div class="btn-bar">
<button class="btn b-primary" id="btn-edit">✎ Edit Item</button> <button class="btn b-primary" id="btn-edit">✎ Edit Item</button>
<button class="btn b-success" id="btn-save-as-new">Save as New</button> <button class="btn b-success" id="btn-save-as-new">⧉ Duplicate</button>
<button class="btn b-cancel" id="btn-view-list">Back to List</button> <button class="btn b-cancel" id="btn-view-list">Back to List</button>
</div></div>`; </div></div>`;
} }
@@ -976,14 +1088,7 @@ function renderPurchasing(tc){
<input class="fi ${isReadonly?'readonly-field':''}" id="p-vendor" placeholder="Vendor code..." value="${esc(_form.DefaultVendor||'')}" ${isReadonly?'readonly':''}/> <input class="fi ${isReadonly?'readonly-field':''}" id="p-vendor" placeholder="Vendor code..." value="${esc(_form.DefaultVendor||'')}" ${isReadonly?'readonly':''}/>
</div> </div>
<div class="fg"><label class="fl">Purchasing UoM</label> <div class="fg"><label class="fl">Purchasing UoM</label>
<select class="fi ${isReadonly?'readonly-field':''}" id="p-puom" ${isReadonly?'disabled':''}> <input class="fi ${isReadonly?'readonly-field':''}" id="p-puom" list="uom-list" placeholder="- Same as inventory -" value="${esc(_form.PurchaseUnit||'')}" ${isReadonly?'readonly':''}/>
<option value="">- Same as inventory -</option>
<option value="NOS" ${_form.PurchaseUnit==='NOS'?'selected':''}>NOS</option>
<option value="KG" ${_form.PurchaseUnit==='KG'?'selected':''}>KG</option>
<option value="BOX" ${_form.PurchaseUnit==='BOX'?'selected':''}>BOX</option>
<option value="LITRE" ${_form.PurchaseUnit==='LITRE'?'selected':''}>LITRE</option>
<option value="METER" ${_form.PurchaseUnit==='METER'?'selected':''}>METER</option>
</select>
</div> </div>
</div> </div>
<div class="fi-row"> <div class="fi-row">
@@ -1006,7 +1111,7 @@ function renderPurchasing(tc){
tc.appendChild(c); tc.appendChild(c);
if(!isReadonly){ if(!isReadonly){
const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='select'?'onchange':'oninput']=function(){_form[f]=type==='num'?parseFloat(this.value)||0:this.value;};}; const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='select'?'onchange':'oninput']=function(){_form[f]=type==='num'?parseFloat(this.value)||0:this.value;};};
w('p-vendor','DefaultVendor'); w('p-puom','PurchaseUnit','select'); w('p-vendor','DefaultVendor'); w('p-puom','PurchaseUnit');
w('p-qpp','PurchaseQtyPerPackage','num'); w('p-ipu','PurchaseItemsPerUnit','num'); w('p-qpp','PurchaseQtyPerPackage','num'); w('p-ipu','PurchaseItemsPerUnit','num');
w('p-lead','LeadTime','num'); w('p-moq','MinimumOrderQuantity','num'); w('p-lead','LeadTime','num'); w('p-moq','MinimumOrderQuantity','num');
} }
@@ -1019,13 +1124,7 @@ function renderSales(tc){
<div class="card-bd"> <div class="card-bd">
<div class="fi-row"> <div class="fi-row">
<div class="fg"><label class="fl">Sales UoM</label> <div class="fg"><label class="fl">Sales UoM</label>
<select class="fi ${isReadonly?'readonly-field':''}" id="s-suom" ${isReadonly?'disabled':''}> <input class="fi ${isReadonly?'readonly-field':''}" id="s-suom" list="uom-list" placeholder="- Same as inventory -" value="${esc(_form.SalesUnit||'')}" ${isReadonly?'readonly':''}/>
<option value="">- Same as inventory -</option>
<option value="NOS" ${_form.SalesUnit==='NOS'?'selected':''}>NOS</option>
<option value="KG" ${_form.SalesUnit==='KG'?'selected':''}>KG</option>
<option value="BOX" ${_form.SalesUnit==='BOX'?'selected':''}>BOX</option>
<option value="LITRE" ${_form.SalesUnit==='LITRE'?'selected':''}>LITRE</option>
</select>
</div> </div>
<div class="fg"><label class="fl">Sales QTY Per Package</label> <div class="fg"><label class="fl">Sales QTY Per Package</label>
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="s-qpp" type="number" min="0" step="0.001" placeholder="0" value="${_form.SalesQtyPerPackage||''}" ${isReadonly?'readonly':''}/> <input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="s-qpp" type="number" min="0" step="0.001" placeholder="0" value="${_form.SalesQtyPerPackage||''}" ${isReadonly?'readonly':''}/>
@@ -1044,7 +1143,7 @@ function renderSales(tc){
if(!isReadonly){ if(!isReadonly){
setTimeout(()=>{ setTimeout(()=>{
const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='select'?'onchange':'oninput']=function(){_form[f]=type==='num'?parseFloat(this.value)||0:this.value;};}; const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='select'?'onchange':'oninput']=function(){_form[f]=type==='num'?parseFloat(this.value)||0:this.value;};};
w('s-suom','SalesUnit','select'); w('s-qpp','SalesQtyPerPackage','num'); w('s-suom','SalesUnit'); w('s-qpp','SalesQtyPerPackage','num');
w('s-moq','SalesMinimumOrderQuantity','num'); w('s-warranty','WarrantyTemplate','num'); w('s-moq','SalesMinimumOrderQuantity','num'); w('s-warranty','WarrantyTemplate','num');
},0); },0);
} }
@@ -1275,19 +1374,33 @@ function wireHeaderEvents(){
// FIX: wire the Back to List / View Items button in all modes // FIX: wire the Back to List / View Items button in all modes
bind('#btn-view-list','onclick', goToList); bind('#btn-view-list','onclick', goToList);
// FIX: wire Save as New button (edit + view modes) // FIX: wire Save as New button (edit + view modes) — same "duplicate"
bind('#btn-save-as-new','onclick',()=>{ // behavior as the list's own Duplicate action: every field carries over
// except Item Code, which gets a fresh auto-generated one (for a
// series-based category) instead of forcing manual entry, so the user
// only has to change what's actually different (typically just the
// Description) before saving.
bind('#btn-save-as-new','onclick',async()=>{
const oldCode = _form.ItemCode; const oldCode = _form.ItemCode;
_form.ItemCode = ''; _form.ItemCode = '';
_form.BarCode = ''; // unique-per-item in SAP — never copy, same as Item Code
_currentItemCode = null; _currentItemCode = null;
_mode = 'create'; _mode = 'create';
_lastSequence = null; _lastSequence = null;
_isManualMode = true;
_sequenceError = null; _sequenceError = null;
const badge = q('#mode-badge'); const badge = q('#mode-badge');
if(badge){ badge.className='mode-badge create'; badge.textContent='Create Mode'; } if(badge){ badge.className='mode-badge create'; badge.textContent='Create Mode'; }
const config = ITEM_TYPE_CONFIG[_selectedTypeKey];
if(config&&config.hasSeries){
_isManualMode=false;
render(); render();
toast(`Cloning from ${oldCode} — enter a new item code`, 'warn'); await refreshItemCode();
}else{
_isManualMode=true;
render();
}
toast(`Duplicated from ${oldCode} — update the Description (and anything else that should differ), then Save`, 'ok');
setTimeout(()=>{ const nm=q('#inp-name'); if(nm){nm.focus();nm.select();} },0);
}); });
// Auto-init sequence for series categories on first create render // Auto-init sequence for series categories on first create render
@@ -1539,7 +1652,7 @@ async function init(){
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+271 -7
View File
@@ -424,14 +424,17 @@ const MODULE_GROUPS=[
{k:'production-dashboard',l:'Dashboard'},{k:'production-create',l:'Create Order'},{k:'production-issue',l:'Issue'}, {k:'production-dashboard',l:'Dashboard'},{k:'production-create',l:'Create Order'},{k:'production-issue',l:'Issue'},
{k:'production-receipt',l:'Receipt'},{k:'work-order-verify',l:'Verify Work Order'},{k:'production-verify',l:'Verify (Production Order — legacy)'},{k:'production-close',l:'Close'}, {k:'production-receipt',l:'Receipt'},{k:'work-order-verify',l:'Verify Work Order'},{k:'production-verify',l:'Verify (Production Order — legacy)'},{k:'production-close',l:'Close'},
{k:'production-requirements',l:'Requirements'},{k:'production-batch-issuance',l:'Batch Issuance'}, {k:'production-requirements',l:'Requirements'},{k:'production-batch-issuance',l:'Batch Issuance'},
{k:'production-work-order',l:'Work Order'},{k:'man-power',l:'Man Power'},{k:'oee',l:'OEE'}, {k:'production-work-order',l:'Work Order'},{k:'man-power',l:'Man Power'},{k:'oee',l:'OEE'},{k:'downtime-analysis',l:'Downtime Analysis'},
{k:'rejection-register',l:'Rejection Register'},{k:'rejection-analytics',l:'Rejection Analytics'}, {k:'rejection-register',l:'Rejection Register'},{k:'rejection-analytics',l:'Rejection Analytics'},
{k:'production-planning',l:'Production Planning'}, {k:'production-planning',l:'Production Planning'},{k:'production-deviations',l:'Deviations'},
]}, ]},
{group:'Purchase',items:[ {group:'Purchase',items:[
{k:'purchase-request',l:'Purchase Request'},{k:'purchase-quotation',l:'Quotation'}, {k:'purchase-request',l:'Purchase Request'},{k:'purchase-quotation',l:'Quotation'},
{k:'purchase-order',l:'Purchase Order'},{k:'purchase-grpo',l:'Goods Receipt PO'}, {k:'purchase-order',l:'Purchase Order'},{k:'purchase-grpo',l:'Goods Receipt PO'},
]}, ]},
{group:'Sales',items:[
{k:'sales-orders',l:'Sales Orders'},{k:'sales-samples',l:'Sample Requests'},{k:'sales-reports',l:'Sales Reports'},{k:'sales-admin',l:'Sales Admin'},
]},
{flat:true,k:'approvals',l:'Approvals'}, {flat:true,k:'approvals',l:'Approvals'},
{flat:true,k:'sap-approvals',l:'SAP Approvals'}, {flat:true,k:'sap-approvals',l:'SAP Approvals'},
{flat:true,k:'customers',l:'Customers'}, {flat:true,k:'customers',l:'Customers'},
@@ -449,6 +452,10 @@ const MODULE_GROUPS=[
{flat:true,k:'reports',l:'Reports'}, {flat:true,k:'reports',l:'Reports'},
{flat:true,k:'ppc-report',l:'PPC Report'}, {flat:true,k:'ppc-report',l:'PPC Report'},
{flat:true,k:'pwo-source-audit',l:'PWO Source Report'}, {flat:true,k:'pwo-source-audit',l:'PWO Source Report'},
{flat:true,k:'inventory-status-report',l:'Inventory Status Report'},
{flat:true,k:'batch-no-transaction',l:'Batch No. Transaction Report'},
{flat:true,k:'inventory-transfer',l:'Inventory Transfer'},
{flat:true,k:'inventory-posting-list',l:'Inventory Posting List'},
{flat:true,k:'gstr1',l:'GSTR-1'}, {flat:true,k:'gstr1',l:'GSTR-1'},
{flat:true,k:'gstr2',l:'GSTR-2 Purchase'}, {flat:true,k:'gstr2',l:'GSTR-2 Purchase'},
{flat:true,k:'itc04',l:'ITC-04 Job Work'}, {flat:true,k:'itc04',l:'ITC-04 Job Work'},
@@ -1274,6 +1281,7 @@ const DEVIATION_TYPE_OPTS = [
{ v:'SHORTAGE', l:'Shortage — top up qty of the same item' }, { v:'SHORTAGE', l:'Shortage — top up qty of the same item' },
{ v:'SUBSTITUTION', l:'Substitution — issue a different item instead' }, { v:'SUBSTITUTION', l:'Substitution — issue a different item instead' },
{ v:'DAMAGE', l:'Damage/Loss — write off a damaged qty' }, { v:'DAMAGE', l:'Damage/Loss — write off a damaged qty' },
{ v:'ADDITION', l:'Addition — add a new item/component not on the Production Order' },
]; ];
const MANPOWER_TAB_OPTS = [ const MANPOWER_TAB_OPTS = [
{ v:'bb',l:'BB Production' },{ v:'packing',l:'Packing' },{ v:'autoclave',l:'Autoclave' }, { v:'bb',l:'BB Production' },{ v:'packing',l:'Packing' },{ v:'autoclave',l:'Autoclave' },
@@ -1593,9 +1601,21 @@ function buildSettingsTab() {
<input class="form-input" id="set-deviationDamageSeries" value="${esc(s.deviationDamageSeries!=null?String(s.deviationDamageSeries):'')}" placeholder="e.g. 5" style="width:150px;font-family:var(--mono)"/> <input class="form-input" id="set-deviationDamageSeries" value="${esc(s.deviationDamageSeries!=null?String(s.deviationDamageSeries):'')}" placeholder="e.g. 5" style="width:150px;font-family:var(--mono)"/>
</div>`, 'set-save-deviation'); </div>`, 'set-save-deviation');
section('🔁', 'Inventory Transfer', `
<div class="set-row">
<div class="set-info"><div class="set-l">Document Series</div><div class="set-h">SAP document numbering series that every Inventory Transfer posts under — pulled live from SAP (Administration → System Initialization → Document Numbering) so you pick it by its recognizable name, not a raw internal ID.</div></div>
<select class="form-input" id="set-inventoryTransferSeries" data-current="${esc(s.inventoryTransferSeries!=null?String(s.inventoryTransferSeries):'')}" style="width:220px">
<option value="">Loading…</option>
</select>
</div>`, 'set-save-invtransfer');
let itemCodeFloors = {}; let itemCodeFloors = {};
try { itemCodeFloors = JSON.parse(s.itemCodeSeriesFloor || '{}') || {}; } catch { itemCodeFloors = {}; } try { itemCodeFloors = JSON.parse(s.itemCodeSeriesFloor || '{}') || {}; } catch { itemCodeFloors = {}; }
section('🔢', 'Item Code Series (Create Item)', ` section('🔢', 'Item Master — Create Item', `
<div class="set-row">
<div class="set-info"><div class="set-l">Item Approval Workflow</div><div class="set-h">When ON (default), a new item submitted by anyone other than admin/SAP Adder/system admin queues as PENDING under Approvals → Item Approvals until one of them reviews it. When OFF, every submission — any role — pushes straight to SAP, same as an approver's own submission; there is no review queue at all.</div></div>
${yesNo('set-itemApprovalEnabled', s.itemApprovalEnabled)}
</div>
<div class="set-row"> <div class="set-row">
<div class="set-info"><div class="set-l">Start next code from</div><div class="set-h">Create Item's auto-numbering normally suggests the highest existing code for that prefix, +1. If a mistaken/out-of-order entry already used a much higher number than intended, that suggestion jumps straight past the whole unused gap. Set a floor here to force the next SUGGESTED code to at least this number, per prefix — leave blank/0 for no override (natural behavior, as before). This only ever pushes the suggestion UP; it can never cause a collision with a real existing item.</div></div> <div class="set-info"><div class="set-l">Start next code from</div><div class="set-h">Create Item's auto-numbering normally suggests the highest existing code for that prefix, +1. If a mistaken/out-of-order entry already used a much higher number than intended, that suggestion jumps straight past the whole unused gap. Set a floor here to force the next SUGGESTED code to at least this number, per prefix — leave blank/0 for no override (natural behavior, as before). This only ever pushes the suggestion UP; it can never cause a collision with a real existing item.</div></div>
<div style="display:flex;flex-direction:column;gap:8px;width:220px"> <div style="display:flex;flex-direction:column;gap:8px;width:220px">
@@ -1746,6 +1766,7 @@ function buildSettingsTab() {
} }
})(); })();
const notifyDisabledSet = new Set(String(s.notifyDisabledSteps||'').split(',').map(x=>x.trim()).filter(Boolean));
section('✉', 'Stage-Change Email Notifications', ` section('✉', 'Stage-Change Email Notifications', `
<div class="set-row"> <div class="set-row">
<div class="set-info"><div class="set-l">Enable Email Notifications</div><div class="set-h">Emails whoever is next assigned an approval step (Work Order, Production Order, Issue for Production, Verify Work Order) or holds the Batch Issuance module, at every stage transition. SMTP server itself is configured in .env — this only controls the on/off switch and optional fixed extra recipients per step. Global switch — turn off to silence all stage-change emails without touching SMTP config.</div></div> <div class="set-info"><div class="set-l">Enable Email Notifications</div><div class="set-h">Emails whoever is next assigned an approval step (Work Order, Production Order, Issue for Production, Verify Work Order) or holds the Batch Issuance module, at every stage transition. SMTP server itself is configured in .env — this only controls the on/off switch and optional fixed extra recipients per step. Global switch — turn off to silence all stage-change emails without touching SMTP config.</div></div>
@@ -1758,6 +1779,19 @@ function buildSettingsTab() {
</div> </div>
<div id="notify-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div> <div id="notify-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
</div> </div>
<div class="set-row set-col">
<div class="chk-head">
<div class="set-info"><div class="set-l">Notify by Item Group</div><div class="set-h">Optional — route an extra recipient by the order's MAIN PRODUCT's SAP Item Group (not every component's group), in addition to whoever holds the step. E.g. every Work Order/Production Order for a "Solutions" item also emails that team's inbox, regardless of who's assigned the approval step.</div></div>
<button type="button" class="chk-mini" id="notifyig-add-row">+ Add</button>
</div>
<div id="notifyig-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
</div>
<div class="set-row set-col">
<div class="set-info"><div class="set-l">Notify Per Step</div><div class="set-h">Uncheck a step to silence its stage-change email entirely — for everyone assigned to it AND any fixed extra recipient above — without touching who's actually assigned. Checked (default) = email fires normally.</div></div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:4px 14px;margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--border2);border-radius:8px;padding:8px 10px">
${NOTIFY_TARGET_OPTS.map(o=>`<label style="display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer"><input type="checkbox" class="set-notifystep" value="${esc(o.v)}" ${notifyDisabledSet.has(o.v)?'':'checked'}/> ${esc(o.l)}</label>`).join('')}
</div>
</div>
<div class="set-row"> <div class="set-row">
<div class="set-info"><div class="set-l">Confirm Email Delivery</div><div class="set-h">Check SMTP connects with the credentials in .env, and/or send a real test email to confirm it's actually delivered. (Not part of the Save above — these run immediately.)</div></div> <div class="set-info"><div class="set-l">Confirm Email Delivery</div><div class="set-h">Check SMTP connects with the credentials in .env, and/or send a real test email to confirm it's actually delivered. (Not part of the Save above — these run immediately.)</div></div>
<div style="display:flex;flex-direction:column;gap:6px;width:280px"> <div style="display:flex;flex-direction:column;gap:6px;width:280px">
@@ -1770,6 +1804,77 @@ function buildSettingsTab() {
</div> </div>
</div>`, 'set-save-notify'); </div>`, 'set-save-notify');
// ── Downtime Analysis (services/downtimeAnalysisStore.js) ──
// Default period/Base Days + the canonical cause taxonomy + per-OEE-tab
// field→cause mapping. Field lists below are a hardcoded mirror of each
// tab's own OEE_SCHEMA (public/oee.html) — duplicated rather than fetched,
// same convention as WORK_ORDER_STEP_KEYS elsewhere in this app, since the
// schema itself lives client-side and rarely changes.
const DT_TAB_FIELDS = {
ebb: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['microStops','Micro Stops < 10 min'],['emptyRun','Empty run'],['startShut','Start up / shut down']],
pd: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['microStops','Micro Stops < 10 min'],['emptyRun','Empty run'],['startShut','Start up / shut down']],
sheet: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['headClean','Head Clean'],['otherProblem','Other Problem'],['mcStartShut','M/C Start up / shut down']],
moulding: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['powerCutSlow','Power Cut / Slow Speed'],['headClean','Head Clean'],['nozzleClean','Nozzle Clean'],['mcStartShut','M/C Start up / shut down']],
autoclave:[['boiler','Boiler breakdown'],['waterSys','Water system breakdown'],['bopt','BOPT breakdown'],['coolingTower','Cooling tower breakdown'],['others','Others']],
};
const DT_TAB_LABELS = { ebb:'EBB Production', pd:'PD Production', sheet:'Plastic Sheet Plant', moulding:'Moulding', autoclave:'Autoclave' };
let dtCfg = {};
try { dtCfg = JSON.parse(s.downtimeAnalysisConfig || '{}') || {}; } catch (_e) { dtCfg = {}; }
const dtCauses = Array.isArray(dtCfg.causes) ? dtCfg.causes : [];
const dtMap = dtCfg.tabFieldMap || {};
function dtCauseOptions(sel) {
return `<option value="">— Not included —</option>` + dtCauses.map(c => `<option value="${esc(c)}" ${sel===c?'selected':''}>${esc(c)}</option>`).join('');
}
section('⏱', 'Downtime Analysis', `
<div class="set-row">
<div class="set-info"><div class="set-l">Default Period &amp; Base Days</div><div class="set-h">The report's own page lets a user override these per run — this is just what it starts pre-filled with. Base Days is the common working-day calendar for the period (e.g. 330); a machine whose OEE entries only start partway through the period gets this prorated down automatically.</div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div><label class="fl">Period From</label><input class="form-input" id="set-dtPeriodFrom" type="date" value="${esc(dtCfg.periodFrom||'')}" style="width:150px"/></div>
<div><label class="fl">Period To</label><input class="form-input" id="set-dtPeriodTo" type="date" value="${esc(dtCfg.periodTo||'')}" style="width:150px"/></div>
<div><label class="fl">Base Days</label><input class="form-input" id="set-dtBaseDays" type="number" min="1" value="${esc(dtCfg.baseDays||330)}" style="width:100px"/></div>
</div>
</div>
<div class="set-row set-col">
<div class="set-info"><div class="set-l">Downtime Causes</div><div class="set-h">Comma-separated — this is the standardized list every OEE tab's own fields get mapped onto below. Add a new cause here, Save, then reopen this section to assign fields to it.</div></div>
<textarea class="form-input" id="set-dtCauses" rows="2" style="margin-top:6px;resize:vertical">${esc(dtCauses.join(', '))}</textarea>
</div>
<div class="set-row set-col">
<div class="set-info"><div class="set-l">Field → Cause Mapping (per OEE tab)</div><div class="set-h">Different tabs use different field names for the same real-world cause (e.g. Sheet's "Head Clean" vs EBB's "Micro Stops") — map each one onto a standard cause above, or leave it "Not included" to exclude it from the report entirely.</div></div>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:8px">
${Object.keys(DT_TAB_FIELDS).map(tab => `
<div>
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:5px">${esc(DT_TAB_LABELS[tab]||tab)}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">
${DT_TAB_FIELDS[tab].map(([key,label]) => `
<div style="display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--border2);border-radius:7px">
<span style="font-size:10.5px;color:var(--text2)">${esc(label)}</span>
<select class="form-input dt-map-sel" data-tab="${esc(tab)}" data-field="${esc(key)}" style="width:100%;height:32px;font-size:11px">${dtCauseOptions(dtMap[tab]&&dtMap[tab][key])}</select>
</div>`).join('')}
</div>
</div>`).join('')}
</div>
</div>`, 'set-save-downtime');
// ── Short in Stock — Auto Email Alerts (services/shortStockAlertStore.js) ──
// Fully independent feature: a background job (started once in server.js,
// runs every 15 min) sums each watched item/group's on-hand quantity and
// emails the concerned user(s) the FIRST time it hits zero — never on a
// report screen, never gated by the Stage-Change Email settings above.
let shortStockRules = [];
try { shortStockRules = JSON.parse(s.shortStockAlertRules || '[]') || []; } catch (_e) { shortStockRules = []; }
section('📉', 'Short in Stock — Auto Email Alerts', `
<div class="set-row">
<div class="set-info"><div class="set-l">Enable Auto Alerts</div><div class="set-h">A background check (every 15 minutes) emails the concerned user(s) below the FIRST time a watched item/group's total on-hand quantity (across every warehouse) hits zero — not repeatedly while it stays at zero, only again once it's restocked and drops to zero a second time.</div></div>
${yesNo('set-shortStockAlertEnabled', s.shortStockAlertEnabled)}
</div>
<div class="set-row set-col">
<div class="chk-head">
<div class="set-info"><div class="set-l">Watch-List</div><div class="set-h">Add a specific Item Code, or an entire Item Group (every item in it), each with its own concerned user(s).</div></div>
<button type="button" class="chk-mini" id="sstock-add-row">+ Add</button>
</div>
<div id="sstock-rows" style="display:flex;flex-direction:column;gap:8px;margin-top:6px"></div>
</div>`, 'set-save-shortstock');
// ── Receipt: Warehouse → Transaction Type mapping (per company) ── // ── Receipt: Warehouse → Transaction Type mapping (per company) ──
const whWin = document.createElement('div'); whWin.className = 'win'; const whWin = document.createElement('div'); whWin.className = 'win';
whWin.innerHTML = ` whWin.innerHTML = `
@@ -1839,6 +1944,112 @@ function buildSettingsTab() {
return out; return out;
} }
// ── Notify by Item Group rows ──
let notifyByGroup = {};
try { notifyByGroup = JSON.parse(s.notifyItemGroupEmails || '{}') || {}; } catch (_e) { notifyByGroup = {}; }
const notifyIgRowsEl = div.querySelector('#notifyig-rows');
function notifyIgRowHtml(groupCode, emails) {
return `<div class="notify-row" style="display:flex;gap:8px;align-items:center">
<select class="form-input notifyig-group" style="width:280px">
${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}" ${String(g.code)===String(groupCode)?'selected':''}>${esc(g.name)} (${esc(g.code)})</option>`).join('')}
</select>
<input class="form-input notifyig-emails" style="flex:1" placeholder="comma-separated emails" value="${esc(emails||'')}"/>
<button type="button" class="chk-mini notifyig-rm" title="Remove">✕</button>
</div>`;
}
function renderNotifyIgRows() {
const keys = Object.keys(notifyByGroup);
notifyIgRowsEl.innerHTML = keys.length
? keys.map(k => notifyIgRowHtml(k, notifyByGroup[k])).join('')
: '<div style="color:var(--text3);font-size:12px">No item-group recipients configured.</div>';
notifyIgRowsEl.querySelectorAll('.notifyig-rm').forEach(b => b.onclick = function(){ this.closest('.notify-row').remove(); if(!notifyIgRowsEl.children.length) renderNotifyIgRows(); });
}
renderNotifyIgRows();
const notifyIgAddBtn = div.querySelector('#notifyig-add-row');
if (notifyIgAddBtn) notifyIgAddBtn.onclick = () => {
if (!ITEM_GROUPS.length) { toast('Item groups not loaded (SAP unreachable?)', 'err'); return; }
if (notifyIgRowsEl.querySelector('div[style*="color:var(--text3)"]')) notifyIgRowsEl.innerHTML = '';
const wrap = document.createElement('div');
wrap.innerHTML = notifyIgRowHtml(ITEM_GROUPS[0].code, '');
const row = wrap.firstElementChild;
notifyIgRowsEl.appendChild(row);
row.querySelector('.notifyig-rm').onclick = () => { row.remove(); if(!notifyIgRowsEl.children.length) renderNotifyIgRows(); };
};
function collectNotifyByGroup() {
const out = {};
notifyIgRowsEl.querySelectorAll('.notify-row').forEach(row => {
const k = row.querySelector('.notifyig-group')?.value;
const v = row.querySelector('.notifyig-emails')?.value.trim();
if (k && v) out[k] = v;
});
return out;
}
// ── Short in Stock — Watch-List rows ──
const sstockRowsEl = div.querySelector('#sstock-rows');
function sstockRowHtml(rule) {
const recip = new Set(rule.recipients || []);
return `<div class="sstock-row" data-id="${esc(rule.id)}" style="display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--glass-border);border-radius:9px;background:var(--surface2,#f8fafc)">
<div style="display:flex;gap:8px;align-items:center">
<select class="form-input sstock-type" style="width:110px">
<option value="ITEM" ${rule.type!=='GROUP'?'selected':''}>Item Code</option>
<option value="GROUP" ${rule.type==='GROUP'?'selected':''}>Item Group</option>
</select>
<input class="form-input sstock-item-val" style="flex:1;font-family:var(--mono);${rule.type==='GROUP'?'display:none':''}" placeholder="Item Code" value="${rule.type!=='GROUP'?esc(rule.value||''):''}"/>
<select class="form-select sstock-group-val" style="flex:1;${rule.type==='GROUP'?'':'display:none'}">
<option value="">— Select Item Group —</option>
${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}" ${String(rule.value)===String(g.code)?'selected':''}>${esc(g.name)} (${esc(g.code)})</option>`).join('')}
</select>
<button type="button" class="chk-mini sstock-rm" title="Remove">✕</button>
</div>
<div>
<div style="font-size:10px;color:var(--text3,#94a3b8);text-transform:uppercase;letter-spacing:.3px;margin-bottom:4px">Concerned User(s)</div>
<div class="sstock-users" style="display:flex;flex-wrap:wrap;gap:4px 12px;max-height:110px;overflow-y:auto;border:1px solid var(--glass-border);border-radius:7px;padding:6px 8px">
${_users.map(u=>`<label style="display:flex;align-items:center;gap:5px;font-size:11.5px;cursor:pointer"><input type="checkbox" class="sstock-user-cb" value="${esc(u.username)}" ${recip.has(u.username)?'checked':''}/> ${esc(u.fullName||u.username)}</label>`).join('')}
</div>
</div>
</div>`;
}
function renderSstockRows() {
sstockRowsEl.innerHTML = shortStockRules.length
? shortStockRules.map(sstockRowHtml).join('')
: '<div style="color:var(--text3);font-size:12px">No items/groups watched yet.</div>';
wireSstockRows();
}
function wireSstockRows() {
sstockRowsEl.querySelectorAll('.sstock-row').forEach(row => {
const typeSel = row.querySelector('.sstock-type');
const itemInp = row.querySelector('.sstock-item-val');
const groupSel = row.querySelector('.sstock-group-val');
typeSel.onchange = () => {
const isGroup = typeSel.value === 'GROUP';
itemInp.style.display = isGroup ? 'none' : '';
groupSel.style.display = isGroup ? '' : 'none';
};
row.querySelector('.sstock-rm').onclick = () => { row.remove(); if (!sstockRowsEl.children.length) renderSstockRows(); };
});
}
renderSstockRows();
const sstockAddBtn = div.querySelector('#sstock-add-row');
if (sstockAddBtn) sstockAddBtn.onclick = () => {
if (sstockRowsEl.querySelector('div[style*="color:var(--text3)"]')) sstockRowsEl.innerHTML = '';
const wrap = document.createElement('div');
wrap.innerHTML = sstockRowHtml({ id: 'r' + Date.now(), type: 'ITEM', value: '', recipients: [] });
const row = wrap.firstElementChild;
sstockRowsEl.appendChild(row);
wireSstockRows();
};
function collectSstockRules() {
const out = [];
sstockRowsEl.querySelectorAll('.sstock-row').forEach(row => {
const type = row.querySelector('.sstock-type').value;
const value = type === 'GROUP' ? row.querySelector('.sstock-group-val').value : row.querySelector('.sstock-item-val').value.trim().toUpperCase();
const recipients = Array.from(row.querySelectorAll('.sstock-user-cb:checked')).map(cb => cb.value);
if (value && recipients.length) out.push({ id: row.dataset.id, type, value, recipients });
});
return out;
}
// ── Confirm email delivery (SMTP test) ── // ── Confirm email delivery (SMTP test) ──
const testResult = div.querySelector('#notify-test-result'); const testResult = div.querySelector('#notify-test-result');
const showTestResult = (ok, msg) => { testResult.style.color = ok ? 'var(--green,#0aa457)' : 'var(--red,#c81e1e)'; testResult.textContent = msg; }; const showTestResult = (ok, msg) => { testResult.style.color = ok ? 'var(--green,#0aa457)' : 'var(--red,#c81e1e)'; testResult.textContent = msg; };
@@ -1936,6 +2147,27 @@ function buildSettingsTab() {
saveBtn.disabled = false; saveBtn.disabled = false;
} }
coSel.onchange = loadWtt; coSel.onchange = loadWtt;
// ── Inventory Transfer Series dropdown (lives in the same company
// context as the warehouse mapping above — SAP's NNM1 series table is
// per-company) ──
(async () => {
const selEl = div.querySelector('#set-inventoryTransferSeries');
if (!selEl) return;
const current = selEl.dataset.current || '';
try {
const r = await api('GET', `/sap/lookup/series?objectCode=67&company=${encodeURIComponent(window.__DEFAULT_COMPANY__ || '')}`);
const list = (r.data || []).filter(x => !x.locked);
const opts = ['<option value="">— SAP default series —</option>']
.concat(list.map(x => `<option value="${x.series}" ${String(x.series) === current ? 'selected' : ''}>${esc(x.seriesName)} (${x.series})</option>`));
// Keep a currently-saved value visible even if it's locked/missing from the live list.
if (current && !list.some(x => String(x.series) === current)) {
opts.push(`<option value="${esc(current)}" selected>#${esc(current)} (not found in SAP — keeping saved value)</option>`);
}
selEl.innerHTML = opts.join('');
} catch (_e) {
selEl.innerHTML = `<option value="${esc(current)}" selected>#${esc(current) || '(none)'} — could not load series names</option>`;
}
})();
saveBtn.onclick = async () => { saveBtn.onclick = async () => {
const map = {}; const map = {};
div.querySelectorAll('.wtt-sel').forEach(s => { map[s.dataset.wh] = s.value; }); div.querySelectorAll('.wtt-sel').forEach(s => { map[s.dataset.wh] = s.value; });
@@ -2039,13 +2271,20 @@ function buildSettingsTab() {
deviationDamageSeries: div.querySelector('#set-deviationDamageSeries').value.trim(), deviationDamageSeries: div.querySelector('#set-deviationDamageSeries').value.trim(),
}), p => !p.deviationEnabledTypes ? 'Select at least one Deviation type' : null); }), p => !p.deviationEnabledTypes ? 'Select at least one Deviation type' : null);
wireSave('set-save-invtransfer', () => ({
inventoryTransferSeries: div.querySelector('#set-inventoryTransferSeries').value.trim(),
}));
wireSave('set-save-itemcode', () => { wireSave('set-save-itemcode', () => {
const floors = {}; const floors = {};
div.querySelectorAll('.set-itemcodefloor').forEach(inp => { div.querySelectorAll('.set-itemcodefloor').forEach(inp => {
const n = parseInt(inp.value); const n = parseInt(inp.value);
if (!isNaN(n) && n > 0) floors[inp.dataset.prefix] = n; if (!isNaN(n) && n > 0) floors[inp.dataset.prefix] = n;
}); });
return { itemCodeSeriesFloor: JSON.stringify(floors) }; return {
itemApprovalEnabled: div.querySelector('#set-itemApprovalEnabled').value,
itemCodeSeriesFloor: JSON.stringify(floors),
};
}); });
wireSave('set-save-prodorder', () => ({ wireSave('set-save-prodorder', () => ({
@@ -2090,8 +2329,33 @@ function buildSettingsTab() {
wireSave('set-save-notify', () => ({ wireSave('set-save-notify', () => ({
notifyEmailsEnabled: div.querySelector('#set-notifyEmailsEnabled').value, notifyEmailsEnabled: div.querySelector('#set-notifyEmailsEnabled').value,
notifyExtraEmails: JSON.stringify(collectNotifyExtra()), notifyExtraEmails: JSON.stringify(collectNotifyExtra()),
notifyItemGroupEmails: JSON.stringify(collectNotifyByGroup()),
notifyDisabledSteps: NOTIFY_TARGET_OPTS.map(o=>o.v).filter(v=>!div.querySelector(`.set-notifystep[value="${CSS.escape(v)}"]`)?.checked).join(','),
})); }));
wireSave('set-save-shortstock', () => ({
shortStockAlertEnabled: div.querySelector('#set-shortStockAlertEnabled').value,
shortStockAlertRules: JSON.stringify(collectSstockRules()),
}));
wireSave('set-save-downtime', () => {
const causes = (div.querySelector('#set-dtCauses')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
const tabFieldMap = {};
div.querySelectorAll('.dt-map-sel').forEach(sel => {
const tab = sel.dataset.tab, field = sel.dataset.field, cause = sel.value;
if (!cause) return;
(tabFieldMap[tab] = tabFieldMap[tab] || {})[field] = cause;
});
return {
downtimeAnalysisConfig: JSON.stringify({
periodFrom: div.querySelector('#set-dtPeriodFrom')?.value || '',
periodTo: div.querySelector('#set-dtPeriodTo')?.value || '',
baseDays: parseInt(div.querySelector('#set-dtBaseDays')?.value) || 330,
causes, tabFieldMap,
}),
};
});
// Settings search — filters every card's .set-row by its visible text // Settings search — filters every card's .set-row by its visible text
// (label + description), not just the card title. A card with any // (label + description), not just the card title. A card with any
// matching row auto-expands and stays visible; a card with zero matches // matching row auto-expands and stays visible; a card with zero matches
@@ -2392,7 +2656,7 @@ function openUserModal(user, cloneFrom) {
<!-- Work Order sign override — off by default, a narrow explicit grant --> <!-- Work Order sign override — off by default, a narrow explicit grant -->
<div class="form-group"> <div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="uc-wo-verify-override" ${user.woVerifyOverride?'checked':''}/> <input type="checkbox" id="uc-wo-verify-override" ${user&&user.woVerifyOverride?'checked':''}/>
<span style="font-size:12px;font-weight:600">Verify Work Order: can sign any step as any user &amp; backdate</span> <span style="font-size:12px;font-weight:600">Verify Work Order: can sign any step as any user &amp; backdate</span>
</label> </label>
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user's Issued/Received/Verified stamps on a Work Order row (on any step they're separately assigned "approve" on) let them pick who it's signed as (any user) and set the sign date to a backdate — including RE-signing a row that's already been stamped, to correct it. Everyone else can only sign as themselves at the current time, once.</div> <div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user's Issued/Received/Verified stamps on a Work Order row (on any step they're separately assigned "approve" on) let them pick who it's signed as (any user) and set the sign date to a backdate — including RE-signing a row that's already been stamped, to correct it. Everyone else can only sign as themselves at the current time, once.</div>
@@ -2401,7 +2665,7 @@ function openUserModal(user, cloneFrom) {
<!-- Impersonate — off by default, a narrow explicit grant --> <!-- Impersonate — off by default, a narrow explicit grant -->
<div class="form-group"> <div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="uc-can-impersonate" ${user.canImpersonate?'checked':''}/> <input type="checkbox" id="uc-can-impersonate" ${user&&user.canImpersonate?'checked':''}/>
<span style="font-size:12px;font-weight:600">Can impersonate other users</span> <span style="font-size:12px;font-weight:600">Can impersonate other users</span>
</label> </label>
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user can pick another user and act on their behalf — seeing exactly what that user sees, with that user's permissions — until they return to their own account. Cannot be used to impersonate an admin unless this user is themselves an admin. Every action taken while impersonating is recorded in the Audit Trail against both identities.</div> <div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user can pick another user and act on their behalf — seeing exactly what that user sees, with that user's permissions — until they return to their own account. Cannot be used to impersonate an admin unless this user is themselves an admin. Every action taken while impersonating is recorded in the Audit Trail against both identities.</div>
@@ -2888,6 +3152,6 @@ function confirmDelete(user) {
// ════════════════════════════════════════ // ════════════════════════════════════════
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+19 -14
View File
@@ -463,6 +463,11 @@ function esc(v) {
} }
const isAdmin = () => _user?.role === 'admin'; const isAdmin = () => _user?.role === 'admin';
// Item Approvals specifically also lets sap_adder and system_admin manage/
// approve (matches routes/itemApproval.js's own privileged-role check
// server-side) — unlike BOM/Cust/Vendor approvals above, which route through
// a configurable per-role approval map instead of a fixed admin-only gate.
const canManageItems = () => isAdmin() || _user?.role === 'sap_adder' || _user?.role === 'system_admin';
function coLabel(company){const m={[window.__DEFAULT_COMPANY__]:'Oil'};return m[company]||'';} function coLabel(company){const m={[window.__DEFAULT_COMPANY__]:'Oil'};return m[company]||'';}
function coBadge(company){const l=coLabel(company);return l&&_activeCompany==='ALL'?`<span style="font-size:9px;font-weight:700;padding:1px 6px;border-radius:4px;background:rgba(159,90,253,.1);color:#9f5afd;border:1px solid rgba(159,90,253,.25)">${l}</span>`:'';} function coBadge(company){const l=coLabel(company);return l&&_activeCompany==='ALL'?`<span style="font-size:9px;font-weight:700;padding:1px 6px;border-radius:4px;background:rgba(159,90,253,.1);color:#9f5afd;border:1px solid rgba(159,90,253,.25)">${l}</span>`:'';}
@@ -597,7 +602,7 @@ async function loadItemApprovals() {
} }
function countMyTurnItem() { function countMyTurnItem() {
if (isAdmin()) return _itemApprovals.filter(r => r.status === 'PENDING').length; if (canManageItems()) return _itemApprovals.filter(r => r.status === 'PENDING').length;
return 0; // only admin sees item approvals return 0; // only admin sees item approvals
} }
@@ -1430,11 +1435,11 @@ function renderItemSection(container) {
<div class="stat-pill"><span class="stat-n red">${rejected}</span><span style="font-size:11px;color:var(--text3)">Rejected</span></div>`; <div class="stat-pill"><span class="stat-n red">${rejected}</span><span style="font-size:11px;color:var(--text3)">Rejected</span></div>`;
container.appendChild(sr); container.appendChild(sr);
if (!isAdmin()) { if (!canManageItems()) {
const info = document.createElement('div'); const info = document.createElement('div');
info.className = 'alert al-info'; info.className = 'alert al-info';
info.style.margin = '12px 0'; info.style.margin = '12px 0';
info.textContent = 'ℹ Item approvals are managed by admin only.'; info.textContent = 'ℹ Item approvals are managed by admin/SAP Adder/system admin only.';
container.appendChild(info); container.appendChild(info);
return; return;
} }
@@ -1480,7 +1485,7 @@ function renderItemSection(container) {
function buildItemCard(req) { function buildItemCard(req) {
const card = document.createElement('div'); const card = document.createElement('div');
const isPending = req.status === 'PENDING'; const isPending = req.status === 'PENDING';
card.className = `req-card border-${isPending ? 'ITEM_PENDING' : req.status}${isPending && isAdmin() ? ' my-turn-card-item' : ''}`; card.className = `req-card border-${isPending ? 'ITEM_PENDING' : req.status}${isPending && canManageItems() ? ' my-turn-card-item' : ''}`;
const statusLabel = req.status === 'SAP_PUSHED' ? 'In SAP B1' : req.status === 'REJECTED' ? 'Rejected' : 'Pending Review'; const statusLabel = req.status === 'SAP_PUSHED' ? 'In SAP B1' : req.status === 'REJECTED' ? 'Rejected' : 'Pending Review';
const statusClass = req.status === 'SAP_PUSHED' ? 'sb-SAP_PUSHED' : req.status === 'REJECTED' ? 'sb-REJECTED' : 'sb-ITEM_PENDING'; const statusClass = req.status === 'SAP_PUSHED' ? 'sb-SAP_PUSHED' : req.status === 'REJECTED' ? 'sb-REJECTED' : 'sb-ITEM_PENDING';
@@ -1493,7 +1498,7 @@ function buildItemCard(req) {
<div class="rc-badges"> <div class="rc-badges">
<span style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;background:rgba(159,90,253,.1);color:var(--purple)">📦 ITEM</span> <span style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:4px;background:rgba(159,90,253,.1);color:var(--purple)">📦 ITEM</span>
<span class="status-badge ${statusClass}">${statusLabel}</span> <span class="status-badge ${statusClass}">${statusLabel}</span>
${isPending && isAdmin() ? '<span class="admin-chip">⚡ Review Required</span>' : ''} ${isPending && canManageItems() ? '<span class="admin-chip">⚡ Review Required</span>' : ''}
</div> </div>
</div> </div>
</div> </div>
@@ -1515,7 +1520,7 @@ function buildItemCard(req) {
db.onclick = () => openItemDetail(req.id); db.onclick = () => openItemDetail(req.id);
actDiv.appendChild(db); actDiv.appendChild(db);
if (isPending && isAdmin()) { if (isPending && canManageItems()) {
const ab = document.createElement('button'); const ab = document.createElement('button');
ab.className = 'btn b-approve admin-direct'; ab.innerHTML = '⚡ Approve → SAP'; ab.className = 'btn b-approve admin-direct'; ab.innerHTML = '⚡ Approve → SAP';
ab.onclick = e => { e.stopPropagation(); openItemDetail(req.id); }; ab.onclick = e => { e.stopPropagation(); openItemDetail(req.id); };
@@ -1585,7 +1590,7 @@ function buildItemDetailModal(container, req) {
<button class="modal-close" id="imc1">✕</button> <button class="modal-close" id="imc1">✕</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
${isAdmin() && isPending ? `<div class="alert al-gold">⚡ <strong>Admin Review:</strong> Check all details below. Edit any wrong fields, then approve to push to SAP B1.</div>` : ''} ${canManageItems() && isPending ? `<div class="alert al-gold">⚡ <strong>Admin Review:</strong> Check all details below. Edit any wrong fields, then approve to push to SAP B1.</div>` : ''}
${req.status === 'SAP_PUSHED' ? `<div class="alert al-green">✅ Item <strong>${esc(req.sapItemCode || req.itemCode)}</strong> successfully created in SAP B1 by ${esc(req.approvedBy )} on ${req.approvedAt ? new Date(req.approvedAt).toLocaleString('en-IN') : ''}.</div>` : ''} ${req.status === 'SAP_PUSHED' ? `<div class="alert al-green">✅ Item <strong>${esc(req.sapItemCode || req.itemCode)}</strong> successfully created in SAP B1 by ${esc(req.approvedBy )} on ${req.approvedAt ? new Date(req.approvedAt).toLocaleString('en-IN') : ''}.</div>` : ''}
${req.status === 'REJECTED' ? `<div class="alert al-red">✗ Rejected by ${esc(req.rejectedBy )}. ${req.adminNotes ? `Reason: "${esc(req.adminNotes)}"` : ''}</div>` : ''} ${req.status === 'REJECTED' ? `<div class="alert al-red">✗ Rejected by ${esc(req.rejectedBy )}. ${req.adminNotes ? `Reason: "${esc(req.adminNotes)}"` : ''}</div>` : ''}
@@ -1599,7 +1604,7 @@ function buildItemDetailModal(container, req) {
<!-- TAB: ITEM DETAILS (editable if pending+admin) --> <!-- TAB: ITEM DETAILS (editable if pending+admin) -->
<div class="modal-tab-pane active" id="itab-details"> <div class="modal-tab-pane active" id="itab-details">
${isPending && isAdmin() ? ` ${isPending && canManageItems() ? `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
${editField('Item Code', 'ItemCode', d.ItemCode || req.itemCode)} ${editField('Item Code', 'ItemCode', d.ItemCode || req.itemCode)}
${editField('Description *', 'ItemName', d.ItemName || req.itemName)} ${editField('Description *', 'ItemName', d.ItemName || req.itemName)}
@@ -1638,7 +1643,7 @@ function buildItemDetailModal(container, req) {
<!-- TAB: WAREHOUSE & STOCK --> <!-- TAB: WAREHOUSE & STOCK -->
<div class="modal-tab-pane" id="itab-warehouse"> <div class="modal-tab-pane" id="itab-warehouse">
${isPending && isAdmin() ? ` ${isPending && canManageItems() ? `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
${editField('Default Warehouse', 'DefaultWarehouse', d.DefaultWarehouse || '')} ${editField('Default Warehouse', 'DefaultWarehouse', d.DefaultWarehouse || '')}
${editField('GL Method', 'GLMethod', d.GLMethod || 'glm_ItemClass')} ${editField('GL Method', 'GLMethod', d.GLMethod || 'glm_ItemClass')}
@@ -1658,7 +1663,7 @@ function buildItemDetailModal(container, req) {
<!-- TAB: GENERAL (GST/HSN) --> <!-- TAB: GENERAL (GST/HSN) -->
<div class="modal-tab-pane" id="itab-general"> <div class="modal-tab-pane" id="itab-general">
${isPending && isAdmin() ? ` ${isPending && canManageItems() ? `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
${editField('HSN Code', 'HsnCode', d.HsnCode || '')} ${editField('HSN Code', 'HsnCode', d.HsnCode || '')}
${editField('Assessable Value', 'AssessableValue', d.AssessableValue || 0, 'number')} ${editField('Assessable Value', 'AssessableValue', d.AssessableValue || 0, 'number')}
@@ -1681,7 +1686,7 @@ function buildItemDetailModal(container, req) {
<!-- TAB: PURCHASING --> <!-- TAB: PURCHASING -->
<div class="modal-tab-pane" id="itab-purchase"> <div class="modal-tab-pane" id="itab-purchase">
${isPending && isAdmin() ? ` ${isPending && canManageItems() ? `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
${editField('Preferred Vendor', 'DefaultVendor', d.DefaultVendor || d.Mainsupplier || '')} ${editField('Preferred Vendor', 'DefaultVendor', d.DefaultVendor || d.Mainsupplier || '')}
${editField('Lead Time (days)', 'LeadTime', d.LeadTime || '', 'number')} ${editField('Lead Time (days)', 'LeadTime', d.LeadTime || '', 'number')}
@@ -1719,14 +1724,14 @@ function buildItemDetailModal(container, req) {
</div>`).join('')} </div>`).join('')}
</div> </div>
${isPending && isAdmin() ? ` ${isPending && canManageItems() ? `
<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border)"> <div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border)">
<label style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);display:block;margin-bottom:7px">Admin Notes / Comment</label> <label style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);display:block;margin-bottom:7px">Admin Notes / Comment</label>
<textarea class="comment-area" id="item-modal-comment" style="min-height:55px" placeholder="Notes about this item request (optional)…"></textarea> <textarea class="comment-area" id="item-modal-comment" style="min-height:55px" placeholder="Notes about this item request (optional)…"></textarea>
</div>` : ''} </div>` : ''}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
${isPending && isAdmin() ? ` ${isPending && canManageItems() ? `
<button class="btn b-approve admin-direct" id="item-modal-approve"> <button class="btn b-approve admin-direct" id="item-modal-approve">
<span id="item-modal-sp" style="display:none" class="sp"></span> <span id="item-modal-sp" style="display:none" class="sp"></span>
⚡ Approve → Push to SAP B1 ⚡ Approve → Push to SAP B1
@@ -1797,7 +1802,7 @@ function buildItemDetailModal(container, req) {
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -191,6 +191,6 @@ function wire(){
(async function init(){ await loadFacets(); load(); })(); (async function init(){ await loadFacets(); load(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1
View File
@@ -65,6 +65,7 @@
'/project-form':['projects-new','projects-view'], '/project-approvals':'projects-approvals', '/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', '/costing-pl':'finance-monthly', '/costing-comparison':'finance-comparison', '/cost-sheet':'finance-costsheet', '/balance-sheet':'finance-balancesheet', '/cash-flow':'finance-cashflow',
'/salary':'salary', '/salary':'salary',
'/sales-orders':'sales-orders', '/sales-samples':'sales-samples', '/sales-reports':'sales-reports', '/sales-admin':'sales-admin',
}; };
// Backward-compat: a user granted the OLD broad key (e.g. 'production', // 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 // before it was split into per-page sub-modules) still gets every new
+1 -1
View File
@@ -95,7 +95,7 @@ body{font-family:'Space Grotesk',sans-serif;background:var(--bg,#f8fafc);color:v
</style> </style>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="page"> <div class="page">
<div class="hdr"> <div class="hdr">
<div> <div>
+108 -18
View File
@@ -192,8 +192,16 @@ let _editId=null;
let _saving=false, _created=null; let _saving=false, _created=null;
let _list=[], _listLoading=false, _expanded=null; let _list=[], _listLoading=false, _expanded=null;
let _linkedWo={}; // { [intimationId]: [{id,woNo,productCode,batchNumber}] } — Delete stays fully blocked once ANY Work Order exists; Edit now locks only the SPECIFIC (productCode,batchNumber) rows that already have one — see startEdit() let _linkedWo={}; // { [intimationId]: [{id,woNo,productCode,batchNumber}] } — Delete stays fully blocked once ANY Work Order exists; Edit now locks only the SPECIFIC (productCode,batchNumber) rows that already have one — see startEdit()
let _linkedWoAll={}; // same shape, but REJECTED Work Orders included — used only to keep an
// already-referenced row's Batch No. (and the row itself) from being
// changed/removed even while the rest of that row stays editable for a
// rejected-and-resubmitting Work Order — see _editLockedSet/_batchNoLockedSet
let _batchNoLockedSet=new Set(); // `${itemCode}|${batchNo}` keys whose Batch No. field (and row-remove button) must stay locked this edit session, even for rows the fuller _editLockedSet leaves otherwise editable
let _editLockedSet=new Set(); // `${itemCode}|${batchNo}` keys locked for the CURRENTLY open edit session — those rows are read-only (already used by a generated Work Order); everything else in the document stays freely editable let _editLockedSet=new Set(); // `${itemCode}|${batchNo}` keys locked for the CURRENTLY open edit session — those rows are read-only (already used by a generated Work Order); everything else in the document stays freely editable
let _viewSearch='', _viewStatus=''; let _viewSearch='', _viewStatus='';
let _viewDateFrom='', _viewDateTo=''; // filters by the Intimation's own Date field
let _viewPage=0; const VF_PAGE_SIZE=20;
let _sapBatchMatches=[], _sapBatchQuery='', _sapBatchTimer=null; // cross-reference results from /sap/lookup/batch-in-sap — see scheduleSapBatchLookup()
let _batchManagedItems=new Set(); // item codes SAP itself tracks by batch (OITM.ManBtchNum='Y') — Batch No./MFG/EXP become required per item automatically let _batchManagedItems=new Set(); // item codes SAP itself tracks by batch (OITM.ManBtchNum='Y') — Batch No./MFG/EXP become required per item automatically
let _biAllowExceedPending=false; // Admin → System Settings — when true, Total Units may exceed the requirement's pending qty let _biAllowExceedPending=false; // Admin → System Settings — when true, Total Units may exceed the requirement's pending qty
// SFG (Semi-Finished Good) workflow — Admin → System Settings → Batch // SFG (Semi-Finished Good) workflow — Admin → System Settings → Batch
@@ -689,15 +697,21 @@ function renderCreate(){
// editable, and new ones can always be added. // editable, and new ones can always be added.
const rowLocked=_editLockedSet.has(`${p.itemCode}|${b.batchNo}`); const rowLocked=_editLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const dis=rowLocked?'disabled':''; const dis=rowLocked?'disabled':'';
// Batch No. itself (and this row's remove button) stays locked even for
// a row a rejected Work Order references — otherwise fixing the other
// fields could also let the Batch No. drift, silently breaking that
// rejected order's (productCode,batchNumber) link back to this row.
const noLocked=rowLocked||_batchNoLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const noDis=noLocked?'disabled':'';
return ` return `
<tr${rowLocked?' title="Already used by a generated Work Order — locked"':''}> <tr${rowLocked?' title="Already used by a generated Work Order — locked"':(noLocked?' title="Batch No. is used by a rejected Work Order — number is locked, other fields can still be corrected"':'')}>
<td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="batchNo" placeholder="${req?'Batch No. *':'Batch No.'}" value="${esc(b.batchNo)}" style="${req?'border-color:var(--amber)':''}"/></td> <td><input class="fi" ${noDis} data-p="${pi}" data-b="${bi}" data-f="batchNo" placeholder="${req?'Batch No. *':'Batch No.'}" value="${esc(b.batchNo)}" style="${req?'border-color:var(--amber)':''}"/></td>
<td>${dateCell(pi,bi,'mfgDate',b.mfgDate,'req',rowDateFmt(pi,bi,'req',b),rowLocked)}</td> <td>${dateCell(pi,bi,'mfgDate',b.mfgDate,'req',rowDateFmt(pi,bi,'req',b),rowLocked)}</td>
<td>${dateCell(pi,bi,'expDate',b.expDate,'req',rowDateFmt(pi,bi,'req',b),rowLocked)}</td> <td>${dateCell(pi,bi,'expDate',b.expDate,'req',rowDateFmt(pi,bi,'req',b),rowLocked)}</td>
<td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="market" placeholder="Market" value="${esc(b.market)}"/></td> <td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="market" placeholder="Market" value="${esc(b.market)}"/></td>
<td><input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchSize" placeholder="0" value="${esc(b.batchSize)}"/></td> <td><input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchSize" placeholder="0" value="${esc(b.batchSize)}"/></td>
${batchVolumeCell('req',pi,bi,b,dis)} ${batchVolumeCell('req',pi,bi,b,dis)}
<td class="td-act">${rowLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="Locked — Work Order already generated"></i>`:`<button class="b-del" data-rmb="${pi}:${bi}" title="Remove batch"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`}</td> <td class="td-act">${noLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="${rowLocked?'Locked — Work Order already generated':'Batch No. locked — a rejected Work Order references it'}"></i>`:`<button class="b-del" data-rmb="${pi}:${bi}" title="Remove batch"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`}</td>
</tr>`;}).join(''); </tr>`;}).join('');
return ` return `
<div class="prod-block"> <div class="prod-block">
@@ -785,17 +799,23 @@ function renderSfgCreate(){
// editable, and new ones can always be added. // editable, and new ones can always be added.
const rowLocked=_editLockedSet.has(`${p.itemCode}|${b.batchNo}`); const rowLocked=_editLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const dis=rowLocked?'disabled':''; const dis=rowLocked?'disabled':'';
// Batch No. itself (and this row's remove button) stays locked even for
// a row a rejected Work Order references — otherwise fixing the other
// fields could also let the Batch No. drift, silently breaking that
// rejected order's (productCode,batchNumber) link back to this row.
const noLocked=rowLocked||_batchNoLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const noDis=noLocked?'disabled':'';
return ` return `
<tr${rowLocked?' title="Already used by a generated Work Order — locked"':''}> <tr${rowLocked?' title="Already used by a generated Work Order — locked"':(noLocked?' title="Batch No. is used by a rejected Work Order — number is locked, other fields can still be corrected"':'')}>
<td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="batchNo" placeholder="${req?'Batch No. *':'Batch No.'}" value="${esc(b.batchNo)}" style="${req?'border-color:var(--amber)':''}"/></td> <td><input class="fi" ${noDis} data-p="${pi}" data-b="${bi}" data-f="batchNo" placeholder="${req?'Batch No. *':'Batch No.'}" value="${esc(b.batchNo)}" style="${req?'border-color:var(--amber)':''}"/></td>
<td>${dateCell(pi,bi,'mfgDate',b.mfgDate,'sfg',rowDateFmt(pi,bi,'sfg',b),rowLocked)}</td> <td>${dateCell(pi,bi,'mfgDate',b.mfgDate,'sfg',rowDateFmt(pi,bi,'sfg',b),rowLocked)}</td>
<td>${dateCell(pi,bi,'expDate',b.expDate,'sfg',rowDateFmt(pi,bi,'sfg',b),rowLocked)}</td> <td>${dateCell(pi,bi,'expDate',b.expDate,'sfg',rowDateFmt(pi,bi,'sfg',b),rowLocked)}</td>
<td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="market" placeholder="Market" value="${esc(b.market)}"/></td> <td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="market" placeholder="Market" value="${esc(b.market)}"/></td>
<td><input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchSize" placeholder="0" value="${esc(b.batchSize)}"/></td> <td><input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchSize" placeholder="0" value="${esc(b.batchSize)}"/></td>
${batchVolumeCell('sfg',pi,bi,b,dis)} ${batchVolumeCell('sfg',pi,bi,b,dis)}
<td class="td-act">${rowLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="Locked — Work Order already generated"></i>`:`<button class="b-del" data-rmb="${pi}:${bi}" title="Remove batch"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`}</td> <td class="td-act">${noLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="${rowLocked?'Locked — Work Order already generated':'Batch No. locked — a rejected Work Order references it'}"></i>`:`<button class="b-del" data-rmb="${pi}:${bi}" title="Remove batch"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`}</td>
</tr>`;}).join(''); </tr>`;}).join('');
const prodLocked=p.batches.some(b=>_editLockedSet.has(`${p.itemCode}|${b.batchNo}`)); const prodLocked=p.batches.some(b=>_editLockedSet.has(`${p.itemCode}|${b.batchNo}`)||_batchNoLockedSet.has(`${p.itemCode}|${b.batchNo}`));
return ` return `
<div class="prod-block"> <div class="prod-block">
<div class="prod-hd"> <div class="prod-hd">
@@ -840,7 +860,7 @@ function renderSfgCreate(){
// Patches only #body (not a full render()) so an in-progress search/edit in // Patches only #body (not a full render()) so an in-progress search/edit in
// this tab is never disrupted — same principle used throughout this app. // this tab is never disrupted — same principle used throughout this app.
function renderSfgOnly(){const b=q('#body');if(b){b.innerHTML=renderSfgCreate();wireSfgCreate();if(window.lucide)lucide.createIcons();}} function renderSfgOnly(){const b=q('#body');if(b){b.innerHTML=renderSfgCreate();wireSfgCreate();if(window.lucide)lucide.createIcons();}}
function resetSfg(){_sfgProducts=[];_sfgDate=today();_sfgRemarks='';_sfgSearchVal='';_editLockedSet=new Set();} function resetSfg(){_sfgProducts=[];_sfgDate=today();_sfgRemarks='';_sfgSearchVal='';_editLockedSet=new Set();_batchNoLockedSet=new Set();}
async function fetchSfgItems(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{}return[];} async function fetchSfgItems(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{}return[];}
async function loadSfgBatchManagedItems(){ async function loadSfgBatchManagedItems(){
@@ -1030,7 +1050,7 @@ async function submit(){
}catch(e){_saving=false;render();toast(e.message,'err');} }catch(e){_saving=false;render();toast(e.message,'err');}
} }
function newDoc(){_editId=null;_created=null;_reqId='';_refNo='';_products=[];_date=today();_remarks='';_batchManagedItems=new Set();_editLockedSet=new Set();_tab='create';render();loadReqs();} function newDoc(){_editId=null;_created=null;_reqId='';_refNo='';_products=[];_date=today();_remarks='';_batchManagedItems=new Set();_editLockedSet=new Set();_batchNoLockedSet=new Set();_tab='create';render();loadReqs();}
async function startEdit(bi){ async function startEdit(bi){
// Rows whose (itemCode,batchNo) already has a generated Work Order — // Rows whose (itemCode,batchNo) already has a generated Work Order —
@@ -1040,6 +1060,8 @@ async function startEdit(bi){
// added. Same key format captureStatus() already uses. // added. Same key format captureStatus() already uses.
const linked=_linkedWo[String(bi.id)]||[]; const linked=_linkedWo[String(bi.id)]||[];
_editLockedSet=new Set(linked.map(l=>`${l.productCode||''}|${l.batchNumber||''}`)); _editLockedSet=new Set(linked.map(l=>`${l.productCode||''}|${l.batchNumber||''}`));
const linkedAll=_linkedWoAll[String(bi.id)]||[];
_batchNoLockedSet=new Set(linkedAll.map(l=>`${l.productCode||''}|${l.batchNumber||''}`));
// A "Without Intimation" (SFG) record has no refNo/requirement at all by // A "Without Intimation" (SFG) record has no refNo/requirement at all by
// design — editing it must stay on that same no-requirement path, not the // design — editing it must stay on that same no-requirement path, not the
// regular Requirement-based edit form (whose submit() requires a refNo and // regular Requirement-based edit form (whose submit() requires a refNo and
@@ -1109,7 +1131,7 @@ function renderSuccess(app){
async function loadList(){ async function loadList(){
_listLoading=true;render(); _listLoading=true;render();
try{const r=await api('GET',`/batch-intimations?company=${encodeURIComponent(_co)}`);_list=r.data||[];}catch(e){toast(e.message,'err');_list=[];} try{const r=await api('GET',`/batch-intimations?company=${encodeURIComponent(_co)}`);_list=r.data||[];}catch(e){toast(e.message,'err');_list=[];}
try{const r=await api('GET','/batch-intimations/linked-work-orders/all');_linkedWo=r.data||{};}catch(_e){_linkedWo={};} try{const r=await api('GET','/batch-intimations/linked-work-orders/all');_linkedWo=r.data||{};_linkedWoAll=r.allData||{};}catch(_e){_linkedWo={};_linkedWoAll={};}
_listLoading=false;render(); _listLoading=false;render();
} }
function lockedInfo(id){const l=_linkedWo[String(id)];return (l&&l.length)?l:null;} function lockedInfo(id){const l=_linkedWo[String(id)];return (l&&l.length)?l:null;}
@@ -1139,23 +1161,45 @@ function filteredBiList(){
(bi.docNo||'').toUpperCase().includes(s)|| (bi.docNo||'').toUpperCase().includes(s)||
(bi.refNo||'').toUpperCase().includes(s)|| (bi.refNo||'').toUpperCase().includes(s)||
(bi.createdByName||bi.createdBy||'').toUpperCase().includes(s)|| (bi.createdByName||bi.createdBy||'').toUpperCase().includes(s)||
(bi.products||[]).some(p=>(p.itemCode||'').toUpperCase().includes(s)||(p.itemName||'').toUpperCase().includes(s))); (bi.products||[]).some(p=>(p.itemCode||'').toUpperCase().includes(s)||(p.itemName||'').toUpperCase().includes(s)||
(p.batches||[]).some(b=>(b.batchNo||'').toUpperCase().includes(s))));
if(_viewDateFrom||_viewDateTo)list=list.filter(bi=>{
if(!bi.date)return false;
const d=bi.date.slice(0,10);
if(_viewDateFrom&&d<_viewDateFrom)return false;
if(_viewDateTo&&d>_viewDateTo)return false;
return true;
});
return list; return list;
} }
function renderView(){ function renderView(){
if(_listLoading)return `<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>`; if(_listLoading)return `<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>`;
if(!_list.length)return `<div class="card"><div class="empty-msg">No intimations yet. Create one from the “New Intimation” tab.</div></div>`; if(!_list.length)return `<div class="card"><div class="empty-msg">No intimations yet. Create one from the “New Intimation” tab.</div></div>`;
const list=filteredBiList(); const allList=filteredBiList();
const totalPages=Math.max(1,Math.ceil(allList.length/VF_PAGE_SIZE));
if(_viewPage>=totalPages)_viewPage=totalPages-1;
if(_viewPage<0)_viewPage=0;
const list=allList.slice(_viewPage*VF_PAGE_SIZE,(_viewPage+1)*VF_PAGE_SIZE);
const statuses=['SENT TO QA','PARTIALLY CAPTURED','FULLY CAPTURED']; const statuses=['SENT TO QA','PARTIALLY CAPTURED','FULLY CAPTURED'];
const bar=`<div class="vf-bar"> const bar=`<div class="vf-bar">
<div class="vf-search"><i data-lucide="search" style="width:14px;height:14px"></i><input id="vf-search" placeholder="Search doc no, ref no, item code/name…" value="${esc(_viewSearch)}"/></div> <div class="vf-search"><i data-lucide="search" style="width:14px;height:14px"></i><input id="vf-search" placeholder="Search doc no, ref no, item code/name…" value="${esc(_viewSearch)}"/></div>
<input class="fi" id="vf-datefrom" type="date" value="${esc(_viewDateFrom)}" style="width:140px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="vf-dateto" type="date" value="${esc(_viewDateTo)}" style="width:140px"/>
<div class="vf-chips"> <div class="vf-chips">
<button class="vf-chip ${!_viewStatus?'on':''}" data-vfstatus="">All</button> <button class="vf-chip ${!_viewStatus?'on':''}" data-vfstatus="">All</button>
${statuses.map(st=>`<button class="vf-chip ${_viewStatus===st?'on':''}" data-vfstatus="${esc(st)}">${esc(st)}</button>`).join('')} ${statuses.map(st=>`<button class="vf-chip ${_viewStatus===st?'on':''}" data-vfstatus="${esc(st)}">${esc(st)}</button>`).join('')}
</div> </div>
<span class="vf-count">${list.length} of ${_list.length}</span> <span class="vf-count">${allList.length} of ${_list.length}</span>
</div>`; </div>`;
const sapBanner=_sapBatchMatches.length?`<div style="margin-bottom:12px;padding:10px 12px;background:var(--surface2);border:1px solid var(--border,#dfe4ea);border-radius:10px">
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:6px"><i data-lucide="search-check" style="width:12px;height:12px;vertical-align:-2px"></i> Also found in SAP (not one of this portal's own Intimations/Work Orders):</div>
${_sapBatchMatches.map(m=>m.source==='productionOrder'
?`<div style="font-size:12px;padding:3px 0"><a href="/production#order=${m.absEntry}&company=${encodeURIComponent(_co)}" style="color:var(--blue)" target="_blank">Production Order ${esc(String(m.docNum))}</a> — ${esc(m.itemCode)} ${esc(m.itemName||'')} · Batch (SAP Comments) <span style="font-family:var(--mono)">${esc(m.batch)}</span></div>`
:`<div style="font-size:12px;padding:3px 0">SAP Batch Master — ${esc(m.itemCode)} ${esc(m.itemName||'')} · <span style="font-family:var(--mono)">${esc(m.batch)}</span></div>`
).join('')}
</div>`:'';
const grid=list.length const grid=list.length
? `<div class="bi-grid">${list.map(bi=>{ ? `<div class="bi-grid">${list.map(bi=>{
const batchCount=(bi.products||[]).reduce((n,p)=>n+((p.batches||[]).length),0); const batchCount=(bi.products||[]).reduce((n,p)=>n+((p.batches||[]).length),0);
@@ -1177,9 +1221,40 @@ function renderView(){
<span>${esc(bi.createdByName||bi.createdBy||'')}${bi.updatedAt?' · edited':''}</span> <span>${esc(bi.createdByName||bi.createdBy||'')}${bi.updatedAt?' · edited':''}</span>
</div> </div>
</div>`; </div>`;
}).join('')}</div>` }).join('')}</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn b-cancel" id="vf-prev" ${_viewPage===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">Page ${_viewPage+1} of ${totalPages} · ${allList.length} intimation${allList.length===1?'':'s'}</span>
<button class="btn b-cancel" id="vf-next" ${_viewPage>=totalPages-1?'disabled':''} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`
: `<div class="card"><div class="empty-msg">No intimations match your search/filter.</div></div>`; : `<div class="card"><div class="empty-msg">No intimations match your search/filter.</div></div>`;
return bar+grid; return bar+sapBanner+grid;
}
// Looks up the current search term against SAP itself (OBTN batch master +
// Production Order Comments — see /sap/lookup/batch-in-sap) once it's long
// enough to be a real batch number, not just item-code-length noise. Debounced
// so it doesn't fire on every keystroke; only re-renders the SAP banner, not
// the whole list, to avoid stealing focus from the search input mid-type.
function scheduleSapBatchLookup(){
clearTimeout(_sapBatchTimer);
const term=_viewSearch.trim();
if(term.length<4){
if(_sapBatchMatches.length){_sapBatchMatches=[];_sapBatchQuery='';q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}
return;
}
_sapBatchTimer=setTimeout(async()=>{
if(_viewSearch.trim()!==term)return; // stale — a newer keystroke already superseded this
try{
const r=await api('GET',`/sap/lookup/batch-in-sap?batch=${encodeURIComponent(term)}&company=${encodeURIComponent(_co)}`);
if(_viewSearch.trim()!==term)return; // still stale by the time the response lands
_sapBatchMatches=r.data||[];_sapBatchQuery=term;
}catch(_e){_sapBatchMatches=[];}
const pos=q('#vf-search')?.selectionStart;
q('#body').innerHTML=renderView();wireView();
const s2=q('#vf-search');if(s2&&pos!=null){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons();
},450);
} }
function closeBiDetail(){document.getElementById('bi-detail-modal').classList.remove('show');} function closeBiDetail(){document.getElementById('bi-detail-modal').classList.remove('show');}
@@ -1228,8 +1303,15 @@ function openBiDetail(id){
if(window.lucide)lucide.createIcons(); if(window.lucide)lucide.createIcons();
document.getElementById('bi-modal-x').onclick=closeBiDetail; document.getElementById('bi-modal-x').onclick=closeBiDetail;
if(CAN_CREATE_WO&&!fullyCaptured)document.getElementById('bi-modal-wo').onclick=()=>{window.location.href='/work-order?from='+bi.id;}; if(CAN_CREATE_WO&&!fullyCaptured)document.getElementById('bi-modal-wo').onclick=()=>{window.location.href='/work-order?from='+bi.id;};
if(!locked){ // Edit's own enable/disable condition is fullyCaptured (a partial lock
// still opens Edit, just with the locked rows read-only inside it — see
// startEdit()) — this must match, not `locked`, which is also true for a
// PARTIAL lock and was silently leaving Edit's click handler unwired
// while the button still rendered enabled (looked clickable, did nothing).
if(!fullyCaptured){
document.getElementById('bi-modal-edit').onclick=()=>{closeBiDetail();startEdit(bi);}; document.getElementById('bi-modal-edit').onclick=()=>{closeBiDetail();startEdit(bi);};
}
if(!locked){
document.getElementById('bi-modal-del').onclick=()=>{closeBiDetail();deleteDoc(bi.id,bi.docNo);}; document.getElementById('bi-modal-del').onclick=()=>{closeBiDetail();deleteDoc(bi.id,bi.docNo);};
} }
} }
@@ -1240,15 +1322,23 @@ function wireView(){
if(s){ if(s){
s.oninput=function(){ s.oninput=function(){
_viewSearch=this.value; _viewSearch=this.value;
_viewPage=0;
const pos=this.selectionStart; const pos=this.selectionStart;
q('#body').innerHTML=renderView(); q('#body').innerHTML=renderView();
wireView(); wireView();
const s2=q('#vf-search'); const s2=q('#vf-search');
if(s2){s2.focus();s2.setSelectionRange(pos,pos);} if(s2){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons(); if(window.lucide)lucide.createIcons();
scheduleSapBatchLookup();
}; };
} }
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}); document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();});
const df=q('#vf-datefrom'),dt=q('#vf-dateto');
if(df)df.onchange=function(){_viewDateFrom=this.value;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(dt)dt.onchange=function(){_viewDateTo=this.value;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
const vp=q('#vf-prev'),vn=q('#vf-next');
if(vp)vp.onclick=function(){_viewPage--;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(vn)vn.onclick=function(){_viewPage++;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
} }
// ─────────── INIT ─────────── // ─────────── INIT ───────────
@@ -1291,6 +1381,6 @@ api('GET','/config').then(cfg=>{
} }
}).catch(()=>{}); }).catch(()=>{});
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+519
View File
@@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Batch No. Transaction 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:1700px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(13,187,138,.14);color:var(--green);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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(--green)}
.fi-mono{font-family:var(--mono)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:130px}
.range-pair{display:flex;align-items:flex-end;gap:6px}
.range-pair span{font-size:11px;color:var(--text3);padding-bottom:8px}
.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(--green);color:#06281f}
.b-primary:disabled{opacity:.5;cursor:not-allowed}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.chk-row{display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer;padding:6px 0}
.wh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 14px;max-height:190px;overflow-y:auto;border:1px solid var(--border2);border-radius:8px;padding:8px 10px;background:var(--surface2)}
.wh-toolbar{display:flex;gap:10px;margin-bottom:6px;font-size:10.5px}
.wh-toolbar a{color:var(--green);cursor:pointer;text-decoration:none}
.search-wrap{position:relative}
.search-dd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1px solid var(--green);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: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(13,187,138,.1)}
.sd-code{font-family:var(--mono);font-size:11px;color:var(--green);font-weight:600;min-width:90px}
.sd-name{font-size:11px;color:var(--text2)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
.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 th.sortable{cursor:pointer;user-select:none}
.lst th.sortable:hover{color:var(--green)}
.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 td.neg{color:var(--red)!important}
.lst tbody tr:hover td{background:rgba(13,187,138,.05)}
.lst tbody tr.sel td{background:rgba(26,111,255,.16)!important}
.lst tbody tr.batch-row{cursor:pointer}
.lst tfoot td{background:var(--surface3);font-weight:700;border-top:2px solid var(--border2)}
.dir-badge{display:inline-block;padding:2px 9px;border-radius:5px;font-size:10px;font-weight:700;letter-spacing:.3px}
.dir-in{background:rgba(13,187,138,.18);color:var(--green)}
.dir-out{background:rgba(229,62,62,.18);color:#fca5a5}
.mono{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(--green);background:rgba(13,187,138,.1);padding:3px 11px;border-radius:20px}
.sp{width:14px;height:14px;border:2px solid rgba(13,187,138,.25);border-top-color:var(--green);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}
.t-ok{background:var(--green);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:46vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
.note{font-size:11px;color:var(--text3);margin-top:8px;line-height:1.5}
</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,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function num3(v){if(v==null)return'';const n=Number(v);if(isNaN(n))return'';return n.toLocaleString('en-IN',{maximumFractionDigits:6});}
function fmtDate(v){if(!v)return'';const d=new Date(v);if(isNaN(d))return'';return d.toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'});}
async function api(p,method,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method:method||'GET',headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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=[];
let _wh=[];
let _selectedWh=new Set(); // empty = every warehouse
const _f={itemFrom:'',itemTo:'',batchNo:'',itemGroup:'',includeZeroQty:false,allTxns:false};
let _loading=false,_ran=false,_rows=[],_search='';
let _page=0;
const PAGE_SIZE=20;
// Selected batch (drives the "Transactions for Batch" panel below)
let _selItem=null,_selBatch=null;
let _txnLoading=false,_txns=[];
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('/sap/lookup/item-groups?company='+encodeURIComponent(_co));_itemGroups=r.data||[];}
catch(_e){_itemGroups=[];}
}
async function loadWarehouses(){
try{const r=await api('/sap/lookup/warehouses?company='+encodeURIComponent(_co));_wh=r.data||[];}
catch(_e){_wh=[];}
}
async function fetchItems(v){
try{const r=await api(`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch(_e){}
return [];
}
let _codeSearchTimer=null;
function wireCodeSearch(inputId,ddId,field,fetchFn,codeKey,nameKey){
const inp=q('#'+inputId),dd=q('#'+ddId);if(!inp||!dd)return;
inp.oninput=function(){
_f[field]=this.value.toUpperCase();
const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_codeSearchTimer);
_codeSearchTimer=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp"></span></div>';dd.style.display='block';
const items=await fetchFn(v);
if(_f[field]!==this.value.toUpperCase())return;
if(!items.length){dd.innerHTML='<div class="sd-msg">No results</div>';return;}
dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it[codeKey])}</span><span class="sd-name">${esc(it[nameKey])}</span>`;
row.onclick=()=>{_f[field]=it[codeKey];inp.value=it[codeKey];dd.style.display='none';};
dd.appendChild(row);
});
},250);
};
}
document.addEventListener('click',e=>{
if(!e.target.closest('.search-wrap'))document.querySelectorAll('.search-dd').forEach(d=>d.style.display='none');
});
async function runReport(){
_loading=true;_ran=true;_page=0;_selItem=null;_selBatch=null;_txns=[];render();
const qs=new URLSearchParams();
qs.set('company',_co);
if(_f.itemFrom)qs.set('itemFrom',_f.itemFrom);
if(_f.itemTo)qs.set('itemTo',_f.itemTo);
if(_f.batchNo)qs.set('batchNo',_f.batchNo);
if(_f.itemGroup)qs.set('itemGroup',_f.itemGroup);
qs.set('includeZeroQty',_f.includeZeroQty?'1':'0');
if(_selectedWh.size)qs.set('warehouses',[..._selectedWh].join(','));
try{
const r=await api('/batch-no-transaction?'+qs.toString());
_rows=r.data||[];
}catch(e){toast(e.message);_rows=[];}
_loading=false;render();
}
let _sortKey='',_sortDir=1;
const SORT_COLS={itemCode:'string',itemName:'string',batchNo:'string',warehouse:'string',inStock:'num',mfgDate:'date',expDate:'date',project:'string',status:'string',coaStatus:'string',saleType:'string',uom:'string'};
function filteredRows(){
let list=_rows;
if(_search.trim()){
const s=_search.trim().toUpperCase();
list=list.filter(r=>(r.itemCode||'').toUpperCase().includes(s)||(r.itemName||'').toUpperCase().includes(s)||(r.batchNo||'').toUpperCase().includes(s));
}
if(_sortKey){
const type=SORT_COLS[_sortKey];
list=[...list].sort((a,b)=>{
const av=a[_sortKey],bv=b[_sortKey];
let cmp;
if(type==='num')cmp=(Number(av)||0)-(Number(bv)||0);
else if(type==='date')cmp=new Date(av||0)-new Date(bv||0);
else cmp=String(av||'').localeCompare(String(bv||''));
return cmp*_sortDir;
});
}
return list;
}
function sortBy(key){
if(_sortKey===key)_sortDir=-_sortDir;else{_sortKey=key;_sortDir=1;}
_page=0;render();
}
function sortArrow(key){ return _sortKey===key?(_sortDir===1?' ▲':' ▼'):''; }
function whToolbar(){
return `<div class="wh-toolbar"><a id="wh-all">Select all</a><a id="wh-none">Clear all</a></div>`;
}
function whGrid(){
return `<div class="wh-grid">${_wh.map(w=>`
<label class="chk-row"><input type="checkbox" class="wh-chk" value="${esc(w.code)}" ${_selectedWh.has(w.code)?'checked':''}/> <span class="mono">${esc(w.code)}</span> — ${esc(w.name||'')}</label>
`).join('')}</div>`;
}
// ── Transactions-for-Batch panel ──
async function selectBatch(itemCode,batchNo){
_selItem=itemCode;_selBatch=batchNo;_txnLoading=true;_txns=[];render();
try{
const r=await api(`/batch-no-transaction/transactions?itemCode=${encodeURIComponent(itemCode)}&batchNo=${encodeURIComponent(batchNo)}&company=${encodeURIComponent(_co)}`);
_txns=r.data||[];
}catch(e){toast(e.message);_txns=[];}
_txnLoading=false;render();
}
// "Display All Transactions for Selected Batches" — loads transactions for
// every batch currently in the (filtered) list, capped to avoid hammering
// the server with an unbounded fan-out when the filters are too broad.
async function loadAllTxns(){
const list=filteredRows();
const pairs=[...new Map(list.map(r=>[r.itemCode+'||'+r.batchNo,r])).values()];
if(pairs.length>50){toast('Too many batches ('+pairs.length+') to load all transactions at once — narrow your filters first (max 50)','err');_f.allTxns=false;render();return;}
_selItem=null;_selBatch=null;_txnLoading=true;_txns=[];render();
try{
const results=await Promise.all(pairs.map(r=>
api(`/batch-no-transaction/transactions?itemCode=${encodeURIComponent(r.itemCode)}&batchNo=${encodeURIComponent(r.batchNo)}&company=${encodeURIComponent(_co)}`).then(x=>x.data||[]).catch(()=>[])
));
_txns=results.flat().sort((a,b)=>new Date(a.date||0)-new Date(b.date||0));
}catch(e){toast(e.message);_txns=[];}
_txnLoading=false;render();
}
function txnPanelHtml(){
const title=_f.allTxns?'Transactions for All Listed Batches':(_selBatch?`Transactions for Batch: ${esc(_selBatch)} (${esc(_selItem)})`:'Transactions for Batch');
let body;
if(_txnLoading)body=`<div class="empty-msg"><span class="sp"></span> Loading transactions…</div>`;
else if(!_f.allTxns&&!_selBatch)body=`<div class="empty-msg">Click a batch row above to see its transaction history.</div>`;
else if(!_txns.length)body=`<div class="empty-msg">No transactions found for this batch.</div>`;
else{
const totalQty=_txns.reduce((s,t)=>s+(Number(t.qty)||0),0);
body=`<div class="tbl-wrap"><table class="lst"><thead><tr>
<th class="num">#</th><th>Document</th><th class="num">Doc Row</th><th>Date</th>
<th>Whse</th><th>Whse Name</th><th>G/L Acct/BP Name</th>
<th class="num">Qty</th><th>Direction</th><th>Item No.</th><th>Batch</th>
</tr></thead><tbody>${_txns.map(t=>`
<tr>
<td class="num">${t.no}</td>
<td class="mono">${esc(t.docTypeLabel)} ${esc(t.docNum)}</td>
<td class="num">${t.docRow}</td>
<td>${fmtDate(t.date)}</td>
<td class="mono">${esc(t.warehouse)}</td>
<td>${esc(t.warehouseName)}</td>
<td>${esc(t.bpName)}</td>
<td class="num${t.qty<0?' neg':''}">${num3(t.qty)}</td>
<td><span class="dir-badge ${t.direction==='In'?'dir-in':'dir-out'}">${t.direction}</span></td>
<td class="mono">${esc(t.itemCode)}</td>
<td class="mono">${esc(t.batchNo)}</td>
</tr>`).join('')}</tbody>
<tfoot><tr><td colspan="7">TOTAL</td><td class="num${totalQty<0?' neg':''}">${num3(totalQty)}</td><td colspan="3"></td></tr></tfoot>
</table></div>`;
}
return `<div class="card">
<div class="card-hd"><span>${title}</span>${_txns.length?`<span class="right">${_txns.length} transaction(s)</span>`:''}</div>
<div class="card-bd">
<label class="chk-row"><input type="checkbox" id="f-alltxns" ${_f.allTxns?'checked':''}/> Display All Transactions for Selected (listed) Batches</label>
${body}
</div>
</div>`;
}
function render(){
const app=q('#app');
const list=filteredRows();
const totalPages=Math.max(1,Math.ceil(list.length/PAGE_SIZE));
if(_page>=totalPages)_page=totalPages-1;
if(_page<0)_page=0;
const pageList=list.slice(_page*PAGE_SIZE,(_page+1)*PAGE_SIZE);
const grandTotalQty=list.reduce((s,r)=>s+(Number(r.inStock)||0),0);
app.innerHTML=`
<div class="page-head">
<div class="page-icon">🧪</div>
<div><div class="page-title">Batch No. Transaction Report</div><div class="page-sub">Same layout as SAP B1's own Batch Number Transactions Report — batch stock on top, per-batch transaction history below</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Selection Criteria</span></div>
<div class="card-bd">
<div class="filters" style="margin-bottom:10px">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div><label class="fl">Item Code</label><div class="range-pair">
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-itemfrom" placeholder="Search item code / name…" value="${esc(_f.itemFrom)}" autocomplete="off"/><div class="search-dd" id="dd-itemfrom"></div></div>
<span>to</span>
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-itemto" placeholder="Search item code / name…" value="${esc(_f.itemTo)}" autocomplete="off"/><div class="search-dd" id="dd-itemto"></div></div>
</div></div>
<div><label class="fl">Batch No.</label><input class="fi fi-mono" id="f-batchno" placeholder="Contains…" value="${esc(_f.batchNo)}"/></div>
<div><label class="fl">Item Group</label><select class="fi" id="f-itemgroup">${grpOptions(_f.itemGroup)}</select></div>
<div><button class="btn b-primary" id="btn-run" ${_loading?'disabled':''}>${_loading?'Running…':'Run Report'}</button></div>
</div>
<label class="chk-row"><input type="checkbox" id="f-zeroqty" ${_f.includeZeroQty?'checked':''}/> Display Batches with Zero Qty</label>
<div style="margin-top:8px">
<label class="fl">Warehouses <span style="font-weight:400;text-transform:none;color:var(--text3)">(none selected = all warehouses)</span></label>
${whToolbar()}
${whGrid()}
</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Batches</span><span class="right">${_ran&&!_loading?`<span class="count">${list.length} row(s)</span>`:''}</span></div>
<div class="card-bd">
<div style="margin-bottom:10px;display:flex;gap:8px;align-items:center">
<input class="fi" id="f-search" placeholder="Item No. / Description / Batch No…" value="${esc(_search)}" style="max-width:340px"/>
${_ran&&list.length?`<button class="btn b-cancel" id="btn-export" style="margin-left:auto">⬇ Export Excel</button>`:''}
</div>
${_loading?`<div class="empty-msg"><span class="sp"></span> Running report…</div>`
:!_ran?`<div class="empty-msg">Set your filters above and click "Run Report".</div>`
:!list.length?`<div class="empty-msg">No batches found for these filters.</div>`
:`<div class="tbl-wrap"><table class="lst"><thead><tr>
<th class="num">#</th>
<th class="sortable" data-sort="itemCode">Item No.${sortArrow('itemCode')}</th>
<th class="sortable" data-sort="itemName">Item Description${sortArrow('itemName')}</th>
<th class="sortable" data-sort="batchNo">Batch${sortArrow('batchNo')}</th>
<th class="sortable" data-sort="warehouse">Whse${sortArrow('warehouse')}</th>
<th class="num sortable" data-sort="inStock">Quantity${sortArrow('inStock')}</th>
<th class="sortable" data-sort="expDate">Expiration Date${sortArrow('expDate')}</th>
<th class="sortable" data-sort="mfgDate">Manufacturing Date${sortArrow('mfgDate')}</th>
<th class="sortable" data-sort="project">Project${sortArrow('project')}</th>
<th class="sortable" data-sort="status">Status${sortArrow('status')}</th>
<th class="sortable" data-sort="coaStatus">COA Status${sortArrow('coaStatus')}</th>
<th class="sortable" data-sort="saleType">Sale Type${sortArrow('saleType')}</th>
<th class="num">Committed</th><th class="num">Ordered</th><th class="num">Available</th>
<th class="sortable" data-sort="uom">UoM${sortArrow('uom')}</th>
</tr></thead><tbody>${pageList.map((r,i)=>{
const isSel=!_f.allTxns&&_selItem===r.itemCode&&_selBatch===r.batchNo;
return `<tr class="batch-row${isSel?' sel':''}" data-item="${esc(r.itemCode)}" data-batch="${esc(r.batchNo)}">
<td class="num">${_page*PAGE_SIZE+i+1}</td>
<td class="mono">${esc(r.itemCode)}</td>
<td>${esc(r.itemName)}</td>
<td class="mono">${esc(r.batchNo)}</td>
<td class="mono">${esc(r.warehouse)}</td>
<td class="num">${num3(r.inStock)}</td>
<td>${fmtDate(r.expDate)}</td>
<td>${fmtDate(r.mfgDate)}</td>
<td>${esc(r.project)}</td>
<td>${esc(r.status)}</td>
<td>${esc(r.coaStatus)}</td>
<td>${esc(r.saleType)}</td>
<td class="num">${num3(r.committed)}</td>
<td class="num">${num3(r.ordered)}</td>
<td class="num${r.available<0?' neg':''}">${num3(r.available)}</td>
<td>${esc(r.uom)}</td>
</tr>`;}).join('')}</tbody>
<tfoot><tr><td colspan="5">TOTAL</td><td class="num">${num3(grandTotalQty)}</td><td colspan="10"></td></tr></tfoot>
</table></div>
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;justify-content:flex-end">
<span style="font-size:11px;color:var(--text3)">Page ${_page+1} of ${totalPages}</span>
<button class="btn b-cancel" id="btn-prev" ${_page===0?'disabled':''} style="padding:6px 14px;height:auto">‹ Prev</button>
<button class="btn b-cancel" id="btn-next" ${_page>=totalPages-1?'disabled':''} style="padding:6px 14px;height:auto">Next ›</button>
</div>
<div class="note">Click a batch row to view its transaction history below. Committed/Ordered/Available are extra columns not in SAP's native report — available here since the data is already on hand.</div>`}
</div>
</div>
${_ran&&!_loading?txnPanelHtml():''}`;
wire();
}
function wire(){
q('#sel-co').onchange=async function(){_co=this.value;_ran=false;_rows=[];_selItem=null;_selBatch=null;_txns=[];await Promise.all([loadItemGroups(),loadWarehouses()]);_selectedWh=new Set();render();};
wireCodeSearch('f-itemfrom','dd-itemfrom','itemFrom',fetchItems,'ItemCode','ItemName');
wireCodeSearch('f-itemto','dd-itemto','itemTo',fetchItems,'ItemCode','ItemName');
q('#f-batchno').oninput=function(){_f.batchNo=this.value;};
q('#f-itemgroup').onchange=function(){_f.itemGroup=this.value;};
q('#f-zeroqty').onchange=function(){_f.includeZeroQty=this.checked;};
q('#btn-run').onclick=runReport;
q('#f-search').oninput=function(){
_search=this.value;_page=0;
const pos=this.selectionStart;
render();
const el=q('#f-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}}
};
if(q('#btn-prev'))q('#btn-prev').onclick=()=>{_page--;render();};
if(q('#btn-next'))q('#btn-next').onclick=()=>{_page++;render();};
document.querySelectorAll('th.sortable').forEach(th=>{th.onclick=()=>sortBy(th.dataset.sort);});
document.querySelectorAll('.wh-chk').forEach(el=>{
el.onchange=function(){
if(this.checked)_selectedWh.add(this.value);else _selectedWh.delete(this.value);
};
});
q('#wh-all').onclick=()=>{_selectedWh=new Set(_wh.map(w=>w.code));render();};
q('#wh-none').onclick=()=>{_selectedWh=new Set();render();};
if(q('#btn-export'))q('#btn-export').onclick=exportExcel;
document.querySelectorAll('.batch-row').forEach(row=>{
row.onclick=()=>{ if(_f.allTxns){_f.allTxns=false;} selectBatch(row.dataset.item,row.dataset.batch); };
});
const allTxnsEl=q('#f-alltxns');
if(allTxnsEl)allTxnsEl.onchange=function(){
_f.allTxns=this.checked;
if(_f.allTxns)loadAllTxns();else{_selItem=null;_selBatch=null;_txns=[];render();}
};
}
const XLSX_GREEN='FF0DBB8A', XLSX_INK='FF1E2A44', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFEFF4FF', XLSX_RED='FFE53E3E';
function xlsxBorder(color){ const b={style:'thin',color:{argb:color||'FFB9CCEA'}}; return {top:b,left:b,bottom:b,right:b}; }
async function exportExcel(){
const rows=filteredRows();
if(!rows.length){toast('Nothing to export');return;}
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry');return;}
const wb=new ExcelJS.Workbook();
wb.creator='SAP ERP Portal';wb.created=new Date();
const ws=wb.addWorksheet('Batches',{views:[{state:'frozen',ySplit:6}]});
ws.mergeCells('A1:P1');
const title=ws.getCell('A1');
title.value='Batch No. Transaction Report — Export';
title.font={bold:true,size:16,color:{argb:XLSX_WHITE}};
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_GREEN}};
title.alignment={vertical:'middle',horizontal:'left'};
ws.getRow(1).height=26;
const whLabel=_selectedWh.size?[..._selectedWh].join(', '):'All';
const metaRows=[
['Company:',_co,'Item Code:',`${_f.itemFrom||'—'} to ${_f.itemTo||'—'}`],
['Warehouse(s):',whLabel,'Rows:',rows.length],
];
metaRows.forEach((r,i)=>{
const row=ws.getRow(2+i);
row.getCell(1).value=r[0];row.getCell(1).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(2).value=r[1];row.getCell(2).font={color:{argb:'FF0F172A'}};
row.getCell(3).value=r[2];row.getCell(3).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(4).value=r[3];row.getCell(4).font={color:{argb:'FF0F172A'}};
});
const hdrRowNum=6;
const cols=['#','Item No.','Item Description','Batch','Whse','Quantity','Expiration Date','Manufacturing Date','Project','Status','COA Status','Sale Type','Committed','Ordered','Available','UoM'];
const hdrRow=ws.getRow(hdrRowNum);
cols.forEach((h,i)=>{
const cell=hdrRow.getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
cell.alignment={vertical:'middle',horizontal:i===0||(i>=5&&i<=5)||(i>=12&&i<=14)?'right':'center'};
cell.border=xlsxBorder('FF0F172A');
});
hdrRow.height=20;
rows.forEach((r,i)=>{
const row=ws.getRow(hdrRowNum+1+i);
const vals=[i+1,r.itemCode,r.itemName,r.batchNo,r.warehouse,r.inStock,
r.expDate?new Date(r.expDate):null,r.mfgDate?new Date(r.mfgDate):null,
r.project,r.status,r.coaStatus,r.saleType,r.committed,r.ordered,r.available,r.uom];
vals.forEach((v,ci)=>{
const cell=row.getCell(ci+1);
cell.value=v;
cell.border=xlsxBorder();
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
if(ci===0||ci===5||(ci>=12&&ci<=14))cell.alignment={horizontal:'right'};
if(ci===6||ci===7)cell.numFmt='dd-mmm-yyyy';
if(ci===5||(ci>=12&&ci<=14)){
cell.numFmt='#,##0.######';
if(ci===14&&r.available<0)cell.font={bold:true,color:{argb:XLSX_RED}};
}
});
});
ws.columns=[{width:6},{width:16},{width:30},{width:16},{width:8},{width:14},{width:14},{width:16},{width:12},{width:10},{width:12},{width:12},{width:12},{width:12},{width:12},{width:10}];
if(_txns.length){
const ws2=wb.addWorksheet('Transactions',{views:[{state:'frozen',ySplit:3}]});
ws2.mergeCells('A1:K1');
const t2=ws2.getCell('A1');
t2.value=_f.allTxns?'Transactions for All Listed Batches':`Transactions for Batch: ${_selBatch} (${_selItem})`;
t2.font={bold:true,size:14,color:{argb:XLSX_WHITE}};
t2.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_GREEN}};
ws2.getRow(1).height=22;
const hdr2=['#','Document','Doc Row','Date','Whse','Whse Name','G/L Acct/BP Name','Qty','Direction','Item No.','Batch'];
hdr2.forEach((h,i)=>{
const c=ws2.getRow(3).getCell(i+1);
c.value=h;c.font={bold:true,color:{argb:XLSX_WHITE}};c.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
c.border=xlsxBorder('FF0F172A');
});
ws2.getRow(3).height=20;
_txns.forEach((t,i)=>{
const row=ws2.getRow(4+i);
const vals=[t.no,`${t.docTypeLabel} ${t.docNum}`,t.docRow,t.date?new Date(t.date):null,t.warehouse,t.warehouseName,t.bpName,t.qty,t.direction,t.itemCode,t.batchNo];
vals.forEach((v,ci)=>{
const cell=row.getCell(ci+1);
cell.value=v;cell.border=xlsxBorder();
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
if(ci===3)cell.numFmt='dd-mmm-yyyy';
if(ci===7){cell.numFmt='#,##0.######';if(t.qty<0)cell.font={bold:true,color:{argb:XLSX_RED}};}
});
});
ws2.columns=[{width:6},{width:16},{width:10},{width:13},{width:8},{width:18},{width:28},{width:14},{width:10},{width:14},{width:16}];
}
const buf=await wb.xlsx.writeBuffer();
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`Batch_No_Transaction_Report_${_co||'all'}.xlsx`;
a.click();
URL.revokeObjectURL(a.href);
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await Promise.all([loadItemGroups(),loadWarehouses()]);
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+1 -1
View File
@@ -498,6 +498,6 @@ function drawLineChart(monthly){
load(); load();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -515,6 +515,6 @@ function mkAlert(msg){const d=mk('alert al-info');d.innerHTML=msg;return d;}
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -345,6 +345,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+6 -3
View File
@@ -88,7 +88,7 @@ tbody td.trunc{max-width:220px;overflow:hidden;text-overflow:ellipsis}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
@@ -275,9 +275,12 @@ async function loadData(reset=true){
rows=json.data||[]; rows=json.data||[];
renderTable(); renderTable();
setStatus(`Loaded ${rows.length} record(s)`,'ok'); setStatus(`Loaded ${rows.length} record(s)`,'ok');
pageInfo.textContent=`Page ${Math.floor(skip/TOP)+1}`; const total=typeof json.total==='number'?json.total:null;
pageInfo.textContent=total!=null
? `Page ${Math.floor(skip/TOP)+1} of ${Math.max(1,Math.ceil(total/TOP))} · ${total} record${total===1?'':'s'}`
: `Page ${Math.floor(skip/TOP)+1}`;
btnPrev.disabled=skip===0; btnPrev.disabled=skip===0;
btnNext.disabled=rows.length<TOP; btnNext.disabled=total!=null?(skip+TOP)>=total:rows.length<TOP;
}catch(e){ }catch(e){
setStatus('Failed: '+e.message,'err'); setStatus('Failed: '+e.message,'err');
showToast(e.message,'error'); showToast(e.message,'error');
+1 -1
View File
@@ -128,7 +128,7 @@ body{font-family:'Space Grotesk',sans-serif;background:var(--bg,#f8fafc);color:v
</style> </style>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="page"> <div class="page">
<div class="hdr"> <div class="hdr">
+14 -6
View File
@@ -155,6 +155,7 @@ let _modalOpen=false;
let _readyOrders=[]; let _readyOrders=[];
let _readyLoading=false; let _readyLoading=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
let _dateFrom='',_dateTo=''; // Due Date range, sent to the server (see loadReadyOrders())
let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
let _closeRequireFullQty=true; // admin setting (Settings → closeRequireFullQty) — fetched in init() let _closeRequireFullQty=true; // admin setting (Settings → closeRequireFullQty) — fetched in init()
let _localStatusByEntry={}; // sapAbsEntry → local status (to exempt REJECTED orders from the full-qty gate) let _localStatusByEntry={}; // sapAbsEntry → local status (to exempt REJECTED orders from the full-qty gate)
@@ -200,7 +201,8 @@ async function loadReadyOrders(resetPage){
// hide every single one of their orders. // hide every single one of their orders.
const readyParam=CONSUMABLE_ONLY_USER?'':'&readyToClose=1'; const readyParam=CONSUMABLE_ONLY_USER?'':'&readyToClose=1';
try{ try{
const r=await api('GET',`/sap/production-orders?status=Released${readyParam}&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`); const dateQ=(_dateFrom?`&dateFrom=${_dateFrom}`:'')+(_dateTo?`&dateTo=${_dateTo}`:'');
const r=await api('GET',`/sap/production-orders?status=Released${readyParam}&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${dateQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
_rawCount=(r.data||[]).length; _rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]); _readyOrders=(r.data||[]);
_total=(typeof r.total==='number')?r.total:null; _total=(typeof r.total==='number')?r.total:null;
@@ -263,11 +265,15 @@ function _doRender(){
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${badge}${consBadge}</div> <div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${badge}${consBadge}</div>
<div class="po-icon">🔒</div> <div class="po-icon">🔒</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</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> <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>${o.DueDate?` · Due <b>${new Date(o.DueDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</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>`:''} ${(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>`; </div>`;}).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px"> const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
<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"/> <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;min-width:220px"/>
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;align-self:center">Due</span>
<input class="fi" id="rp-datefrom" type="date" value="${esc(_dateFrom)}" style="font-size:12px"/>
<span style="font-size:11px;color:var(--text3);align-self:center">to</span>
<input class="fi" id="rp-dateto" type="date" value="${esc(_dateTo)}" style="font-size:12px"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--amber);color:#fff;white-space:nowrap">Search</button> <button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--amber);color:#fff;white-space:nowrap">Search</button>
</div>`; </div>`;
const typeRow=CONSUMABLE_ONLY_USER const typeRow=CONSUMABLE_ONLY_USER
@@ -276,7 +282,7 @@ function _doRender(){
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span> <span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span>
${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:#9f5afd;border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')} ${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:#9f5afd;border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')}
</div>`:''; </div>`:'';
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Close<span class="right">${_readyOrders.length}</span></div> c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Close<span class="right">${_total!=null?_total:_readyOrders.length}</span></div>
<div class="card-bd"> <div class="card-bd">
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">${CONSUMABLE_ONLY_USER?'Consumable Orders — Release → Issue → Close only, no Receipt required.':'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> <div style="font-size:11px;color:var(--text3);margin-bottom:10px">${CONSUMABLE_ONLY_USER?'Consumable Orders — Release → Issue → Close only, no Receipt required.':'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}${typeRow}${gridBody}${renderPager()}</div>`; ${searchRow}${typeRow}${gridBody}${renderPager()}</div>`;
@@ -290,6 +296,8 @@ function _doRender(){
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;}); 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-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-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
q('#rp-datefrom')&&(q('#rp-datefrom').onchange=function(){_dateFrom=this.value;loadReadyOrders(true);});
q('#rp-dateto')&&(q('#rp-dateto').onchange=function(){_dateTo=this.value;loadReadyOrders(true);});
document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);}); document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);});
q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}}); q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}});
q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}}); q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}});
@@ -437,6 +445,6 @@ window.addEventListener('hashchange',()=>{
} }
}); });
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1015,6 +1015,6 @@ function printCostSheet() {
w.document.close(); w.document.close();
} }
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -118,7 +118,7 @@ table.cmp{border-collapse:collapse;font-size:12px}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
<h1><span>P&amp;L</span> Comparison</h1> <h1><span>P&amp;L</span> Comparison</h1>
+1 -1
View File
@@ -136,7 +136,7 @@ table.pl-tbl{border-collapse:collapse;min-width:100%;font-size:12px}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
+301
View File
@@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Customer Portal — Mitra Industries</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;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--purple:#9f5afd;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#5b7594;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace}
*{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}
.top{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--surface3);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:20;flex-wrap:wrap}
.top img{height:30px}.top .brand{font-weight:700;font-size:14px}.top .who{margin-left:auto;text-align:right;font-size:12px}
.nav{display:flex;gap:2px;padding:0 12px;background:var(--surface);border-bottom:1px solid var(--border);overflow-x:auto}
.nav a{padding:10px 14px;font-size:12.5px;font-weight:700;color:var(--text2);text-decoration:none;border-bottom:2px solid transparent;white-space:nowrap}
.nav a.on{color:var(--blue);border-bottom-color:var(--blue)}
main{padding:14px;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:12.5px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.card-bd{padding:12px 14px}
.kpis{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:12px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px}
.kpi b{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--text3)}.kpi span{font-size:20px;font-weight:700}
.lst{width:100%;border-collapse:collapse;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);white-space:nowrap;background:var(--surface3)}
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
.lst td.n,.lst th.n{text-align:right;font-family:var(--mono)}
.lst tbody tr[data-id]{cursor:pointer}.lst tbody tr[data-id]:hover td{background:rgba(26,111,255,.06)}
.tbl-wrap{overflow-x:auto}
.mono{font-family:var(--mono)}
.empty{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.login{max-width:380px;margin:9vh auto;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:26px}
.login img{height:42px;display:block;margin:0 auto 10px}.login h1{font-size:17px;text-align:center;margin-bottom:4px}.login p{text-align:center;font-size:12px;color:var(--text3);margin-bottom:18px}
.login .sc-in{margin-bottom:12px;height:38px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
<script src="/sales-common.js?v=4"></script>
</head>
<body>
<div id="root"></div>
<script>
'use strict';
const { esc, money, num, date, dt, q, qa, toast, api, pill } = SC;
// Customer session: sessionStorage + a session cookie (no expiry → cleared when
// the browser closes) so new tabs stay logged in — same model as the ERP login.
const TK = 'cust_token';
function getTok() { try { let t = sessionStorage.getItem(TK); if (!t) { const m = document.cookie.match(/(?:^|;\s*)cust_token=([^;]+)/); if (m) { t = decodeURIComponent(m[1]); sessionStorage.setItem(TK, t); } } return t; } catch (_e) { return null; } }
function setTok(t) { try { sessionStorage.setItem(TK, t); } catch (_e) {} document.cookie = `cust_token=${encodeURIComponent(t)};path=/;SameSite=Lax`; }
function clearTok() { try { sessionStorage.removeItem(TK); } catch (_e) {} document.cookie = 'cust_token=;path=/;max-age=0'; }
SC.base = '/api/sales-portal';
SC.getToken = getTok;
SC.onUnauthorized = () => { clearTok(); showLogin('Your session has expired — please log in again.'); };
let ME = null;
const divName = id => ((ME.allDivisions || ME.divisions).find(d => d.id === id) || {}).name || id; // incl. disabled categories (existing orders)
function showLogin(msg) {
q('#root').innerHTML = `<div class="login"><img src="/logo.png" alt="" onerror="this.style.display='none'"><h1>Customer Portal</h1><p>Mitra Industries Pvt. Ltd. — place and track your orders</p>
${msg ? `<div class="sc-warn">${esc(msg)}</div>` : ''}
<label class="sc-lbl">Customer code</label><input class="sc-in" id="l-c" autocomplete="username" placeholder="e.g. 10ABCDEF">
<label class="sc-lbl">Password</label><input class="sc-in" id="l-p" type="password" autocomplete="current-password">
<button class="sc-btn sc-b-primary" id="l-go" style="width:100%;height:38px">Log in</button>
<p style="margin:14px 0 0">Forgot your password? Please contact your MIPL sales representative.</p></div>`;
const go = async () => {
const b = q('#l-go'); b.disabled = true;
try {
const r = await fetch('/api/sales-portal/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cardCode: q('#l-c').value.trim(), password: q('#l-p').value }) });
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.message || 'Login failed');
setTok(d.data.token); await boot();
} catch (e) { toast(e.message); b.disabled = false; }
};
q('#l-go').onclick = go; q('#l-p').onkeydown = e => { if (e.key === 'Enter') go(); };
SC.eyeify(q('.login'));
}
async function boot() {
if (!getTok()) return showLogin();
try { ME = await api('/me'); } catch (e) { return; }
q('#root').innerHTML = `<div class="top"><img src="/logo.png" alt="" onerror="this.style.display='none'"><div class="brand">Mitra Industries — Customer Portal</div>
<div class="who"><b>${esc(ME.account.cardName)}</b> (${esc(ME.account.cardCode)})<div class="sc-muted">Wallet: <b id="w-bal">${money(ME.wallet.balance)}</b> · <span class="sc-link" id="lo">Log out</span></div></div></div>
<div class="nav">${[['dashboard', 'Dashboard'], ['new', '+ New Order'], ['orders', 'My Orders'], ['ledger', 'Ledger'], ['password', 'Change Password']].map(([h, l]) => `<a href="#${h}" data-h="${h}">${l}</a>`).join('')}</div>
<main id="view"></main>`;
q('#lo').onclick = () => { clearTok(); ME = null; showLogin(); };
if (ME.account.mustChangePwd && !location.hash.startsWith('#password')) location.hash = '#password';
route();
}
window.addEventListener('hashchange', () => ME && route());
function route() {
const h = (location.hash || '#dashboard').slice(1); const [v, arg] = h.split('/');
qa('.nav a').forEach(a => a.classList.toggle('on', a.dataset.h === v || (v === 'order' && a.dataset.h === 'orders')));
if (ME.account.mustChangePwd && v !== 'password') { location.hash = '#password'; return; }
({ dashboard, new: () => orderForm(), orders, order: () => orderView(arg), modify: () => orderForm(arg), ledger, password }[v] || dashboard)();
}
const view = () => q('#view');
async function refreshWallet() { try { ME.wallet = (await api('/ledger')).summary; q('#w-bal').textContent = money(ME.wallet.balance); } catch (_e) {} }
// ── Dashboard ───────────────────────────────────────────────────────────────
async function dashboard() {
view().innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
const rows = await api('/orders?limit=500');
const open = rows.filter(o => [1, 2, 3, 4, 5, 6, 7, 8].includes(o.status));
const toPay = rows.filter(o => o.status === 4), toModify = rows.filter(o => o.status === 5);
view().innerHTML = `<div class="kpis">
<div class="kpi"><b>Open orders</b><span>${open.length}</span></div>
<div class="kpi"><b>Awaiting your payment</b><span>${toPay.length}</span><div class="sc-muted">${money(toPay.reduce((a, o) => a + o.grandTotal, 0))}</div></div>
<div class="kpi"><b>Wallet balance</b><span>${money(ME.wallet.balance)}</span>${ME.wallet.pendingCredits ? `<div class="sc-muted">${money(ME.wallet.pendingCredits)} under verification</div>` : ''}</div>
<div class="kpi"><b>Credit limit</b><span>${money(ME.wallet.creditLimit)}</span></div></div>
${toPay.length || toModify.length ? `<div class="card"><div class="card-hd">Needs your action</div><div class="tbl-wrap">${tbl([...toPay, ...toModify])}</div></div>` : ''}
<div class="card"><div class="card-hd">Recent orders <a class="sc-link" href="#orders" style="margin-left:auto">View all</a></div><div class="tbl-wrap">${tbl(rows.slice(0, 10))}</div></div>`;
wireRows();
}
function tbl(rows) {
return rows.length ? `<table class="lst"><thead><tr><th>Ref no.</th><th>Date</th><th>Type</th><th>Category</th><th>Your PO</th><th class="n">Value</th><th>Status</th><th>SAP SO</th></tr></thead><tbody>
${rows.map(o => `<tr data-id="${o.id}"><td class="mono">${esc(o.refNo)}</td><td>${date(o.createdAt)}</td><td>${o.soType === 1 ? 'Trade' : 'Institution'}</td><td>${esc(divName(o.divisionId))}</td>
<td>${esc(o.custOrderNo)}</td><td class="n">${money(o.grandTotal)}</td><td>${pill(o.status, o.customerStatusLabel)}</td><td class="mono">${esc(o.sapDocNum || '')}</td></tr>`).join('')}</tbody></table>`
: '<div class="empty">No orders yet.</div>';
}
function wireRows() { qa('tr[data-id]').forEach(tr => { tr.onclick = () => { location.hash = '#order/' + tr.dataset.id; }; }); }
// ── Orders list ─────────────────────────────────────────────────────────────
async function orders() {
view().innerHTML = `<div class="card"><div class="card-bd" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<div><label class="sc-lbl">Show</label><select class="sc-in" id="o-st"><option value="1,2,3,4,5,6,7,8">Open</option><option value="9">Closed</option><option value="10">Cancelled</option><option value="">All</option></select></div>
<div style="flex:1;min-width:200px"><label class="sc-lbl">Search</label><input class="sc-in" id="o-q" placeholder="Ref no. / your PO / SAP no."></div>
<button class="sc-btn sc-b-primary" id="o-go">Search</button></div></div>
<div class="card"><div class="tbl-wrap" id="o-t"><div class="empty"><span class="sc-spin"></span></div></div></div>`;
const load = async () => { q('#o-t').innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
const p = new URLSearchParams({ statuses: q('#o-st').value, search: q('#o-q').value.trim() });
try { q('#o-t').innerHTML = tbl(await api('/orders?' + p)); wireRows(); } catch (e) { q('#o-t').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; } };
q('#o-go').onclick = load; q('#o-q').onkeydown = e => { if (e.key === 'Enter') load(); }; q('#o-st').onchange = load;
load();
}
// ── Order view + customer actions ───────────────────────────────────────────
async function orderView(id) {
view().innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
let o; try { o = await api('/orders/' + id); } catch (e) { view().innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; return; }
const canCancel = [1, 2, 3, 4, 5].includes(o.status);
view().innerHTML = `<div class="card"><div class="card-hd"><a class="sc-link" href="#orders">← My orders</a> &nbsp; Order ${esc(o.refNo)}
<span style="margin-left:auto;display:flex;gap:8px">
${o.status === 4 ? '<button class="sc-btn sc-b-green" id="x-pay">Make payment</button>' : ''}
${o.status === 5 ? '<button class="sc-btn sc-b-amber" id="x-mod">Modify & resubmit</button>' : ''}
${canCancel ? '<button class="sc-btn sc-b-red" id="x-can">Cancel order</button>' : ''}
<button class="sc-btn sc-b-ghost" id="x-up">Attach document</button></span></div>
<div class="card-bd" id="x-body">${o.status === 4 ? `<div class="sc-info">Your order is approved. Please make the payment of <b>${money(o.grandTotal)}</b> (less any wallet balance) and submit the details here.</div>` : ''}
${o.status === 5 ? '<div class="sc-warn">MIPL has asked you to modify this order — see MIPL remarks below.</div>' : ''}
${SC.orderHtml(o, { employee: false, divisions: ME.allDivisions || ME.divisions })}</div></div>`;
SC.wireOrder(q('#x-body'), o.id);
if (q('#x-pay')) q('#x-pay').onclick = () => payForm(o);
if (q('#x-mod')) q('#x-mod').onclick = () => { location.hash = '#modify/' + o.id; };
if (q('#x-can')) q('#x-can').onclick = () => SC.modal({ title: 'Cancel order ' + esc(o.refNo), body: '<label class="sc-lbl">Reason *</label><textarea class="sc-in" id="c-r"></textarea>',
buttons: [{ label: 'Back' }, { label: 'Cancel order', cls: 'sc-b-red', onClick: async ({ root }) => { await api(`/orders/${o.id}/cancel`, { method: 'POST', body: { remarks: q('#c-r', root).value } }); toast('Order cancelled', 'ok'); refreshWallet(); orderView(o.id); } }] });
q('#x-up').onclick = () => SC.modal({ title: 'Attach document', body: `<label class="sc-lbl">Title</label><input class="sc-in" id="u-t" value="Purchase order copy">
<label class="sc-lbl" style="margin-top:8px">File (PDF / JPG / PNG, max 10 MB)</label><input class="sc-in" type="file" id="u-f" accept=".pdf,.jpg,.jpeg,.png,.bmp">`,
buttons: [{ label: 'Cancel' }, { label: 'Upload', cls: 'sc-b-primary', onClick: async ({ root }) => { const f = q('#u-f', root).files[0]; if (!f) throw new Error('Choose a file');
await SC.upload(f, { entity: 'order', entityId: o.id, docType: 'po_copy', title: q('#u-t', root).value }); toast('Uploaded', 'ok'); orderView(o.id); } }] });
}
function payForm(o) {
const w = o.wallet || ME.wallet;
const calc = (tds) => Math.max(0, Math.round((o.grandTotal - (+tds || 0) - w.balance) * 100) / 100);
const m = SC.modal({ title: 'Payment — order ' + esc(o.refNo), body: `
<div class="sc-grid"><div class="sc-kv"><b>Order value</b><span>${money(o.grandTotal)}</span></div><div class="sc-kv"><b>Wallet balance</b><span>${money(w.balance)}</span></div>
<div class="sc-kv"><b>Payable now</b><span style="font-size:16px;font-weight:700" id="p-due">${money(calc(0))}</span></div></div>
<div class="sc-row" style="margin-top:10px"><div><label class="sc-lbl">TDS deducted by you (₹)</label><input class="sc-in" id="p-tds" type="number" min="0" step="0.01" value="0"></div>
<div><label class="sc-lbl">Amount paid now (₹)</label><input class="sc-in" id="p-amt" type="number" min="0" step="0.01" value="${calc(0)}"></div></div>
<div id="p-det"><div class="sc-row" style="margin-top:8px">
<div><label class="sc-lbl">Payment mode *</label><select class="sc-in" id="p-mode"><option>RTGS</option><option>NEFT</option><option>Cheque</option><option>Online</option></select></div>
<div><label class="sc-lbl" id="p-ref-l">UTR no. *</label><input class="sc-in" id="p-ref"></div>
<div><label class="sc-lbl">Payment date *</label><input class="sc-in" type="date" id="p-date" max="${SC.today()}" value="${SC.today()}"></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Payment proof (PDF / image)</label><input class="sc-in" type="file" id="p-file" accept=".pdf,.jpg,.jpeg,.png,.bmp"></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Payment detail</label><input class="sc-in" id="p-pd" placeholder="e.g. bank name, TDS note"></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Remarks</label><textarea class="sc-in" id="p-rm"></textarea></div>
<div class="sc-muted" style="margin-top:6px">Anything you pay above the payable amount stays in your wallet for future orders. Payments are verified by MIPL Accounts.</div>`,
buttons: [{ label: 'Cancel' }, { label: 'Submit payment', cls: 'sc-b-green', onClick: async ({ root }) => {
const amt = +q('#p-amt', root).value || 0; let docId = null;
const f = q('#p-file', root).files[0];
if (f) docId = (await SC.upload(f, { entity: 'order', entityId: o.id, docType: 'payment', title: 'Payment proof' })).id;
await api(`/orders/${o.id}/pay`, { method: 'POST', body: { paidAmount: amt, tds: q('#p-tds', root).value, paymentMode: q('#p-mode', root).value, paymentRef: q('#p-ref', root).value,
paymentDate: q('#p-date', root).value, paymentDetail: q('#p-pd', root).value, remarks: q('#p-rm', root).value, docId } });
toast('Payment submitted', 'ok'); refreshWallet(); orderView(o.id); } }] });
const R = m.root;
const upd = () => { const d = calc(q('#p-tds', R).value); q('#p-due', R).textContent = money(d); q('#p-det', R).style.display = (+q('#p-amt', R).value > 0) ? '' : 'none'; };
q('#p-tds', R).oninput = () => { q('#p-amt', R).value = calc(q('#p-tds', R).value); upd(); };
q('#p-amt', R).oninput = upd;
q('#p-mode', R).onchange = () => { q('#p-ref-l', R).textContent = ({ Cheque: 'Cheque no. *', Online: 'Online transaction ID *' })[q('#p-mode', R).value] || 'UTR no. *'; };
upd();
}
// ── New order / modify ──────────────────────────────────────────────────────
async function orderForm(modifyId) {
const ex = modifyId ? await api('/orders/' + modifyId) : null;
if (ex && ex.status !== 5) { location.hash = '#order/' + modifyId; return; }
const c = ME.customer;
const addrOpt = (list, sel) => list.map(a => `<option value="${esc(a.code)}" ${a.code === sel ? 'selected' : ''}>${esc(a.code)} — ${esc(a.city)}, ${esc(a.stateName || a.state)}</option>`).join('');
view().innerHTML = `<div class="card"><div class="card-hd">${ex ? `Modify order ${esc(ex.refNo)}` : 'New sales order'}</div><div class="card-bd">
${ex ? `<div class="sc-warn">MIPL remarks:<br>${esc(ex.employeeRemarks || '—')}</div>` : ''}
<div class="sc-row">
<div><label class="sc-lbl">Order type *</label><select class="sc-in" id="n-type" ${ex ? 'disabled' : ''}><option value="1">Trade</option><option value="2">Institution</option></select></div>
<div><label class="sc-lbl">Product category *</label><select class="sc-in" id="n-div" ${ex ? 'disabled' : ''}><option value="">— Select —</option>${ME.divisions.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select></div>
<div><label class="sc-lbl">Your order ref. no. *</label><input class="sc-in" id="n-po" value="${esc(ex ? ex.custOrderNo : '')}">
${ex ? '' : '<div class="sc-muted" id="n-po-h" style="margin-top:3px">Generated automatically — you may type your own PO number instead. <span class="sc-link" id="n-po-reset" style="display:none">Use automatic no.</span></div>'}</div>
<div><label class="sc-lbl">Committed Dispatch Date *</label><input class="sc-in" type="date" id="n-dd" min="${SC.today()}" value="${ex ? SC.iso(ex.deliveryDate) : ''}"></div></div>
<div class="sc-row" style="margin-top:8px">
<div><label class="sc-lbl">Ship to *</label><select class="sc-in" id="n-ship">${addrOpt(c.shipTo, ex && ex.shipToCode)}</select><div class="sc-muted" id="n-ship-t" style="white-space:pre-wrap;margin-top:3px"></div></div>
<div><label class="sc-lbl">Bill to *</label><select class="sc-in" id="n-bill">${addrOpt(c.billTo, ex && ex.billToCode)}</select><div class="sc-muted" id="n-bill-t" style="white-space:pre-wrap;margin-top:3px"></div></div></div>
<div class="sc-sec">Products</div><div id="n-cart" class="sc-muted">Select a product category first.</div>
<div class="sc-row" style="margin-top:10px"><div><label class="sc-lbl">Remarks</label><textarea class="sc-in" id="n-rm"></textarea></div>
${ex ? '' : '<div><label class="sc-lbl">Purchase order copy (PDF / image)</label><input class="sc-in" type="file" id="n-file" accept=".pdf,.jpg,.jpeg,.png,.bmp"></div>'}</div>
<div style="margin-top:12px;text-align:right"><button class="sc-btn sc-b-primary" id="n-save">${ex ? 'Resubmit order' : 'Place order'}</button></div></div></div>`;
if (ex) { q('#n-type').value = ex.soType; q('#n-div').value = ex.divisionId; }
if (!c.shipTo.length || !c.billTo.length) view().insertAdjacentHTML('afterbegin', '<div class="sc-err">No Ship-To / Bill-To address is set up for your account in SAP — please contact MIPL.</div>');
const shipState = () => (c.shipTo.find(a => a.code === q('#n-ship').value) || {}).state;
const addrText = () => { q('#n-ship-t').textContent = (c.shipTo.find(a => a.code === q('#n-ship').value) || {}).text || ''; q('#n-bill-t').textContent = (c.billTo.find(a => a.code === q('#n-bill').value) || {}).text || ''; };
addrText();
// Auto "Your order ref. no." (per customer: CODE/FY/0001) — editable.
let autoNo = '';
if (!ex) {
const po = q('#n-po'), rst = q('#n-po-reset');
const syncHint = () => { rst.style.display = autoNo && po.value.trim() !== autoNo ? '' : 'none'; };
api('/next-order-no').then(d => { autoNo = d.custOrderNo; if (!po.value.trim()) po.value = autoNo; syncHint(); }).catch(() => {});
po.oninput = syncHint;
rst.onclick = () => { po.value = autoNo; syncHint(); };
}
let cart = null;
// Product list = only the selected category's products (server-filtered).
let lastDiv = ex ? String(ex.divisionId) : '';
const loadCart = async () => {
const div = q('#n-div').value;
if (!div) { cart = null; q('#n-cart').className = 'sc-muted'; q('#n-cart').textContent = 'Select a product category first.'; return; }
q('#n-cart').innerHTML = '<span class="sc-spin"></span>';
const prods = await api('/products?divisionId=' + div);
q('#n-cart').className = '';
cart = SC.cartEditor(q('#n-cart'), prods, { lines: ex ? ex.items.map(i => ({ key: i.productId ? `P:${i.productId}` : `S:${i.itemCode}`, qty: i.qty, specialPrice: i.specialPrice })) : [],
allowSpecial: true, settings: ME.settings, getShipState: shipState, categoryName: divName(+div) });
};
q('#n-div').onchange = async () => {
if (cart && cart.count() && !(await SC.confirmBox('Change product category?', 'Products already added belong to the current category and will be removed.', 'Change category', 'sc-b-amber'))) { q('#n-div').value = lastDiv; return; }
lastDiv = q('#n-div').value; loadCart();
};
if (ex) loadCart();
q('#n-ship').onchange = () => { addrText(); cart && cart.refresh(); }; q('#n-bill').onchange = addrText;
q('#n-save').onclick = async (e) => {
if (!cart) return toast('Select a product category and add products');
const body = { soType: +q('#n-type').value, divisionId: +q('#n-div').value, custOrderNo: q('#n-po').value.trim(), deliveryDate: q('#n-dd').value,
// Untouched suggestion → server assigns the next free number at save time.
custOrderNoAuto: !ex && !!autoNo && q('#n-po').value.trim() === autoNo,
shipToCode: q('#n-ship').value, billToCode: q('#n-bill').value, remarks: q('#n-rm').value, lines: cart.getLines() };
e.target.disabled = true;
try {
const o = ex ? await api(`/orders/${ex.id}/resubmit`, { method: 'PUT', body }) : await api('/orders', { method: 'POST', body });
const f = q('#n-file') && q('#n-file').files[0];
if (f) { try { await SC.upload(f, { entity: 'order', entityId: o.id, docType: 'po_copy', title: 'Purchase order copy' }); } catch (er) { toast('Order placed, but the PO copy upload failed: ' + er.message); } }
toast(`Order ${o.refNo} ${ex ? 'resubmitted' : 'placed'} successfully`, 'ok');
location.hash = '#order/' + o.id;
} catch (er) { toast(er.message); e.target.disabled = false; }
};
}
// ── Ledger ──────────────────────────────────────────────────────────────────
async function ledger() {
view().innerHTML = `<div class="card"><div class="card-bd" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<div><label class="sc-lbl">From</label><input class="sc-in" type="date" id="g-f"></div><div><label class="sc-lbl">To</label><input class="sc-in" type="date" id="g-t"></div>
<button class="sc-btn sc-b-primary" id="g-go">Show</button><button class="sc-btn sc-b-ghost" id="g-csv">⬇ Download CSV</button></div></div>
<div class="card"><div class="tbl-wrap" id="g-o"></div></div>`;
let data = null;
const load = async () => { q('#g-o').innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
data = (await api(`/ledger?from=${q('#g-f').value}&to=${q('#g-t').value}`)).ledger;
q('#g-o').innerHTML = `<table class="lst"><thead><tr><th>Date</th><th>Description</th><th>Order</th><th>Reference</th><th>Status</th><th class="n">Credit</th><th class="n">Debit</th><th class="n">Balance</th></tr></thead><tbody>
<tr><td colspan="7"><i>Opening balance</i></td><td class="n">${money(data.opening)}</td></tr>
${data.lines.map(x => `<tr><td>${dt(x.createdAt)}</td><td>${esc({ topup: 'Payment', order: 'Order', refund: 'Refund', adjustment: 'Adjustment', opening: 'Opening balance' }[x.refType] || x.refType)}</td>
<td>${esc(x.refNo || '')}</td><td>${esc(x.paymentMode)} ${esc(x.paymentRef || x.remarks)}</td><td>${esc(x.status)}</td>
<td class="n">${x.type === 'credit' ? money(x.amount) : ''}</td><td class="n">${x.type === 'debit' ? money(x.amount) : ''}</td><td class="n">${money(x.runningBalance)}</td></tr>`).join('')}
<tr><td colspan="7"><b>Closing balance</b></td><td class="n"><b>${money(data.closing)}</b></td></tr></tbody></table>`; };
q('#g-go').onclick = load;
q('#g-csv').onclick = () => data && SC.csv(`ledger_${ME.account.cardCode}.csv`, ['Date', 'Type', 'Order', 'Mode', 'Reference', 'Status', 'Credit', 'Debit', 'Balance'],
data.lines.map(x => [dt(x.createdAt), x.refType, x.refNo || '', x.paymentMode, x.paymentRef || x.remarks, x.status, x.type === 'credit' ? x.amount : '', x.type === 'debit' ? x.amount : '', x.runningBalance]));
load();
}
// ── Change password ─────────────────────────────────────────────────────────
function password() {
view().innerHTML = `<div class="card" style="max-width:420px"><div class="card-hd">Change password</div><div class="card-bd">
${ME.account.mustChangePwd ? '<div class="sc-warn">Please set a new password before continuing.</div>' : ''}
<label class="sc-lbl">Current password</label><input class="sc-in" type="password" id="w-o" style="margin-bottom:8px">
<label class="sc-lbl">New password (min. 8 characters)</label><input class="sc-in" type="password" id="w-n" style="margin-bottom:8px">
<label class="sc-lbl">Confirm new password</label><input class="sc-in" type="password" id="w-c" style="margin-bottom:12px">
<button class="sc-btn sc-b-primary" id="w-go">Change password</button></div></div>`;
SC.eyeify(view());
q('#w-go').onclick = async () => {
if (q('#w-n').value !== q('#w-c').value) return toast('New passwords do not match');
try { await api('/change-password', { method: 'POST', body: { oldPassword: q('#w-o').value, newPassword: q('#w-n').value } });
toast('Password changed', 'ok'); ME.account.mustChangePwd = false; location.hash = '#dashboard'; } catch (e) { toast(e.message); }
};
}
boot();
</script>
</body>
</html>
+234
View File
@@ -0,0 +1,234 @@
<!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>Deviations — 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;--extra:#ec4899;--extrasevere:#ea580c;--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:1500px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(234,88,12,.14);color:var(--extrasevere);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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:150px}
.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;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 tbody tr:hover td{background:rgba(26,111,255,.05)}
.mono{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}
.t-ok{background:var(--green);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:70vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
.pill{font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap;display:inline-block}
.link{color:var(--blue);text-decoration:none;cursor:pointer;font-weight:600}
.link:hover{text-decoration:underline}
</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,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function num3(v){if(v==null||v==='')return'';const n=Number(v);if(isNaN(n))return'';return n.toLocaleString('en-IN',{maximumFractionDigits:6});}
function fmtDT(v){if(!v)return'—';const d=new Date(v);if(isNaN(d))return'—';return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;}
async function api(p,method,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method:method||'GET',headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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;
}
// Same labels/colors as public/production.html's own deviation cards —
// kept in sync so a type/status looks identical wherever it's shown.
function devTypeLabel(t){return t==='SHORTAGE'?'Shortage':t==='SUBSTITUTION'?'Substitution':t==='DAMAGE'?'Damage/Loss':t==='ADDITION'?'Addition':t;}
function devTypeColor(t){return t==='SHORTAGE'?'var(--amber)':t==='SUBSTITUTION'?'var(--extra)':t==='ADDITION'?'var(--green)':'var(--extrasevere)';}
function qaBadge(status){
if(status==='N_A')return '<span style="color:var(--text3)">—</span>';
if(status==='PENDING')return '<span class="pill" style="background:rgba(245,166,35,.15);color:var(--amber)">PENDING</span>';
if(status==='APPROVED')return '<span class="pill" style="background:rgba(13,187,138,.15);color:var(--green)">APPROVED</span>';
if(status==='REJECTED')return '<span class="pill" style="background:rgba(229,62,62,.15);color:var(--red)">REJECTED</span>';
return esc(status);
}
let _co=window.__DEFAULT_COMPANY__||'';
let _companies=[];
let _rows=[];
let _loading=false,_loaded=false;
const _f={type:'',qaStatus:'',search:'',dateFrom:'',dateTo:''};
let _page=0;const PAGE_SIZE=20;
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
async function loadCompanies(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
}
async function loadDeviations(){
_loading=true;_loaded=true;_page=0;render();
try{
const r=await api('/sap/deviations/report?company='+encodeURIComponent(_co));
_rows=r.data||[];
}catch(e){toast(e.message,'err');_rows=[];}
_loading=false;render();
}
function filteredRows(){
return _rows.filter(r=>{
if(_f.type&&r.type!==_f.type)return false;
if(_f.qaStatus&&r.qaStatus!==_f.qaStatus)return false;
if(_f.dateFrom||_f.dateTo){
const d=(r.raisedAt||'').slice(0,10);
if(!d)return false;
if(_f.dateFrom&&d<_f.dateFrom)return false;
if(_f.dateTo&&d>_f.dateTo)return false;
}
if(_f.search){
const s=_f.search.trim().toUpperCase();
const hay=[r.sapDocNum,r.itemCode,r.itemName,r.newItemCode,r.newItemName,r.raisedName,r.raisedBy,r.reason].map(x=>(x||'').toUpperCase()).join(' ');
if(!hay.includes(s))return false;
}
return true;
}).sort((a,b)=>new Date(b.raisedAt||0)-new Date(a.raisedAt||0));
}
function render(){
const app=q('#app');
const allList=filteredRows();
const totalPages=Math.max(1,Math.ceil(allList.length/PAGE_SIZE));
if(_page>=totalPages)_page=totalPages-1;
if(_page<0)_page=0;
const list=allList.slice(_page*PAGE_SIZE,(_page+1)*PAGE_SIZE);
app.innerHTML=`
<div class="page-head">
<div class="page-icon">⚠️</div>
<div><div class="page-title">Deviations</div><div class="page-sub">Every Shortage / Substitution / Damage / Addition raised across all Production Orders, in one place</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Filters</span></div>
<div class="card-bd">
<div class="filters">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div><label class="fl">Type</label><select class="fi" id="f-type">
<option value="">All Types</option>
<option value="SHORTAGE" ${_f.type==='SHORTAGE'?'selected':''}>Shortage</option>
<option value="SUBSTITUTION" ${_f.type==='SUBSTITUTION'?'selected':''}>Substitution</option>
<option value="DAMAGE" ${_f.type==='DAMAGE'?'selected':''}>Damage/Loss</option>
<option value="ADDITION" ${_f.type==='ADDITION'?'selected':''}>Addition</option>
</select></div>
<div><label class="fl">QA Status</label><select class="fi" id="f-qa">
<option value="">All</option>
<option value="PENDING" ${_f.qaStatus==='PENDING'?'selected':''}>Pending</option>
<option value="APPROVED" ${_f.qaStatus==='APPROVED'?'selected':''}>Approved</option>
<option value="REJECTED" ${_f.qaStatus==='REJECTED'?'selected':''}>Rejected</option>
<option value="N_A" ${_f.qaStatus==='N_A'?'selected':''}>Not Required</option>
</select></div>
<div><label class="fl">Raised Date</label><div style="display:flex;gap:6px;align-items:center">
<input class="fi" id="f-datefrom" type="date" value="${esc(_f.dateFrom)}"/><span style="font-size:11px;color:var(--text3)">to</span><input class="fi" id="f-dateto" type="date" value="${esc(_f.dateTo)}"/>
</div></div>
<div style="flex:1;min-width:220px"><label class="fl">Search</label><input class="fi" id="f-search" placeholder="PWO#, Item, Raised By, Reason…" value="${esc(_f.search)}"/></div>
<div><button class="btn b-primary" id="btn-run" ${_loading?'disabled':''}>${_loading?'Loading…':'Refresh'}</button></div>
</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Deviated Production Orders</span><span class="right">${_loaded&&!_loading?`<span class="count">${allList.length} deviation(s)</span>`:''}</span></div>
<div class="card-bd">
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`
:!_loaded?`<div class="empty-msg">Loading…</div>`
:!allList.length?`<div class="empty-msg">No deviations found.</div>`
:`<div class="tbl-wrap"><table class="lst"><thead><tr>
<th class="num">#</th><th>PWO #</th><th>Item</th><th>Type</th><th class="num">Qty</th><th>Warehouse</th>
<th>Reason</th><th>Raised By</th><th>Raised At</th><th>QA Status</th><th>Reversed</th><th></th>
</tr></thead><tbody>${list.map((r,i)=>`
<tr>
<td class="num">${_page*PAGE_SIZE+i+1}</td>
<td class="mono">${esc(r.sapDocNum||'—')}</td>
<td>${r.type==='SUBSTITUTION'||r.type==='ADDITION'?`${esc(r.itemCode||'')}${r.newItemCode?` → <b>${esc(r.newItemCode)}</b>`:''}`:esc(r.itemCode||'')}<div style="font-size:10px;color:var(--text3)">${esc(r.itemName||r.newItemName||'')}</div></td>
<td><span class="pill" style="background:rgba(255,255,255,.06);color:${devTypeColor(r.type)}">${devTypeLabel(r.type)}</span></td>
<td class="num">${num3(r.quantity)}</td>
<td>${esc(r.destWarehouse||r.warehouse||'')}</td>
<td style="white-space:normal;max-width:220px">${esc(r.reason||'')}</td>
<td>${esc(r.raisedName||r.raisedBy||'')}</td>
<td>${fmtDT(r.raisedAt)}</td>
<td>${qaBadge(r.qaStatus)}</td>
<td>${r.reversed?'<span style="color:var(--red)">Yes</span>':'—'}</td>
<td>${r.sapAbsEntry?`<a class="link" href="/production?open=${r.sapAbsEntry}">Open PWO ›</a>`:''}</td>
</tr>
`).join('')}</tbody></table></div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn b-cancel" id="dev-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">Page ${_page+1} of ${totalPages} · ${allList.length} deviation${allList.length===1?'':'s'}</span>
<button class="btn b-cancel" id="dev-next" ${_page>=totalPages-1?'disabled':''} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`}
</div>
</div>`;
wire();
}
function wire(){
q('#sel-co').onchange=async function(){_co=this.value;_loaded=false;_rows=[];_page=0;render();await loadDeviations();};
q('#f-type').onchange=function(){_f.type=this.value;_page=0;render();};
q('#f-qa').onchange=function(){_f.qaStatus=this.value;_page=0;render();};
q('#f-datefrom').onchange=function(){_f.dateFrom=this.value;_page=0;render();};
q('#f-dateto').onchange=function(){_f.dateTo=this.value;_page=0;render();};
q('#f-search').oninput=function(){
_f.search=this.value;
_page=0;
const pos=this.selectionStart;
render();
const el=q('#f-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}}
};
q('#btn-run').onclick=loadDeviations;
q('#dev-prev')&&(q('#dev-prev').onclick=()=>{_page--;render();});
q('#dev-next')&&(q('#dev-next').onclick=()=>{_page++;render();});
}
async function init(){
await loadCompanies();
render();
await loadDeviations();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+1 -1
View File
@@ -467,6 +467,6 @@ window.viewAttachment=function(entry,lineId){
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}loadDocs();})(); (async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}loadDocs();})();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+560
View File
@@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Downtime Analysis — 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:1700px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(245,166,35,.14);color:var(--amber);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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(--amber)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:140px}
.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(--amber);color:#1a1200}
.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:11.5px}
.lst th{text-align:left;padding:7px 9px;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:6px 9px;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 tr.sec td{background:var(--surface3);font-weight:700;color:var(--text2)}
.lst tr.hi td{color:#fca5a5}
.lst td.hi-cell{background:rgba(229,62,62,.14);color:#fca5a5;font-weight:700}
.lst tbody tr:hover td{background:rgba(245,166,35,.05)}
.mono{font-family:var(--mono)}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(245,166,35,.25);border-top-color:var(--amber);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}
.t-ok{background:var(--green);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:70vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
.note{font-size:11px;color:var(--text3);margin-top:8px;line-height:1.5}
</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,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function num0(v){if(v==null)return'';const n=Number(v);if(isNaN(n))return'';return Math.round(n).toLocaleString('en-IN');}
function pct(v){if(v==null||!isFinite(v))return'—';return v.toFixed(2)+'%';}
async function api(p,method,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method:method||'GET',headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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 _loading=false,_ran=false,_report=null,_monthly=null,_detailByTab=null;
const _f={periodFrom:'',periodTo:'',baseDays:''};
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
async function loadConfig(){
try{
const r=await api('/settings');
const cfg=JSON.parse(r.data?.downtimeAnalysisConfig||'{}');
if(!_f.periodFrom)_f.periodFrom=cfg.periodFrom||'';
if(!_f.periodTo)_f.periodTo=cfg.periodTo||'';
if(!_f.baseDays)_f.baseDays=cfg.baseDays||'';
}catch(_e){ /* non-admin users can't read /settings — fall back to blank, user fills in manually */ }
}
async function runReport(){
if(!_f.periodFrom||!_f.periodTo){toast('Period From/To are required');return;}
_loading=true;_ran=true;render();
try{
const qs=new URLSearchParams();
qs.set('company',_co);qs.set('periodFrom',_f.periodFrom);qs.set('periodTo',_f.periodTo);
if(_f.baseDays)qs.set('baseDays',_f.baseDays);
const r=await api('/downtime-analysis/report?'+qs.toString());
_report=r.data;
const dr=await api('/downtime-analysis/detail-by-tab?company='+encodeURIComponent(_co)+'&periodFrom='+_f.periodFrom+'&periodTo='+_f.periodTo);
_detailByTab=dr.data;
const mr=await api('/downtime-analysis/monthly?company='+encodeURIComponent(_co)+'&periodFrom='+_f.periodFrom+'&periodTo='+_f.periodTo);
_monthly=mr.data;
}catch(e){toast(e.message,'err');_report=null;_detailByTab=null;_monthly=null;}
_loading=false;render();
}
function fmtMin0(v){return v==null?'':Math.round(v).toLocaleString('en-IN');}
function monthlySummaryHtml(mo){
if(!mo||!mo.rows.length)return `<div class="empty-msg">No OEE entries found for this period.</div>`;
const rows=mo.rows.map(r=>`<tr>
<td class="num">${r.no}</td>
<td>${esc(r.monthLabel)}</td>
<td class="num">${fmtMin0(r.downMin)}</td>
<td class="num">${fmtMin0(r.runMin)}</td>
<td class="num">${fmtMin0(r.netRunMin)}</td>
<td class="num">${r.pctDown.toFixed(0)}%</td>
</tr>`).join('');
return `<div style="display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap">
<div class="tbl-wrap" style="flex:1;min-width:420px">
<table class="lst">
<thead><tr><th>S.No</th><th>Month</th><th class="num">Total Down Time<br><span style="font-weight:400">MIN</span></th><th class="num">Total M/C Run Time<br><span style="font-weight:400">MIN</span></th><th class="num" style="color:var(--red)">Total Run Time − Total Down Time<br><span style="font-weight:400">MIN</span></th><th class="num">% Of Down Time</th></tr></thead>
<tbody>${rows}
<tr style="background:rgba(245,166,35,.12);font-weight:700">
<td></td><td>TOTAL</td>
<td class="num">${fmtMin0(mo.total.downMin)}</td>
<td class="num">${fmtMin0(mo.total.runMin)}</td>
<td class="num">${fmtMin0(mo.total.netRunMin)}</td>
<td class="num">${mo.total.pctDown.toFixed(0)}%</td>
</tr>
</tbody>
</table>
</div>
<div style="width:110px;height:110px;border-radius:50%;background:var(--red);color:#fff;display:flex;align-items:center;justify-content:center;font-family:var(--display);font-size:32px;flex-shrink:0;margin-top:30px">${mo.total.pctDown.toFixed(0)}</div>
</div>`;
}
// Per-tab shift-level detail table — one row per OEE shift entry, matching
// the Master Cause Sheet's per-form layout: Date/Shift/Shift Length/Equ.Min/
// Gold Std, one column per cause, TOTAL DOWN TIME, % OF DOWN TIME. The TOTAL
// row's % column is the SUM of every row's own % (not a recomputed ratio) —
// matches the screenshot's arithmetic exactly.
function detailByTabCardsHtml(dbt){
const tabKeys=Object.keys(dbt.tabs||{});
if(!tabKeys.length)return '';
return tabKeys.map(tab=>{
const t=dbt.tabs[tab];
if(!t.rows.length)return '';
const causeHead=dbt.causes.map(c=>`<th class="num">${esc(c)}</th>`).join('');
const rowsHtml=t.rows.map(r=>`<tr>
<td>${esc(r.date)}</td>
<td>${esc(r.shift)}</td>
<td class="num">${r.shiftLenHrs}</td>
<td class="num">${num0(r.equMin)}</td>
<td class="num">${r.goldStd||''}</td>
${dbt.causes.map(c=>`<td class="num">${num0(r.causeMins[c]||0)}</td>`).join('')}
<td class="num">${num0(r.totalDownMin)}</td>
<td class="num">${r.pctDown.toFixed(0)}%</td>
</tr>`).join('');
return `<div class="card">
<div class="card-hd"><span>Downtime Analysis — ${esc(t.tabLabel)}</span><span class="right">${t.rows.length} shift(s)</span></div>
<div class="card-bd">
<div class="tbl-wrap"><table class="lst">
<thead><tr>
<th>Date</th><th>Shift</th><th class="num">Shift Length<br>(Hrs)</th><th class="num">Equ. Min</th><th class="num">Prod Rate<br>(Gold Std)</th>
${causeHead}
<th class="num">Total Down<br>Time (Min)</th><th class="num">% Of<br>Down Time</th>
</tr></thead>
<tbody>${rowsHtml}
<tr style="background:rgba(245,166,35,.12);font-weight:700">
<td colspan="3">TOTAL</td>
<td class="num">${num0(t.totals.equMin)}</td>
<td></td>
${dbt.causes.map(c=>`<td class="num">${num0(t.rows.reduce((s,r)=>s+(r.causeMins[c]||0),0))}</td>`).join('')}
<td class="num">${num0(t.totals.totalDownMin)}</td>
<td class="num">${t.totals.pctDownSum.toFixed(0)}%</td>
</tr>
</tbody>
</table></div>
</div>
</div>`;
}).join('');
}
// Highlight rule: a cell is flagged if its % value is at or above 70% of
// that row's highest value among the machine columns (NET excluded from the
// threshold calc, but still highlighted if it also clears it) — a simple,
// row-relative "stands out" rule rather than a fixed universal cutoff, since
// what counts as "high" genuinely differs per cause.
function rowThreshold(machines,getter){
const vals=machines.map(getter).filter(v=>isFinite(v));
if(!vals.length)return Infinity;
return Math.max(...vals)*0.7;
}
// Shared by both the Machine-wise and OEE-Form-wise summary tables — `groups`
// is the list of per-group metric objects (machines, or tabs), `net` the
// combined column, `labelOf` how to read each column's header text.
function summaryTableHtml(causes,groups,net,labelOf){
const cols=[...groups,net];
const causeRows=causes.map(c=>({label:c,get:g=>g.causeTotals[c]||0,getPct:g=>g.causePcts[c]||0}));
const rowsHtml=(label,cells)=>`<tr><td>${esc(label)}</td>${cells}</tr>`;
const headCols=cols.map(g=>`<th class="num">${esc(labelOf(g))}</th>`).join('');
let body='';
body+=`<tr class="sec"><td colspan="${cols.length+1}">1. BASE (WORKING) DAYS USED</td></tr>`;
body+=rowsHtml('Base Days',cols.map(g=>`<td class="num">${num0(g.baseDays)}</td>`).join(''));
body+=rowsHtml('Base Days × 1440 (min)',cols.map(g=>`<td class="num">${num0(g.baseMin)}</td>`).join(''));
body+=`<tr class="sec"><td colspan="${cols.length+1}">2. DOWNTIME BY CAUSE (min / %) — from OEE entries</td></tr>`;
causeRows.forEach(cr=>{
body+=rowsHtml(cr.label,cols.map(g=>`<td class="num">${num0(cr.get(g))}</td>`).join(''));
});
causeRows.forEach(cr=>{
const th=rowThreshold(groups,cr.getPct);
body+=rowsHtml('% '+cr.label,cols.map(g=>{
const v=cr.getPct(g);
return `<td class="num${v>=th?' hi-cell':''}">${pct(v)}</td>`;
}).join(''));
});
body+=`<tr class="sec"><td colspan="${cols.length+1}">3. ACTUAL ACTIVE DAYS</td></tr>`;
body+=rowsHtml('Actual Active Days',cols.map(g=>`<td class="num">${num0(g.actualActiveDays)}</td>`).join(''));
body+=`<tr class="sec"><td colspan="${cols.length+1}">4. TOTAL SHIFT TIME (Scheduled, incl. downtime)</td></tr>`;
body+=rowsHtml('Total Shift Time (min)',cols.map(g=>`<td class="num">${num0(g.totalShiftMin)}</td>`).join(''));
body+=`<tr class="sec"><td colspan="${cols.length+1}">5. DOWNTIME DUE TO PLANT INOPERATION</td></tr>`;
body+=rowsHtml('Minutes (Base Days×1440 − Total Shift Time)',cols.map(g=>`<td class="num">${num0(g.plantInoperationMin)}</td>`).join(''));
body+=rowsHtml('%',cols.map(g=>`<td class="num">${pct(g.plantInoperationPct)}</td>`).join(''));
body+=`<tr class="sec"><td colspan="${cols.length+1}">6. NET DOWNTIME % (Causes Only)</td></tr>`;
body+=rowsHtml('Net Downtime %',cols.map(g=>`<td class="num">${pct(g.netDowntimePct)}</td>`).join(''));
body+=rowsHtml('Total % (Causes + Plant Inoperative)',cols.map(g=>`<td class="num">${pct(g.totalPct)}</td>`).join(''));
body+=rowsHtml('Remainder: Actual Run %',cols.map(g=>`<td class="num">${pct(g.remainderPct)}</td>`).join(''));
return `<div class="tbl-wrap"><table class="lst"><thead><tr><th>Metric</th>${headCols}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function render(){
const app=q('#app');
app.innerHTML=`
<div class="page-head">
<div class="page-icon">⏱</div>
<div><div class="page-title">Downtime Analysis</div><div class="page-sub">Machine-wise downtime by cause, aggregated from OEE entries — matches the Master Cause Sheet layout</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Selection Criteria</span></div>
<div class="card-bd">
<div class="filters">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div><label class="fl">Period From</label><input class="fi" id="f-from" type="date" value="${esc(_f.periodFrom)}"/></div>
<div><label class="fl">Period To</label><input class="fi" id="f-to" type="date" value="${esc(_f.periodTo)}"/></div>
<div style="min-width:110px"><label class="fl">Base Days</label><input class="fi fi-mono" id="f-basedays" type="number" min="1" value="${esc(_f.baseDays)}" placeholder="330"/></div>
<div><button class="btn b-primary" id="btn-run" ${_loading?'disabled':''}>${_loading?'Running…':'Run Report'}</button></div>
</div>
<div class="note">Base Days = the common working-day calendar for this period (set once in Admin → System Settings → Downtime Analysis, or override here). A machine whose own OEE entries only start partway through the period gets this prorated down automatically.</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Downtime Analysis — All Machines</span>${_ran&&!_loading&&_report?`<span class="right">${_report.machines.length} machine(s)</span>`:''}
${_ran&&!_loading&&_report?`<button class="btn b-cancel" id="btn-export" style="margin-left:10px">⬇ Export Excel</button>`:''}
</div>
<div class="card-bd">
${_loading?`<div class="empty-msg"><span class="sp"></span> Running report…</div>`
:!_ran?`<div class="empty-msg">Set the period above and click "Run Report".</div>`
:!_report||!_report.machines.length?`<div class="empty-msg">No OEE entries with a Machine Name found for this period.</div>`
:summaryTableHtml(_report.causes,_report.machines,_report.net,g=>g.machine)}
</div>
</div>
${_ran&&!_loading&&_monthly?`<div class="card">
<div class="card-hd"><span>Monthly Downtime Summary — All Machines</span></div>
<div class="card-bd">${monthlySummaryHtml(_monthly)}</div>
</div>`:''}
${_ran&&!_loading&&_detailByTab?detailByTabCardsHtml(_detailByTab):''}`;
wire();
}
function wire(){
q('#sel-co').onchange=function(){_co=this.value;};
q('#f-from').onchange=function(){_f.periodFrom=this.value;};
q('#f-to').onchange=function(){_f.periodTo=this.value;};
q('#f-basedays').onchange=function(){_f.baseDays=this.value;};
q('#btn-run').onclick=runReport;
if(q('#btn-export'))q('#btn-export').onclick=exportExcel;
}
// ── Excel export — "All Machines" summary sheet (same layout as the on-page
// table) + one sheet per machine (cause breakdown, matching the Master
// Sheet's own per-machine tab layout). ──
const XLSX_BLUE='FF1A6FFF', XLSX_AMBER='FFF5A623', XLSX_INK='FF1E2A44', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFFFF7EC', XLSX_HI='FFF8D7DA', XLSX_SEC='FFEFF4FF';
function xlsxBorder(color){ const b={style:'thin',color:{argb:color||'FFB9CCEA'}}; return {top:b,left:b,bottom:b,right:b}; }
// Builds one "summary" sheet (Base Days / cause mins & %s / active days /
// shift time / plant inoperation / net downtime %) for a given set of
// groups (machines, or OEE tabs) + their combined NET column — shared by
// both the "All Machines" and "By OEE Form" sheets below.
function addSummarySheet(wb,{sheetName,titleText,causes,baseDaysCommon,periodFrom,periodTo,groups,net,labelOf}){
const cols=[...groups,net];
const ws=wb.addWorksheet(sheetName,{views:[{state:'frozen',ySplit:4,xSplit:1}]});
ws.mergeCells(1,1,1,cols.length+1);
const title=ws.getCell(1,1);
title.value=`${titleText} (${periodFrom} to ${periodTo})`;
title.font={bold:true,size:14,color:{argb:XLSX_WHITE}};
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_AMBER}};
ws.getRow(1).height=24;
ws.getRow(2).getCell(1).value=`Base Days (common): ${baseDaysCommon}`;
ws.getRow(2).getCell(1).font={italic:true,color:{argb:'FF64748B'}};
const hdrRowNum=4;
const headLabels=['Metric',...cols.map(labelOf)];
headLabels.forEach((h,i)=>{
const cell=ws.getRow(hdrRowNum).getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
cell.border=xlsxBorder('FF0F172A');
cell.alignment={horizontal:i===0?'left':'right',vertical:'middle'};
});
ws.getRow(hdrRowNum).height=20;
let r=hdrRowNum+1;
const addSection=(label)=>{
ws.mergeCells(r,1,r,cols.length+1);
const c=ws.getRow(r).getCell(1);
c.value=label; c.font={bold:true,color:{argb:'FF1E2A44'}};
c.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_SEC}};
r++;
};
const addRow=(label,vals,fmt,hiMask)=>{
const row=ws.getRow(r);
row.getCell(1).value=label;row.getCell(1).font={bold:true};row.getCell(1).border=xlsxBorder();
vals.forEach((v,i)=>{
const cell=row.getCell(i+2);
cell.value=v; cell.numFmt=fmt||'#,##0';
cell.alignment={horizontal:'right'};
cell.border=xlsxBorder();
if(hiMask&&hiMask[i])cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_HI}};
});
r++;
};
addSection('1. BASE (WORKING) DAYS USED');
addRow('Base Days',cols.map(g=>g.baseDays));
addRow('Base Days × 1440 (min)',cols.map(g=>g.baseMin));
addSection('2. DOWNTIME BY CAUSE (min / %)');
causes.forEach(c=>addRow(c,cols.map(g=>g.causeTotals[c]||0)));
causes.forEach(c=>{
const th=rowThreshold(groups,g=>g.causePcts[c]||0);
const vals=cols.map(g=>(g.causePcts[c]||0)/100);
const mask=cols.map(g=>(g.causePcts[c]||0)>=th);
addRow('% '+c,vals,'0.00%',mask);
});
addSection('3. ACTUAL ACTIVE DAYS');
addRow('Actual Active Days',cols.map(g=>g.actualActiveDays));
addSection('4. TOTAL SHIFT TIME (Scheduled, incl. downtime)');
addRow('Total Shift Time (min)',cols.map(g=>g.totalShiftMin));
addSection('5. DOWNTIME DUE TO PLANT INOPERATION');
addRow('Minutes (Base Days×1440 − Total Shift Time)',cols.map(g=>g.plantInoperationMin));
addRow('%',cols.map(g=>g.plantInoperationPct/100),'0.00%');
addSection('6. NET DOWNTIME % (Causes Only)');
addRow('Net Downtime %',cols.map(g=>g.netDowntimePct/100),'0.00%');
addRow('Total % (Causes + Plant Inoperative)',cols.map(g=>g.totalPct/100),'0.00%');
addRow('Remainder: Actual Run %',cols.map(g=>g.remainderPct/100),'0.00%');
ws.getColumn(1).width=42;
for(let i=2;i<=cols.length+1;i++)ws.getColumn(i).width=16;
}
async function exportExcel(){
const rep=_report;
if(!rep||!rep.machines.length){toast('Nothing to export');return;}
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry');return;}
const wb=new ExcelJS.Workbook();
wb.creator='SAP ERP Portal';wb.created=new Date();
addSummarySheet(wb,{
sheetName:'All Machines',titleText:'Downtime Analysis — All Machines',
causes:rep.causes,baseDaysCommon:rep.baseDaysCommon,periodFrom:rep.periodFrom,periodTo:rep.periodTo,
groups:rep.machines,net:rep.net,labelOf:g=>g.machine,
});
// ── One sheet per machine ──
rep.machines.forEach(m=>{
const safeName=m.machine.replace(/[\\/*?:[\]]/g,' ').slice(0,31)||'Machine';
const mws=wb.addWorksheet(safeName);
mws.mergeCells(1,1,1,2);
const t=mws.getCell(1,1);
t.value=`${m.machine} — Downtime Analysis`;
t.font={bold:true,size:13,color:{argb:XLSX_WHITE}};
t.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_AMBER}};
mws.getRow(1).height=22;
mws.getRow(2).getCell(1).value=`Base days = ${m.baseDays} (${rep.periodFrom} to ${rep.periodTo})${m.firstActiveDate&&m.firstActiveDate>rep.periodFrom?` — prorated from first entry ${m.firstActiveDate}`:''}`;
mws.getRow(2).getCell(1).font={italic:true,color:{argb:'FF64748B'}};
let mr=4;
const mAdd=(label,val,fmt)=>{
mws.getRow(mr).getCell(1).value=label; mws.getRow(mr).getCell(1).border=xlsxBorder();
const c=mws.getRow(mr).getCell(2); c.value=val; c.numFmt=fmt||'#,##0'; c.alignment={horizontal:'right'}; c.border=xlsxBorder();
mr++;
};
const mSection=(label)=>{
mws.mergeCells(mr,1,mr,2);
const c=mws.getRow(mr).getCell(1); c.value=label; c.font={bold:true}; c.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_SEC}};
mr++;
};
mSection('1. BASE (WORKING) DAYS USED');
mAdd('Base Days',m.baseDays);
mAdd('Base Days × 1440 (min)',m.baseMin);
mr++;
mSection('2. DOWNTIME BY CAUSE');
const causeHdr=mws.getRow(mr);
causeHdr.getCell(1).value='Cause';causeHdr.getCell(2).value='Minutes';causeHdr.getCell(3).value='%';
[1,2,3].forEach(ci=>{const c=causeHdr.getCell(ci);c.font={bold:true,color:{argb:XLSX_WHITE}};c.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};c.border=xlsxBorder('FF0F172A');});
mr++;
rep.causes.forEach((c,i)=>{
const row=mws.getRow(mr);
row.getCell(1).value=c;row.getCell(1).border=xlsxBorder();
row.getCell(2).value=m.causeTotals[c]||0;row.getCell(2).numFmt='#,##0';row.getCell(2).border=xlsxBorder();row.getCell(2).alignment={horizontal:'right'};
row.getCell(3).value=(m.causePcts[c]||0)/100;row.getCell(3).numFmt='0.00%';row.getCell(3).border=xlsxBorder();row.getCell(3).alignment={horizontal:'right'};
if(i%2)[1,2,3].forEach(ci=>row.getCell(ci).fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_ZEBRA}});
mr++;
});
mr++;
mSection('3. ACTUAL ACTIVE DAYS (machine ran)');
mAdd('Actual Active Days',m.actualActiveDays);
mSection('4. TOTAL SHIFT TIME (Scheduled, incl. downtime)');
mAdd('Total Shift Time (min)',m.totalShiftMin);
mSection('5. DOWNTIME DUE TO PLANT INOPERATION');
mAdd('Minutes',m.plantInoperationMin);
mAdd('%',m.plantInoperationPct/100,'0.00%');
mSection('6. NET DOWNTIME % (Causes Only)');
mAdd('Net Downtime %',m.netDowntimePct/100,'0.00%');
mAdd('Total % (Causes + Plant Inoperative)',m.totalPct/100,'0.00%');
mAdd('Remainder: Actual Run %',m.remainderPct/100,'0.00%');
mws.getColumn(1).width=42;mws.getColumn(2).width=16;mws.getColumn(3).width=12;
});
// ── Monthly Downtime Summary sheet (all machines combined) ──
if(_monthly&&_monthly.rows.length){
const mo=_monthly;
const mws2=wb.addWorksheet('Monthly Summary',{views:[{state:'frozen',ySplit:3}]});
mws2.mergeCells(1,1,1,6);
const t2=mws2.getCell(1,1);
t2.value=`Monthly Downtime Summary (${mo.periodFrom} to ${mo.periodTo})`;
t2.font={bold:true,size:14,color:{argb:XLSX_WHITE}};
t2.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_AMBER}};
mws2.getRow(1).height=24;
const hdr2=['S.No','Month','Total Down Time (MIN)','Total M/C Run Time (MIN)','Total Run Time − Total Down Time (MIN)','% Of Down Time'];
hdr2.forEach((h,i)=>{
const c=mws2.getRow(3).getCell(i+1);
c.value=h;c.font={bold:true,color:{argb:XLSX_WHITE}};c.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
c.border=xlsxBorder('FF0F172A');c.alignment={horizontal:i<2?'left':'right',vertical:'middle',wrapText:true};
});
mws2.getRow(3).height=28;
let mr2=4;
mo.rows.forEach(r=>{
const row=mws2.getRow(mr2);
row.getCell(1).value=r.no;row.getCell(2).value=r.monthLabel;
row.getCell(3).value=r.downMin;row.getCell(4).value=r.runMin;row.getCell(5).value=r.netRunMin;
row.getCell(6).value=r.pctDown/100;row.getCell(6).numFmt='0%';
[1,2,3,4,5,6].forEach(ci=>{row.getCell(ci).border=xlsxBorder();if(ci>=3)row.getCell(ci).alignment={horizontal:'right'};});
mr2++;
});
const totalRow=mws2.getRow(mr2);
totalRow.getCell(2).value='TOTAL';
totalRow.getCell(3).value=mo.total.downMin;totalRow.getCell(4).value=mo.total.runMin;totalRow.getCell(5).value=mo.total.netRunMin;
totalRow.getCell(6).value=mo.total.pctDown/100;totalRow.getCell(6).numFmt='0%';
[1,2,3,4,5,6].forEach(ci=>{
const c=totalRow.getCell(ci);
c.font={bold:true};c.border=xlsxBorder();c.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FFFFF176'}};
if(ci>=3)c.alignment={horizontal:'right'};
});
mws2.getColumn(1).width=6;mws2.getColumn(2).width=12;
for(let i=3;i<=6;i++)mws2.getColumn(i).width=22;
}
// ── One detail sheet per OEE tab — shift-level rows (Date/Shift/Shift
// Length/Equ.Min/Gold Std/cause columns/Total Down Time/% Down Time),
// matching the on-page detailByTabCardsHtml tables exactly. Placed after
// "Monthly Summary" per request. ──
if(_detailByTab&&Object.keys(_detailByTab.tabs||{}).length){
const dbt=_detailByTab;
Object.keys(dbt.tabs).forEach(tab=>{
const t=dbt.tabs[tab];
if(!t.rows.length)return;
const safeName=('OEE - '+t.tabLabel).replace(/[\\/*?:[\]]/g,' ').slice(0,31);
const dws=wb.addWorksheet(safeName,{views:[{state:'frozen',ySplit:3,xSplit:2}]});
const hdr=['Date','Shift','Shift Length (Hrs)','Equ. Min','Prod Rate (Gold Std)',...dbt.causes,'Total Down Time (Min)','% Of Down Time'];
dws.mergeCells(1,1,1,hdr.length);
const dt=dws.getCell(1,1);
dt.value=`Downtime Analysis — ${t.tabLabel} (${dbt.periodFrom} to ${dbt.periodTo})`;
dt.font={bold:true,size:13,color:{argb:XLSX_WHITE}};
dt.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_AMBER}};
dws.getRow(1).height=22;
hdr.forEach((h,i)=>{
const c=dws.getRow(3).getCell(i+1);
c.value=h;c.font={bold:true,color:{argb:XLSX_WHITE}};c.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
c.border=xlsxBorder('FF0F172A');c.alignment={horizontal:'center',vertical:'middle',wrapText:true};
});
dws.getRow(3).height=30;
let dr=4;
t.rows.forEach((row,i)=>{
const xr=dws.getRow(dr);
xr.getCell(1).value=row.date;xr.getCell(2).value=row.shift;
xr.getCell(3).value=row.shiftLenHrs;xr.getCell(4).value=row.equMin;xr.getCell(5).value=row.goldStd||0;
dbt.causes.forEach((c,ci)=>{xr.getCell(6+ci).value=row.causeMins[c]||0;});
xr.getCell(6+dbt.causes.length).value=row.totalDownMin;
const pc=xr.getCell(7+dbt.causes.length);pc.value=row.pctDown/100;pc.numFmt='0%';
for(let ci=1;ci<=hdr.length;ci++){
const cell=xr.getCell(ci);
cell.border=xlsxBorder();
if(ci>=3)cell.alignment={horizontal:'right'};
if(i%2)cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_ZEBRA}};
}
dr++;
});
const totalRow=dws.getRow(dr);
dws.mergeCells(dr,1,dr,3);
totalRow.getCell(1).value='TOTAL';
totalRow.getCell(4).value=t.totals.equMin;
dbt.causes.forEach((c,ci)=>{
totalRow.getCell(6+ci).value=t.rows.reduce((s,r)=>s+(r.causeMins[c]||0),0);
});
totalRow.getCell(6+dbt.causes.length).value=t.totals.totalDownMin;
const ptc=totalRow.getCell(7+dbt.causes.length);ptc.value=t.totals.pctDownSum/100;ptc.numFmt='0%';
for(let ci=1;ci<=hdr.length;ci++){
const cell=totalRow.getCell(ci);
cell.font={bold:true};cell.border=xlsxBorder();cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FFFFF176'}};
if(ci>=3)cell.alignment={horizontal:'right'};
}
dws.getColumn(1).width=12;dws.getColumn(2).width=10;
for(let ci=3;ci<=hdr.length;ci++)dws.getColumn(ci).width=14;
});
}
const buf=await wb.xlsx.writeBuffer();
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`Downtime_Analysis_${rep.periodFrom}_to_${rep.periodTo}.xlsx`;
a.click();
URL.revokeObjectURL(a.href);
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await loadConfig();
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+1 -1
View File
@@ -82,7 +82,7 @@ tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
+1 -1
View File
@@ -1205,7 +1205,7 @@ function showResp(d){
p.style.display = 'block'; p.style.display = 'block';
} }
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -119,7 +119,7 @@ tbody td.trunc-sm{max-width:110px;overflow:hidden;text-overflow:ellipsis}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
+1 -1
View File
@@ -101,7 +101,7 @@ tbody td.trunc-sm{max-width:110px;overflow:hidden;text-overflow:ellipsis}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
+85 -32
View File
@@ -69,30 +69,33 @@ ol.steps b{color:var(--text)}
<a href="#start">1. Getting Started</a> <a href="#start">1. Getting Started</a>
<a href="#bom">2. BOM Requests</a> <a href="#bom">2. BOM Requests</a>
<a href="#items">3. Items</a> <a href="#items">3. Items</a>
<a href="#workorder">4. Work Order</a> <a href="#invreports">4. Inventory Reports</a>
<a href="#batchissue">5. Batch Issuance</a> <a href="#invtransfer">5. Inventory Transfer</a>
<a href="#requirements">6. Requirements</a> <a href="#workorder">5. Work Order</a>
<a href="#planning">7. Production Planning</a> <a href="#batchissue">6. Batch Issuance</a>
<a href="#prodorder">8. Production Order</a> <a href="#requirements">7. Requirements</a>
<a href="#issue">9. Issue for Production</a> <a href="#planning">8. Production Planning</a>
<a href="#receipt">10. Receipt from Production</a> <a href="#prodorder">9. Production Order</a>
<a href="#verifyprod">11. Verify Production</a> <a href="#deviations">10. Deviations</a>
<a href="#closeprod">12. Close Production</a> <a href="#issue">11. Issue for Production</a>
<a href="#verifywo">13. Verify Work Order</a> <a href="#receipt">12. Receipt from Production</a>
<a href="#manpower">14. Man Power</a> <a href="#verifyprod">13. Verify Production</a>
<a href="#oee">15. OEE</a> <a href="#closeprod">14. Close Production</a>
<a href="#rejection">16. Rejection Register &amp; Analytics</a> <a href="#verifywo">15. Verify Work Order</a>
<a href="#purchase">17. Purchase</a> <a href="#manpower">16. Man Power</a>
<a href="#custvendor">18. Customer / Vendor</a> <a href="#oee">17. OEE</a>
<a href="#approvals">19. Approvals</a> <a href="#rejection">18. Rejection Register &amp; Analytics</a>
<a href="#projects">20. Projects</a> <a href="#purchase">19. Purchase</a>
<a href="#finance">21. Finance</a> <a href="#custvendor">20. Customer / Vendor</a>
<a href="#gst">22. GST Reports</a> <a href="#approvals">21. Approvals</a>
<a href="#masters">23. Business Master &amp; General Ledger</a> <a href="#projects">22. Projects</a>
<a href="#reports">24. Reports &amp; Dashboards</a> <a href="#finance">23. Finance</a>
<a href="#logs">25. Audit Logs &amp; Mail Log</a> <a href="#gst">24. GST Reports</a>
<a href="#documents">26. Documents</a> <a href="#masters">25. Business Master &amp; General Ledger</a>
<a href="#profile">27. Your Profile</a> <a href="#reports">26. Reports &amp; Dashboards</a>
<a href="#logs">27. Audit Logs &amp; Mail Log</a>
<a href="#documents">28. Documents</a>
<a href="#profile">29. Your Profile</a>
</div> </div>
<div> <div>
@@ -112,6 +115,7 @@ ol.steps b{color:var(--text)}
<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> <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> </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> 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> If your company has set up Central Auth, you can also sign in by picking this app from your Central Auth dashboard — no separate password needed here, as long as your Central Auth email exactly matches the email on your profile in this portal.</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 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> </div>
</details> </details>
@@ -135,13 +139,38 @@ ol.steps b{color:var(--text)}
<div class="sec-bd"> <div class="sec-bd">
<ol class="steps"> <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>Create Item</b> to add a new SAP item with its code, description, group and UoM.</li>
<li><b>Purchasing UoM</b> and <b>Sales UoM</b> aren't limited to a fixed list — start typing to pick a common one (NOS, KG, BOX…) or just type any UoM you need; it's saved exactly as entered.</li>
<li>Use <b>View Items</b> to search existing items before creating a duplicate.</li> <li>Use <b>View Items</b> to search existing items before creating a duplicate.</li>
</ol> </ol>
</div> </div>
</details> </details>
<details class="sec" id="invreports">
<summary><span class="sec-ico">📚</span> 4. Inventory Reports <span class="chev">›</span></summary>
<div class="sec-bd">
<ol class="steps">
<li><b>Inventory Status</b> — live on-hand stock per item/warehouse, with the same Item Code/Vendor range filters, sorting and serial-numbered rows as SAP's own report. A negative available quantity shows in red. Export to a styled Excel workbook, 20 rows per page. Vendor is hidden from non-admin users.</li>
<li><b>Posting List</b> — every stock-moving transaction (issues, receipts, transfers, GRPO, invoices, returns…) for a date range, with a running Balance per item, matching SAP B1's own Inventory Posting List. Filter by Item Code range/group/date, toggle Daily/Monthly/Yearly subtotals, and export to Excel.</li>
<li><b>Batch No. Transaction</b> — same layout as SAP's own Batch Number Transactions Report: a top grid of every batch's current stock (Item, Batch, Whse, Quantity, Expiration/Manufacturing Date, Project, Status, COA Status, Sale Type), and below it the full transaction history for whichever batch you click — every document (Goods Receipt, Goods Issue, Sales Order, etc.) that ever moved that batch, with its quantity and In/Out direction. Tick <b>Display All Transactions for Selected (listed) Batches</b> to pull history for every batch currently listed at once. Exports to Excel.</li>
</ol>
</div>
</details>
<details class="sec" id="invtransfer">
<summary><span class="sec-ico">🔁</span> 5. Inventory Transfer <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">Move stock from one warehouse to another — any item, not tied to a Work Order or Production Order. Same document SAP's own Inventory Transfer window posts.</p>
<ol class="steps">
<li>Open <b>Inventory Transfer</b>, pick the <b>From Warehouse</b> and <b>To Warehouse</b>, then click <b>+ Add Item</b> and search for the item code.</li>
<li>Enter the <b>Quantity</b>. If the item is batch-managed, its available batches load automatically and the quantity is <b>pre-selected for you</b> (earliest-expiry-first — same FEFO auto-pick used in Issue for Production) — you can still adjust the split across batches manually if needed.</li>
<li>Add as many item lines as you need, then <b>OK — Post Transfer</b>. This uses <b>your own</b> SAP login (Profile → My SAP Account), so make sure that's set up for whichever company you're posting against.</li>
</ol>
<div class="tip"><b>Tip:</b> The document <b>Series</b> shown at the top-right of the page (e.g. "IC2627") is set once by your admin under System Settings — you don't choose it per transfer.</div>
</div>
</details>
<details class="sec" id="workorder"> <details class="sec" id="workorder">
<summary><span class="sec-ico">📋</span> 4. Work Order <span class="chev">›</span></summary> <summary><span class="sec-ico">📋</span> 5. Work Order <span class="chev">›</span></summary>
<div class="sec-bd"> <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> <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> <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>
@@ -157,6 +186,9 @@ ol.steps b{color:var(--text)}
<div class="tip"><b>Round Up / Exact:</b> each Packing/Component Qty Req. row has its own toggle pill — some items should round up to the next whole unit, others shouldn't, and this is set per row rather than one plant-wide setting.</div> <div class="tip"><b>Round Up / Exact:</b> each Packing/Component Qty Req. row has its own toggle pill — some items should round up to the next whole unit, others shouldn't, and this is set per row rather than one plant-wide setting.</div>
<div class="tip"><b>Intimation No. vs. Reference:</b> a Work Order's card/detail shows both — <b>Intimation No.</b> is the actual linked Batch Intimation (always accurate, can't be hand-edited), while <b>Reference</b> is a free-text field anyone can type over (e.g. a customer/PO reference) — don't assume Reference always matches the Intimation it was generated from.</div> <div class="tip"><b>Intimation No. vs. Reference:</b> a Work Order's card/detail shows both — <b>Intimation No.</b> is the actual linked Batch Intimation (always accurate, can't be hand-edited), while <b>Reference</b> is a free-text field anyone can type over (e.g. a customer/PO reference) — don't assume Reference always matches the Intimation it was generated from.</div>
<div class="tip"><b>Search &amp; filters:</b> the Work Order list has a search box (WO No., product, batch, reference) and All/Pending/Approved/Rejected filter chips, with pagination once the list gets long.</div> <div class="tip"><b>Search &amp; filters:</b> the Work Order list has a search box (WO No., product, batch, reference) and All/Pending/Approved/Rejected filter chips, with pagination once the list gets long.</div>
<div class="tip"><b>Reject &amp; resubmit:</b> a rejected Work Order isn't a dead end — open it, edit whatever needed correcting (including its Batch No., if it's still tied to a Batch Issuance whose linked Work Order was rejected), and saving it both applies your changes and restarts the full approval chain from step 1.</div>
<div class="tip"><b>Send to QA</b> (if granted): a fully approved Work Order can be recalled back to QA for editing — same effect as a rejection, restarting the full chain — via the <b>Send to QA</b> button on its detail page. This normally needs an admin, unless you've been specifically granted the <code>work_order:send_to_qa</code> approval step.</div>
<div class="tip"><b>Item Group visibility (if restricted):</b> if you're restricted to certain Item Groups (Admin → Issue Items), the Work Order list only shows orders whose product is in your allowed groups.</div>
</div> </div>
</details> </details>
@@ -171,6 +203,7 @@ ol.steps b{color:var(--text)}
<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="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 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 class="tip"><b>Multiple Solutions per batch (if enabled):</b> for a product whose BOM has more than one Solution, a single Batch Volume field isn't enough — click <b>+ Solutions</b> to open a modal that auto-detects every Solution in the product's own SAP BOM and lets you enter each one's Volume (Ltr) separately. These carry through exactly into the Work Order's per-Solution Batch Size, instead of one number being applied to every Solution.</div> <div class="tip"><b>Multiple Solutions per batch (if enabled):</b> for a product whose BOM has more than one Solution, a single Batch Volume field isn't enough — click <b>+ Solutions</b> to open a modal that auto-detects every Solution in the product's own SAP BOM and lets you enter each one's Volume (Ltr) separately. These carry through exactly into the Work Order's per-Solution Batch Size, instead of one number being applied to every Solution.</div>
<div class="tip"><b>Batch No. Required (if enabled):</b> if your admin has turned this on, every batch line needs a Batch No./Mfg/Exp filled in before you can save, and it's checked for uniqueness against both other intimations in the portal and SAP's own batch records — a duplicate is rejected with a clear message instead of silently causing a clash later.</div>
</div> </div>
</details> </details>
@@ -214,26 +247,42 @@ ol.steps b{color:var(--text)}
<li>Go to <b>Receipt</b> to receive the finished quantity into stock (section 10).</li> <li>Go to <b>Receipt</b> to receive the finished quantity into stock (section 10).</li>
<li>Once fully received, <b>Transfer to Finished Goods</b> then <b>Close</b> the order.</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>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> <li><b>Raise Deviation</b> — once an order has completed Issuance, you can raise a <b>Shortage</b> (extra quantity of the same item needed), <b>Substitution</b> (replace one component with another), <b>Damage/Loss</b> (write off a damaged quantity) or <b>Addition</b> (add a genuinely new item that was never on the order at all) 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. See the <b>Deviations</b> section next for a single list of every deviation across every order.</li>
<li><b>Cancel Order</b> (if granted) — bypasses all normal Close checks to cancel an order outright. This is irreversible and only available to admins or whoever's been specifically granted the Cancel approval step.</li>
</ol> </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 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 class="tip"><b>Manual Entry item restriction (if set):</b> an admin can limit which SAP Item Groups you're allowed to originate a Manual Entry Production Order for (Admin → user → <b>Manual PWO Item Groups</b>) — separate from the Issue Items restriction. If it's set, the Product No. search only shows items in your permitted groups, and the server independently re-checks on submit either way. No restriction set = you can create for any item, as before.</div> <div class="tip"><b>Manual Entry item restriction (if set):</b> an admin can limit which SAP Item Groups you're allowed to originate a Manual Entry Production Order for (Admin → user → <b>Manual PWO Item Groups</b>) — separate from the Issue Items restriction. If it's set, the Product No. search only shows items in your permitted groups, and the server independently re-checks on submit either way. No restriction set = you can create for any item, as before.</div>
<div class="tip"><b>Warehouse per component:</b> each row in the Components table has its own Warehouse dropdown (both a regular Production Order and a Consumable Order) — set it per component, not once for the whole order, since different components can come from different warehouses.</div>
<div class="tip"><b>Item Group visibility (if restricted):</b> if you're restricted to certain Item Groups (Admin → Issue Items), the Production Order list only shows orders whose main product is in your allowed groups — same restriction that already applied to issuing, now applied to what you can even see in the list.</div>
</div>
</details>
<details class="sec" id="deviations">
<summary><span class="sec-ico">⚠️</span> 10. Deviations <span class="chev">›</span></summary>
<div class="sec-bd">
<p class="lead">A single list of every Shortage/Substitution/Damage/Addition raised across every Production Order — instead of hunting through each order individually.</p>
<ol class="steps">
<li>Open <b>Deviations</b> and filter by Company, Type or QA Status, or search by PWO #, item, raised-by or reason.</li>
<li>Click <b>Open PWO ›</b> on any row to jump straight to that order's detail, where you can approve/reject it if you hold the Deviation approval step (see Production Order → Raise Deviation above).</li>
</ol>
<div class="tip"><b>Tip:</b> This is its own module — ask your admin to grant it if you don't see it in the sidebar yet.</div>
</div> </div>
</details> </details>
<details class="sec" id="issue"> <details class="sec" id="issue">
<summary><span class="sec-ico">📤</span> 9. Issue for Production <span class="chev">›</span></summary> <summary><span class="sec-ico">📤</span> 11. Issue for Production <span class="chev">›</span></summary>
<div class="sec-bd"> <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> <p class="lead">You don't have to issue every component at once — pick exactly what you're issuing today.</p>
<ol class="steps"> <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>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>Each component shows as a card with a <b>checkbox</b> (checked by default), the quantity to issue, warehouse, an <b>Available</b> on-hand figure (red if short of what's needed) 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>✕ 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>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>Adjust quantities/warehouse/batches as needed. Add an optional <b>Remarks</b> note for the whole Issue document — it's saved as the document's Comments in SAP, not just kept in the portal.</li>
<li>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> <li>Items already fully issued show a <span class="badge b-green">✓ Fully Issued</span> badge and can't be changed.</li>
</ol> </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 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 — and orders whose main product is outside your allowed groups won't be pickable at all.</div>
</div> </div>
</details> </details>
@@ -247,6 +296,7 @@ ol.steps b{color:var(--text)}
<li>Submit to post the receipt into SAP.</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> <li>Check <b>Receipts History</b> any time to see everything received against an order.</li>
</ol> </ol>
<div class="warn"><b>Note:</b> If any component is set to auto-consume (Backflush) on this order, the page warns you up front if its warehouse doesn't have enough stock to cover the receipt — before you submit, not as a confusing SAP error afterward. It's informational only; you can still try to post if you believe it's fine.</div>
</div> </div>
</details> </details>
@@ -311,7 +361,7 @@ ol.steps b{color:var(--text)}
<ol class="steps"> <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>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>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>Fill in the downtime/output fields for that shift — Availability, Performance, Quality and the overall <b>OEE %</b> calculate live as you type. Each shift also has its own <b>Remarks</b> field for a free-text note.</li>
<li>Save the shift, then save the whole entry once all shifts for the month are in.</li> <li>Save the shift, then save the whole entry once all shifts for the month are in.</li>
</ol> </ol>
</div> </div>
@@ -492,11 +542,14 @@ document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();docume
start: {always:true}, start: {always:true},
bom: {modules:['bom']}, bom: {modules:['bom']},
items: {modules:['items']}, items: {modules:['items']},
invreports: {modules:['inventory-status-report','inventory-posting-list','batch-no-transaction']},
invtransfer:{modules:['inventory-transfer']},
workorder: {modules:['production-work-order']}, workorder: {modules:['production-work-order']},
batchissue: {modules:['production-batch-issuance']}, batchissue: {modules:['production-batch-issuance']},
requirements:{modules:['production-requirements']}, requirements:{modules:['production-requirements']},
planning: {modules:['production-planning']}, planning: {modules:['production-planning']},
prodorder: {modules:['production-create']}, prodorder: {modules:['production-create']},
deviations: {modules:['production-deviations']},
issue: {modules:['production-issue']}, issue: {modules:['production-issue']},
receipt: {modules:['production-receipt']}, receipt: {modules:['production-receipt']},
verifyprod: {modules:['production-verify']}, verifyprod: {modules:['production-verify']},
@@ -571,6 +624,6 @@ function syncToc(){
window.addEventListener('scroll',syncToc,{passive:true}); window.addEventListener('scroll',syncToc,{passive:true});
syncToc(); syncToc();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+15 -2
View File
@@ -49,6 +49,10 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:1
.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{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:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.field-input::placeholder{color:var(--text4)} .field-input::placeholder{color:var(--text4)}
/* Show/hide password (eye) button */
.pw-wrap{position:relative}.pw-wrap .field-input{padding-right:42px}
.pw-eye{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:32px;height:32px;border:none;background:transparent;cursor:pointer;color:var(--text4);display:flex;align-items:center;justify-content:center;border-radius:6px}
.pw-eye:hover{color:var(--blue);background:rgba(37,99,235,.08)}
.login-row2{display:flex;align-items:center;justify-content:flex-end;margin:-8px 0 18px} .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{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} .forgot-link:hover{color:var(--blue2);text-decoration:underline}
@@ -304,7 +308,8 @@ function buildLogin(){
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">Password</label> <label class="field-label">Password</label>
<input class="field-input" id="l-pass" type="password" placeholder="Enter password" autocomplete="current-password"/> <div class="pw-wrap"><input class="field-input" id="l-pass" type="password" placeholder="Enter password" autocomplete="current-password"/>
<button type="button" class="pw-eye" id="l-eye" title="Show password" aria-label="Show password"></button></div>
</div> </div>
<div class="login-row2"><button type="button" class="forgot-link" id="l-forgot">Forgot password?</button></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> <button class="btn-login" id="l-btn">Sign In</button>
@@ -401,6 +406,14 @@ function buildLogin(){
}; };
wrap.querySelector('#l-btn').onclick=go; wrap.querySelector('#l-btn').onclick=go;
wrap.querySelector('#l-pass').addEventListener('keydown',e=>{if(e.key==='Enter')go();}); wrap.querySelector('#l-pass').addEventListener('keydown',e=>{if(e.key==='Enter')go();});
// Eye button: toggle password visibility (keeps focus/cursor in the field).
(()=>{const pw=wrap.querySelector('#l-pass'),eye=wrap.querySelector('#l-eye');if(!pw||!eye)return;
const EYE='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const OFF='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 19c-6.5 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c6.5 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
eye.innerHTML=EYE;
eye.addEventListener('mousedown',e=>e.preventDefault());
eye.addEventListener('click',()=>{const show=pw.type==='password';pw.type=show?'text':'password';eye.innerHTML=show?OFF:EYE;
eye.title=show?'Hide password':'Show password';eye.setAttribute('aria-label',eye.title);pw.focus();});})();
wrap.querySelector('#l-forgot').onclick=openForgot; wrap.querySelector('#l-forgot').onclick=openForgot;
wrap.querySelector('#fp-overlay').onclick=e=>{if(e.target.id==='fp-overlay')closeForgot();}; wrap.querySelector('#fp-overlay').onclick=e=>{if(e.target.id==='fp-overlay')closeForgot();};
wrap.querySelector('#l-user').focus(); wrap.querySelector('#l-user').focus();
@@ -689,6 +702,6 @@ async function loadPendingCounts(root){
if(window._injectLucide)window._injectLucide(); if(window._injectLucide)window._injectLucide();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+417
View File
@@ -0,0 +1,417 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Inventory Posting List — 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}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(26,111,255,.14);color:var(--blue);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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)}
.fi-mono{font-family:var(--mono)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:130px}
.range-pair{display:flex;align-items:flex-end;gap:6px}
.range-pair span{font-size:11px;color:var(--text3);padding-bottom:8px}
.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)}
.chk-row{display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer;padding:6px 0}
.wh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 14px;max-height:190px;overflow-y:auto;border:1px solid var(--border2);border-radius:8px;padding:8px 10px;background:var(--surface2)}
.wh-toolbar{display:flex;gap:10px;margin-bottom:6px;font-size:10.5px}
.wh-toolbar a{color:var(--blue);cursor:pointer;text-decoration:none}
.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 th.sortable{cursor:pointer;user-select:none}
.lst th.sortable:hover{color:var(--blue)}
.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 tr.subtotal td{background:var(--surface3);font-weight:700;color:var(--text2)}
.lst tbody tr:hover td{background:rgba(26,111,255,.05)}
.mono{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}
.t-ok{background:var(--green);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:65vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
.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:220px;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)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
</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,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function num3(v){if(v==null||v==='')return'';const n=Number(v);if(isNaN(n))return'';return n.toLocaleString('en-IN',{maximumFractionDigits:6});}
function fmtD(v){if(!v)return'—';const d=new Date(v);if(isNaN(d))return'—';return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`;}
function today(){return new Date().toISOString().slice(0,10);}
async function api(p,method,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method:method||'GET',headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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=[];
let _wh=[];
let _selectedWh=new Set();
const _f={itemFrom:'',itemTo:'',itemGroup:'',dateFrom:today(),dateTo:today(),hideNoQtyChange:false,subDaily:true,subMonthly:true,subYearly:true};
let _loading=false,_ran=false,_rows=[],_search='';
let _page=0;
const PAGE_SIZE=50;
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('/sap/lookup/item-groups?company='+encodeURIComponent(_co));_itemGroups=r.data||[];}
catch(_e){_itemGroups=[];}
}
async function loadWarehouses(){
try{const r=await api('/sap/lookup/warehouses?company='+encodeURIComponent(_co));_wh=r.data||[];}
catch(_e){_wh=[];}
}
async function fetchItems(v){
try{const r=await api(`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch(_e){}
return [];
}
let _codeSearchTimer=null;
function wireItemSearch(inputId,ddId,field){
const inp=q('#'+inputId),dd=q('#'+ddId);if(!inp||!dd)return;
inp.oninput=function(){
_f[field]=this.value.toUpperCase();
const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_codeSearchTimer);
_codeSearchTimer=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp"></span></div>';dd.style.display='block';
const items=await fetchItems(v);
if(_f[field]!==this.value.toUpperCase())return;
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</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=()=>{_f[field]=it.ItemCode;inp.value=it.ItemCode;dd.style.display='none';};
dd.appendChild(row);
});
},250);
};
}
document.addEventListener('click',e=>{
if(!e.target.closest('.search-wrap'))document.querySelectorAll('.search-dd').forEach(d=>d.style.display='none');
});
async function runReport(){
if(!_f.dateFrom||!_f.dateTo){toast('Date From/To are required');return;}
if(_f.dateFrom>_f.dateTo){toast('"Date From" cannot be after "Date To"');return;}
_loading=true;_ran=true;_page=0;render();
const qs=new URLSearchParams();
qs.set('company',_co);
if(_f.itemFrom)qs.set('itemFrom',_f.itemFrom);
if(_f.itemTo)qs.set('itemTo',_f.itemTo);
if(_f.itemGroup)qs.set('itemGroup',_f.itemGroup);
qs.set('dateFrom',_f.dateFrom);qs.set('dateTo',_f.dateTo);
if(_f.hideNoQtyChange)qs.set('hideNoQtyChange','1');
if(_selectedWh.size)qs.set('warehouses',[..._selectedWh].join(','));
try{
const r=await api('/inventory-posting-list?'+qs.toString());
_rows=r.data||[];
}catch(e){toast(e.message);_rows=[];}
_loading=false;render();
}
function filteredRows(){
if(!_search.trim())return _rows;
const s=_search.trim().toUpperCase();
return _rows.filter(r=>(r.itemCode||'').toUpperCase().includes(s)||(r.itemName||'').toUpperCase().includes(s));
}
// Inserts a subtotal row (Rec./Iss. Qty sums) whenever the item OR the
// selected bucket(s) change — Daily/Monthly/Yearly toggles, matching SAP's
// own "Display Subtotal" checkboxes. Rows are already server-sorted by
// item then date, so a simple single pass is enough.
function bucketKey(row,gran){
const d=new Date(row.docDate);
if(gran==='d')return row.itemCode+'|'+d.toISOString().slice(0,10);
if(gran==='m')return row.itemCode+'|'+d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
return row.itemCode+'|'+d.getFullYear();
}
function buildDisplayRows(list){
const grans=[];
if(_f.subDaily)grans.push('d');
if(_f.subMonthly)grans.push('m');
if(_f.subYearly)grans.push('y');
if(!grans.length)return list.map(r=>({row:r}));
const out=[];
let prevKeys=null;
let acc=null;
const flush=()=>{
if(!acc)return;
const labels={d:'Daily Subtotal',m:'Monthly Subtotal',y:'Yearly Subtotal'};
grans.forEach(g=>{
out.push({subtotal:true,label:labels[g],inQty:acc[g].inQty,outQty:acc[g].outQty});
});
acc=null;
};
list.forEach(r=>{
const keys=grans.map(g=>bucketKey(r,g));
if(prevKeys&&keys.join('§')!==prevKeys.join('§')){flush();}
if(!acc)acc=Object.fromEntries(grans.map(g=>[g,{inQty:0,outQty:0}]));
grans.forEach(g=>{acc[g].inQty+=r.inQty;acc[g].outQty+=r.outQty;});
out.push({row:r});
prevKeys=keys;
});
flush();
return out;
}
function whToolbar(){
return `<div class="wh-toolbar"><a id="wh-all">Select all</a><a id="wh-none">Clear all</a></div>`;
}
function whGrid(){
return `<div class="wh-grid">${_wh.map(w=>`
<label class="chk-row"><input type="checkbox" class="wh-chk" value="${esc(w.code)}" ${_selectedWh.has(w.code)?'checked':''}/> <span class="mono">${esc(w.code)}</span> — ${esc(w.name||'')}</label>
`).join('')}</div>`;
}
function render(){
const app=q('#app');
const list=filteredRows();
const totalPages=Math.max(1,Math.ceil(list.length/PAGE_SIZE));
if(_page>=totalPages)_page=totalPages-1;
if(_page<0)_page=0;
const pageList=list.slice(_page*PAGE_SIZE,(_page+1)*PAGE_SIZE);
const displayRows=buildDisplayRows(pageList);
app.innerHTML=`
<div class="page-head">
<div class="page-icon">📒</div>
<div><div class="page-title">Inventory Posting List</div><div class="page-sub">Every stock-moving transaction per item, with a running Balance — same as SAP B1's own Inventory Posting List report</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Selection Criteria</span></div>
<div class="card-bd">
<div class="filters" style="margin-bottom:10px">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div><label class="fl">Item Code</label><div class="range-pair">
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-itemfrom" placeholder="Search item code / name…" value="${esc(_f.itemFrom)}" autocomplete="off"/><div class="search-dd" id="dd-itemfrom"></div></div>
<span>to</span>
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-itemto" placeholder="Search item code / name…" value="${esc(_f.itemTo)}" autocomplete="off"/><div class="search-dd" id="dd-itemto"></div></div>
</div></div>
<div><label class="fl">Item Group</label><select class="fi" id="f-itemgroup">${grpOptions(_f.itemGroup)}</select></div>
<div><label class="fl">Date</label><div class="range-pair"><input class="fi" id="f-datefrom" type="date" value="${esc(_f.dateFrom)}"/><span>to</span><input class="fi" id="f-dateto" type="date" value="${esc(_f.dateTo)}"/></div></div>
<div><button class="btn b-primary" id="btn-run" ${_loading?'disabled':''}>${_loading?'Running…':'Run Report'}</button></div>
</div>
<label class="chk-row"><input type="checkbox" id="f-hidenoqty" ${_f.hideNoQtyChange?'checked':''}/> Hide Trans. without Qty Change</label>
<div style="display:flex;gap:16px;margin-top:4px">
<span class="fl" style="margin:0;align-self:center">Display Subtotal:</span>
<label class="chk-row" style="padding:4px 0"><input type="checkbox" id="f-subdaily" ${_f.subDaily?'checked':''}/> Daily</label>
<label class="chk-row" style="padding:4px 0"><input type="checkbox" id="f-submonthly" ${_f.subMonthly?'checked':''}/> Monthly</label>
<label class="chk-row" style="padding:4px 0"><input type="checkbox" id="f-subyearly" ${_f.subYearly?'checked':''}/> Yearly</label>
</div>
<div style="margin-top:10px">
<label class="fl">Warehouses <span style="font-weight:400;text-transform:none;color:var(--text3)">(none selected = all warehouses)</span></label>
${whToolbar()}
${whGrid()}
</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Results</span><span class="right">${_ran&&!_loading?`<span class="count">${list.length} transaction(s)</span>`:''}</span></div>
<div class="card-bd">
<div style="margin-bottom:10px;display:flex;gap:8px;align-items:center">
<input class="fi" id="f-search" placeholder="Item No. / Description…" value="${esc(_search)}" style="max-width:320px"/>
${_ran&&list.length?`<button class="btn b-cancel" id="btn-export" style="margin-left:auto">⬇ Export Excel</button>`:''}
</div>
${_loading?`<div class="empty-msg"><span class="sp"></span> Running report…</div>`
:!_ran?`<div class="empty-msg">Set your filters above and click "Run Report".</div>`
:!list.length?`<div class="empty-msg">No transactions found for these filters.</div>`
:`<div class="tbl-wrap"><table class="lst"><thead><tr>
<th class="num">#</th><th>Posting Date</th><th>Item No.</th><th>Item Description</th><th>Document</th><th>Whse</th>
<th class="num">Doc.Row</th><th>BP Name</th><th class="num">Rec. Qty</th><th class="num">Iss. Qty</th>
<th class="num">Price</th><th class="num">Balance</th><th>Remarks</th><th>Ref. 2</th>
</tr></thead><tbody>${displayRows.map((d,i)=>{
if(d.subtotal)return `<tr class="subtotal"><td colspan="8">${esc(d.label)}</td><td class="num">${num3(d.inQty)}</td><td class="num">${num3(d.outQty)}</td><td colspan="4"></td></tr>`;
const r=d.row;
return `<tr>
<td class="num">${_page*PAGE_SIZE+i+1}</td>
<td>${fmtD(r.docDate)}</td>
<td class="mono">${esc(r.itemCode)}</td>
<td>${esc(r.itemName)}</td>
<td>${esc(r.docLabel)}${r.docRef?' '+esc(r.docRef):''}</td>
<td>${esc(r.warehouse)}</td>
<td class="num">${r.docRow}</td>
<td>${esc(r.bpName)}</td>
<td class="num">${r.inQty?num3(r.inQty):''}</td>
<td class="num">${r.outQty?num3(r.outQty):''}</td>
<td class="num">${num3(r.price)}</td>
<td class="num">${num3(r.balance)}</td>
<td>${esc(r.remarks)}</td>
<td>${esc(r.ref2)}</td>
</tr>`;
}).join('')}</tbody></table></div>
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;justify-content:flex-end">
<span style="font-size:11px;color:var(--text3)">Page ${_page+1} of ${totalPages}</span>
<button class="btn b-cancel" id="btn-prev" ${_page===0?'disabled':''} style="padding:6px 14px;height:auto">‹ Prev</button>
<button class="btn b-cancel" id="btn-next" ${_page>=totalPages-1?'disabled':''} style="padding:6px 14px;height:auto">Next ›</button>
</div>`}
</div>
</div>`;
wire();
}
function wire(){
q('#sel-co').onchange=async function(){_co=this.value;_ran=false;_rows=[];await Promise.all([loadItemGroups(),loadWarehouses()]);_selectedWh=new Set();render();};
wireItemSearch('f-itemfrom','dd-itemfrom','itemFrom');
wireItemSearch('f-itemto','dd-itemto','itemTo');
q('#f-itemgroup').onchange=function(){_f.itemGroup=this.value;};
q('#f-datefrom').onchange=function(){_f.dateFrom=this.value;};
q('#f-dateto').onchange=function(){_f.dateTo=this.value;};
q('#f-hidenoqty').onchange=function(){_f.hideNoQtyChange=this.checked;};
q('#f-subdaily').onchange=function(){_f.subDaily=this.checked;if(_ran)render();};
q('#f-submonthly').onchange=function(){_f.subMonthly=this.checked;if(_ran)render();};
q('#f-subyearly').onchange=function(){_f.subYearly=this.checked;if(_ran)render();};
q('#btn-run').onclick=runReport;
q('#f-search').oninput=function(){
_search=this.value;_page=0;
const pos=this.selectionStart;
render();
const el=q('#f-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}}
};
if(q('#btn-prev'))q('#btn-prev').onclick=()=>{_page--;render();};
if(q('#btn-next'))q('#btn-next').onclick=()=>{_page++;render();};
document.querySelectorAll('.wh-chk').forEach(el=>{
el.onchange=function(){
if(this.checked)_selectedWh.add(this.value);else _selectedWh.delete(this.value);
};
});
q('#wh-all').onclick=()=>{_selectedWh=new Set(_wh.map(w=>w.code));render();};
q('#wh-none').onclick=()=>{_selectedWh=new Set();render();};
if(q('#btn-export'))q('#btn-export').onclick=exportExcel;
}
// Styled .xlsx export via ExcelJS — same look/border convention as the other
// reports in this app (bordered header banner + criteria summary, bordered/
// zebra-striped data grid, frozen header row). Exports exactly what's
// currently filtered on screen (search box), not just the current page.
const XLSX_BLUE='FF1A6FFF', XLSX_INK='FF1E2A44', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFEFF4FF';
function xlsxBorder(color){ const b={style:'thin',color:{argb:color||'FFB9CCEA'}}; return {top:b,left:b,bottom:b,right:b}; }
async function exportExcel(){
const rows=filteredRows();
if(!rows.length){toast('Nothing to export');return;}
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry');return;}
const wb=new ExcelJS.Workbook();
wb.creator='SAP ERP Portal';wb.created=new Date();
const ws=wb.addWorksheet('Inventory Posting List',{views:[{state:'frozen',ySplit:6}]});
ws.mergeCells('A1:N1');
const title=ws.getCell('A1');
title.value='Inventory Posting List — Export';
title.font={bold:true,size:16,color:{argb:XLSX_WHITE}};
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_BLUE}};
title.alignment={vertical:'middle',horizontal:'left'};
ws.getRow(1).height=26;
const whLabel=_selectedWh.size?[..._selectedWh].join(', '):'All';
const metaRows=[
['Company:',_co,'Date:',`${_f.dateFrom} to ${_f.dateTo}`],
['Warehouse(s):',whLabel,'Rows:',rows.length],
];
metaRows.forEach((r,i)=>{
const row=ws.getRow(2+i);
row.getCell(1).value=r[0];row.getCell(1).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(2).value=r[1];row.getCell(2).font={color:{argb:'FF0F172A'}};
row.getCell(3).value=r[2];row.getCell(3).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(4).value=r[3];row.getCell(4).font={color:{argb:'FF0F172A'}};
});
const hdrRowNum=6;
const cols=['#','Posting Date','Item No.','Item Description','Document','Whse','Doc.Row','BP Name','Rec. Qty','Iss. Qty','Price','Balance','Remarks','Ref. 2'];
const hdrRow=ws.getRow(hdrRowNum);
cols.forEach((h,i)=>{
const cell=hdrRow.getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
cell.alignment={vertical:'middle',horizontal:(i===0||(i>=8&&i<=11))?'right':'center'};
cell.border=xlsxBorder('FF0F172A');
});
hdrRow.height=20;
rows.forEach((r,i)=>{
const row=ws.getRow(hdrRowNum+1+i);
const vals=[i+1,fmtD(r.docDate),r.itemCode,r.itemName,`${r.docLabel}${r.docRef?' '+r.docRef:''}`,r.warehouse,r.docRow,r.bpName,r.inQty||'',r.outQty||'',r.price,r.balance,r.remarks,r.ref2];
vals.forEach((v,ci)=>{
const cell=row.getCell(ci+1);
cell.value=v;
cell.border=xlsxBorder();
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
if(ci===0||(ci>=8&&ci<=11)){cell.numFmt=ci===0?'0':'#,##0.######';cell.alignment={horizontal:'right'};}
});
});
ws.columns=[{width:6},{width:12},{width:16},{width:30},{width:20},{width:8},{width:9},{width:22},{width:12},{width:12},{width:12},{width:12},{width:16},{width:14}];
const buf=await wb.xlsx.writeBuffer();
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`Inventory_Posting_List_${_f.dateFrom}_to_${_f.dateTo}.xlsx`;
a.click();
URL.revokeObjectURL(a.href);
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await Promise.all([loadItemGroups(),loadWarehouses()]);
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+408
View File
@@ -0,0 +1,408 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Inventory Status 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:1500px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(26,111,255,.14);color:var(--blue);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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)}
.fi-mono{font-family:var(--mono)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:130px}
.range-pair{display:flex;align-items:flex-end;gap:6px}
.range-pair span{font-size:11px;color:var(--text3);padding-bottom:8px}
.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)}
.chk-row{display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer;padding:6px 0}
.wh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 14px;max-height:190px;overflow-y:auto;border:1px solid var(--border2);border-radius:8px;padding:8px 10px;background:var(--surface2)}
.wh-toolbar{display:flex;gap:10px;margin-bottom:6px;font-size:10.5px}
.wh-toolbar a{color:var(--blue);cursor:pointer;text-decoration:none}
.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:220px;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)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
.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 th.sortable{cursor:pointer;user-select:none}
.lst th.sortable:hover{color:var(--blue)}
.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 td.neg{color:var(--red)!important}
.lst tbody tr:hover td{background:rgba(26,111,255,.05)}
.mono{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}
.t-ok{background:var(--green);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:65vh;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='/';
// Preferred Vendor is only meaningful/visible to Admin & System Admin — same
// sessionStorage user blob auth-guard.js itself already decodes from, no
// extra API round trip needed.
let _role='';
try{const raw=sessionStorage.getItem('portal_user');const u=raw?JSON.parse(raw):null;_role=u&&u.role||'';}catch(_e){}
const CAN_SEE_VENDOR=_role==='admin'||_role==='system_admin';
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,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function num3(v){if(v==null)return'';const n=Number(v);if(isNaN(n))return'';return n.toLocaleString('en-IN',{maximumFractionDigits:6});}
async function api(p,method,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method:method||'GET',headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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=[];
let _wh=[];
let _selectedWh=new Set(); // empty = every warehouse, matching SAP's own "nothing ticked = everything" behavior
const _f={itemFrom:'',itemTo:'',vendorFrom:'',vendorTo:'',itemGroup:'',hideZeroStock:false};
let _loading=false,_ran=false,_rows=[],_search='';
let _page=0;
const PAGE_SIZE=20;
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('/sap/lookup/item-groups?company='+encodeURIComponent(_co));_itemGroups=r.data||[];}
catch(_e){_itemGroups=[];}
}
async function loadWarehouses(){
try{const r=await api('/sap/lookup/warehouses?company='+encodeURIComponent(_co));_wh=r.data||[];}
catch(_e){_wh=[];}
}
// Same code-search-and-pick pattern as New Production Order's own Product
// No. field (public/production.html's wireSearch()/fetchItems()) — shared
// here by both Item Code From/To (against /sap/lookup/items) and Vendor
// From/To (against /sap/lookup/vendors), so each can be searched by
// name too, not just typed exactly, instead of the plain text inputs they
// started as.
async function fetchItems(v){
try{const r=await api(`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch(_e){}
return [];
}
async function fetchVendors(v){
try{const r=await api(`/sap/lookup/vendors?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch(_e){}
return [];
}
let _codeSearchTimer=null;
function wireCodeSearch(inputId,ddId,field,fetchFn,codeKey,nameKey){
const inp=q('#'+inputId),dd=q('#'+ddId);if(!inp||!dd)return;
inp.oninput=function(){
_f[field]=this.value.toUpperCase();
const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_codeSearchTimer);
_codeSearchTimer=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp"></span></div>';dd.style.display='block';
const items=await fetchFn(v);
if(_f[field]!==this.value.toUpperCase())return; // stale — a newer keystroke already superseded this
if(!items.length){dd.innerHTML='<div class="sd-msg">No results</div>';return;}
dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it[codeKey])}</span><span class="sd-name">${esc(it[nameKey])}</span>`;
row.onclick=()=>{_f[field]=it[codeKey];inp.value=it[codeKey];dd.style.display='none';};
dd.appendChild(row);
});
},250);
};
}
document.addEventListener('click',e=>{
if(!e.target.closest('.search-wrap'))document.querySelectorAll('.search-dd').forEach(d=>d.style.display='none');
});
async function runReport(){
_loading=true;_ran=true;_page=0;render();
const qs=new URLSearchParams();
qs.set('company',_co);
if(_f.itemFrom)qs.set('itemFrom',_f.itemFrom);
if(_f.itemTo)qs.set('itemTo',_f.itemTo);
if(_f.vendorFrom)qs.set('vendorFrom',_f.vendorFrom);
if(_f.vendorTo)qs.set('vendorTo',_f.vendorTo);
if(_f.itemGroup)qs.set('itemGroup',_f.itemGroup);
if(_f.hideZeroStock)qs.set('hideZeroStock','1');
if(_selectedWh.size)qs.set('warehouses',[..._selectedWh].join(','));
try{
const r=await api('/inventory-status-report?'+qs.toString());
_rows=r.data||[];
}catch(e){toast(e.message);_rows=[];}
_loading=false;render();
}
let _sortKey='',_sortDir=1; // '' = server order (unsorted); 1=asc, -1=desc
const SORT_COLS={itemCode:'string',itemName:'string',inStock:'num',committed:'num',ordered:'num',available:'num',uom:'string'};
function filteredRows(){
let list=_rows;
if(_search.trim()){
const s=_search.trim().toUpperCase();
list=list.filter(r=>(r.itemCode||'').toUpperCase().includes(s)||(r.itemName||'').toUpperCase().includes(s));
}
if(_sortKey){
const type=SORT_COLS[_sortKey];
list=[...list].sort((a,b)=>{
const av=a[_sortKey],bv=b[_sortKey];
const cmp=type==='num'?(Number(av)||0)-(Number(bv)||0):String(av||'').localeCompare(String(bv||''));
return cmp*_sortDir;
});
}
return list;
}
function sortBy(key){
if(_sortKey===key)_sortDir=-_sortDir;else{_sortKey=key;_sortDir=1;}
_page=0;render();
}
function sortArrow(key){ return _sortKey===key?(_sortDir===1?' ▲':' ▼'):''; }
function whToolbar(){
return `<div class="wh-toolbar"><a id="wh-all">Select all</a><a id="wh-none">Clear all</a></div>`;
}
function whGrid(){
return `<div class="wh-grid">${_wh.map(w=>`
<label class="chk-row"><input type="checkbox" class="wh-chk" value="${esc(w.code)}" ${_selectedWh.has(w.code)?'checked':''}/> <span class="mono">${esc(w.code)}</span> — ${esc(w.name||'')}</label>
`).join('')}</div>`;
}
function render(){
const app=q('#app');
const list=filteredRows();
const totalPages=Math.max(1,Math.ceil(list.length/PAGE_SIZE));
if(_page>=totalPages)_page=totalPages-1;
if(_page<0)_page=0;
const pageList=list.slice(_page*PAGE_SIZE,(_page+1)*PAGE_SIZE);
app.innerHTML=`
<div class="page-head">
<div class="page-icon">📋</div>
<div><div class="page-title">Inventory Status Report</div><div class="page-sub">Per-item In Stock / Committed / Ordered / Available — same as SAP B1's own Inventory Status report</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Selection Criteria</span></div>
<div class="card-bd">
<div class="filters" style="margin-bottom:10px">
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div><label class="fl">Item Code</label><div class="range-pair">
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-itemfrom" placeholder="Search item code / name…" value="${esc(_f.itemFrom)}" autocomplete="off"/><div class="search-dd" id="dd-itemfrom"></div></div>
<span>to</span>
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-itemto" placeholder="Search item code / name…" value="${esc(_f.itemTo)}" autocomplete="off"/><div class="search-dd" id="dd-itemto"></div></div>
</div></div>
${CAN_SEE_VENDOR?`<div><label class="fl">Vendor</label><div class="range-pair">
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-vendorfrom" placeholder="Search vendor code / name…" value="${esc(_f.vendorFrom)}" autocomplete="off"/><div class="search-dd" id="dd-vendorfrom"></div></div>
<span>to</span>
<div class="search-wrap" style="flex:1"><input class="fi fi-mono" id="f-vendorto" placeholder="Search vendor code / name…" value="${esc(_f.vendorTo)}" autocomplete="off"/><div class="search-dd" id="dd-vendorto"></div></div>
</div></div>`:''}
<div><label class="fl">Item Group</label><select class="fi" id="f-itemgroup">${grpOptions(_f.itemGroup)}</select></div>
<div><button class="btn b-primary" id="btn-run" ${_loading?'disabled':''}>${_loading?'Running…':'Run Report'}</button></div>
</div>
<label class="chk-row"><input type="checkbox" id="f-hidezero" ${_f.hideZeroStock?'checked':''}/> Hide Items with No Quantity in Stock</label>
<div style="margin-top:8px">
<label class="fl">Warehouses <span style="font-weight:400;text-transform:none;color:var(--text3)">(none selected = all warehouses)</span></label>
${whToolbar()}
${whGrid()}
</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Results</span><span class="right">${_ran&&!_loading?`<span class="count">${list.length} item(s)</span>`:''}</span></div>
<div class="card-bd">
<div style="margin-bottom:10px;display:flex;gap:8px;align-items:center">
<input class="fi" id="f-search" placeholder="Item No. / Description…" value="${esc(_search)}" style="max-width:320px"/>
${_ran&&list.length?`<button class="btn b-cancel" id="btn-export" style="margin-left:auto">⬇ Export Excel</button>`:''}
</div>
${_loading?`<div class="empty-msg"><span class="sp"></span> Running report…</div>`
:!_ran?`<div class="empty-msg">Set your filters above and click "Run Report".</div>`
:!list.length?`<div class="empty-msg">No items found for these filters.</div>`
:`<div class="tbl-wrap"><table class="lst"><thead><tr>
<th class="num">#</th>
<th class="sortable" data-sort="itemCode">Item No.${sortArrow('itemCode')}</th>
<th class="sortable" data-sort="itemName">Item Description${sortArrow('itemName')}</th>
<th class="num sortable" data-sort="inStock">In Stock${sortArrow('inStock')}</th>
<th class="num sortable" data-sort="committed">Committed${sortArrow('committed')}</th>
<th class="num sortable" data-sort="ordered">Ordered${sortArrow('ordered')}</th>
<th class="num sortable" data-sort="available">Available${sortArrow('available')}</th>
<th class="sortable" data-sort="uom">UoM${sortArrow('uom')}</th>
</tr></thead><tbody>${pageList.map((r,i)=>`
<tr>
<td class="num">${_page*PAGE_SIZE+i+1}</td>
<td class="mono">${esc(r.itemCode)}</td>
<td>${esc(r.itemName)}</td>
<td class="num">${num3(r.inStock)}</td>
<td class="num">${num3(r.committed)}</td>
<td class="num">${num3(r.ordered)}</td>
<td class="num${r.available<0?' neg':''}">${num3(r.available)}</td>
<td>${esc(r.uom)}</td>
</tr>`).join('')}</tbody></table></div>
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;justify-content:flex-end">
<span style="font-size:11px;color:var(--text3)">Page ${_page+1} of ${totalPages}</span>
<button class="btn b-cancel" id="btn-prev" ${_page===0?'disabled':''} style="padding:6px 14px;height:auto">‹ Prev</button>
<button class="btn b-cancel" id="btn-next" ${_page>=totalPages-1?'disabled':''} style="padding:6px 14px;height:auto">Next ›</button>
</div>`}
</div>
</div>`;
wire();
}
function wire(){
q('#sel-co').onchange=async function(){_co=this.value;_ran=false;_rows=[];await Promise.all([loadItemGroups(),loadWarehouses()]);_selectedWh=new Set();render();};
wireCodeSearch('f-itemfrom','dd-itemfrom','itemFrom',fetchItems,'ItemCode','ItemName');
wireCodeSearch('f-itemto','dd-itemto','itemTo',fetchItems,'ItemCode','ItemName');
wireCodeSearch('f-vendorfrom','dd-vendorfrom','vendorFrom',fetchVendors,'CardCode','CardName');
wireCodeSearch('f-vendorto','dd-vendorto','vendorTo',fetchVendors,'CardCode','CardName');
q('#f-itemgroup').onchange=function(){_f.itemGroup=this.value;};
q('#f-hidezero').onchange=function(){_f.hideZeroStock=this.checked;};
q('#btn-run').onclick=runReport;
q('#f-search').oninput=function(){
_search=this.value;_page=0;
const pos=this.selectionStart;
render();
const el=q('#f-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}}
};
if(q('#btn-prev'))q('#btn-prev').onclick=()=>{_page--;render();};
if(q('#btn-next'))q('#btn-next').onclick=()=>{_page++;render();};
document.querySelectorAll('th.sortable').forEach(th=>{th.onclick=()=>sortBy(th.dataset.sort);});
document.querySelectorAll('.wh-chk').forEach(el=>{
el.onchange=function(){
if(this.checked)_selectedWh.add(this.value);else _selectedWh.delete(this.value);
};
});
q('#wh-all').onclick=()=>{_selectedWh=new Set(_wh.map(w=>w.code));render();};
q('#wh-none').onclick=()=>{_selectedWh=new Set();render();};
if(q('#btn-export'))q('#btn-export').onclick=exportExcel;
}
// Styled .xlsx export via ExcelJS — same look/border convention as the PWO
// Source Report's own export (bordered header banner + criteria summary,
// bordered/zebra-striped data grid, frozen header row). Exports exactly
// what's currently filtered on screen (filteredRows()), not the whole
// unfiltered fetch.
const XLSX_BLUE='FF1A6FFF', XLSX_INK='FF1E2A44', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFEFF4FF', XLSX_RED='FFE53E3E';
function xlsxBorder(color){ const b={style:'thin',color:{argb:color||'FFB9CCEA'}}; return {top:b,left:b,bottom:b,right:b}; }
async function exportExcel(){
const rows=filteredRows();
if(!rows.length){toast('Nothing to export');return;}
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry');return;}
const wb=new ExcelJS.Workbook();
wb.creator='SAP ERP Portal';wb.created=new Date();
const ws=wb.addWorksheet('Inventory Status',{views:[{state:'frozen',ySplit:6}]});
ws.mergeCells('A1:H1');
const title=ws.getCell('A1');
title.value='Inventory Status Report — Export';
title.font={bold:true,size:16,color:{argb:XLSX_WHITE}};
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_BLUE}};
title.alignment={vertical:'middle',horizontal:'left'};
ws.getRow(1).height=26;
const whLabel=_selectedWh.size?[..._selectedWh].join(', '):'All';
const metaRows=[
['Company:',_co,'Item Code:',`${_f.itemFrom||'—'} to ${_f.itemTo||'—'}`],
['Warehouse(s):',whLabel,'Rows:',rows.length],
];
metaRows.forEach((r,i)=>{
const row=ws.getRow(2+i);
row.getCell(1).value=r[0];row.getCell(1).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(2).value=r[1];row.getCell(2).font={color:{argb:'FF0F172A'}};
row.getCell(3).value=r[2];row.getCell(3).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(4).value=r[3];row.getCell(4).font={color:{argb:'FF0F172A'}};
});
const hdrRowNum=6;
const cols=['#','Item No.','Item Description','In Stock','Committed','Ordered','Available','UoM'];
const hdrRow=ws.getRow(hdrRowNum);
cols.forEach((h,i)=>{
const cell=hdrRow.getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
cell.alignment={vertical:'middle',horizontal:i===0||(i>=3&&i<=6)?'right':'center'};
cell.border=xlsxBorder('FF0F172A');
});
hdrRow.height=20;
rows.forEach((r,i)=>{
const row=ws.getRow(hdrRowNum+1+i);
const vals=[i+1,r.itemCode,r.itemName,r.inStock,r.committed,r.ordered,r.available,r.uom];
vals.forEach((v,ci)=>{
const cell=row.getCell(ci+1);
cell.value=v;
cell.border=xlsxBorder();
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
if(ci===0)cell.alignment={horizontal:'right'};
if(ci>=3&&ci<=6){
cell.numFmt='#,##0.######';
cell.alignment={horizontal:'right'};
if(ci===6&&r.available<0)cell.font={bold:true,color:{argb:XLSX_RED}};
}
});
});
ws.columns=[{width:6},{width:16},{width:34},{width:14},{width:14},{width:14},{width:14},{width:10}];
const buf=await wb.xlsx.writeBuffer();
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`Inventory_Status_Report_${_co||'all'}.xlsx`;
a.click();
URL.revokeObjectURL(a.href);
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await Promise.all([loadItemGroups(),loadWarehouses()]);
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+343
View File
@@ -0,0 +1,343 @@
<!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>Inventory Transfer — 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;--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}
main{padding:12px;max-width:900px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(6,182,212,.14);color:var(--teal);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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}
.fg{margin-bottom:10px}
.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(--teal)}
.fi-mono{font-family:var(--mono)}
select.fi{cursor:pointer}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.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(--teal);color:#042b33}
.b-primary:disabled{opacity:.5;cursor:not-allowed}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.search-wrap{position:relative}
.search-dd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1px 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: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(6,182,212,.1)}
.sd-code{font-family:var(--mono);font-size:11px;color:var(--teal);font-weight:600;min-width:90px}
.sd-name{font-size:11px;color:var(--text2)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
.line-card{background:var(--surface2);border:1px solid var(--border2);border-radius:9px;padding:10px 12px;margin-bottom:10px}
.line-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.line-num{font-size:11px;font-weight:700;color:var(--text3)}
.line-remove{margin-left:auto;background:none;border:none;color:var(--red);cursor:pointer;font-size:14px;padding:2px 6px}
.line-grid{display:grid;grid-template-columns:2fr 1fr;gap:8px}
.line-item-code{font-family:var(--mono);font-weight:700;color:var(--teal);font-size:12.5px}
.line-item-name{font-size:11px;color:var(--text2);margin-top:1px}
.batch-sec{margin-top:10px;border-top:1px dashed var(--border2);padding-top:8px}
.batch-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);margin-bottom:6px}
.batch-auto{color:var(--teal);font-weight:400;text-transform:none;letter-spacing:0}
.batch-row{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:11.5px}
.batch-num{font-family:var(--mono);color:var(--text);min-width:150px}
.batch-avail{color:var(--text3);min-width:90px}
.batch-inp{width:90px;padding:5px 7px;border:1px solid var(--border2);border-radius:6px;background:var(--surface);color:var(--text);font-family:var(--mono);font-size:11.5px;text-align:right}
.batch-total-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;padding:6px 8px;border-radius:6px}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(6,182,212,.25);border-top-color:var(--teal);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}
.t-ok{background:var(--green);color:#fff}
.success-box{text-align:center;padding:30px 20px}
.success-box .big{font-size:40px;color:var(--green);margin-bottom:10px}
</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,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function fmtQty(n){return(Math.round((parseFloat(n)||0)*1000)/1000).toString();}
async function api(method,p,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method,headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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;
}
// Per-company SAP login (see [[per-company-sap-login]]) — each user can have
// a different SAP User/Password per company, embedded in the JWT. Checked
// client-side BEFORE attempting to post, same pattern as every other SAP-
// posting page (e.g. Receipt from Production), so switching Company here
// re-checks against the newly selected company every time.
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(`No SAP login set for company "${_co}" — open Profile → "My SAP Account" and add one before posting a transfer.`,'err');return false;}return true;}
let _co=window.__DEFAULT_COMPANY__||'';
let _companies=[];
let _wh=[];
let _fromWh='',_toWh='',_postDate='',_remarks='';
let _lines=[]; // [{itemCode,itemName,quantity,batches,batchLoading,selectedBatches}]
let _submitting=false;
let _success=null;
let _seriesInfo=null; // {series,seriesName} — admin-configured document series, resolved to its SAP name
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
function whOptions(sel){return `<option value="">— select —</option>`+_wh.map(w=>`<option value="${esc(w.code)}" ${sel===w.code?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('');}
async function loadWarehouses(){
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 loadSeriesInfo(){
try{const r=await api('GET',`/inventory-transfer/series-info?company=${encodeURIComponent(_co)}`);_seriesInfo=r.success?{series:r.series,seriesName:r.seriesName}:null;}
catch(_e){_seriesInfo=null;}
}
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 [];
}
function addLine(){
_lines.push({itemCode:'',itemName:'',quantity:0,batches:[],batchLoading:false,selectedBatches:[]});
render();
}
function removeLine(i){ _lines.splice(i,1); render(); }
// Same FEFO auto-select pattern as Issue for Production (public/issue-
// production.html's autoSelectBatches) — earliest-expiry batches first,
// greedily filled until the requested quantity is covered.
async function loadBatches(line){
if(!line.itemCode||!_fromWh){line.batches=[];line.selectedBatches=[];return;}
line.batchLoading=true;render();
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(line.itemCode)}&warehouse=${encodeURIComponent(_fromWh)}&company=${encodeURIComponent(_co)}`);
line.batches=r.data||[];
if(line.batches.length) autoSelectBatches(line);
}catch(_e){line.batches=[];}
line.batchLoading=false;render();
}
function autoSelectBatches(line){
let rem=parseFloat(line.quantity)||0;
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;
}
}
let _codeSearchTimer=null;
function wireItemSearch(i){
const inp=q(`#li-item-${i}`),dd=q(`#li-dd-${i}`);if(!inp||!dd)return;
inp.oninput=function(){
const l=_lines[i];
l.itemCode=this.value.toUpperCase();l.itemName='';l.batches=[];l.selectedBatches=[];
const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_codeSearchTimer);
_codeSearchTimer=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp"></span></div>';dd.style.display='block';
const items=await fetchItems(v);
if(l.itemCode!==this.value.toUpperCase())return;
if(!items.length){dd.innerHTML='<div class="sd-msg">No results</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=()=>{
l.itemCode=it.ItemCode;l.itemName=it.ItemName||'';
dd.style.display='none';render();loadBatches(l);
};
dd.appendChild(row);
});
},250);
};
}
document.addEventListener('click',e=>{
if(!e.target.closest('.search-wrap'))document.querySelectorAll('.search-dd').forEach(d=>d.style.display='none');
});
function lineBatchHtml(l,i){
if(l.batchLoading)return `<div class="batch-sec"><span class="sp"></span> Loading batches…</div>`;
if(!l.batches.length)return'';
const total=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
const ok=Math.abs(total-(parseFloat(l.quantity)||0))<=0.001;
return `<div class="batch-sec">
<div class="batch-title">Batches <span class="batch-auto">(auto-selected, FEFO)</span></div>
<div class="batch-total-row" style="border:1px solid ${ok?'rgba(13,187,138,.35)':'rgba(229,62,62,.4)'};background:${ok?'rgba(13,187,138,.08)':'rgba(229,62,62,.08)'}">
<span style="font-size:11px;font-weight:700;color:${ok?'var(--green)':'var(--red)'}">Total</span>
<span style="font-size:12px;font-weight:700;color:${ok?'var(--green)':'var(--red)'}">${fmtQty(total)}${ok?'':` / ${fmtQty(l.quantity)} required`}</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: ${fmtQty(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>`;
}
function render(){
const app=q('#app');
if(_success){
app.innerHTML=`
<div class="page-head">
<div class="page-icon">🔁</div>
<div><div class="page-title">Inventory Transfer</div><div class="page-sub">Move stock between warehouses — same as SAP B1's own Inventory Transfer window</div></div>
</div>
<div class="card"><div class="card-bd success-box">
<div class="big">✓</div>
<div style="font-size:15px;font-weight:700;margin-bottom:6px">Inventory Transfer Posted</div>
<div style="font-size:12px;color:var(--text2);margin-bottom:18px">DocEntry <b>${esc(_success.docEntry)}</b> — DocNum <b>${esc(_success.docNum)}</b><br/>${esc(_fromWh)} → ${esc(_toWh)}</div>
<button class="btn b-primary" id="btn-new">+ New Inventory Transfer</button>
</div></div>`;
q('#btn-new').onclick=()=>{_success=null;_fromWh='';_toWh='';_postDate='';_remarks='';_lines=[];render();};
return;
}
const totalQty=_lines.reduce((s,l)=>s+(parseFloat(l.quantity)||0),0);
app.innerHTML=`
<div class="page-head">
<div class="page-icon">🔁</div>
<div><div class="page-title">Inventory Transfer</div><div class="page-sub">Move stock between warehouses — batches auto-selected (FEFO) once issued, same as Issue for Production</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Transfer Details</span>${_seriesInfo?.seriesName?`<span class="right">Series: <b style="color:var(--teal)">${esc(_seriesInfo.seriesName)}</b></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="fi-row">
<div class="fg"><label class="fl">From Warehouse *</label><select class="fi" id="sel-fromwh">${whOptions(_fromWh)}</select></div>
<div class="fg"><label class="fl">To Warehouse *</label><select class="fi" id="sel-towh">${whOptions(_toWh)}</select></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Posting Date</label><input class="fi" id="inp-postdate" type="date" value="${esc(_postDate)}"/></div>
<div class="fg"><label class="fl">Remarks</label><input class="fi" id="inp-remarks" value="${esc(_remarks)}" placeholder="Optional"/></div>
</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Items</span><span class="right">${_lines.length} line(s)${totalQty?` · Total Qty ${fmtQty(totalQty)}`:''}</span></div>
<div class="card-bd">
${!_lines.length?`<div class="empty-msg">No items added yet.</div>`:
_lines.map((l,i)=>`
<div class="line-card">
<div class="line-top"><span class="line-num">#${i+1}</span><button type="button" class="line-remove" data-remove="${i}" title="Remove">✕</button></div>
<div class="line-grid">
<div class="search-wrap">
<label class="fl">Item Code</label>
<input class="fi fi-mono" id="li-item-${i}" value="${esc(l.itemCode)}" placeholder="Search item code / name…" autocomplete="off"/>
<div class="search-dd" id="li-dd-${i}"></div>
${l.itemName?`<div class="line-item-name">${esc(l.itemName)}</div>`:''}
</div>
<div><label class="fl">Quantity</label><input class="fi fi-mono" data-qty="${i}" type="number" min="0.001" step="0.001" value="${l.quantity||''}" style="text-align:right"/></div>
</div>
${lineBatchHtml(l,i)}
</div>`).join('')}
<button type="button" class="btn b-cancel" id="btn-add-line">+ Add Item</button>
</div>
</div>
<div class="card"><div class="card-bd" style="display:flex;gap:10px">
<button class="btn b-primary" id="btn-ok" style="flex:1" ${_submitting?'disabled':''}>${_submitting?'<span class="sp"></span> Posting…':'OK — Post Transfer'}</button>
<button class="btn b-cancel" id="btn-cancel" style="flex:1">Cancel</button>
</div></div>`;
wire();
}
function wire(){
if(_success)return;
q('#sel-co').onchange=async function(){_co=this.value;_wh=[];_fromWh='';_toWh='';_lines=[];await Promise.all([loadWarehouses(),loadSeriesInfo()]);render();};
q('#sel-fromwh').onchange=function(){_fromWh=this.value;_lines.forEach(l=>{l.batches=[];l.selectedBatches=[];if(l.itemCode)loadBatches(l);});render();};
q('#sel-towh').onchange=function(){_toWh=this.value;};
q('#inp-postdate').onchange=function(){_postDate=this.value;};
q('#inp-remarks').oninput=function(){_remarks=this.value;};
q('#btn-add-line').onclick=addLine;
q('#btn-cancel').onclick=()=>{_fromWh='';_toWh='';_postDate='';_remarks='';_lines=[];render();};
q('#btn-ok').onclick=doTransfer;
document.querySelectorAll('[data-remove]').forEach(btn=>{btn.onclick=()=>removeLine(parseInt(btn.dataset.remove));});
document.querySelectorAll('[data-qty]').forEach(inp=>{
inp.onchange=function(){
const i=parseInt(this.dataset.qty);
_lines[i].quantity=parseFloat(this.value)||0;
if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
render();
};
});
document.querySelectorAll('.batch-inp').forEach(inp=>{
inp.onchange=function(){
const li=parseInt(this.dataset.li),bn=this.dataset.bn;
const qty=parseFloat(this.value)||0;
const l=_lines[li];
const existing=l.selectedBatches.find(b=>b.BatchNumber===bn);
if(existing)existing.Quantity=qty;
else if(qty>0)l.selectedBatches.push({BatchNumber:bn,Quantity:qty});
render();
};
});
_lines.forEach((_,i)=>wireItemSearch(i));
}
async function doTransfer(){
if(!_fromWh||!_toWh){toast('Select From and To Warehouse','err');return;}
if(_fromWh===_toWh){toast('From and To Warehouse must be different','err');return;}
const valid=_lines.filter(l=>l.itemCode&&(parseFloat(l.quantity)||0)>0);
if(!valid.length){toast('Add at least one item with a quantity','err');return;}
for(const l of valid){
if(l.batches.length){
const total=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
if(Math.abs(total-l.quantity)>0.001){toast(`${l.itemCode}: batch qty (${fmtQty(total)}) must equal quantity (${fmtQty(l.quantity)})`,'err');return;}
}
}
if(!requireSapLogin())return;
_submitting=true;render();
try{
const payload={
company:_co,fromWarehouse:_fromWh,toWarehouse:_toWh,
postingDate:_postDate||undefined,comments:_remarks||undefined,
lines:valid.map(l=>({itemCode:l.itemCode,quantity:l.quantity,batchNumbers:l.selectedBatches})),
};
const r=await api('POST','/inventory-transfer',payload);
_success={docEntry:r.data?.DocEntry||'',docNum:r.data?.DocNum||''};
toast('Inventory Transfer posted!','ok');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await Promise.all([loadWarehouses(),loadSeriesInfo()]);
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+148 -17
View File
@@ -188,10 +188,12 @@ let _issueGroups=null; // this user's allowed Item Groups for issuing (null =
let _loadingDetail=false; let _loadingDetail=false;
let _submitting=false; let _submitting=false;
let _success=null; let _success=null;
let _remarks=''; // free-text note for this Issue document — saved to SAP as its Comments
let _readyOrders=[]; let _readyOrders=[];
let _readyLoading=false; let _readyLoading=false;
let _modalOpen=false; let _modalOpen=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
let _dateFrom='',_dateTo=''; // Due Date range, sent to the server (see loadReadyOrders())
// A user who can act on Consumable Orders but does NOT hold the general // A user who can act on Consumable Orders but does NOT hold the general
// Issuance approval step must never see (or be able to submit for) a normal // Issuance approval step must never see (or be able to submit for) a normal
// PWO here — force the list to Consumable-only for them, with no toggle // PWO here — force the list to Consumable-only for them, with no toggle
@@ -234,9 +236,17 @@ async function loadReadyOrders(resetPage){
_readyLoading=true;render(); _readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:''; const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{ try{
const r=await api('GET',`/sap/production-orders?status=Released&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`); // readyToIssue=1 tells the server to filter OUT already-fully-issued
// orders itself, BEFORE paginating — it used to be filtered here
// client-side, AFTER the server had already handed back one fixed page
// of raw "Released" orders, which meant a page whose raw orders all
// happened to be already fully issued rendered completely empty while
// the pager still counted every Released order regardless — real data
// ended up scattered across seemingly random pages with long gaps.
const dateQ=(_dateFrom?`&dateFrom=${_dateFrom}`:'')+(_dateTo?`&dateTo=${_dateTo}`:'');
const r=await api('GET',`/sap/production-orders?status=Released&readyToIssue=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${dateQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
_rawCount=(r.data||[]).length; _rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>o._issueStatus!=='full'); _readyOrders=r.data||[];
_total=(typeof r.total==='number')?r.total:null; _total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;} }catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render(); _readyLoading=false;render();
@@ -247,7 +257,7 @@ async function loadBranches(){try{const r=await api('GET',`/sap/lookup/branches?
async function selectOrder(absEntry){ async function selectOrder(absEntry){
_modalOpen=true;_loadingDetail=true;_selectedOrder=null;_lines=[];_removedLines=[];render(); _modalOpen=true;_loadingDetail=true;_selectedOrder=null;_lines=[];_removedLines=[];_remarks='';render();
try{ try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`); const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
const d=r.data||r; const d=r.data||r;
@@ -315,6 +325,38 @@ async function selectOrder(absEntry){
await Promise.all(_lines.map(l=>loadBatches(l))); await Promise.all(_lines.map(l=>loadBatches(l)));
}catch(e){toast(e.message,'err');} }catch(e){toast(e.message,'err');}
_loadingDetail=false;render(); _loadingDetail=false;render();
loadStockForLines();
}
// "Available" on-hand stock (OITW) shown next to each component — same
// pattern/endpoint as New Production Order's own Available Qty column
// (public/production.html's loadStockForLines()/stockFor()), grouped/
// fetched per distinct warehouse among the current lines (usually just one
// call). Non-blocking: patches _stockMap then patches just the modal, never
// gates anything — this is informational only, not a hard stock check.
let _stockMap={}; // `${itemCode}|${warehouse}` -> on-hand qty
async function loadStockForLines(){
const groups={};
_lines.forEach(l=>{
if(!l.itemCode||l.itemType==='pit_Resource')return;
const wh=l.warehouse||'';
(groups[wh]=groups[wh]||new Set()).add(l.itemCode.trim().toUpperCase());
});
const entries=Object.entries(groups);
if(!entries.length)return;
await Promise.all(entries.map(async([wh,codesSet])=>{
const codes=[...codesSet];
try{
const r=await api('GET',`/sap/lookup/item-stock?codes=${encodeURIComponent(codes.join(','))}${wh?'&warehouse='+encodeURIComponent(wh):''}&company=${encodeURIComponent(_co)}`);
if(r.success&&r.data)codes.forEach(c=>{_stockMap[`${c}|${wh}`]=r.data[c]||0;});
}catch(_e){/* non-fatal — Available just stays blank for these */}
}));
if(_modalOpen&&_selectedOrder)renderModal();
}
function stockFor(l){
const wh=l.warehouse||'';
const key=`${(l.itemCode||'').trim().toUpperCase()}|${wh}`;
return key in _stockMap ? _stockMap[key] : null;
} }
async function loadBatches(line){ async function loadBatches(line){
@@ -355,11 +397,13 @@ async function doIssue(){
_submitting=true;render(); _submitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2; const bplId=parseInt(q('#sel-branch')?.value)||2;
const postDate=q('#inp-postdate')?.value||''; const postDate=q('#inp-postdate')?.value||'';
const remarks=(_remarks||'').trim();
const payload={ const payload={
company:_co, company:_co,
branchId:bplId, branchId:bplId,
}; };
if(postDate){payload.DocDate=postDate;payload.DocDueDate=postDate;} if(postDate){payload.DocDate=postDate;payload.DocDueDate=postDate;}
if(remarks){payload.Comments=remarks;}
payload.DocumentLines=issuable.map(l=>{ payload.DocumentLines=issuable.map(l=>{
const dl={BaseEntry:_selectedOrder.AbsoluteEntry,BaseLine:l.lineNum,BaseType:202,ItemCode:l.itemCode,Quantity:l.issueQty,WarehouseCode:l.warehouse}; 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.costingCode) dl.CostingCode=l.costingCode;
@@ -376,13 +420,29 @@ async function doIssue(){
_submitting=false;render(); _submitting=false;render();
} }
// Order is already 100% issued in SAP (confirmed live, not just assumed from
// what's displayed) but the portal's own approval chain never recorded it —
// see the long comment on POST /sap/confirm-issued-outside-portal for why
// this can't just silently auto-advance and why it's not a repost.
async function doConfirmIssued(){
if(!_selectedOrder)return;
if(!requireSapLogin())return;
_submitting=true;render();
try{
await api('POST','/sap/confirm-issued-outside-portal',{absEntry:_selectedOrder.AbsoluteEntry,company:_co});
_success={confirmed:true};
toast('Issuance confirmed — Receipt is now unblocked');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// RENDER // RENDER
// ═══════════════════════════════════════ // ═══════════════════════════════════════
function 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>`; } 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 closeIssModal(){_modalOpen=false;_selectedOrder=null;_lines=[];_removedLines=[];_success=null;_remarks='';render();loadReadyOrders();}
function _doRender(){ function _doRender(){
const app=document.getElementById('app');app.innerHTML=''; const app=document.getElementById('app');app.innerHTML='';
@@ -418,11 +478,15 @@ function _doRender(){
<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>':''}${o.IsConsumable?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple)">CONSUMABLE</span>':''}</div> <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>':''}${o.IsConsumable?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple)">CONSUMABLE</span>':''}</div>
<div class="po-icon">📦</div> <div class="po-icon">📦</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</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> <div class="po-meta"><span>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>WH <b>${esc(o.Warehouse||'-')}</b>${o.DueDate?` · Due <b>${new Date(o.DueDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</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>`:''} ${(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>`; </div>`).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px"> const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
<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"/> <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;min-width:220px"/>
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;align-self:center">Due</span>
<input class="fi" id="rp-datefrom" type="date" value="${esc(_dateFrom)}" style="font-size:12px"/>
<span style="font-size:11px;color:var(--text3);align-self:center">to</span>
<input class="fi" id="rp-dateto" type="date" value="${esc(_dateTo)}" style="font-size:12px"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--teal);color:#fff;white-space:nowrap">Search</button> <button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--teal);color:#fff;white-space:nowrap">Search</button>
</div>`; </div>`;
const typeRow=CONSUMABLE_ONLY_USER const typeRow=CONSUMABLE_ONLY_USER
@@ -431,7 +495,7 @@ function _doRender(){
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span> <span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span>
${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')} ${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')}
</div>`:''; </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}${typeRow}${gridBody}${renderPager()}</div>`; c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready for Issuance<span class="right">${_total!=null?_total:_readyOrders.length}</span></div><div class="card-bd">${searchRow}${typeRow}${gridBody}${renderPager()}</div>`;
app.appendChild(c2b); app.appendChild(c2b);
renderModal(); renderModal();
@@ -440,6 +504,8 @@ function _doRender(){
setTimeout(()=>{ setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=async function(){_co=this.value;await Promise.all([loadWH(),loadBranches(),loadVarieties()]);loadReadyOrders(true);}); 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').oninput=function(){_search=this.value;});
q('#rp-datefrom')&&(q('#rp-datefrom').onchange=function(){_dateFrom=this.value;loadReadyOrders(true);});
q('#rp-dateto')&&(q('#rp-dateto').onchange=function(){_dateTo=this.value;loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadReadyOrders(true);}}); 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-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);}); document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);});
@@ -460,7 +526,17 @@ function renderModal(){
bg.classList.add('show'); bg.classList.add('show');
if(_success){ if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Issue for Production — Posted</span><button class="modal-close" id="iss-modal-x">✕</button></div> inner.innerHTML=_success.confirmed
?`<div class="modal-hd"><span>Issuance Confirmed</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">Recorded in the Portal</div>
<p style="font-size:13px;color:var(--text3)">Production Order #${esc(String(_selectedOrder?.DocumentNumber||''))} — Issuance approval recorded. Nothing new was posted to SAP (it was already fully issued there); Receipt is now unblocked.</p>
<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>`
:`<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="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div> <div class="suc-icon">✓</div>
<div class="suc-title">Issue Posted!</div> <div class="suc-title">Issue Posted!</div>
@@ -498,7 +574,13 @@ function renderModal(){
if(!inactive&&l.batchLoading){ 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>'; 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){ }else if(!inactive&&l.batches.length){
const batchTotal0=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
const batchOk0=Math.abs(batchTotal0-l.issueQty)<=0.001;
batchHtml=`<div class="batch-sec"><div class="batch-title">Batches <span class="batch-auto">(auto-selected)</span></div> batchHtml=`<div class="batch-sec"><div class="batch-title">Batches <span class="batch-auto">(auto-selected)</span></div>
<div class="batch-total-row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;padding:6px 8px;border-radius:6px;border:1px solid ${batchOk0?'rgba(13,187,138,.35)':'rgba(220,38,38,.4)'};background:${batchOk0?'rgba(13,187,138,.08)':'rgba(220,38,38,.08)'}">
<span style="font-size:11px;font-weight:700;color:${batchOk0?'var(--green)':'#dc2626'}">Total Qty</span>
<span id="batch-total-${i}" data-required="${l.issueQty}" style="font-size:12px;font-weight:700;color:${batchOk0?'var(--green)':'#dc2626'}" title="${batchOk0?'':`Must equal Issue Qty (${l.issueQty}) — this item's available stock in this warehouse may be short`}">${batchTotal0}${batchOk0?'':` / ${l.issueQty} required`}</span>
</div>
${l.batches.map((b,bi)=>{ ${l.batches.map((b,bi)=>{
const sel=l.selectedBatches.find(s=>s.BatchNumber===b.BatchNumber); const sel=l.selectedBatches.find(s=>s.BatchNumber===b.BatchNumber);
const qty=sel?sel.Quantity:0; const qty=sel?sel.Quantity:0;
@@ -524,6 +606,7 @@ function renderModal(){
<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">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">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">Remaining</span><span class="comp-fv" style="color:${l.remainingQty>0?'var(--amber)':'var(--green)'}">${l.remainingQty}</span></div>
${(()=>{const avail=stockFor(l);const short=avail!=null&&avail<l.remainingQty-0.0001;return `<div class="comp-field"><span class="comp-fl">Available</span><span class="comp-fv" style="color:${avail==null?'var(--text3)':short?'var(--red)':'var(--green)'}" title="${avail==null?'Loading…':short?'Short of Remaining qty':'On hand ≥ Remaining qty'}">${avail==null?'…':avail}</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"><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 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> </div>
@@ -551,10 +634,22 @@ function renderModal(){
// user just has nothing left THEY'RE permitted to act on. Never claim // 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 // "fully issued" when that's true — it's misleading and could make
// someone think the order needs no further attention from anyone. // someone think the order needs no further attention from anyone.
// All lines fully issued in SAP, but the portal's own approval chain never
// recorded it (stage still 1) — this order was issued directly in SAP B1,
// bypassing the portal entirely. Nothing left to post (re-posting would
// double-issue real stock), so offer a "Confirm" action that just records
// the approval + advances the local stage — see /sap/confirm-issued-outside-portal.
const needsConfirm=allIssued&&!_hasHiddenPending&&d._localStage===1;
const footer=allIssued const footer=allIssued
?(_hasHiddenPending ?(_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="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="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`
:needsConfirm
?`<div class="alert al-green" style="margin:0">All components have been fully issued for this production order in SAP — but this wasn't recorded as an Issuance approval in the portal, so Receipt is still blocked. Someone assigned to this order's Issuance step needs to confirm it before Receipt can proceed.</div>
<div class="btn-bar" style="margin-top:8px">
<button class="btn b-issue" id="btn-confirm-issued" ${_submitting?'disabled':''}><span id="sp-confirm" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Confirm Issued in SAP</button>
<button class="btn b-cancel" id="btn-clear">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="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-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`)
:`<div class="btn-bar" style="margin-top:8px"> :`<div class="btn-bar" style="margin-top:8px">
@@ -562,6 +657,11 @@ function renderModal(){
<button class="btn b-cancel" id="btn-clear">Clear</button> <button class="btn b-cancel" id="btn-clear">Clear</button>
</div>`; </div>`;
// Preserve the modal's own scroll position across a rebuild — e.g.
// changing a component's Warehouse re-renders this whole block (batches
// depend on it), and without this the modal would jump back to the top
// every time, losing your place among a long component list.
const savedBdScroll=inner.querySelector('.modal-bd')?.scrollTop||0;
inner.innerHTML=` inner.innerHTML=`
<div class="modal-hd"> <div class="modal-hd">
<span style="font-family:var(--mono);color:var(--teal)">${esc(d.ItemNo)}</span> <span style="font-family:var(--mono);color:var(--teal)">${esc(d.ItemNo)}</span>
@@ -581,16 +681,22 @@ function renderModal(){
${removedStrip} ${removedStrip}
${toolbar} ${toolbar}
${_lines.length?linesHtml:'<div class="empty-msg">No component lines</div>'} ${_lines.length?linesHtml:'<div class="empty-msg">No component lines</div>'}
${!allIssued?`<div class="fg" style="margin-top:10px"><label class="fl">Remarks</label><input class="fi" id="inp-remarks" type="text" maxlength="254" placeholder="Optional note for this Issue document" value="${esc(_remarks)}"/></div>`:''}
${footer} ${footer}
</div>`; </div>`;
const newBd=inner.querySelector('.modal-bd');
if(newBd)newBd.scrollTop=savedBdScroll;
setTimeout(()=>{ setTimeout(()=>{
q('#iss-modal-x').onclick=closeIssModal; q('#iss-modal-x').onclick=closeIssModal;
wireLineInputs(); wireLineInputs();
wireBatchInputs(); wireBatchInputs();
wireSelectionControls(); wireSelectionControls();
q('#btn-issue')&&(q('#btn-issue').onclick=doIssue); q('#btn-issue')&&(q('#btn-issue').onclick=doIssue);
q('#btn-confirm-issued')&&(q('#btn-confirm-issued').onclick=doConfirmIssued);
q('#btn-clear')&&(q('#btn-clear').onclick=closeIssModal); q('#btn-clear')&&(q('#btn-clear').onclick=closeIssModal);
q('#inp-remarks')&&(q('#inp-remarks').oninput=function(){_remarks=this.value;});
},0); },0);
} }
@@ -611,7 +717,7 @@ function wireSelectionControls(){
l._savedQty=l.issueQty; l._savedQty=l.issueQty;
l.issueQty=0; l.issueQty=0;
} }
render(); renderModal();
}; };
}); });
document.querySelectorAll('[data-remove-i]').forEach(el=>{ document.querySelectorAll('[data-remove-i]').forEach(el=>{
@@ -619,21 +725,21 @@ function wireSelectionControls(){
const i=parseInt(this.dataset.removeI); const i=parseInt(this.dataset.removeI);
const removed=_lines.splice(i,1)[0]; const removed=_lines.splice(i,1)[0];
if(removed)_removedLines.push(removed); if(removed)_removedLines.push(removed);
render(); renderModal();
}; };
}); });
q('#btn-select-all')&&(q('#btn-select-all').onclick=()=>{ 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); } }); _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(); renderModal();
}); });
q('#btn-select-none')&&(q('#btn-select-none').onclick=()=>{ 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; } }); _lines.forEach(l=>{ if(l.remainingQty>0&&l.selected){ l._savedQty=l.issueQty; l.selected=false; l.issueQty=0; } });
render(); renderModal();
}); });
q('#btn-restore-removed')&&(q('#btn-restore-removed').onclick=()=>{ q('#btn-restore-removed')&&(q('#btn-restore-removed').onclick=()=>{
_lines.push(..._removedLines); _lines.push(..._removedLines);
_removedLines=[]; _removedLines=[];
render(); renderModal();
}); });
} }
@@ -645,11 +751,15 @@ function wireLineInputs(){
_lines[i].warehouse=this.value; _lines[i].warehouse=this.value;
// Reload batches for new warehouse // Reload batches for new warehouse
_lines[i].batches=[];_lines[i].selectedBatches=[]; _lines[i].batches=[];_lines[i].selectedBatches=[];
loadBatches(_lines[i]).then(()=>render()); // renderModal() only — NOT the full page render(), which rebuilds
// the "Orders Ready" grid too and resets window scroll on top of
// the modal's own scroll, doubling the jump-to-top effect.
loadBatches(_lines[i]).then(()=>renderModal());
loadStockForLines();
}else if(f==='issueQty'){ }else if(f==='issueQty'){
_lines[i].issueQty=parseFloat(this.value)||0; _lines[i].issueQty=parseFloat(this.value)||0;
if(_lines[i].batches.length) autoSelectBatches(_lines[i]); if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
render(); renderModal();
}else if(f==='costingCode'){ }else if(f==='costingCode'){
_lines[i].costingCode=this.value;return; _lines[i].costingCode=this.value;return;
} }
@@ -670,6 +780,27 @@ function wireBatchInputs(){
else if(qty>0) line.selectedBatches.push({BatchNumber:bn,Quantity:qty}); else if(qty>0) line.selectedBatches.push({BatchNumber:bn,Quantity:qty});
// Remove zeros // Remove zeros
line.selectedBatches=line.selectedBatches.filter(b=>b.Quantity>0); line.selectedBatches=line.selectedBatches.filter(b=>b.Quantity>0);
// Patch the Total Qty badge directly instead of a full render() — same
// convention as every other live input in this app (avoids losing
// focus/scroll position mid-edit). See the sibling batch-total-row
// markup this mirrors.
const badge=document.getElementById(`batch-total-${li}`);
if(badge){
const total=line.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
const required=parseFloat(badge.dataset.required)||0;
const ok=Math.abs(total-required)<=0.001;
const color=ok?'var(--green)':'#dc2626';
badge.textContent=ok?String(total):`${total} / ${required} required`;
badge.style.color=color;
badge.title=ok?'':`Must equal Issue Qty (${required}) — this item's available stock in this warehouse may be short`;
const row=badge.closest('.batch-total-row');
if(row){
row.style.borderColor=ok?'rgba(13,187,138,.35)':'rgba(220,38,38,.4)';
row.style.background=ok?'rgba(13,187,138,.08)':'rgba(220,38,38,.08)';
const label=row.querySelector('span');
if(label) label.style.color=color;
}
}
}; };
}); });
} }
@@ -723,6 +854,6 @@ window.addEventListener('hashchange',()=>{
} }
}); });
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -345,6 +345,6 @@ window.uploadToGsp=uploadToGsp;
window.onTypeChange=onTypeChange; window.onTypeChange=onTypeChange;
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -59,7 +59,7 @@ main{padding:24px;max-width:980px;margin:0 auto}
<div style="padding:60px;text-align:center;color:var(--igc-text3)">Loading…</div> <div style="padding:60px;text-align:center;color:var(--igc-text3)">Loading…</div>
</main> </main>
<div class="toast-c" id="toast-c"></div> <div class="toast-c" id="toast-c"></div>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<script> <script>
'use strict'; 'use strict';
let _tok=null,_user=null; let _tok=null,_user=null;
+1 -1
View File
@@ -141,6 +141,6 @@ function wire(){
load(); load();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+6 -6
View File
@@ -202,7 +202,7 @@ 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('');} 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 _mode='list'; // 'list' | 'form'
let _docs=[];let _listLoading=false;let _filterDate='';let _page=0,_pageSize=20,_rawCount=0; let _docs=[];let _listLoading=false;let _filterDate='';let _page=0,_pageSize=20,_rawCount=0;let _docsTotal=null;
let _editEntry=null; // docEntry when editing, null for new let _editEntry=null; // docEntry when editing, null for new
let _date='';let _remark='';let _tabsData={};let _active='bb'; let _date='';let _remark='';let _tabsData={};let _active='bb';
let _canceled=false;let _saving=false;let _formLoading=false; let _canceled=false;let _saving=false;let _formLoading=false;
@@ -233,8 +233,8 @@ async function loadList(resetPage){
if(resetPage)_page=0; if(resetPage)_page=0;
_listLoading=true;render(); _listLoading=true;render();
const dq=_filterDate?`&date=${_filterDate}`:''; 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;} try{const r=await api('GET',`/manpower?top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${dq}`);_docs=r.data||[];_rawCount=_docs.length;_docsTotal=typeof r.total==='number'?r.total:null;}
catch(e){toast(e.message,'err');_docs=[];_rawCount=0;} catch(e){toast(e.message,'err');_docs=[];_rawCount=0;_docsTotal=null;}
_listLoading=false;render(); _listLoading=false;render();
} }
@@ -339,8 +339,8 @@ function renderList(app){
`<div class="mp-list">${docsHtml}</div> `<div class="mp-list">${docsHtml}</div>
<div class="mp-pager"> <div class="mp-pager">
<button class="btn b-cancel" id="l-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button> <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> <span class="mp-count">${_docsTotal!=null?`Page ${_page+1} of ${Math.max(1,Math.ceil(_docsTotal/_pageSize))} · ${_docsTotal} record${_docsTotal===1?'':'s'}`:`Page ${_page+1}`}</span>
<button class="btn b-cancel" id="l-next" ${_rawCount>=_pageSize?'':'disabled'} style="padding:6px 14px;font-size:11px">Next ›</button> <button class="btn b-cancel" id="l-next" ${(_docsTotal!=null?(_page+1)*_pageSize<_docsTotal:_rawCount>=_pageSize)?'':'disabled'} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`} </div>`}
</div>`; </div>`;
app.appendChild(c); app.appendChild(c);
@@ -524,6 +524,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+29 -8
View File
@@ -289,6 +289,9 @@ const OEE_SCHEMA={
{k:'qualityRate',l:'Quality Rate',u:'Nos.',type:'calc'}, {k:'qualityRate',l:'Quality Rate',u:'Nos.',type:'calc'},
{k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true}, {k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]}, ]},
{title:'Remarks',color:'#8aa0c0',fields:[
{k:'remarks',l:'Remarks',type:'text',ph:'Optional note...'},
]},
], ],
thead:`<thead> thead:`<thead>
<tr> <tr>
@@ -414,6 +417,9 @@ const OEE_SCHEMA={
{k:'qualityRate',l:'Quality Rate',u:'Nos.',type:'calc'}, {k:'qualityRate',l:'Quality Rate',u:'Nos.',type:'calc'},
{k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true}, {k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]}, ]},
{title:'Remarks',color:'#8aa0c0',fields:[
{k:'remarks',l:'Remarks',type:'text',ph:'Optional note...'},
]},
], ],
compute:function(r){ compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;}; const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
@@ -480,6 +486,9 @@ const OEE_SCHEMA={
{k:'qualityRate',l:'Quality Rate',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}, {k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]}, ]},
{title:'Remarks',color:'#8aa0c0',fields:[
{k:'remarks',l:'Remarks',type:'text',ph:'Optional note...'},
]},
], ],
compute:function(r){ compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;}; const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
@@ -546,6 +555,9 @@ const OEE_SCHEMA={
{k:'qualityRate',l:'Quality Rate',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}, {k:'qualityRate2',l:'Quality Rate',u:'%',type:'calc',hl:true},
]}, ]},
{title:'Remarks',color:'#8aa0c0',fields:[
{k:'remarks',l:'Remarks',type:'text',ph:'Optional note...'},
]},
], ],
compute:function(r){ compute:function(r){
const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;}; const n=k=>{const v=parseFloat(r[k]);return isNaN(v)?0:v;};
@@ -595,7 +607,7 @@ const OEE_SCHEMA={
{k:'oee',l:'OEE (Target 85%)',u:'%',type:'calc',hl:true}, {k:'oee',l:'OEE (Target 85%)',u:'%',type:'calc',hl:true},
]}, ]},
{title:'Remarks',color:'#8aa0c0',fields:[ {title:'Remarks',color:'#8aa0c0',fields:[
{k:'remarks',l:'Remarks',type:'text'}, {k:'remarks',l:'Remarks',type:'text',ph:'Optional note...'},
]}, ]},
], ],
compute:function(r){ compute:function(r){
@@ -641,7 +653,8 @@ 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('');} 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 _mode='list'; // 'list' | 'form'
let _docs=[];let _listLoading=false;let _filterTab='';let _page=0,_pageSize=30,_rawCount=0; let _docs=[];let _listLoading=false;let _filterTab='';let _page=0,_pageSize=30,_rawCount=0;let _docsTotal=null;
let _filterMonthFrom='',_filterMonthTo='';
let _editId=null; // doc id when editing, null for new let _editId=null; // doc id when editing, null for new
let _tab='ebb';let _month='';let _remark='';let _rows=[]; let _tab='ebb';let _month='';let _remark='';let _rows=[];
let _canceled=false;let _saving=false;let _formLoading=false; let _canceled=false;let _saving=false;let _formLoading=false;
@@ -672,8 +685,9 @@ async function loadList(resetPage){
if(resetPage)_page=0; if(resetPage)_page=0;
_listLoading=true;render(); _listLoading=true;render();
const tq=_filterTab?`&tab=${encodeURIComponent(_filterTab)}`:''; 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;} const mq=(_filterMonthFrom?`&monthFrom=${_filterMonthFrom}`:'')+(_filterMonthTo?`&monthTo=${_filterMonthTo}`:'');
catch(e){toast(e.message,'err');_docs=[];_rawCount=0;} try{const r=await api('GET',`/oee?top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${tq}${mq}`);_docs=r.data||[];_rawCount=_docs.length;_docsTotal=typeof r.total==='number'?r.total:null;}
catch(e){toast(e.message,'err');_docs=[];_rawCount=0;_docsTotal=null;}
_listLoading=false;render(); _listLoading=false;render();
} }
@@ -757,6 +771,11 @@ function renderList(app){
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:14px"> <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: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> <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>
<div class="fg" style="margin:0"><label class="fl">Month</label><div style="display:flex;gap:6px;align-items:center">
<input class="fi" id="f-monthfrom" type="month" value="${esc(_filterMonthFrom)}" style="width:130px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="f-monthto" type="month" value="${esc(_filterMonthTo)}" style="width:130px"/>
</div></div>
${CAN_ADD?`<button class="btn b-primary" id="btn-new" style="height:36px;margin-left:auto">+ New Entry</button>`:''} ${CAN_ADD?`<button class="btn b-primary" id="btn-new" style="height:36px;margin-left:auto">+ New Entry</button>`:''}
</div> </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>`: ${_listLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading…</div>`:
@@ -764,14 +783,16 @@ function renderList(app){
`<div class="mp-list">${docsHtml}</div> `<div class="mp-list">${docsHtml}</div>
<div class="mp-pager"> <div class="mp-pager">
<button class="btn b-cancel" id="l-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button> <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> <span class="mp-count">${_docsTotal!=null?`Page ${_page+1} of ${Math.max(1,Math.ceil(_docsTotal/_pageSize))} · ${_docsTotal} record${_docsTotal===1?'':'s'}`:`Page ${_page+1}`}</span>
<button class="btn b-cancel" id="l-next" ${_rawCount>=_pageSize?'':'disabled'} style="padding:6px 14px;font-size:11px">Next ›</button> <button class="btn b-cancel" id="l-next" ${(_docsTotal!=null?(_page+1)*_pageSize<_docsTotal:_rawCount>=_pageSize)?'':'disabled'} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`} </div>`}
</div>`; </div>`;
app.appendChild(c); app.appendChild(c);
setTimeout(()=>{ setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;loadList(true);}); 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('#f-tab')&&(q('#f-tab').onchange=function(){_filterTab=this.value;loadList(true);});
q('#f-monthfrom')&&(q('#f-monthfrom').onchange=function(){_filterMonthFrom=this.value;loadList(true);});
q('#f-monthto')&&(q('#f-monthto').onchange=function(){_filterMonthTo=this.value;loadList(true);});
q('#btn-new')&&(q('#btn-new').onclick=newEntry); q('#btn-new')&&(q('#btn-new').onclick=newEntry);
q('#l-prev')&&(q('#l-prev').onclick=()=>{if(_page>0){_page--;loadList();}}); q('#l-prev')&&(q('#l-prev').onclick=()=>{if(_page>0){_page--;loadList();}});
q('#l-next')&&(q('#l-next').onclick=()=>{if(_rawCount>=_pageSize){_page++;loadList();}}); q('#l-next')&&(q('#l-next').onclick=()=>{if(_rawCount>=_pageSize){_page++;loadList();}});
@@ -821,7 +842,7 @@ function openRowModal(index,readOnly){
const opts=machineNamesForTab(_tab).map(m=>`<option value="${esc(m)}" ${cur===m?'selected':''}>${esc(m)}</option>`).join(''); const opts=machineNamesForTab(_tab).map(m=>`<option value="${esc(m)}" ${cur===m?'selected':''}>${esc(m)}</option>`).join('');
inp=`<select data-f="${f.k}" ${readOnly?'disabled':''}><option value="">— Select —</option>${opts}</select>`; inp=`<select data-f="${f.k}" ${readOnly?'disabled':''}><option value="">— Select —</option>${opts}</select>`;
} }
else if(f.type==='text'||f.type==='machine')inp=`<input data-f="${f.k}" type="text" placeholder="${f.type==='machine'?'Machine name':'e.g. A / B / C'}" value="${esc(String(work[f.k]||''))}" ${readOnly?'disabled':''}/>`; else if(f.type==='text'||f.type==='machine')inp=`<input data-f="${f.k}" type="text" placeholder="${esc(f.ph||(f.type==='machine'?'Machine name':'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':''}/>`; 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>`; return `<div class="oee-fld">${label}${inp}</div>`;
}; };
@@ -999,6 +1020,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -322,6 +322,6 @@ function wire(){
render(); render();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -291,6 +291,6 @@ function drawLine(monthly){
const _r0=rangeDates('6'); const _r0=rangeDates('6');
load(_r0.from,_r0.to); load(_r0.from,_r0.to);
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -414,6 +414,6 @@ function wire(){
loadAll(); loadAll();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -648,6 +648,6 @@ function render(){
await Promise.all([loadList(),loadDefaults()]); await Promise.all([loadList(),loadDefaults()]);
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+96 -26
View File
@@ -200,8 +200,8 @@ let _devScrapWh=''; // Admin → System Settings → Deviation Scrap Wareho
let _devShowRemoveOldLineBtn=true; // Admin → System Settings → Deviation "Show Remove from SAP Button" (fetched once) let _devShowRemoveOldLineBtn=true; // Admin → System Settings → Deviation "Show Remove from SAP Button" (fetched once)
let _devOpen=false; let _devOpen=false;
let _devType='SHORTAGE'; let _devType='SHORTAGE';
let _devEnabledTypes=['SHORTAGE','SUBSTITUTION','DAMAGE']; // Admin → System Settings → Deviation Enabled Types (fetched once) let _devEnabledTypes=['SHORTAGE','SUBSTITUTION','DAMAGE','ADDITION']; // Admin → System Settings → Deviation Enabled Types (fetched once)
const _devTypeLabels={SHORTAGE:'Shortage — top up qty of the same item',SUBSTITUTION:'Substitution — issue a different item instead',DAMAGE:'Damage/Loss — write off a damaged qty'}; const _devTypeLabels={SHORTAGE:'Shortage — top up qty of the same item',SUBSTITUTION:'Substitution — issue a different item instead',DAMAGE:'Damage/Loss — write off a damaged qty',ADDITION:'Addition — add a new item/component'};
let _devItemCode=''; let _devItemCode='';
let _devNewItemCode='',_devNewItemName=''; let _devNewItemCode='',_devNewItemName='';
let _devWarehouse=''; let _devWarehouse='';
@@ -261,6 +261,11 @@ const CONSUMABLE_ONLY_USER=CAN_ANY_CONSUMABLE&&!(CAN_CREATE_PO||CAN_MANUAL_CREAT
// bypassing every normal gate below (step assignment, full-issuance, // bypassing every normal gate below (step assignment, full-issuance,
// full-qty, sequencing) — a deliberate override for these two roles only. // full-qty, sequencing) — a deliberate override for these two roles only.
const IS_PO_ADMIN_OVERRIDE=!!(_user&&['admin','system_admin'].includes(_user.role)); const IS_PO_ADMIN_OVERRIDE=!!(_user&&['admin','system_admin'].includes(_user.role));
// Previously Cancel had NO assignable step at all — admin/system_admin
// only. Now also grantable to a regular user via 'approve' on this step
// (Admin → Edit User → Approval Steps), same shape as every other
// production_order permission.
const CAN_CANCEL_PO=hasStepPerm('production_order:cancel','approve');
const CAN_RAISE_DEVIATION=hasStepPerm('production_order:deviation','add'); const CAN_RAISE_DEVIATION=hasStepPerm('production_order:deviation','add');
const CAN_APPROVE_DEVIATION=hasStepPerm('production_order:deviation','approve'); const CAN_APPROVE_DEVIATION=hasStepPerm('production_order:deviation','approve');
const CAN_VIEW_DEVIATION=hasStepAssigned('production_order:deviation'); const CAN_VIEW_DEVIATION=hasStepAssigned('production_order:deviation');
@@ -281,6 +286,7 @@ let _orders=[];
let _ordersEverLoaded=false; let _ordersEverLoaded=false;
let _viewLoading=false; let _viewLoading=false;
let _viewFilter=''; let _viewFilter='';
let _viewDateFrom='',_viewDateTo=''; // filters the loaded list by Due Date — client-side, same scope as the search box above
let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
let _viewSearch=''; let _viewSearch='';
let _page=0; // View Orders pagination (0-based) let _page=0; // View Orders pagination (0-based)
@@ -604,7 +610,7 @@ function formCardsHtml(){
if(_bomLoading)ch='<div class="bom-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading BOM...</div>'; if(_bomLoading)ch='<div class="bom-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading BOM...</div>';
else if(!_lines.length)ch=`<div class="bom-msg">${_hdr.type==='bopotSpecial'?'Add components manually using the button below':'Select a product to auto-load BOM components'}</div>`; else if(!_lines.length)ch=`<div class="bom-msg">${_hdr.type==='bopotSpecial'?'Add components manually using the button below':'Select a product to auto-load BOM components'}</div>`;
else ch=`<div class="tbl-wrap"><table class="comp-tbl"> else ch=`<div class="tbl-wrap"><table class="comp-tbl">
<thead><tr><th style="width:28px">#</th><th>Item</th><th style="width:100px">Base Qty</th><th style="width:110px">Planned Qty</th><th style="width:90px">Available</th><th style="width:60px"></th></tr></thead> <thead><tr><th style="width:28px">#</th><th>Item</th><th style="width:100px">Base Qty</th><th style="width:110px">Planned Qty</th><th style="width:200px">Warehouse</th><th style="width:90px">Available</th><th style="width:60px"></th></tr></thead>
<tbody>${_lines.map((l,i)=>{ <tbody>${_lines.map((l,i)=>{
const isManual=!l.itemNo; const isManual=!l.itemNo;
const avail=l.itemNo&&l.type!=='Resource'?stockFor(l):null; const avail=l.itemNo&&l.type!=='Resource'?stockFor(l):null;
@@ -618,6 +624,7 @@ function formCardsHtml(){
:`<span class="comp-type ${l.type==='Resource'?'resource':'item'}">${l.type}</span> <span class="comp-item">${esc(l.itemNo)}</span><div class="comp-desc">${esc(l.desc)}</div>`}</td> :`<span class="comp-type ${l.type==='Resource'?'resource':'item'}">${l.type}</span> <span class="comp-item">${esc(l.itemNo)}</span><div class="comp-desc">${esc(l.desc)}</div>`}</td>
<td>${l.itemNo?`<input class="comp-inp" data-i="${i}" data-f="baseQty" type="number" min="0" step="0.001" value="${l.baseQty}" style="width:90px"/>`:''}</td> <td>${l.itemNo?`<input class="comp-inp" data-i="${i}" data-f="baseQty" type="number" min="0" step="0.001" value="${l.baseQty}" style="width:90px"/>`:''}</td>
<td>${l.itemNo?`<input class="comp-inp" data-i="${i}" data-f="plannedQty" type="number" min="0" step="0.001" value="${l.plannedQty}" style="width:100px"/>`:''}</td> <td>${l.itemNo?`<input class="comp-inp" data-i="${i}" data-f="plannedQty" type="number" min="0" step="0.001" value="${l.plannedQty}" style="width:100px"/>`:''}</td>
<td>${l.type==='Resource'?'':`<select class="comp-inp" data-i="${i}" data-f="warehouse" style="width:100%;min-width:140px"><option value="" ${l.warehouse?'':'selected'}>— Select —</option>${_wh.map(w=>`<option value="${esc(w.code)}" ${l.warehouse===w.code?'selected':''}>${esc(w.code)} — ${esc(w.name||'')}</option>`).join('')}</select>`}</td>
<td style="text-align:right">${availCell}</td> <td style="text-align:right">${availCell}</td>
<td style="text-align:right"><button class="btn" data-rm="${i}" style="padding:3px 10px;font-size:10px;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25);border-radius:5px">Remove</button></td> <td style="text-align:right"><button class="btn" data-rm="${i}" style="padding:3px 10px;font-size:10px;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25);border-radius:5px">Remove</button></td>
</tr>`; </tr>`;
@@ -760,6 +767,13 @@ function renderView(app){
<button class="btn" id="v-load" style="padding:6px 16px;font-size:11px;background:var(--purple);color:#fff;white-space:nowrap">Load</button> <button class="btn" id="v-load" style="padding:6px 16px;font-size:11px;background:var(--purple);color:#fff;white-space:nowrap">Load</button>
</div> </div>
<div style="font-size:10px;color:var(--text3);margin:-6px 0 10px">Type to filter loaded orders, or press <b>Load</b> / Enter to search all production orders on the server.</div> <div style="font-size:10px;color:var(--text3);margin:-6px 0 10px">Type to filter loaded orders, or press <b>Load</b> / Enter to search all production orders on the server.</div>
<div class="fg" style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px">Due Date</span>
<input class="fi" id="v-datefrom" type="date" value="${esc(_viewDateFrom)}" style="font-size:12px;height:30px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="v-dateto" type="date" value="${esc(_viewDateTo)}" style="font-size:12px;height:30px"/>
${(_viewDateFrom||_viewDateTo)?`<button class="btn b-cancel" id="v-dateclear" style="padding:5px 10px;font-size:11px">✕ Clear</button>`:''}
</div>
<div style="display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap"> <div style="display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap">
${['','Planned','Released','Closed'].map(s=>`<button class="btn" style="padding:6px 12px;font-size:11px;${_viewFilter===s?'background:var(--purple);color:#fff':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vf="${s}">${s||'All'}</button>`).join('')} ${['','Planned','Released','Closed'].map(s=>`<button class="btn" style="padding:6px 12px;font-size:11px;${_viewFilter===s?'background:var(--purple);color:#fff':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vf="${s}">${s||'All'}</button>`).join('')}
<button class="btn b-view" style="margin-left:auto;padding:6px 12px;font-size:11px" id="v-refresh">Refresh</button> <button class="btn b-view" style="margin-left:auto;padding:6px 12px;font-size:11px" id="v-refresh">Refresh</button>
@@ -778,6 +792,9 @@ function renderView(app){
q('#v-co').onchange=function(){_co=this.value;loadOrders(true);}; q('#v-co').onchange=function(){_co=this.value;loadOrders(true);};
q('#v-refresh').onclick=()=>loadOrders(); q('#v-refresh').onclick=()=>loadOrders();
q('#v-search').oninput=function(){_viewSearch=this.value;const el=q('#v-list');if(el)el.innerHTML=renderOrderList();wirePoCards();}; q('#v-search').oninput=function(){_viewSearch=this.value;const el=q('#v-list');if(el)el.innerHTML=renderOrderList();wirePoCards();};
q('#v-datefrom').onchange=function(){_viewDateFrom=this.value;render();};
q('#v-dateto').onchange=function(){_viewDateTo=this.value;render();};
if(q('#v-dateclear'))q('#v-dateclear').onclick=function(){_viewDateFrom='';_viewDateTo='';render();};
// Enter or the Load button runs a server-side search across ALL orders // Enter or the Load button runs a server-side search across ALL orders
// (resets to the first page), not just the ones already loaded. // (resets to the first page), not just the ones already loaded.
q('#v-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadOrders(true);}}; q('#v-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadOrders(true);}};
@@ -862,7 +879,25 @@ function renderOrderList(){
let list=_orders; let list=_orders;
if(_viewSearch.trim()){ if(_viewSearch.trim()){
const s=_viewSearch.trim().toUpperCase(); const s=_viewSearch.trim().toUpperCase();
list=list.filter(o=>(o.ItemNo||'').toUpperCase().includes(s)||(o.ProductDescription||'').toUpperCase().includes(s)||(String(o.DocumentNumber)||'').includes(s)||(String(o.AbsoluteEntry)||'').includes(s)||(o.WoNo||'').toUpperCase().includes(s)||(o.BatchNumber||'').toUpperCase().includes(s)); // Bidirectional on WoNo/BatchNumber: after Enter/Load triggers a SERVER
// search (which matches the fuller OWOR.Comments text, e.g. "SOL
// BB2609327 CPDA"), this same client-side re-filter runs again over the
// now-narrowed _orders — a plain field.includes(s) would immediately
// drop that just-fetched match again, since the clean BatchNumber shown
// ("BB2609327") doesn't contain the longer typed text. s.includes(field)
// covers that case while field.includes(s) still covers the normal
// "type a partial batch/WO no. to filter the loaded list" case.
const bidi=(field,s)=>{ field=(field||'').toUpperCase(); return !!field&&(field.includes(s)||s.includes(field)); };
list=list.filter(o=>(o.ItemNo||'').toUpperCase().includes(s)||(o.ProductDescription||'').toUpperCase().includes(s)||(String(o.DocumentNumber)||'').includes(s)||(String(o.AbsoluteEntry)||'').includes(s)||bidi(o.WoNo,s)||bidi(o.BatchNumber,s));
}
if(_viewDateFrom||_viewDateTo){
list=list.filter(o=>{
if(!o.DueDate)return false;
const d=o.DueDate.slice(0,10);
if(_viewDateFrom&&d<_viewDateFrom)return false;
if(_viewDateTo&&d>_viewDateTo)return false;
return true;
});
} }
if(!list.length)return'<div class="po-empty">No production orders found</div>'; if(!list.length)return'<div class="po-empty">No production orders found</div>';
return `<div class="po-grid">${list.map(o=>{ return `<div class="po-grid">${list.map(o=>{
@@ -1187,7 +1222,7 @@ async function openDetail(entry){
const isOverride=!normalCloseOk&&(consumableCloseOk||IS_PO_ADMIN_OVERRIDE); const isOverride=!normalCloseOk&&(consumableCloseOk||IS_PO_ADMIN_OVERRIDE);
return `<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)" id="m-close-order">Close Order${isOverride?' (Override)':''}</button></div>`; return `<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)" id="m-close-order">Close Order${isOverride?' (Override)':''}</button></div>`;
})()} })()}
${(st==='Planned'||st==='Released')&&IS_PO_ADMIN_OVERRIDE?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25)" id="m-cancel-order">✕ Cancel Order (Admin override)</button></div>`:''} ${(st==='Planned'||st==='Released')&&(IS_PO_ADMIN_OVERRIDE||CAN_CANCEL_PO)?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25)" id="m-cancel-order">✕ Cancel Order${IS_PO_ADMIN_OVERRIDE?' (Admin override)':''}</button></div>`:''}
${(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)?`<div class="alert al-green" style="margin-top:8px">Receipt complete — all ${d.PlannedQuantity} qty received.</div>`:''} ${(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)?`<div class="alert al-green" style="margin-top:8px">Receipt complete — all ${d.PlannedQuantity} qty received.</div>`:''}
</div> </div>
</div>`; </div>`;
@@ -1270,8 +1305,8 @@ async function loadDevList(entry){
try{const r=await api('GET',`/sap/deviations?sapAbsEntry=${entry}&company=${encodeURIComponent(_co)}`);_devList=r.data||[];} try{const r=await api('GET',`/sap/deviations?sapAbsEntry=${entry}&company=${encodeURIComponent(_co)}`);_devList=r.data||[];}
catch(_e){_devList=[];} catch(_e){_devList=[];}
} }
function devTypeLabel(t){return t==='SHORTAGE'?'Shortage':t==='SUBSTITUTION'?'Substitution':t==='DAMAGE'?'Damage/Loss':t;} function devTypeLabel(t){return t==='SHORTAGE'?'Shortage':t==='SUBSTITUTION'?'Substitution':t==='DAMAGE'?'Damage/Loss':t==='ADDITION'?'Addition':t;}
function devTypeColor(t){return t==='SHORTAGE'?'var(--amber)':t==='SUBSTITUTION'?'var(--extra)':'var(--extrasevere)';} function devTypeColor(t){return t==='SHORTAGE'?'var(--amber)':t==='SUBSTITUTION'?'var(--extra)':t==='ADDITION'?'var(--green)':'var(--extrasevere)';}
function fmtDevDate(v){if(!v)return'—';try{return new Date(v).toLocaleString('en-IN',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return v;}} function fmtDevDate(v){if(!v)return'—';try{return new Date(v).toLocaleString('en-IN',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return v;}}
function renderDevSection(){ function renderDevSection(){
@@ -1283,7 +1318,7 @@ function renderDevSection(){
dv.qaStatus==='PENDING'?'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(245,166,35,.15);color:var(--amber)">QA PENDING</span>' dv.qaStatus==='PENDING'?'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(245,166,35,.15);color:var(--amber)">QA PENDING</span>'
:dv.qaStatus==='APPROVED'?'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(13,187,138,.15);color:var(--green)">QA APPROVED</span>' :dv.qaStatus==='APPROVED'?'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(13,187,138,.15);color:var(--green)">QA APPROVED</span>'
:'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(229,62,62,.15);color:var(--red)">QA REJECTED</span>'); :'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(229,62,62,.15);color:var(--red)">QA REJECTED</span>');
const deferrable=dv.type==='SHORTAGE'||dv.type==='SUBSTITUTION'; const deferrable=dv.type==='SHORTAGE'||dv.type==='SUBSTITUTION'||dv.type==='ADDITION';
let postedTxt; let postedTxt;
if(dv.postedToSap) postedTxt=`<span style="color:var(--green)">✓ Posted — Doc #${esc(String(dv.sapDocNumPosted||dv.sapDocEntry||''))}</span>`; if(dv.postedToSap) postedTxt=`<span style="color:var(--green)">✓ Posted — Doc #${esc(String(dv.sapDocNumPosted||dv.sapDocEntry||''))}</span>`;
else if(deferrable&&dv.qaStatus==='PENDING') postedTxt=`<span style="color:var(--text3)">⏳ Pending QA approval — nothing added to the Production Order yet</span>`; else if(deferrable&&dv.qaStatus==='PENDING') postedTxt=`<span style="color:var(--text3)">⏳ Pending QA approval — nothing added to the Production Order yet</span>`;
@@ -1483,8 +1518,14 @@ function closeDevModal(){document.getElementById('dev-modal').classList.remove('
// whenever the item, substitute item, warehouse, or quantity changes — SAP // whenever the item, substitute item, warehouse, or quantity changes — SAP
// rejects a batch-managed item's issue/transfer with -4014 "Cannot add row // rejects a batch-managed item's issue/transfer with -4014 "Cannot add row
// without complete selection of batch/serial numbers" if none is supplied. // without complete selection of batch/serial numbers" if none is supplied.
// Substitution and Addition both work off the SAME "search and pick a not-
// currently-a-component item" widget (_devNewItemCode/_devNewItemName) —
// Substitution just also has an "Affected Component" being replaced,
// Addition doesn't. Kept as one helper so every place that needs to branch
// on this stays in sync.
function devUsesNewItemPicker(){ return _devType==='SUBSTITUTION'||_devType==='ADDITION'; }
async function loadDevBatches(){ async function loadDevBatches(){
const itemCode=_devType==='SUBSTITUTION'?_devNewItemCode:_devItemCode; const itemCode=devUsesNewItemPicker()?_devNewItemCode:_devItemCode;
_devBatches=[];_devSelectedBatches=[]; _devBatches=[];_devSelectedBatches=[];
if(!itemCode||!_devWarehouse)return; if(!itemCode||!_devWarehouse)return;
try{ try{
@@ -1522,11 +1563,12 @@ function renderDevModal(){
${_devEnabledTypes.map(t=>`<option value="${t}" ${_devType===t?'selected':''}>${esc(_devTypeLabels[t]||t)}</option>`).join('')} ${_devEnabledTypes.map(t=>`<option value="${t}" ${_devType===t?'selected':''}>${esc(_devTypeLabels[t]||t)}</option>`).join('')}
</select> </select>
</div> </div>
${_devType==='ADDITION'?'':`
<div class="fg"><label class="fl">${_devType==='DAMAGE'?'Damaged Component':'Affected Component'}</label> <div class="fg"><label class="fl">${_devType==='DAMAGE'?'Damaged Component':'Affected Component'}</label>
<select class="fi" id="dev-item">${devComponentOptions(_devItemCode)}</select> <select class="fi" id="dev-item">${devComponentOptions(_devItemCode)}</select>
</div> </div>`}
${_devType==='SUBSTITUTION'?` ${devUsesNewItemPicker()?`
<div class="fg" style="position:relative"><label class="fl">Substitute Item</label> <div class="fg" style="position:relative"><label class="fl">${_devType==='ADDITION'?'New Item':'Substitute Item'}</label>
<input class="fi" id="dev-newitem-search" placeholder="Search item code / name…" value="${_devNewItemCode?esc(_devNewItemCode+' — '+_devNewItemName):''}" autocomplete="off"/> <input class="fi" id="dev-newitem-search" placeholder="Search item code / name…" value="${_devNewItemCode?esc(_devNewItemCode+' — '+_devNewItemName):''}" autocomplete="off"/>
<div id="dev-newitem-dd" class="sd" style="display:none;position:absolute;left:0;right:0;top:100%;z-index:20;max-height:200px;overflow-y:auto;background:var(--ink2);border:1px solid var(--border2);border-radius:8px;margin-top:2px"></div> <div id="dev-newitem-dd" class="sd" style="display:none;position:absolute;left:0;right:0;top:100%;z-index:20;max-height:200px;overflow-y:auto;background:var(--ink2);border:1px solid var(--border2);border-radius:8px;margin-top:2px"></div>
</div>`:''} </div>`:''}
@@ -1588,8 +1630,18 @@ function refreshDevBatchUI(){
function wireDevModal(){ function wireDevModal(){
document.getElementById('dev-modal-x').onclick=closeDevModal; document.getElementById('dev-modal-x').onclick=closeDevModal;
document.getElementById('dev-cancel').onclick=closeDevModal; document.getElementById('dev-cancel').onclick=closeDevModal;
document.getElementById('dev-type').onchange=async function(){_devType=this.value;await loadDevBatches();renderDevModal();}; document.getElementById('dev-type').onchange=async function(){
document.getElementById('dev-item').onchange=async function(){ _devType=this.value;
// Switching type changes which item field(s) are even shown — stale
// picks from the previous type would otherwise silently carry over
// (e.g. an "Affected Component" picked under Shortage staying set, but
// invisible, after switching to Addition).
_devItemCode='';_devNewItemCode='';_devNewItemName='';_devQty='';
await loadDevBatches();
renderDevModal();
};
const itemSel=document.getElementById('dev-item');
if(itemSel)itemSel.onchange=async function(){
_devItemCode=this.value; _devItemCode=this.value;
const opt=this.options[this.selectedIndex]; const opt=this.options[this.selectedIndex];
if(opt&&opt.dataset.wh)_devWarehouse=opt.dataset.wh; if(opt&&opt.dataset.wh)_devWarehouse=opt.dataset.wh;
@@ -1648,12 +1700,15 @@ function wireDevModal(){
} }
async function submitDeviation(){ async function submitDeviation(){
if(!_devOrder)return; if(!_devOrder)return;
if(!_devItemCode){toast('Select the affected component','err');return;} // Addition has no "Affected Component" at all — it's a brand-new item, so
// its own itemCode comes from the New Item search picker instead (see
// devUsesNewItemPicker()/the payload building below), never this dropdown.
if(_devType!=='ADDITION'&&!_devItemCode){toast('Select the affected component','err');return;}
const qty=parseFloat(_devQty)||0; const qty=parseFloat(_devQty)||0;
if(qty<=0){toast('Quantity must be > 0','err');return;} if(qty<=0){toast('Quantity must be > 0','err');return;}
if(!_devWarehouse){toast('Select a warehouse','err');return;} if(!_devWarehouse){toast('Select a warehouse','err');return;}
if(!_devReason.trim()){toast('A reason is required','err');return;} if(!_devReason.trim()){toast('A reason is required','err');return;}
if(_devType==='SUBSTITUTION'&&!_devNewItemCode){toast('Search and pick a substitute item','err');return;} if(devUsesNewItemPicker()&&!_devNewItemCode){toast(_devType==='ADDITION'?'Search and pick the new item':'Search and pick a substitute item','err');return;}
if(_devType==='DAMAGE'&&!_devDestWarehouse){toast('Select a scrap/destination warehouse','err');return;} if(_devType==='DAMAGE'&&!_devDestWarehouse){toast('Select a scrap/destination warehouse','err');return;}
// Item is batch-managed — the selected batches must add up to the full // Item is batch-managed — the selected batches must add up to the full
// quantity being posted (same rule as Issue for Production). // quantity being posted (same rule as Issue for Production).
@@ -1665,7 +1720,10 @@ async function submitDeviation(){
const itemOpt=document.getElementById('dev-item'); const itemOpt=document.getElementById('dev-item');
const selOpt=itemOpt?itemOpt.options[itemOpt.selectedIndex]:null; const selOpt=itemOpt?itemOpt.options[itemOpt.selectedIndex]:null;
const lineNum=selOpt?selOpt.dataset.linenum:null; const lineNum=selOpt?selOpt.dataset.linenum:null;
const itemName=(_devOrder.lines||[]).find(l=>l.ItemNo===_devItemCode)?.ItemName||''; // Addition's own item IS the New Item picker's pick — there's no existing
// Production Order line to read a name off of, unlike every other type.
const itemCode=_devType==='ADDITION'?_devNewItemCode:_devItemCode;
const itemName=_devType==='ADDITION'?_devNewItemName:((_devOrder.lines||[]).find(l=>l.ItemNo===_devItemCode)?.ItemName||'');
if(!requireSapLogin())return; if(!requireSapLogin())return;
_devSubmitting=true;renderDevModal(); _devSubmitting=true;renderDevModal();
@@ -1673,8 +1731,9 @@ async function submitDeviation(){
const payload={ const payload={
company:_co, branchId:2, company:_co, branchId:2,
sapAbsEntry:_devOrder.entry, sapDocNum:_devOrder.docNum, sapAbsEntry:_devOrder.entry, sapDocNum:_devOrder.docNum,
type:_devType, itemCode:_devItemCode, itemName, lineNum, type:_devType, itemCode, itemName, lineNum,
newItemCode:_devNewItemCode, newItemName:_devNewItemName, newItemCode:_devType==='SUBSTITUTION'?_devNewItemCode:undefined,
newItemName:_devType==='SUBSTITUTION'?_devNewItemName:undefined,
quantity:qty, warehouse:_devWarehouse, quantity:qty, warehouse:_devWarehouse,
destWarehouse:_devType==='DAMAGE'?_devDestWarehouse:undefined, destWarehouse:_devType==='DAMAGE'?_devDestWarehouse:undefined,
postToSap:_devType==='DAMAGE'?_devPostToSap:undefined, postToSap:_devType==='DAMAGE'?_devPostToSap:undefined,
@@ -1701,7 +1760,7 @@ function wireSearch(){
clearTimeout(_hst); clearTimeout(_hst);
_hst=setTimeout(async()=>{ _hst=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block'; dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block';
const items=await fetchItems(v,false,_srcConsumable?CONSUMABLE_ITEM_GROUP:(_manualPoGroups.length?_manualPoGroups:null)); const items=await fetchItems(v,true,_srcConsumable?CONSUMABLE_ITEM_GROUP:(_manualPoGroups.length?_manualPoGroups:null));
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;} if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{ dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row'; const r=document.createElement('div');r.className='sd-row';
@@ -1918,7 +1977,7 @@ function wireComp(){
// back to the top on every edit (confusing on a long component list). // back to the top on every edit (confusing on a long component list).
document.querySelectorAll('.comp-inp').forEach(el=>{el.onchange=function(){ document.querySelectorAll('.comp-inp').forEach(el=>{el.onchange=function(){
const i=parseInt(this.dataset.i),f=this.dataset.f; const i=parseInt(this.dataset.i),f=this.dataset.f;
if(f==='warehouse'){_lines[i][f]=this.value;return;} if(f==='warehouse'){_lines[i][f]=this.value;loadStockForLines();return;}
const val=parseFloat(this.value)||0; const val=parseFloat(this.value)||0;
_lines[i][f]=val; _lines[i][f]=val;
const hq=_hdr.qty||1; const hq=_hdr.qty||1;
@@ -1946,7 +2005,7 @@ function wireComp(){
clearTimeout(_cst[i]); clearTimeout(_cst[i]);
_cst[i]=setTimeout(async()=>{ _cst[i]=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block'; dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block';
const items=await fetchItems(v); const items=await fetchItems(v,true);
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;} if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{ dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row'; const r=document.createElement('div');r.className='sd-row';
@@ -1992,7 +2051,18 @@ async function doPlanned(){
if(_preWoReviewEnabled&&_srcWorkOrderId){await doStagePrePwo();return;} if(_preWoReviewEnabled&&_srcWorkOrderId){await doStagePrePwo();return;}
if(!requireSapLogin())return; if(!requireSapLogin())return;
const btn=q('#btn-planned'),sp=q('#sp-planned');btn.disabled=true;sp.style.display='inline-block'; const btn=q('#btn-planned'),sp=q('#sp-planned');btn.disabled=true;sp.style.display='inline-block';
const payload={company:_co,ItemNo:_hdr.itemNo.trim().toUpperCase(),PlannedQuantity:parseFloat(_hdr.qty)||1,ProductionOrderStatus:'boposPlanned',ProductionOrderType:_hdr.type}; // Captured ONCE and reused for both calls below (the SAP create, then the
// local stage-tracking link) — _co is a live global the user can change
// from the Company dropdown at any time, including during the first
// call's network round trip. Reading it fresh for the second call let the
// two writes land under DIFFERENT companies if the user switched company
// mid-submit: the real SAP order under the old company, the local
// ZPRODUCTION_ORDERS tracking row under the new one — invisible to every
// company-scoped list/lookup afterward even though creation itself
// "succeeded" (confirmed live: a Consumable Order whose local row ended up
// in a different database than its real SAP document).
const submitCo=_co;
const payload={company:submitCo,ItemNo:_hdr.itemNo.trim().toUpperCase(),PlannedQuantity:parseFloat(_hdr.qty)||1,ProductionOrderStatus:'boposPlanned',ProductionOrderType:_hdr.type};
// Tells the server which of the two Create permissions applies (from-WO // Tells the server which of the two Create permissions applies (from-WO
// vs standalone) — stripped before the payload is forwarded to SAP. // vs standalone) — stripped before the payload is forwarded to SAP.
if(_srcWorkOrderId)payload.workOrderId=_srcWorkOrderId; if(_srcWorkOrderId)payload.workOrderId=_srcWorkOrderId;
@@ -2021,7 +2091,7 @@ async function doPlanned(){
await api('POST','/production-orders',{ await api('POST','/production-orders',{
workOrderId:_srcWorkOrderId||null, sapAbsEntry:_created.entry, sapDocNum:_created.docNum, workOrderId:_srcWorkOrderId||null, sapAbsEntry:_created.entry, sapDocNum:_created.docNum,
itemCode:_hdr.itemNo, itemName:_hdr.desc, plannedQty:_hdr.qty, itemCode:_hdr.itemNo, itemName:_hdr.desc, plannedQty:_hdr.qty,
batchNumber:_srcBatchNumber, warehouse:_hdr.warehouse, company:_co, batchNumber:_srcBatchNumber, warehouse:_hdr.warehouse, company:submitCo,
refWoNo:_refWoNo, refBatchNumber:_refBatchNumber, refWoNo:_refWoNo, refBatchNumber:_refBatchNumber,
fromComponent:_srcFromComponent, refParentEntry:_srcFromComponentInfo?.entry||null, fromComponent:_srcFromComponent, refParentEntry:_srcFromComponentInfo?.entry||null,
fromConsumable:_srcConsumable, department:_srcConsumable?_consumableDept:'', fromConsumable:_srcConsumable, department:_srcConsumable?_consumableDept:'',
@@ -2289,7 +2359,7 @@ function wirePrePwoModal(){
clearTimeout(_preWoSearchTimer[i]); clearTimeout(_preWoSearchTimer[i]);
_preWoSearchTimer[i]=setTimeout(async()=>{ _preWoSearchTimer[i]=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block'; dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block';
const items=await fetchItems(v); const items=await fetchItems(v,true);
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;} if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{ dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row'; const r=document.createElement('div');r.className='sd-row';
@@ -2339,6 +2409,6 @@ function resetForm(){_created=null;_preWoStaged=null;_lines=[];_hdr={itemNo:'',d
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -557,6 +557,6 @@ loadProfile();
loadSapCompanyOptions().then(loadSapStatus); loadSapCompanyOptions().then(loadSapStatus);
loadSignature(); loadSignature();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -54,7 +54,7 @@ table.list-tbl{border-collapse:collapse;min-width:100%;font-size:12px}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
+1 -1
View File
@@ -194,7 +194,7 @@ table.list-tbl{border-collapse:collapse;min-width:100%;font-size:12px}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg" id="main"> <div class="pg" id="main">
+1 -1
View File
@@ -905,6 +905,6 @@ async function loadOrders() {
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
initPage(); initPage();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+6 -3
View File
@@ -884,6 +884,7 @@ async function loadQuotations(){
return; return;
} }
const rows = d.value||d.data||[]; const rows = d.value||d.data||[];
const total = typeof d.total === 'number' ? d.total : null;
if (!rows.length){ wrap.innerHTML='<div class="pq-empty">No purchase quotations found.</div>'; document.getElementById('pgBar').style.display='none'; return; } if (!rows.length){ wrap.innerHTML='<div class="pq-empty">No purchase quotations found.</div>'; document.getElementById('pgBar').style.display='none'; return; }
wrap.innerHTML = `<div class="pq-list">${rows.map(pq=>` wrap.innerHTML = `<div class="pq-list">${rows.map(pq=>`
<div class="pq-card" onclick="openDoc(${pq.DocEntry})"> <div class="pq-card" onclick="openDoc(${pq.DocEntry})">
@@ -901,8 +902,10 @@ async function loadQuotations(){
const pgBar = document.getElementById('pgBar'); const pgBar = document.getElementById('pgBar');
pgBar.style.display = 'flex'; pgBar.style.display = 'flex';
document.getElementById('pgPrev').disabled = _page===0; document.getElementById('pgPrev').disabled = _page===0;
document.getElementById('pgNext').disabled = rows.length < PAGE_SIZE; document.getElementById('pgNext').disabled = total!=null ? (_page+1)*PAGE_SIZE >= total : rows.length < PAGE_SIZE;
document.getElementById('pgInfo').textContent = `Page ${_page+1}`; document.getElementById('pgInfo').textContent = total!=null
? `Page ${_page+1} of ${Math.max(1,Math.ceil(total/PAGE_SIZE))} · ${total} quotation${total===1?'':'s'}`
: `Page ${_page+1}`;
} catch(err){ wrap.innerHTML=`<div class="pq-empty" style="color:var(--red)">${esc(err.message)}</div>`; } } catch(err){ wrap.innerHTML=`<div class="pq-empty" style="color:var(--red)">${esc(err.message)}</div>`; }
} }
@@ -1131,7 +1134,7 @@ async function choosePR(){
addLine(); addLine();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+94 -13
View File
@@ -309,6 +309,7 @@ td select{appearance:none;cursor:pointer}
<option value="">All Status</option> <option value="">All Status</option>
<option value="bost_Open">Open</option> <option value="bost_Open">Open</option>
<option value="bost_Close">Closed</option> <option value="bost_Close">Closed</option>
<option value="drafts">Pending SAP Approval</option>
</select> </select>
<button class="btn b-primary btn-sm" onclick="loadList()">Search</button> <button class="btn b-primary btn-sm" onclick="loadList()">Search</button>
</div> </div>
@@ -438,7 +439,7 @@ let _page = 0;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const ah = () => ({ Authorization: `Bearer ${_tok}` }); const ah = () => ({ Authorization: `Bearer ${_tok}` });
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const dt = s => s ? s.slice(0,10) : '—'; const dt = s => s ? s.slice(0,10) : '—';
// ── Toast ────────────────────────────────────────────────────── // ── Toast ──────────────────────────────────────────────────────
@@ -545,9 +546,23 @@ function vendorAc(val){
const r = await fetch(`/api/sap/lookup/vendors?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() }); const r = await fetch(`/api/sap/lookup/vendors?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() });
const d = await r.json(); const d = await r.json();
const items = d.data || []; const items = d.data || [];
dd.innerHTML = items.length // Built as real DOM elements with a bound click handler, not an
? 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('') // inline onclick="..." string — a vendor name containing a quote
: '<div class="sdd-msg">No vendors found</div>'; // character (e.g. an apostrophe) would otherwise terminate that
// string/attribute early and silently break the click entirely, same
// bug class as pickItem() below.
dd.innerHTML = '';
if (items.length) {
items.forEach(v => {
const row = document.createElement('div');
row.className = 'sdd-row';
row.innerHTML = `<span class="sdd-code">${esc(v.CardCode)}</span><span class="sdd-name">${esc(v.CardName)}</span>`;
row.onclick = () => pickVendor(v.CardCode, v.CardName);
dd.appendChild(row);
});
} else {
dd.innerHTML = '<div class="sdd-msg">No vendors found</div>';
}
dd.classList.add('open'); dd.classList.add('open');
} catch {} } catch {}
}, 300); }, 300);
@@ -573,12 +588,26 @@ function itemAc(inp, lineId){
if (val.length < 2){ dd.classList.remove('open'); dd.innerHTML=''; return; } if (val.length < 2){ dd.classList.remove('open'); dd.innerHTML=''; return; }
_itemTimers[lineId] = setTimeout(async () => { _itemTimers[lineId] = setTimeout(async () => {
try { try {
const r = await fetch(`/api/sap/lookup/items?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() }); const r = await fetch(`/api/sap/lookup/items?q=${encodeURIComponent(val)}&company=${_co}&activeOnly=1`, { headers: ah() });
const d = await r.json(); const d = await r.json();
const items = d.data || []; const items = d.data || [];
dd.innerHTML = items.length // Built as real DOM elements with a bound click handler, not an
? 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('') // inline onclick="..." string — an item description containing a
: '<div class="sdd-msg">No items found</div>'; // quote character (e.g. an inch mark, "NYLON RUBBER BAND 1"") would
// otherwise terminate that HTML attribute early, silently corrupting
// the click handler so picking the item did nothing at all.
dd.innerHTML = '';
if (items.length) {
items.forEach(i => {
const row = document.createElement('div');
row.className = 'sdd-row';
row.innerHTML = `<span class="sdd-code">${esc(i.ItemCode)}</span><span class="sdd-name">${esc(i.ItemName)}</span>`;
row.onclick = () => pickItem(lineId, i.ItemCode, i.ItemName || '', i.UoM || '', i.Warehouse || '');
dd.appendChild(row);
});
} else {
dd.innerHTML = '<div class="sdd-msg">No items found</div>';
}
dd.classList.add('open'); dd.classList.add('open');
const rect = inp.getBoundingClientRect(); const rect = inp.getBoundingClientRect();
dd.style.top = (rect.bottom + window.scrollY + 2) + 'px'; dd.style.top = (rect.bottom + window.scrollY + 2) + 'px';
@@ -615,7 +644,7 @@ function addLine(){
<div class="sdd item-dd" style="position:fixed;z-index:9999"></div> <div class="sdd item-dd" style="position:fixed;z-index:9999"></div>
</td> </td>
<td><input class="desc-inp" type="text" placeholder="Description" readonly style="min-width:140px"></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><input class="rd-inp" type="date" value="${dueDate}" oninput="this.dataset.manual='1'" 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 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><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 class="nc"><input class="price-inp" type="number" value="0" min="0" step="any" oninput="recalc()" style="width:90px"></td>
@@ -630,6 +659,15 @@ function addLine(){
recalc(); recalc();
} }
// Header "Required By" flows down to every line date the user hasn't set by hand
function syncLineDates(){
const due = document.getElementById('docDueDate').value;
if (!due) return;
document.querySelectorAll('#linesBody .rd-inp').forEach(inp => {
if (!inp.dataset.manual) inp.value = due;
});
}
function removeLine(id){ document.getElementById('lr-'+id)?.remove(); recalc(); } function removeLine(id){ document.getElementById('lr-'+id)?.remove(); recalc(); }
function clearLines(){ document.getElementById('linesBody').innerHTML=''; _seq=0; recalc(); } function clearLines(){ document.getElementById('linesBody').innerHTML=''; _seq=0; recalc(); }
@@ -732,7 +770,7 @@ async function confirmApproval(){
}); });
const d = await r.json(); const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.message||'SAP error'); if (!r.ok || !d.success) throw new Error(d.message||'SAP error');
toast(`Purchase Request #${d.data?.DocEntry || ''} submitted — pending approval`, 'ok'); toast(`Purchase Request #${d.data?.DocEntry || ''} submitted. If it needed SAP approval it won't show under Open/Closed yet — check "Pending SAP Approval" in the status filter.`, 'ok');
resetForm(); resetForm();
_pendingPayload = null; _pendingPayload = null;
} catch(err){ toast(err.message,'err'); } } catch(err){ toast(err.message,'err'); }
@@ -749,11 +787,20 @@ function debounceSearch(){
_srchTimer = setTimeout(() => { _page=0; loadList(); }, 400); _srchTimer = setTimeout(() => { _page=0; loadList(); }, 400);
} }
// A Purchase Request that triggers one of SAP's own Approval Procedures
// isn't created as a real PurchaseRequests document at all — DI API creates
// it as a SAP Draft instead, sitting there until an SAP approver acts on it
// (see "SAP Approval" for that). It has no DocumentStatus (Open/Closed), so
// it never showed up here at all — the requester submitted it and then had
// no way to even confirm it existed. "Pending SAP Approval" queries the
// dedicated GET /purchase-request/drafts endpoint instead of the normal
// PurchaseRequests list.
async function loadList(){ async function loadList(){
const wrap = document.getElementById('listWrap'); const wrap = document.getElementById('listWrap');
wrap.innerHTML = '<div class="pr-empty">Loading…</div>'; wrap.innerHTML = '<div class="pr-empty">Loading…</div>';
const q = document.getElementById('srchQ').value.trim(); const q = document.getElementById('srchQ').value.trim();
const status = document.getElementById('srchStatus').value; const status = document.getElementById('srchStatus').value;
if (status === 'drafts'){ await loadDraftList(q); return; }
const skip = _page * PAGE_SIZE; const skip = _page * PAGE_SIZE;
let url = `/api/sap/purchase-requests?company=${_co}&$top=${PAGE_SIZE}&$skip=${skip}`; let url = `/api/sap/purchase-requests?company=${_co}&$top=${PAGE_SIZE}&$skip=${skip}`;
if (q) url += `&q=${encodeURIComponent(q)}`; if (q) url += `&q=${encodeURIComponent(q)}`;
@@ -762,6 +809,7 @@ async function loadList(){
const r = await fetch(url, { headers: ah() }); const r = await fetch(url, { headers: ah() });
const d = await r.json(); const d = await r.json();
const rows = d.data || []; const rows = d.data || [];
const total = typeof d.total === 'number' ? d.total : null;
if (!rows.length){ wrap.innerHTML='<div class="pr-empty">No purchase requests found.</div>'; document.getElementById('pgBar').style.display='none'; return; } 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 => ` wrap.innerHTML = `<div class="pr-list">${rows.map(pr => `
<div class="pr-card" onclick="openDoc(${pr.DocEntry})"> <div class="pr-card" onclick="openDoc(${pr.DocEntry})">
@@ -781,11 +829,43 @@ async function loadList(){
const pgBar = document.getElementById('pgBar'); const pgBar = document.getElementById('pgBar');
pgBar.style.display = 'flex'; pgBar.style.display = 'flex';
document.getElementById('pgPrev').disabled = _page === 0; document.getElementById('pgPrev').disabled = _page === 0;
document.getElementById('pgNext').disabled = rows.length < PAGE_SIZE; document.getElementById('pgNext').disabled = total!=null ? (_page+1)*PAGE_SIZE >= total : rows.length < PAGE_SIZE;
document.getElementById('pgInfo').textContent = `Page ${_page+1}`; document.getElementById('pgInfo').textContent = total!=null
? `Page ${_page+1} of ${Math.max(1,Math.ceil(total/PAGE_SIZE))} · ${total} request${total===1?'':'s'}`
: `Page ${_page+1}`;
} catch(err){ wrap.innerHTML=`<div class="pr-empty" style="color:var(--red)">${esc(err.message)}</div>`; } } catch(err){ wrap.innerHTML=`<div class="pr-empty" style="color:var(--red)">${esc(err.message)}</div>`; }
} }
async function loadDraftList(q){
const wrap = document.getElementById('listWrap');
document.getElementById('pgBar').style.display = 'none';
try {
const r = await fetch(`/api/sap/purchase-request/drafts?company=${_co}`, { headers: ah() });
const d = await r.json();
let rows = d.data || [];
if (q){
const needle = q.toUpperCase();
rows = rows.filter(pr => [pr.CardCode, pr.CardName, pr.Comments].some(v => (v||'').toUpperCase().includes(needle)));
}
if (!rows.length){ wrap.innerHTML = '<div class="pr-empty">No purchase requests pending SAP approval.</div>'; return; }
wrap.innerHTML = `<div class="pr-list">${rows.map(pr => `
<div class="pr-card" style="cursor:default">
<div class="pr-card-top">
<span class="pr-docnum">Draft #${esc(String(pr.DocEntry))}</span>
<span class="badge s-draft">⏳ Pending SAP Approval</span>
</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.RequriedDate||pr.DocDueDate)}</b></span>
${pr.CardCode ? `<span>Vendor <b>${esc(pr.CardCode)}</b></span>` : ''}
${(pr.U_Depart||pr.U_Department) ? `<span>Dept <b>${esc(pr.U_Depart||pr.U_Department)}</b></span>` : ''}
</div>
</div>`).join('')}</div>`;
} 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 pgMove(dir){ _page = Math.max(0, _page+dir); loadList(); }
function statusBadge(s){ function statusBadge(s){
@@ -901,12 +981,13 @@ async function docAction(action){
const today = new Date().toISOString().slice(0,10); const today = new Date().toISOString().slice(0,10);
document.getElementById('docDate').value = today; document.getElementById('docDate').value = today;
document.getElementById('docDueDate').value = today; document.getElementById('docDueDate').value = today;
document.getElementById('docDueDate').addEventListener('change', syncLineDates);
await loadWarehouses(); await loadWarehouses();
await loadDepartments(); await loadDepartments();
addLine(); addLine();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -276,6 +276,6 @@ async function exportExcel(){
render(); render();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+59 -7
View File
@@ -66,6 +66,7 @@ select.fi option{background:var(--surface2)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5} .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-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} .al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
.al-red{background:rgba(229,62,62,.1);border:1px solid rgba(229,62,62,.3);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} .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)}} @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-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
@@ -169,6 +170,7 @@ let _readyOrders=[];
let _readyLoading=false; let _readyLoading=false;
let _modalOpen=false; let _modalOpen=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
let _dateFrom='',_dateTo=''; // Due Date range, sent to the server (see loadReadyOrders())
let _viewOrderType=''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else let _viewOrderType=''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
// Receipt fields // Receipt fields
@@ -184,6 +186,8 @@ let _expDate='';
let _datesLocked=false; let _datesLocked=false;
let _variety=''; let _variety='';
let _postDate=''; let _postDate='';
let _backflushWarnings=[]; // [{itemCode,itemName,warehouse,required,onHand,shortfall}] — insufficient Backflush-component stock, see checkBackflushStock()
let _bfCheckTimer=null;
// Return Components (defective components back to stock after Issuance) // Return Components (defective components back to stock after Issuance)
let _retLines=[]; let _retLines=[];
@@ -237,9 +241,16 @@ async function loadReadyOrders(resetPage){
_readyLoading=true;render(); _readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:''; const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{ try{
const r=await api('GET',`/sap/production-orders?status=Released&readyToReceive=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`); const dateQ=(_dateFrom?`&dateFrom=${_dateFrom}`:'')+(_dateTo?`&dateTo=${_dateTo}`:'');
const r=await api('GET',`/sap/production-orders?status=Released&readyToReceive=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${dateQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
// Server-side WHERE (readyToReceive, routes/sap.js) now guarantees every
// returned row is already "at least partially issued, not yet fully
// received" — no client-side re-filter needed anymore. Re-filtering here
// used to be able to shrink a full 20-row page down to zero visible
// cards (an order not issued at all is still "Released"), while the
// pager kept counting every Released order as the total regardless.
_rawCount=(r.data||[]).length; _rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>(o._issueStatus==='full'||o._issueStatus==='partial')&&o._receiptStatus!=='done'); _readyOrders=r.data||[];
_total=(typeof r.total==='number')?r.total:null; _total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;} }catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render(); _readyLoading=false;render();
@@ -299,10 +310,12 @@ async function loadOrder(absEntry){
// FG group list. Non-blocking — the modal renders immediately. // FG group list. Non-blocking — the modal renders immediately.
_autoclaveQty='';_isFgItem=false; _autoclaveQty='';_isFgItem=false;
}catch(e){toast(e.message,'err');} }catch(e){toast(e.message,'err');}
_backflushWarnings=[];
_loadingOrder=false;render(); _loadingOrder=false;render();
if(_retLines.length) loadReturnBatches(); if(_retLines.length) loadReturnBatches();
if(_autoclaveEnabled&&_autoclaveGroups.length&&_order?.ItemNo) loadFgFlag(_order.ItemNo); if(_autoclaveEnabled&&_autoclaveGroups.length&&_order?.ItemNo) loadFgFlag(_order.ItemNo);
loadRecvUoms(); loadRecvUoms();
checkBackflushStock();
} }
// Unit (Inventory UOM) shown next to each Warehouse/Qty/Transaction Type // Unit (Inventory UOM) shown next to each Warehouse/Qty/Transaction Type
@@ -375,6 +388,38 @@ function refreshRecvTotal(){
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001; const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
btn.disabled=_submitting||!qtyMatches; btn.disabled=_submitting||!qtyMatches;
} }
// Backflush stock-shortage check depends on the total too — debounced so it
// doesn't fire on every keystroke.
clearTimeout(_bfCheckTimer);
_bfCheckTimer=setTimeout(checkBackflushStock,400);
}
// Pre-flight warning for SAP's own "-5002 consumed quantity would cause
// inventory to fall below zero": Backflush-type components are auto-consumed
// by SAP when the Receipt posts (never manually issued), so nothing else in
// this flow ever checks their stock. Purely informational — never blocks
// Post, since the admin/user may still want to proceed (e.g. SAP's "allow
// negative inventory" is enabled for that item) or fix stock afterwards.
async function checkBackflushStock(){
if(!_order?.AbsoluteEntry){_backflushWarnings=[];return;}
const qty=recvTotal();
if(!(qty>0)){_backflushWarnings=[];refreshBackflushWarn();return;}
try{
const r=await api('GET',`/sap/production-orders/${_order.AbsoluteEntry}/backflush-check?qty=${qty}&company=${encodeURIComponent(_co)}`);
_backflushWarnings=r.warnings||[];
}catch(_e){_backflushWarnings=[];}
refreshBackflushWarn();
}
function backflushWarnHtml(){
if(!_backflushWarnings.length)return'';
return `<div class="alert al-red" id="backflush-warn-inner">
<strong>⚠ Insufficient stock for ${_backflushWarnings.length} auto-consumed component${_backflushWarnings.length>1?'s':''}:</strong>
${_backflushWarnings.map(w=>`<div style="margin-top:4px">${esc(w.itemCode)} ${esc(w.itemName?'('+w.itemName+')':'')} — needs <strong>${fmtQty(w.required)}</strong> at Whse ${esc(w.warehouse)}, only <strong>${fmtQty(w.onHand)}</strong> on hand (short by ${fmtQty(w.shortfall)}).</div>`).join('')}
<div style="margin-top:6px;font-size:11px;opacity:.85">These items are Backflush-type (auto-consumed by SAP on Receipt, never manually issued). SAP will likely reject the post with error -5002 unless this stock is topped up first.</div>
</div>`;
}
function refreshBackflushWarn(){
const el=document.getElementById('backflush-warn');
if(el)el.innerHTML=backflushWarnHtml();
} }
// MFG/EXP — 6 selectable formats (2 full-date + 4 month-only). Entry is // MFG/EXP — 6 selectable formats (2 full-date + 4 month-only). Entry is
// picker-only (see rcptDateField()/openRcptDatePicker() below) — the field // picker-only (see rcptDateField()/openRcptDatePicker() below) — the field
@@ -672,18 +717,22 @@ function _doRender(){
<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>':''}${o.IsConsumable?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple)">CONSUMABLE</span>':''}</div> <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>':''}${o.IsConsumable?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple)">CONSUMABLE</span>':''}</div>
<div class="po-icon">📥</div> <div class="po-icon">📥</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</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> <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>${o.DueDate?` · Due <b>${fmtDate(o.DueDate)}</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>`:''} ${(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>`; </div>`).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px"> const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
<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"/> <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;min-width:220px"/>
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;align-self:center">Due</span>
<input class="fi" id="rp-datefrom" type="date" value="${esc(_dateFrom)}" style="font-size:12px"/>
<span style="font-size:11px;color:var(--text3);align-self:center">to</span>
<input class="fi" id="rp-dateto" type="date" value="${esc(_dateTo)}" style="font-size:12px"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--green);color:#fff;white-space:nowrap">Search</button> <button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--green);color:#fff;white-space:nowrap">Search</button>
</div>`; </div>`;
const typeRow=CAN_CREATE_CONSUMABLE?`<div style="display:flex;gap:4px;margin-bottom:10px;flex-wrap:wrap;align-items:center"> const typeRow=CAN_CREATE_CONSUMABLE?`<div style="display:flex;gap:4px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span> <span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span>
${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')} ${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')}
</div>`:''; </div>`:'';
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Receive<span class="right">${_readyOrders.length}</span></div> c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Receive<span class="right">${_total!=null?_total:_readyOrders.length}</span></div>
<div class="card-bd"> <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> <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}${typeRow}${gridBody}${renderPager()}</div>`; ${searchRow}${typeRow}${gridBody}${renderPager()}</div>`;
@@ -697,6 +746,8 @@ function _doRender(){
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;}); 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-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-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
q('#rp-datefrom')&&(q('#rp-datefrom').onchange=function(){_dateFrom=this.value;loadReadyOrders(true);});
q('#rp-dateto')&&(q('#rp-dateto').onchange=function(){_dateTo=this.value;loadReadyOrders(true);});
document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);}); document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadReadyOrders(true);});
q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}}); q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}});
q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}}); q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}});
@@ -797,6 +848,7 @@ function renderModal(){
</div>`;}).join('')}</div> </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> <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 id="recv-total" style="margin-top:6px;font-size:11px;color:var(--text3)">${recvTotalHtml()}</div>
<div id="backflush-warn" style="margin-top:8px">${backflushWarnHtml()}</div>
</div> </div>
<div class="fg" style="margin-top:4px;border-top:1px solid var(--border);padding-top:10px"> <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"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--text);font-weight:600">
@@ -1005,6 +1057,6 @@ window.addEventListener('hashchange',()=>{
} }
}); });
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -184,6 +184,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -956,7 +956,7 @@ function renderStep3(){
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}render();})(); (async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}render();})();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1131,6 +1131,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -570,6 +570,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -479,6 +479,6 @@ window.exportCsv = exportCsv;
window.switchReport = switchReport; window.switchReport = switchReport;
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1058,6 +1058,6 @@ function wireView(){
if(_tab==='view')loadList(); else loadNextRef(); if(_tab==='view')loadList(); else loadNextRef();
loadReviewConfig(); loadReviewConfig();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -120,7 +120,7 @@ tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
<link rel="stylesheet" href="/theme.css?v=3"/> <link rel="stylesheet" href="/theme.css?v=3"/>
</head> </head>
<body> <body>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
<div class="pg"> <div class="pg">
<div class="hdr"> <div class="hdr">
+335
View File
@@ -0,0 +1,335 @@
<!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>Sales Admin — 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;--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}
*{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:1500px;margin:0 auto}
.page-title{font-size:16px;font-weight:700;margin-bottom:10px}
.tabs{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var(--border);overflow-x:auto}
.tab{padding:8px 14px;font-size:12.5px;font-weight:700;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}.tab.on{color:var(--blue);border-bottom-color:var(--blue)}
.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);flex-wrap:wrap}
.card-hd .r{margin-left:auto;display:flex;gap:8px;align-items:center}
.card-bd{padding:12px 14px}
.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;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}
.lst td.n,.lst th.n{text-align:right;font-family:var(--mono)}
.lst tbody tr.click{cursor:pointer}.lst tbody tr.click:hover td{background:rgba(26,111,255,.06)}
.tbl-wrap{overflow:auto;max-height:70vh}
.mono{font-family:var(--mono)}.empty{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.chk{display:inline-flex;gap:5px;align-items:center;font-size:12px;margin:0 12px 6px 0}
.perm-grid td{padding:4px 8px}.perm-grid input{transform:scale(1.1)}
.ac{position:relative}.ac-list{position:absolute;z-index:50;left:0;right:0;top:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;max-height:260px;overflow:auto}
.ac-list div{padding:7px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border)}.ac-list div:hover{background:rgba(26,111,255,.12)}
.off{opacity:.5}.off .sw,.off .sw-l{opacity:1}
/* on/off switch */
.sw{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;cursor:pointer}
.sw input{opacity:0;width:0;height:0}
.sw span{position:absolute;inset:0;background:#64748b;border-radius:21px;transition:.2s}
.sw span:before{content:'';position:absolute;width:15px;height:15px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}
.sw input:checked+span{background:var(--green)}.sw input:checked+span:before{transform:translateX(17px)}
.sw input:disabled+span{opacity:.5}
.sw-l{font-size:11px;font-weight:700;margin-left:6px;color:var(--red)}.sw-l.on{color:var(--green)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
<script src="/sales-common.js?v=4"></script>
</head>
<body>
<main id="app"><div class="empty"><span class="sc-spin"></span></div></main>
<script>
'use strict';
const { esc, money, dt, q, qa, toast, api } = SC;
let ME = null, TAB = null, STEPS = [], UTYPES = [];
const PERMS = ['view', 'add', 'edit', 'approve', 'delete'], PL = { view: 'V', add: 'Ad', edit: 'Ed', approve: 'Ap', delete: 'Del' };
const MODS = [['sales-orders', 'Sales Orders'], ['sales-samples', 'Sample Requests'], ['sales-reports', 'Sales Reports'], ['sales-admin', 'Sales Admin']];
const user = (() => { try { return JSON.parse(sessionStorage.getItem('portal_user') || '{}'); } catch (_e) { return {}; } })();
const isAdmin = () => ME.isAdmin;
const isUserAdmin = () => ME.isAdmin || ['system_admin', 'sap_adder'].includes(user.role);
const cap = (k, p) => ME.caps[k] && ME.caps[k].includes(p);
const ALLDIV = () => ME.allDivisions || ME.divisions; // admin screens always list disabled categories too
const divName = id => ((ME.allDivisions || ME.divisions).find(d => d.id === id) || {}).name || id; // incl. disabled categories (existing orders)
const ac = (input, list, onPick) => { let t; input.oninput = () => { clearTimeout(t); const v = input.value.trim(); if (v.length < 2) { list.style.display = 'none'; return; }
t = setTimeout(async () => { const r = await api('/customers?q=' + encodeURIComponent(v)).catch(e => { toast(e.message); return []; });
list.innerHTML = r.map(c => `<div data-cc="${esc(c.cardCode)}" data-n="${esc(c.cardName)}" data-e="${esc(c.email)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)} · ${esc(c.city)}</span></div>`).join('') || '<div class="sc-muted">No match</div>';
list.style.display = 'block'; qa('[data-cc]', list).forEach(d => { d.onclick = () => { list.style.display = 'none'; onPick({ cardCode: d.dataset.cc, cardName: d.dataset.n, email: d.dataset.e }); }; }); }, 300); }; };
async function init() {
ME = await api('/me');
const steps = await fetch('/api/approval-steps', { headers: { Authorization: 'Bearer ' + SC.getToken() } }).then(r => r.json()).catch(() => ({}));
const groups = (steps && steps.data) || [];
STEPS = (Array.isArray(groups) ? groups : []).filter(g => /^sales_/.test(g.workflow)).flatMap(g => (g.steps || []).map(s => ({ key: `${g.workflow}:${s.key || s.stepKey}`, label: s.label || s.stepLabel, wf: g.label || g.workflow })));
if (!STEPS.length) STEPS = Object.keys(ME.caps).map(k => ({ key: k, label: k, wf: k.split(':')[0] }));
const tabs = available();
TAB = tabs[0] ? tabs[0][0] : null;
render();
}
function available() {
return [['users', 'Sales Users', isUserAdmin()], ['types', 'User Types & Permissions', isUserAdmin()], ['products', 'Products & Prices', isAdmin() || cap('sales_master:products', 'view')],
['persons', 'Sales Persons & Mapping', isAdmin() || cap('sales_master:mapping', 'view')], ['customers', 'Customer Logins', isAdmin() || cap('sales_master:customers', 'view')],
['divisions', 'Product Categories', isAdmin()], ['settings', 'Settings', isAdmin()]].filter(t => t[2]);
}
function render() {
const tabs = available();
q('#app').innerHTML = `<div class="page-title">Sales Admin</div>
${tabs.length ? `<div class="tabs">${tabs.map(([k, l]) => `<div class="tab ${TAB === k ? 'on' : ''}" data-t="${k}">${l}</div>`).join('')}</div><div id="pane"></div>` : '<div class="sc-err">You have no Sales admin permissions.</div>'}`;
qa('.tab').forEach(t => { t.onclick = () => { TAB = t.dataset.t; render(); }; });
if (TAB) ({ users, types, products, persons, customers, divisions, settings })[TAB]();
}
const pane = () => q('#pane');
// ── Sales users ─────────────────────────────────────────────────────────────
async function users() {
pane().innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
UTYPES = await api('/admin/user-types');
const list = await api('/admin/users');
const ut = id => (UTYPES.find(t => t.id === id) || {}).name || '';
pane().innerHTML = `<div class="sc-info">Assign each employee a <b>Sales user type</b> (same 18 types as msale) and the <b>product categories</b> they handle. Saving with "apply template" writes the type's default
Sales approval steps & page access onto the user (other modules are left untouched). Fine-tune individual steps afterwards in <a class="sc-link" href="/admin">Admin → Users</a>. Users pick up changes on their next login / token refresh.</div>
<div class="card"><div class="card-hd">ERP users <span class="r"><input class="sc-in" id="u-q" placeholder="Filter…" style="width:220px"><label class="chk"><input type="checkbox" id="u-only"> Only with a sales profile</label></span></div>
<div class="tbl-wrap"><table class="lst"><thead><tr><th>User</th><th>Email</th><th>ERP role</th><th>Sales user type</th><th>Categories</th><th>Sales steps</th></tr></thead><tbody id="u-tb"></tbody></table></div></div>`;
const draw = () => {
const f = q('#u-q').value.toLowerCase(), only = q('#u-only').checked;
q('#u-tb').innerHTML = list.filter(u => (!only || u.profile) && (!f || `${u.username} ${u.fullName} ${u.email}`.toLowerCase().includes(f))).map(u => `<tr class="click ${u.active ? '' : 'off'}" data-id="${u.id}">
<td><b>${esc(u.fullName || u.username)}</b><div class="sc-muted">${esc(u.username)}</div></td><td>${esc(u.email)}</td><td>${esc(u.role)}</td>
<td>${u.profile ? esc(ut(u.profile.userTypeId)) : '<span class="sc-muted">—</span>'}</td><td>${u.profile ? u.profile.divisions.map(divName).map(esc).join(', ') : ''}</td>
<td class="sc-muted">${u.salesSteps.length}</td></tr>`).join('');
qa('#u-tb tr').forEach(tr => { tr.onclick = () => editProfile(list.find(u => u.id === +tr.dataset.id), users); });
};
q('#u-q').oninput = draw; q('#u-only').onchange = draw; draw();
}
function editProfile(u, after) {
const p = u.profile || { divisions: [], userTypeId: '', empCode: '', territory: '', ccEmails: '' };
SC.modal({ title: `Sales profile — ${esc(u.fullName || u.username)}`, body: `
<div class="sc-row"><div><label class="sc-lbl">Sales user type</label><select class="sc-in" id="p-ut"><option value="">— None —</option>${UTYPES.map(t => `<option value="${t.id}" ${t.id === p.userTypeId ? 'selected' : ''}>${t.id} · ${esc(t.name)} (${esc(t.scope)})</option>`).join('')}</select></div>
<div><label class="sc-lbl">Employee code</label><input class="sc-in" id="p-ec" value="${esc(p.empCode)}"></div></div>
<label class="sc-lbl" style="margin-top:8px">Product categories (divisions)</label>
<div>${ALLDIV().map(d => `<label class="chk"><input type="checkbox" class="p-div" value="${d.id}" ${p.divisions.includes(d.id) ? 'checked' : ''}> ${esc(d.name)} (${esc(d.code)})</label>`).join('')}</div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Territory</label><input class="sc-in" id="p-tr" value="${esc(p.territory)}"></div><div><label class="sc-lbl">Extra CC emails</label><input class="sc-in" id="p-cc" value="${esc(p.ccEmails)}"></div></div>
<label class="chk" style="margin-top:10px"><input type="checkbox" id="p-ap" ${u.profile ? '' : 'checked'}> Apply the user type's default Sales permissions & page access (replaces this user's existing sales_* steps)</label>
${u.salesSteps.length ? `<div class="sc-muted" style="margin-top:6px">Current sales steps: ${u.salesSteps.map(s => esc(typeof s === 'string' ? s : `${s.step} [${(s.perms || []).map(x => PL[x]).join('/')}]`)).join(', ')}</div>` : ''}`,
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
await api(`/admin/users/${u.id}/profile`, { method: 'PUT', body: { userTypeId: q('#p-ut', root).value, divisions: qa('.p-div:checked', root).map(c => +c.value), empCode: q('#p-ec', root).value,
territory: q('#p-tr', root).value, ccEmails: q('#p-cc', root).value, applyTemplate: q('#p-ap', root).checked } });
toast('Sales profile saved', 'ok'); after(); } }] });
}
// ── User types (permission templates) ───────────────────────────────────────
async function types() {
UTYPES = await api('/admin/user-types');
pane().innerHTML = `<div class="sc-info"><b>Scope</b>: <i>all</i> = every customer in the user's categories · <i>mapped</i> = only customers mapped to them as a sales person (msale SM) · <i>own</i> = only orders they created (msale Tender).
<b>Default list statuses</b> = the order list's default filter (msale showed Accounts 6–9, Logistics 2,7,8,9). Editing a type does not change existing users until you re-apply it from Sales Users.</div>
<div class="card"><div class="card-hd">User types ${isAdmin() ? '<span class="r"><button class="sc-btn sc-b-primary" id="t-new">+ New type</button></span>' : ''}</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>ID</th><th>Name</th><th>Scope</th><th>Default statuses</th><th>Pages</th><th>Default steps</th></tr></thead><tbody>
${UTYPES.map(t => `<tr class="click ${t.active ? '' : 'off'}" data-id="${t.id}"><td>${t.id}</td><td><b>${esc(t.name)}</b></td><td>${esc(t.scope)}</td><td>${esc(t.listStatuses || 'all')}</td>
<td>${t.defaultModules.map(m => esc((MODS.find(x => x[0] === m) || [m, m])[1])).join(', ')}</td><td class="sc-muted" style="white-space:normal">${t.defaultSteps.map(s => `${esc(s.step)} [${s.perms.map(x => PL[x]).join('/')}]`).join(', ')}</td></tr>`).join('')}
</tbody></table></div></div>`;
qa('tr[data-id]').forEach(tr => { tr.onclick = () => editType(UTYPES.find(t => t.id === +tr.dataset.id)); });
if (q('#t-new')) q('#t-new').onclick = () => editType(null);
}
function editType(t) {
const isNew = !t; t = t || { id: Math.max(...UTYPES.map(x => x.id)) + 1, name: '', scope: 'all', listStatuses: '', defaultSteps: [], defaultModules: [], active: true };
const has = (k, p) => (t.defaultSteps.find(s => s.step === k) || { perms: [] }).perms.includes(p);
let lastWf = '';
SC.modal({ title: isNew ? 'New user type' : `User type ${t.id} — ${esc(t.name)}`, wide: true, body: `
<div class="sc-row"><div style="max-width:100px"><label class="sc-lbl">ID</label><input class="sc-in" id="y-id" value="${t.id}" ${isNew ? '' : 'disabled'}></div><div><label class="sc-lbl">Name</label><input class="sc-in" id="y-n" value="${esc(t.name)}"></div>
<div><label class="sc-lbl">Scope</label><select class="sc-in" id="y-s">${['all', 'mapped', 'own'].map(s => `<option ${t.scope === s ? 'selected' : ''}>${s}</option>`).join('')}</select></div>
<div><label class="sc-lbl">Default list statuses (e.g. 6,7,8,9)</label><input class="sc-in" id="y-l" value="${esc(t.listStatuses)}"></div>
<div style="max-width:110px"><label class="sc-lbl">Active</label><select class="sc-in" id="y-a"><option value="1">Yes</option><option value="0" ${t.active ? '' : 'selected'}>No</option></select></div></div>
<label class="sc-lbl" style="margin-top:10px">Pages</label><div>${MODS.map(([k, l]) => `<label class="chk"><input type="checkbox" class="y-mod" value="${k}" ${t.defaultModules.includes(k) ? 'checked' : ''}> ${l}</label>`).join('')}</div>
<label class="sc-lbl" style="margin-top:10px">Default approval steps</label>
<table class="sc-tbl perm-grid"><thead><tr><th>Step</th>${PERMS.map(p => `<th>${PL[p]}</th>`).join('')}</tr></thead><tbody>
${STEPS.map(s => { const head = s.wf !== lastWf ? `<tr><td colspan="6" style="font-weight:700;color:var(--blue)">${esc(s.wf)}</td></tr>` : ''; lastWf = s.wf;
return head + `<tr><td>${esc(s.label)} <span class="sc-muted">${esc(s.key)}</span></td>${PERMS.map(p => `<td><input type="checkbox" class="y-p" data-k="${esc(s.key)}" data-p="${p}" ${has(s.key, p) ? 'checked' : ''}></td>`).join('')}</tr>`; }).join('')}
</tbody></table>`,
buttons: [{ label: 'Cancel' }, ...(isAdmin() ? [{ label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
const steps = {}; qa('.y-p:checked', root).forEach(c => { (steps[c.dataset.k] = steps[c.dataset.k] || []).push(c.dataset.p); });
await api('/admin/user-types', { method: 'POST', body: { id: +q('#y-id', root).value, name: q('#y-n', root).value, scope: q('#y-s', root).value, listStatuses: q('#y-l', root).value,
active: q('#y-a', root).value === '1', defaultModules: qa('.y-mod:checked', root).map(c => c.value), defaultSteps: Object.entries(steps).map(([step, perms]) => ({ step, perms })) } });
toast('Saved', 'ok'); types(); } }] : [])] });
}
// ── Products ────────────────────────────────────────────────────────────────
async function products() {
const cat = (q('#pr-cat') || {}).value || 'domestic';
pane().innerHTML = `${ME.settings.productSource === 'sap' ? '<div class="sc-warn">Product source is set to <b>SAP</b> (Settings) — customers order from SAP items & the SAP price list. This catalog is only used for export samples until you switch back to <b>catalog</b>.</div>' : ''}
<div class="card"><div class="card-hd">Product catalog <span class="r"><select class="sc-in" id="pr-cat" style="width:160px"><option value="domestic">Domestic</option><option value="export">Export</option></select>
<select class="sc-in" id="pr-div" style="width:200px"><option value="">All categories</option>${ALLDIV().map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select>
<input class="sc-in" id="pr-q" placeholder="Filter…" style="width:200px">${isAdmin() || cap('sales_master:products', 'add') ? '<button class="sc-btn sc-b-primary" id="pr-new">+ Product</button>' : ''}</span></div>
<div class="tbl-wrap" id="pr-t"><div class="empty"><span class="sc-spin"></span></div></div></div>`;
q('#pr-cat').value = cat;
let list = [];
const draw = () => { const f = q('#pr-q').value.toLowerCase(), dv = q('#pr-div').value;
q('#pr-t').innerHTML = `<table class="lst"><thead><tr><th>Display code</th><th>SAP item code</th><th>Description</th><th>Category</th><th class="n">Pack size</th><th>HSN</th><th class="n">Unit price</th><th>Active</th></tr></thead><tbody>
${list.filter(p => (!dv || p.divisionId === +dv) && (!f || `${p.displayCode} ${p.itemCode} ${p.description}`.toLowerCase().includes(f))).map(p => `<tr class="click ${p.active ? '' : 'off'}" data-id="${p.id}">
<td class="mono">${esc(p.displayCode)}</td><td class="mono">${esc(p.itemCode)}</td><td>${esc(p.description)}</td><td>${esc(divName(p.divisionId))}</td><td class="n">${p.packSize}</td><td>${esc(p.hsn)}</td><td class="n">${money(p.price)}</td><td>${p.active ? 'Yes' : 'No'}</td></tr>`).join('')}</tbody></table>`;
qa('#pr-t tr[data-id]').forEach(tr => { tr.onclick = () => editProduct(list.find(p => p.id === +tr.dataset.id)); }); };
const load = async () => { list = await api('/admin/products?catalog=' + q('#pr-cat').value); draw(); };
q('#pr-cat').onchange = load; q('#pr-div').onchange = draw; q('#pr-q').oninput = draw;
if (q('#pr-new')) q('#pr-new').onclick = () => editProduct(null);
load();
function editProduct(p) {
p = p || { catalog: q('#pr-cat').value, divisionId: +q('#pr-div').value || ALLDIV()[0].id, itemCode: '', displayCode: '', description: '', shortDesc: '', packSize: 1, hsn: '', price: 0, isInstrument: false, active: true };
const canEdit = isAdmin() || cap('sales_master:products', p.id ? 'edit' : 'add');
SC.modal({ title: p.id ? `Product ${esc(p.displayCode)}` : 'New product', body: `
<div class="sc-row"><div><label class="sc-lbl">Catalog</label><select class="sc-in" id="e-cat"><option value="domestic">Domestic</option><option value="export" ${p.catalog === 'export' ? 'selected' : ''}>Export</option></select></div>
<div><label class="sc-lbl">Category</label><select class="sc-in" id="e-div">${ALLDIV().map(d => `<option value="${d.id}" ${d.id === p.divisionId ? 'selected' : ''}>${esc(d.name)}</option>`).join('')}</select></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">SAP item code * (posted to SAP)</label><input class="sc-in" id="e-ic" value="${esc(p.itemCode)}"></div><div><label class="sc-lbl">Display code (shown to customers)</label><input class="sc-in" id="e-dc" value="${esc(p.displayCode)}"></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Description</label><input class="sc-in" id="e-de" value="${esc(p.description)}"></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Pack size (units per pack)</label><input class="sc-in" type="number" min="1" id="e-ps" value="${p.packSize}"></div><div><label class="sc-lbl">HSN</label><input class="sc-in" id="e-hs" value="${esc(p.hsn)}"></div>
<div><label class="sc-lbl">Unit price (₹, before tax)</label><input class="sc-in" type="number" min="0" step="0.01" id="e-pr" value="${p.price}"></div>
<div><label class="sc-lbl">Active</label><select class="sc-in" id="e-ac"><option value="1">Yes</option><option value="0" ${p.active ? '' : 'selected'}>No</option></select></div></div>`,
buttons: [{ label: 'Cancel' }, ...(canEdit ? [{ label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
await api('/admin/products', { method: 'POST', body: { id: p.id, catalog: q('#e-cat', root).value, divisionId: +q('#e-div', root).value, itemCode: q('#e-ic', root).value.trim(), displayCode: q('#e-dc', root).value.trim(),
description: q('#e-de', root).value, packSize: q('#e-ps', root).value, hsn: q('#e-hs', root).value, price: q('#e-pr', root).value, active: q('#e-ac', root).value === '1' } });
toast('Saved', 'ok'); load(); } }] : [])] });
}
}
// ── Sales persons & mapping ─────────────────────────────────────────────────
async function persons() {
const list = await api('/admin/sales-persons');
const canEdit = isAdmin() || cap('sales_master:mapping', 'edit');
pane().innerHTML = `<div class="sc-info">Sales persons (msale <i>sales_person_master</i>) are mapped to customers. Users whose Sales user type has scope <b>mapped</b> see only their mapped customers (matched by linked ERP user or the same email);
mapped persons are also CC'd on that customer's order emails for their categories.</div>
<div class="card"><div class="card-hd">Sales persons ${canEdit ? '<span class="r"><button class="sc-btn sc-b-primary" id="sp-new">+ Sales person</button></span>' : ''}</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>Name</th><th>Email</th><th>Designation</th><th>Categories</th><th class="n">Customers</th><th></th></tr></thead><tbody>
${list.map(p => `<tr class="${p.active ? '' : 'off'}"><td><b>${esc(p.name)}</b></td><td>${esc(p.email)}</td><td>${esc(p.designation)}</td><td>${p.divisions.map(divName).map(esc).join(', ')}</td><td class="n">${p.customerCount}</td>
<td><span class="sc-link" data-map="${p.id}">Customers</span>${canEdit ? ` · <span class="sc-link" data-ed="${p.id}">Edit</span>` : ''}</td></tr>`).join('')}</tbody></table></div></div>`;
if (q('#sp-new')) q('#sp-new').onclick = () => editPerson(null);
qa('[data-ed]').forEach(el => { el.onclick = () => editPerson(list.find(p => p.id === +el.dataset.ed)); });
qa('[data-map]').forEach(el => { el.onclick = () => mapping(list.find(p => p.id === +el.dataset.map)); });
function editPerson(p) {
p = p || { name: '', email: '', designation: 'Sales Manager', divisions: [], active: true };
SC.modal({ title: p.id ? 'Edit sales person' : 'New sales person', body: `<div class="sc-row"><div><label class="sc-lbl">Name *</label><input class="sc-in" id="s-n" value="${esc(p.name)}"></div><div><label class="sc-lbl">Email</label><input class="sc-in" id="s-e" value="${esc(p.email)}"></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Designation</label><input class="sc-in" id="s-d" value="${esc(p.designation)}"></div><div><label class="sc-lbl">Active</label><select class="sc-in" id="s-a"><option value="1">Yes</option><option value="0" ${p.active ? '' : 'selected'}>No</option></select></div></div>
<label class="sc-lbl" style="margin-top:8px">Categories</label><div>${ALLDIV().map(d => `<label class="chk"><input type="checkbox" class="s-dv" value="${d.id}" ${p.divisions.includes(d.id) ? 'checked' : ''}> ${esc(d.name)}</label>`).join('')}</div>`,
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
await api('/admin/sales-persons', { method: 'POST', body: { id: p.id, name: q('#s-n', root).value, email: q('#s-e', root).value, designation: q('#s-d', root).value, active: q('#s-a', root).value === '1', divisions: qa('.s-dv:checked', root).map(c => +c.value) } });
toast('Saved', 'ok'); persons(); } }] });
}
async function mapping(p) {
const m = SC.modal({ title: `Customers mapped to ${esc(p.name)}`, wide: true, body: `${isAdmin() || cap('sales_master:mapping', 'add') ? `<div class="ac" style="margin-bottom:10px"><label class="sc-lbl">Add customer</label><input class="sc-in" id="m-q" placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="m-ac" style="display:none"></div></div>` : ''}
<div id="m-l"><span class="sc-spin"></span></div>`, buttons: [{ label: 'Close', onClick: () => { persons(); } }] });
const R = m.root;
const load = async () => { const l = await api(`/admin/sales-persons/${p.id}/customers`);
q('#m-l', R).innerHTML = `<table class="sc-tbl"><thead><tr><th>Code</th><th>Name</th><th></th></tr></thead><tbody>${l.map(x => `<tr><td class="mono">${esc(x.cardCode)}</td><td>${esc(x.cardName)}</td>
<td>${isAdmin() || cap('sales_master:mapping', 'delete') ? `<span class="sc-link" data-rm="${x.id}">Remove</span>` : ''}</td></tr>`).join('') || '<tr><td colspan="3" class="sc-muted">No customers mapped</td></tr>'}</tbody></table>`;
qa('[data-rm]', R).forEach(el => { el.onclick = async () => { await api('/admin/sp-map/' + el.dataset.rm, { method: 'DELETE' }).catch(e => toast(e.message)); load(); }; }); };
if (q('#m-q', R)) ac(q('#m-q', R), q('#m-ac', R), async c => { await api(`/admin/sales-persons/${p.id}/customers`, { method: 'POST', body: { cardCodes: [c.cardCode] } }).catch(e => toast(e.message)); q('#m-q', R).value = ''; load(); });
load();
}
}
// ── Customer logins ─────────────────────────────────────────────────────────
async function customers() {
const canEdit = isAdmin() || cap('sales_master:customers', 'edit') || cap('sales_master:customers', 'add');
pane().innerHTML = `<div class="sc-info">Customers log in at <a class="sc-link" href="/customer-portal" target="_blank">/customer-portal</a> with their <b>SAP customer code</b>. Name, addresses and sales person come live from SAP.
Passwords migrated from msale keep working and are upgraded to a secure hash at first login. 5 wrong passwords lock the login.</div>
<div class="card"><div class="card-hd">Customer logins <span class="r"><input class="sc-in" id="c-q" placeholder="Search code / name…" style="width:240px"><button class="sc-btn sc-b-ghost" id="c-go">Search</button>
${canEdit ? '<button class="sc-btn sc-b-primary" id="c-new">+ Enable a customer</button>' : ''}</span></div><div class="tbl-wrap" id="c-t"></div></div>`;
const load = async () => { q('#c-t').innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
const l = await api('/admin/customers?q=' + encodeURIComponent(q('#c-q').value.trim()));
q('#c-t').innerHTML = `<table class="lst"><thead><tr><th>Code</th><th>Name</th><th>Email</th><th>Active</th><th>Locked</th><th>Last login</th><th class="n">Credit limit</th></tr></thead><tbody>
${l.map(c => `<tr class="click ${c.active ? '' : 'off'}" data-cc="${esc(c.cardCode)}"><td class="mono">${esc(c.cardCode)}</td><td>${esc(c.cardName)}</td><td>${esc(c.email)}</td><td>${c.active ? 'Yes' : 'No'}</td>
<td>${c.locked ? '<b style="color:var(--red)">Locked</b>' : ''}</td><td>${dt(c.lastLogin)}</td><td class="n">${money(c.creditLimit)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty">No customers (max 500 shown — search to narrow)</td></tr>'}</tbody></table>`;
qa('#c-t tr[data-cc]').forEach(tr => { tr.onclick = () => edit(l.find(c => c.cardCode === tr.dataset.cc)); }); };
q('#c-go').onclick = load; q('#c-q').onkeydown = e => { if (e.key === 'Enter') load(); };
if (q('#c-new')) q('#c-new').onclick = () => edit(null);
load();
function edit(c) {
const isNew = !c; c = c || { cardCode: '', cardName: '', email: '', active: true, locked: false, creditLimit: 0 };
const m = SC.modal({ title: isNew ? 'Enable customer login' : `Customer login — ${esc(c.cardCode)}`, body: `
${isNew ? `<div class="ac"><label class="sc-lbl">SAP customer *</label><input class="sc-in" id="k-q" placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="k-ac" style="display:none"></div></div>` : `<div class="sc-kv"><b>Customer</b><span>${esc(c.cardName)} (${esc(c.cardCode)})</span></div>`}
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Notification email</label><input class="sc-in" id="k-e" value="${esc(c.email)}"></div><div><label class="sc-lbl">Credit limit (₹)</label><input class="sc-in" type="number" min="0" id="k-cl" value="${c.creditLimit}"></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">${isNew ? 'Initial password *' : 'Reset password (leave blank to keep)'}</label><input class="sc-in" id="k-p" type="text" autocomplete="off"></div>
<div><label class="sc-lbl">Active</label><select class="sc-in" id="k-a"><option value="1">Yes</option><option value="0" ${c.active ? '' : 'selected'}>No</option></select></div>
<div><label class="sc-lbl">Locked</label><select class="sc-in" id="k-l"><option value="0">No</option><option value="1" ${c.locked ? 'selected' : ''}>Yes</option></select></div></div>
<label class="chk" style="margin-top:8px"><input type="checkbox" id="k-mail" checked> Email the portal link, customer code and ${isNew ? 'initial' : 'new'} password to the customer${ME.settings.emailTestMode !== false ? ` <b style="color:var(--amber)">(Test mode — goes to ${esc(ME.settings.emailTestRecipient || 'nobody')} only)</b>` : ''}</label>
<div class="sc-muted" style="margin-top:6px">A new / reset password must be changed by the customer at next login. The email is sent only when a login is created or its password is reset.</div>`,
buttons: [{ label: 'Cancel' }, ...(canEdit ? [{ label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
if (!c.cardCode) throw new Error('Select a customer');
const r = await api('/admin/customers', { method: 'POST', body: { cardCode: c.cardCode, cardName: c.cardName, email: q('#k-e', root).value, creditLimit: q('#k-cl', root).value, password: q('#k-p', root).value,
active: q('#k-a', root).value === '1', locked: q('#k-l', root).value === '1', sendEmail: q('#k-mail', root).checked } });
toast(r.emailed ? `Saved — login email sent${ME.settings.emailTestMode !== false ? ' (test mode: to ' + ME.settings.emailTestRecipient + ')' : ''}` : 'Saved', 'ok'); load(); } }] : [])] });
if (isNew) ac(q('#k-q', m.root), q('#k-ac', m.root), x => { c.cardCode = x.cardCode; c.cardName = x.cardName; q('#k-q', m.root).value = `${x.cardName} (${x.cardCode})`; if (!q('#k-e', m.root).value) q('#k-e', m.root).value = x.email; });
}
}
// ── Divisions ───────────────────────────────────────────────────────────────
async function divisions() {
const list = await api('/admin/divisions');
pane().innerHTML = `<div class="sc-info">Product categories = msale's <i>product_type</i>. <b>SAP item groups</b> are used when Product source = SAP; <b>Warehouse</b> is set on every SAP order line (blank = SAP default).</div>
<div class="card"><div class="card-hd">Product categories <span class="r"><button class="sc-btn sc-b-primary" id="d-new">+ Category</button></span></div><div class="tbl-wrap"><table class="lst"><thead><tr><th>ID</th><th>Name</th><th>Code</th><th>SAP item groups</th><th>Warehouse</th><th>Sort</th><th>Enabled</th></tr></thead><tbody>
${list.map(d => `<tr class="click ${d.active ? '' : 'off'}" data-id="${d.id}"><td>${d.id}</td><td><b>${esc(d.name)}</b></td><td>${esc(d.code)}</td><td>${esc(d.itemGroups)}</td><td>${esc(d.warehouse)}</td><td>${d.sort}</td>
<td><label class="sw" title="${d.active ? 'Enabled — click to disable' : 'Disabled — click to enable'}"><input type="checkbox" class="d-tg" data-id="${d.id}" ${d.active ? 'checked' : ''}><span></span></label>
<span class="sw-l ${d.active ? 'on' : ''}">${d.active ? 'Enabled' : 'Disabled'}</span></td></tr>`).join('')}</tbody></table></div></div>
<div class="sc-muted">A <b>disabled</b> category is hidden when customers or employees create new orders / sample requests, and from the category filters. Orders already raised in it keep flowing (approval, payment, SAP posting, invoices) and stay visible.</div>`;
qa('.d-tg').forEach(cb => {
const td = cb.closest('td'); td.onclick = e => e.stopPropagation(); // don't open the edit form
cb.onchange = async () => {
const d = list.find(x => x.id === +cb.dataset.id); const on = cb.checked;
if (!on && !(await SC.confirmBox(`Disable ${esc(d.name)}?`, `Customers and employees will no longer be able to place new orders or sample requests in <b>${esc(d.name)}</b>. Existing orders are not affected.`, 'Disable', 'sc-b-red'))) { cb.checked = true; return; }
cb.disabled = true;
try {
const r = await api(`/admin/divisions/${d.id}/active`, { method: 'PUT', body: { active: on } });
toast(`${d.name} ${on ? 'enabled' : 'disabled'}${!on && r.openOrders ? ` — ${r.openOrders} open order(s) in it continue as normal` : ''}`, 'ok');
ME = await api('/me'); divisions();
} catch (e) { toast(e.message); cb.checked = !on; cb.disabled = false; }
};
});
const edit = d => { const isNew = !d; d = d || { id: '', name: '', code: '', itemGroups: '', warehouse: '01', sort: list.length + 1, active: true };
SC.modal({ title: isNew ? 'New category' : `Category ${d.id}`, body: `<div class="sc-row"><div><label class="sc-lbl">ID *</label><input class="sc-in" id="v-id" value="${d.id}" ${isNew ? '' : 'disabled'}></div><div><label class="sc-lbl">Name *</label><input class="sc-in" id="v-n" value="${esc(d.name)}"></div><div><label class="sc-lbl">Code</label><input class="sc-in" id="v-c" value="${esc(d.code)}"></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">SAP item groups (comma separated)</label><input class="sc-in" id="v-g" value="${esc(d.itemGroups)}"></div><div><label class="sc-lbl">Warehouse</label><input class="sc-in" id="v-w" value="${esc(d.warehouse)}"></div>
<div><label class="sc-lbl">Sort</label><input class="sc-in" type="number" id="v-s" value="${d.sort}"></div><div><label class="sc-lbl">Active</label><select class="sc-in" id="v-a"><option value="1">Yes</option><option value="0" ${d.active ? '' : 'selected'}>No</option></select></div></div>`,
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
await api('/admin/divisions', { method: 'POST', body: { id: q('#v-id', root).value, name: q('#v-n', root).value, code: q('#v-c', root).value, itemGroups: q('#v-g', root).value, warehouse: q('#v-w', root).value, sort: q('#v-s', root).value, active: q('#v-a', root).value === '1' } });
toast('Saved', 'ok'); ME = await api('/me'); divisions(); } }] }); };
qa('tr[data-id]').forEach(tr => { tr.onclick = () => edit(list.find(d => d.id === +tr.dataset.id)); });
q('#d-new').onclick = () => edit(null);
}
// ── Settings ────────────────────────────────────────────────────────────────
async function settings() {
const s = await api('/admin/settings');
const companies = window.fetchCompanyList ? await window.fetchCompanyList().catch(() => []) : [];
const f = (k, l, type = 'text', extra = '') => `<div><label class="sc-lbl">${l}</label><input class="sc-in" id="g-${k}" type="${type}" value="${esc(s[k] == null ? '' : s[k])}" ${extra}></div>`;
pane().innerHTML = `<div class="card"><div class="card-hd">SAP & products</div><div class="card-bd">
<div class="sc-row"><div><label class="sc-lbl">SAP company for sales orders</label><select class="sc-in" id="g-company">${(companies.length ? companies : [{ value: s.company, label: s.company }]).map(c => `<option value="${esc(c.value)}" ${c.value === s.company ? 'selected' : ''}>${esc(c.label || c.value)}</option>`).join('')}</select></div>
<div><label class="sc-lbl">Product source</label><select class="sc-in" id="g-productSource"><option value="catalog">ERP catalog (migrated msale products & prices)</option><option value="sap" ${s.productSource === 'sap' ? 'selected' : ''}>SAP items + SAP price list</option></select></div>
${f('sapPriceList', 'SAP price list no. (SAP source)', 'number')}${f('sapSeries', 'SAP ORDR series (blank = default)', 'number')}</div>
<label class="chk" style="margin-top:10px"><input type="checkbox" id="g-autoPostToSap" ${s.autoPostToSap ? 'checked' : ''}> Auto-post orders to SAP (shared .env SAP account) as soon as they are ready — otherwise a user with "Post to SAP" does it</label></div></div>
<div class="card"><div class="card-hd">GST</div><div class="card-bd"><div class="sc-row">${f('companyState', 'Company state code (intra-state)')}${f('intraTaxCode', 'Intra-state SAP tax code')}${f('cgstRate', 'CGST %', 'number', 'step="0.01"')}${f('sgstRate', 'SGST %', 'number', 'step="0.01"')}</div>
<div class="sc-row" style="margin-top:8px">${f('interTaxCode', 'Inter-state SAP tax code')}${f('igstRate', 'IGST %', 'number', 'step="0.01"')}${f('tcsRate', 'TCS % (0 = off)', 'number', 'step="0.001"')}</div>
<div class="sc-muted" style="margin-top:6px">Tax is decided by the Ship-To state. Existing orders keep the rates they were created with.</div></div></div>
<div class="card"><div class="card-hd">Payments & email</div><div class="card-bd">
<label class="chk" style="margin-bottom:8px"><input type="checkbox" id="g-emailNotifications" ${s.emailNotifications !== false ? 'checked' : ''}> Send Sales email notifications (customers, BUH, Logistics, Accounts, sales persons). The ERP-wide switch in Admin → System Settings → Stage-Change Email Notifications must also be on.</label>
<label class="chk" style="margin-bottom:8px"><input type="checkbox" id="g-invoiceEmail" ${s.invoiceEmail !== false ? 'checked' : ''}> Email "invoice raised" (invoice no., amount, items, LR details) to the customer, sales person, BUH, Logistics & Accounts when SAP invoices an order
<span class="sc-muted">— checked every 15 min; invoices dated before ${esc(s.invoiceEmailSince || '—')} are not emailed</span></label>
<div class="sc-warn" style="color:inherit"><label class="chk" style="margin:0"><input type="checkbox" id="g-emailTestMode" ${s.emailTestMode !== false ? 'checked' : ''}> <b>Test mode</b> — send every Sales email ONLY to the test address below (real recipients are listed inside the email). Untick at go-live.</label>
<div style="margin-top:6px"><label class="sc-lbl">Test recipient(s), comma separated</label><input class="sc-in" id="g-emailTestRecipient" value="${esc(s.emailTestRecipient || '')}"></div></div>
<div class="sc-row">${f('roundingTolerance', 'Under-payment tolerance (₹, written off as rebate)', 'number')}${f('orderEmailCc', 'Extra CC on every order email')}</div>
<div class="sc-row" style="margin-top:8px">${f('bankApiUrl', 'Bank auto-match API URL (optional — msale sapapi check_payment)')}${f('bankApiKey', 'Bank API key', 'password')}</div></div></div>
<button class="sc-btn sc-b-primary" id="g-save">Save settings</button>`;
q('#g-save').onclick = async () => {
const b = { company: q('#g-company').value, productSource: q('#g-productSource').value, autoPostToSap: q('#g-autoPostToSap').checked, emailNotifications: q('#g-emailNotifications').checked, invoiceEmail: q('#g-invoiceEmail').checked,
emailTestMode: q('#g-emailTestMode').checked, emailTestRecipient: q('#g-emailTestRecipient').value.trim() };
if (!b.emailTestMode && !(await SC.confirmBox('Turn off email test mode?', 'Sales emails will now go to <b>real customers and employees</b>. Continue?', 'Yes, go live', 'sc-b-red'))) return;
if (b.emailTestMode && !b.emailTestRecipient) return toast('Enter a test recipient, or untick test mode');
['sapPriceList', 'sapSeries', 'cgstRate', 'sgstRate', 'igstRate', 'tcsRate', 'roundingTolerance'].forEach(k => { const v = q('#g-' + k).value; b[k] = v === '' ? null : +v; });
['companyState', 'intraTaxCode', 'interTaxCode', 'orderEmailCc', 'bankApiUrl', 'bankApiKey'].forEach(k => { b[k] = q('#g-' + k).value.trim(); });
try { await api('/admin/settings', { method: 'PUT', body: b }); toast('Settings saved', 'ok'); ME = await api('/me'); } catch (e) { toast(e.message); }
};
}
init().catch(e => { q('#app').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; });
</script>
<script src="/company-list.js"></script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+362
View File
@@ -0,0 +1,362 @@
/* public/sales-common.js — shared UI helpers for the Sales Order module
* (employee pages + customer portal). Exposes window.SC.
* Configure before use: SC.base ('/api/sales' or '/api/sales-portal') and
* SC.getToken (fn returning the bearer token), SC.onUnauthorized.
*/
(function () {
'use strict';
const SC = window.SC = {};
SC.base = '/api/sales';
SC.getToken = () => { try { return sessionStorage.getItem('portal_token'); } catch (_e) { return null; } };
SC.onUnauthorized = () => { location.href = '/'; };
// ── styles ────────────────────────────────────────────────────────────────
const css = `
.sc-modal-bg{position:fixed;inset:0;background:rgba(3,8,16,.62);z-index:900;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:28px 10px}
.sc-modal{background:var(--surface,#0f1d30);border:1px solid var(--border2,#243650);border-radius:12px;width:100%;max-width:760px;box-shadow:0 18px 60px rgba(0,0,0,.55);color:var(--text,#c8d6e8)}
.sc-modal.wide{max-width:1180px}
.sc-mh{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--border,#1c2e48);font-weight:700;font-size:14px}
.sc-mh .x{margin-left:auto;background:none;border:none;color:var(--text2,#7a9ab8);font-size:20px;cursor:pointer;line-height:1}
.sc-mb{padding:14px 16px}
.sc-mf{display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--border,#1c2e48);flex-wrap:wrap}
.sc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}
.sc-kv{font-size:12px}.sc-kv b{display:block;font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--text3,#3d5470);margin-bottom:2px;font-weight:700}
.sc-kv span{white-space:pre-wrap}
.sc-sec{margin-top:14px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text2,#7a9ab8);border-bottom:1px solid var(--border,#1c2e48);padding-bottom:5px;margin-bottom:8px}
.sc-tbl{width:100%;border-collapse:collapse;font-size:12px}
.sc-tbl th{text-align:left;padding:6px 8px;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;color:var(--text3,#3d5470);border-bottom:2px solid var(--border2,#243650);white-space:nowrap}
.sc-tbl td{padding:6px 8px;border-bottom:1px solid var(--border,#1c2e48);vertical-align:top}
.sc-tbl td.n,.sc-tbl th.n{text-align:right;font-family:var(--mono,monospace)}
.sc-tbl tr.tot td{font-weight:700;border-top:2px solid var(--border2,#243650)}
.sc-pill{display:inline-block;font-size:10px;font-weight:700;padding:2px 9px;border-radius:999px;white-space:nowrap}
.sc-in{width:100%;padding:7px 9px;border:1.5px solid var(--border2,#243650);border-radius:7px;font-size:12.5px;background:var(--surface2,#142338);color:var(--text,#c8d6e8);font-family:inherit;outline:none}
.sc-in:focus{border-color:var(--blue,#1a6fff)} textarea.sc-in{min-height:58px;resize:vertical}
.sc-lbl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3,#3d5470);margin-bottom:3px;display:block}
.sc-btn{padding:7px 14px;border-radius:8px;border:none;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.sc-btn:disabled{opacity:.5;cursor:not-allowed}
.sc-b-primary{background:var(--blue,#1a6fff);color:#fff}.sc-b-green{background:var(--green,#0dbb8a);color:#fff}
.sc-b-red{background:var(--red,#e53e3e);color:#fff}.sc-b-amber{background:var(--amber,#f5a623);color:#111}
.sc-b-ghost{background:var(--surface2,#142338);color:var(--text2,#7a9ab8);border:1px solid var(--border2,#243650)}
.sc-remarks{white-space:pre-wrap;font-size:12px;background:var(--surface3,#0b1520);border:1px solid var(--border,#1c2e48);border-radius:8px;padding:8px 10px;max-height:160px;overflow:auto}
.sc-warn{background:rgba(245,166,35,.12);border:1px solid rgba(245,166,35,.4);color:var(--amber,#f5a623);border-radius:8px;padding:8px 10px;font-size:12px;margin:8px 0}
.sc-info{background:rgba(26,111,255,.1);border:1px solid rgba(26,111,255,.35);border-radius:8px;padding:8px 10px;font-size:12px;margin:8px 0}
.sc-err{background:rgba(229,62,62,.12);border:1px solid rgba(229,62,62,.4);color:#ff8a8a;border-radius:8px;padding:8px 10px;font-size:12px;margin:8px 0;white-space:pre-wrap}
.sc-diff{background:rgba(229,62,62,.14)!important}
.sc-toast-c{position:fixed;top:14px;right:10px;z-index:2000;display:flex;flex-direction:column;gap:6px}
.sc-toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:0 8px 24px rgba(0,0,0,.4);max-width:420px}
.sc-t-err{background:#e53e3e;color:#fff}.sc-t-ok{background:#0dbb8a;color:#fff}
.sc-row{display:flex;gap:10px;flex-wrap:wrap}.sc-row>*{flex:1;min-width:160px}
.sc-link{color:var(--blue,#1a6fff);cursor:pointer;font-weight:600;text-decoration:none}.sc-link:hover{text-decoration:underline}
.sc-muted{color:var(--text3,#3d5470);font-size:11px}
.sc-ps{position:relative}
.sc-ps-list{position:absolute;z-index:60;left:0;right:0;top:100%;margin-top:2px;background:var(--surface2,#142338);border:1px solid var(--border2,#243650);border-radius:8px;max-height:300px;overflow:auto;box-shadow:0 10px 30px rgba(0,0,0,.45)}
.sc-ps-list div{padding:7px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border,#1c2e48);display:flex;gap:6px;align-items:baseline}
.sc-ps-list div.on,.sc-ps-list div:hover{background:rgba(26,111,255,.14)}
.sc-ps-price{margin-left:auto;white-space:nowrap;color:var(--text2,#7a9ab8);font-family:var(--mono,monospace);font-size:11px}
.sc-tl{font-size:11.5px}.sc-tl div{padding:4px 0;border-bottom:1px dashed var(--border,#1c2e48)}
.sc-spin{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:#1a6fff;border-radius:50%;animation:scspin .6s linear infinite;display:inline-block;vertical-align:middle}
@keyframes scspin{to{transform:rotate(360deg)}}
@media (max-width:640px){.sc-modal-bg{padding:0}.sc-modal{border-radius:0;min-height:100vh}.sc-tbl{font-size:11px}}`;
const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
// ── basics ────────────────────────────────────────────────────────────────
SC.esc = v => (v == null ? '' : String(v)).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
SC.money = v => (v == null || v === '' ? '—' : '₹ ' + Number(v).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
SC.num = v => (v == null || v === '' ? '' : Number(v).toLocaleString('en-IN', { maximumFractionDigits: 3 }));
const p2 = n => String(n).padStart(2, '0');
SC.date = v => { if (!v) return '—'; const d = new Date(v); return isNaN(d) ? '—' : `${p2(d.getDate())}-${p2(d.getMonth() + 1)}-${d.getFullYear()}`; };
SC.dt = v => { if (!v) return '—'; const d = new Date(v); return isNaN(d) ? '—' : `${SC.date(d)} ${p2(d.getHours())}:${p2(d.getMinutes())}`; };
SC.iso = v => { if (!v) return ''; const d = new Date(v); return isNaN(d) ? '' : `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; };
SC.today = () => SC.iso(new Date());
SC.q = (s, root) => (root || document).querySelector(s);
SC.qa = (s, root) => Array.from((root || document).querySelectorAll(s));
SC.toast = (m, kind) => {
let c = SC.q('.sc-toast-c'); if (!c) { c = document.createElement('div'); c.className = 'sc-toast-c'; document.body.appendChild(c); }
const e = document.createElement('div'); e.className = 'sc-toast ' + (kind === 'ok' ? 'sc-t-ok' : 'sc-t-err'); e.textContent = m; c.appendChild(e);
setTimeout(() => e.remove(), kind === 'ok' ? 3500 : 6500);
};
SC.api = async (p, opts = {}) => {
const headers = { Authorization: 'Bearer ' + SC.getToken() };
const o = { method: opts.method || 'GET', headers };
if (opts.form) o.body = opts.form;
else if (opts.body !== undefined) { headers['Content-Type'] = 'application/json'; o.body = JSON.stringify(opts.body); }
const r = await fetch(SC.base + p, o);
if (r.status === 401) { SC.onUnauthorized(); throw new Error('Session expired'); }
const d = await r.json().catch(() => ({ success: false, message: 'HTTP ' + r.status }));
if (!r.ok || d.success === false) throw new Error(d.message || ('HTTP ' + r.status));
return d.data;
};
// Open an authenticated file (doc / COA / invoice PDF) in a new tab.
SC.openFile = async (p) => {
const w = window.open('', '_blank'); if (w) w.document.write('<p style="font-family:sans-serif">Loading…</p>');
try {
const r = await fetch(SC.base + p, { headers: { Authorization: 'Bearer ' + SC.getToken() } });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.message || 'HTTP ' + r.status); }
const url = URL.createObjectURL(await r.blob());
if (w) w.location.href = url; else window.open(url, '_blank');
} catch (e) { if (w) w.close(); SC.toast(e.message); }
};
SC.upload = async (file, fields) => {
const fd = new FormData(); fd.append('file', file); Object.entries(fields).forEach(([k, v]) => fd.append(k, v));
return SC.api('/docs', { method: 'POST', form: fd });
};
SC.csv = (filename, headers, rows) => {
const cell = v => { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
const text = [headers.map(cell).join(','), ...rows.map(r => r.map(cell).join(','))].join('\r\n');
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob(['' + text], { type: 'text/csv' })); a.download = filename; a.click();
};
// ── show/hide password (eye button) ───────────────────────────────────────
const EYE = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const EYE_OFF = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 19c-6.5 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c6.5 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
const eyeCss = document.createElement('style');
eyeCss.textContent = `.sc-pw{position:relative;display:block}.sc-pw>input{padding-right:42px!important}
.sc-pw-eye{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:32px;height:30px;border:none;background:transparent;cursor:pointer;color:var(--text2,#7a9ab8);display:flex;align-items:center;justify-content:center;border-radius:6px}
.sc-pw-eye:hover{color:var(--blue,#1a6fff);background:rgba(26,111,255,.1)}`;
document.head.appendChild(eyeCss);
// Wrap each password input in `root` with an eye toggle (idempotent).
SC.eyeify = (root) => {
SC.qa('input[type="password"]', root).forEach(inp => {
if (inp.parentElement.classList.contains('sc-pw')) return;
// Move the input's bottom margin onto the wrapper so the eye stays vertically centred on the field.
const mb = getComputedStyle(inp).marginBottom; inp.style.marginBottom = '0';
const w = document.createElement('span'); w.className = 'sc-pw'; if (mb && mb !== '0px') w.style.marginBottom = mb;
inp.parentNode.insertBefore(w, inp); w.appendChild(inp);
const b = document.createElement('button'); b.type = 'button'; b.className = 'sc-pw-eye'; b.title = 'Show password'; b.setAttribute('aria-label', 'Show password'); b.innerHTML = EYE;
b.addEventListener('mousedown', e => e.preventDefault());
b.addEventListener('click', () => { const show = inp.type === 'password'; inp.type = show ? 'text' : 'password'; b.innerHTML = show ? EYE_OFF : EYE;
b.title = show ? 'Hide password' : 'Show password'; b.setAttribute('aria-label', b.title); inp.focus(); });
w.appendChild(b);
});
};
// ── status pills ──────────────────────────────────────────────────────────
const COLORS = { 1: '#f5a623', 2: '#06b6d4', 3: '#06b6d4', 4: '#9f5afd', 5: '#ec4899', 6: '#f5a623', 7: '#1a6fff', 8: '#0dbb8a', 9: '#64748b', 10: '#e53e3e', 11: '#e53e3e' };
SC.pill = (status, label) => { const c = COLORS[status] || '#64748b'; return `<span class="sc-pill" style="background:${c}22;color:${c};border:1px solid ${c}55">${SC.esc(label)}</span>`; };
SC.typeLabel = o => (o.orderType === 'DIRECT' ? 'Direct' : o.soType === 1 ? 'Trade' : 'Institution');
// ── modal (closes ONLY via its buttons — never on backdrop click) ─────────
SC.modal = ({ title, body, wide, buttons = [] }) => {
const bg = document.createElement('div'); bg.className = 'sc-modal-bg';
bg.innerHTML = `<div class="sc-modal ${wide ? 'wide' : ''}"><div class="sc-mh"><span>${title}</span><button class="x" title="Close">×</button></div>
<div class="sc-mb">${body}</div>${buttons.length ? '<div class="sc-mf"></div>' : ''}</div>`;
document.body.appendChild(bg); document.body.style.overflow = 'hidden';
const close = () => { bg.remove(); if (!SC.q('.sc-modal-bg')) document.body.style.overflow = ''; };
SC.q('.x', bg).onclick = close;
const f = SC.q('.sc-mf', bg);
buttons.forEach(b => {
const el = document.createElement('button'); el.className = 'sc-btn ' + (b.cls || 'sc-b-ghost'); el.textContent = b.label;
el.onclick = async () => {
if (!b.onClick) return close();
el.disabled = true; const old = el.textContent; el.innerHTML = '<span class="sc-spin"></span> ' + SC.esc(old);
try { const keep = await b.onClick({ close, root: bg }); if (keep !== true) close(); }
catch (e) { SC.toast(e.message); } finally { el.disabled = false; el.textContent = old; }
};
f.appendChild(el);
});
return { root: bg, close };
};
SC.confirmBox = (title, html, okLabel, okCls) => new Promise(res => {
SC.modal({ title, body: html, buttons: [{ label: 'Back', onClick: () => res(false) }, { label: okLabel || 'Confirm', cls: okCls || 'sc-b-primary', onClick: () => res(true) }] });
});
// ── tax/total preview (server recomputes authoritatively) ────────────────
SC.taxFor = (settings, shipState) => {
const intra = String(shipState || '').toUpperCase() === String(settings.companyState || '').toUpperCase();
return intra ? { taxCode: settings.intraTaxCode, igst: 0, cgst: +settings.cgstRate || 0, sgst: +settings.sgstRate || 0, tcs: +settings.tcsRate || 0 }
: { taxCode: settings.interTaxCode, igst: +settings.igstRate || 0, cgst: 0, sgst: 0, tcs: +settings.tcsRate || 0 };
};
const r2 = n => Math.round((+n || 0) * 100) / 100;
SC.totals = (lines, tax) => {
const sub = r2(lines.reduce((a, l) => a + l.lineTotal, 0));
const igst = r2(sub * tax.igst / 100), cgst = r2(sub * tax.cgst / 100), sgst = r2(sub * tax.sgst / 100);
const tcs = tax.tcs > 0 ? r2((sub + igst + cgst + sgst) * tax.tcs / 100) : 0;
return { sub, igst, cgst, sgst, tcs, grand: r2(sub + igst + cgst + sgst + tcs) };
};
SC.totalsRows = (t, tax, colspan) => `
<tr class="tot"><td colspan="${colspan}" class="n">Total before taxes</td><td class="n">${SC.money(t.sub)}</td></tr>
${tax.igst ? `<tr><td colspan="${colspan}" class="n">IGST @ ${tax.igst}%</td><td class="n">${SC.money(t.igst)}</td></tr>` : ''}
${tax.cgst ? `<tr><td colspan="${colspan}" class="n">CGST @ ${tax.cgst}%</td><td class="n">${SC.money(t.cgst)}</td></tr>` : ''}
${tax.sgst ? `<tr><td colspan="${colspan}" class="n">SGST @ ${tax.sgst}%</td><td class="n">${SC.money(t.sgst)}</td></tr>` : ''}
${t.tcs ? `<tr><td colspan="${colspan}" class="n">TCS @ ${tax.tcs}%</td><td class="n">${SC.money(t.tcs)}</td></tr>` : ''}
<tr class="tot"><td colspan="${colspan}" class="n">Grand total</td><td class="n">${SC.money(t.grand)}</td></tr>`;
// ── cart editor ───────────────────────────────────────────────────────────
// container: element; products: [{key,displayCode,description,price,packSize,hsn}]
// opts: { lines:[{key,qty,specialPrice,commission}], allowSpecial, allowCommission, settings, getShipState }
SC.cartEditor = (container, products, opts = {}) => {
const st = { lines: (opts.lines || []).map(l => ({ ...l })) };
const byKey = Object.fromEntries(products.map(p => [p.key, p]));
const compute = () => st.lines.map(l => {
const p = byKey[l.key] || {}; const eff = +l.specialPrice > 0 ? +l.specialPrice : +p.price || 0;
return { ...l, p, nop: Math.max(1, Math.ceil((+l.qty || 0) / (p.packSize || 1))), lineTotal: r2((+l.qty || 0) * eff), diff: +l.specialPrice > 0 && Math.abs(+l.specialPrice - (+p.price || 0)) > 0.0001 };
});
// Searchable product picker: matches every typed word against item code,
// display code and description; ↑/↓ to move, Enter to pick, Esc to close.
function wirePicker() {
const qi = SC.q('.sc-prod-q', container), hid = SC.q('.sc-prod', container), list = SC.q('.sc-ps-list', container);
if (!qi) return;
let hits = [], hi = 0;
const label = p => `${p.displayCode} — ${p.description}`;
const draw = () => {
const words = qi.value.toLowerCase().split(/\s+/).filter(Boolean);
hits = products.filter(p => { const s = `${p.displayCode} ${p.itemCode} ${p.description} ${p.shortDesc || ''}`.toLowerCase(); return words.every(w => s.includes(w)); }).slice(0, 60);
hi = Math.min(hi, Math.max(0, hits.length - 1));
list.innerHTML = hits.length ? hits.map((p, i) => `<div data-k="${SC.esc(p.key)}" class="${i === hi ? 'on' : ''}"><b class="mono">${SC.esc(p.displayCode)}</b> — ${SC.esc(p.description)}
<span class="sc-ps-price">${SC.money(p.price)}${st.lines.some(l => l.key === p.key) ? ' · in cart' : ''}</span></div>`).join('')
: '<div class="sc-muted" style="cursor:default">No matching product in this category</div>';
list.style.display = 'block';
SC.qa('[data-k]', list).forEach(el => { el.onmousedown = e => { e.preventDefault(); pick(el.dataset.k); }; });
};
const pick = key => { const p = byKey[key]; if (!p) return; hid.value = key; qi.value = label(p); list.style.display = 'none'; const qq = SC.q('.sc-qty', container); qq.focus(); qq.select(); };
qi.oninput = () => { hid.value = ''; hi = 0; draw(); };
qi.onfocus = () => { if (!hid.value) draw(); };
qi.onblur = () => setTimeout(() => { list.style.display = 'none'; }, 120);
qi.onkeydown = e => {
if (list.style.display === 'none' && ['ArrowDown', 'ArrowUp'].includes(e.key)) { draw(); return; }
if (e.key === 'ArrowDown') { hi = Math.min(hi + 1, hits.length - 1); draw(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { hi = Math.max(hi - 1, 0); draw(); e.preventDefault(); }
else if (e.key === 'Enter') { if (hits[hi]) pick(hits[hi].key); e.preventDefault(); }
else if (e.key === 'Escape') list.style.display = 'none';
};
SC.q('.sc-qty', container).onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); SC.q('.sc-add', container).click(); } };
}
function render() {
const ls = compute();
const tax = SC.taxFor(opts.settings || {}, opts.getShipState ? opts.getShipState() : '');
const t = SC.totals(ls, tax);
const cols = 6 + (opts.allowSpecial ? 1 : 0) + (opts.allowCommission ? 1 : 0);
container.innerHTML = `
<div class="sc-row" style="align-items:flex-end;margin-bottom:8px">
<div style="flex:3" class="sc-ps"><label class="sc-lbl">Product${opts.categoryName ? ` — ${SC.esc(opts.categoryName)}` : ''} (${products.length})</label>
<input class="sc-in sc-prod-q" autocomplete="off" ${products.length ? '' : 'disabled'}
placeholder="${products.length ? 'Search by item code or description…' : 'No products in this category'}">
<input type="hidden" class="sc-prod"><div class="sc-ps-list" style="display:none"></div></div>
<div style="flex:1;min-width:90px"><label class="sc-lbl">Qty</label><input class="sc-in sc-qty" type="number" min="1" step="1"></div>
<div style="flex:0 0 auto;min-width:auto"><button type="button" class="sc-btn sc-b-primary sc-add">+ Add</button></div>
</div>
<div style="overflow-x:auto"><table class="sc-tbl"><thead><tr><th>#</th><th>Item</th><th>Description</th><th class="n">Qty</th><th class="n">Packs</th><th class="n">Price</th>
${opts.allowSpecial ? '<th class="n">Special price</th>' : ''}${opts.allowCommission ? '<th class="n">Comm %</th>' : ''}<th class="n">Amount</th><th></th></tr></thead><tbody>
${ls.length ? ls.map((l, i) => `<tr data-i="${i}">
<td>${i + 1}</td><td class="mono">${SC.esc(l.p.displayCode || l.key)}</td><td style="min-width:160px">${SC.esc(l.p.description || '')}<div class="sc-muted">HSN ${SC.esc(l.p.hsn || '—')}</div></td>
<td class="n"><input class="sc-in sc-lq" type="number" min="1" step="1" value="${SC.esc(l.qty)}" style="width:90px;text-align:right"></td>
<td class="n">${l.nop}</td><td class="n">${SC.money(l.p.price)}</td>
${opts.allowSpecial ? `<td class="n ${l.diff ? 'sc-diff' : ''}"><input class="sc-in sc-lsp" type="number" min="0" step="0.01" value="${+l.specialPrice > 0 ? l.specialPrice : ''}" placeholder="—" style="width:100px;text-align:right"></td>` : ''}
${opts.allowCommission ? `<td class="n"><input class="sc-in sc-lcm" type="number" min="0" step="0.01" value="${+l.commission || ''}" style="width:70px;text-align:right"></td>` : ''}
<td class="n">${SC.money(l.lineTotal)}</td><td><button type="button" class="sc-btn sc-b-red sc-del" style="padding:3px 9px">×</button></td></tr>`).join('')
: `<tr><td colspan="${cols + 2}" class="sc-muted" style="text-align:center;padding:16px">No products added yet</td></tr>`}
${ls.length ? SC.totalsRows(t, tax, cols) + '<tr><td></td></tr>' : ''}
</tbody></table></div>
${ls.some(l => l.diff) ? '<div class="sc-warn">A special price differs from the list price — the order will be reviewed by MIPL before approval.</div>' : ''}`;
wirePicker();
SC.q('.sc-add', container).onclick = () => {
const key = SC.q('.sc-prod', container).value; const qty = +SC.q('.sc-qty', container).value;
if (!key) return SC.toast('Search and select a product from the list');
if (!(qty > 0)) return SC.toast('Enter a quantity');
const ex = st.lines.find(l => l.key === key);
if (ex) ex.qty = (+ex.qty || 0) + qty; else st.lines.push({ key, qty, specialPrice: 0, commission: 0 });
render(); opts.onChange && opts.onChange();
};
SC.qa('tr[data-i]', container).forEach(tr => {
const i = +tr.dataset.i;
const upd = (cls, f) => { const el = SC.q(cls, tr); if (el) el.onchange = () => { st.lines[i][f] = el.value === '' ? 0 : +el.value; render(); opts.onChange && opts.onChange(); }; };
upd('.sc-lq', 'qty'); upd('.sc-lsp', 'specialPrice'); upd('.sc-lcm', 'commission');
SC.q('.sc-del', tr).onclick = () => { st.lines.splice(i, 1); render(); opts.onChange && opts.onChange(); };
});
}
render();
return { count: () => st.lines.length, getLines: () => st.lines.filter(l => +l.qty > 0).map(l => ({ key: l.key, qty: +l.qty, specialPrice: +l.specialPrice || 0, commission: +l.commission || 0 })),
computed: compute, refresh: render };
};
// ── order detail view ─────────────────────────────────────────────────────
SC.orderHtml = (o, { employee, divisions, labels } = {}) => {
const divName = ((divisions || []).find(d => d.id === o.divisionId) || {}).name || o.divisionId;
const tax = { igst: o.igstRate, cgst: o.cgstRate, sgst: o.sgstRate, tcs: o.tcsRate };
const t = { sub: o.subTotal, igst: o.igstVal, cgst: o.cgstVal, sgst: o.sgstVal, tcs: o.tcsVal, grand: o.grandTotal };
const showSp = o.items.some(i => i.specialPrice > 0);
const showCm = employee && o.items.some(i => i.commission > 0);
const cols = 6 + (showSp ? 1 : 0) + (showCm ? 1 : 0);
return `
<div class="sc-grid">
<div class="sc-kv"><b>Order ref.</b><span class="mono">${SC.esc(o.refNo)}</span></div>
<div class="sc-kv"><b>Status</b>${SC.pill(o.status, employee ? o.statusLabel : o.customerStatusLabel)}</div>
<div class="sc-kv"><b>Type</b><span>${SC.typeLabel(o)}${o.legacy ? ' · migrated from msale' : ''}</span></div>
<div class="sc-kv"><b>Product category</b><span>${SC.esc(divName)}</span></div>
<div class="sc-kv"><b>Customer</b><span>${SC.esc(o.cardName)} (${SC.esc(o.cardCode)})</span></div>
<div class="sc-kv"><b>Customer ref. order no.</b><span>${SC.esc(o.custOrderNo || '—')}</span></div>
<div class="sc-kv"><b>Order date</b><span>${SC.date(o.orderDate || o.createdAt)}</span></div>
<div class="sc-kv"><b>Committed Dispatch Date</b><span>${SC.date(o.deliveryDate)}</span></div>
<div class="sc-kv"><b>Contact person</b><span>${SC.esc(o.contactPerson || '—')}</span></div>
<div class="sc-kv"><b>Sales employee</b><span>${SC.esc(o.salesEmployee || '—')}</span></div>
${o.creditDays != null ? `<div class="sc-kv"><b>Credit days</b><span>${SC.esc(o.creditDays)}</span></div>` : ''}
${o.sapDocNum ? `<div class="sc-kv"><b>SAP sales order</b><span class="mono">${SC.esc(o.sapDocNum)} · ${SC.date(o.sapOrderDate || o.sapPostedAt)}</span></div>` : ''}
<div class="sc-kv"><b>Created by</b><span>${SC.esc(o.createdByName)} · ${SC.dt(o.createdAt)}</span></div>
</div>
<div class="sc-row" style="margin-top:10px">
<div class="sc-kv"><b>Ship to — ${SC.esc(o.shipToCode)}</b><span>${SC.esc(o.shipToText)}</span></div>
<div class="sc-kv"><b>Bill to — ${SC.esc(o.billToCode)}</b><span>${SC.esc(o.billToText)}</span></div>
</div>
${o.sapError && employee ? `<div class="sc-err">Last SAP posting error: ${SC.esc(o.sapError)}</div>` : ''}
<div class="sc-sec">Items</div>
<div style="overflow-x:auto"><table class="sc-tbl"><thead><tr><th>#</th><th>Item</th><th>Description</th><th class="n">Qty</th><th class="n">Packs</th><th class="n">Price</th>
${showSp ? '<th class="n">Special price</th>' : ''}${showCm ? '<th class="n">Comm %</th>' : ''}<th class="n">Amount</th></tr></thead><tbody>
${o.items.map((i, n) => `<tr><td>${n + 1}</td><td class="mono">${SC.esc(i.displayCode)}${i.displayCode !== i.itemCode ? `<div class="sc-muted">SAP ${SC.esc(i.itemCode)}</div>` : ''}</td>
<td>${SC.esc(i.description)}<div class="sc-muted">HSN ${SC.esc(i.hsn || '—')}</div></td><td class="n">${SC.num(i.qty)}</td><td class="n">${SC.esc(i.nop || '')}</td>
<td class="n">${SC.money(i.price)}</td>${showSp ? `<td class="n ${i.specialPrice > 0 && Math.abs(i.specialPrice - i.price) > 0.0001 ? 'sc-diff' : ''}">${i.specialPrice > 0 ? SC.money(i.specialPrice) : '—'}</td>` : ''}
${showCm ? `<td class="n">${i.commission || ''}</td>` : ''}<td class="n">${SC.money(i.lineTotal)}</td></tr>`).join('')}
${SC.totalsRows(t, tax, cols)}</tbody></table></div>
${o.consignees && o.consignees.length ? `<div class="sc-sec">Consignees</div><table class="sc-tbl"><thead><tr><th>Ship to</th><th>Sales emp.</th><th>Items</th><th>SAP SO</th></tr></thead><tbody>
${o.consignees.map(c => `<tr><td><b>${SC.esc(c.shipToCode)}</b><div class="sc-muted" style="white-space:pre-wrap">${SC.esc(c.shipToText)}</div></td><td>${SC.esc(c.seName)}<div class="sc-muted">${SC.esc(c.seEmail)}</div></td>
<td>${c.items.map(x => `${SC.esc(x.itemCode)} × ${SC.num(x.qty)}`).join('<br>')}</td><td class="mono">${SC.esc(c.sapDocNum || '—')}</td></tr>`).join('')}</tbody></table>` : ''}
${o.paidAmount != null || o.payments && o.payments.length ? `<div class="sc-sec">Payment</div>
<div class="sc-grid">
<div class="sc-kv"><b>Paid amount</b><span>${SC.money(o.paidAmount)}</span></div>
<div class="sc-kv"><b>TDS</b><span>${SC.money(o.tdsVal)}</span></div>
<div class="sc-kv"><b>Adjusted from wallet</b><span>${SC.money(o.walletUsed)}</span></div>
${o.rebate ? `<div class="sc-kv"><b>Rounding rebate</b><span>${SC.money(o.rebate)}</span></div>` : ''}
<div class="sc-kv"><b>Mode / ref.</b><span>${SC.esc(o.paymentMode || '—')} ${SC.esc(o.paymentRef || '')}</span></div>
<div class="sc-kv"><b>Payment date</b><span>${SC.date(o.paymentDate)}</span></div>
<div class="sc-kv"><b>Payment status</b><span>${SC.esc(o.paidStatus || '—')}</span></div>
</div>
${(o.payments || []).length ? `<table class="sc-tbl" style="margin-top:8px"><thead><tr><th>Txn</th><th>Type</th><th class="n">Amount</th><th>Status</th><th>Ref</th><th>When</th></tr></thead><tbody>
${o.payments.map(p => `<tr><td>#${p.id}</td><td>${SC.esc(p.refType)} (${SC.esc(p.type)})</td><td class="n">${SC.money(p.amount)}</td><td>${SC.esc(p.status)}</td><td>${SC.esc(p.paymentRef || p.remarks || '')}</td><td>${SC.dt(p.createdAt)}</td></tr>`).join('')}</tbody></table>` : ''}` : ''}
<div class="sc-sec">Remarks</div>
<div class="sc-row">
<div><label class="sc-lbl">Customer remarks</label><div class="sc-remarks">${SC.esc(o.customerRemarks || '—')}</div></div>
<div><label class="sc-lbl">MIPL remarks</label><div class="sc-remarks">${SC.esc(o.employeeRemarks || '—')}</div></div>
${employee ? `<div><label class="sc-lbl">Internal remarks (not visible to customer)</label><div class="sc-remarks">${SC.esc(o.internalRemarks || '—')}</div></div>` : ''}
</div>
${(o.docs || []).length ? `<div class="sc-sec">Documents</div>${o.docs.map(d => `<div><span class="sc-link" data-doc="${d.id}">📄 ${SC.esc(d.title || d.origName || d.fileName)}</span>
<span class="sc-muted">${SC.esc(d.docType)} · ${SC.esc(d.createdByName)} · ${SC.dt(d.createdAt)}</span></div>`).join('')}` : ''}
<div class="sc-sec">History</div>
<div class="sc-tl">${(o.log || []).map(l => `<div><b>${SC.dt(l.at)}</b> — ${SC.esc(l.actorName)}: ${SC.esc(l.action.replace(/_/g, ' '))}${l.toStatus != null && l.toStatus !== l.fromStatus ? ` → <i>${SC.esc((labels || {})[l.toStatus] || l.toStatus)}</i>` : ''}${l.remarks ? ` — <span class="sc-muted">${SC.esc(l.remarks)}</span>` : ''}</div>`).join('') || '<span class="sc-muted">—</span>'}</div>
<div class="sc-sec">Invoices, dispatch & COA</div><div class="sc-inv" data-order="${o.id}">${[8, 9].includes(o.status) ? '<span class="sc-link sc-inv-load">Load from SAP</span>' : '<span class="sc-muted">Available once the order is placed in SAP.</span>'}</div>`;
};
// Wire the doc links + invoice loader inside a rendered order view.
SC.wireOrder = (root, orderId) => {
SC.qa('[data-doc]', root).forEach(el => { el.onclick = () => SC.openFile(`/docs/${el.dataset.doc}`); });
const box = SC.q('.sc-inv', root); const ld = box && SC.q('.sc-inv-load', box);
if (ld) ld.onclick = () => SC.loadInvoices(box, orderId);
};
SC.loadInvoices = async (box, orderId) => {
box.innerHTML = '<span class="sc-spin"></span> Loading from SAP…';
try {
const d = await SC.api(`/orders/${orderId}/invoices`);
if (!d.invoices.length) { box.innerHTML = '<span class="sc-muted">No invoice raised yet.</span>'; return; }
box.innerHTML = d.invoices.map(inv => `<div style="border:1px solid var(--border,#1c2e48);border-radius:8px;padding:8px 10px;margin-bottom:8px">
<div class="sc-row" style="align-items:center"><div><b>Invoice ${SC.esc(inv.invoiceNo)}</b> · ${SC.date(inv.invoiceDate)} · ${SC.money(inv.total)}</div>
<div style="text-align:right">${inv.hasPdf ? `<span class="sc-link" data-pdf="${SC.esc(inv.invoiceNo)}">📄 Invoice PDF</span>` : '<span class="sc-muted">Invoice PDF not uploaded yet</span>'}</div></div>
<div class="sc-muted" style="margin:4px 0">Dispatch — LR/GR/AWB: <b>${SC.esc(inv.dispatch.lrNo || '—')}</b>${inv.dispatch.lrDate ? ' · ' + SC.date(inv.dispatch.lrDate) : ''}${inv.dispatch.transporter ? ' · ' + SC.esc(inv.dispatch.transporter) : ''}${inv.dispatch.vehicle ? ' · ' + SC.esc(inv.dispatch.vehicle) : ''}${inv.dispatch.ewayBill ? ' · e-Way ' + SC.esc(inv.dispatch.ewayBill) : ''}</div>
${inv.attachments.length ? `<div>${inv.attachments.map(a => `<span class="sc-link" data-att="${a.absEntry}/${a.line}" style="margin-right:12px">📎 ${SC.esc(a.fileName)}.${SC.esc(a.ext)}</span>`).join('')}</div>` : ''}
<table class="sc-tbl" style="margin-top:6px"><thead><tr><th>Item</th><th class="n">Qty</th><th>Batches / COA</th></tr></thead><tbody>
${inv.lines.map(l => { const bs = inv.batches.filter(b => b.itemCode === l.itemCode); return `<tr><td>${SC.esc(l.itemCode)}<div class="sc-muted">${SC.esc(l.description)}</div></td><td class="n">${SC.num(l.qty)}</td>
<td>${bs.map(b => `${SC.esc(b.batch)} <span class="sc-muted">(exp ${SC.date(b.expDate)})</span> ${b.coa ? `<span class="sc-link" data-att="${b.coa.absEntry}/${b.coa.line}">COA</span>` : '<span class="sc-muted">no COA</span>'}`).join('<br>') || '<span class="sc-muted">—</span>'}</td></tr>`; }).join('')}
</tbody></table></div>`).join('');
SC.qa('[data-pdf]', box).forEach(el => { el.onclick = () => SC.openFile(`/invoice-pdf/${el.dataset.pdf}?orderId=${orderId}`); });
SC.qa('[data-att]', box).forEach(el => { el.onclick = () => SC.openFile(`/attachment/${el.dataset.att}?orderId=${orderId}`); });
} catch (e) { box.innerHTML = `<div class="sc-err">${SC.esc(e.message)}</div>`; }
};
})();
+351
View File
@@ -0,0 +1,351 @@
<!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>Sales Orders — 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;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--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}
*{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:1500px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
.page-title{font-size:16px;font-weight:700}.page-sub{font-size:11px;color:var(--text3)}
.head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.tabs{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var(--border)}
.tab{padding:8px 14px;font-size:12.5px;font-weight:700;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent}
.tab.on{color:var(--blue);border-bottom-color:var(--blue)}
.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-bd{padding:12px 14px}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}.filters>div{min-width:140px}
.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;z-index:1}
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
.lst td.n,.lst th.n{text-align:right;font-family:var(--mono)}
.lst tbody tr{cursor:pointer}.lst tbody tr:hover td{background:rgba(26,111,255,.06)}
.tbl-wrap{overflow:auto;max-height:68vh;border-radius:8px;border:1px solid var(--border2)}
.mono{font-family:var(--mono)}
.empty{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}
.ac{position:relative}.ac-list{position:absolute;z-index:50;left:0;right:0;top:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;max-height:260px;overflow:auto}
.ac-list div{padding:7px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border)}.ac-list div:hover{background:rgba(26,111,255,.12)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
<script src="/sales-common.js?v=4"></script>
</head>
<body>
<main id="app"><div class="empty"><span class="sc-spin"></span> Loading…</div></main>
<script>
'use strict';
const { esc, money, num, date, dt, q, qa, toast, api, pill } = SC;
let ME = null, DIVS = [], ROWS = [], TAB = 'orders';
const F = { type: '', divisionId: '', statuses: 'default', search: '', from: '', to: '' };
const cap = (k, p) => ME && ME.caps[k] && ME.caps[k].includes(p);
const divName = id => ((ME && ME.allDivisions) || DIVS).find(d => d.id === id)?.name || id;
const lbl = o => (o.orderType === 'DIRECT' ? ME.directStatusLabels : ME.statusLabels)[o.status] || o.statusLabel;
async function init() {
ME = await api('/me');
DIVS = ME.divisions;
const p = new URLSearchParams(location.search);
if (p.get('tab') === 'payments') TAB = 'payments';
render();
if (p.get('id')) openOrder(p.get('id'));
}
function render() {
const canPay = cap('sales_order:payment_verify', 'view') || cap('sales_order:payment_entry', 'view');
const prof = ME.profile;
q('#app').innerHTML = `
<div class="page-head"><div><div class="page-title">Sales Orders</div>
<div class="page-sub">${esc(prof.userTypeName || 'No sales user type assigned')} · divisions: ${prof.divisions.map(divName).map(esc).join(', ') || '<b style="color:var(--red)">none — ask an admin to set your Sales profile</b>'}${prof.scope !== 'all' ? ` · scope: ${esc(prof.scope === 'mapped' ? 'mapped customers only' : 'own orders only')}` : ''}</div></div>
<div class="head-actions">
${cap('sales_order:sap_post', 'approve') ? '<button class="sc-btn sc-b-ghost" id="b-sync" title="Link orders already in SAP and close fully-invoiced ones">⟳ Sync with SAP</button>' : ''}
${cap('sales_order:create_direct', 'add') ? '<button class="sc-btn sc-b-primary" id="b-new">+ New Direct Order</button>' : ''}
</div></div>
${canPay ? `<div class="tabs"><div class="tab ${TAB === 'orders' ? 'on' : ''}" data-t="orders">Orders</div><div class="tab ${TAB === 'payments' ? 'on' : ''}" data-t="payments">Payments & Wallet</div></div>` : ''}
<div id="pane"></div>`;
qa('.tab').forEach(t => { t.onclick = () => { TAB = t.dataset.t; render(); }; });
if (q('#b-new')) q('#b-new').onclick = () => directOrderForm();
if (q('#b-sync')) q('#b-sync').onclick = async (e) => { e.target.disabled = true; try { const s = await api('/sync-sap', { method: 'POST' }); toast(`SAP sync done — ${s.linked || 0} linked, ${s.closed || 0} closed`, 'ok'); loadOrders(); } catch (er) { toast(er.message); } e.target.disabled = false; };
if (TAB === 'payments') renderPayments(); else renderOrdersPane();
}
// ── Orders list ─────────────────────────────────────────────────────────────
function renderOrdersPane() {
const def = ME.profile.listStatuses.length ? ME.profile.listStatuses.join(',') : '';
q('#pane').innerHTML = `<div class="card"><div class="card-bd"><div class="filters">
<div><label class="sc-lbl">Type</label><select class="sc-in" id="f-type"><option value="">All</option><option value="trade">Trade</option><option value="inst">Institution (in-direct)</option><option value="direct">Direct</option></select></div>
<div><label class="sc-lbl">Product category</label><select class="sc-in" id="f-div"><option value="">All mine</option>${DIVS.filter(d => ME.profile.divisions.includes(d.id)).map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select></div>
<div style="min-width:220px"><label class="sc-lbl">Status</label><select class="sc-in" id="f-st">
<option value="default">${def ? 'My queue (default for my role)' : 'Open orders'}</option><option value="">All (incl. closed/cancelled)</option>
${Object.entries(ME.statusLabels).map(([k, v]) => `<option value="${k}">${k} · ${esc(v)}</option>`).join('')}</select></div>
<div style="min-width:200px;flex:1"><label class="sc-lbl">Search</label><input class="sc-in" id="f-q" placeholder="Ref / customer / PO / SAP no."></div>
<div><label class="sc-lbl">From</label><input class="sc-in" type="date" id="f-from"></div>
<div><label class="sc-lbl">To</label><input class="sc-in" type="date" id="f-to"></div>
<div style="min-width:auto"><button class="sc-btn sc-b-primary" id="f-go">Search</button></div>
</div></div></div>
<div class="card"><div class="card-hd">Orders <span class="count" id="cnt">—</span></div><div class="tbl-wrap" id="tbl"></div></div>`;
q('#f-type').value = F.type; q('#f-div').value = F.divisionId; q('#f-st').value = F.statuses; q('#f-q').value = F.search; q('#f-from').value = F.from; q('#f-to').value = F.to;
q('#f-go').onclick = () => { F.type = q('#f-type').value; F.divisionId = q('#f-div').value; F.statuses = q('#f-st').value; F.search = q('#f-q').value.trim(); F.from = q('#f-from').value; F.to = q('#f-to').value; loadOrders(); };
q('#f-q').onkeydown = e => { if (e.key === 'Enter') q('#f-go').click(); };
loadOrders();
}
async function loadOrders() {
if (!q('#tbl')) return;
q('#tbl').innerHTML = '<div class="empty"><span class="sc-spin"></span> Loading…</div>';
const p = new URLSearchParams();
if (F.type === 'trade') p.set('soType', 1); if (F.type === 'inst') { p.set('soType', 2); p.set('orderType', 'IN-DIRECT'); } if (F.type === 'direct') p.set('orderType', 'DIRECT');
if (F.divisionId) p.set('divisionId', F.divisionId);
const st = F.statuses === 'default' ? (ME.profile.listStatuses.length ? ME.profile.listStatuses.join(',') : '1,2,3,4,5,6,7,8') : F.statuses;
if (st) p.set('statuses', st);
if (F.search) p.set('search', F.search); if (F.from) p.set('from', F.from); if (F.to) p.set('to', F.to);
try { ROWS = await api('/orders?' + p.toString()); } catch (e) { q('#tbl').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; return; }
q('#cnt').textContent = ROWS.length;
q('#tbl').innerHTML = ROWS.length ? `<table class="lst"><thead><tr><th>Ref no.</th><th>Date</th><th>Type</th><th>Customer</th><th>Category</th><th>Cust PO</th><th class="n">Qty</th><th class="n">Value</th><th>Status</th><th>SAP SO</th></tr></thead><tbody>
${ROWS.map(o => `<tr data-id="${o.id}"><td class="mono">${esc(o.refNo)}</td><td>${date(o.createdAt)}</td><td>${SC.typeLabel(o)}</td><td>${esc(o.cardName)}<div class="sc-muted">${esc(o.cardCode)}</div></td>
<td>${esc(divName(o.divisionId))}</td><td>${esc(o.custOrderNo)}</td><td class="n">${num(o.orderQty)}</td><td class="n">${money(o.grandTotal)}</td><td>${pill(o.status, lbl(o))}</td><td class="mono">${esc(o.sapDocNum || '')}</td></tr>`).join('')}
</tbody></table>` : '<div class="empty">No orders match these filters.</div>';
qa('#tbl tr[data-id]').forEach(tr => { tr.onclick = () => openOrder(tr.dataset.id); });
}
// ── Order detail + actions ──────────────────────────────────────────────────
async function openOrder(id) {
let o;
try { o = await api('/orders/' + id); } catch (e) { return toast(e.message); }
const labels = o.orderType === 'DIRECT' ? ME.directStatusLabels : ME.statusLabels;
const acts = o.actions || [];
const body = SC.orderHtml(o, { employee: true, divisions: ME.allDivisions || DIVS, labels }) + (acts.length ? `
<div class="sc-sec">Take action</div>
<div class="sc-row"><div><label class="sc-lbl">Action</label><select class="sc-in" id="a-act"><option value="">— Select —</option>${acts.map(a => `<option value="${a.key}">${esc(a.label)}</option>`).join('')}</select></div></div>
<div id="a-fields" style="margin-top:8px"></div>` : '');
const m = SC.modal({ title: `Sales Order ${esc(o.refNo)} — ${esc(o.cardName)}`, wide: true, body,
buttons: [{ label: 'Close' }, ...(acts.length ? [{ label: 'Submit action', cls: 'sc-b-primary', onClick: () => submitAction(o, m) }] : [])] });
SC.wireOrder(m.root, o.id);
const sel = q('#a-act', m.root);
if (sel) sel.onchange = () => {
const a = acts.find(x => x.key === sel.value); const box = q('#a-fields', m.root);
if (!a) { box.innerHTML = ''; return; }
if (a.key === 'resubmit') { box.innerHTML = '<div class="sc-info">Opens the order editor so you can change items and resubmit to Logistics.</div>'; return; }
if (a.key === 'sap_post') { box.innerHTML = `<div class="sc-info">Creates the Sales Order in SAP company <b>${esc(o.company)}</b> using <b>your</b> SAP login (Profile → My SAP Account).${o.isMultiConsignee ? ' One SAP order is created per consignee.' : ''}</div>`; return; }
box.innerHTML = `<div class="sc-row">
<div><label class="sc-lbl">Remarks — visible to customer ${a.need.includes('employeeRemarks') ? '<b style="color:var(--red)">*</b>' : ''}</label><textarea class="sc-in" id="a-er"></textarea></div>
<div><label class="sc-lbl">Internal remarks ${a.need.includes('internalRemarks') ? '<b style="color:var(--red)">*</b>' : ''}</label><textarea class="sc-in" id="a-ir"></textarea></div></div>
${a.key === 'final_approve' && o.orderType === 'DIRECT' ? `<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Credit days</label><input class="sc-in" id="a-cd" type="number" min="0" value="${esc(o.creditDays ?? '')}"></div>
<div style="flex:3"><label class="sc-lbl">Payment detail</label><input class="sc-in" id="a-pd" value="${esc(o.paymentDetail || '')}"></div></div>` : ''}
${a.key === 'cancel' && o.status >= 6 ? '<div class="sc-warn">Payment was already submitted for this order. Any amount already debited goes back to the customer\'s wallet; a still-unverified payment stays pending for Accounts.</div>' : ''}`;
};
}
async function submitAction(o, m) {
const key = q('#a-act', m.root).value;
if (!key) { toast('Select an action'); return true; }
if (key === 'resubmit') { m.close(); directOrderForm(o); return true; }
if (key === 'sap_post' && !(await SC.confirmBox('Post to SAP', `Create SAP Sales Order(s) for <b>${esc(o.refNo)}</b>?`, 'Post to SAP', 'sc-b-green'))) return true;
// company: the order's SAP company — lets the server's SAP-login middleware pick the right per-company login.
const body = { action: key, company: o.company, employeeRemarks: (q('#a-er', m.root) || {}).value || '', internalRemarks: (q('#a-ir', m.root) || {}).value || '' };
if (q('#a-cd', m.root)) { body.creditDays = q('#a-cd', m.root).value; body.paymentDetail = q('#a-pd', m.root).value; }
const fresh = await api(`/orders/${o.id}/action`, { method: 'POST', body });
toast(`Order ${fresh.refNo}: ${lbl(fresh)}`, 'ok');
loadOrders();
m.close(); openOrder(o.id);
return true;
}
// ── Direct order (create / modify & resubmit) ───────────────────────────────
async function directOrderForm(existing) {
const myDivs = DIVS.filter(d => ME.profile.divisions.includes(d.id));
const S = { cust: null, products: [], cart: null, consignees: existing ? existing.consignees.map(c => ({ ...c })) : [] };
const m = SC.modal({ title: existing ? `Modify Direct Order ${esc(existing.refNo)}` : 'New Direct Order', wide: true, body: `
<div class="sc-row">
<div class="ac" style="flex:2"><label class="sc-lbl">Customer *</label><input class="sc-in" id="d-cust" placeholder="Type customer code or name…" ${existing ? 'disabled' : ''} autocomplete="off"><div class="ac-list" id="d-ac" style="display:none"></div></div>
<div><label class="sc-lbl">Product category *</label><select class="sc-in" id="d-div" ${existing ? 'disabled' : ''}><option value="">— Select —</option>${myDivs.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select></div>
</div>
<div id="d-rest" class="sc-muted" style="margin-top:10px">Select a customer and product category to continue.</div>`,
buttons: [{ label: 'Cancel' }, { label: existing ? 'Resubmit to Logistics' : 'Create order', cls: 'sc-b-primary', onClick: () => save() }] });
const R = m.root;
if (existing) { q('#d-cust', R).value = `${existing.cardName} (${existing.cardCode})`; q('#d-div', R).value = existing.divisionId; loadCustomer(existing.cardCode); }
let t = null;
q('#d-cust', R).oninput = () => {
clearTimeout(t); const v = q('#d-cust', R).value.trim(); const ac = q('#d-ac', R);
if (v.length < 2) { ac.style.display = 'none'; return; }
t = setTimeout(async () => {
try { const list = await api('/customers?q=' + encodeURIComponent(v));
ac.innerHTML = list.map(c => `<div data-cc="${esc(c.cardCode)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)} · ${esc(c.city)}${c.frozen ? ' · INACTIVE' : ''}</span></div>`).join('') || '<div class="sc-muted">No match</div>';
ac.style.display = 'block';
qa('[data-cc]', ac).forEach(d => { d.onclick = () => { ac.style.display = 'none'; q('#d-cust', R).value = d.textContent; loadCustomer(d.dataset.cc); }; });
} catch (e) { toast(e.message); }
}, 300);
};
// Changing category: keep the header fields already typed, only swap the
// product list (asks first if products were already added).
let lastDiv = existing ? String(existing.divisionId) : '';
q('#d-div', R).onchange = async () => {
const sel = q('#d-div', R);
if (S.cart && S.cart.count() && !(await SC.confirmBox('Change product category?', 'Products already added belong to the current category and will be removed.', 'Change category', 'sc-b-amber'))) { sel.value = lastDiv; return; }
lastDiv = sel.value;
if (!S.cust) return;
if (q('#d-cart', R) && sel.value) { S.products = await api('/products?divisionId=' + sel.value); S.consignees = []; makeCart([]); renderCons(); }
else buildRest();
};
async function loadCustomer(cc) { try { S.cust = await api('/customers/' + encodeURIComponent(cc)); buildRest(); } catch (e) { toast(e.message); } }
// Product list = only the selected category's products (server-filtered).
function makeCart(lines) {
const c = S.cust; const shipState = () => (c.shipTo.find(a => a.code === q('#d-ship', R).value) || {}).state;
S.cart = SC.cartEditor(q('#d-cart', R), S.products, { lines, allowSpecial: true, allowCommission: true, settings: ME.settings, getShipState: shipState,
onChange: renderCons, categoryName: divName(+q('#d-div', R).value) });
}
async function buildRest() {
const div = +q('#d-div', R).value; const box = q('#d-rest', R);
if (!S.cust || !div) return;
box.className = ''; box.innerHTML = '<span class="sc-spin"></span> Loading products…';
S.products = await api('/products?divisionId=' + div);
const c = S.cust; const e = existing || {};
const addrOpt = (list, sel) => list.map(a => `<option value="${esc(a.code)}" ${a.code === sel ? 'selected' : ''}>${esc(a.code)} — ${esc(a.city)}, ${esc(a.stateName || a.state)}</option>`).join('');
box.innerHTML = `
${c.frozen ? '<div class="sc-err">This customer is inactive (frozen) in SAP — the order cannot be saved.</div>' : ''}
<div class="sc-row">
<div><label class="sc-lbl">Ship to *</label><select class="sc-in" id="d-ship">${addrOpt(c.shipTo, e.shipToCode)}</select><div class="sc-muted" id="d-ship-t" style="white-space:pre-wrap;margin-top:3px"></div></div>
<div><label class="sc-lbl">Bill to *</label><select class="sc-in" id="d-bill">${addrOpt(c.billTo, e.billToCode)}</select><div class="sc-muted" id="d-bill-t" style="white-space:pre-wrap;margin-top:3px"></div></div>
</div>
<div class="sc-row" style="margin-top:8px">
<div><label class="sc-lbl">Customer ref. order no. *</label><input class="sc-in" id="d-po" value="${esc(e.custOrderNo || '')}"></div>
<div><label class="sc-lbl">Order date</label><input class="sc-in" type="date" id="d-od" value="${SC.iso(e.orderDate) || SC.today()}" ${existing ? 'disabled' : ''}></div>
<div><label class="sc-lbl">Committed Dispatch Date *</label><input class="sc-in" type="date" id="d-dd" value="${SC.iso(e.deliveryDate)}"></div>
<div><label class="sc-lbl">Contact person</label><input class="sc-in" id="d-cp" value="${esc(e.contactPerson || c.contactPerson)}" ${existing ? 'disabled' : ''}></div>
<div><label class="sc-lbl">Sales employee</label><input class="sc-in" id="d-se" value="${esc(e.salesEmployee || c.slpName)}" ${existing ? 'disabled' : ''}></div>
</div>
${existing ? '' : `<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Credit days</label><input class="sc-in" type="number" min="0" id="d-cd"></div>
<div style="flex:3"><label class="sc-lbl">Payment detail</label><input class="sc-in" id="d-pd"></div></div>`}
<div class="sc-sec">Products</div><div id="d-cart"></div>
<label style="display:flex;gap:6px;align-items:center;font-size:12px;margin-top:10px"><input type="checkbox" id="d-mc" ${existing ? (existing.isMultiConsignee ? 'checked disabled' : 'disabled') : ''}> Multiple consignees (split delivery to several ship-to addresses — one SAP order each)</label>
<div id="d-cons"></div>
<div class="sc-row" style="margin-top:10px"><div><label class="sc-lbl">Remarks</label><textarea class="sc-in" id="d-rm"></textarea></div>
<div><label class="sc-lbl">Customer PO copy (PDF / image)</label><input class="sc-in" type="file" id="d-file" accept=".pdf,.jpg,.jpeg,.png,.bmp"></div></div>`;
const addrText = () => { q('#d-ship-t', R).textContent = (c.shipTo.find(a => a.code === q('#d-ship', R).value) || {}).text || ''; q('#d-bill-t', R).textContent = (c.billTo.find(a => a.code === q('#d-bill', R).value) || {}).text || ''; };
addrText();
makeCart((e.items || []).map(i => ({ key: i.productId ? `P:${i.productId}` : `S:${i.itemCode}`, qty: i.qty, specialPrice: i.specialPrice, commission: i.commission })));
q('#d-ship', R).onchange = () => { addrText(); S.cart.refresh(); };
q('#d-bill', R).onchange = addrText;
q('#d-mc', R).onchange = renderCons;
renderCons();
}
function renderCons() {
const box = q('#d-cons', R); if (!box) return;
if (!q('#d-mc', R).checked) { box.innerHTML = ''; return; }
const items = S.cart.computed();
const sum = code => S.consignees.reduce((a, c) => a + (+((c.items || []).find(x => x.itemCode === code) || {}).qty || 0), 0);
box.innerHTML = `<div class="sc-sec">Consignees</div>
${S.consignees.map((c, i) => `<div class="card" style="margin-bottom:8px"><div class="card-bd">
<div class="sc-row"><div style="flex:2"><label class="sc-lbl">Ship to</label><select class="sc-in" data-c="${i}" data-f="shipToCode">${S.cust.shipTo.map(a => `<option value="${esc(a.code)}" ${a.code === c.shipToCode ? 'selected' : ''}>${esc(a.code)} — ${esc(a.city)}</option>`).join('')}</select></div>
<div><label class="sc-lbl">Sales emp. name</label><input class="sc-in" data-c="${i}" data-f="seName" value="${esc(c.seName || '')}"></div>
<div><label class="sc-lbl">Sales emp. email</label><input class="sc-in" data-c="${i}" data-f="seEmail" value="${esc(c.seEmail || '')}"></div>
<div style="flex:0 0 auto;min-width:auto;align-self:flex-end"><button class="sc-btn sc-b-red" data-rm="${i}">Remove</button></div></div>
<table class="sc-tbl" style="margin-top:6px"><thead><tr><th>Item</th><th class="n">Qty for this consignee</th></tr></thead><tbody>
${items.map(it => `<tr><td>${esc(it.p.displayCode || it.key)}</td><td class="n"><input class="sc-in" type="number" min="0" style="width:110px;text-align:right" data-ci="${i}" data-item="${esc(it.p.itemCode)}" value="${esc(((c.items || []).find(x => x.itemCode === it.p.itemCode) || {}).qty || '')}"></td></tr>`).join('')}
</tbody></table></div></div>`).join('')}
<button class="sc-btn sc-b-ghost" id="d-addc">+ Add consignee</button>
<div class="sc-muted" style="margin-top:6px">${items.map(it => `${esc(it.p.displayCode)}: allocated ${num(sum(it.p.itemCode))} / ${num(it.qty)}`).join(' · ')}</div>`;
q('#d-addc', R).onclick = () => { S.consignees.push({ shipToCode: (S.cust.shipTo[0] || {}).code, seName: '', seEmail: '', items: [] }); renderCons(); };
qa('[data-rm]', box).forEach(b => { b.onclick = () => { S.consignees.splice(+b.dataset.rm, 1); renderCons(); }; });
qa('[data-f]', box).forEach(el => { el.onchange = () => { S.consignees[+el.dataset.c][el.dataset.f] = el.value; }; });
qa('[data-ci]', box).forEach(el => { el.onchange = () => {
const c = S.consignees[+el.dataset.ci]; c.items = (c.items || []).filter(x => x.itemCode !== el.dataset.item);
if (+el.value > 0) c.items.push({ itemCode: el.dataset.item, qty: +el.value }); renderCons(); }; });
}
async function save() {
if (!S.cust || !S.cart) { toast('Select a customer and product category'); return true; }
const body = { divisionId: +q('#d-div', R).value, cardCode: S.cust.cardCode, shipToCode: q('#d-ship', R).value, billToCode: q('#d-bill', R).value,
custOrderNo: q('#d-po', R).value.trim(), orderDate: q('#d-od', R).value, deliveryDate: q('#d-dd', R).value, contactPerson: q('#d-cp', R).value,
salesEmployee: q('#d-se', R).value, creditDays: q('#d-cd', R) ? q('#d-cd', R).value : null, paymentDetail: q('#d-pd', R) ? q('#d-pd', R).value : null,
remarks: q('#d-rm', R).value, lines: S.cart.getLines(), isMultiConsignee: q('#d-mc', R).checked, consignees: S.consignees };
const o = existing ? await api(`/orders/${existing.id}/resubmit`, { method: 'PUT', body }) : await api('/orders', { method: 'POST', body });
const f = q('#d-file', R).files[0];
if (f) { try { await SC.upload(f, { entity: 'order', entityId: o.id, docType: 'po_copy', title: 'Customer PO copy' }); } catch (e) { toast('Order saved, but PO copy upload failed: ' + e.message); } }
toast(`Direct order ${o.refNo} ${existing ? 'resubmitted' : 'created'}`, 'ok');
loadOrders();
}
}
// ── Payments & wallet (Accounts) ────────────────────────────────────────────
function renderPayments() {
q('#pane').innerHTML = `
${cap('sales_order:payment_verify', 'view') ? `<div class="card"><div class="card-hd">Payments awaiting verification <span class="count" id="p-cnt">—</span></div><div class="tbl-wrap" id="p-tbl"></div></div>` : ''}
<div class="card"><div class="card-hd">Customer wallet / ledger</div><div class="card-bd">
<div class="filters"><div class="ac" style="min-width:320px"><label class="sc-lbl">Customer</label><input class="sc-in" id="w-cust" placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="w-ac" style="display:none"></div></div>
<div><label class="sc-lbl">From</label><input class="sc-in" type="date" id="w-from"></div><div><label class="sc-lbl">To</label><input class="sc-in" type="date" id="w-to"></div>
${cap('sales_order:payment_entry', 'add') ? '<div style="min-width:auto"><button class="sc-btn sc-b-green" id="w-add" disabled>+ On-account payment</button></div>' : ''}</div>
<div id="w-out" style="margin-top:10px"></div></div></div>`;
if (q('#p-tbl')) loadPending();
let cc = null, t = null;
q('#w-cust').oninput = () => { clearTimeout(t); const v = q('#w-cust').value.trim(); const ac = q('#w-ac'); if (v.length < 2) { ac.style.display = 'none'; return; }
t = setTimeout(async () => { const list = await api('/customers?q=' + encodeURIComponent(v)).catch(e => { toast(e.message); return []; });
ac.innerHTML = list.map(c => `<div data-cc="${esc(c.cardCode)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)}</span></div>`).join('') || '<div class="sc-muted">No match</div>'; ac.style.display = 'block';
qa('[data-cc]', ac).forEach(d => { d.onclick = () => { ac.style.display = 'none'; q('#w-cust').value = d.textContent; cc = d.dataset.cc; if (q('#w-add')) q('#w-add').disabled = false; loadLedger(); }; }); }, 300); };
q('#w-from').onchange = q('#w-to').onchange = () => cc && loadLedger();
if (q('#w-add')) q('#w-add').onclick = () => cc && onAccountForm(cc, loadLedger);
async function loadLedger() {
const out = q('#w-out'); out.innerHTML = '<span class="sc-spin"></span>';
try {
const d = await api(`/wallet/${encodeURIComponent(cc)}?from=${q('#w-from').value}&to=${q('#w-to').value}`);
const L = d.ledger;
out.innerHTML = `<div class="sc-grid" style="margin-bottom:8px"><div class="sc-kv"><b>Wallet balance</b><span style="font-size:16px;font-weight:700">${money(d.summary.balance)}</span></div>
<div class="sc-kv"><b>Pending credits</b><span>${money(d.summary.pendingCredits)}</span></div><div class="sc-kv"><b>Pending debits</b><span>${money(d.summary.pendingDebits)}</span></div>
<div class="sc-kv"><b>Credit limit</b><span>${money(d.summary.creditLimit)} ${cap('sales_order:payment_entry', 'edit') ? '<span class="sc-link" id="w-cl">edit</span>' : ''}</span></div></div>
<button class="sc-btn sc-b-ghost" id="w-csv">⬇ Download ledger (CSV)</button>
<table class="sc-tbl" style="margin-top:8px"><thead><tr><th>Date</th><th>Txn</th><th>Type</th><th>Order</th><th>Mode / ref.</th><th>Status</th><th class="n">Credit</th><th class="n">Debit</th><th class="n">Balance</th></tr></thead><tbody>
<tr><td colspan="8"><i>Opening balance</i></td><td class="n">${money(L.opening)}</td></tr>
${L.lines.map(x => `<tr><td>${dt(x.createdAt)}</td><td>#${x.id}</td><td>${esc(x.refType)}</td><td>${esc(x.refNo || '')}</td><td>${esc(x.paymentMode)} ${esc(x.paymentRef || x.remarks)}</td>
<td>${esc(x.status)}</td><td class="n">${x.type === 'credit' ? money(x.amount) : ''}</td><td class="n">${x.type === 'debit' ? money(x.amount) : ''}${x.tds ? `<div class="sc-muted">TDS ${money(x.tds)}</div>` : ''}</td><td class="n">${money(x.runningBalance)}</td></tr>`).join('')}
<tr class="tot"><td colspan="8">Closing balance (approved only)</td><td class="n">${money(L.closing)}</td></tr></tbody></table>`;
q('#w-csv').onclick = () => SC.csv(`ledger_${cc}.csv`, ['Date', 'Txn', 'Type', 'Order', 'Mode', 'Reference', 'Status', 'Credit', 'Debit', 'TDS', 'Balance'],
L.lines.map(x => [dt(x.createdAt), x.id, x.refType, x.refNo || '', x.paymentMode, x.paymentRef || x.remarks, x.status, x.type === 'credit' ? x.amount : '', x.type === 'debit' ? x.amount : '', x.tds || '', x.runningBalance]));
if (q('#w-cl')) q('#w-cl').onclick = () => { const m = SC.modal({ title: 'Credit limit', body: `<label class="sc-lbl">Credit limit (₹)</label><input class="sc-in" id="cl-v" type="number" min="0" value="${d.summary.creditLimit}">
<div class="sc-muted" style="margin-top:6px">Shown to the customer and on the order screens. Payment rules don't consume the limit (same as msale).</div>`,
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => { await api('/credit-limit/' + encodeURIComponent(cc), { method: 'PUT', body: { creditLimit: q('#cl-v', root).value } }); toast('Saved', 'ok'); loadLedger(); } }] }); };
} catch (e) { out.innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; }
}
}
async function loadPending() {
const box = q('#p-tbl'); box.innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
try {
const rows = await api('/payments/pending');
q('#p-cnt').textContent = rows.length;
box.innerHTML = rows.length ? `<table class="lst"><thead><tr><th>Txn</th><th>Submitted</th><th>Customer</th><th>Order</th><th class="n">Order value</th><th class="n">Amount paid</th><th>Mode</th><th>Reference</th><th>Pay date</th><th>Detail</th><th></th></tr></thead><tbody>
${rows.map(r => `<tr><td>#${r.id}</td><td>${dt(r.createdAt)}</td><td>${esc(r.cardName || r.cardCode)}<div class="sc-muted">${esc(r.cardCode)}</div></td>
<td>${r.orderId ? `<span class="sc-link" data-o="${r.orderId}">${esc(r.orderRef)}</span>` : '<i>on-account</i>'}</td><td class="n">${money(r.orderTotal)}</td><td class="n">${money(r.amount)}</td>
<td>${esc(r.paymentMode)}</td><td class="mono">${esc(r.paymentRef)}</td><td>${date(r.paymentDate)}</td><td>${esc(r.paymentDetail)}${r.docId ? ` <span class="sc-link" data-doc="${r.docId}">proof</span>` : ''}</td>
<td>${cap('sales_order:payment_verify', 'approve') ? `<button class="sc-btn sc-b-green" data-ok="${r.id}">Approve</button> <button class="sc-btn sc-b-red" data-no="${r.id}">Reject</button>` : ''}</td></tr>`).join('')}
</tbody></table>` : '<div class="empty">Nothing waiting for verification.</div>';
qa('[data-o]', box).forEach(el => { el.onclick = () => openOrder(el.dataset.o); });
qa('[data-doc]', box).forEach(el => { el.onclick = () => SC.openFile('/docs/' + el.dataset.doc); });
qa('[data-ok]', box).forEach(b => { b.onclick = () => decide(b.dataset.ok, 'approve'); });
qa('[data-no]', box).forEach(b => { b.onclick = () => decide(b.dataset.no, 'reject'); });
} catch (e) { box.innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; }
}
function decide(id, decision) {
SC.modal({ title: decision === 'approve' ? 'Approve payment' : 'Reject payment', body: `<div class="sc-muted" style="margin-bottom:6px">${decision === 'approve' ? 'Confirm the amount is received in the bank. The order moves to "ready for SAP".' : 'The order goes back to the customer to pay again.'}</div>
<label class="sc-lbl">Remarks ${decision === 'reject' ? '*' : ''} (visible to customer)</label><textarea class="sc-in" id="dc-r"></textarea>`,
buttons: [{ label: 'Back' }, { label: decision === 'approve' ? 'Approve' : 'Reject', cls: decision === 'approve' ? 'sc-b-green' : 'sc-b-red',
onClick: async ({ root }) => { await api(`/payments/${id}/decide`, { method: 'POST', body: { decision, remarks: q('#dc-r', root).value } }); toast('Done', 'ok'); loadPending(); } }] });
}
function onAccountForm(cc, after) {
SC.modal({ title: `On-account payment — ${esc(cc)}`, body: `<div class="sc-row">
<div><label class="sc-lbl">Type</label><select class="sc-in" id="oa-t"><option value="credit">Credit (payment received)</option><option value="debit">Debit (adjustment)</option></select></div>
<div><label class="sc-lbl">Amount *</label><input class="sc-in" type="number" min="0" step="0.01" id="oa-a"></div>
<div><label class="sc-lbl">Mode *</label><select class="sc-in" id="oa-m"><option>RTGS</option><option>NEFT</option><option>Cheque</option><option>Online</option><option>Adjustment</option></select></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Reference no. *</label><input class="sc-in" id="oa-r"></div><div><label class="sc-lbl">Payment date *</label><input class="sc-in" type="date" id="oa-d" value="${SC.today()}"></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Payment remarks *</label><textarea class="sc-in" id="oa-x"></textarea></div>`,
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-green', onClick: async ({ root }) => {
await api('/payments/on-account', { method: 'POST', body: { cardCode: cc, type: q('#oa-t', root).value, amount: q('#oa-a', root).value, paymentMode: q('#oa-m', root).value,
paymentRef: q('#oa-r', root).value, paymentDate: q('#oa-d', root).value, paymentDetail: q('#oa-x', root).value } });
toast('Payment saved', 'ok'); after(); } }] });
}
init().catch(e => { q('#app').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; });
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!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>Sales Reports — 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;--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}
*{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:1500px;margin:0 auto}
.page-title{font-size:16px;font-weight:700;margin-bottom:10px}
.tabs{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var(--border);overflow-x:auto}
.tab{padding:8px 14px;font-size:12.5px;font-weight:700;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}.tab.on{color:var(--blue);border-bottom-color:var(--blue)}
.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-bd{padding:12px 14px}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}.filters>div{min-width:140px}
.kpis{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px;margin-bottom:12px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px}
.kpi b{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--text3)}.kpi span{font-size:20px;font-weight:700}
.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;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);white-space:nowrap}
.lst td.n,.lst th.n{text-align:right;font-family:var(--mono)}
.tbl-wrap{overflow:auto;max-height:68vh}
.two{display:grid;grid-template-columns:1fr 1fr;gap:12px}@media(max-width:900px){.two{grid-template-columns:1fr}}
.mono{font-family:var(--mono)}.empty{text-align:center;padding:26px;color:var(--text3);font-size:12px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
<script src="/sales-common.js?v=4"></script>
</head>
<body>
<main id="app"><div class="empty"><span class="sc-spin"></span></div></main>
<script>
'use strict';
const { esc, money, num, date, q, qa, toast, api } = SC;
let ME = null, TAB = 'dashboard', DATA = null;
const divName = id => ((ME.allDivisions || ME.divisions).find(d => d.id === id) || {}).name || id; // incl. disabled categories (existing orders)
const TABS = [['dashboard', 'Dashboard'], ['order-wise', 'Order-wise'], ['item-wise', 'Item-wise'], ['pending', 'Pending (open in SAP)'], ['customer-pending', 'Customer-wise pending']];
async function init() { ME = await api('/me'); render(); }
function render() {
const firstOfYear = `${new Date().getMonth() >= 3 ? new Date().getFullYear() : new Date().getFullYear() - 1}-04-01`;
q('#app').innerHTML = `<div class="page-title">Sales Reports</div>
<div class="tabs">${TABS.map(([k, l]) => `<div class="tab ${TAB === k ? 'on' : ''}" data-t="${k}">${l}</div>`).join('')}</div>
<div class="card"><div class="card-bd"><div class="filters">
<div><label class="sc-lbl">Product category</label><select class="sc-in" id="f-div"><option value="">All mine</option>${ME.divisions.filter(d => ME.profile.divisions.includes(d.id)).map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select></div>
<div><label class="sc-lbl">Type</label><select class="sc-in" id="f-type"><option value="">All</option><option value="1">Trade</option><option value="2">Institution</option></select></div>
${['pending', 'customer-pending'].includes(TAB) ? '' : `<div><label class="sc-lbl">From</label><input class="sc-in" type="date" id="f-from" value="${firstOfYear}"></div><div><label class="sc-lbl">To</label><input class="sc-in" type="date" id="f-to"></div>`}
<div style="min-width:auto"><button class="sc-btn sc-b-primary" id="f-go">Run</button></div>
${TAB === 'dashboard' ? '' : '<div style="min-width:auto"><button class="sc-btn sc-b-ghost" id="f-csv">⬇ CSV</button></div>'}</div></div></div>
<div id="out"></div>`;
qa('.tab').forEach(t => { t.onclick = () => { TAB = t.dataset.t; render(); }; });
q('#f-go').onclick = run; if (q('#f-csv')) q('#f-csv').onclick = csv;
run();
}
async function run() {
const p = new URLSearchParams({ divisionId: q('#f-div').value, soType: q('#f-type').value });
if (q('#f-from')) { p.set('from', q('#f-from').value); p.set('to', q('#f-to').value); }
q('#out').innerHTML = '<div class="empty"><span class="sc-spin"></span> Running…</div>';
try { DATA = await api(`/reports/${TAB}?` + p); show(); } catch (e) { q('#out').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; }
}
function show() {
const d = DATA, o = q('#out');
if (TAB === 'dashboard') {
const k = d.kpis; const t = (l, x, extra) => `<div class="kpi"><b>${l}</b><span>${x ? x.n : 0}</span><div class="sc-muted">${x ? money(x.v) : ''}${extra || ''}</div></div>`;
o.innerHTML = `<div class="kpis">${t('Orders (excl. cancelled)', k.total)}${t('Awaiting MIPL action', k.awaitingAction)}${t('Awaiting payment / verification', k.awaitingPayment)}
${t('Ready to post in SAP', k.readyForSap)}${t('In SAP (invoicing)', k.inSap)}${t('Closed', k.closed)}${t('Cancelled', k.cancelled)}
<div class="kpi"><b>Avg. days order → SAP</b><span>${k.avgDaysToSap == null ? '—' : k.avgDaysToSap}</span></div></div>
<div class="two"><div class="card"><div class="card-hd">Monthly (last 12 months)</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>Month</th><th class="n">Orders</th><th class="n">Value</th></tr></thead><tbody>
${d.monthly.map(m => `<tr><td>${esc(m.month)}</td><td class="n">${m.count}</td><td class="n">${money(m.value)}</td></tr>`).join('') || '<tr><td colspan="3" class="empty">No data</td></tr>'}</tbody></table></div></div>
<div class="card"><div class="card-hd">Top customers</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>Customer</th><th class="n">Orders</th><th class="n">Value</th></tr></thead><tbody>
${d.topCustomers.map(c => `<tr><td>${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)}</span></td><td class="n">${c.count}</td><td class="n">${money(c.value)}</td></tr>`).join('') || '<tr><td colspan="3" class="empty">No data</td></tr>'}</tbody></table></div></div>
<div class="card"><div class="card-hd">By product category</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>Category</th><th class="n">Orders</th><th class="n">Value</th></tr></thead><tbody>
${d.byDivision.map(c => `<tr><td>${esc(c.division)}</td><td class="n">${c.count}</td><td class="n">${money(c.value)}</td></tr>`).join('') || '<tr><td colspan="3" class="empty">No data</td></tr>'}</tbody></table></div></div>
<div class="card"><div class="card-hd">By status</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>Status</th><th class="n">Orders</th><th class="n">Value</th></tr></thead><tbody>
${d.byStatus.sort((a, b) => a.status - b.status).map(c => `<tr><td>${SC.pill(c.status, c.label)}${c.orderType === 'DIRECT' ? ' <span class="sc-muted">direct</span>' : ''}</td><td class="n">${c.count}</td><td class="n">${money(c.value)}</td></tr>`).join('')}</tbody></table></div></div></div>`;
return;
}
const H = { 'order-wise': ['Ref no.', 'Date', 'Type', 'Customer', 'Category', 'Cust PO', 'Qty', 'Sub total', 'Grand total', 'Status', 'SAP SO'],
'item-wise': ['Item', 'Description', 'Orders', 'Qty', 'Value', 'Avg. price', 'Special-price lines'],
pending: ['Ref no.', 'Order date', 'Age (days)', 'Customer', 'SAP SO', 'Item', 'Description', 'Ordered', 'Delivered', 'Open qty', 'Open value'] }[TAB];
if (TAB === 'customer-pending') {
o.innerHTML = d.customers.length ? `<div class="card"><div class="tbl-wrap"><table class="lst"><thead><tr><th>Customer</th>${d.items.map(i => `<th class="n" title="${esc(i.description)}">${esc(i.itemCode)}</th>`).join('')}<th class="n">Total qty</th><th class="n">Open value</th></tr></thead><tbody>
${d.customers.map(c => `<tr><td>${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)}</span></td>${d.items.map(i => `<td class="n">${c.qty[i.itemCode] ? num(c.qty[i.itemCode]) : ''}</td>`).join('')}<td class="n">${num(c.total)}</td><td class="n">${money(c.value)}</td></tr>`).join('')}
<tr><td><b>Total</b></td>${d.items.map(i => `<td class="n"><b>${num(i.total)}</b></td>`).join('')}<td></td><td></td></tr></tbody></table></div></div>` : '<div class="empty">Nothing pending.</div>';
return;
}
const rows = rowsFor(d);
o.innerHTML = rows.length ? `<div class="card"><div class="card-hd">${rows.length} rows</div><div class="tbl-wrap"><table class="lst"><thead><tr>${H.map((h, i) => `<th class="${typeof rows[0][i] === 'number' ? 'n' : ''}">${h}</th>`).join('')}</tr></thead><tbody>
${rows.map(r => `<tr>${r.map(c => `<td class="${typeof c === 'number' ? 'n' : ''}">${typeof c === 'number' ? (Number.isInteger(c) ? num(c) : money(c)) : esc(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div></div>` : '<div class="empty">No data.</div>';
}
function rowsFor(d) {
if (TAB === 'order-wise') return d.map(r => [r.refNo, date(r.date), r.orderType === 'DIRECT' ? 'Direct' : r.soType === 1 ? 'Trade' : 'Institution', `${r.cardName} (${r.cardCode})`, divName(r.divisionId), r.custOrderNo, r.qty, r.subTotal, r.grandTotal, r.statusLabel, r.sapDocNum ? String(r.sapDocNum) : '']);
if (TAB === 'item-wise') return d.map(r => [r.displayCode, r.description, r.orders, r.qty, r.value, r.avgPrice, r.specialLines]);
if (TAB === 'pending') return d.map(r => [r.refNo || '', date(r.orderDate), r.ageDays == null ? '' : r.ageDays, `${r.cardName || ''} (${r.cardCode || ''})`, String(r.sapDocNum), r.itemCode, r.description, r.qty, r.deliveredQty, r.openQty, r.openValue]);
return [];
}
function csv() {
if (!DATA) return;
if (TAB === 'customer-pending') return SC.csv('customer_pending.csv', ['Customer', 'Code', ...DATA.items.map(i => i.itemCode), 'Total', 'Open value'], DATA.customers.map(c => [c.cardName, c.cardCode, ...DATA.items.map(i => c.qty[i.itemCode] || ''), c.total, c.value]));
const H = Array.from(document.querySelectorAll('#out th')).map(th => th.textContent);
SC.csv(`sales_${TAB}.csv`, H, rowsFor(DATA));
}
init().catch(e => { q('#app').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; });
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+234
View File
@@ -0,0 +1,234 @@
<!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>Sample Requests — 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;--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}
*{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:1500px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}.page-title{font-size:16px;font-weight:700}
.head-actions{margin-left:auto;display:flex;gap:8px}
.tabs{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var(--border)}
.tab{padding:8px 14px;font-size:12.5px;font-weight:700;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent}.tab.on{color:var(--blue);border-bottom-color:var(--blue)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-bd{padding:12px 14px}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}.filters>div{min-width:150px}
.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;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);white-space:nowrap}
.lst tbody tr{cursor:pointer}.lst tbody tr:hover td{background:rgba(26,111,255,.06)}
.tbl-wrap{overflow:auto;max-height:70vh}
.mono{font-family:var(--mono)}.empty{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.ac{position:relative}.ac-list{position:absolute;z-index:50;left:0;right:0;top:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;max-height:260px;overflow:auto}
.ac-list div{padding:7px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border)}.ac-list div:hover{background:rgba(26,111,255,.12)}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
<script src="/sales-common.js?v=4"></script>
</head>
<body>
<main id="app"><div class="empty"><span class="sc-spin"></span></div></main>
<script>
'use strict';
const { esc, num, date, dt, q, qa, toast, api } = SC;
let ME = null, TAB = 'domestic';
const cap = (k, p) => ME.caps[k] && ME.caps[k].includes(p);
const divName = id => ((ME.allDivisions || ME.divisions).find(d => d.id === id) || {}).name || id; // incl. disabled categories (existing orders)
const SCOL = { 1: 3, 2: 7, 3: 5, 4: 10, 5: 1, 6: 6, 7: 2, 8: 8, 9: 9 }; // reuse order pill colours
const ECOL = { 1: 3, 2: 2, 3: 6, 4: 8 };
async function init() {
ME = await api('/me');
if (new URLSearchParams(location.search).get('tab') === 'export' || (!cap('sales_sample:view', 'view') && cap('sales_export_sample:view', 'view'))) TAB = 'export';
render();
}
function render() {
const d = cap('sales_sample:view', 'view'), e = cap('sales_export_sample:view', 'view');
q('#app').innerHTML = `<div class="page-head"><div class="page-title">Sample Requests</div><div class="head-actions">
${TAB === 'domestic' && cap('sales_sample:create', 'add') ? '<button class="sc-btn sc-b-primary" id="b-new">+ New sample request</button>' : ''}
${TAB === 'export' && cap('sales_export_sample:create', 'add') ? '<button class="sc-btn sc-b-primary" id="b-new">+ New export sample request</button>' : ''}</div></div>
<div class="tabs">${d ? `<div class="tab ${TAB === 'domestic' ? 'on' : ''}" data-t="domestic">Domestic</div>` : ''}${e ? `<div class="tab ${TAB === 'export' ? 'on' : ''}" data-t="export">Export</div>` : ''}</div>
${!d && !e ? '<div class="sc-err">You are not assigned any Sample Request step.</div>' : ''}
<div class="card"><div class="card-bd"><div class="filters">
<div><label class="sc-lbl">Status</label><select class="sc-in" id="f-st"><option value="">All</option>${Object.entries(TAB === 'export' ? ME.exportStatusLabels : ME.sampleStatusLabels).map(([k, v]) => `<option value="${k}">${esc(v)}</option>`).join('')}</select></div>
<div style="flex:1;min-width:220px"><label class="sc-lbl">Search</label><input class="sc-in" id="f-q" placeholder="Ref / customer"></div>
<div style="min-width:auto"><button class="sc-btn sc-b-primary" id="f-go">Search</button></div></div></div></div>
<div class="card"><div class="tbl-wrap" id="tbl"></div></div>`;
qa('.tab').forEach(t => { t.onclick = () => { TAB = t.dataset.t; render(); }; });
if (q('#b-new')) q('#b-new').onclick = () => (TAB === 'export' ? exportForm() : sampleForm());
q('#f-go').onclick = load; q('#f-q').onkeydown = ev => { if (ev.key === 'Enter') load(); };
if (d || e) load();
}
async function load() {
const p = new URLSearchParams({ statuses: q('#f-st').value, search: q('#f-q').value.trim() });
q('#tbl').innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
try {
if (TAB === 'export') {
const rows = await api('/export-samples?' + p);
q('#tbl').innerHTML = rows.length ? `<table class="lst"><thead><tr><th>Ref no.</th><th>Date</th><th>Customer</th><th>Country</th><th>Region</th><th>Raised by</th><th>Status</th><th>Courier</th></tr></thead><tbody>
${rows.map(r => `<tr data-id="${r.id}"><td class="mono">${esc(r.refNo)}</td><td>${date(r.reqDate || r.createdAt)}</td><td>${esc(r.custName)}</td><td>${esc(r.country)}</td><td>${esc(r.region)}</td><td>${esc(r.createdByName)}</td>
<td>${SC.pill(ECOL[r.status], r.statusLabel)}</td><td>${esc(r.courierNo)}</td></tr>`).join('')}</tbody></table>` : '<div class="empty">No export sample requests.</div>';
qa('#tbl tr[data-id]').forEach(tr => { tr.onclick = () => openExport(tr.dataset.id); });
} else {
const rows = await api('/samples?' + p);
q('#tbl').innerHTML = rows.length ? `<table class="lst"><thead><tr><th>Ref no.</th><th>Date</th><th>Customer</th><th>Category</th><th>Blood bank</th><th>Raised by</th><th>Status</th><th>QA due</th><th>Courier</th></tr></thead><tbody>
${rows.map(r => `<tr data-id="${r.id}"><td class="mono">${esc(r.refNo)}</td><td>${date(r.reqDate || r.createdAt)}</td><td>${esc(r.cardName)}<div class="sc-muted">${esc(r.cardCode)}</div></td><td>${esc(divName(r.divisionId))}</td>
<td>${esc(r.bbName)}</td><td>${esc(r.createdByName)}</td><td>${SC.pill(SCOL[r.status], r.statusLabel)}</td><td>${r.status === 6 ? date(r.qaDueDate) : ''}</td><td>${esc(r.courierNo)}</td></tr>`).join('')}</tbody></table>` : '<div class="empty">No sample requests.</div>';
qa('#tbl tr[data-id]').forEach(tr => { tr.onclick = () => openSample(tr.dataset.id); });
}
} catch (e) { q('#tbl').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; }
}
function itemsTable(items, editBatch) {
return `<table class="sc-tbl"><thead><tr><th>Item</th><th>Description</th><th class="n">Qty</th><th>Batch no.</th></tr></thead><tbody>
${items.map(i => `<tr><td class="mono">${esc(i.displayCode)}</td><td>${esc(i.description)}</td><td class="n">${num(i.qty)}</td>
<td>${editBatch ? `<input class="sc-in" data-batch="${i.id}" value="${esc(i.batchNo)}" style="width:160px">` : esc(i.batchNo || '—')}</td></tr>`).join('')}</tbody></table>`;
}
function logHtml(log, labels) { return `<div class="sc-tl">${log.map(l => `<div><b>${dt(l.at)}</b> — ${esc(l.actorName)}: ${esc(l.action.replace(/_/g, ' '))}${l.toStatus != null && l.toStatus !== l.fromStatus ? ` → <i>${esc(labels[l.toStatus] || l.toStatus)}</i>` : ''}${l.remarks ? ` — <span class="sc-muted">${esc(l.remarks)}</span>` : ''}</div>`).join('')}</div>`; }
const batchMap = root => Object.fromEntries(qa('[data-batch]', root).map(i => [i.dataset.batch, i.value]));
// ── Domestic ────────────────────────────────────────────────────────────────
async function openSample(id) {
let s; try { s = await api('/samples/' + id); } catch (e) { return toast(e.message); }
const A = s.actions;
const actHtml = A.length ? `<div class="sc-sec">Take action</div>
${A.includes('buh_approve') ? `<div class="sc-row"><div><label class="sc-lbl">Decision</label><select class="sc-in" id="x-buh"><option value="buh_approve">Approve</option><option value="buh_modify">Request modification</option><option value="buh_reject">Reject</option></select></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Remarks (required for modification / rejection)</label><textarea class="sc-in" id="x-rm"></textarea></div>` : ''}
${A.includes('stock') ? `<div class="sc-row"><div><label class="sc-lbl">Stock available?</label><select class="sc-in" id="x-stock"><option value="">— Select —</option><option value="1">Yes — enter batch nos., send to QA</option><option value="0">No — goes to production</option></select></div></div>
<div class="sc-muted" style="margin-top:6px">Enter batch nos. in the items table above when stock is available.</div>` : ''}
${A.includes('dispatch') ? `<div class="sc-row"><div><label class="sc-lbl">Challan no. *</label><input class="sc-in" id="x-ch"></div><div><label class="sc-lbl">Challan date *</label><input class="sc-in" type="date" id="x-cd" value="${SC.today()}"></div>
<div><label class="sc-lbl">Courier no. *</label><input class="sc-in" id="x-co"></div></div><div style="margin-top:8px"><label class="sc-lbl">Dispatched qty & batch details</label><input class="sc-in" id="x-di"></div>
<div class="sc-muted" style="margin-top:6px">Submitting marks QA verification done and the sample as shipped.</div>` : ''}
${A.includes('feedback') ? `<label class="sc-lbl">Customer feedback *</label><textarea class="sc-in" id="x-fb"></textarea>` : ''}
${A.includes('resubmit') ? `<div class="sc-warn">BUH asked for modification: ${esc(s.modificationRemarks)}</div>` : ''}` : '';
const editBatch = A.includes('stock');
const m = SC.modal({ title: `Sample ${esc(s.refNo)} — ${esc(s.cardName)}`, wide: true, body: `
<div class="sc-grid"><div class="sc-kv"><b>Status</b>${SC.pill(SCOL[s.status], s.statusLabel)}</div><div class="sc-kv"><b>Customer</b><span>${esc(s.cardName)} (${esc(s.cardCode)})</span></div>
<div class="sc-kv"><b>Category</b><span>${esc(divName(s.divisionId))}</span></div><div class="sc-kv"><b>Request date</b><span>${date(s.reqDate)}</span></div>
<div class="sc-kv"><b>Required in (days)</b><span>${esc(s.deliveryDays || '—')}</span></div><div class="sc-kv"><b>Blood bank</b><span>${esc(s.bbName || '—')}</span></div>
<div class="sc-kv"><b>BB licence no.</b><span>${esc(s.bbLicenceNo || '—')}</span></div><div class="sc-kv"><b>Raised by</b><span>${esc(s.createdByName)} · ${dt(s.createdAt)}</span></div>
${s.qaDueDate ? `<div class="sc-kv"><b>QA due</b><span>${date(s.qaDueDate)}</span></div>` : ''}${s.courierNo ? `<div class="sc-kv"><b>Challan / courier</b><span>${esc(s.challanNo)} · ${date(s.challanDate)} · ${esc(s.courierNo)}</span></div>` : ''}</div>
<div class="sc-kv" style="margin-top:8px"><b>Address</b><span>${esc(s.address)}</span></div>
<div class="sc-sec">Items</div>${itemsTable(s.items, editBatch)}
${s.internalRemarks || s.modificationRemarks || s.rejectionRemarks || s.feedback ? `<div class="sc-sec">Remarks</div><div class="sc-row">
${s.internalRemarks ? `<div><label class="sc-lbl">BUH remarks</label><div class="sc-remarks">${esc(s.internalRemarks)}</div></div>` : ''}
${s.modificationRemarks ? `<div><label class="sc-lbl">Modification</label><div class="sc-remarks">${esc(s.modificationRemarks)}</div></div>` : ''}
${s.rejectionRemarks ? `<div><label class="sc-lbl">Rejection</label><div class="sc-remarks">${esc(s.rejectionRemarks)}</div></div>` : ''}
${s.feedback ? `<div><label class="sc-lbl">Feedback</label><div class="sc-remarks">${esc(s.feedback)}</div></div>` : ''}</div>` : ''}
${actHtml}<div class="sc-sec">History</div>${logHtml(s.log, ME.sampleStatusLabels)}`,
buttons: [{ label: 'Close' }, ...(A.includes('resubmit') ? [{ label: 'Modify & resubmit', cls: 'sc-b-amber', onClick: () => { sampleForm(s); } }] : []),
...(A.includes('qa_extend') ? [{ label: 'Extend QA due date (+2 days)', onClick: async () => { await api(`/samples/${s.id}/action`, { method: 'POST', body: { action: 'qa_extend' } }); toast('QA due date extended', 'ok'); load(); } }] : []),
...(A.some(a => ['buh_approve', 'stock', 'dispatch', 'feedback'].includes(a)) ? [{ label: 'Submit', cls: 'sc-b-primary', onClick: ({ root }) => sampleSubmit(s, root) }] : [])] });
}
async function sampleSubmit(s, R) {
const A = s.actions; let body;
if (A.includes('buh_approve')) { const a = q('#x-buh', R).value; const rm = q('#x-rm', R).value; body = { action: a, internalRemarks: rm, modificationRemarks: rm, rejectionRemarks: rm }; }
else if (A.includes('stock')) body = { action: 'stock', stockAvailable: q('#x-stock', R).value, batchNos: batchMap(R) };
else if (A.includes('dispatch')) body = { action: 'dispatch', challanNo: q('#x-ch', R).value, challanDate: q('#x-cd', R).value, courierNo: q('#x-co', R).value, dispatchInfo: q('#x-di', R).value };
else if (A.includes('feedback')) body = { action: 'feedback', feedback: q('#x-fb', R).value };
const r = await api(`/samples/${s.id}/action`, { method: 'POST', body });
toast(`Sample ${r.refNo}: ${r.statusLabel}`, 'ok'); load();
}
async function sampleForm(ex) {
const myDivs = ME.divisions.filter(d => ME.profile.divisions.includes(d.id));
const S = { cust: ex ? { cardCode: ex.cardCode, cardName: ex.cardName } : null, cart: null };
const m = SC.modal({ title: ex ? `Modify sample ${esc(ex.refNo)}` : 'New sample request', wide: true, body: `
<div class="sc-row"><div class="ac" style="flex:2"><label class="sc-lbl">Customer *</label><input class="sc-in" id="s-cust" ${ex ? `value="${esc(ex.cardName)} (${esc(ex.cardCode)})" disabled` : ''} placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="s-ac" style="display:none"></div></div>
<div><label class="sc-lbl">Product category *</label><select class="sc-in" id="s-div" ${ex ? 'disabled' : ''}><option value="">— Select —</option>${myDivs.map(d => `<option value="${d.id}" ${ex && ex.divisionId === d.id ? 'selected' : ''}>${esc(d.name)}</option>`).join('')}</select></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Blood bank name</label><input class="sc-in" id="s-bb" value="${esc(ex ? ex.bbName : '')}"></div>
<div><label class="sc-lbl">BB licence no.</label><input class="sc-in" id="s-lic" value="${esc(ex ? ex.bbLicenceNo : '')}"></div>
<div><label class="sc-lbl">Required within (days)</label><input class="sc-in" type="number" min="1" id="s-days" value="${esc(ex ? ex.deliveryDays : '')}"></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Delivery address</label><textarea class="sc-in" id="s-addr">${esc(ex ? ex.address : '')}</textarea></div>
<div class="sc-sec">Products</div><div id="s-cart" class="sc-muted">Select a product category.</div>
${ex ? '' : '<div style="margin-top:8px"><label class="sc-lbl">Remarks</label><textarea class="sc-in" id="s-rm"></textarea></div>'}`,
buttons: [{ label: 'Cancel' }, { label: ex ? 'Resubmit' : 'Submit request', cls: 'sc-b-primary', onClick: async () => {
if (!S.cust || !S.cart) throw new Error('Select a customer and product category');
const body = { cardCode: S.cust.cardCode, cardName: S.cust.cardName, divisionId: +q('#s-div', R).value, bbName: q('#s-bb', R).value, bbLicenceNo: q('#s-lic', R).value,
deliveryDays: q('#s-days', R).value, address: q('#s-addr', R).value, remarks: q('#s-rm', R) ? q('#s-rm', R).value : '', lines: S.cart.getLines() };
if (ex) await api(`/samples/${ex.id}/action`, { method: 'POST', body: { action: 'resubmit', ...body } }); else await api('/samples', { method: 'POST', body });
toast(ex ? 'Sample request resubmitted' : 'Sample request submitted', 'ok'); load(); } }] });
const R = m.root; let t;
q('#s-cust', R).oninput = () => { clearTimeout(t); const v = q('#s-cust', R).value.trim(); const ac = q('#s-ac', R); if (v.length < 2) { ac.style.display = 'none'; return; }
t = setTimeout(async () => { const list = await api('/customers?q=' + encodeURIComponent(v)).catch(e => { toast(e.message); return []; });
ac.innerHTML = list.map(c => `<div data-cc="${esc(c.cardCode)}" data-n="${esc(c.cardName)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)}</span></div>`).join('') || '<div class="sc-muted">No match</div>'; ac.style.display = 'block';
qa('[data-cc]', ac).forEach(d => { d.onclick = async () => { ac.style.display = 'none'; q('#s-cust', R).value = d.textContent; S.cust = { cardCode: d.dataset.cc, cardName: d.dataset.n };
try { const c = await api('/customers/' + encodeURIComponent(d.dataset.cc)); const a = c.shipTo[0] || c.billTo[0]; if (a && !q('#s-addr', R).value) q('#s-addr', R).value = a.text; } catch (_e) {} }; }); }, 300); };
// Product list = only the selected category's products (server-filtered).
let lastDiv = ex ? String(ex.divisionId) : '';
const loadCart = async () => { const div = q('#s-div', R).value;
if (!div) { S.cart = null; q('#s-cart', R).className = 'sc-muted'; q('#s-cart', R).textContent = 'Select a product category.'; return; }
q('#s-cart', R).innerHTML = '<span class="sc-spin"></span>';
const prods = await api('/products?divisionId=' + div); q('#s-cart', R).className = '';
S.cart = SC.cartEditor(q('#s-cart', R), prods, { lines: ex ? ex.items.map(i => ({ key: i.productId ? `P:${i.productId}` : `S:${i.itemCode}`, qty: i.qty })) : [], settings: {}, categoryName: divName(+div) }); };
q('#s-div', R).onchange = async () => {
if (S.cart && S.cart.count() && !(await SC.confirmBox('Change product category?', 'Products already added will be removed.', 'Change category', 'sc-b-amber'))) { q('#s-div', R).value = lastDiv; return; }
lastDiv = q('#s-div', R).value; loadCart(); };
if (ex) loadCart();
}
// ── Export ──────────────────────────────────────────────────────────────────
async function openExport(id) {
let e; try { e = await api('/export-samples/' + id); } catch (er) { return toast(er.message); }
const A = e.actions;
const m = SC.modal({ title: `Export sample ${esc(e.refNo)} — ${esc(e.custName)}`, wide: true, body: `
<div class="sc-grid"><div class="sc-kv"><b>Status</b>${SC.pill(ECOL[e.status], e.statusLabel)}</div><div class="sc-kv"><b>Customer</b><span>${esc(e.custName)}</span></div>
<div class="sc-kv"><b>Country / region</b><span>${esc(e.country)} ${e.region ? '· ' + esc(e.region) : ''}</span></div><div class="sc-kv"><b>Sample against</b><span>${esc(e.sampleAgainst || '—')}</span></div>
<div class="sc-kv"><b>Contact</b><span>${esc(e.contactPerson)}<br>${esc(e.contactEmail)}<br>${esc(e.contactNo)}</span></div>
<div class="sc-kv"><b>Courier</b><span>${e.courierByCustomer ? 'Customer courier account: ' + esc(e.accountDetails) : 'MIPL courier'}</span></div>
<div class="sc-kv"><b>Request date</b><span>${date(e.reqDate)}</span></div><div class="sc-kv"><b>Expected delivery</b><span>${date(e.deliveryDate)}</span></div>
<div class="sc-kv"><b>Raised by</b><span>${esc(e.createdByName)} · ${dt(e.createdAt)}</span></div>
${e.courierNo ? `<div class="sc-kv"><b>Dispatch</b><span>${esc(e.challanNo)} ${date(e.challanDate)} · AWB ${esc(e.courierNo)}</span></div>` : ''}</div>
<div class="sc-kv" style="margin-top:8px"><b>Shipping address</b><span>${esc(e.shippingAddress)}</span></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Remarks for QA</label><div class="sc-remarks">${esc(e.remarksQa || '—')}</div></div><div><label class="sc-lbl">Remarks for logistics</label><div class="sc-remarks">${esc(e.remarksLogistics || '—')}</div></div></div>
<div class="sc-sec">Items</div>${itemsTable(e.items, A.includes('stock'))}
${e.docs.length ? `<div class="sc-sec">Documents</div>${e.docs.map(d => `<div><span class="sc-link" data-doc="${d.id}">📄 ${esc(d.title || d.origName)}</span> <span class="sc-muted">${esc(d.docType)} · ${dt(d.createdAt)}</span></div>`).join('')}` : ''}
${A.includes('stock') ? `<div class="sc-sec">Stock</div><div class="sc-row"><div><label class="sc-lbl">Stock available?</label><select class="sc-in" id="e-stock"><option value="">— Select —</option><option value="1">Yes — QA verification</option><option value="0">No — production</option></select></div>
<div><label class="sc-lbl">Expected delivery date</label><input class="sc-in" type="date" id="e-dd" value="${SC.iso(e.deliveryDate)}"></div></div>` : ''}
${A.includes('dispatch') ? `<div class="sc-sec">Dispatch</div><div class="sc-row"><div><label class="sc-lbl">Invoice / challan no.</label><input class="sc-in" id="e-ch"></div><div><label class="sc-lbl">Date</label><input class="sc-in" type="date" id="e-cd" value="${SC.today()}"></div>
<div><label class="sc-lbl">Courier / AWB no. *</label><input class="sc-in" id="e-co"></div></div><div style="margin-top:8px"><label class="sc-lbl">Dispatch details</label><input class="sc-in" id="e-di"></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Invoice copy</label><input class="sc-in" type="file" id="e-inv" accept=".pdf,.jpg,.jpeg,.png"></div><div><label class="sc-lbl">Courier receipt</label><input class="sc-in" type="file" id="e-cour" accept=".pdf,.jpg,.jpeg,.png"></div></div>` : ''}
<div class="sc-sec">History</div>${logHtml(e.log, ME.exportStatusLabels)}`,
buttons: [{ label: 'Close' }, ...(A.includes('edit') ? [{ label: 'Edit', onClick: () => exportForm(e) }] : []),
...(A.includes('stock') || A.includes('dispatch') ? [{ label: 'Submit', cls: 'sc-b-primary', onClick: async ({ root }) => {
let body;
if (A.includes('stock')) body = { action: 'stock', stockAvailable: q('#e-stock', root).value, deliveryDate: q('#e-dd', root).value, batchNos: batchMap(root) };
else {
for (const [sel, type, title] of [['#e-inv', 'invoice', 'Invoice copy'], ['#e-cour', 'courier', 'Courier receipt']]) { const f = q(sel, root).files[0]; if (f) await SC.upload(f, { entity: 'export_sample', entityId: e.id, docType: type, title }); }
body = { action: 'dispatch', challanNo: q('#e-ch', root).value, challanDate: q('#e-cd', root).value, courierNo: q('#e-co', root).value, dispatchDetails: q('#e-di', root).value };
}
const r = await api(`/export-samples/${e.id}/action`, { method: 'POST', body }); toast(`${r.refNo}: ${r.statusLabel}`, 'ok'); load(); } }] : [])] });
qa('[data-doc]', m.root).forEach(el => { el.onclick = () => SC.openFile('/docs/' + el.dataset.doc); });
}
async function exportForm(ex) {
const prods = await api('/products?catalog=export');
const v = f => esc(ex ? ex[f] : '');
let cart;
const m = SC.modal({ title: ex ? `Edit export sample ${esc(ex.refNo)}` : 'New export sample request', wide: true, body: `
<div class="sc-row"><div><label class="sc-lbl">Customer name *</label><input class="sc-in" id="x-custName" value="${v('custName')}"></div><div><label class="sc-lbl">Country *</label><input class="sc-in" id="x-country" value="${v('country')}"></div>
<div><label class="sc-lbl">Region</label><input class="sc-in" id="x-region" value="${v('region')}"></div><div><label class="sc-lbl">Sample against</label><input class="sc-in" id="x-sampleAgainst" value="${v('sampleAgainst')}" placeholder="Tender / enquiry ref."></div></div>
<div style="margin-top:8px"><label class="sc-lbl">Shipping address *</label><textarea class="sc-in" id="x-shippingAddress">${v('shippingAddress')}</textarea></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Contact person *</label><input class="sc-in" id="x-contactPerson" value="${v('contactPerson')}"></div>
<div><label class="sc-lbl">Contact email *</label><input class="sc-in" id="x-contactEmail" value="${v('contactEmail')}"></div><div><label class="sc-lbl">Contact no. *</label><input class="sc-in" id="x-contactNo" value="${v('contactNo')}"></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Courier</label><select class="sc-in" id="x-cbc"><option value="0">MIPL courier</option><option value="1" ${ex && ex.courierByCustomer ? 'selected' : ''}>Customer's courier account</option></select></div>
<div><label class="sc-lbl">Courier account details</label><input class="sc-in" id="x-accountDetails" value="${v('accountDetails')}"></div><div><label class="sc-lbl">Required within (days)</label><input class="sc-in" type="number" id="x-days" value="${v('deliveryDays')}"></div></div>
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Remarks for QA</label><textarea class="sc-in" id="x-remarksQa">${v('remarksQa')}</textarea></div><div><label class="sc-lbl">Remarks for logistics</label><textarea class="sc-in" id="x-remarksLogistics">${v('remarksLogistics')}</textarea></div></div>
<div class="sc-sec">Products (export catalog)</div><div id="x-cart"></div>
${ex ? '' : '<div style="margin-top:8px"><label class="sc-lbl">Attachment (enquiry / PI)</label><input class="sc-in" type="file" id="x-file" accept=".pdf,.jpg,.jpeg,.png"></div>'}`,
buttons: [{ label: 'Cancel' }, { label: ex ? 'Save' : 'Submit request', cls: 'sc-b-primary', onClick: async ({ root }) => {
const body = { id: ex ? ex.id : undefined, courierByCustomer: q('#x-cbc', root).value === '1', deliveryDays: q('#x-days', root).value, lines: cart.getLines() };
['custName', 'country', 'region', 'sampleAgainst', 'shippingAddress', 'contactPerson', 'contactEmail', 'contactNo', 'accountDetails', 'remarksQa', 'remarksLogistics'].forEach(f => { body[f] = q('#x-' + f, root).value; });
const res = await api('/export-samples', { method: 'POST', body });
const f = q('#x-file', root) && q('#x-file', root).files[0];
if (f) await SC.upload(f, { entity: 'export_sample', entityId: res, docType: 'enquiry', title: 'Enquiry / PI' }).catch(er => toast('Saved, but upload failed: ' + er.message));
toast('Export sample request saved', 'ok'); load(); } }] });
cart = SC.cartEditor(q('#x-cart', m.root), prods, { lines: ex ? ex.items.map(i => ({ key: `P:${i.productId}`, qty: i.qty })) : [], settings: {} });
}
init().catch(e => { q('#app').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; });
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>
+1 -1
View File
@@ -436,6 +436,6 @@ window.viewAtt=function(entry,lineId){
try{const p=await api('GET','/auth/profile');if(Array.isArray(p?.user?.sapApprovalTypes))_myApprovalTypes=p.user.sapApprovalTypes.map(String);}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();})(); loadRequests();})();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+12
View File
@@ -132,10 +132,15 @@
{href:'/bom', lucide:'list-tree', label:'BOM', module:'bom', sub:true}, {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', 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}, {href:'/items?list=1', lucide:'list', label:'View Items', module:'items', sub:true,exact:true},
{href:'/inventory-status-report',lucide:'clipboard-list',label:'Inventory Status', module:'inventory-status-report', sub:true},
{href:'/batch-no-transaction',lucide:'test-tube-2',label:'Batch No. Transaction', module:'batch-no-transaction', sub:true},
{href:'/inventory-transfer',lucide:'repeat',label:'Inventory Transfer', module:'inventory-transfer', sub:true},
{href:'/inventory-posting-list',lucide:'book-open-text',label:'Posting List', module:'inventory-posting-list', sub:true},
// Production // Production
{type:'group',label:'Production',lucide:'factory'}, {type:'group',label:'Production',lucide:'factory'},
{href:'/production-dashboard',lucide:'gauge', label:'Dashboard', module:'production-dashboard', sub:true}, {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:'/production', lucide:'file-plus-2', label:'Production Order', module:'production-create', sub:true},
{href:'/deviations', lucide:'alert-triangle', label:'Deviations', module:'production-deviations', sub:true},
{href:'/issue-production', lucide:'package-open', label:'Issue', module:'production-issue', 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:'/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-work-order', lucide:'clipboard-check', label:'Verify Work Order',module:'work-order-verify', sub:true},
@@ -147,6 +152,7 @@
{href:'/work-order', lucide:'file-cog', label:'Work Order', module:'production-work-order', 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:'/man-power', lucide:'users', label:'Man Power', module:'man-power', sub:true},
{href:'/oee', lucide:'activity', label:'OEE', module:'oee', sub:true}, {href:'/oee', lucide:'activity', label:'OEE', module:'oee', sub:true},
{href:'/downtime-analysis', lucide:'bar-chart-3', label:'Downtime Analysis',module:'downtime-analysis', sub:true},
{href:'/ppc-report', lucide:'table-2', label:'PPC Report', module:'ppc-report', 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}, {href:'/pwo-source-audit', lucide:'shield-check', label:'PWO Source Report',module:'pwo-source-audit', sub:true},
{href:'/rejection-register',lucide:'clipboard-x', label:'Rejection Register',module:'rejection-register', sub:true}, {href:'/rejection-register',lucide:'clipboard-x', label:'Rejection Register',module:'rejection-register', sub:true},
@@ -159,6 +165,12 @@
{href:'/purchase-order', lucide:'shopping-bag', label:'Purchase Order', module:'purchase-order', 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}, {href:'/grpo', lucide:'truck', label:'Goods Receipt PO', module:'purchase-grpo', sub:true},
// Approval // Approval
{type:'group',label:'Sales',lucide:'receipt-indian-rupee'},
{href:'/sales-orders', lucide:'shopping-basket', label:'Sales Orders', module:'sales-orders', sub:true},
{href:'/sales-samples', lucide:'flask-conical', label:'Sample Requests', module:'sales-samples', sub:true},
{href:'/sales-reports', lucide:'bar-chart-3', label:'Sales Reports', module:'sales-reports', sub:true},
{href:'/sales-admin', lucide:'settings-2', label:'Sales Admin', module:'sales-admin', sub:true},
{type:'group',label:'Approvals',lucide:'badge-check'}, {type:'group',label:'Approvals',lucide:'badge-check'},
{href:'/approvals', lucide:'stamp', label:'External Approval',module:'approvals', sub:true}, {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}, {href:'/sap-approvals', lucide:'shield-check', label:'SAP Approval', module:'sap-approvals', sub:true},
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Signing in… — SAP B1 Portal</title>
<style>
:root{--ink:#080e18;--surface:#0f1d30;--border:#1c2e48;--text:#c8d6e8;--text2:#7a9ab8;--amber:#f5a623;--red:#e53e3e;--font:'Segoe UI',sans-serif}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:36px 32px;max-width:400px;width:92%;text-align:center}
.sp{width:36px;height:36px;border:3px solid rgba(245,166,35,.25);border-top-color:var(--amber);border-radius:50%;animation:spin .7s linear infinite;margin:0 auto 18px}
@keyframes spin{to{transform:rotate(360deg)}}
h1{font-size:16px;margin-bottom:8px}
p{font-size:13px;color:var(--text2);line-height:1.5}
.err{color:#fca5a5;background:rgba(229,62,62,.12);border:1px solid rgba(229,62,62,.3);border-radius:8px;padding:10px 12px;margin-top:14px;font-size:12.5px;text-align:left}
.btn{margin-top:18px;padding:9px 18px;border-radius:8px;border:none;background:var(--amber);color:#1a1200;font-weight:700;font-size:13px;cursor:pointer}
</style>
</head>
<body>
<div class="card" id="card">
<div class="sp"></div>
<h1>Signing you in…</h1>
<p>Verifying your central auth login, please wait.</p>
</div>
<script>
'use strict';
async function run(){
const card=document.getElementById('card');
const params=new URLSearchParams(location.search);
const token=params.get('access_token')||params.get('token');
if(!token){
card.innerHTML='<h1>Missing login token</h1><p>No token was provided in the URL. Please go back to central auth and try again.</p><button class="btn" onclick="location.href=\'/\'">Go to Login</button>';
return;
}
try{
const r=await fetch('/api/auth/sso-login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})});
const d=await r.json();
if(!r.ok||!d.success)throw new Error(d.message||'SSO login failed');
try{
sessionStorage.setItem('portal_token',d.token);
sessionStorage.setItem('portal_user',JSON.stringify(d.user));
document.cookie='portal_token='+encodeURIComponent(d.token)+';path=/;SameSite=Lax';
document.cookie='portal_user='+encodeURIComponent(JSON.stringify(d.user))+';path=/;SameSite=Lax';
}catch(_e){}
location.href='/';
}catch(e){
card.innerHTML='<h1 style="color:#fca5a5">Sign-in failed</h1><div class="err">'+String(e.message||e).replace(/</g,'&lt;')+'</div><button class="btn" onclick="location.href=\'/\'">Back to Login</button>';
}
}
run();
</script>
</body>
</html>
+1 -1
View File
@@ -786,7 +786,7 @@ function renderStep3(){
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}render();})(); (async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}render();})();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -317,6 +317,6 @@ async function init(){
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+101 -10
View File
@@ -176,11 +176,21 @@ let _userNames=null; // lazy-loaded [{username,fullName}], only fetched if CAN
let _co=window.__DEFAULT_COMPANY__;let _companies=[{value:_co,label:_co}]; let _co=window.__DEFAULT_COMPANY__;let _companies=[{value:_co,label:_co}];
let _rows=[],_loading=false,_hasSig=false,_curPO=null; let _rows=[],_loading=false,_hasSig=false,_curPO=null;
let _viewSearch='',_viewStatus='',_viewPage=0; // _viewStatus: '' | 'ISSUED' | 'RECEIVED' | 'VERIFIED' — every row here is an approved Work Order already, so the filter is about which sign-off is still outstanding, not WO approval status let _viewSearch='',_viewStatus='',_viewPage=0; // _viewStatus: '' | 'ISSUED' | 'RECEIVED' | 'VERIFIED' — every row here is an approved Work Order already, so the filter is about which sign-off is still outstanding, not WO approval status
let _viewDateFrom='',_viewDateTo=''; // filters by the Work Order's Created date
let _sapBatchMatches=[], _sapBatchTimer=null; // cross-reference results from /sap/lookup/batch-in-sap — same rationale as work-order.html's identical function
const VF_PAGE_SIZE=24; const VF_PAGE_SIZE=24;
// This user's allowed Item Groups for issuing (Admin → user → Issue Items). // This user's allowed Item Groups for issuing (Admin → user → Issue Items).
// Empty = no restriction. Same rule the real SAP issuance enforces. // Empty = no restriction. Same rule the real SAP issuance enforces.
let _issueGroups=[]; let _issueGroups=[];
let _balanceIds=[]; // Admin → System Settings — allowed Weighing Balance IDs; empty = free text let _balanceIds=[]; // Admin → System Settings — allowed Weighing Balance IDs; empty = free text
// Admin → System Settings → "Raw Material Qty Issued Source". Only under
// 'issue_for_production' (the default) does Qty Issued get set BEFORE this
// row's own "Mark Issued" stamp (derived from the real SAP posting) — so
// only then does it make sense to require that qty first. Under
// 'mark_issued', THIS stamp is what sets Qty Issued to the full required
// amount in the first place, so gating the button on qty already being
// there would make it permanently unreachable.
let _rawQtyIssuedSource='issue_for_production';
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');} 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 q(s){return document.querySelector(s);}
@@ -299,7 +309,7 @@ const ROW_STAMP_PREREQ={received:'issued',verified:'received'};
// A row's Issued/Received/Verified cell: signer+date if already stamped, a // 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 // 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. // stamp already exists, a "waiting on …" note if not, else a dash.
function stampCell(row,canMark,which,label){ function stampCell(row,canMark,which,label,blockedMsg){
const sig=rowSig(row,which); const sig=rowSig(row,which);
if(sig){ if(sig){
// Already signed — normally final. A woVerifyOverride/admin user who // Already signed — normally final. A woVerifyOverride/admin user who
@@ -312,16 +322,18 @@ function stampCell(row,canMark,which,label){
return issSigDisp(sig); return issSigDisp(sig);
} }
const prereq=ROW_STAMP_PREREQ[which]; const prereq=ROW_STAMP_PREREQ[which];
// A woVerifyOverride/admin user may sign a stage out of the normal // The NEXT-action button only ever appears once the prior stage is
// Issued→Received→Verified order (server allows it — see canOverrideStamp // genuinely done — no exception, for anyone, including a woVerifyOverride/
// in routes/workOrders.js) — everyone else still sees "Waiting on …" and // admin user. Override is only ever for RE-SIGNING an already-completed
// can't act until the prior stage is done. // stamp (see the `sig` branch above — correcting who signed/when), never
if(prereq&&!row[prereq+'At']&&!(canMark&&CAN_VERIFY_OVERRIDE))return `<span style="color:var(--text3);font-size:9.5px">Waiting on ${prereq}</span>`; // for skipping straight past a stage that hasn't happened yet.
if(prereq&&!row[prereq+'At'])return `<span style="color:var(--text3);font-size:9.5px">Waiting on ${prereq}</span>`;
if(canMark){ if(canMark){
if(CAN_VERIFY_OVERRIDE) if(CAN_VERIFY_OVERRIDE)
return `<button class="btn-mini" onclick="openVerifyOverride(${row.__woId},'${row.__section}',${row.__idx},'${which}')">${esc(label)}</button>`; return `<button class="btn-mini" onclick="openVerifyOverride(${row.__woId},'${row.__section}',${row.__idx},'${which}')">${esc(label)}</button>`;
return `<button class="btn-mini" onclick="markRow(${row.__woId},'${row.__section}',${row.__idx},'${which}',this)">${esc(label)}</button>`; return `<button class="btn-mini" onclick="markRow(${row.__woId},'${row.__section}',${row.__idx},'${which}',this)">${esc(label)}</button>`;
} }
if(blockedMsg)return `<span style="color:var(--text3);font-size:9.5px">${esc(blockedMsg)}</span>`;
return '<span style="color:var(--text3)">—</span>'; return '<span style="color:var(--text3)">—</span>';
} }
// Weighing Balance ID / AR No. — editable inline by whoever holds 'edit' on // Weighing Balance ID / AR No. — editable inline by whoever holds 'edit' on
@@ -352,8 +364,42 @@ function issCells(row,iss,groupMap){
// and workOrderPdf.js already do. // and workOrderPdf.js already do.
const qtyVal=row.__section==='raw'?row.qtyIssued:(iss.qtyByItem&&iss.qtyByItem[(row.itemCode||'').trim()]); const qtyVal=row.__section==='raw'?row.qtyIssued:(iss.qtyByItem&&iss.qtyByItem[(row.itemCode||'').trim()]);
const qty=`<td class="wo-td">${num3wo(qtyVal)}</td>`; const qty=`<td class="wo-td">${num3wo(qtyVal)}</td>`;
const canIssue=CAN_MARK_ISSUED&&itemGroupAllowed(row.itemCode,groupMap); // "Mark Issued" only appears once this row's FULL required quantity has
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>`; // actually been issued (in SAP for Packing/live off the PO; persisted on
// the row itself for Raw Material — see the comment above) — not merely
// whenever some partial quantity has gone out. A 0.001 tolerance absorbs
// float rounding from the prorated Raw Material figure.
const reqQty=parseFloat(row.__section==='raw'?row.qtyReq:row.qtyReqUnits)||0;
// The 'mark_issued' bypass ONLY applies to Raw Material — its displayed
// Qty Issued is the persisted, prorated value this exact stamp writes
// under that mode (see the server route), so checking it first would be
// circular. Packing Material's displayed Qty Issued always comes straight
// off the live SAP posting (qtyByItem above) no matter which mode is set —
// it's never written by this stamp — so it must always be checked for
// real, or a Work Order with no Production Order/issuance yet (qtyVal
// empty) would wrongly show "Mark Issued" as available.
// Even under the 'mark_issued' bypass, nothing is issuable before a
// Production Order actually exists for this Work Order — there's no
// production event at all yet for the raw-material stamp to be recording.
// This is what actually closes the reported bug: a fresh WO with "No
// Production Order created yet" was still showing every raw-material row
// as markable, because the bypass alone had no precondition of its own.
const rawBypass=row.__section==='raw'&&_rawQtyIssuedSource==='mark_issued'&&iss.hasPO;
const fullyIssued=rawBypass||(iss.hasPO&&(reqQty<=0||(parseFloat(qtyVal)||0)+0.001>=reqQty));
const permittedToIssue=CAN_MARK_ISSUED&&itemGroupAllowed(row.itemCode,groupMap);
const canIssue=permittedToIssue&&fullyIssued;
const issuedBlockedMsg=(permittedToIssue&&!fullyIssued)?(iss.hasPO?'Awaiting full qty issued':'No Production Order yet'):undefined;
// Received/Verified must also require a Production Order to exist — not
// just because Issued isn't stamped yet (the ordinary prereq check below
// already covers that for everyone), but specifically because a
// woVerifyOverride/admin user is otherwise allowed to sign OUT of the
// normal order to catch up genuine paperwork, and that bypass alone has no
// "did production even start" precondition of its own. Without gating it
// here too, that same user could stamp Received/Verified on a Work Order
// with no Production Order at all — nothing to actually be catching up on.
const canReceive=CAN_MARK_RECEIVED&&iss.hasPO;
const canVerify=CAN_MARK_VERIFIED&&iss.hasPO;
return qty+`<td class="wo-td">${stampCell(row,canIssue,'issued','Mark Issued',issuedBlockedMsg)}</td><td class="wo-td">${stampCell(row,canReceive,'received','Mark Received')}</td><td class="wo-td">${stampCell(row,canVerify,'verified','Mark Verified')}</td>`;
} }
function buildWoMaterials(w,iss,groupMap){ function buildWoMaterials(w,iss,groupMap){
@@ -493,6 +539,13 @@ function filteredRows(){
(w.itemName||'').toUpperCase().includes(s)|| (w.itemName||'').toUpperCase().includes(s)||
(w.batchNumber||'').toUpperCase().includes(s)|| (w.batchNumber||'').toUpperCase().includes(s)||
(w.intimationDocNo||'').toUpperCase().includes(s)); (w.intimationDocNo||'').toUpperCase().includes(s));
if(_viewDateFrom||_viewDateTo)list=list.filter(w=>{
if(!w.createdAt)return false;
const d=w.createdAt.slice(0,10);
if(_viewDateFrom&&d<_viewDateFrom)return false;
if(_viewDateTo&&d>_viewDateTo)return false;
return true;
});
return list; return list;
} }
function woCard(w){ function woCard(w){
@@ -513,6 +566,7 @@ function woCard(w){
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg> Batch ${esc(w.batchNumber||'—')}</span> <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg> Batch ${esc(w.batchNumber||'—')}</span>
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Intimation ${esc(w.intimationDocNo||'—')}</span> <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Intimation ${esc(w.intimationDocNo||'—')}</span>
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> ${esc(w.createdByName||w.createdBy||'—')}</span> <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> ${esc(w.createdByName||w.createdBy||'—')}</span>
${w.createdAt?`<span>${new Date(w.createdAt).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</span>`:''}
<span style="color:var(--text3)">${esc(poChip)}</span> <span style="color:var(--text3)">${esc(poChip)}</span>
</div> </div>
</div>`; </div>`;
@@ -540,9 +594,19 @@ function render(){
!_rows.length?`<div class="empty-msg">No approved work orders yet.</div>`:` !_rows.length?`<div class="empty-msg">No approved work orders yet.</div>`:`
<div class="vf-bar"> <div class="vf-bar">
<div class="vf-search"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--text3)"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg><input id="vf-search" placeholder="Search WO No, product, batch, intimation…" value="${esc(_viewSearch)}" style="padding-left:32px"/></div> <div class="vf-search"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--text3)"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg><input id="vf-search" placeholder="Search WO No, product, batch, intimation…" value="${esc(_viewSearch)}" style="padding-left:32px"/></div>
<input class="fi" id="vf-datefrom" type="date" value="${esc(_viewDateFrom)}" style="width:140px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="vf-dateto" type="date" value="${esc(_viewDateTo)}" style="width:140px"/>
<div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}" title="${v?'Still pending '+lbl:'All approved Work Orders'}">${lbl}</button>`).join('')}</div> <div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}" title="${v?'Still pending '+lbl:'All approved Work Orders'}">${lbl}</button>`).join('')}</div>
<span class="vf-count">${list.length} of ${_rows.length}</span> <span class="vf-count">${list.length} of ${_rows.length}</span>
</div> </div>
${_sapBatchMatches.length?`<div style="margin:0 0 12px;padding:10px 12px;background:var(--surface2,#f5f7fa);border:1px solid var(--border,#dfe4ea);border-radius:10px">
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:6px">Also found in SAP (not one of this portal's own Work Orders):</div>
${_sapBatchMatches.map(m=>m.source==='productionOrder'
?`<div style="font-size:12px;padding:3px 0"><a href="/production#order=${m.absEntry}&company=${encodeURIComponent(_co)}" style="color:#2563eb" target="_blank">Production Order ${esc(String(m.docNum))}</a> — ${esc(m.itemCode)} ${esc(m.itemName||'')} · Batch (SAP Comments) <span style="font-family:monospace">${esc(m.batch)}</span></div>`
:`<div style="font-size:12px;padding:3px 0">SAP Batch Master — ${esc(m.itemCode)} ${esc(m.itemName||'')} · <span style="font-family:monospace">${esc(m.batch)}</span></div>`
).join('')}
</div>`:''}
${pageList.length?`<div class="wo-grid">${pageList.map(woCard).join('')}</div>`:'<div class="empty-msg">No work orders match this search/filter.</div>'} ${pageList.length?`<div class="wo-grid">${pageList.map(woCard).join('')}</div>`:'<div class="empty-msg">No work orders match this search/filter.</div>'}
${list.length>VF_PAGE_SIZE?`<div class="vf-pager"> ${list.length>VF_PAGE_SIZE?`<div class="vf-pager">
<button id="vf-prev" ${_viewPage===0?'disabled':''}>← Prev</button> <button id="vf-prev" ${_viewPage===0?'disabled':''}>← Prev</button>
@@ -556,8 +620,11 @@ function render(){
q('#vf-search')&&(q('#vf-search').oninput=function(){ q('#vf-search')&&(q('#vf-search').oninput=function(){
const pos=this.selectionStart;_viewSearch=this.value;_viewPage=0;render(); const pos=this.selectionStart;_viewSearch=this.value;_viewPage=0;render();
const el=q('#vf-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}} const el=q('#vf-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}}
scheduleSapBatchLookup();
}); });
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;render();}); document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;render();});
q('#vf-datefrom')&&(q('#vf-datefrom').onchange=function(){_viewDateFrom=this.value;_viewPage=0;render();});
q('#vf-dateto')&&(q('#vf-dateto').onchange=function(){_viewDateTo=this.value;_viewPage=0;render();});
if(q('#vf-prev'))q('#vf-prev').onclick=function(){_viewPage--;render();}; if(q('#vf-prev'))q('#vf-prev').onclick=function(){_viewPage--;render();};
if(q('#vf-next'))q('#vf-next').onclick=function(){_viewPage++;render();}; if(q('#vf-next'))q('#vf-next').onclick=function(){_viewPage++;render();};
document.querySelectorAll('[data-open]').forEach(el=>el.onclick=function(){ document.querySelectorAll('[data-open]').forEach(el=>el.onclick=function(){
@@ -565,15 +632,39 @@ function render(){
}); });
} }
// Same cross-reference lookup as Batch Issuance/Work Order's own search
// boxes — see batch-issuance.html's identical function for the full
// rationale (SAP Production Orders created directly in SAP B1 store their
// batch number as free text in Comments, invisible to every local search).
function scheduleSapBatchLookup(){
clearTimeout(_sapBatchTimer);
const term=_viewSearch.trim();
if(term.length<4){
if(_sapBatchMatches.length){_sapBatchMatches=[];render();}
return;
}
_sapBatchTimer=setTimeout(async()=>{
if(_viewSearch.trim()!==term)return;
try{
const r=await api('GET',`/sap/lookup/batch-in-sap?batch=${encodeURIComponent(term)}&company=${encodeURIComponent(_co)}`);
if(_viewSearch.trim()!==term)return;
_sapBatchMatches=r.data||[];
}catch(_e){_sapBatchMatches=[];}
const pos=q('#vf-search')?.selectionStart;
render();
const s2=q('#vf-search');if(s2&&pos!=null){s2.focus();try{s2.setSelectionRange(pos,pos);}catch(_e){}}
},450);
}
async function init(){ async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){} 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 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 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){} try{const cfg=await api('GET','/config');if(Array.isArray(cfg?.woWeighingBalanceIds))_balanceIds=cfg.woWeighingBalanceIds;if(cfg?.woRawQtyIssuedSource)_rawQtyIssuedSource=cfg.woRawQtyIssuedSource;}catch(_e){}
if(CAN_VIEW)load();else render(); if(CAN_VIEW)load();else render();
} }
init(); init();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -303,6 +303,6 @@ async function save(){
loadProfiles(); loadProfiles();
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+87 -8
View File
@@ -302,6 +302,8 @@ let _bomFetched=false;
let _saving=false, _created=null, _bomLoading=false; let _saving=false, _created=null, _bomLoading=false;
let _list=[], _listLoading=false; let _list=[], _listLoading=false;
let _viewSearch='', _viewStatus='', _viewPage=0; let _viewSearch='', _viewStatus='', _viewPage=0;
let _viewDateFrom='', _viewDateTo=''; // filters by the Work Order's Created date
let _sapBatchMatches=[], _sapBatchTimer=null; // cross-reference results from /sap/lookup/batch-in-sap — see scheduleSapBatchLookup()
const VF_PAGE_SIZE=24; const VF_PAGE_SIZE=24;
let _searchTimer={}, _ddInput=null; let _searchTimer={}, _ddInput=null;
@@ -1796,6 +1798,35 @@ async function startEdit(w){
// then shows up correctly instead of staying wrongly flattened forever. // then shows up correctly instead of staying wrongly flattened forever.
// (A non-rejected edit, or one that was never flattened, keeps loading the // (A non-rejected edit, or one that was never flattened, keeps loading the
// saved rows as before — no need to touch what's already correct.) // saved rows as before — no need to touch what's already correct.)
// A rejected order's header (batch size/MFG/EXP/etc.) is a snapshot taken
// when it was first created from the Batch Intimation — if that Intimation
// was corrected afterwards (the whole point of sending a WO back), editing
// here must show the CURRENT intimation data, not the stale snapshot. Only
// for the rejected+resubmit path — a normal in-flight edit keeps its own
// already-correct saved values untouched.
if(_editingRejected&&_intimationId){
try{
const r=await api('GET',`/batch-intimations/${_intimationId}`);
const it=r.data;
const p=(it.products||[]).find(x=>String(x.itemCode)===String(w.productCode));
const b=p&&(p.batches||[]).find(x=>String(x.batchNo)===String(w.batchNumber));
if(b){
_srcBatchVolume=b.batchVolume!=null&&b.batchVolume!==''?String(b.batchVolume):'';
_srcSolutionVolumes=Array.isArray(b.solutionVolumes)?b.solutionVolumes.filter(sv=>(sv.name||'').trim()&&sv.volume!=null&&sv.volume!==''):[];
Object.assign(_wo,{
reference:it.docNo||w.reference||'',
productName:p.itemName||p.itemDesc||w.productName||'',
batchNumber:b.batchNo||w.batchNumber||'',
totalUnits:b.batchSize!=null?String(b.batchSize):(w.totalUnits||''),
batchSize:_srcBatchVolume?`${_srcBatchVolume} Ltrs`:(w.batchSize||''),
mfgDate:b.mfgDate||'',
expDate:b.expDate||'',
market:b.market||w.market||'',
});
toast('Header refreshed from latest '+(it.docNo||'Intimation')+' data');
}
}catch(_e){/* fall back silently to the saved snapshot below */}
}
if(_editingRejected&&w.componentsOnly&&_wo.productCode){ if(_editingRejected&&w.componentsOnly&&_wo.productCode){
_forceComponentsOnly=false; // try for a real split now, not the old flat snapshot _forceComponentsOnly=false; // try for a real split now, not the old flat snapshot
_bomFetched=false;_raw=[rawBlank()];_pack=[packBlank()]; _bomFetched=false;_raw=[rawBlank()];_pack=[packBlank()];
@@ -1875,6 +1906,13 @@ function filteredWOList(){
(w.productName||'').toUpperCase().includes(s)|| (w.productName||'').toUpperCase().includes(s)||
(w.batchNumber||'').toUpperCase().includes(s)|| (w.batchNumber||'').toUpperCase().includes(s)||
(w.reference||'').toUpperCase().includes(s)); (w.reference||'').toUpperCase().includes(s));
if(_viewDateFrom||_viewDateTo)list=list.filter(w=>{
if(!w.createdAt)return false;
const d=w.createdAt.slice(0,10);
if(_viewDateFrom&&d<_viewDateFrom)return false;
if(_viewDateTo&&d>_viewDateTo)return false;
return true;
});
return list; return list;
} }
@@ -1889,6 +1927,9 @@ function renderView(){
const chips=[['','All'],['IN_PROGRESS','Pending'],['APPROVED','Approved'],['REJECTED','Rejected']]; const chips=[['','All'],['IN_PROGRESS','Pending'],['APPROVED','Approved'],['REJECTED','Rejected']];
const bar=`<div class="vf-bar"> const bar=`<div class="vf-bar">
<div class="vf-search"><i data-lucide="search" style="width:14px;height:14px"></i><input id="vf-search" placeholder="Search WO No, product, batch, reference…" value="${esc(_viewSearch)}"/></div> <div class="vf-search"><i data-lucide="search" style="width:14px;height:14px"></i><input id="vf-search" placeholder="Search WO No, product, batch, reference…" value="${esc(_viewSearch)}"/></div>
<input class="fi" id="vf-datefrom" type="date" value="${esc(_viewDateFrom)}" style="width:140px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="vf-dateto" type="date" value="${esc(_viewDateTo)}" style="width:140px"/>
<div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}">${lbl}</button>`).join('')}</div> <div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}">${lbl}</button>`).join('')}</div>
<span class="vf-count">${list.length} of ${_list.length}</span> <span class="vf-count">${list.length} of ${_list.length}</span>
</div>`; </div>`;
@@ -1914,11 +1955,44 @@ function renderView(){
<span><i data-lucide="hash" style="width:12px;height:12px;flex-shrink:0"></i> Batch ${esc(w.batchNumber||'—')}</span> <span><i data-lucide="hash" style="width:12px;height:12px;flex-shrink:0"></i> Batch ${esc(w.batchNumber||'—')}</span>
<span><i data-lucide="link" style="width:12px;height:12px;flex-shrink:0"></i> Intimation ${esc(w.intimationDocNo||'—')}</span> <span><i data-lucide="link" style="width:12px;height:12px;flex-shrink:0"></i> Intimation ${esc(w.intimationDocNo||'—')}</span>
<span><i data-lucide="user" style="width:12px;height:12px;flex-shrink:0"></i> ${esc(w.createdByName||w.createdBy||'—')}</span> <span><i data-lucide="user" style="width:12px;height:12px;flex-shrink:0"></i> ${esc(w.createdByName||w.createdBy||'—')}</span>
${w.createdAt?`<span><i data-lucide="calendar" style="width:12px;height:12px;flex-shrink:0"></i> ${fmtDate(w.createdAt)}</span>`:''}
</div> </div>
</div>`; </div>`;
}).join('')}</div>` }).join('')}</div>`
: `<div class="card"><div class="empty-msg">No work orders match your search/filter.</div></div>`; : `<div class="card"><div class="empty-msg">No work orders match your search/filter.</div></div>`;
return bar+grid+pager; const sapBanner=_sapBatchMatches.length?`<div style="margin:0 0 12px;padding:10px 12px;background:var(--wo-surface2,#f5f7fa);border:1px solid var(--wo-border,#dfe4ea);border-radius:10px">
<div style="font-size:11px;font-weight:700;color:var(--wo-text2);margin-bottom:6px"><i data-lucide="search-check" style="width:12px;height:12px;vertical-align:-2px"></i> Also found in SAP (not one of this portal's own Work Orders):</div>
${_sapBatchMatches.map(m=>m.source==='productionOrder'
?`<div style="font-size:12px;padding:3px 0"><a href="/production#order=${m.absEntry}&company=${encodeURIComponent(_co)}" style="color:#2563eb" target="_blank">Production Order ${esc(String(m.docNum))}</a> — ${esc(m.itemCode)} ${esc(m.itemName||'')} · Batch (SAP Comments) <span style="font-family:monospace">${esc(m.batch)}</span></div>`
:`<div style="font-size:12px;padding:3px 0">SAP Batch Master — ${esc(m.itemCode)} ${esc(m.itemName||'')} · <span style="font-family:monospace">${esc(m.batch)}</span></div>`
).join('')}
</div>`:'';
return bar+sapBanner+grid+pager;
}
// Same cross-reference lookup as Batch Issuance's own search box — see that
// file's identical function for the full rationale (SAP Production Orders
// created directly in SAP B1 store their batch number as free text in
// Comments, invisible to every local search otherwise).
function scheduleSapBatchLookup(){
clearTimeout(_sapBatchTimer);
const term=_viewSearch.trim();
if(term.length<4){
if(_sapBatchMatches.length){_sapBatchMatches=[];q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}
return;
}
_sapBatchTimer=setTimeout(async()=>{
if(_viewSearch.trim()!==term)return;
try{
const r=await api('GET',`/sap/lookup/batch-in-sap?batch=${encodeURIComponent(term)}&company=${encodeURIComponent(_co)}`);
if(_viewSearch.trim()!==term)return;
_sapBatchMatches=r.data||[];
}catch(_e){_sapBatchMatches=[];}
const pos=q('#vf-search')?.selectionStart;
q('#body').innerHTML=renderView();wireView();
const s2=q('#vf-search');if(s2&&pos!=null){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons();
},450);
} }
function wireView(){ function wireView(){
@@ -1933,9 +2007,12 @@ function wireView(){
const s2=q('#vf-search'); const s2=q('#vf-search');
if(s2){s2.focus();s2.setSelectionRange(pos,pos);} if(s2){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons(); if(window.lucide)lucide.createIcons();
scheduleSapBatchLookup();
}; };
} }
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}); document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();});
if(q('#vf-datefrom'))q('#vf-datefrom').onchange=function(){_viewDateFrom=this.value;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(q('#vf-dateto'))q('#vf-dateto').onchange=function(){_viewDateTo=this.value;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(q('#vf-prev'))q('#vf-prev').onclick=function(){_viewPage--;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}; if(q('#vf-prev'))q('#vf-prev').onclick=function(){_viewPage--;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(q('#vf-next'))q('#vf-next').onclick=function(){_viewPage++;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}; if(q('#vf-next'))q('#vf-next').onclick=function(){_viewPage++;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
} }
@@ -2079,19 +2156,21 @@ async function openWODetail(id){
// permission (matches routes/workOrders.js's PUT /:id REJECTED branch). // permission (matches routes/workOrders.js's PUT /:id REJECTED branch).
const canEditHere=w.status==='REJECTED'?hasStepPerm(`work_order:${STEP_KEYS[0]}`,'edit'):(canAct&&curStepKey&&hasStepPerm(`work_order:${curStepKey}`,'edit')); const canEditHere=w.status==='REJECTED'?hasStepPerm(`work_order:${STEP_KEYS[0]}`,'edit'):(canAct&&curStepKey&&hasStepPerm(`work_order:${curStepKey}`,'edit'));
const canDeleteHere=hasStepPerm(`work_order:${curStepKey||'prepared_qa'}`,'delete'); const canDeleteHere=hasStepPerm(`work_order:${curStepKey||'prepared_qa'}`,'delete');
// Admin-only override: recall a FULLY APPROVED order back to QA for // Recall a FULLY APPROVED order back to QA for editing — admin/
// editing (no approval step required — an admin action, not a normal // system_admin always may; a regular user may also be granted this via
// in-flight rejection). Re-uses the REJECTED status, so once sent back it // 'approve' on the dedicated work_order:send_to_qa step (Admin → Edit
// shows up with the existing "Edit & Resubmit" button, exactly like a // User → Approval Steps) — previously admin/system_admin only, no
// assignable step at all. Re-uses the REJECTED status, so once sent back
// it shows up with the existing "Edit & Resubmit" button, exactly like a
// normal rejection would. // normal rejection would.
const canSendToQa=w.status==='APPROVED'&&_user&&(_user.role==='admin'||_user.role==='system_admin'); const canSendToQa=w.status==='APPROVED'&&_user&&(_user.role==='admin'||_user.role==='system_admin'||hasStepPerm('work_order:send_to_qa','approve'));
const footerBtns=` const footerBtns=`
<button class="wc-btn" onclick="window.open('/work-order-print?id=${w.id}','_blank')"><i data-lucide="printer" style="width:13px;height:13px"></i> Print</button> <button class="wc-btn" onclick="window.open('/work-order-print?id=${w.id}','_blank')"><i data-lucide="printer" style="width:13px;height:13px"></i> Print</button>
<button class="wc-btn" onclick="downloadWoPdf(${w.id},'${esc(w.woNo)}')"><i data-lucide="file-down" style="width:13px;height:13px"></i> Download PDF</button> <button class="wc-btn" onclick="downloadWoPdf(${w.id},'${esc(w.woNo)}')"><i data-lucide="file-down" style="width:13px;height:13px"></i> Download PDF</button>
${CAN_DUPLICATE_WO?`<button class="wc-btn" data-dup="${w.id}" title="Create a new Work Order — same item selection, fresh batch, starts at Step 1"><i data-lucide="copy" style="width:13px;height:13px"></i> Duplicate</button>`:''} ${CAN_DUPLICATE_WO?`<button class="wc-btn" data-dup="${w.id}" title="Create a new Work Order — same item selection, fresh batch, starts at Step 1"><i data-lucide="copy" style="width:13px;height:13px"></i> Duplicate</button>`:''}
${canApproveHere?`<button class="wc-btn" data-appr="${w.id}" style="border-color:#b3dab3;color:#16a34a"><i data-lucide="check" style="width:13px;height:13px"></i> Approve step</button> ${canApproveHere?`<button class="wc-btn" data-appr="${w.id}" style="border-color:#b3dab3;color:#16a34a"><i data-lucide="check" style="width:13px;height:13px"></i> Approve step</button>
<button class="wc-btn del" data-rej="${w.id}"><i data-lucide="x" style="width:13px;height:13px"></i> Reject</button>`:''} <button class="wc-btn del" data-rej="${w.id}"><i data-lucide="x" style="width:13px;height:13px"></i> Reject</button>`:''}
${canSendToQa?`<button class="wc-btn" data-sendqa="${w.id}" style="border-color:#fbd38d;color:#b45309" title="Admin: recall this fully approved Work Order back to QA for editing"><i data-lucide="undo-2" style="width:13px;height:13px"></i> Send to QA</button>`:''} ${canSendToQa?`<button class="wc-btn" data-sendqa="${w.id}" style="border-color:#fbd38d;color:#b45309" title="Recall this fully approved Work Order back to QA for editing — restarts the full 5-step chain"><i data-lucide="undo-2" style="width:13px;height:13px"></i> Send to QA</button>`:''}
${canEditHere?`<button class="wc-btn" data-edit="${w.id}"><i data-lucide="pencil" style="width:13px;height:13px"></i> ${w.status==='REJECTED'?'Edit & Resubmit':'Edit'}</button>`:''} ${canEditHere?`<button class="wc-btn" data-edit="${w.id}"><i data-lucide="pencil" style="width:13px;height:13px"></i> ${w.status==='REJECTED'?'Edit & Resubmit':'Edit'}</button>`:''}
${canDeleteHere?`<button class="wc-btn del" data-del="${w.id}"><i data-lucide="trash-2" style="width:13px;height:13px"></i> Delete</button>`:''}`; ${canDeleteHere?`<button class="wc-btn del" data-del="${w.id}"><i data-lucide="trash-2" style="width:13px;height:13px"></i> Delete</button>`:''}`;
@@ -2275,6 +2354,6 @@ function applyDuplicatePayloadIfAny(){
} }
})(); })();
</script> </script>
<script src="/sidebar.js?v=25"></script> <script src="/sidebar.js?v=31"></script>
</body> </body>
</html> </html>
+6
View File
@@ -59,6 +59,12 @@ router.post('/login', async (req, res) => {
} }
}); });
// NOTE: central-auth SSO login (POST /sso-login) lives in server.js's own
// inline authRouter, not here — this file (routes/auth.js) is NOT mounted by
// server.js (which defines its own, more complete /api/auth router with
// modules/approvalSteps/per-company sapLogins), so anything added here is
// dead code. See server.js's authRouter.post('/sso-login', ...).
// ── GET /auth/me ────────────────────────────────────────────────────────────── // ── GET /auth/me ──────────────────────────────────────────────────────────────
router.get('/me', (req, res) => { router.get('/me', (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', ''); const token = (req.headers.authorization || '').replace('Bearer ', '');
+14 -4
View File
@@ -246,13 +246,23 @@ router.get('/:id/linked-work-orders', verifyToken, async (req, res) => {
router.get('/linked-work-orders/all', verifyToken, async (req, res) => { router.get('/linked-work-orders/all', verifyToken, async (req, res) => {
try { try {
const wos = await workOrderStore().listWorkOrders({}); const wos = await workOrderStore().listWorkOrders({});
const map = {}; const map = {}; // non-rejected only — whole-row/whole-document lock (Edit/Delete gating)
const allMap = {}; // every non-deleted status, REJECTED included — used only to keep a
// batch's own Batch No. (and its row) from being changed/removed once
// ANY Work Order, even a rejected one awaiting edit+resubmit, already
// references it by that number. Without this, correcting other fields
// on a rejected batch's row (which IS meant to stay editable) could also
// let the Batch No. itself drift, silently breaking the (productCode,
// batchNumber) link back to that rejected Work Order.
wos.forEach(w => { wos.forEach(w => {
if (!w.intimationId || w.isDeleted || w.status === 'REJECTED') return; if (!w.intimationId || w.isDeleted) return;
const k = String(w.intimationId); const k = String(w.intimationId);
(map[k] || (map[k] = [])).push({ id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' }); const entry = { id: w.id, woNo: w.woNo, productCode: w.productCode || '', batchNumber: w.batchNumber || '' };
(allMap[k] || (allMap[k] = [])).push(entry);
if (w.status === 'REJECTED') return;
(map[k] || (map[k] = [])).push(entry);
}); });
res.json({ success: true, data: map }); res.json({ success: true, data: map, allData: allMap });
} catch (err) { res.status(500).json({ success: false, message: err.message }); } } catch (err) { res.status(500).json({ success: false, message: err.message }); }
}); });
+161
View File
@@ -0,0 +1,161 @@
// routes/batchNoTransaction.js
// Mirrors SAP B1's own standard "Batch Number Transactions Report" (Inventory
// → Inventory Reports → Batch Number Transactions Report): a master grid of
// batches (from OIBT, SAP's per-batch/per-warehouse stock table) and, for a
// selected batch, a detail grid of every stock-affecting document that ever
// touched it (from IBT1, SAP's batch-transaction-log table).
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const cq = (req) => req.query?.company || req.body?.company || null;
const sqlEsc = (v) => String(v).replace(/'/g, "''");
// GET / — the "Batches" master grid: one row per Item/Batch/Warehouse,
// exactly the fields SAP's own report shows (Item No, Item Description,
// Batch, Whse, Quantity, Expiration Date, Manufacturing Date, Project,
// Status, COA_Status, Sale Type — all native OIBT columns), plus Committed/
// OnOrder/Available as extra value-add columns (same formula as Inventory
// Status Report) since the data is already on hand.
router.get('/', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const itemFrom = (req.query.itemFrom || '').trim();
const itemTo = (req.query.itemTo || '').trim();
const batchNo = (req.query.batchNo || '').trim();
const itemGroup = (req.query.itemGroup || '').trim();
const includeZeroQty = req.query.includeZeroQty === '1';
const warehouses = (req.query.warehouses || '').split(',').map((w) => w.trim()).filter(Boolean);
const where = [];
if (itemFrom) where.push(`T0."ItemCode" >= '${sqlEsc(itemFrom)}'`);
if (itemTo) where.push(`T0."ItemCode" <= '${sqlEsc(itemTo)}'`);
if (batchNo) where.push(`T0."BatchNum" LIKE '%${sqlEsc(batchNo)}%'`);
if (itemGroup) where.push(`T2."ItmsGrpCod" = ${parseInt(itemGroup)}`);
if (warehouses.length) where.push(`T0."WhsCode" IN (${warehouses.map((w) => `'${sqlEsc(w)}'`).join(',')})`);
if (!includeZeroQty) where.push(`T0."Quantity" <> 0`);
const query = `
SELECT T0."ItemCode", T0."ItemName", T0."BatchNum", T0."WhsCode",
T0."Quantity", T0."IsCommited", T0."OnOrder",
T0."PrdDate", T0."ExpDate",
T0."U_Project", T0."U_Status", T0."U_COA_Status", T0."U_SaleType",
T2."InvntryUom" AS "Uom"
FROM [dbo].[OIBT] T0
LEFT JOIN [dbo].[OITM] T2 ON T2."ItemCode" = T0."ItemCode"
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY T0."ItemCode", T0."BatchNum", T0."WhsCode"`;
const result = await pool.request().query(query);
const data = result.recordset.map((r) => {
const qty = Number(r.Quantity) || 0;
const committed = Number(r.IsCommited) || 0;
const onOrder = Number(r.OnOrder) || 0;
return {
itemCode: r.ItemCode,
itemName: r.ItemName || '',
batchNo: r.BatchNum,
warehouse: r.WhsCode,
inStock: qty,
committed,
ordered: onOrder,
available: qty - committed + onOrder,
mfgDate: r.PrdDate,
expDate: r.ExpDate,
project: r.U_Project || '',
status: r.U_Status || '',
coaStatus: r.U_COA_Status || '',
saleType: r.U_SaleType || '',
uom: r.Uom || '',
};
});
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// BaseType (IBT1) → the SAP document table/label that actually recorded the
// stock movement. Confirmed by cross-checking real BaseEntry values against
// each candidate table in this database (e.g. BaseType 18 entries' BaseEntry
// values are found in OPCH, with matching CardName/DocDate — not any other
// table), not guessed from memory of SAP's BoObjectTypes enum alone.
const DOC_TYPES = {
13: { label: 'A/R Invoice', table: 'OINV' },
14: { label: 'A/R Credit Memo', table: 'ORIN' },
15: { label: 'Delivery', table: 'ODLN' },
16: { label: 'Return', table: 'ORDN' },
17: { label: 'Sales Order', table: 'ORDR' },
18: { label: 'A/P Invoice', table: 'OPCH' },
19: { label: 'A/P Credit Memo', table: 'ORPC' },
20: { label: 'Goods Receipt PO', table: 'OPDN' },
21: { label: 'Goods Return', table: 'ORPD' },
22: { label: 'Purchase Order', table: 'OPOR' },
59: { label: 'Goods Receipt', table: 'OIGN' },
60: { label: 'Goods Issue', table: 'OIGE' },
67: { label: 'Inventory Transfer', table: 'OWTR' },
310000001: { label: 'Production Order', table: 'OWOR' },
};
// GET /transactions?itemCode=X&batchNo=Y — the "Transactions for Batch" grid.
// Reads IBT1, SAP's own batch-transaction log, one row per stock-affecting
// document line that ever touched this exact Item+Batch.
//
// Sign/direction rule (reverse-engineered against this database's real IBT1
// data, not assumed from documentation): Direction 0 and 1 ALWAYS store
// Quantity as a positive magnitude — 0 means the document increased batch
// stock (In), 1 means it decreased it (Out), so the sign has to be applied
// here. Direction 2 (used by AR/Sales-side docs — Invoice/Credit
// Memo/Return/Sales Order) stores Quantity ALREADY signed correctly, so it's
// used as-is. Verified against BaseType 60 (Goods Issue: always Direction 1,
// Quantity always ≥0) and BaseType 15 (Delivery: Direction 0/1/2 all appear,
// but only Direction 2 rows have negative Quantity stored).
router.get('/transactions', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const itemCode = (req.query.itemCode || '').trim();
const batchNo = (req.query.batchNo || '').trim();
if (!itemCode || !batchNo) return res.json({ success: true, data: [] });
const query = `
SELECT T0."ItemCode", T0."BatchNum", T0."WhsCode", T1."WhsName",
T0."LineNum", T0."BaseType", T0."BaseNum", T0."BaseLinNum",
T0."DocDate", T0."Quantity", T0."Direction", T0."CardName"
FROM [dbo].[IBT1] T0
LEFT JOIN [dbo].[OWHS] T1 ON T1."WhsCode" = T0."WhsCode"
WHERE T0."ItemCode" = '${sqlEsc(itemCode)}' AND T0."BatchNum" = '${sqlEsc(batchNo)}'
ORDER BY T0."DocDate", T0."LineNum"`;
const result = await pool.request().query(query);
const data = result.recordset.map((r, i) => {
const rawQty = Number(r.Quantity) || 0;
const dir = Number(r.Direction);
const qty = dir === 1 ? -Math.abs(rawQty) : dir === 0 ? Math.abs(rawQty) : rawQty;
const docType = DOC_TYPES[Number(r.BaseType)] || { label: `Doc Type ${r.BaseType}`, table: '' };
return {
no: i + 1,
docTypeLabel: docType.label,
docNum: r.BaseNum,
docRow: r.BaseLinNum,
date: r.DocDate,
warehouse: r.WhsCode,
warehouseName: r.WhsName || '',
bpName: r.CardName || '',
qty,
direction: qty >= 0 ? 'In' : 'Out',
itemCode: r.ItemCode,
batchNo: r.BatchNum,
};
});
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+10 -1
View File
@@ -55,7 +55,16 @@ router.get('/', verifyToken, async (req, res) => {
try { try {
const result = await getSap().sapRequest('GET', endpoint, null, companyDB, true, { Prefer: `odata.maxpagesize=${top}` }); const result = await getSap().sapRequest('GET', endpoint, null, companyDB, true, { Prefer: `odata.maxpagesize=${top}` });
res.json({ success: true, data: result.value || [], count: result['odata.count'] }); // Genuine total (same filter, no paging) — drives "Page X of Y" on the
// client instead of an open-ended "Page X".
let total = null;
try {
const countEndpoint = `BusinessPartners/$count${filters.length ? `?$filter=${encodeURIComponent(filters.join(' and '))}` : ''}`;
const c = await getSap().sapRequest('GET', countEndpoint, null, companyDB);
const n = Number(c);
if (!isNaN(n)) total = n;
} catch (e) { console.warn('[BUSINESS-MASTER] $count failed — pager will show no total:', e.message); }
res.json({ success: true, data: result.value || [], count: result['odata.count'], total });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, message: err.message }); res.status(500).json({ success: false, message: err.message });
} }
+70
View File
@@ -0,0 +1,70 @@
'use strict';
// routes/downtimeAnalysis.js — "Downtime Analysis" report (see
// services/downtimeAnalysisStore.js for the aggregation logic and
// services/appSettingsStore.js's downtimeAnalysisConfig for the admin-edited
// cause taxonomy/field mapping/Base Days). Gated purely by the
// 'downtime-analysis' sidebar module (Admin → Users) — same soft-gate
// pattern as the other report-style pages (Inventory Status Report, PWO
// Source Report, etc.): verifyToken only here, the module toggle controls
// who even sees the menu link.
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const store = require('./../services/downtimeAnalysisStore');
const cq = (req) => req.query?.company || req.body?.company || null;
router.get('/report', verifyToken, async (req, res) => {
try {
const data = await store.buildReport({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
baseDays: req.query.baseDays,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
router.get('/by-tab', verifyToken, async (req, res) => {
try {
const data = await store.buildTabReport({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
baseDays: req.query.baseDays,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
router.get('/detail-by-tab', verifyToken, async (req, res) => {
try {
const data = await store.buildDetailByTab({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
router.get('/monthly', verifyToken, async (req, res) => {
try {
const data = await store.buildMonthlyReport({
company: cq(req),
periodFrom: req.query.periodFrom,
periodTo: req.query.periodTo,
});
res.json({ success: true, data });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
module.exports = router;
+28 -25
View File
@@ -58,6 +58,11 @@ function validUQC(code) {
// ── SQL builders ────────────────────────────────────────────────────────── // ── SQL builders ──────────────────────────────────────────────────────────
// Deemed Export (DE) sales ledgers — invoices posting to any of these GL
// accounts are reported as B2B with invoice_type 'DE' and kept out of EXP.
// Updated 05-10-2026: added 4110202005, 4110202007 (matches EXCL_EI in reports.js)
const DE_LEDGERS = `'4110202001','4110202003','4110202005','4110202007'`;
function sqlB2B(from, to) { function sqlB2B(from, to) {
return ` return `
WITH TaxData AS ( WITH TaxData AS (
@@ -127,7 +132,7 @@ WITH TaxData AS (
FROM TaxData GROUP BY DocEntry, TaxRate FROM TaxData GROUP BY DocEntry, TaxRate
) )
SELECT SELECT
CASE WHEN EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN ('4110202001','4110202003')) THEN 'DE' CASE WHEN EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN (${DE_LEDGERS})) THEN 'DE'
WHEN T_DOC12.CountryB<>'IN' THEN 'EXP' WHEN T_DOC12.CountryB<>'IN' THEN 'EXP'
WHEN T_ODOC.U_CustomerCategory='Indirect Export' THEN 'B2B' WHEN T_ODOC.U_CustomerCategory='Indirect Export' THEN 'B2B'
WHEN T_DOC12.BpGSTN IS NULL AND T_DOC12.CountryS='IN' THEN 'B2CS' WHEN T_DOC12.BpGSTN IS NULL AND T_DOC12.CountryS='IN' THEN 'B2CS'
@@ -154,7 +159,7 @@ LEFT JOIN OCST T_OCST_S ON T_OCST_S.Code=T_DOC12.StateS
WHERE T_ODOC.CANCELED='N' WHERE T_ODOC.CANCELED='N'
AND T_ODOC.DocDate>='${from}' AND T_ODOC.DocDate<='${to}' AND T_ODOC.DocDate>='${from}' AND T_ODOC.DocDate<='${to}'
AND ( AND (
EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN ('4110202001','4110202003')) EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN (${DE_LEDGERS}))
OR ( OR (
T_DOC12.CountryB='IN' T_DOC12.CountryB='IN'
AND (T_DOC12.BpGSTN IS NOT NULL OR T_ODOC.U_CustomerCategory='Indirect Export') AND (T_DOC12.BpGSTN IS NOT NULL OR T_ODOC.U_CustomerCategory='Indirect Export')
@@ -434,7 +439,7 @@ WHERE T_ODOC.CANCELED='N'
AND T_ODOC.DocDate>='${from}' AND T_ODOC.DocDate<='${to}' AND T_ODOC.DocDate>='${from}' AND T_ODOC.DocDate<='${to}'
AND T_DOC12.ImpORExp='Y' AND T_DOC12.CountryB<>'IN' AND T_DOC12.ImpORExp='Y' AND T_DOC12.CountryB<>'IN'
AND T_DOC12.BpGSTN IS NULL AND T_OCST_S.GSTCode IS NULL AND T_DOC12.BpGSTN IS NULL AND T_OCST_S.GSTCode IS NULL
AND NOT EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN ('4110202001','4110202003')) AND NOT EXISTS (SELECT 1 FROM INV1 T_DE WHERE T_DE.DocEntry=T_ODOC.DocEntry AND T_DE.AcctCode IN (${DE_LEDGERS}))
ORDER BY ITS.DocEntry, ITS.TaxRate`; ORDER BY ITS.DocEntry, ITS.TaxRate`;
} }
@@ -447,7 +452,9 @@ function sqlHsn(from, to) {
SUM(CASE WHEN staType = '-120' THEN TaxSum ELSE 0 END) AS iamt, SUM(CASE WHEN staType = '-120' THEN TaxSum ELSE 0 END) AS iamt,
SUM(CASE WHEN staType = '-110' THEN TaxSum ELSE 0 END) AS camt, SUM(CASE WHEN staType = '-110' THEN TaxSum ELSE 0 END) AS camt,
SUM(CASE WHEN staType = '-100' THEN TaxSum ELSE 0 END) AS samt, SUM(CASE WHEN staType = '-100' THEN TaxSum ELSE 0 END) AS samt,
SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs,
-- line GST rate (IGST or CGST+SGST) — excludes TCS, unlike VatPrcnt
SUM(CASE WHEN staType IN ('-120','-110','-100') THEN TaxRate ELSE 0 END) AS rt
FROM INV4 FROM INV4
GROUP BY DocEntry, LineNum GROUP BY DocEntry, LineNum
), ),
@@ -459,7 +466,9 @@ RIN_TAX AS (
SUM(CASE WHEN staType = '-120' THEN TaxSum ELSE 0 END) AS iamt, SUM(CASE WHEN staType = '-120' THEN TaxSum ELSE 0 END) AS iamt,
SUM(CASE WHEN staType = '-110' THEN TaxSum ELSE 0 END) AS camt, SUM(CASE WHEN staType = '-110' THEN TaxSum ELSE 0 END) AS camt,
SUM(CASE WHEN staType = '-100' THEN TaxSum ELSE 0 END) AS samt, SUM(CASE WHEN staType = '-100' THEN TaxSum ELSE 0 END) AS samt,
SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs SUM(CASE WHEN staType = '10' THEN TaxSum ELSE 0 END) AS tcs,
-- line GST rate (IGST or CGST+SGST) — excludes TCS, unlike VatPrcnt
SUM(CASE WHEN staType IN ('-120','-110','-100') THEN TaxRate ELSE 0 END) AS rt
FROM RIN4 FROM RIN4
GROUP BY DocEntry, LineNum GROUP BY DocEntry, LineNum
), ),
@@ -486,7 +495,8 @@ HSN_Lines AS (
ISNULL(T.iamt, 0) AS iamt, ISNULL(T.iamt, 0) AS iamt,
ISNULL(T.camt, 0) AS camt, ISNULL(T.camt, 0) AS camt,
ISNULL(T.samt, 0) AS samt, ISNULL(T.samt, 0) AS samt,
ISNULL(T.tcs, 0) AS tcs ISNULL(T.tcs, 0) AS tcs,
ISNULL(T.rt, 0) AS rt
FROM OINV FROM OINV
INNER JOIN INV1 INNER JOIN INV1
ON OINV.DocEntry = INV1.DocEntry ON OINV.DocEntry = INV1.DocEntry
@@ -525,7 +535,8 @@ HSN_Lines AS (
-ISNULL(T.iamt, 0) AS iamt, -ISNULL(T.iamt, 0) AS iamt,
-ISNULL(T.camt, 0) AS camt, -ISNULL(T.camt, 0) AS camt,
-ISNULL(T.samt, 0) AS samt, -ISNULL(T.samt, 0) AS samt,
-ISNULL(T.tcs, 0) AS tcs -ISNULL(T.tcs, 0) AS tcs,
ISNULL(T.rt, 0) AS rt
FROM ORIN FROM ORIN
INNER JOIN RIN1 INNER JOIN RIN1
ON ORIN.DocEntry = RIN1.DocEntry ON ORIN.DocEntry = RIN1.DocEntry
@@ -557,18 +568,9 @@ HSN_Agg AS (
SUM(camt) AS camt_n, SUM(camt) AS camt_n,
SUM(samt) AS samt_n, SUM(samt) AS samt_n,
SUM(tcs) AS tcs_n, SUM(tcs) AS tcs_n,
CASE -- group by the line's own GST rate so 0% exports and 5%/18% supplies
WHEN SUM(txval) = 0 THEN 0 -- under the same HSN are separate rows (a blended rate is invalid)
WHEN ROUND( rt AS rate_of_tax_n
(SUM(iamt) + SUM(camt) + SUM(samt)) * 100.0 / SUM(txval),
0
) IN (0, 5, 12, 18, 28)
THEN ROUND(
(SUM(iamt) + SUM(camt) + SUM(samt)) * 100.0 / SUM(txval),
0
)
ELSE 0
END AS rate_of_tax_n
FROM HSN_Lines FROM HSN_Lines
GROUP BY GROUP BY
fp, fp,
@@ -576,7 +578,8 @@ HSN_Agg AS (
hsn_sc, hsn_sc,
gstr1Desc, gstr1Desc,
section, section,
uqc uqc,
rt
) )
SELECT SELECT
@@ -613,29 +616,29 @@ WITH DocStatus AS (
CASE WHEN T0.CANCELED='Y' OR CM.BaseEntry IS NOT NULL THEN 1 ELSE 0 END AS IsCanceled CASE WHEN T0.CANCELED='Y' OR CM.BaseEntry IS NOT NULL THEN 1 ELSE 0 END AS IsCanceled
FROM OINV T0 FROM OINV T0
LEFT JOIN (SELECT DISTINCT R1.BaseEntry FROM RIN1 R1 INNER JOIN ORIN R0 ON R0.DocEntry=R1.DocEntry WHERE R1.BaseType=13 AND R0.CANCELED='N') CM ON CM.BaseEntry=T0.DocEntry LEFT JOIN (SELECT DISTINCT R1.BaseEntry FROM RIN1 R1 INNER JOIN ORIN R0 ON R0.DocEntry=R1.DocEntry WHERE R1.BaseType=13 AND R0.CANCELED='N') CM ON CM.BaseEntry=T0.DocEntry
WHERE T0.DocDate>='${from}' AND T0.DocDate<'${to}' WHERE T0.DocDate>='${from}' AND T0.DocDate<='${to}'
UNION ALL UNION ALL
SELECT D0.DocEntry, D0.DocNum, D0.Series, D0.DocDate, 'Delivery', SELECT D0.DocEntry, D0.DocNum, D0.Series, D0.DocDate, 'Delivery',
CASE WHEN D0.CANCELED='Y' THEN 1 ELSE 0 END CASE WHEN D0.CANCELED='Y' THEN 1 ELSE 0 END
FROM ODLN D0 INNER JOIN NNM1 N1 ON N1.Series=D0.Series FROM ODLN D0 INNER JOIN NNM1 N1 ON N1.Series=D0.Series
WHERE N1.SeriesName LIKE '%FOC%' AND D0.DocDate>='${from}' AND D0.DocDate<'${to}' WHERE N1.SeriesName LIKE '%FOC%' AND D0.DocDate>='${from}' AND D0.DocDate<='${to}'
UNION ALL UNION ALL
SELECT W0.DocEntry, W0.DocNum, W0.Series, W0.DocDate, SELECT W0.DocEntry, W0.DocNum, W0.Series, W0.DocDate,
CASE WHEN N1.SeriesName LIKE '%JW%' THEN 'JW' WHEN N1.SeriesName LIKE '%IT%' AND N1.SeriesName NOT LIKE '%ITR%' THEN 'IT' END, CASE WHEN N1.SeriesName LIKE '%JW%' THEN 'JW' WHEN N1.SeriesName LIKE '%IT%' AND N1.SeriesName NOT LIKE '%ITR%' THEN 'IT' END,
CASE WHEN W0.CANCELED='Y' THEN 1 ELSE 0 END CASE WHEN W0.CANCELED='Y' THEN 1 ELSE 0 END
FROM OWTR W0 INNER JOIN NNM1 N1 ON N1.Series=W0.Series FROM OWTR W0 INNER JOIN NNM1 N1 ON N1.Series=W0.Series
WHERE (N1.SeriesName LIKE '%JW%' OR (N1.SeriesName LIKE '%IT%' AND N1.SeriesName NOT LIKE '%ITR%')) WHERE (N1.SeriesName LIKE '%JW%' OR (N1.SeriesName LIKE '%IT%' AND N1.SeriesName NOT LIKE '%ITR%'))
AND W0.DocDate>='${from}' AND W0.DocDate<'${to}' AND W0.DocDate>='${from}' AND W0.DocDate<='${to}'
UNION ALL UNION ALL
SELECT P0.DocEntry, P0.DocNum, P0.Series, P0.DocDate, 'AP Invoice (RCM)', SELECT P0.DocEntry, P0.DocNum, P0.Series, P0.DocDate, 'AP Invoice (RCM)',
CASE WHEN P0.CANCELED='Y' THEN 1 ELSE 0 END CASE WHEN P0.CANCELED='Y' THEN 1 ELSE 0 END
FROM OPCH P0 INNER JOIN NNM1 N1 ON N1.Series=P0.Series FROM OPCH P0 INNER JOIN NNM1 N1 ON N1.Series=P0.Series
WHERE N1.SeriesName LIKE '%rev%' AND P0.DocDate>='${from}' AND P0.DocDate<'${to}' WHERE N1.SeriesName LIKE '%rev%' AND P0.DocDate>='${from}' AND P0.DocDate<='${to}'
UNION ALL UNION ALL
SELECT R0.DocEntry, R0.DocNum, R0.Series, R0.DocDate, 'AR Credit Memo', SELECT R0.DocEntry, R0.DocNum, R0.Series, R0.DocDate, 'AR Credit Memo',
CASE WHEN R0.CANCELED='Y' THEN 1 ELSE 0 END CASE WHEN R0.CANCELED='Y' THEN 1 ELSE 0 END
FROM ORIN R0 INNER JOIN NNM1 N1 ON N1.Series=R0.Series FROM ORIN R0 INNER JOIN NNM1 N1 ON N1.Series=R0.Series
WHERE N1.SeriesName LIKE '%ARCN%' AND R0.DocDate>='${from}' AND R0.DocDate<'${to}' WHERE N1.SeriesName LIKE '%ARCN%' AND R0.DocDate>='${from}' AND R0.DocDate<='${to}'
) )
SELECT CONVERT(char(6),DocDate,112) AS fp, Series, DocumentType, SELECT CONVERT(char(6),DocDate,112) AS fp, Series, DocumentType,
CASE WHEN DocumentType='AR Credit Memo' THEN CONCAT('CN-',MIN(DocNum)) CASE WHEN DocumentType='AR Credit Memo' THEN CONCAT('CN-',MIN(DocNum))
+123
View File
@@ -0,0 +1,123 @@
// routes/inventoryPostingList.js
// Mirrors SAP B1's own "Inventory Posting List" report (Inventory →
// Inventory Reports → Inventory Posting List): every stock-moving
// transaction (OINM) for a range of items over a date range, with a running
// quantity Balance per item, filterable by Item Code range, Item Group,
// and Warehouse(s) — same selection-criteria shape as the Inventory Status
// Report.
//
// NOTE — "Split Display by Batch/Serial Numbers" (visible on SAP's own
// selection screen) is deliberately NOT implemented here: OINM has no
// reliable, verified link back to the batch/serial actually posted on each
// transaction in this database (the obvious ITL1→OBTN join came back empty
// against real data), and showing a guessed/wrong batch number would be
// worse than not showing one at all. Every other column is real, verified
// OINM data.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const cq = (req) => req.query?.company || req.body?.company || null;
// Real SAP TransType codes seen against this database's own transaction
// history (verified live: 60/59/67/15/18/20/14/16/19/13/21 account for
// nearly every row) — full descriptive labels rather than guessed 2-letter
// SAP abbreviations, since those couldn't be verified. Anything outside this
// list (e.g. a UDO-driven custom type) falls back to "Type <code>" rather
// than asserting a label that might be wrong.
const TRANS_TYPE_LABELS = {
13: 'AR Invoice', 14: 'AR Credit Memo', 15: 'Delivery', 16: 'AR Return',
17: 'Reserve Invoice', 18: 'Goods Receipt PO', 19: 'Goods Return',
20: 'AP Invoice', 21: 'AP Credit Memo',
59: 'Inventory Receipt', 60: 'Inventory Issue', 67: 'Stock Transfer',
};
function transLabel(t) { return TRANS_TYPE_LABELS[t] || `Type ${t}`; }
router.get('/', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const sqlEsc = (v) => String(v).replace(/'/g, "''");
const itemFrom = (req.query.itemFrom || '').trim();
const itemTo = (req.query.itemTo || '').trim();
const itemGroup = (req.query.itemGroup || '').trim();
const warehouses = (req.query.warehouses || '').split(',').map((w) => w.trim()).filter(Boolean);
const dateFrom = (req.query.dateFrom || '').trim();
const dateTo = (req.query.dateTo || '').trim();
const hideNoQtyChange = req.query.hideNoQtyChange === '1';
if (!dateFrom || !dateTo) return res.status(400).json({ success: false, message: 'dateFrom and dateTo are required' });
// Resolve the matching item set first — same range+group filter as the
// Inventory Status Report, so the two stay consistent.
const itemWhere = [];
if (itemFrom) itemWhere.push(`"ItemCode" >= '${sqlEsc(itemFrom)}'`);
if (itemTo) itemWhere.push(`"ItemCode" <= '${sqlEsc(itemTo)}'`);
if (itemGroup) itemWhere.push(`"ItmsGrpCod" = ${parseInt(itemGroup)}`);
const itemRows = await pool.request().query(`SELECT "ItemCode" FROM [dbo].[OITM] ${itemWhere.length ? 'WHERE ' + itemWhere.join(' AND ') : ''}`);
const itemCodes = itemRows.recordset.map((r) => r.ItemCode);
if (!itemCodes.length) return res.json({ success: true, data: [] });
// Guard against an unbounded range (e.g. both From/To left blank) —
// matching against every item in SAP would make the OINM scan below
// enormous. 2000 items is already a generous ceiling for one report run.
if (itemCodes.length > 2000) return res.status(400).json({ success: false, message: `${itemCodes.length} items match this filter — narrow the Item Code range or Item Group first (max 2000 at a time).` });
const itemList = itemCodes.map((c) => `'${sqlEsc(c)}'`).join(',');
const whWhere = warehouses.length ? `AND "Warehouse" IN (${warehouses.map((w) => `'${sqlEsc(w)}'`).join(',')})` : '';
// Opening balance per item — every transaction strictly BEFORE the
// selected date range, so "Balance" in the results is a real absolute
// stock level, not just a delta within the window (matches what SAP's
// own report shows).
const openingRows = await pool.request().query(`
SELECT "ItemCode", SUM(ISNULL("InQty",0)) - SUM(ISNULL("OutQty",0)) AS "Opening"
FROM [dbo].[OINM]
WHERE "ItemCode" IN (${itemList}) ${whWhere} AND "DocDate" < '${sqlEsc(dateFrom)}'
GROUP BY "ItemCode"`);
const openingByItem = {};
openingRows.recordset.forEach((r) => { openingByItem[r.ItemCode] = Number(r.Opening) || 0; });
const qtyFilter = hideNoQtyChange ? `AND (ISNULL("InQty",0)<>0 OR ISNULL("OutQty",0)<>0)` : '';
const rows = await pool.request().query(`
SELECT T0."ItemCode", T0."Dscription", T0."DocDate", T0."CreateDate", T0."Warehouse",
T0."InQty", T0."OutQty", T0."CardCode", T0."CardName", T0."Ref1", T0."Ref2",
T0."TransType", T0."TransNum", T0."DocLineNum", T0."CalcPrice", T0."Price", T0."Comments"
FROM [dbo].[OINM] T0
WHERE T0."ItemCode" IN (${itemList}) ${whWhere}
AND T0."DocDate" >= '${sqlEsc(dateFrom)}' AND T0."DocDate" <= '${sqlEsc(dateTo)}'
${qtyFilter}
ORDER BY T0."ItemCode", T0."DocDate", T0."TransNum"`);
// Running balance computed here (not trusted from OINM.Balance, which is
// only populated on certain valuation checkpoints, not every row —
// verified live: most receipt/issue rows carry a NULL Balance) — a
// simple per-item cumulative sum seeded from the opening balance above.
const runningByItem = {};
const data = rows.recordset.map((r) => {
const itemCode = r.ItemCode;
if (!(itemCode in runningByItem)) runningByItem[itemCode] = openingByItem[itemCode] || 0;
const inQty = Number(r.InQty) || 0;
const outQty = Number(r.OutQty) || 0;
runningByItem[itemCode] += inQty - outQty;
return {
itemCode, itemName: r.Dscription || '',
docDate: r.DocDate, warehouse: r.Warehouse || '',
transType: r.TransType, docLabel: transLabel(r.TransType),
docRef: r.Ref1 || '', ref2: r.Ref2 || '',
docRow: (r.DocLineNum || 0) + 1,
bpName: r.CardName || '', cardCode: r.CardCode || '',
inQty, outQty,
price: (r.CalcPrice != null && r.CalcPrice !== 0) ? Number(r.CalcPrice) : Number(r.Price) || 0,
balance: runningByItem[itemCode],
remarks: r.Comments || '',
};
});
res.json({ success: true, data, openingBalances: openingByItem });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+81
View File
@@ -0,0 +1,81 @@
// routes/inventoryStatusReport.js
// Mirrors SAP B1's own "Inventory Status" report (Inventory → Inventory
// Reports → Inventory Status): per-item In Stock / Committed / Ordered /
// Available, aggregated across whichever warehouses the user selects,
// filterable by Item Code range, Preferred Vendor range, and Item Group.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const { getPool } = require('../services/sqlPool');
const cq = (req) => req.query?.company || req.body?.company || null;
// Available = In Stock − Committed + Ordered — same formula SAP's own
// Inventory Status report uses (verified live against a real item: In Stock
// 2,817.648 − Committed 103,891.201 + Ordered 3,900 = Available −97,173.553,
// matching SAP's screen exactly).
router.get('/', verifyToken, async (req, res) => {
try {
const co = cq(req);
const pool = await getPool(co);
const sqlEsc = (v) => String(v).replace(/'/g, "''");
const itemFrom = (req.query.itemFrom || '').trim();
const itemTo = (req.query.itemTo || '').trim();
const vendorFrom = (req.query.vendorFrom || '').trim();
const vendorTo = (req.query.vendorTo || '').trim();
const itemGroup = (req.query.itemGroup || '').trim();
const hideZeroStock = req.query.hideZeroStock === '1';
const warehouses = (req.query.warehouses || '').split(',').map((w) => w.trim()).filter(Boolean);
const where = [];
if (itemFrom) where.push(`T0."ItemCode" >= '${sqlEsc(itemFrom)}'`);
if (itemTo) where.push(`T0."ItemCode" <= '${sqlEsc(itemTo)}'`);
if (vendorFrom) where.push(`T0."CardCode" >= '${sqlEsc(vendorFrom)}'`);
if (vendorTo) where.push(`T0."CardCode" <= '${sqlEsc(vendorTo)}'`);
if (itemGroup) where.push(`T0."ItmsGrpCod" = ${parseInt(itemGroup)}`);
// No warehouses selected = every warehouse (matches leaving every
// checkbox unticked on SAP's own selection screen). Otherwise scope the
// OITW join to just the picked ones, in the JOIN condition — not a
// WHERE clause — so an item with stock in ONLY unselected warehouses
// still appears (with everything zeroed out), same as SAP's own report.
const whJoin = warehouses.length
? `AND T1."WhsCode" IN (${warehouses.map((w) => `'${sqlEsc(w)}'`).join(',')})`
: '';
const havingZero = hideZeroStock ? `HAVING SUM(ISNULL(T1."OnHand",0)) <> 0` : '';
const query = `
SELECT T0."ItemCode", T0."ItemName", T0."InvntryUom" AS "Uom",
SUM(ISNULL(T1."OnHand",0)) AS "InStock",
SUM(ISNULL(T1."IsCommited",0)) AS "Committed",
SUM(ISNULL(T1."OnOrder",0)) AS "Ordered"
FROM [dbo].[OITM] T0
LEFT JOIN [dbo].[OITW] T1 ON T1."ItemCode" = T0."ItemCode" ${whJoin}
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY T0."ItemCode", T0."ItemName", T0."InvntryUom"
${havingZero}
ORDER BY T0."ItemCode"`;
const result = await pool.request().query(query);
const data = result.recordset.map((r) => {
const inStock = Number(r.InStock) || 0;
const committed = Number(r.Committed) || 0;
const ordered = Number(r.Ordered) || 0;
return {
itemCode: r.ItemCode,
itemName: r.ItemName || '',
inStock, committed, ordered,
available: inStock - committed + ordered,
uom: r.Uom || '',
};
});
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;
+102
View File
@@ -0,0 +1,102 @@
// routes/inventoryTransfer.js
// General-purpose "Inventory Transfer" — mirrors SAP B1's own Inventory
// Transfer window (Inventory → Inventory Transactions → Inventory Transfer):
// move stock from one warehouse to another, any item(s), not tied to a
// Production/Work Order. Posts a standard SAP StockTransfers document.
'use strict';
const express = require('express');
const router = express.Router();
const { verifyToken } = require('../middleware/auth');
const appSettings = require('../services/appSettingsStore');
const { getPool } = require('../services/sqlPool');
let _sapSvc = null;
function getSap(){ if(!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); return _sapSvc; }
const cq = (req) => req.query?.company || req.body?.company || null;
// GET /api/inventory-transfer/series-info — resolves the admin-configured
// Series ID to its human-readable SAP name (e.g. 28615 → "IC2627"), for the
// page itself to display so a non-admin user understands which series their
// transfer will post under. Deliberately NOT behind the admin-only /settings
// endpoint — any logged-in user doing a transfer needs to see this.
router.get('/series-info', verifyToken, async (req, res) => {
const co = cq(req);
const series = appSettings.inventoryTransferSeries();
if (series == null) return res.json({ success: true, series: null, seriesName: null });
try {
const pool = await getPool(co);
const r = await pool.request().query(`SELECT "SeriesName" FROM [dbo].[NNM1] WHERE "ObjectCode"='67' AND "Series"=${parseInt(series)}`);
res.json({ success: true, series, seriesName: r.recordset[0]?.SeriesName || null });
} catch (err) {
res.json({ success: true, series, seriesName: null, warning: err.message });
}
});
router.post('/', verifyToken, async (req, res) => {
try{
const sap = getSap();
if(!sap) return res.status(503).json({success:false,message:'SAP service not ready'});
const co = cq(req);
const body = req.body || {};
const fromWarehouse = (body.fromWarehouse||'').trim();
const toWarehouse = (body.toWarehouse||'').trim();
if(!fromWarehouse||!toWarehouse) return res.status(400).json({success:false,message:'From Warehouse and To Warehouse are required'});
if(fromWarehouse===toWarehouse) return res.status(400).json({success:false,message:'From Warehouse and To Warehouse must be different'});
const rawLines = Array.isArray(body.lines) ? body.lines : [];
const lines = rawLines
.filter(l=>l.itemCode && (parseFloat(l.quantity)||0)>0)
.map((l,idx)=>{
const line={
ItemCode: String(l.itemCode).trim(),
Quantity: parseFloat(l.quantity)||0,
WarehouseCode: toWarehouse,
FromWarehouseCode: fromWarehouse,
};
if(Array.isArray(l.batchNumbers)&&l.batchNumbers.length){
const bn=l.batchNumbers.filter(b=>b.BatchNumber&&(parseFloat(b.Quantity)||0)>0)
.map(b=>({BatchNumber:b.BatchNumber,Quantity:parseFloat(b.Quantity)||0,BaseLineNumber:idx}));
if(bn.length) line.BatchNumbers=bn;
}
return line;
});
if(!lines.length) return res.status(400).json({success:false,message:'At least one item line with a quantity > 0 is required'});
const payload={
FromWarehouse: fromWarehouse,
ToWarehouse: toWarehouse,
StockTransferLines: lines,
};
if(body.comments) payload.Comments=String(body.comments).trim();
// StockTransfers has no DocDueDate field (unlike InventoryGenEntries) —
// sending it fails with "-1000 Property 'DocDueDate' of 'StockTransfer'
// is invalid", confirmed live.
if(body.postingDate){ payload.DocDate=body.postingDate; payload.TaxDate=body.postingDate; }
// Admin-configurable (System Settings → Inventory Transfer → Document
// Series) — e.g. SAP's "IC2627" series, internal ID 28615, maintained in
// Admin so it never needs a code change if the series changes.
const series=appSettings.inventoryTransferSeries();
if(series!=null) payload.Series=series;
console.log('[INV-TRANSFER] Final payload:\n',JSON.stringify(payload,null,2));
const result = await sap.sapRequest('POST','StockTransfers',payload,co);
console.log('[INV-TRANSFER] ✅ DocEntry:',result?.DocEntry,'DocNum:',result?.DocNum);
require('../services/auditStore').record({
userId:req.user?.id, username:req.user?.username, role:req.user?.role,
method:req.method, action:'CREATE', entity:'InventoryTransfer', entityId:String(result?.DocEntry||''),
path:req.originalUrl, status:200, ok:true,
summary:`Inventory Transfer #${result?.DocNum||result?.DocEntry} posted: ${fromWarehouse} → ${toWarehouse}, ${lines.length} item(s).`,
details:{fromWarehouse,toWarehouse,lines}, ip:req.ip, company:co,
}).catch(()=>{});
res.json({success:true,data:result});
}catch(err){
const sapMsg=err.message||'Unknown SAP error';
console.error('[INV-TRANSFER] ❌',sapMsg);
res.status(400).json({success:false,message:sapMsg});
}
});
module.exports = router;
+15 -4
View File
@@ -15,6 +15,14 @@ function getSap() {
if (!_sapSvc) _sapSvc = require('../services/sapServiceLayer'); if (!_sapSvc) _sapSvc = require('../services/sapServiceLayer');
return _sapSvc; return _sapSvc;
} }
const appSettings = () => require('../services/appSettingsStore');
// admin/sap_adder/system_admin may push directly and approve/reject others'
// submissions. Kept as one helper so the client (public/approvals.html's
// canManageItems()) and server always agree on who this is.
function isItemApprover(user) {
return user?.role === 'admin' || user?.role === 'sap_adder' || user?.role === 'system_admin';
}
const cq = (req) => req.query?.company || req.body?.company || null; const cq = (req) => req.query?.company || req.body?.company || null;
@@ -110,8 +118,11 @@ function buildSapPayload(data) {
} }
// ── POST /api/item-approvals/submit ───────────────────────────────────────── // ── POST /api/item-approvals/submit ─────────────────────────────────────────
// admin/sap_adder → push directly to SAP (no approval queue) // admin/sap_adder/system_admin → push directly to SAP (no approval queue)
// all other roles → save as PENDING for admin review // all other roles → save as PENDING for admin review
// Admin → System Settings → "Item Approval Workflow": when OFF, the whole
// queue is bypassed and EVERYONE pushes directly (there's no one left who'd
// review a pending item) — same as an approver submitting, regardless of role.
router.post('/submit', verifyToken, async (req, res) => { router.post('/submit', verifyToken, async (req, res) => {
try { try {
const store = getStore(); const store = getStore();
@@ -130,7 +141,7 @@ router.post('/submit', verifyToken, async (req, res) => {
company: company || '', company: company || '',
}; };
if (req.user.role === 'admin' || req.user.role === 'sap_adder') { if (isItemApprover(req.user) || !appSettings().itemApprovalEnabled()) {
const payload = await pushItemToSap(itemData, co); const payload = await pushItemToSap(itemData, co);
const logEntry = { const logEntry = {
username: req.user.username, name: req.user.name || req.user.username, username: req.user.username, name: req.user.name || req.user.username,
@@ -187,8 +198,8 @@ router.get('/:id', verifyToken, async (req, res) => {
// action: 'approve' | 'reject' // action: 'approve' | 'reject'
// Admin can also pass editedData to override item fields before pushing // Admin can also pass editedData to override item fields before pushing
router.patch('/:id/action', verifyToken, async (req, res) => { router.patch('/:id/action', verifyToken, async (req, res) => {
if (req.user.role !== 'admin' && req.user.role !== 'sap_adder') { if (!isItemApprover(req.user)) {
return res.status(403).json({ success: false, message: 'Only admin can approve items' }); return res.status(403).json({ success: false, message: 'Only admin/SAP Adder/system admin can approve items' });
} }
try { try {
const store = getStore(); const store = getStore();
+9 -1
View File
@@ -151,7 +151,15 @@ router.get('/', verifyToken, requireApprovalStep('man_power:entry', 'view'), asy
docs.forEach(d => { d.tabCounts = byDoc[d.docEntry] || {}; d.totalRows = Object.values(d.tabCounts).reduce((a, b) => a + b, 0); }); docs.forEach(d => { d.tabCounts = byDoc[d.docEntry] || {}; d.totalRows = Object.values(d.tabCounts).reduce((a, b) => a + b, 0); });
} catch (e) { console.warn('[MANPOWER] tab counts failed (non-fatal):', e.message); } } catch (e) { console.warn('[MANPOWER] tab counts failed (non-fatal):', e.message); }
} }
res.json({ success: true, data: docs }); // Genuine total (same filter, no paging) — drives "Page X of Y" on the
// client instead of an open-ended "Page X".
let total = null;
try {
const c = await sap().sapRequest('GET', `Production_Data/$count${filter ? '?' + filter.slice(1) : ''}`, null, co);
const n = Number(c);
if (!isNaN(n)) total = n;
} catch (e) { console.warn('[MANPOWER] $count failed — pager will show no total:', e.message); }
res.json({ success: true, data: docs, total });
} catch (err) { res.status(500).json({ success: false, message: err.message }); } } catch (err) { res.status(500).json({ success: false, message: err.message }); }
}); });
+36 -6
View File
@@ -1,11 +1,13 @@
'use strict'; 'use strict';
// routes/notifications.js — aggregates "pending on me" items across the // routes/notifications.js — aggregates "pending on me" items across the
// workflows that already have clear, per-user pending logic (Work Order, // workflows that already have clear, per-user pending logic (Work Order,
// Production Order, BOM Requests, and — admin only — Password Reset // Production Order, Batch Issuance, BOM Requests, Production Deviations,
// requests), for the sidebar bell + dashboard "Pending Actions" widget. // and — admin only — Password Reset requests), for the sidebar bell +
// Deliberately scoped to these four for now; other approval workflows // dashboard "Pending Actions" widget. Every item is gated by the same
// (Purchase Requests, Customer/Vendor registration, Project approvals, // approval-step/role check its own workflow route enforces, so a user only
// etc.) aren't included yet. // ever sees what they're actually permitted to act on. Other approval
// workflows (Purchase Requests, Customer/Vendor registration, Project
// approvals, etc.) aren't included yet.
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { verifyToken, hasStepPerm, hasStepAssigned } = require('../middleware/auth'); const { verifyToken, hasStepPerm, hasStepAssigned } = require('../middleware/auth');
@@ -56,13 +58,24 @@ router.get('/pending', verifyToken, async (req, res) => {
// Release and Transfer to Finished Goods have no standalone page of // Release and Transfer to Finished Goods have no standalone page of
// their own — those stay on production.html's detail popup. // their own — those stay on production.html's detail popup.
const STAGE_CARD_HREF = { release: '/production', issuance: '/issue-production', receipt: '/receipt-production', transfer_fg: '/production', close: '/close-production' }; const STAGE_CARD_HREF = { release: '/production', issuance: '/issue-production', receipt: '/receipt-production', transfer_fg: '/production', close: '/close-production' };
// Consumable Orders (Release → Issue → Close only, no Receipt/Transfer to
// FG) are gated by their OWN dedicated steps, never the general
// production_order:* ones — mirrors services/productionOrderStore.js's
// own notifyStepFor()/CONSUMABLE_STEP_FOR (not exported, so duplicated
// here, same convention as WORK_ORDER_STEP_KEYS above). Without this, a
// user holding the general 'production_order:release' step (but NOT
// 'production_order:consumable_release') incorrectly saw every pending
// Consumable Order in the bell too, even though the Production Order
// page itself already correctly hides Consumable Orders from them.
const CONSUMABLE_STEP_FOR = { release: 'consumable_release', issuance: 'consumable_issue', close: 'consumable_close' };
const pos = await poStore.listProductionOrders({ status: 'IN_PROGRESS' }); const pos = await poStore.listProductionOrders({ status: 'IN_PROGRESS' });
// REJECTED orders (receipt posted with rejection lines) still need to be // REJECTED orders (receipt posted with rejection lines) still need to be
// CLOSED — surface them to the close-step users too. // CLOSED — surface them to the close-step users too.
const rejected = await poStore.listProductionOrders({ status: 'REJECTED' }); const rejected = await poStore.listProductionOrders({ status: 'REJECTED' });
rejected.forEach(p => { pos.push(Object.assign({}, p, { stage: 4 })); }); // stage 4 = Close rejected.forEach(p => { pos.push(Object.assign({}, p, { stage: 4 })); }); // stage 4 = Close
pos.forEach(p => { pos.forEach(p => {
const key = poStore.STEP_KEYS[p.stage]; const generalKey = poStore.STEP_KEYS[p.stage];
const key = p.isConsumable ? (CONSUMABLE_STEP_FOR[generalKey] || null) : generalKey;
// Same rule as the Issue/Receipt/Close pages themselves: being ASSIGNED // Same rule as the Issue/Receipt/Close pages themselves: being ASSIGNED
// the step (any perm — view/add/edit/approve) means the action is yours, // the step (any perm — view/add/edit/approve) means the action is yours,
// so it must show in the bell too. (Was 'approve'-only, which hid e.g. // so it must show in the bell too. (Was 'approve'-only, which hid e.g.
@@ -120,6 +133,23 @@ router.get('/pending', verifyToken, async (req, res) => {
}); });
} catch (e) { console.warn('[notifications] bom scan failed:', e.message); } } catch (e) { console.warn('[notifications] bom scan failed:', e.message); }
// Production Deviations awaiting QA sign-off — pending for whoever holds
// 'approve' on production_order:deviation (see [[production-deviation-workflow]]).
// Only QA_STATUS='PENDING' counts as pending; N_A (QA not required for this
// one) / APPROVED / REJECTED are already resolved, nothing left to do.
try {
if (hasStepPerm(user, 'production_order:deviation', 'approve')) {
const devs = await require('../services/deviationStore').listDeviations({});
devs.filter(d => d.qaStatus === 'PENDING').forEach(d => {
items.push({
module: 'deviation', label: 'Deviation', title: d.sapDocNum ? `PWO #${d.sapDocNum}` : `Item ${d.itemCode}`,
detail: `${d.type.charAt(0) + d.type.slice(1).toLowerCase()} — ${d.itemCode}${d.newItemCode ? ' → ' + d.newItemCode : ''} — awaiting QA`,
link: d.sapAbsEntry ? `/production?open=${d.sapAbsEntry}` : '/deviations', id: d.id, cardHref: '/deviations',
});
});
}
} catch (e) { console.warn('[notifications] deviation scan failed:', e.message); }
if (user.role === 'admin' || user.role === 'sap_adder') { if (user.role === 'admin' || user.role === 'sap_adder') {
try { try {
const pending = await require('../services/passwordResetStore').listRequests('PENDING'); const pending = await require('../services/passwordResetStore').listRequests('PENDING');
+4 -2
View File
@@ -48,10 +48,12 @@ router.get('/', verifyToken, requireApprovalStep('oee:entry', 'view'), async (re
const skip = Number(req.query.skip) || 0; const skip = Number(req.query.skip) || 0;
const tab = (req.query.tab || '').trim() || null; const tab = (req.query.tab || '').trim() || null;
const month = (req.query.month || '').trim() || null; const month = (req.query.month || '').trim() || null;
const monthFrom = (req.query.monthFrom || '').trim() || null;
const monthTo = (req.query.monthTo || '').trim() || null;
// If a specific tab was requested but the user isn't allowed it, return none. // If a specific tab was requested but the user isn't allowed it, return none.
if (tab && allow && !allow.includes(tab)) return res.json({ success: true, data: [] }); if (tab && allow && !allow.includes(tab)) return res.json({ success: true, data: [] });
const data = await store.list({ company: cq(req), tab, month, allowTabs: allow, top, skip }); const { data, total } = await store.list({ company: cq(req), tab, month, monthFrom, monthTo, allowTabs: allow, top, skip });
res.json({ success: true, data }); res.json({ success: true, data, total });
} catch (err) { res.status(500).json({ success: false, message: err.message }); } } catch (err) { res.status(500).json({ success: false, message: err.message }); }
}); });
+1
View File
@@ -89,6 +89,7 @@ router.get('/for-verification', verifyToken, requireAnyStep(['work_order:verify'
itemCode: w.productCode, itemName: w.productName, itemCode: w.productCode, itemName: w.productName,
plannedQty: w.totalUnits, batchNumber: w.batchNumber, plannedQty: w.totalUnits, batchNumber: w.batchNumber,
intimationDocNo: w.intimationDocNo || '', createdBy: w.createdBy, createdByName: w.createdByName, intimationDocNo: w.intimationDocNo || '', createdBy: w.createdBy, createdByName: w.createdByName,
createdAt: w.createdAt,
status: w.status, status: w.status,
issuedComplete: stampComplete(w, 'issued'), issuedComplete: stampComplete(w, 'issued'),
receivedComplete: stampComplete(w, 'received'), receivedComplete: stampComplete(w, 'received'),
+284
View File
@@ -0,0 +1,284 @@
// routes/sales.js — Sales Order module, EMPLOYEE API (/api/sales)
// Replaces the msale portal's employee side. Every route needs a normal ERP
// login; what a user may see/do is decided by their Approval Steps
// (sales_order:*, sales_sample:*, sales_export_sample:*, sales_master:*) plus
// their Sales profile (user type → scope, divisions) — see services/sales/*.
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { verifyToken, hasStepPerm, hasWorkflowPerm } = require('../middleware/auth');
const masters = require('../services/sales/masters');
const orders = require('../services/sales/orders');
const samples = require('../services/sales/samples');
const reports = require('../services/sales/reports');
const sap = require('../services/sales/sap');
const { STEPS, statusLabel, sampleStatusLabel, exportSampleStatusLabel } = require('../services/sales/constants');
const { query } = require('../services/sales/db');
const router = express.Router();
router.use(verifyToken);
// Private document store — deliberately NOT under /uploads (served statically).
const DOC_DIR = path.join(__dirname, '..', 'storage', 'sales');
fs.mkdirSync(DOC_DIR, { recursive: true });
const ALLOWED_EXT = ['.pdf', '.jpg', '.jpeg', '.png', '.bmp'];
const upload = multer({
storage: multer.diskStorage({
destination: DOC_DIR,
filename: (req, file, cb) => cb(null, `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${path.extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => cb(ALLOWED_EXT.includes(path.extname(file.originalname).toLowerCase()) ? null : new Error('Only PDF / JPG / PNG / BMP files are allowed'), true),
});
// Build the actor once per request (+ the user's email, needed for the
// 'mapped' scope which msale keyed on the employee's email).
const _emailCache = new Map();
router.use(async (req, res, next) => {
try {
let email = _emailCache.get(req.user.id);
if (email === undefined) {
const u = await require('../services/hanaUsers').findById(req.user.id);
email = (u && u.email) || '';
_emailCache.set(req.user.id, email);
setTimeout(() => _emailCache.delete(req.user.id), 60000);
}
req.actor = { type: 'user', user: req.user, name: req.user.name || req.user.username, email };
next();
} catch (e) { next(e); }
});
const wrap = fn => async (req, res) => {
try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.json({ success: true, data: out }); }
catch (e) { if (!res.headersSent) res.status(e.status || (/^\[SAP/.test(e.message) ? 502 : 500)).json({ success: false, message: e.message }); }
};
const isAdmin = req => req.user.role === 'admin';
const hasModule = (req, m) => isAdmin(req) || (Array.isArray(req.user.modules) && req.user.modules.includes(m));
function need(cond, msg = 'Not allowed') { if (!cond) { const e = new Error(msg); e.status = 403; throw e; } }
// ── Session info / lookups ──────────────────────────────────────────────────
router.get('/me', wrap(async (req) => {
const prof = await masters.getProfile(req.user);
const caps = {};
STEPS.forEach(s => { caps[`${s.workflow}:${s.key}`] = ['view', 'add', 'edit', 'approve', 'delete'].filter(p => hasStepPerm(req.user, `${s.workflow}:${s.key}`, p)); });
const settings = masters.publicSettings(await masters.getSettings());
// divisions = enabled only (for creating orders/samples); allDivisions also
// includes disabled ones so existing orders still show their category name.
return { profile: prof, caps, isAdmin: isAdmin(req), divisions: await masters.listDivisions(), allDivisions: await masters.listDivisions(true), settings,
statusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(s => [s, statusLabel(s)])),
directStatusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(s => [s, statusLabel(s, 'DIRECT')])),
sampleStatusLabels: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9].map(s => [s, sampleStatusLabel(s)])),
exportStatusLabels: Object.fromEntries([1, 2, 3, 4].map(s => [s, exportSampleStatusLabel(s)])) };
}));
router.get('/divisions', wrap(() => masters.listDivisions()));
// Customers from SAP, limited to the caller's mapped customers when their scope is 'mapped'.
router.get('/customers', wrap(async (req) => {
need(hasWorkflowPerm(req.user, 'sales_order', 'view') || hasWorkflowPerm(req.user, 'sales_sample', 'view') || hasWorkflowPerm(req.user, 'sales_master', 'view'));
const s = await masters.getSettings();
const { prof, cards } = await orders.scopeFor(req.actor);
return sap.searchCustomers(s.company, req.query.q, { limit: 50, cardCodes: prof.scope === 'mapped' ? cards : null });
}));
router.get('/customers/:cardCode', wrap(async (req) => {
need(hasWorkflowPerm(req.user, 'sales_order', 'view') || hasWorkflowPerm(req.user, 'sales_sample', 'view') || hasWorkflowPerm(req.user, 'sales_master', 'view'));
const s = await masters.getSettings();
const { prof, cards } = await orders.scopeFor(req.actor);
if (prof.scope === 'mapped') need(cards.includes(req.params.cardCode), 'This customer is not mapped to you');
const c = await sap.getCustomer(s.company, req.params.cardCode);
if (!c) { const e = new Error('Customer not found'); e.status = 404; throw e; }
c.wallet = hasStepPerm(req.user, 'sales_order:view', 'view') ? await orders.walletSummary(c.cardCode) : null;
return c;
}));
router.get('/products', wrap(async (req) => {
const catalog = req.query.catalog === 'export' ? 'export' : 'domestic';
if (catalog === 'export') return masters.listCatalog({ catalog: 'export' });
if (!req.query.divisionId) return [];
return masters.listOrderableProducts(parseInt(req.query.divisionId));
}));
// ── Orders ──────────────────────────────────────────────────────────────────
router.get('/orders', wrap(req => orders.listOrders(req.actor, req.query)));
router.get('/orders/:id', wrap(req => orders.getOrder(req.params.id, req.actor)));
router.post('/orders', wrap(req => orders.createOrder(req.actor, req.body))); // Direct order
router.put('/orders/:id/resubmit', wrap(req => orders.resubmit(req.actor, req.params.id, req.body)));
router.post('/orders/:id/action', wrap(req => orders.act(req.actor, req.params.id, req.body.action, req.body)));
router.post('/sync-sap', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:sap_post', 'approve'), 'You are not assigned "approve" on sales_order:sap_post');
return orders.syncWithSap();
}));
// Invoices / dispatch / COA for an order, live from SAP.
async function invoiceBundle(actor, id) {
const o = await orders.getOrderRaw(id);
if (!o || !(await orders.canSee(actor, o))) { const e = new Error('Order not found'); e.status = 404; throw e; }
if (!o.sapDocEntry && ![8, 9].includes(o.status)) return { invoices: [], batches: [] };
const invoices = await sap.invoicesForOrder(o.company, o.id);
const batches = await sap.batchesWithCoa(o.company, invoices.map(i => i.docEntry));
const pdfs = invoices.length ? await query(`SELECT INVOICE_NO, MAX(ID) ID FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO IN (${invoices.map(() => '?').join(',')}) GROUP BY INVOICE_NO`,
invoices.map(i => String(i.invoiceNo))) : [];
const pdfSet = new Set(pdfs.map(p => String(p.INVOICE_NO)));
for (const inv of invoices) {
inv.hasPdf = pdfSet.has(String(inv.invoiceNo));
inv.attachments = inv.atcEntry ? await sap.attachmentLines(o.company, inv.atcEntry) : [];
inv.batches = batches.filter(b => b.invoiceDocEntry === inv.docEntry);
delete inv.atcEntry;
}
return { order: o, invoices };
}
router.get('/orders/:id/invoices', wrap(async req => { const b = await invoiceBundle(req.actor, req.params.id); delete b.order; return b; }));
async function streamAttachment(req, res, actor) {
const b = await invoiceBundle(actor, req.query.orderId);
const abs = parseInt(req.params.abs), line = parseInt(req.params.line);
const allowed = b.invoices.some(i => i.attachments.some(a => a.absEntry === abs && a.line === line) || i.batches.some(x => x.coa && x.coa.absEntry === abs && x.coa.line === line));
need(allowed, 'This file does not belong to the order');
const att = await sap.attachmentLine(b.order.company, abs, line);
const f = sap.readAttachment(att);
res.setHeader('Content-Disposition', `inline; filename="${f.name.replace(/"/g, '')}"`);
res.type(path.extname(f.name) || 'application/octet-stream').send(f.buffer);
}
async function streamInvoicePdf(req, res, actor) {
const b = await invoiceBundle(actor, req.query.orderId);
need(b.invoices.some(i => String(i.invoiceNo) === String(req.params.invoiceNo)), 'Invoice does not belong to the order');
const r = (await query(`SELECT TOP 1 FILE_NAME FROM dbo.ZSO_INVOICE_FILES WHERE INVOICE_NO=? ORDER BY ID DESC`, [String(req.params.invoiceNo)]))[0];
if (!r) { const e = new Error('Invoice PDF not available yet'); e.status = 404; throw e; }
const full = path.join(DOC_DIR, 'invoices', path.basename(r.FILE_NAME));
if (!fs.existsSync(full)) { const e = new Error('Invoice PDF file missing'); e.status = 404; throw e; }
res.setHeader('Content-Disposition', `inline; filename="${path.basename(r.FILE_NAME)}"`);
res.type('pdf').send(fs.readFileSync(full));
}
router.get('/attachment/:abs/:line', wrap((req, res) => streamAttachment(req, res, req.actor)));
router.get('/invoice-pdf/:invoiceNo', wrap((req, res) => streamInvoicePdf(req, res, req.actor)));
// ── Documents ───────────────────────────────────────────────────────────────
async function canSeeEntity(actor, entity, id) {
if (entity === 'order') { const o = await orders.getOrderRaw(id); return !!o && orders.canSee(actor, o); }
if (entity === 'export_sample') { try { await samples.getExport(actor, id); return true; } catch (_e) { return false; } }
if (entity === 'sample') { try { await samples.getSample(actor, id); return true; } catch (_e) { return false; } }
return false;
}
router.post('/docs', upload.single('file'), wrap(async (req) => {
const { entity, entityId, docType, title } = req.body;
try {
need(req.file, 'No file uploaded');
need(['order', 'sample', 'export_sample'].includes(entity), 'Invalid entity');
need(await canSeeEntity(req.actor, entity, entityId), 'Not allowed');
return { id: await orders.addDoc(entity, entityId, docType || 'other', title, req.file.filename, req.file.originalname, req.actor.name) };
} catch (e) { if (req.file) fs.unlink(req.file.path, () => {}); throw e; }
}));
router.get('/docs/:id', wrap(async (req, res) => {
const d = await orders.getDoc(req.params.id);
need(d && await canSeeEntity(req.actor, d.ENTITY, d.ENTITY_ID), 'Not allowed');
const full = path.join(DOC_DIR, path.basename(d.FILE_NAME));
need(fs.existsSync(full), 'File missing');
res.setHeader('Content-Disposition', `inline; filename="${(d.ORIG_NAME || d.FILE_NAME).replace(/"/g, '')}"`);
res.sendFile(full);
}));
// ── Payments / wallet (Accounts) ────────────────────────────────────────────
router.get('/payments/pending', wrap(req => orders.pendingPayments(req.actor)));
router.post('/payments/:txnId/decide', wrap(req => orders.decideTopup(req.actor, req.params.txnId, req.body.decision, req.body.remarks)));
router.post('/payments/on-account', wrap(req => orders.addOnAccountPayment(req.actor, req.body)));
router.get('/wallet/:cardCode', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:payment_entry', 'view') || hasStepPerm(req.user, 'sales_order:payment_verify', 'view'), 'Not allowed');
return { summary: await orders.walletSummary(req.params.cardCode), ledger: await orders.ledger(req.params.cardCode, req.query) };
}));
router.put('/credit-limit/:cardCode', wrap(async (req) => {
need(hasStepPerm(req.user, 'sales_order:payment_entry', 'edit'), 'You are not assigned "edit" on sales_order:payment_entry');
const acc = await masters.getCustomerAccount(req.params.cardCode);
need(acc, 'This customer has no portal login yet — create one in Sales Admin → Customer Logins');
await query(`UPDATE dbo.ZSO_CUSTOMERS SET CREDIT_LIMIT=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=? WHERE CARD_CODE=?`, [Number(req.body.creditLimit) || 0, req.actor.name, req.params.cardCode]);
return { ok: true };
}));
// ── Samples ─────────────────────────────────────────────────────────────────
router.get('/samples', wrap(req => samples.listSamples(req.actor, req.query)));
router.get('/samples/:id', wrap(req => samples.getSample(req.actor, req.params.id)));
router.post('/samples', wrap(req => samples.createSample(req.actor, req.body)));
router.post('/samples/:id/action', wrap(req => samples.sampleAct(req.actor, req.params.id, req.body.action, req.body)));
router.get('/export-samples', wrap(req => samples.listExport(req.actor, req.query)));
router.get('/export-samples/:id', wrap(req => samples.getExport(req.actor, req.params.id)));
router.post('/export-samples', wrap(req => samples.saveExport(req.actor, req.body)));
router.post('/export-samples/:id/action', wrap(req => samples.exportAct(req.actor, req.params.id, req.body.action, req.body)));
// ── Reports ─────────────────────────────────────────────────────────────────
const REPORTS = { dashboard: reports.dashboard, 'order-wise': reports.orderWise, 'item-wise': reports.itemWise, pending: reports.pending, 'customer-pending': reports.customerPending };
router.get('/reports/:name', wrap(async (req) => {
need(hasModule(req, 'sales-reports') || hasModule(req, 'sales-orders'), 'Sales Reports access is required');
need(hasStepPerm(req.user, 'sales_order:view', 'view'), 'You are not assigned "view" on Sales Orders');
const fn = REPORTS[req.params.name];
need(fn, 'Unknown report');
return fn(req.actor, req.query);
}));
// ── Admin / masters ─────────────────────────────────────────────────────────
const master = key => req => isAdmin(req) || hasStepPerm(req.user, `sales_master:${key}`, 'view');
router.get('/admin/settings', wrap(async (req) => { need(isAdmin(req), 'Admin only'); return masters.getSettings(true); }));
router.put('/admin/settings', wrap(async (req) => { need(isAdmin(req), 'Admin only'); return masters.saveSettings(req.body, req.actor.name); }));
router.get('/admin/divisions', wrap(async (req) => { need(isAdmin(req) || master('products')(req)); return masters.listDivisions(true); }));
router.post('/admin/divisions', wrap(async (req) => { need(isAdmin(req), 'Admin only'); await masters.saveDivision(req.body); return masters.listDivisions(true); }));
// Quick enable/disable of a product category (disabled = hidden for NEW orders/samples; existing orders unaffected).
router.put('/admin/divisions/:id/active', wrap(async (req) => {
need(isAdmin(req), 'Admin only');
const d = await masters.getDivision(req.params.id);
need(d, 'Category not found');
await masters.saveDivision({ ...d, active: !!req.body.active });
const open = (await query(`SELECT COUNT(*) N FROM dbo.ZSO_ORDERS WHERE DIVISION_ID=? AND STATUS BETWEEN 1 AND 8`, [d.id]))[0].N;
return { divisions: await masters.listDivisions(true), openOrders: open };
}));
router.get('/admin/products', wrap(async (req) => { need(master('products')(req)); return masters.listCatalog({ catalog: req.query.catalog || 'domestic', divisionId: req.query.divisionId, includeInactive: true }); }));
router.post('/admin/products', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:products', req.body.id ? 'edit' : 'add'), 'Not allowed');
return { id: await masters.saveProduct(req.body, req.actor.name) };
}));
router.get('/admin/user-types', wrap(async (req) => { need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role)); return masters.listUserTypes(); }));
router.post('/admin/user-types', wrap(async (req) => { need(isAdmin(req), 'Admin only'); await masters.saveUserType(req.body); return masters.listUserTypes(); }));
router.get('/admin/users', wrap(async (req) => {
need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role), 'User admin only');
const users = await require('../services/hanaUsers').listUsers();
const profs = Object.fromEntries((await masters.listProfiles()).map(p => [p.userId, p]));
return users.map(u => ({ id: u.id, username: u.username, fullName: u.fullName, email: u.email, role: u.role, active: u.active, profile: profs[u.id] || null,
salesSteps: (u.approvalSteps || []).filter(s => /^sales_/.test(typeof s === 'string' ? s : s.step)) }));
}));
router.put('/admin/users/:id/profile', wrap(async (req) => {
need(isAdmin(req) || ['system_admin', 'sap_adder'].includes(req.user.role), 'User admin only');
await masters.saveProfile({ ...req.body, userId: req.params.id }, req.actor.name);
return { ok: true };
}));
router.get('/admin/sales-persons', wrap(async (req) => { need(master('mapping')(req)); return masters.listSalesPersons(); }));
router.post('/admin/sales-persons', wrap(async (req) => { need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'edit'), 'Not allowed'); return { id: await masters.saveSalesPerson(req.body) }; }));
router.get('/admin/sales-persons/:id/customers', wrap(async (req) => {
need(master('mapping')(req));
const list = await masters.listMappedCustomers(req.params.id);
const names = await sap.customerNames((await masters.getSettings()).company, list.map(x => x.cardCode));
return list.map(x => ({ ...x, cardName: (names[x.cardCode] || {}).name || '' }));
}));
router.post('/admin/sales-persons/:id/customers', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'add'), 'Not allowed');
await masters.mapCustomers(req.params.id, (req.body.cardCodes || []).map(String).filter(Boolean), req.actor.name);
return { ok: true };
}));
router.delete('/admin/sp-map/:mapId', wrap(async (req) => { need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:mapping', 'delete'), 'Not allowed'); await masters.unmapCustomer(req.params.mapId); return { ok: true }; }));
router.get('/admin/customers', wrap(async (req) => { need(master('customers')(req)); return masters.listCustomerAccounts(req.query.q); }));
router.post('/admin/customers', wrap(async (req) => {
need(isAdmin(req) || hasStepPerm(req.user, 'sales_master:customers', 'edit') || hasStepPerm(req.user, 'sales_master:customers', 'add'), 'Not allowed');
if (!req.body.cardName) { const c = await sap.getCustomer((await masters.getSettings()).company, req.body.cardCode); if (!c) { const e = new Error('Customer not found in SAP'); e.status = 400; throw e; } req.body.cardName = c.cardName; if (!req.body.email) req.body.email = c.email; }
const r = await masters.upsertCustomerAccount(req.body, req.actor.name);
// Welcome / reset email (Sales email Test mode → goes to the test address only).
let emailed = false;
if ((r.created || r.passwordReset) && req.body.active !== false && req.body.sendEmail !== false) {
let email = req.body.email;
if (!email) { try { const c = await sap.getCustomer((await masters.getSettings()).company, req.body.cardCode); email = c && c.email; } catch (_e) {} }
require('../services/sales/notify').customerLoginEvent(r.created ? 'created' : 'reset',
{ cardCode: req.body.cardCode, cardName: req.body.cardName, email: email || '', password: req.body.password });
emailed = true;
}
return { ok: true, ...r, emailed };
}));
module.exports = router;
module.exports.DOC_DIR = DOC_DIR;
module.exports.streamAttachment = streamAttachment;
module.exports.streamInvoicePdf = streamInvoicePdf;
module.exports.upload = upload;

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