app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-
replace-in-prod')
# --- Database Configuratisafe_password = quote_plus(DB_PASSWORD) if DB_PASSWORD
else ''
params =
f'DRIVER={DB_DRIVER};SERVER={DB_SERVER};DATABASE={DB_DATABASE};UID={DB_USERNAME};PW
D={safe_password};Encrypt=yes;TrustServerCertificate=yes;'
app.config['SQLALCHEMY_DATABASE_URI'] = f"mssql+pyodbc:///?
odbc_connect={quote_plus(params)}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ECHO'] = False
# --- Initialize Extensions ---
db = SQLAlchemy(app)
class User(db.Model):
# ... (User model definition remains the same as the last version) ...
__tablename__ = 'Users'; __table_args__ = (CheckConstraint("AccountStatus IN
('Active', 'Locked', 'Disabled', 'PendingVerification')",
name='CK_Users_AccountStatus'), {'schema': 'dbo'}); UserID = db.Column(db.Integer,
primary_key=True); FullName = db.Column(db.String(150), nullable=False); Username =
db.Column(db.String(50), unique=True, nullable=False); Email =
db.Column(db.String(255), unique=True, nullable=False); PasswordHash =
db.Column(db.String(255), nullable=False); Organization = db.Column(db.String(150),
nullable=True); ContactInfo = db.Column(db.String(255), nullable=True);
RegistrationDate = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=text('SYSUTCDATETIME()')); IsEmailVerified = db.Column(db.Boolean,
nullable=False, default=False, server_default='0'); LastLoginDate =
db.Column(db.DateTime(timezone=True), nullable=True); AccountStatus =
db.Column(db.String(20), nullable=False, default='Active',
server_default='Active'); security_logs = db.relationship('SecurityLog',
backref='user', lazy=True, foreign_keys='SecurityLog.UserID');
def __repr__(self): return f"<User {self.Username} ({self.UserID})>"
details_str = str(details) if details is not None else None
log_entry = SecurityLog(
UserID=user_id, Action=action, Status=status,
SourceIPAddress=ip_address[:50] if ip_address else None,
XForwardedFor=xff_header_trunc, XRealIP=x_real_ip_header_trunc,
# ClientHostname field ADDED BACK to log entry creation
ClientHostname=client_hostname, # Will be None if lookup failed/timed
out
UserAgent=user_agent_trunc,
Referrer=referrer_trunc,
RequestPath=request_path_trunc, ServerHostname=server_hostname_trunc,
Details=details_str
)
db.session.add(log_entry)
db.session.commit()
except Exception as e:
db.session.rollback()
print(f"CRITICAL ERROR: Failed to write security log to DB!")
print(f"Log Details Attempted: Action={action}, Status={status},
UserID={user_id}, Details={details_str}")
print(f"Logging Exception: {e}")
# --- Input Validation ---
EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
USERNAME_REGEX = r'^[a-zA-Z0-9_.-]{3,20}$'
# --- Schema Management Function ---
def initialize_database():
"""Creates tables and adds missing columns for enhanced logging."""
print("Initializing database schema...")
try:
print("Checking/Creating base tables (Users, SecurityLogs)...")
db.create_all()
print("Base tables checked/created.")
print("Checking/Adding enhanced columns to SecurityLogs table...")
# ClientHostname ADDED BACK to this list
sql_commands = [
("ClientHostname", "NVARCHAR(255) NULL"), # ADDED BACK
("XForwardedFor", "NVARCHAR(255) NULL"),
("XRealIP", "NVARCHAR(50) NULL"),
("Referrer", "NVARCHAR(512) NULL"),
]
all_cols_exist = True
for col_name, col_def in sql_commands:
check_sql = text(f"SELECT 1 FROM sys.columns WHERE Name = N'{col_name}'
AND Object_ID = Object_ID(N'dbo.SecurityLogs')")
result = db.session.execute(check_sql).scalar()
if not result:
all_cols_exist = False
try:
alter_sql = text(f"ALTER TABLE dbo.SecurityLogs ADD
[{col_name}] {col_def};")
db.session.execute(alter_sql)
db.session.commit()
print(f" Column '{col_name}' added successfully.")
except Exception as alter_err:
db.session.rollback()
print(f" ERROR: Failed to add column '{col_name}'. Check DB
user permissions.")
print(f" Error details: {alter_err}")
if all_cols_exist: print("All enhanced SecurityLogs columns already
exist.")
else: print("Enhanced SecurityLogs columns checked/added.")
print("Database schema initialization complete.")
except Exception as e:
db.session.rollback()
print(f"CRITICAL ERROR during database schema initialization: {e}")
# --- Routes (No changes needed in route logic itself) ---
@app.route('/login', methods=['GET', 'POST'])
def login():
# ... (Login route logic remains the same as previous version) ...
if 'user_id' in session: return redirect(url_for('serve_main_app'))
if request.method == 'POST':
identifier = request.form.get('identifier', '').strip(); password =
request.form.get('password', '')
log_security_event(action='Login Attempt', status='Attempt',
details=f"Identifier: {identifier}")
if not identifier or not password:
flash('Username/Email and Password are required.', 'danger')
log_security_event(action='Login Attempt', status='Failure',
details=f"Identifier: {identifier} - Missing fields")
return render_template('login.html')
try:
user = User.query.filter((User.Username == identifier) | (User.Email ==
identifier)).first()
login_failure = False; user_id_for_log = user.UserID if user else None;
failure_reason = ""
if not user: login_failure = True; failure_reason = "User not found"
elif user.AccountStatus != 'Active': login_failure = True;
failure_reason = f"Account status: {user.AccountStatus}"
elif not bcrypt.checkpw(password.encode('utf-8'),
user.PasswordHash.encode('utf-8')): login_failure = True; failure_reason = "Invalid
password"
if login_failure:
flash('Invalid username/email or password.', 'danger')
log_security_event(action='Login Attempt', status='Failure',
user_id=user_id_for_log, details=f"Identifier: {identifier} - {failure_reason}")
return render_template('login.html')
session.clear(); session['user_id'] = user.UserID; session.permanent =
True
user.LastLoginDate = datetime.now(timezone.utc); db.session.add(user);
db.session.commit()
log_security_event(action='Login', status='Success',
user_id=user.UserID, details=f"User logged in: {user.Username}")
print(f"INFO: User {user.Username} (ID: {user.UserID}) logged in
successfully.")
return redirect(url_for('serve_main_app'))
except Exception as e:
db.session.rollback(); print(f"ERROR: Exception during login processing
for identifier '{identifier}': {e}")
flash('An unexpected error occurred during login. Please try again
later.', 'danger')
log_security_event(action='Login Attempt', status='Error',
details=f"Identifier: {identifier} - Exception: {e}")
return render_template('login.html'), 500
return render_template('login.html')
@app.route('/logout')
def logout():
# ... (Logout route logic remains the same, including db.session.get fix) ...
user_id = session.get('user_id'); username_for_log = "(Unknown)"
if user_id:
try:
user = db.session.get(User, user_id) # Use updated method
if user: username_for_log = user.Username
except Exception as e: print(f"WARN: Could not fetch user details during
logout for UserID {user_id}: {e}")
session.clear(); flash('You have been successfully logged out.', 'success')
log_details = f"User logged out. Username: {username_for_log}"
log_security_event(action='Logout', status='Success', user_id=user_id,
details=log_details)
print(f"INFO: User {username_for_log} (ID: {user_id}) logged out.")
return redirect(url_for('login'))
# --- Initialize Database Schema on Startup (within Application Context) ---
# Create/update tables and columns
# --- NEW: Define upload folders ---
BAI_UPLOAD_FOLDER = os.path.join(app.root_path, 'uploads', 'bai')
ERA_UPLOAD_FOLDER = os.path.join(app.root_path, 'uploads', 'era')
RECONCILE_ERA_FOLDER = os.path.join(app.root_path, 'reconciled_files', 'era') #
Changed folder name slightly for clarity
# --- NEW: Ensure upload folders exist ---
os.makedirs(BAI_UPLOAD_FOLDER, exist_ok=True)
os.makedirs(ERA_UPLOAD_FOLDER, exist_ok=True)
# --- NEW: Ensure reconcile folder exists ---
os.makedirs(RECONCILE_ERA_FOLDER, exist_ok=True)
# -------------------------
# Database Helper Functions
# (No changes needed in these functions for duplicate check)
# -------------------------
def load_db_config(config_path="db_config.json"):
try:
with open(config_path, "r") as f:
return json.load(f)
except Exception as e:
print("Error loading DB configuration:", e)
return None
def get_db_connection():
db_config = load_db_config()
if not db_config:
return None
try:
conn_str = (
f"DRIVER={{ODBC Driver 18 for SQL Server}};"
f"SERVER={db_config['host']};"
f"DATABASE={db_config['database']};"
f"UID={db_config['user']};"
f"PWD={db_config['password']};"
f"Encrypt=no;"
f"TrustServerCertificate=Yes;"
)
conn = pyodbc.connect(conn_str, autocommit=False) # Ensure autocommit is
off for transactions
return conn
except Exception as e:
print("Error connecting to database:", e)
return None
# --- MODIFIED: init_db to create three BAI tables ---
def init_db():
conn = get_db_connection()
if conn is None: return
cur = conn.cursor()
try:
# --- BAI Summary Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA
= 'dbo' AND TABLE_NAME = 'BAI_Summary')
BEGIN CREATE TABLE BAI_Summary (id INT IDENTITY(1,1) PRIMARY KEY,
file_name NVARCHAR(MAX) NOT NULL, file_path NVARCHAR(MAX), sender_name
NVARCHAR(MAX), receiver_name NVARCHAR(MAX), customer_reference NVARCHAR(MAX),
deposit_date NVARCHAR(50), receive_date NVARCHAR(50), total_amount NVARCHAR(50),
raw_content NVARCHAR(MAX), created_at DATETIME2) END
""")
# --- BAI Detail Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA =
'dbo' AND TABLE_NAME = 'BAI_Detail')
BEGIN
CREATE TABLE BAI_Detail (
id INT IDENTITY(1,1) PRIMARY KEY,
bai_summary_id INT FOREIGN KEY REFERENCES BAI_Summary(id) ON DELETE
CASCADE,
transaction_type NVARCHAR(100),
category NVARCHAR(100),
receive_date NVARCHAR(50),
customer_reference NVARCHAR(MAX),
company_id NVARCHAR(MAX),
payer_name NVARCHAR(MAX),
recipient_name NVARCHAR(MAX),
amount NVARCHAR(50), -- Original amount string for display consistency
-- NEW COLUMNS for reconciliation tracking --
parsed_amount DECIMAL(18, 2) NULL, -- Store the numeric amount for
calculations
reconciled_amount DECIMAL(18, 2) NOT NULL DEFAULT 0,
remaining_amount DECIMAL(18, 2) NULL, -- Can be calculated, but storing
simplifies logic
reconciliation_status NVARCHAR(20) NOT NULL DEFAULT 'No' -- 'No',
'Partial', 'Yes'
)
END
-- Add columns if table exists but columns don't (for development
convenience)
-- In production, use proper migration scripts
ELSE
BEGIN
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME
= 'BAI_Detail' AND COLUMN_NAME = 'parsed_amount')
ALTER TABLE BAI_Detail ADD parsed_amount DECIMAL(18, 2) NULL;
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME
= 'BAI_Detail' AND COLUMN_NAME = 'reconciled_amount')
ALTER TABLE BAI_Detail ADD reconciled_amount DECIMAL(18, 2) NOT NULL
DEFAULT 0;
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME
= 'BAI_Detail' AND COLUMN_NAME = 'remaining_amount')
ALTER TABLE BAI_Detail ADD remaining_amount DECIMAL(18, 2) NULL;
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME
= 'BAI_Detail' AND COLUMN_NAME = 'reconciliation_status')
ALTER TABLE BAI_Detail ADD reconciliation_status NVARCHAR(20) NOT
NULL DEFAULT 'No';
END
""")
# --- BAI Json Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA
= 'dbo' AND TABLE_NAME = 'BAI_Json')
BEGIN CREATE TABLE BAI_Json (id INT IDENTITY(1,1) PRIMARY KEY,
bai_summary_id INT FOREIGN KEY REFERENCES BAI_Summary(id) ON DELETE CASCADE,
json_data NVARCHAR(MAX)) END
""")
conn.commit()
except Exception as e:
print(f"Error during DB initialization (BAI Tables): {e}") # Adjusted error
message
conn.rollback()
finally:
if cur: cur.close()
if conn: conn.close()
# --- init_era_table remains the same ---
def init_era_table():
conn = get_db_connection()
if conn is None: return
cur = conn.cursor()
try:
# --- ERA Summary Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA
= 'dbo' AND TABLE_NAME = 'ERA_Summary')
BEGIN CREATE TABLE ERA_Summary (id INT IDENTITY(1,1) PRIMARY KEY,
file_name NVARCHAR(MAX) NOT NULL, file_path NVARCHAR(MAX), payer_name
NVARCHAR(MAX), payee_name NVARCHAR(MAX), payee_id NVARCHAR(MAX), check_date
NVARCHAR(50), trn_number NVARCHAR(MAX), amount NVARCHAR(50), check_eft
NVARCHAR(50), raw_content NVARCHAR(MAX), uploaded_at DATETIME2) END
""")
# --- ERA Detail Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA
= 'dbo' AND TABLE_NAME = 'ERA_Detail')
BEGIN CREATE TABLE ERA_Detail (id INT IDENTITY(1,1) PRIMARY KEY,
era_summary_id INT FOREIGN KEY REFERENCES ERA_Summary(id) ON DELETE CASCADE,
patient_name NVARCHAR(MAX), account_number NVARCHAR(MAX), icn NVARCHAR(MAX),
billed_amount NVARCHAR(50), paid_amount NVARCHAR(50), from_date NVARCHAR(50),
to_date NVARCHAR(50)) END
""")
# --- ERA Json Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA
= 'dbo' AND TABLE_NAME = 'ERA_Json')
BEGIN CREATE TABLE ERA_Json (id INT IDENTITY(1,1) PRIMARY KEY,
era_summary_id INT FOREIGN KEY REFERENCES ERA_Summary(id) ON DELETE CASCADE,
json_data NVARCHAR(MAX)) END
""")
conn.commit()
except Exception as e:
print(f"Error during DB initialization (ERA Tables): {e}") # Adjusted error
message
conn.rollback()
finally:
if cur: cur.close()
if conn: conn.close()
# --- NEW: Function to create Reconciliation table ---
def init_reconciliation_table():
conn = get_db_connection()
if conn is None: return
cur = conn.cursor()
try:
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'Reconciliation')
BEGIN
CREATE TABLE Reconciliation (
id INT IDENTITY(1,1) PRIMARY KEY,
bai_detail_id INT NOT NULL,
era_summary_id INT NOT NULL,
reconciliation_date DATETIME2 NOT NULL,
reconciled_by NVARCHAR(100) NULL,
-- NEW COLUMNS --
amount_reconciled DECIMAL(18, 2) NOT NULL, -- Amount matched in *this*
specific reconciliation event
manual_reconciliation BIT NOT NULL DEFAULT 0, -- 1 if manual, 0 if auto
-- Foreign Key constraints (Now valid as tables exist)
CONSTRAINT FK_Reconciliation_BAI_Detail FOREIGN KEY (bai_detail_id)
REFERENCES BAI_Detail(id),
CONSTRAINT FK_Reconciliation_ERA_Summary FOREIGN KEY (era_summary_id)
REFERENCES ERA_Summary(id)
-- Removed unique constraints to allow partial BAI reconciliation with
multiple ERAs
-- CONSTRAINT UQ_Reconciliation_BAI UNIQUE (bai_detail_id), -- REMOVED
-- CONSTRAINT UQ_Reconciliation_ERA UNIQUE (era_summary_id) -- REMOVED
(One ERA might map to one BAI part)
-- Need a constraint to prevent reconciling the *same* BAI Detail part
with the *same* ERA Summary twice?
-- Maybe a composite unique constraint? For now, rely on application
logic.
)
END
-- Add columns if table exists but columns don't
ELSE
BEGIN
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME
= 'Reconciliation' AND COLUMN_NAME = 'amount_reconciled')
ALTER TABLE Reconciliation ADD amount_reconciled DECIMAL(18, 2) NOT
NULL DEFAULT 0; -- Default 0, should always be set on insert
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME
= 'Reconciliation' AND COLUMN_NAME = 'manual_reconciliation')
ALTER TABLE Reconciliation ADD manual_reconciliation BIT NOT NULL
DEFAULT 0;
END
""")
conn.commit()
except Exception as e:
print(f"Error during DB initialization (Reconciliation Table): {e}") #
Specific message
conn.rollback()
finally:
if cur: cur.close()
if conn: conn.close()
def format_bai_date(date_str):
"""Formats BAI date (YYMMDD or YYYYMMDD) to MM/DD/YYYY."""
if not date_str: return ""
date_str = str(date_str).strip() # Ensure it's a string
if len(date_str) == 6: # YYMMDD
# Add century prefix (assuming 20xx)
return f"{date_str[2:4]}/{date_str[4:6]}/20{date_str[0:2]}"
elif len(date_str) == 8: # YYYYMMDD
return f"{date_str[4:6]}/{date_str[6:8]}/{date_str[0:4]}"
return date_str # Return original if format is unexpected
# --- NEW: Helper function to parse amount strings safely ---
def parse_amount(amount_str):
"""Safely parses an amount string (e.g., '$1,234.56') into a Decimal."""
if amount_str is None:
return None
try:
# Remove common currency symbols and commas
cleaned_str = str(amount_str).replace('$', '').replace(',', '').strip()
if not cleaned_str:
return None
# Handle potential negative signs represented by parentheses
if cleaned_str.startswith('(') and cleaned_str.endswith(')'):
cleaned_str = '-' + cleaned_str[1:-1]
return Decimal(cleaned_str)
except InvalidOperation:
print(f"Warning: Could not parse amount string '{amount_str}' to Decimal.")
return None
except Exception as e:
print(f"Error parsing amount string '{amount_str}': {e}")
return None
# --- MODIFIED: init_era_table to create three ERA tables ---
def init_era_table():
conn = get_db_connection()
if conn is None:
return
try:
cur = conn.cursor()
# --- ERA Summary Table ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'ERA_Summary')
BEGIN
CREATE TABLE ERA_Summary (
id INT IDENTITY(1,1) PRIMARY KEY,
file_name NVARCHAR(MAX) NOT NULL, -- Base filename
file_path NVARCHAR(MAX), payer_name NVARCHAR(MAX), payee_name
NVARCHAR(MAX),
payee_id NVARCHAR(MAX), check_date NVARCHAR(50), trn_number
NVARCHAR(MAX),
amount NVARCHAR(50), check_eft NVARCHAR(50), raw_content
NVARCHAR(MAX),
uploaded_at DATETIME2
-- Optional: Add unique constraint for duplicate check fields
-- CONSTRAINT UQ_ERA_File UNIQUE (file_name, trn_number,
check_date) -- Adjust fields as needed
)
END
""")
# --- ERA Detail Table (Stores presentation-ready data) ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'ERA_Detail')
BEGIN
CREATE TABLE ERA_Detail (
id INT IDENTITY(1,1) PRIMARY KEY,
era_summary_id INT FOREIGN KEY REFERENCES ERA_Summary(id) ON DELETE
CASCADE,
patient_name NVARCHAR(MAX), account_number NVARCHAR(MAX), icn
NVARCHAR(MAX),
billed_amount NVARCHAR(50), paid_amount NVARCHAR(50), from_date
NVARCHAR(50), to_date NVARCHAR(50)
)
END
""")
# --- ERA Json Table (Stores full original parsed JSON) ---
cur.execute("""
IF NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'ERA_Json')
BEGIN
CREATE TABLE ERA_Json (
id INT IDENTITY(1,1) PRIMARY KEY,
era_summary_id INT FOREIGN KEY REFERENCES ERA_Summary(id) ON DELETE
CASCADE,
json_data NVARCHAR(MAX)
)
END
""")
conn.commit()
cur.close()
except Exception as e:
print("Error creating ERA tables:", e)
conn.rollback()
finally:
if conn:
conn.close()
# Create tables on startup
init_db()
init_era_table()
init_reconciliation_table()
# -------------------------
# BAI Parsing Functions (No changes)
# -------------------------
def parse_file_header(fields):
return {
"recordId": fields[0],
"senderId": fields[1] if len(fields) > 1 else "",
"receiverId": fields[2] if len(fields) > 2 else "",
"fileCreationDate": fields[3] if len(fields) > 3 else "",
"fileCreationTime": fields[4] if len(fields) > 4 else "",
"fileId": fields[5] if len(fields) > 5 else "",
"physicalRecordLength": fields[6] if len(fields) > 6 else "",
"blockSize": fields[7] if len(fields) > 7 else "",
"versionNumber": fields[8] if len(fields) > 8 else ""
}
def parse_group_header(fields):
return {
"recordId": fields[0],
"ultimateReceiverId": fields[1] if len(fields) > 1 else "",
"originatorId": fields[2] if len(fields) > 2 else "",
"groupStatus": fields[3] if len(fields) > 3 else "",
"asOfDate": fields[4] if len(fields) > 4 else "",
"asOfTime": fields[5] if len(fields) > 5 else "",
"currencyCode": fields[6] if len(fields) > 6 else "",
"asOfDateModifier": fields[7] if len(fields) > 7 else ""
}
def parse_account_header(fields):
account_header = {
"recordId": fields[0],
"accountNumber": fields[1] if len(fields) > 1 else "",
"currencyCode": fields[2] if len(fields) > 2 else ""
}
remaining = fields[3:]
# Remove any trailing empty fields (which may come from extra commas)
while remaining and remaining[-1] == "":
remaining.pop()
type_details = []
# If the remaining fields are an even number, assume they are (typeCode,
amount) pairs.
if len(remaining) % 2 == 0:
for i in range(0, len(remaining), 2):
group = {
"typeCode": remaining[i],
"amount": remaining[i+1]
}
type_details.append(group)
else:
# Fallback: if the count is a multiple of three, use groups of three.
i = 0
while i < len(remaining):
group = {
"typeCode": remaining[i] if i < len(remaining) else "",
"amount": remaining[i+1] if i+1 < len(remaining) else "",
"itemCount": remaining[i+2] if i+2 < len(remaining) else ""
}
type_details.append(group)
i += 3
account_header["typeDetails"] = type_details
return account_header
def parse_transaction(fields):
t = {"recordId": fields[0]}
t["typeCode"] = fields[1] if len(fields) > 1 else ""
amount_str = fields[2] if len(fields) > 2 else ""
try:
# Keep amount as string with potential formatting, but raw value might be
needed
amount_num = float(amount_str) / 100
t["amount"] = f"${amount_num:,.2f}"
t["raw_amount_cents"] = amount_str # Store original cents value if needed
except Exception:
t["amount"] = amount_str
t["raw_amount_cents"] = None
t["fundsType"] = fields[3] if len(fields) > 3 else ""
if len(fields) == 6: # simplified assumption based on example
t["fundsTypeDetails"] = []
t["bankReferenceNumber"] = fields[4]
t["customerReferenceNumber"] = fields[5]
elif len(fields) > 6:
funds_details = fields[4:-2]
t["fundsTypeDetails"] = [fd for fd in funds_details if fd]
t["bankReferenceNumber"] = fields[-2]
t["customerReferenceNumber"] = fields[-1]
else: # Handle cases with fewer fields if necessary
t["fundsTypeDetails"] = []
t["bankReferenceNumber"] = ""
t["customerReferenceNumber"] = ""
return t
def parse_continuation(fields):
text = ",".join(fields[1:]) if len(fields) > 1 else ""
return {"recordId": fields[0], "text": text}
def parse_account_trailer(fields):
return {
"recordId": fields[0],
"accountControlTotal": fields[1] if len(fields) > 1 else "",
"numberOfRecords": fields[2] if len(fields) > 2 else ""
}
def parse_group_trailer(fields):
return {
"recordId": fields[0],
"groupControlTotal": fields[1] if len(fields) > 1 else "",
"numberOfAccounts": fields[2] if len(fields) > 2 else "",
"numberOfRecords": fields[3] if len(fields) > 3 else ""
}
def parse_file_trailer(fields):
return {
"recordId": fields[0],
"fileControlTotal": fields[1] if len(fields) > 1 else "",
"numberOfGroups": fields[2] if len(fields) > 2 else "",
"numberOfRecords": fields[3] if len(fields) > 3 else ""
}
def extract_ending_balance(line):
fields = [f.strip() for f in line.rstrip("/").split(",")]
for i, field in enumerate(fields):
if field == "060" and (i+1) < len(fields):
return fields[i+1]
return None
class BAIParser:
def __init__(self):
self.result = {}
self.current_group = None
self.current_account = None
self.current_transaction = None
def parse_line(self, line):
line = line.strip()
if line.endswith("/"):
line = line[:-1]
fields = [f.strip() for f in line.split(",")]
record_type = fields[0] if fields else ""
return record_type, fields
def parse(self, lines):
self.result = {"groups": []}
for line in lines:
if not line.strip():
continue
record_type, fields = self.parse_line(line)
if record_type == "01":
self.result["fileHeader"] = parse_file_header(fields)
elif record_type == "02":
self.current_group = parse_group_header(fields)
self.current_group["accounts"] = []
self.result["groups"].append(self.current_group)
elif record_type == "03":
self.current_account = parse_account_header(fields)
self.current_account["transactions"] = []
if self.current_group is not None:
self.current_group["accounts"].append(self.current_account)
else: # Handle case where 03 might appear without 02 (unlikely but
safe)
self.current_group = {"accounts": [self.current_account]}
self.result["groups"].append(self.current_group)
elif record_type == "16":
self.current_transaction = parse_transaction(fields)
if self.current_account is not None:
self.current_account["transactions"].append(self.current_transaction)
else: # Handle case where 16 might appear without 03 (unlikely but
safe)
self.current_account = {"transactions":
[self.current_transaction]}
if self.current_group is None:
self.current_group = {"accounts": [self.current_account]}
self.result["groups"].append(self.current_group)
else:
if not self.current_group.get("accounts"):
self.current_group["accounts"] = []
self.current_group["accounts"].append(self.current_account)
elif record_type == "88":
cont = parse_continuation(fields)
if self.current_transaction is not None:
# Ensure 'continuations' list exists before appending
self.current_transaction.setdefault("continuations",
[]).append(cont)
# If 88 appears without a current transaction, where should it go?
# Option 1: Ignore it (current behavior)
# Option 2: Attach to current account? (less likely)
# Option 3: Store at group level? (less likely)
# Current approach assumes 88 always follows a 16.
elif record_type == "49":
if self.current_account is not None:
self.current_account["accountTrailer"] =
parse_account_trailer(fields)
self.current_account = None # Reset current account
# Handle case where 49 appears without a current account (e.g.,
malformed file)
elif record_type == "98":
if self.current_group is not None:
self.current_group["groupTrailer"] =
parse_group_trailer(fields)
self.current_group = None # Reset current group
# Handle case where 98 appears without a current group
elif record_type == "99":
self.result["fileTrailer"] = parse_file_trailer(fields)
else:
# Handle unknown record types if necessary
pass # Ignore unknown types for now
return self.result
# --------------------------
# ERA Parsing Functions (No changes)
# --------------------------
SEGMENT_FIELD_MAPPINGS = {
"ISA": [
"Segment",
"AuthorizationInfoQualifier",
"AuthorizationInformation",
"SecurityInfoQualifier",
"SecurityInformation",
"InterchangeIDQualifierSender",
"InterchangeSenderID",
"InterchangeIDQualifierReceiver",
"InterchangeReceiverID",
"InterchangeDate",
"InterchangeTime",
"InterchangeControlStandardsIdentifier",
"InterchangeControlVersionNumber",
"InterchangeControlNumber",
"AcknowledgmentRequested",
"UsageIndicator",
"ComponentElementSeparator"
],
"GS": [
"Segment",
"FunctionalIdentifierCode",
"ApplicationSenderCode",
"ApplicationReceiverCode",
"Date",
"Time",
"GroupControlNumber",
"ResponsibleAgencyCode",
"VersionReleaseIndustryIdentifierCode"
],
"ST": [
"Segment",
"TransactionSetID",
"TransactionSetControlNumber"
],
"BPR": [
"Segment",
"TransactionHandlingCode",
"MonetaryAmount",
"CreditDebitIndicator",
"PaymentMethod",
"PaymentFormatCode",
"SenderDFIQualifier",
"SenderDFIAccount",
"ReceiverDFIQualifier",
"ReceiverDFIAccount",
"TraceNumber",
"ReceiverBankABA",
"ReceiverIdentificationQualifier",
"ReceiverIdentification",
"AdditionalIdentificationQualifier",
"AdditionalIdentification",
"Date"
],
"TRN": [
"Segment",
"TraceType",
"TraceNumber",
"OriginatingCompanyIdentifier"
],
"REF": [
"Segment",
"ReferenceIdentificationQualifier",
"ReferenceIdentification"
],
"DTM": [
"Segment",
"DateTimeQualifier",
"DateOrTime"
],
"N1": [
"Segment",
"EntityIdentifierCode",
"Name",
"IdentificationCodeQualifier",
"IdentificationCode"
],
"N3": [
"Segment",
"AddressLine1",
"AddressLine2"
],
"N4": [
"Segment",
"City",
"State",
"PostalCode",
"CountryCode",
"LocationQualifier",
"LocationIdentifier"
],
"PER": [
"Segment",
"ContactFunctionCode",
"Name",
"CommunicationNumberQualifier",
"CommunicationNumber",
"CommunicationNumberQualifier2",
"CommunicationNumber2",
"CommunicationNumberQualifier3",
"CommunicationNumber3",
"ContactInquiryReference"
],
"LX": [
"Segment",
"AssignedNumber"
],
"CLP": [
"Segment",
"ClaimSubmitterIdentifier",
"ClaimStatus",
"TotalClaimChargeAmount",
"ClaimPaymentAmount",
"PatientResponsibilityAmount",
"ClaimIdentifierCode",
"PayerClaimControlNumber",
"FacilityTypeCode",
"ClaimFrequencyCode",
"PatientStatusCode",
"DRGCode",
"DRGWeight",
"DischargeFraction",
"MedicareInpatientAdjudicationIndicator"
],
"NM1": [
"Segment",
"EntityIdentifierCode",
"EntityTypeQualifier",
"NameLastOrOrganizationName",
"NameFirst",
"NameMiddle",
"NamePrefix",
"NameSuffix",
"IdentificationCodeQualifier",
"IdentificationCode"
],
"MOA": [
"Segment",
"ReimbursementRate",
"HCPCSPayableAmount",
"RemarkCode1",
"RemarkCode2",
"RemarkCode3",
"RemarkCode4",
"RemarkCode5",
"EndStageRenalDiseasePaymentAmount",
"NonPayableProfessionalComponentBilledAmount"
],
"SVC": [
"Segment",
"CompositeMedicalProcedureIdentifier",
"LineItemChargeAmount",
"LineItemPaidAmount",
"RevenueCode",
"UnitsOfServicePaidCount",
"CompositeMedicalProcedureIdentifier2",
"LineItemNumber"
],
"CAS": [
"Segment",
"AdjustmentGroupCode",
"AdjustmentReasonCode1",
"AdjustmentAmount1",
"AdjustmentQuantity1",
"AdjustmentReasonCode2",
"AdjustmentAmount2",
"AdjustmentQuantity2",
"AdjustmentReasonCode3",
"AdjustmentAmount3",
"AdjustmentQuantity3"
],
"AMT": [
"Segment",
"AmountQualifier",
"MonetaryAmount"
],
"LQ": [
"Segment",
"CodeListQualifierCode",
"IndustryCodeValue"
],
"SE": [
"Segment",
"NumberOfIncludedSegments",
"TransactionSetControlNumber"
],
"GE": [
"Segment",
"NumberOfTransactionSetsIncluded",
"GroupControlNumber"
],
"IEA": [
"Segment",
"NumberOfIncludedFunctionalGroups",
"InterchangeControlNumber"
],
"PLB": [
"Segment",
"ProviderIdentifier",
"FiscalPeriodDate",
"AdjustmentIdentifier",
"ProviderAdjustmentAmount"
]
}
def parse_edi_content(content: str) -> list:
# Standard EDI parsing logic - split segments, elements
segment_terminator = '~'
element_separator = '*'
# Detect separators dynamically? For now, assume standard.
# Simple check: if content contains a different likely terminator
if segment_terminator not in content and '\n' in content:
segment_terminator = '\n' # Basic fallback
if element_separator not in content.split(segment_terminator)[0]:
# Try common alternatives if needed
pass
edi_data = content.replace('\r', '').strip() # Keep \n if it's the terminator
segments_raw = [seg.strip() for seg in edi_data.split(segment_terminator) if
seg.strip()]
flat_segments = []
for seg in segments_raw:
elements = seg.split(element_separator)
seg_id = elements[0]
mapping = SEGMENT_FIELD_MAPPINGS.get(seg_id)
seg_dict = {}
if mapping:
for i, el in enumerate(elements):
# Check index bounds
if i < len(mapping):
seg_dict[mapping[i]] = el
else:
# Handle extra elements if mapping is shorter
seg_dict[f"Field_{i+1}"] = el # Generic field name
else:
# Handle unknown segment types
seg_dict = {"Segment": seg_id, "Elements": elements[1:]}
flat_segments.append(seg_dict)
return flat_segments
def group_edi_segments(flat_segments: list) -> dict:
# Basic hierarchical grouping (ISA/GS/ST/...)
# This function focuses on the structure needed for display/extraction
# It might not represent a fully compliant EDI structure object model
envelope = []
functional_groups = []
current_group = None
current_transaction = None # Added for clarity
for seg in flat_segments:
seg_id = seg.get("Segment")
if seg_id == "ISA":
envelope.append(seg)
elif seg_id == "GS":
# Start a new functional group
current_group = {"header": seg, "transactions": [], "trailer": None,
"details": []} # Added details list here
elif seg_id == "ST":
# Start a new transaction set within the current group
if current_group:
current_transaction = {"header": seg, "claims": [], "trailer":
None, "details": []} # Added details list here
current_group["transactions"].append(current_transaction)
else:
# Handle ST outside GS? (Error or specific structure)
pass # Log warning?
elif seg_id == "CLP":
# Start a new claim within the current transaction
if current_transaction: # Check if we are inside a transaction
current_claim = {"CLP": seg, "details": []}
current_transaction["claims"].append(current_claim)
else:
# Handle CLP outside ST? (Error or specific structure)
pass # Log warning?
elif seg_id == "SE":
# End of transaction set
if current_transaction: # Check if we are inside a transaction
current_transaction["trailer"] = seg
current_transaction = None # Reset current transaction
else:
# Handle SE outside ST?
pass # Log warning?
elif seg_id == "GE":
# End of functional group
if current_group:
current_group["trailer"] = seg
functional_groups.append(current_group)
current_group = None # Reset current group
else:
# Handle GE outside GS?
pass # Log warning?
elif seg_id == "IEA":
# End of interchange
envelope.append(seg)
else:
# Add segment to the current context (claim, transaction, group, or
envelope)
if current_transaction and current_transaction.get("claims"): # Check
claims exist
# Add to the last claim's details
current_transaction["claims"][-1]["details"].append(seg)
elif current_transaction:
# Add to the current transaction's details (if no claims yet,
e.g., N1, REF before first CLP)
current_transaction["details"].append(seg)
elif current_group:
# Add to the current group's details (e.g., segments between GS
and ST, or between SE and GE)
current_group["details"].append(seg)
else:
# Add to the envelope (segments outside GS/GE, like ISA/IEA or
potentially TA1)
envelope.append(seg)
# Handle potentially unclosed group
if current_group and not current_group.get("trailer"):
functional_groups.append(current_group) # Add the last group if GE is
missing
# Construct the final output, focusing on the first transaction for simplicity
as before
# This part might need adjustment based on how multi-transaction files should
be handled
first_transaction_envelope = []
first_transaction_claims = []
first_transaction_trailing = [] # Segments after claims, before GE/IEA
if functional_groups: # Check if any groups were found
fg = functional_groups[0] # Process only the first group
first_transaction_envelope.extend(envelope) # ISA
first_transaction_envelope.append(fg["header"]) # GS
first_transaction_envelope.extend(fg.get("details", [])) # Segments between
GS and ST
if fg["transactions"]: # Check if transactions exist
tx = fg["transactions"][0] # Process only the first transaction
first_transaction_envelope.append(tx["header"]) # ST
first_transaction_envelope.extend(tx.get("details",[])) # Segments
between ST and first CLP
first_transaction_claims = tx["claims"] # List of claims {CLP:...,
details:[...]}
# Trailing segments within the transaction (after last claim, before
SE)
# Note: The previous logic added *all* non-claim segments here. Let's
refine.
# This assumes segments after the last claim detail and before SE
belong here.
# The grouping logic needs refinement if segments can interleave
differently.
if tx["trailer"]: # SE segment
first_transaction_trailing.append(tx["trailer"])
# Trailing segments within the group (after SE, before GE)
# The current grouping logic puts these in fg["details"] if they occur
after ST/SE handled.
# Let's assume fg["details"] mainly contains segments before ST. Revisit if
needed.
if fg["trailer"]: # GE segment
first_transaction_trailing.append(fg["trailer"])
# Extract PLB segments from wherever they ended up (likely envelope or
group details if outside ST/SE)
# A more robust parser would place PLB specifically. Let's assume they
might be in trailing for now.
plbs = [s for s in first_transaction_trailing if s.get("Segment") == "PLB"]
first_transaction_trailing = [s for s in first_transaction_trailing if
s.get("Segment") != "PLB"]
first_transaction_trailing.extend(plbs) # Append PLBs at the end of
trailing
# Add IEA if it exists in the envelope
iea_seg = next((s for s in envelope if s.get("Segment") == "IEA"), None)
if iea_seg: first_transaction_trailing.append(iea_seg)
else: # Handle case with no functional groups (e.g., ISA/IEA only or malformed)
first_transaction_envelope.extend(envelope)
# Return structure focused on display needs
return {"envelope": first_transaction_envelope, "claims":
first_transaction_claims, "trailing": first_transaction_trailing}
def convert_edi_content_to_json(content: str) -> dict:
flat_segments = parse_edi_content(content)
grouped_data = group_edi_segments(flat_segments)
return grouped_data
# --- Helper to extract BAI details for BAI_Detail table ---
# --- CORRECTED: Helper to extract BAI details for BAI_Detail table ---
def extract_bai_details_for_db(trans, summary):
# Reuses logic from the old Excel export helper, adapted for DB insertion
# This logic determines Type, Category, Customer Ref, Comp ID, Payer, Recipient
# --- Initialize payer and recipient from summary ---
# These are the defaults unless overridden by specific continuations
payer = summary.get('sender_name', "Unknown Sender")
recipient = summary.get('receiver_name', "Unknown Receiver") # Get from summary
(mapped via config)
trnVal = ""
achVal = ""
otherRef = None
recID = ""
compID = "" # Initialize compID specifically
raw_continuations = trans.get("continuations", [])
# Flags to track if specific overrides occurred
payer_overridden_by_cont = False
recipient_overridden_by_cont = False
if raw_continuations:
for cont in raw_continuations:
text = cont.get("text", "").strip()
if not text:
continue
# --- Modified block for OTHER REFERENCE entries ---
if text.upper().startswith("OTHER REFERENCE:"):
try:
# Split the text after "OTHER REFERENCE:" into tokens
(whitespace-separated)
ref_part = text.split(":", 1)[1].strip()
parts = ref_part.split() # Split by whitespace
parsed_comp_id = parts[0] if len(parts) > 0 else ""
parsed_cheque = parts[1] if len(parts) > 1 else ""
otherRef = {"compID": parsed_comp_id, "cheque": parsed_cheque}
# --- Specific overrides for OTHER REFERENCE lines ---
payer = "OTHER REFERENCE" # Set Payer Name specifically
payer_overridden_by_cont = True # Mark payer as set by
continuation
# ** DO NOT OVERRIDE recipient here - it comes from the summary
mapping **
except Exception as e:
print(f"Warning: Could not parse 'OTHER REFERENCE': {text}.
Error: {e}")
elif text.upper().startswith("TRN*"):
parts = text.split("*")
if len(parts) >= 3:
trnVal = parts[2].strip()
elif text.upper().startswith("ACH"):
match = re.search(r'ACH[:\s]*(\S+)', text, re.IGNORECASE)
achVal = match.group(1) if match else text
elif text.upper().startswith("RECIPIENT ID:"):
try:
recID = text.split(":", 1)[1].strip()
except IndexError:
print(f"Warning: Could not parse 'RECIPIENT ID': {text}")
elif text.upper().startswith("COMPANY ID:"):
try:
# This continuation specifically sets the Company ID
compID = text.split(":", 1)[1].strip()
except IndexError:
print(f"Warning: Could not parse 'COMPANY ID': {text}")
elif text.upper().startswith("COMPANY NAME:"):
try:
# This continuation specifically sets the Payer Name
name_part = text.split(":", 1)[1].strip()
payer_parts = re.split(r'\s{2,}', name_part)
payer = payer_parts[0].strip() if payer_parts else name_part
payer_overridden_by_cont = True # Mark payer as set by
continuation
except IndexError:
print(f"Warning: Could not parse 'COMPANY NAME': {text}")
elif text.upper().startswith("RECIPIENT NAME:"):
try:
# This continuation specifically sets the Recipient Name
recipient = text.split(":", 1)[1].strip()
recipient_overridden_by_cont = True # Mark recipient as set by
continuation
except IndexError:
print(f"Warning: Could not parse 'RECIPIENT NAME': {text}")
# --- End of continuation parsing ---
# --- Determine Type, Category, Customer Reference ---
type_val = "Unknown"
category = "Unknown"
customerRef = trans.get("customerReferenceNumber", "") # Default to base field
if otherRef is not None:
type_val = "Deposit"
category = "Deposit"
customerRef = otherRef["cheque"] # Use parsed cheque number
# Use compID from OTHER REF only if not already set by a specific COMPANY
ID line
if not compID:
compID = otherRef["compID"]
elif trnVal:
type_val = "ACH"
category = "Insurance Payment"
customerRef = trnVal
elif achVal:
type_val = "ACH"
category = "Insurance Payment"
customerRef = achVal
elif recID:
type_val = "Deposit"
category = "Credit Card"
customerRef = recID
else:
# Fallback based on Type Code
tc = trans.get("typeCode", "")
if tc in ["165", "166", "169", "277", "354"]: type_val = "Deposit";
category = "Deposit"
elif tc in ["469", "475", "503"]: type_val = "ACH"; category = "ACH
Payment"
else:
type_val = "Deposit" if (trans.get("amount", "0")[0] != '-') else
"Withdrawal"
category = "Deposit" if type_val == "Deposit" else "Payment"
# customerRef remains the default
# --- Finalize Company ID ---
# Ensure Company ID has a value, falling back to Customer Ref if still empty
if not compID:
compID = trans.get("customerReferenceNumber", "") # Fallback
# --- Return the final structured data ---
return {
"transaction_type": type_val,
"category": category,
"customer_reference": customerRef,
"company_id": compID,
"payer_name": payer, # Use the final determined payer name
"recipient_name": recipient, # Use the final determined recipient name
"amount": trans.get("amount", "")
}
# --- Helper to extract ERA details for ERA_Detail table ---
def extract_era_details_for_db(claim_data, era_summary):
# (This function remains unchanged from the previous version)
details = []
if not claim_data: return details
def format_era_date(date_str):
if not date_str or not isinstance(date_str, str): return "N/A"
date_str = date_str.strip()
if len(date_str) == 8 and date_str.isdigit(): return
f"{date_str[4:6]}/{date_str[6:8]}/{date_str[0:4]}"
elif len(date_str) == 6 and date_str.isdigit(): return
f"{date_str[2:4]}/{date_str[4:6]}/20{date_str[0:2]}"
return date_str
for claim in claim_data:
clp = claim.get("CLP", {})
claim_details_segments = claim.get("details", [])
patient_name = "N/A"; from_date = "N/A"; to_date = "N/A"
for seg in claim_details_segments:
seg_id = seg.get("Segment")
if seg_id == "NM1" and seg.get("EntityIdentifierCode") == "QC":
last_name = seg.get("NameLastOrOrganizationName", "")
first_name = seg.get("NameFirst", "")
middle_name = seg.get("NameMiddle", "")
name_parts = [part for part in [last_name, first_name, middle_name]
if part]
patient_name = ", ".join(name_parts[:1]) + (" " + "
".join(name_parts[1:]) if len(name_parts)>1 else "")
if not patient_name: patient_name = "N/A"
elif seg_id == "DTM" and seg.get("DateTimeQualifier") == "472":
raw_date = seg.get("DateOrTime", "")
if '-' in raw_date: parts = raw_date.split('-'); from_date =
format_era_date(parts[0]); to_date = format_era_date(parts[1]) if len(parts) > 1
else from_date
elif raw_date: from_date = format_era_date(raw_date); to_date =
from_date
break
acnt = clp.get("ClaimSubmitterIdentifier", "N/A"); icn =
clp.get("PayerClaimControlNumber", "N/A")
billed_raw = clp.get("TotalClaimChargeAmount", "0"); paid_raw =
clp.get("ClaimPaymentAmount", "0")
try: billed_amount = f"${float(billed_raw):,.2f}"
except (ValueError, TypeError): billed_amount = "$0.00"
try: paid_amount = f"${float(paid_raw):,.2f}"
except (ValueError, TypeError): paid_amount = "$0.00"
details.append({"patient_name": patient_name, "account_number": acnt,
"icn": icn, "billed_amount": billed_amount, "paid_amount": paid_amount,
"from_date": from_date, "to_date": to_date})
return details
# --------------------------
# Database Insertion Functions (No change here)
# --------------------------
def insert_into_db_web(parsed_json, file_path):
conn = get_db_connection()
if conn is None:
return False, "Database connection failed."
cur = conn.cursor()
try:
# Inside insert_into_db_web:
summary = parsed_json['file_summary']
file_name = summary.get('File Name', '')
sender_name = summary.get('Sender Name', '')
receiver_name = summary.get('Receiver Name', '')
first_customer_ref = summary.get('Customer Reference', '') # This seems
wrong - should be from summary? No, BAI Detail. Let's assume summary is correct for
now.
deposit_date = summary.get('Deposit Date', '')
receive_date = summary.get('Receive Date', '')
total_amount = summary.get('Total Amount', '') # THIS IS THE KEY
raw_content = parsed_json.get('raw_content', '')
ist_timestamp = datetime.now(timezone.utc) + timedelta(hours=5, minutes=30)
# --- 1. Insert into BAI_Summary ---
cur.execute("""
INSERT INTO BAI_Summary
(file_name, file_path, sender_name, receiver_name, customer_reference,
deposit_date, receive_date, total_amount, raw_content, created_at)
OUTPUT INSERTED.id
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", (
file_name, file_path, sender_name, receiver_name, first_customer_ref,
deposit_date, receive_date, total_amount, raw_content, ist_timestamp #
total_amount IS the 8th parameter
))
summary_id_row = cur.fetchone()
if not summary_id_row:
raise Exception("Failed to retrieve generated summary ID after
insert.")
summary_id = summary_id_row[0]
# --- 2. Insert into BAI_Json ---
details_json_str = json.dumps(parsed_json['data'])
cur.execute("INSERT INTO BAI_Json (bai_summary_id, json_data) VALUES
(?, ?);", (summary_id, details_json_str))
# --- 3. Insert into BAI_Detail ---
summary_for_details = {
"sender_name": sender_name,
"receiver_name": receiver_name,
"receive_date": receive_date
}
if parsed_json['data'] and parsed_json['data'].get("groups"):
for group in parsed_json['data']["groups"]:
for account in group.get("accounts", []):
for trans in account.get("transactions", []):
db_detail_data = extract_bai_details_for_db(trans,
summary_for_details)
# --- NEW: Parse amount and set initial remaining ---
parsed_amt_dec = parse_amount(db_detail_data['amount'])
initial_remaining = parsed_amt_dec if parsed_amt_dec is
not None else Decimal('0.00')
# --- END NEW ---
cur.execute("""
INSERT INTO BAI_Detail
(bai_summary_id, transaction_type, category,
receive_date,
customer_reference, company_id, payer_name,
recipient_name, amount,
-- NEW Fields being inserted --
parsed_amount, reconciled_amount,
remaining_amount, reconciliation_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", (
summary_id,
db_detail_data['transaction_type'],
db_detail_data['category'],
receive_date, # Use summary's receive date
db_detail_data['customer_reference'],
db_detail_data['company_id'],
db_detail_data['payer_name'],
db_detail_data['recipient_name'],
db_detail_data['amount'], # Original string amount
# --- NEW Values ---
parsed_amt_dec, # Parsed decimal amount
Decimal('0.00'), # Initial reconciled amount
initial_remaining, # Initial remaining amount
'No' # Initial status
))
conn.commit()
return True, "Success"
except pyodbc.IntegrityError as ie:
print(f"Database Integrity Error (BAI): {ie}")
conn.rollback()
# Check if the error message indicates a unique constraint violation
# Error codes can vary by DB driver, check specific error message/number
if possible
# Example check (might need adjustment):
if "unique constraint" in str(ie).lower() or "duplicate key" in
str(ie).lower():
return False, "Duplicate record detected based on defined
constraints."
else:
return False, f"Database integrity error. Details: {ie}"
except Exception as e:
error_message = f"Error inserting BAI data into DB: {e}"
print(error_message)
conn.rollback()
return False, error_message
finally:
if cur: cur.close()
if conn: conn.close()
def insert_era_json(era_json, file_name, raw_content, file_path):
conn = get_db_connection()
if conn is None:
return False, "Database connection failed."
cur = conn.cursor()
try:
payer_name = "N/A"; payee_name = "N/A"; payee_id = "N/A"; check_date =
"N/A"; trn_number = "N/A"; amount_str = "N/A"; check_eft = "Unknown"
bpr_record = None; trn_record = None
search_segments = era_json.get("envelope", []) + era_json.get("trailing",
[])
for seg in search_segments:
seg_id = seg.get("Segment")
if seg_id == "N1":
entity_code = seg.get("EntityIdentifierCode")
if entity_code == "PR": payer_name = seg.get("Name", "Unknown
Payer")
elif entity_code == "PE":
payee_name = seg.get("Name", "Unknown Payee")
id_qual = seg.get("IdentificationCodeQualifier"); id_code =
seg.get("IdentificationCode")
if id_qual in ["FI", "XX", "NPI"]: payee_id = id_code if
id_code else "N/A"
elif seg_id == "BPR" and bpr_record is None: bpr_record = seg
elif seg_id == "TRN" and trn_record is None: trn_record = seg
if bpr_record:
raw_date = bpr_record.get("Date", "")
if len(raw_date) == 8 and raw_date.isdigit(): check_date =
f"{raw_date[4:6]}/{raw_date[6:8]}/{raw_date[0:4]}"
elif len(raw_date) == 6 and raw_date.isdigit(): check_date =
f"{raw_date[2:4]}/{raw_date[4:6]}/20{raw_date[0:2]}"
else: check_date = raw_date
amt = bpr_record.get("MonetaryAmount", "");
try: amount_str = f"${float(amt):,.2f}"
except (ValueError, TypeError): amount_str = amt
payment_method = bpr_record.get("PaymentMethod", "")
if payment_method == "CHK": check_eft = "CHK"
elif payment_method in ["ACH", "FWT", "NON"]: check_eft = "EFT"
else: clpCount = raw_content.count("CLP*") if raw_content else 0;
check_eft = "CHK" if clpCount > 1 else "EFT"
if trn_record: trn_number = trn_record.get("TraceNumber", "N/A")
ist_timestamp = datetime.now(timezone.utc) + timedelta(hours=5, minutes=30)
# --- 1. Insert into ERA_Summary ---
cur.execute("""
INSERT INTO ERA_Summary
(file_name, file_path, payer_name, payee_name, payee_id, check_date,
trn_number, amount, check_eft, raw_content, uploaded_at)
OUTPUT INSERTED.id
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", (
file_name, file_path, payer_name, payee_name, payee_id, check_date,
trn_number, amount_str, check_eft, raw_content, ist_timestamp
))
summary_id_row = cur.fetchone()
if not summary_id_row:
raise Exception("Failed to retrieve generated ERA summary ID.")
summary_id = summary_id_row[0]
# --- 2. Insert into ERA_Json ---
json_data_str = json.dumps(era_json)
cur.execute("INSERT INTO ERA_Json (era_summary_id, json_data) VALUES
(?, ?);", (summary_id, json_data_str))
# --- 3. Insert into ERA_Detail ---
era_summary_for_details = {"check_date": check_date}
db_detail_list = extract_era_details_for_db(era_json.get("claims", []),
era_summary_for_details)
for detail_row in db_detail_list:
cur.execute("""
INSERT INTO ERA_Detail
(era_summary_id, patient_name, account_number, icn,
billed_amount, paid_amount, from_date, to_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
""", (
summary_id,
detail_row['patient_name'],
detail_row['account_number'],
detail_row['icn'],
detail_row['billed_amount'],
detail_row['paid_amount'],
detail_row['from_date'],
detail_row['to_date']
))
conn.commit()
return True, "Success"
except pyodbc.IntegrityError as ie:
print(f"Database Integrity Error (ERA): {ie}")
conn.rollback()
if "unique constraint" in str(ie).lower() or "duplicate key" in
str(ie).lower():
return False, "Duplicate record detected based on defined
constraints."
else:
return False, f"Database integrity error. Details: {ie}"
except Exception as e:
error_message = f"Error inserting ERA data into DB: {e}"
print(error_message)
conn.rollback()
return False, error_message
finally:
if cur: cur.close()
if conn: conn.close()
# --------------------------
# Flask API Endpoints
# --------------------------
@app.route("/")
def index():
# If user is already logged in, send them to the app, otherwise to login
if 'user_id' in session:
return redirect(url_for('serve_main_app'))
return redirect(url_for('login'))
@app.route('/app') # Or choose a different path like '/main'
def serve_main_app():
user_id = session.get('user_id') # Get user ID first
if not user_id:
flash('Please log in to access the application.', 'warning')
# Log the attempt before redirecting (Protection block already logs this)
# log_security_event(action='Access Denied', status='Attempt',
details='Attempted access to /app without login')
return redirect(url_for('login'))
# User is logged in, proceed to render the main application page
# Log successful access (already done in the original code, keep it)
log_security_event(action='View Main App', status='Success', user_id=user_id)
return render_template("index.html")
@app.route("/favicon.ico")
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static', 'assets'),
'logo.png', mimetype='image/png')
# app.py: Add near the end, before the __main__ block
# --- NEW: Dashboard Data Endpoint ---
# app.py: Replace the entire get_dashboard_data function
@app.route("/dashboard_data", methods=["GET"])
def get_dashboard_data():
# --- Add this block at the start of existing app routes ---
user_id = session.get('user_id')
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
from_date_str = request.args.get('from_date') # Expect 'YYYY-MM'
to_date_str = request.args.get('to_date') # Expect 'YYYY-MM'
filter_details = f"Filters: From={from_date_str or 'N/A'}, To={to_date_str or
'N/A'}"
# Log the attempt to view dashboard data
log_security_event(action='View Dashboard Data', status='Attempt',
user_id=user_id, details=filter_details)
# --- ADD THIS LINE ---
user_id = session.get('user_id') # Get user ID *after* successful check
log_security_event(action='View Dashboard Data', status='Success',
user_id=user_id, details=f"Filters: from={request.args.get('from_date')},
to={request.args.get('to_date')}")
# --- END ADD THIS LINE ---
conn = None
cur = None
from_date_str = request.args.get('from_date') # Expect 'YYYY-MM'
to_date_str = request.args.get('to_date') # Expect 'YYYY-MM'
start_date_param = None
end_date_param = None
filter_active = False
filter_params = [] # Parameters for SQL query
# --- Date Filter Processing ---
if from_date_str and to_date_str:
try:
from_year, from_month = map(int, from_date_str.split('-'))
to_year, to_month = map(int, to_date_str.split('-'))
start_date_param = datetime(from_year, from_month, 1) # Use datetime
object for range
last_day = monthrange(to_year, to_month)[1]
# Ensure end date covers the entire last day of the month for BETWEEN
comparison
end_date_param = datetime(to_year, to_month, last_day, 23, 59, 59)
if start_date_param <= end_date_param:
filter_active = True
# Parameters for SQL BETWEEN clause (using full datetime for end)
filter_params = [start_date_param.date(), end_date_param.date()] #
Pass dates for comparison
print(f"Filtering BAI/ERA data from {start_date_param.date()} to
{end_date_param.date()}")
else:
print("Warning: Invalid date range provided (From > To). Ignoring
filter.")
except ValueError:
print(f"Warning: Invalid date format received: {from_date_str},
{to_date_str}. Ignoring filter.")
try:
conn = get_db_connection()
if conn is None:
return jsonify({"error": "Database connection failed."}), 500
cur = conn.cursor()
# --- Date Conversion SQL Snippets ---
# Assume dates are stored as MM/DD/YYYY strings based on insert logic
bai_date_conversion = "TRY_CONVERT(DATE, bs.deposit_date, 101)" # Style 101
for MM/DD/YYYY
era_date_conversion = "TRY_CONVERT(DATE, es.check_date, 101)" # Style 101
for MM/DD/YYYY
# --- BAI Data Aggregation (Filtered or All Time for card) ---
bai_summary_filter_clause = ""
bai_detail_filter_params = []
if filter_active:
# Filter for BAI Detail based on BAI Summary date
bai_summary_filter_clause = f" WHERE {bai_date_conversion} >= ? AND
{bai_date_conversion} <= ? "
bai_detail_filter_params = filter_params
# Step 1: Get counts and reconciled amount from BAI_Detail (Filtered)
detail_query = f"""
SELECT
SUM(CAST(ISNULL(bd.reconciled_amount, 0) AS DECIMAL(38, 2))) as
total_reconciled_bai_amount,
COUNT(bd.id) as total_bai_details,
SUM(CASE WHEN bd.reconciliation_status = 'Yes' THEN 1 ELSE 0 END)
as reconciled_bai_details,
SUM(CASE WHEN bd.reconciliation_status != 'Yes' THEN 1 ELSE 0 END)
as non_reconciled_bai_details
FROM BAI_Detail bd
JOIN BAI_Summary bs ON bd.bai_summary_id = bs.id
{bai_summary_filter_clause}; -- Apply filter here
"""
cur.execute(detail_query, bai_detail_filter_params) # Use correct params
detail_result = cur.fetchone()
total_reconciled_bai_amount = detail_result[0] if detail_result and
detail_result[0] is not None else Decimal('0.00')
total_bai_details = detail_result[1] if detail_result and detail_result[1]
is not None else 0
reconciled_bai_details = detail_result[2] if detail_result and
detail_result[2] is not None else 0
non_reconciled_bai_details = detail_result[3] if detail_result and
detail_result[3] is not None else 0
# Step 2: Get and sum 'total_amount' from BAI_Summary (Filtered)
summary_query = f"SELECT total_amount FROM BAI_Summary bs
{bai_summary_filter_clause};"
cur.execute(summary_query, bai_detail_filter_params) # Use correct params
summary_rows = cur.fetchall()
total_bai_summary_amount = Decimal('0.00')
for row in summary_rows:
parsed_summary_amount = parse_amount(row[0])
if parsed_summary_amount is not None: total_bai_summary_amount +=
parsed_summary_amount
# Step 3 & 4: Combine BAI data for the card
bai_data = {
"total_amount": total_bai_summary_amount, "reconciled_amount":
total_reconciled_bai_amount,
"total_details": total_bai_details, "reconciled_details":
reconciled_bai_details,
"non_reconciled_details": non_reconciled_bai_details, "filter_active":
filter_active,
"filter_from": from_date_str if filter_active else None, "filter_to":
to_date_str if filter_active else None
}
bai_data["non_reconciled_amount"] = bai_data["total_amount"] -
bai_data["reconciled_amount"]
if bai_data["non_reconciled_amount"] < 0: bai_data["non_reconciled_amount"]
= Decimal('0.00')
# --- ERA Data Aggregation (Always All Time for the card) ---
# (No changes to this part - ERA card remains all-time)
cur.execute("SELECT id, amount FROM ERA_Summary;")
era_summary_rows_all = cur.fetchall()
total_era_summaries_all = len(era_summary_rows_all)
total_era_amount_all = Decimal('0.00')
for row in era_summary_rows_all:
parsed = parse_amount(row[1]);
if parsed is not None: total_era_amount_all += parsed
cur.execute("""
SELECT COUNT(DISTINCT era_summary_id) as reconciled_era_count,
SUM(CAST(ISNULL(amount_reconciled, 0) AS DECIMAL(38, 2))) as
total_reconciled_era_amount
FROM Reconciliation;
""")
recon_era_result = cur.fetchone()
reconciled_era_count_all = recon_era_result[0] if recon_era_result and
recon_era_result[0] is not None else 0
total_reconciled_era_amount_all = recon_era_result[1] if recon_era_result
and recon_era_result[1] is not None else Decimal('0.00')
non_reconciled_era_count_all = total_era_summaries_all -
reconciled_era_count_all
era_data = {
"total_amount": total_era_amount_all, "reconciled_amount":
total_reconciled_era_amount_all,
"total_summaries": total_era_summaries_all, "reconciled_summaries":
reconciled_era_count_all,
"non_reconciled_summaries": non_reconciled_era_count_all,
}
era_data["non_reconciled_amount"] = era_data["total_amount"] -
era_data["reconciled_amount"]
if era_data["non_reconciled_amount"] < 0: era_data["non_reconciled_amount"]
= Decimal('0.00')
# --- *** NEW: Monthly Breakdown Data (Only if filter is active) *** ---
monthly_breakdown_data = None
if filter_active:
monthly_data_dict = defaultdict(lambda: {
"total_bai": 0, "reconciled_bai": 0, "total_era": 0,
"reconciled_era": 0,
"bai_recon_dates": "", "era_recon_dates": "" # Store aggregated
dates as string
})
# Query 1: Monthly BAI Counts and Dates (Using STRING_AGG)
bai_monthly_query = f"""
WITH FilteredBAI AS (
SELECT bs.id, {bai_date_conversion} AS converted_date
FROM BAI_Summary bs
WHERE {bai_date_conversion} >= ? AND {bai_date_conversion} <= ?
-- Use DATE comparison
AND {bai_date_conversion} IS NOT NULL
), ReconDatesBAI AS (
SELECT DISTINCT bd.bai_summary_id,
FORMAT(r.reconciliation_date, 'MM/dd/yy') as recon_date_str
FROM Reconciliation r
JOIN BAI_Detail bd ON r.bai_detail_id = bd.id
JOIN BAI_Summary bs_inner ON bd.bai_summary_id = bs_inner.id --
Join back to summary to filter dates
WHERE {bai_date_conversion.replace('bs.','bs_inner.')} >= ? AND
{bai_date_conversion.replace('bs.','bs_inner.')} <= ?
)
SELECT
FORMAT(fb.converted_date, 'yyyy-MM') AS month_year,
COUNT(DISTINCT fb.id) AS total_bai_files,
COUNT(DISTINCT rdb.bai_summary_id) AS reconciled_bai_files,
STRING_AGG(rdb.recon_date_str, ', ') WITHIN GROUP (ORDER BY
rdb.recon_date_str) AS bai_dates
FROM FilteredBAI fb
LEFT JOIN ReconDatesBAI rdb ON fb.id = rdb.bai_summary_id
GROUP BY FORMAT(fb.converted_date, 'yyyy-MM');
"""
# Execute with date params (passed 4 times: twice for FilteredBAI,
twice for ReconDatesBAI filter)
cur.execute(bai_monthly_query, filter_params + filter_params)
for row in cur.fetchall():
month_year, total, reconciled, dates = row
monthly_data_dict[month_year]["total_bai"] = total
monthly_data_dict[month_year]["reconciled_bai"] = reconciled
monthly_data_dict[month_year]["bai_recon_dates"] = dates or "" #
Store dates string
# Query 2: Monthly ERA Counts and Dates (Using STRING_AGG)
era_monthly_query = f"""
WITH FilteredERA AS (
SELECT es.id, {era_date_conversion} AS converted_date
FROM ERA_Summary es
WHERE {era_date_conversion} >= ? AND {era_date_conversion} <= ?
AND {era_date_conversion} IS NOT NULL
), ReconDatesERA AS (
SELECT DISTINCT r.era_summary_id, FORMAT(r.reconciliation_date,
'MM/dd/yy') as recon_date_str
FROM Reconciliation r
JOIN ERA_Summary es_inner ON r.era_summary_id = es_inner.id --
Join back to summary to filter dates
WHERE {era_date_conversion.replace('es.','es_inner.')} >= ? AND
{era_date_conversion.replace('es.','es_inner.')} <= ?
)
SELECT
FORMAT(fe.converted_date, 'yyyy-MM') AS month_year,
COUNT(DISTINCT fe.id) AS total_era_files,
COUNT(DISTINCT rde.era_summary_id) AS reconciled_era_files,
STRING_AGG(rde.recon_date_str, ', ') WITHIN GROUP (ORDER BY
rde.recon_date_str) AS era_dates
FROM FilteredERA fe
LEFT JOIN ReconDatesERA rde ON fe.id = rde.era_summary_id
GROUP BY FORMAT(fe.converted_date, 'yyyy-MM');
"""
# Execute with date params (passed 4 times)
cur.execute(era_monthly_query, filter_params + filter_params)
for row in cur.fetchall():
month_year, total, reconciled, dates = row
# Update the dictionary entry for this month
monthly_data_dict[month_year]["total_era"] = total
monthly_data_dict[month_year]["reconciled_era"] = reconciled
monthly_data_dict[month_year]["era_recon_dates"] = dates or "" #
Store dates string
# Convert defaultdict to list of dicts, sorted by month
monthly_breakdown_data = [{"month_year": k, **v} for k, v in
sorted(monthly_data_dict.items())]
# --- *** END Monthly Breakdown Data *** ---
# --- Construct Final Response ---
response_data = { "bai": bai_data, "era": era_data }
if monthly_breakdown_data is not None:
response_data["monthly_breakdown"] = monthly_breakdown_data
log_security_event(action='View Dashboard Data', status='Success',
user_id=user_id, details=f"{filter_details} - Data fetched successfully.")
return jsonify(response_data)
except Exception as e:
# Log general errors during data fetching
log_security_event(action='View Dashboard Data', status='Error',
user_id=user_id, details=f"{filter_details} - Error fetching data: {e}")
print(f"Error fetching dashboard data: {e}")
print(f"Error fetching dashboard data: {e}")
import traceback
print(traceback.format_exc()) # Print full stack trace for debugging
# Try to get a more specific DB error message if available
db_error_msg = str(e);
if hasattr(e, 'args') and len(e.args) > 1: db_error_msg = f"({e.args[0]})
{e.args[1]}"
return jsonify({"error": f"Error fetching dashboard data:
{db_error_msg}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- END NEW Dashboard Data Endpoint ---
# Make sure to import Decimal at the top if you haven't already
# from decimal import Decimal
# --- MODIFIED: upload_bai with duplicate check ---
@app.route("/upload_bai", methods=["POST"])
def upload_bai():
# --- Add this block at the start of existing app routes ---
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
original_filename = request.files.get('file').filename if 'file' in
request.files else 'No file'
log_details_base = f"Filename: '{original_filename}'"
log_security_event(action='Upload BAI File', status='Attempt', user_id=user_id,
details=log_details_base)
# ... (file checking, reading, parsing - same as previous correct version) ...
if 'file' not in request.files: return jsonify({"error": "No file part
provided."}), 400
file = request.files['file']
original_filename = file.filename
if original_filename == '': return jsonify({"error": "No file selected."}), 400
base_filename = os.path.splitext(original_filename)[0]
secure_save_filename = secure_filename(original_filename)
save_path = os.path.join(BAI_UPLOAD_FOLDER, secure_save_filename)
try:
content_bytes = file.read(); file.seek(0)
try: content = content_bytes.decode("utf-8")
except UnicodeDecodeError:
try: content = content_bytes.decode("latin-1")
except Exception as decode_err: return jsonify({"error": f"File decode
error: {decode_err}"}), 400
except Exception as e: return jsonify({"error": f"Error reading file content:
{e}"}), 400
lines = content.splitlines(); bai_parser = BAIParser()
try: parsed_data = bai_parser.parse(lines); print(f"Parsed BAI Data Structure
Keys: {list(parsed_data.keys())}")
except Exception as e: print(f"Error during BAI parsing: {e}"); return
jsonify({"error": f"Error parsing BAI file content: {e}"}), 400
file_header = parsed_data.get("fileHeader", {}); groups =
parsed_data.get("groups", [])
first_customer_ref = ""
if groups and isinstance(groups, list) and len(groups) > 0:
first_group = groups[0]
if isinstance(first_group, dict) and first_group.get("accounts"):
accounts = first_group["accounts"]
if isinstance(accounts, list) and len(accounts) > 0:
first_account = accounts[0]
if isinstance(first_account, dict) and
first_account.get("transactions"):
transactions = first_account["transactions"]
if isinstance(transactions, list) and len(transactions) > 0:
first_transaction = transactions[0]
if isinstance(first_transaction, dict): first_customer_ref
= first_transaction.get("customerReferenceNumber", "")
print(f"Extracted for Duplicate Check - Filename: '{base_filename}', Customer
Ref: '{first_customer_ref}'")
# ... (Duplicate Check - same as previous correct version) ...
conn_check = None; cur_check = None; is_duplicate = False
try:
conn_check = get_db_connection()
if conn_check:
cur_check = conn_check.cursor(); cur_check.execute("SELECT id FROM
BAI_Summary WHERE file_name = ? AND customer_reference = ?", (base_filename,
first_customer_ref))
if cur_check.fetchone() is not None: is_duplicate = True;
print(f"Duplicate Found - Filename: '{base_filename}', Customer Ref:
'{first_customer_ref}'")
else: return jsonify({"error": "Database connection unavailable for
duplicate check."}), 503
except Exception as e: print(f"Error during BAI duplicate check: {e}"); return
jsonify({"error": f"Database error during duplicate check: {e}"}), 500
finally:
if cur_check: cur_check.close()
if conn_check: conn_check.close()
if is_duplicate: return jsonify({"error": f"Duplicate BAI file detected
(Filename: '{base_filename}', Customer Ref: '{first_customer_ref}'). Upload
aborted."}), 409
print("Duplicate check passed. Proceeding to save and process.")
if is_duplicate:
error_msg = f"Duplicate BAI file detected (Filename: '{base_filename}',
Customer Ref: '{first_customer_ref}'). Upload aborted."
log_security_event(action='Upload BAI File', status='Failure',
user_id=user_id, details=f"{log_details_base} - {error_msg}")
return jsonify({"error": error_msg}), 409
# ... (Save File - same as previous correct version) ...
try:
with open(save_path, 'wb') as f_save: f_save.write(content_bytes);
print(f"File saved successfully to: {save_path}")
except Exception as e: print(f"Error saving file '{secure_save_filename}' after
duplicate check: {e}"); return jsonify({"error": f"Error saving file: {e}"}), 500
# --- Prepare data for insertion ---
try:
with open("config.json", "r") as f: config_data = json.load(f)
except Exception as e: config_data = {"sender_map": {}, "receiver_map": {}};
print(f"Warning: Could not load config.json - {e}")
sender_id = file_header.get("senderId", ""); receiver_id =
file_header.get("receiverId", "")
sender_name = config_data.get("sender_map", {}).get(sender_id, sender_id or
"Unknown Sender")
receiver_name = config_data.get("receiver_map", {}).get(receiver_id,
receiver_id or "Unknown Receiver")
deposit_date_raw = file_header.get("fileCreationDate", ""); receive_date_raw =
groups[0].get("asOfDate", "") if groups else ""
deposit_date = format_bai_date(deposit_date_raw); receive_date =
format_bai_date(receive_date_raw)
# --- Recalculate total amount (CORRECTED CENTS CONVERSION) ---
total_amount_str = ""
ending_balance_raw = None
found_code = None
print("Attempting to find closing balance in parsed data...")
if groups and isinstance(groups, list) and len(groups) > 0:
for group in groups:
if isinstance(group, dict) and group.get("accounts"):
for acc in group.get("accounts", []):
if isinstance(acc, dict) and acc.get("typeDetails"):
print(f"Checking account {acc.get('accountNumber', 'N/A')}
for balance codes...")
for type_detail in acc.get("typeDetails", []):
if isinstance(type_detail, dict):
code = type_detail.get("typeCode")
amount = type_detail.get("amount") # This is the
raw string from BAI file
print(f" Found typeCode: {code}, Raw Amount
String: {amount}") # Debug raw value
if code == "060":
ending_balance_raw = amount
found_code = code
print(f" *** Found target balance code
{found_code} with raw amount {ending_balance_raw} ***")
break # Found preferred code (060)
elif code == "040" and ending_balance_raw is None:
# Only use 040 if 060 not yet found
ending_balance_raw = amount
found_code = code
print(f" * Found fallback balance code
{found_code} with raw amount {ending_balance_raw} *")
if found_code == "060": break
if found_code == "060": break
if ending_balance_raw is not None:
print(f"Raw ending balance found (Code {found_code}):
'{ending_balance_raw}'")
try:
# *** THIS IS THE CORRECTED PART ***
# The raw amount is in cents, treat it as an integer string first
cents_int = int(str(ending_balance_raw).strip())
# Convert cents to dollars using Decimal for precision
balance_decimal = Decimal(cents_int) / Decimal(100)
# Format as currency string: $XXX,XXX.XX
total_amount_str = f"${balance_decimal:,.2f}"
# *** END OF CORRECTION ***
print(f"Successfully formatted total amount: {total_amount_str}")
except (ValueError, TypeError, InvalidOperation) as conv_err:
# ValueError if ending_balance_raw isn't a valid integer string
# InvalidOperation from Decimal if something unexpected happens
print(f"Warning: Could not convert raw balance '{ending_balance_raw}'
to integer cents or format as amount: {conv_err}")
total_amount_str = "" # Set to empty if conversion fails
except Exception as e:
print(f"Error processing ending balance '{ending_balance_raw}': {e}")
total_amount_str = ""
else:
print("Ending balance code (060 or 040) not found in account type
details.")
summary_data = {
"File Name": base_filename, "Sender Name": sender_name, "Receiver Name":
receiver_name,
"Customer Reference": first_customer_ref, "Deposit Date": deposit_date,
"Receive Date": receive_date, "Total Amount": total_amount_str
}
print(f"Data prepared for DB insertion: {summary_data}")
final_result_for_db = { "file_summary": summary_data, "data": parsed_data,
"raw_content": content }
# --- Insert into DB ---
insert_success, message = insert_into_db_web(final_result_for_db, save_path)
if insert_success:
success_details = f"{log_details_base} - Saved as '{secure_save_filename}'.
Processed successfully."
log_security_event(action='Upload BAI File', status='Success',
user_id=user_id, details=success_details)
return jsonify({"message": f"File '{original_filename}' uploaded, saved as
'{secure_save_filename}', and processed successfully."})
else:
failure_details = f"{log_details_base} - Error during DB insertion:
{message}"
log_security_event(action='Upload BAI File', status='Error',
user_id=user_id, details=failure_details)
return jsonify({"error": f"File processing failed during database
insertion: {message}"}), 500
# --- MODIFIED: upload_era with duplicate check ---
@app.route("/upload_era", methods=["POST"])
def upload_era():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
original_filename = request.files.get('file').filename if 'file' in
request.files else 'No file'
log_details_base = f"Filename: '{original_filename}'"
log_security_event(action='Upload ERA File', status='Attempt', user_id=user_id,
details=log_details_base)
if 'file' not in request.files:
return jsonify({"error": "No file part provided."}), 400
file = request.files['file']
original_filename = file.filename
if original_filename == '':
return jsonify({"error": "No file selected."}), 400
# --- Secure filename and prepare base name ---
base_filename = os.path.splitext(original_filename)[0]
secure_save_filename = secure_filename(original_filename) # Ensure filename is
safe
save_path = os.path.join(ERA_UPLOAD_FOLDER, secure_save_filename)
# --- Read content FIRST ---
try:
content_bytes = file.read()
file.seek(0)
try:
content = content_bytes.decode("utf-8")
except UnicodeDecodeError:
try:
content = content_bytes.decode("latin-1")
except Exception as decode_err:
return jsonify({"error": f"File decode error: {decode_err}"}), 400
except Exception as e:
return jsonify({"error": f"Error reading file content: {e}"}), 400
# --- Parse ERA ---
try:
era_json = convert_edi_content_to_json(content)
except Exception as e:
print(f"EDI Parsing Error: {e}")
return jsonify({"error": f"Error parsing EDI file content: {e}"}), 400
# --- Extract Key Fields for Duplicate Check ---
trn_number = "N/A"
check_date = "N/A"
bpr_record = None
trn_record = None
search_segments = era_json.get("envelope", []) + era_json.get("trailing", [])
for seg in search_segments:
seg_id = seg.get("Segment")
if seg_id == "BPR" and bpr_record is None:
bpr_record = seg
elif seg_id == "TRN" and trn_record is None:
trn_record = seg
if bpr_record:
raw_date = bpr_record.get("Date", "")
if len(raw_date) == 8 and raw_date.isdigit():
check_date = f"{raw_date[4:6]}/{raw_date[6:8]}/{raw_date[0:4]}"
elif len(raw_date) == 6 and raw_date.isdigit():
check_date = f"{raw_date[2:4]}/{raw_date[4:6]}/20{raw_date[0:2]}"
else:
check_date = raw_date
if trn_record:
trn_number = trn_record.get("TraceNumber", "N/A")
duplicate_check_keys = f"Filename: '{base_filename}', TRN: '{trn_number}',
Date: '{check_date}'"
# --- PERFORM DUPLICATE CHECK ---
conn_check = None
cur_check = None
is_duplicate = False
try:
conn_check = get_db_connection()
if conn_check:
cur_check = conn_check.cursor()
# Check against base_filename, trn_number, and check_date in
ERA_Summary
cur_check.execute(
"SELECT id FROM ERA_Summary WHERE file_name = ? AND trn_number = ?
AND check_date = ?",
(base_filename, trn_number, check_date) # Use base_filename
)
if cur_check.fetchone() is not None:
is_duplicate = True
else:
return jsonify({"error": "Database connection unavailable for
duplicate check."}), 503
except Exception as e:
print(f"Error during ERA duplicate check: {e}")
return jsonify({"error": f"Database error during duplicate check: {e}"}),
500
finally:
if cur_check: cur_check.close()
if conn_check: conn_check.close()
if is_duplicate:
error_msg = f"Duplicate ERA file detected ({duplicate_check_keys}). Upload
aborted."
log_security_event(action='Upload ERA File', status='Failure',
user_id=user_id, details=f"{log_details_base} - {error_msg}")
return jsonify({"error": f"Duplicate ERA file detected (Filename:
'{base_filename}', TRN: '{trn_number}', Date: '{check_date}'). Upload aborted."}),
409
# --- DUPLICATE CHECK PASSED - NOW SAVE FILE ---
try:
with open(save_path, 'wb') as f_save:
f_save.write(content_bytes) # Save original bytes
except Exception as e:
print(f"Error saving file '{secure_save_filename}' after duplicate check:
{e}")
return jsonify({"error": f"Error saving file: {e}"}), 500
# --- Insert into DB ---
# Pass base_filename for storage in DB, but secure_save_filename for the path
insert_success, message = insert_era_json(era_json, base_filename, content,
save_path)
if insert_success:
success_details = f"{log_details_base} - Saved as '{secure_save_filename}'.
Processed successfully."
log_security_event(action='Upload ERA File', status='Success',
user_id=user_id, details=success_details)
return jsonify({"message": f"ERA file '{original_filename}' uploaded, saved
as '{secure_save_filename}', and processed successfully."})
else:
failure_details = f"{log_details_base} - Error during DB insertion:
{message}"
log_security_event(action='Upload ERA File', status='Error',
user_id=user_id, details=failure_details)
return jsonify({"error": f"ERA file processing failed during database
insertion: {message}"}), 500
# --- MODIFIED: get_files to query BAI_Summary ---
@app.route("/files", methods=["GET"])
def get_files():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_security_event(action='View BAI Files List', status='Attempt',
user_id=user_id) # Log attempt
conn = get_db_connection()
if conn is None:
log_security_event(action='View BAI Files List', status='Error',
user_id=user_id, details="Database connection error.")
return jsonify({"error": "Database connection error."}), 500
cur = conn.cursor()
try:
# Fetch summary data along with counts for reconciliation status
determination
cur.execute("""
SELECT
bs.id, bs.file_name, bs.sender_name, bs.receiver_name,
bs.deposit_date, bs.receive_date, bs.total_amount,
COUNT(bd.id) as total_details, -- Total relevant details
COUNT(r.id) as reconciled_details -- Count of reconciled details
for this summary
FROM BAI_Summary bs
LEFT JOIN BAI_Detail bd ON bs.id = bd.bai_summary_id AND
bd.customer_reference IS NOT NULL AND bd.amount IS NOT NULL -- Count only details
that *could* be reconciled
LEFT JOIN Reconciliation r ON bd.id = r.bai_detail_id
GROUP BY bs.id, bs.file_name, bs.sender_name, bs.receiver_name,
bs.deposit_date, bs.receive_date, bs.total_amount
ORDER BY bs.id DESC;
""")
rows = cur.fetchall()
files_list = []
columns = [column[0] for column in cur.description]
for row in rows:
file_data = dict(zip(columns, row))
# Determine simple file-level status based on counts
total_details = file_data.get('total_details', 0)
reconciled_details = file_data.get('reconciled_details', 0)
status = "N/A" # Default if no details to reconcile
if total_details > 0:
if reconciled_details == 0:
status = "NO"
elif reconciled_details < total_details:
status = "PARTIAL"
else: # reconciled_details == total_details
status = "YES"
file_data['reconcileStatus'] = status # Add calculated status
# Remove count columns before sending to frontend if not needed there
del file_data['total_details']
del file_data['reconciled_details']
files_list.append(file_data)
# Map keys for frontend compatibility if needed (e.g., sender_name ->
sender)
for file in files_list:
file['sender'] = file.pop('sender_name', None)
file['receiver'] = file.pop('receiver_name', None)
log_security_event(action='View BAI Files List', status='Success',
user_id=user_id, details=f"Fetched {len(files_list)} BAI summaries.") # Log success
return jsonify(files_list)
except Exception as e:
log_security_event(action='View BAI Files List', status='Error',
user_id=user_id, details=f"Error retrieving BAI records: {e}") # Log error
print(f"Error retrieving BAI summary records: {e}")
return jsonify({"error": f"Error retrieving BAI records: {e}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- MODIFIED: get_file_details to fetch from BAI_Summary and BAI_Detail ---
# --- MODIFIED: get_file_details to include reconciliation status per detail ---
@app.route("/files/<int:file_id>", methods=["GET"])
def get_file_details(file_id):
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_details_base = f"BAI File ID: {file_id}"
log_security_event(action='View BAI File Details', status='Attempt',
user_id=user_id, details=log_details_base)
conn = get_db_connection()
if conn is None:
log_security_event(action='View BAI File Details', status='Error',
user_id=user_id, details=f"{log_details_base} - Database connection error.")
return jsonify({"error": "Database connection error."}), 500
cur = conn.cursor()
try:
# --- 1. Fetch Summary (no change here) ---
cur.execute("SELECT file_name, sender_name, receiver_name, deposit_date,
receive_date, total_amount, raw_content, file_path FROM BAI_Summary WHERE id = ?;",
(file_id,))
summary_result = cur.fetchone()
if not summary_result:
log_security_event(action='View BAI File Details', status='Failure',
user_id=user_id, details=f"{log_details_base} - Record not found.")
return jsonify({"error": "BAI record not found."}), 404
summary_data = {
"file_name": summary_result[0], "sender_name": summary_result[1],
"receiver_name": summary_result[2],
"deposit_date": summary_result[3], "receive_date": summary_result[4],
"total_amount": summary_result[5],
"raw_content": summary_result[6], "file_path": summary_result[7]
}
# --- 2. Fetch Details with Reconciliation Status ---
cur.execute("""
SELECT
bd.transaction_type, bd.category, bd.receive_date,
bd.customer_reference,
bd.company_id, bd.payer_name, bd.recipient_name, bd.amount,
CASE WHEN r.id IS NOT NULL THEN 'Yes' ELSE 'No' END AS
reconcile_status
FROM BAI_Detail bd
LEFT JOIN Reconciliation r ON bd.id = r.bai_detail_id
WHERE bd.bai_summary_id = ?
ORDER BY bd.id;
""", (file_id,))
detail_rows = cur.fetchall()
details_list = []
detail_columns = [column[0] for column in cur.description]
for row in detail_rows:
details_list.append(dict(zip(detail_columns, row)))
log_security_event(action='View BAI File Details', status='Success',
user_id=user_id, details=f"{log_details_base} - Details fetched successfully.")
return jsonify({"summary": summary_data, "details": details_list})
except Exception as e:
log_security_event(action='View BAI File Details', status='Error',
user_id=user_id, details=f"{log_details_base} - Error retrieving details: {e}")
print(f"Error retrieving BAI details for id {file_id}: {e}")
return jsonify({"error": f"Error retrieving BAI file details: {e}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- MODIFIED: export_excel (remains unchanged structurally) ---
@app.route("/export_excel/<int:file_id>")
def export_excel(file_id):
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# ... (rest of protection block) ...
# Since this is a file download, redirect or JSON error might be less
ideal.
# Flashing a message and redirecting to login is probably best here.
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_details_base = f"BAI File ID: {file_id}"
log_security_event(action='Export BAI to Excel', status='Attempt',
user_id=user_id, details=log_details_base)
summary = get_file_summary(file_id)
if not summary:
log_security_event(action='Export BAI to Excel', status='Failure',
user_id=user_id, details=f"{log_details_base} - Summary info not found.")
return jsonify({"error": f"Summary info not found for file ID
{file_id}"}), 404
log_details_base = f"BAI File ID: {file_id}, Filename:
'{summary.get('file_name', 'N/A')}'" #
conn = get_db_connection()
if conn is None:
return jsonify({"error": "Database connection error."}), 500
cur = conn.cursor()
try:
# Fetch the original JSON data from BAI_Json
cur.execute("SELECT json_data FROM BAI_Json WHERE bai_summary_id = ?;",
(file_id,))
res = cur.fetchone()
details_json = None
if res and res[0]:
try:
details_json = json.loads(res[0])
except json.JSONDecodeError as json_err:
print(f"Error decoding JSON for Excel, file_id {file_id}:
{json_err}")
return jsonify({"error": f"Corrupted JSON data found for record
{file_id}."}), 500
else:
return jsonify({"error": f"No detailed JSON found for file ID
{file_id}."}), 404
workbook = openpyxl.Workbook()
sheet = workbook.active
headers = ["Type", "Category", "Receive Date", "Customer Reference No.",
"Company ID (Payer ID)", "Payer Name", "Recipient Name", "Amount"]
sheet.append(headers)
def extract_transaction_details_for_excel(trans, current_summary):
# (This internal helper function remains unchanged)
trnVal = ""
achVal = ""
otherRef = None
recID = ""
compID = ""
payer = current_summary.get('sender_name', "Unknown")
recipient = current_summary.get('receiver_name', "Unknown")
raw_continuations = trans.get("continuations", [])
if raw_continuations:
for cont in raw_continuations:
text = cont.get("text", "").strip();
if not text: continue
if text.upper().startswith("OTHER REFERENCE:"):
try:
ref_part = text.split(":", 1)[1].strip(); parts =
ref_part.split(None, 1)
if len(parts) >= 2: otherRef = {"compID": parts[0],
"cheque": parts[1]}
elif len(parts) == 1: otherRef = {"compID": "",
"cheque": parts[0]}
payer = "OTHER REFERENCE"; recipient = "FL Simonmed
Imaging FL LLC" # Apply overrides
except IndexError: print(f"Warning: Could not parse 'OTHER
REFERENCE': {text}")
elif text.upper().startswith("TRN*"):
parts = text.split("*");
if len(parts) >= 3: trnVal = parts[2].strip()
elif text.upper().startswith("ACH"):
match = re.search(r'ACH[:\s]*(\S+)', text, re.IGNORECASE);
achVal = match.group(1) if match else text
elif text.upper().startswith("RECIPIENT ID:"):
try: recID = text.split(":", 1)[1].strip()
except IndexError: print(f"Warning: Could not parse
'RECIPIENT ID': {text}")
elif text.upper().startswith("COMPANY ID:"):
try: compID = text.split(":", 1)[1].strip()
except IndexError: print(f"Warning: Could not parse
'COMPANY ID': {text}")
elif text.upper().startswith("COMPANY NAME:"):
try: name_part = text.split(":", 1)[1].strip();
payer_parts = re.split(r'\s{2,}', name_part); payer = payer_parts[0].strip() if
payer_parts else name_part
except IndexError: print(f"Warning: Could not parse
'COMPANY NAME': {text}")
elif text.upper().startswith("RECIPIENT NAME:"):
try: recipient = text.split(":", 1)[1].strip()
except IndexError: print(f"Warning: Could not parse
'RECIPIENT NAME': {text}")
type_val = "Unknown"; category = "Unknown"; customerRef =
trans.get("customerReferenceNumber", "")
if otherRef is not None:
type_val = "Deposit"; category = "Deposit"; customerRef =
otherRef["cheque"];
if not compID: compID = otherRef["compID"]
elif trnVal: type_val = "ACH"; category = "Insurance Payment";
customerRef = trnVal
elif achVal: type_val = "ACH"; category = "Insurance Payment";
customerRef = achVal
elif recID: type_val = "Deposit"; category = "Credit Card"; customerRef
= recID
else:
tc = trans.get("typeCode", "")
if tc in ["165", "166", "169", "277", "354"]: type_val = "Deposit";
category = "Deposit"
elif tc in ["469", "475", "503"]: type_val = "ACH"; category = "ACH
Payment"
else: type_val = "Deposit" if (trans.get("amount", "0")[0] != '-')
else "Withdrawal"; category = "Deposit" if type_val == "Deposit" else "Payment"
if not compID: compID = trans.get("customerReferenceNumber", "")
return {"type": type_val, "category": category, "cheque": customerRef,
"compID": compID, "payer": payer, "recipient": recipient}
if details_json and details_json.get("groups"):
for group in details_json["groups"]:
for account in group.get("accounts", []):
for trans in account.get("transactions", []):
transaction_details =
extract_transaction_details_for_excel(trans, summary)
row = [
transaction_details["type"],
transaction_details["category"], summary['receive_date'],
transaction_details["cheque"],
transaction_details["compID"], transaction_details["payer"],
transaction_details["recipient"], trans.get("amount",
"")
]
sheet.append(row)
for col_idx, header in enumerate(headers, 1):
column_letter = get_column_letter(col_idx)
width = max(len(header) + 2, 20)
sheet.column_dimensions[column_letter].width = width
response = make_response()
safe_file_name = re.sub(r'[\\/*?:"<>|]', "", summary['file_name'])
response.headers['Content-Disposition'] = f'attachment;
filename="{safe_file_name}_Detailed_Transactions.xlsx"'
response.headers['Content-Type'] = 'application/vnd.openxmlformats-
officedocument.spreadsheetml.sheet'
workbook.save(response.stream)
log_security_event(action='Export BAI to Excel', status='Success',
user_id=user_id, details=log_details_base)
return response
except Exception as e:
log_security_event(action='Export BAI to Excel', status='Error',
user_id=user_id, details=f"{log_details_base} - Error generating file: {e}")
print(f"Error generating Excel file for id {file_id}: {e}")
return jsonify({"error": f"Error generating Excel file: {e}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- MODIFIED: get_file_summary to query BAI_Summary ---
def get_file_summary(file_id):
conn = get_db_connection()
if conn is None:
print(f"get_file_summary: DB conn error for id {file_id}")
return None
cur = conn.cursor()
try:
cur.execute("SELECT file_name, sender_name, receiver_name, deposit_date,
receive_date, total_amount FROM BAI_Summary WHERE id = ?;", (file_id,))
result = cur.fetchone()
if not result:
print(f"get_file_summary: Record not found in BAI_Summary for id
{file_id}")
return None
summary = {
"file_name": result[0], "sender_name": result[1], "receiver_name":
result[2],
"deposit_date": result[3], "receive_date": result[4], "total_amount":
result[5]
}
return summary
except Exception as e:
print(f"Error retrieving summary from BAI_Summary for file id {file_id}:
{e}")
return None
finally:
if cur: cur.close()
if conn: conn.close()
# --- MODIFIED: get_era_files to query ERA_Summary ---
@app.route("/era_files", methods=["GET"])
def get_era_files():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_security_event(action='View ERA Files List', status='Attempt',
user_id=user_id)
conn = get_db_connection()
if conn is None:
log_security_event(action='View ERA Files List', status='Error',
user_id=user_id, details="Database connection error.")
return jsonify({"error": "Database connection error."}), 500
cur = conn.cursor()
try:
cur.execute("""
SELECT
es.id, es.file_name, es.payer_name, es.payee_name, es.payee_id,
es.check_date, es.trn_number, es.amount, es.check_eft,
es.raw_content,
CASE WHEN r.id IS NOT NULL THEN 'Yes' ELSE 'No' END AS
reconcile_status
FROM ERA_Summary es
LEFT JOIN Reconciliation r ON es.id = r.era_summary_id
ORDER BY es.id DESC;
""")
rows = cur.fetchall()
# Convert keys if needed for frontend compatibility
era_files = [dict(zip([column[0] for column in cur.description], row)) for
row in rows]
log_security_event(action='View ERA Files List', status='Success',
user_id=user_id, details=f"Fetched {len(era_files)} ERA summaries.")
return jsonify(era_files)
except Exception as e:
log_security_event(action='View ERA Files List', status='Error',
user_id=user_id, details=f"Error retrieving ERA records: {e}")
print(f"Error retrieving ERA summary records: {e}")
return jsonify({"error": f"Error retrieving ERA records: {e}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- MODIFIED: get_era_file_details to fetch from ERA_Summary and ERA_Detail ---
@app.route("/era_files/<int:era_id>", methods=["GET"])
def get_era_file_details(era_id):
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_details_base = f"ERA File ID: {era_id}"
log_security_event(action='View ERA File Details', status='Attempt',
user_id=user_id, details=log_details_base)
conn = get_db_connection()
if conn is None:
log_security_event(action='View ERA File Details', status='Error',
user_id=user_id, details=f"{log_details_base} - Database connection error.")
return jsonify({"error": "Database connection error."}), 500
cur = conn.cursor()
try:
# --- 1. Fetch Summary with Reconciliation Status ---
cur.execute("""
SELECT
es.file_name, es.payer_name, es.payee_name, es.payee_id,
es.check_date,
es.trn_number, es.amount, es.check_eft, es.raw_content,
es.file_path,
CASE WHEN r.id IS NOT NULL THEN 'Yes' ELSE 'No' END AS
reconcile_status
FROM ERA_Summary es
LEFT JOIN Reconciliation r ON es.id = r.era_summary_id
WHERE es.id = ?;
""", (era_id,))
summary_result = cur.fetchone()
if not summary_result:
log_security_event(action='View ERA File Details', status='Failure',
user_id=user_id, details=f"{log_details_base} - Record not found.")
return jsonify({"error": "ERA record not found."}), 404
# Use column names directly from description
summary_columns = [column[0] for column in cur.description]
summary_data = dict(zip(summary_columns, summary_result))
# --- 2. Fetch ERA Claim Details (from ERA_Detail) ---
cur.execute("""
SELECT patient_name, account_number, icn, billed_amount, paid_amount,
from_date, to_date
FROM ERA_Detail
WHERE era_summary_id = ?
ORDER BY id;
""", (era_id,))
detail_rows = cur.fetchall()
details_list = []
detail_columns = [column[0] for column in cur.description]
for row in detail_rows:
details_list.append(dict(zip(detail_columns, row)))
# Return both summary (with status) and claim details
log_security_event(action='View ERA File Details', status='Success',
user_id=user_id, details=f"{log_details_base} - Details fetched successfully.")
return jsonify({"summary": summary_data, "details": details_list})
except Exception as e:
log_security_event(action='View ERA File Details', status='Error',
user_id=user_id, details=f"{log_details_base} - Error retrieving details: {e}")
print(f"Error retrieving ERA details for id {era_id}: {e}")
return jsonify({"error": f"Error retrieving ERA details: {e}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- MODIFIED: download_era to fetch raw_content from ERA_Summary ---
@app.route("/download_era/<int:era_id>", methods=["GET"])
def download_era(era_id):
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# ... (rest of protection block) ...
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_details_base = f"ERA File ID: {era_id}"
log_security_event(action='Download ERA File', status='Attempt',
user_id=user_id, details=log_details_base)
conn = get_db_connection()
if conn is None:
log_security_event(action='Download ERA File', status='Error',
user_id=user_id, details=f"{log_details_base} - Database connection error.")
return jsonify({"error": "Database connection error."}), 500
cur = conn.cursor()
try:
cur.execute("SELECT file_name, raw_content FROM ERA_Summary WHERE id = ?;",
(era_id,))
result = cur.fetchone()
if not result:
log_security_event(action='Download ERA File', status='Failure',
user_id=user_id, details=f"{log_details_base} - Record not found.")
return jsonify({"error": "ERA Record not found."}), 404
file_name = result[0]
log_details_base = f"ERA File ID: {era_id}, Filename: '{file_name}'" #
Update details
raw_content = result[1]
if raw_content is None: raw_content = ""
blob = raw_content.encode("utf-8")
safe_file_name = re.sub(r'[\\/*?:"<>|]', "", file_name)
if not re.search(r'\.(txt|edi|835)$', safe_file_name, re.IGNORECASE):
safe_file_name += ".835"
response = make_response(blob)
response.headers['Content-Disposition'] = f"attachment;
filename=\"{safe_file_name}\""
response.headers['Content-Type'] = 'application/edi-x12; charset=utf-8'
log_security_event(action='Download ERA File', status='Success',
user_id=user_id, details=log_details_base)
return response
except Exception as e:
log_security_event(action='Download ERA File', status='Error',
user_id=user_id, details=f"{log_details_base} - Error downloading file: {e}")
print(f"Error downloading ERA file id {era_id}: {e}")
return jsonify({"error": f"Error downloading ERA file: {e}"}), 500
finally:
if cur: cur.close()
if conn: conn.close()
# --- NEW: Reconciliation Logic Endpoint ---
# app.py: Replace the entire existing reconcile_now function with this one
@app.route("/reconcile_now", methods=["POST"])
def reconcile_now():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_security_event(action='Trigger Auto Reconciliation', status='Attempt',
user_id=user_id)
"""
Attempts to automatically reconcile BAI Details with ERA Summaries based on:
1. BAI Detail Customer Reference == ERA Summary TRN Number
2. BAI Detail Parsed Amount == ERA Summary Parsed Amount
Updates BAI Detail status ('No', 'Partial', 'Yes') and amounts accordingly.
Copies matched ERA files to the reconciled folder.
"""
conn = None
matched_count = 0
errors = [] # Store warnings or non-blocking errors
try:
# Get DB connection and cursor
conn = get_db_connection()
if conn is None:
return jsonify({"error": "Database connection failed."}), 500
cur = conn.cursor()
# --- Step 1: Fetch BAI Details that are not fully reconciled ---
# Includes 'No' and 'Partial' statuses. Fetches necessary amount/status
fields.
cur.execute("""
SELECT
bd.id, bs.file_name, bd.customer_reference, bd.receive_date,
bd.amount,
bd.parsed_amount, bd.reconciled_amount, bd.remaining_amount,
bd.reconciliation_status
FROM BAI_Detail bd
JOIN BAI_Summary bs ON bd.bai_summary_id = bs.id
WHERE bd.reconciliation_status != 'Yes' -- Fetch 'No' or 'Partial'
AND bd.customer_reference IS NOT NULL
AND bd.parsed_amount IS NOT NULL; -- Must have a parsed amount to
match
""")
bai_details_rows = cur.fetchall()
# Convert rows to list of dictionaries for easier access by column name
bai_details = [dict(zip([column[0] for column in cur.description], row))
for row in bai_details_rows]
# --- Step 2: Fetch ERA Summaries not yet linked in Reconciliation table
---
cur.execute("""
SELECT es.id, es.file_name, es.trn_number, es.check_date, es.amount,
es.file_path
FROM ERA_Summary es
LEFT JOIN Reconciliation r ON es.id = r.era_summary_id
WHERE r.id IS NULL -- Only fetch ERAs not yet linked
AND es.trn_number IS NOT NULL AND es.trn_number != '' -- Must have a
TRN
AND es.amount IS NOT NULL -- Must have an amount
AND es.file_path IS NOT NULL AND es.file_path != ''; -- Must have a
file path for copying
""")
era_summaries_rows = cur.fetchall()
# Convert rows to list of dictionaries
era_summaries = [dict(zip([column[0] for column in cur.description], row))
for row in era_summaries_rows]
# --- Step 3: Create an ERA lookup dictionary for efficient matching ---
# Key: Tuple (TRN Number, Parsed Amount Decimal)
# Value: ERA Summary Dictionary
era_lookup = {}
for era in era_summaries:
era_amt_dec = parse_amount(era['amount']) # Parse ERA amount to Decimal
# Store only if TRN is valid and amount is positive Decimal
if era['trn_number'] and era_amt_dec is not None and era_amt_dec >
Decimal('0.00'):
# Using tuple key (TRN, Amount). If duplicates exist, the last one
read overwrites.
era_lookup[(era['trn_number'], era_amt_dec)] = era
# --- Step 4: Iterate through eligible BAI details and attempt matching ---
ist_now = datetime.now(timezone.utc) + timedelta(hours=5, minutes=30) #
Timestamp for reconciliation
reconciled_era_ids_in_run = set() # Track ERAs used in this run to prevent
reuse
for bai in bai_details:
bai_detail_id = bai['id']
bai_customer_ref = bai['customer_reference']
bai_original_parsed_dec = bai.get('parsed_amount') # Get the original
total amount for this BAI detail
# Basic validation for the BAI record fetched
if not bai_customer_ref or bai_original_parsed_dec is None:
print(f"Skipping BAI Detail ID {bai_detail_id} due to missing
Customer Reference or Parsed Amount.")
continue
# --- Create the lookup key for finding an exact ERA match ---
# Match based on BAI Customer Ref == ERA TRN and BAI Parsed Amt == ERA
Parsed Amt
lookup_key = (bai_customer_ref, bai_original_parsed_dec)
# Check if a matching ERA exists in our lookup (and hasn't been used
yet)
if lookup_key in era_lookup:
matched_era = era_lookup[lookup_key]
matched_era_id = matched_era['id']
# Crucial check: Ensure this ERA hasn't already been matched in
THIS run
if matched_era_id in reconciled_era_ids_in_run:
continue # Skip, already used
# --- Found an Exact Match - Process it ---
try:
# Amount being reconciled is the full ERA amount (which equals
BAI original amount here)
amount_being_reconciled = bai_original_parsed_dec
# 1. Insert into Reconciliation table (mark as 'system'/auto)
cur.execute("""
INSERT INTO Reconciliation (bai_detail_id, era_summary_id,
reconciliation_date, reconciled_by, amount_reconciled, manual_reconciliation)
VALUES (?, ?, ?, ?, ?, ?);
""", (bai_detail_id, matched_era_id, ist_now, 'system',
amount_being_reconciled, 0)) # manual = 0 for auto
# 2. Update BAI_Detail record - For exact match, it becomes
fully reconciled
new_reconciled_amount = bai_original_parsed_dec
new_remaining_amount = Decimal('0.00')
new_status = 'Yes'
cur.execute("""
UPDATE BAI_Detail
SET reconciled_amount = ?, remaining_amount = ?,
reconciliation_status = ?
WHERE id = ?;
""", (new_reconciled_amount, new_remaining_amount, new_status,
bai_detail_id))
# 3. Copy the matched ERA file to the reconciled folder
source_path = matched_era['file_path']
if source_path and os.path.exists(source_path):
base_filename = os.path.basename(source_path)
secure_base_filename = secure_filename(base_filename) #
Sanitize filename
target_path = os.path.join(RECONCILE_ERA_FOLDER,
secure_base_filename)
try:
shutil.copy2(source_path, target_path) # copy2
preserves metadata
print(f"Copied ERA file from {source_path} to
{target_path}")
except Exception as copy_err:
# Log file copy error, but DON'T rollback DB changes
by default.
# The reconciliation is valid based on data, file
copy is secondary.
error_msg = f"Error copying file for ERA ID
{matched_era_id}: {copy_err}. DB record was still reconciled."
errors.append(error_msg)
print(f"Warning: {error_msg}")
else:
# Log missing file path warning
error_msg = f"ERA file path not found or invalid for ERA ID
{matched_era_id}: {source_path}. DB record was still reconciled."
errors.append(error_msg)
print(f"Warning: {error_msg}")
# --- Mark ERA as processed in this run ---
matched_count += 1
reconciled_era_ids_in_run.add(matched_era_id)
# Remove from lookup dictionary to prevent reuse within this
run
del era_lookup[lookup_key]
except pyodbc.Error as db_err: # Catch specific database errors
(like IntegrityError)
conn.rollback() # Rollback the transaction for THIS specific
failed match attempt
error_msg = f"DB Error reconciling BAI ID {bai_detail_id} & ERA
ID {matched_era_id}: {db_err}"
errors.append(error_msg)
print(error_msg)
# If it was a constraint violation, it might already be
reconciled, remove from lookup
if "constraint" in str(db_err).lower() or "duplicate key" in
str(db_err).lower() :
if lookup_key in era_lookup: del era_lookup[lookup_key]
except Exception as e:
conn.rollback() # Rollback transaction for other unexpected
errors during processing of THIS match
error_msg = f"Unexpected Error processing match for BAI ID
{bai_detail_id} & ERA ID {matched_era_id}: {e}"
errors.append(error_msg)
print(error_msg)
# Remove from lookup to prevent potential infinite loops if the
error repeats
if lookup_key in era_lookup: del era_lookup[lookup_key]
# --- End of processing for one BAI detail ---
# --- Step 5: Commit all successful DB changes made during the loop ---
conn.commit()
# Prepare response message
message = f"Reconciliation process completed. {matched_count} records
matched automatically."
if errors:
# Include count and details of non-blocking errors/warnings
message += f" Encountered {len(errors)} errors/warnings during
processing (see server logs or error list for details)."
log_security_event(action='Trigger Auto Reconciliation', status='Success',
user_id=user_id, details=message)
# Return success response
return jsonify({"message": message, "matched_count": matched_count,
"errors": errors})
except Exception as e:
log_security_event(action='Trigger Auto Reconciliation', status='Error',
user_id=user_id, details=f"Critical error during reconciliation: {e}")
# Handle errors occurring during setup (DB connection, initial fetches)
if conn: conn.rollback() # Rollback any potential partial changes
print(f"Critical error during reconciliation process: {e}")
return jsonify({"error": f"An unexpected critical error occurred during
reconciliation: {e}"}), 500
finally:
# Ensure database connection is closed properly
if cur: cur.close()
if conn: conn.close()
# --- NEW: Endpoints to fetch data for Reconcile/Non-Reconcile views ---
@app.route("/reconciled_data", methods=["GET"])
def get_reconciled_data():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
log_security_event(action='View Reconciled Data', status='Attempt',
user_id=user_id)
"""Fetches data that HAS been successfully reconciled, separated by BAI and
ERA."""
conn = None
cur = None # Initialize cur to None
try:
conn = get_db_connection()
if conn is None:
log_security_event(action='View Reconciled Data', status='Error',
user_id=user_id, details="Database connection failed.")
return jsonify({"error": "Database connection failed."}), 500
cur = conn.cursor()
# Fetch BAI side of matched records
cur.execute("""
SELECT
r.id as reconciliation_id, -- Get reconciliation ID for revert
bs.file_name AS bai_file_name,
bd.customer_reference,
bd.receive_date AS bai_receive_date,
bd.amount AS bai_display_amount, -- Original display amount
r.amount_reconciled, -- Amount reconciled in this specific
link
bd.reconciled_amount AS bai_total_reconciled, -- Total reconciled
for this BAI detail
bd.remaining_amount AS bai_remaining_amount,
bd.reconciliation_status AS bai_status,
r.manual_reconciliation -- Flag if it was manual
FROM Reconciliation r
JOIN BAI_Detail bd ON r.bai_detail_id = bd.id
JOIN BAI_Summary bs ON bd.bai_summary_id = bs.id
ORDER BY r.reconciliation_date DESC;
""")
bai_data = [dict(zip([column[0] for column in cur.description], row)) for
row in cur.fetchall()]
# Fetch ERA side of matched records
cur.execute("""
SELECT
r.id as reconciliation_id, -- Get reconciliation ID for
alignment/revert
es.file_name AS era_file_name,
es.trn_number,
es.check_date AS era_check_date,
es.amount AS era_amount, -- ERA summary amount
r.manual_reconciliation -- Also include type here if needed for
consistency
FROM Reconciliation r
JOIN ERA_Summary es ON r.era_summary_id = es.id
ORDER BY r.reconciliation_date DESC;
""")
era_data = [dict(zip([column[0] for column in cur.description], row)) for
row in cur.fetchall()]
# Return two separate lists
log_security_event(action='View Reconciled Data', status='Success',
user_id=user_id, details=f"Fetched {len(bai_data)} BAI and {len(era_data)} ERA
reconciled records.")
return jsonify({"bai_reconciled": bai_data, "era_reconciled": era_data})
except Exception as e:
print(f"Error fetching reconciled data: {e}")
log_security_event(action='View Reconciled Data', status='Error',
user_id=user_id, details=f"Error retrieving reconciled data: {e}")
return jsonify({"error": f"Error retrieving reconciled data: {e}"}), 500
finally:
# Ensure cursor is closed if it was opened
if cur: cur.close()
if conn: conn.close()
@app.route("/manual_reconcile", methods=["POST"])
def manual_reconcile():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
data = request.get_json()
bai_detail_id = data.get('bai_detail_id')
era_summary_id = data.get('era_summary_id')
log_details_base = f"BAI Detail ID: {bai_detail_id}, ERA Summary ID:
{era_summary_id}"
log_security_event(action='Manual Reconcile', status='Attempt',
user_id=user_id, details=log_details_base)
if not bai_detail_id or not era_summary_id:
log_security_event(action='Manual Reconcile', status='Failure',
user_id=user_id, details=f"{log_details_base} - Missing IDs.")
return jsonify({"error": "Missing BAI Detail ID or ERA Summary ID."}), 400
conn = None
try:
conn = get_db_connection()
if conn is None: return jsonify({"error": "Database connection failed."}),
500
cur = conn.cursor()
# --- Get BAI Detail Info ---
cur.execute("""
SELECT parsed_amount, reconciled_amount, remaining_amount,
reconciliation_status
FROM BAI_Detail WHERE id = ?
""", (bai_detail_id,))
bai_row = cur.fetchone()
if not bai_row: return jsonify({"error": "BAI Detail record not found."}),
404
bai_parsed_amount = bai_row[0]
bai_reconciled_amount = bai_row[1]
bai_remaining_amount = bai_row[2]
bai_status = bai_row[3]
# Ensure amounts are Decimals
if bai_parsed_amount is None: bai_parsed_amount = Decimal('0.00')
if bai_reconciled_amount is None: bai_reconciled_amount = Decimal('0.00')
if bai_remaining_amount is None: bai_remaining_amount = bai_parsed_amount -
bai_reconciled_amount # Recalculate if null
if bai_status == 'Yes' or bai_remaining_amount <= Decimal('0.005'):
log_security_event(action='Manual Reconcile', status='Failure',
user_id=user_id, details=f"{log_details_base} - BAI Detail already fully
reconciled.")
return jsonify({"error": "BAI Detail is already fully reconciled."}),
400
# --- Get ERA Summary Info ---
cur.execute("SELECT amount, file_path FROM ERA_Summary WHERE id = ?",
(era_summary_id,))
era_row = cur.fetchone()
if not era_row: return jsonify({"error": "ERA Summary record not found."}),
404
era_amount_str = era_row[0]
era_file_path = era_row[1] # For potential copy later if needed
era_amount_dec = parse_amount(era_amount_str)
if era_amount_dec is None or era_amount_dec <= Decimal('0.00'):
return jsonify({"error": "Invalid or zero ERA amount."}), 400
# --- Check if ERA is already reconciled ---
cur.execute("SELECT 1 FROM Reconciliation WHERE era_summary_id = ?",
(era_summary_id,))
if cur.fetchone():
return jsonify({"error": "ERA Summary is already linked to a
reconciliation."}), 400
# --- Compare amounts ---
if era_amount_dec > bai_remaining_amount + Decimal('0.005'): # Add
tolerance
return jsonify({"error": f"ERA amount (${era_amount_dec:.2f}) cannot be
greater than BAI remaining amount (${bai_remaining_amount:.2f})."}), 400
# --- Proceed with Reconciliation ---
amount_to_reconcile = era_amount_dec
ist_now = datetime.now(timezone.utc) + timedelta(hours=5, minutes=30)
try:
# Start transaction
conn.autocommit = False # Ensure manual transaction control
# 1. Insert into Reconciliation
cur.execute("""
INSERT INTO Reconciliation (bai_detail_id, era_summary_id,
reconciliation_date, reconciled_by, amount_reconciled, manual_reconciliation)
VALUES (?, ?, ?, ?, ?, ?);
""", (bai_detail_id, era_summary_id, ist_now, 'manual',
amount_to_reconcile, 1))
# 2. Update BAI_Detail
new_reconciled = bai_reconciled_amount + amount_to_reconcile
new_remaining = bai_parsed_amount - new_reconciled # Recalc based on
original parsed amount
new_status = 'No'
# Use tolerance for status check
if abs(new_remaining) < Decimal('0.005'):
new_status = 'Yes'
elif new_reconciled > Decimal('0.005'):
new_status = 'Partial'
cur.execute("""
UPDATE BAI_Detail
SET reconciled_amount = ?, remaining_amount = ?,
reconciliation_status = ?
WHERE id = ?;
""", (new_reconciled, new_remaining, new_status, bai_detail_id))
# 3. Optionally copy ERA file if needed for manual reconcile tracking
# (Skipping copy for now, assuming auto-reconcile handles the main
copy)
conn.commit() # Commit successful transaction
log_security_event(action='Manual Reconcile', status='Success',
user_id=user_id, details=f"{log_details_base} - Reconciled successfully. BAI
Status: {new_status}, Remaining: {new_remaining:.2f}")
return jsonify({
"message": "Records successfully reconciled manually.",
"bai_new_status": new_status,
"bai_remaining": f"{new_remaining:.2f}"
}), 200
except Exception as e_inner:
if conn: conn.rollback()
log_security_event(action='Manual Reconcile', status='Error',
user_id=user_id, details=f"{log_details_base} - DB error during transaction:
{e_inner}")
print(f"Error during manual reconcile transaction: {e_inner}")
return jsonify({"error": f"Database error during reconciliation:
{e_inner}"}), 500
except Exception as e_outer:
if conn: conn.rollback() # Rollback on outer errors too
print(f"Error in manual_reconcile endpoint: {e_outer}")
return jsonify({"error": f"An unexpected error occurred: {e_outer}"}), 500
finally:
if cur: cur.close()
if conn:
conn.autocommit = True # Reset autocommit
conn.close()
@app.route("/revert_reconciliation", methods=["POST"])
def revert_reconciliation():
# --- BEGIN PROTECTION BLOCK ---
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
# --- END PROTECTION BLOCK ---
data = request.get_json()
reconciliation_id = data.get('reconciliation_id')
log_details_base = f"Reconciliation ID: {reconciliation_id}"
log_security_event(action='Revert Reconciliation', status='Attempt',
user_id=user_id, details=log_details_base)
if not reconciliation_id:
log_security_event(action='Revert Reconciliation', status='Failure',
user_id=user_id, details=f"{log_details_base} - Missing ID.")
return jsonify({"error": "Missing Reconciliation ID."}), 400
conn = None
try:
conn = get_db_connection()
if conn is None: return jsonify({"error": "Database connection failed."}),
500
cur = conn.cursor()
# --- Get Reconciliation Info ---
cur.execute("""
SELECT bai_detail_id, era_summary_id, amount_reconciled
FROM Reconciliation WHERE id = ?
""", (reconciliation_id,))
rec_row = cur.fetchone()
if not rec_row:
log_security_event(action='Revert Reconciliation', status='Failure',
user_id=user_id, details=f"{log_details_base} - Record not found.")
return jsonify({"error": "Reconciliation record not found."}), 404
bai_detail_id = rec_row[0]
era_summary_id = rec_row[1] # Keep for potential logging/auditing
amount_reverted = rec_row[2]
if amount_reverted is None: amount_reverted = Decimal('0.00')
# --- Get Current BAI Detail Info ---
cur.execute("""
SELECT parsed_amount, reconciled_amount, remaining_amount
FROM BAI_Detail WHERE id = ?
""", (bai_detail_id,))
bai_row = cur.fetchone()
if not bai_row:
# This shouldn't happen if FK constraint exists, but check anyway
return jsonify({"error": "Associated BAI Detail record not found."}),
404
bai_parsed_amount = bai_row[0] or Decimal('0.00')
bai_reconciled_amount = bai_row[1] or Decimal('0.00')
# bai_remaining_amount = bai_row[2] # We'll recalculate
try:
# Start Transaction
conn.autocommit = False
# 1. Delete from Reconciliation
cur.execute("DELETE FROM Reconciliation WHERE id = ?",
(reconciliation_id,))
# 2. Update BAI_Detail
new_reconciled = bai_reconciled_amount - amount_reverted
# Ensure reconciled doesn't go below zero
if new_reconciled < Decimal('0.00'): new_reconciled = Decimal('0.00')
new_remaining = bai_parsed_amount - new_reconciled # Recalculate
remaining
new_status = 'No'
if new_reconciled > Decimal('0.005'): # If any amount is still
reconciled
new_status = 'Partial'
cur.execute("""
UPDATE BAI_Detail
SET reconciled_amount = ?, remaining_amount = ?,
reconciliation_status = ?
WHERE id = ?;
""", (new_reconciled, new_remaining, new_status, bai_detail_id))
# 3. Optionally remove copied ERA file? Maybe not, keep it for history?
Decide policy.
conn.commit() # Commit successful revert
log_security_event(action='Revert Reconciliation', status='Success',
user_id=user_id, details=log_details_base)
return jsonify({"message": "Reconciliation successfully reverted."}),
200
except Exception as e_inner:
if conn: conn.rollback()
log_security_event(action='Revert Reconciliation', status='Error',
user_id=user_id, details=f"{log_details_base} - DB error during revert: {e_inner}")
print(f"Error during revert transaction: {e_inner}")
return jsonify({"error": f"Database error during revert: {e_inner}"}),
500
except Exception as e_outer:
if conn: conn.rollback()
print(f"Error in revert_reconciliation endpoint: {e_outer}")
return jsonify({"error": f"An unexpected error occurred: {e_outer}"}), 500
finally:
if cur: cur.close()
if conn:
conn.autocommit = True # Reset autocommit
conn.close()
# app.py: Verify or replace the get_non_reconciled_data function
@app.route("/non_reconciled_data", methods=["GET"])
def get_non_reconciled_data():
user_id = session.get('user_id') # Get user ID
# --- BEGIN PROTECTION BLOCK ---
if not user_id:
log_security_event(action='Access Denied', status='Attempt',
details=f'Attempted access to {request.path} without login')
# For API endpoints, returning JSON might be better than redirecting
if request.path.startswith(('/dashboard_data', '/files', '/era_files',
'/reconciled_data', '/non_reconciled_data')) or
request.accept_mimetypes.accept_json:
return jsonify({"error": "Authentication required. Please log in."}),
401
else:
flash('Your session has expired or you are not logged in. Please log in
again.', 'warning')
return redirect(url_for('login'))
log_security_event(action='View Non-Reconciled Data', status='Attempt',
user_id=user_id)
# --- END PROTECTION BLOCK ---
"""
Fetches data eligible for manual reconciliation:
- BAI Details with status 'No' or 'Partial' AND remaining_amount > 0.
- ERA Summaries not yet linked in the Reconciliation table.
"""
conn = None
cur = None # Initialize cur
try:
conn = get_db_connection()
if conn is None:
log_security_event(action='View Non-Reconciled Data', status='Error',
user_id=user_id, details="Database connection failed.")
return jsonify({"error": "Database connection failed."}), 500
cur = conn.cursor()
# --- Fetch BAI Details eligible for further reconciliation ---
# Status must be 'No' or 'Partial', AND Remaining Amount must be positive.
cur.execute("""
SELECT
bd.id AS bai_detail_id,
bs.file_name AS bai_file_name,
bd.customer_reference,
bd.receive_date AS bai_receive_date,
bd.amount AS bai_display_amount, -- Original display amount
(string)
bd.parsed_amount AS bai_total_parsed_amount, -- The original parsed
total amount
bd.reconciled_amount AS bai_reconciled_amount, -- How much HAS been
reconciled already
bd.remaining_amount AS bai_remaining_amount, -- How much is left
bd.reconciliation_status AS bai_status
FROM BAI_Detail bd
JOIN BAI_Summary bs ON bd.bai_summary_id = bs.id
WHERE
bd.reconciliation_status != 'Yes'
AND bd.remaining_amount > 0.005
ORDER BY bs.id DESC, bd.id;
""")
bai_data = [dict(zip([column[0] for column in cur.description], row)) for
row in cur.fetchall()]
# --- Fetch ERA Summaries not yet reconciled ---
# These are ERAs that do not appear in the Reconciliation table at all.
cur.execute("""
SELECT
es.id AS era_summary_id,
es.file_name AS era_file_name,
es.trn_number,
es.check_date AS era_check_date,
es.amount AS era_amount -- Needed for selection & validation
FROM ERA_Summary es
LEFT JOIN Reconciliation r ON es.id = r.era_summary_id
WHERE r.id IS NULL -- The crucial condition: ERA is not linked
ORDER BY es.id DESC;
""")
# Fetchall and convert to dict list
era_data = [dict(zip([column[0] for column in cur.description], row)) for
row in cur.fetchall()]
# Return the two lists
log_security_event(action='View Non-Reconciled Data', status='Success',
user_id=user_id, details=f"Fetched {len(bai_data)} unmatched BAI and
{len(era_data)} unmatched ERA records.")
return jsonify({"bai_unreconciled": bai_data, "era_unreconciled":
era_data})
except Exception as e:
log_security_event(action='View Non-Reconciled Data', status='Error',
user_id=user_id, details=f"Error retrieving non-reconciled data: {e}")
print(f"Error fetching non-reconciled data: {e}")
# Consider logging the full traceback here for better debugging
# import traceback
# print(traceback.format_exc())
return jsonify({"error": f"Error retrieving non-reconciled data: {e}"}),
500
finally:
# Ensure cursor and connection are closed
if cur: cur.close()
if conn: conn.close()
# if __name__ == "__main__":
# # Ensure upload directories exist at startup as well
# os.makedirs(BAI_UPLOAD_FOLDER, exist_ok=True)
# os.makedirs(ERA_UPLOAD_FOLDER, exist_ok=True)
# os.makedirs(RECONCILE_ERA_FOLDER, exist_ok=True)
# app.run(debug=True)
if __name__ == "__main__":
# Ensure upload directories exist at startup as well
# --- Initialize Database Schema on Startup (within Application Context) ---
with app.app_context():
initialize_database() # Create/update tables and columns
os.makedirs(BAI_UPLOAD_FOLDER, exist_ok=True)
os.makedirs(ERA_UPLOAD_FOLDER, exist_ok=True)
os.makedirs(RECONCILE_ERA_FOLDER, exist_ok=True)
# --- Optional: Add startup messages ---
print("---")
print("Flask Application Starting...")
if app.debug:
print("WARNING: Debug mode is ON. Do not use in production!")
print("WARNING: Reverse DNS lookup (for ClientHostname) enabled.")
print(" Lookup might time out (log ClientHostname=NULL) or add
latency.")
print("---")
# --- End Optional ---
# Serve using Waitress (production server)
serve(app, host="0.0.0.0", port=5000)
# Or for development:
config.json:
{
"sender_map": {
"121000248": "Wells Fargo 7716"
},
"receiver_map": {
"SIMONMED": "FL Simonmed Imaging FL LLC"
}
}
db_config.json:
{
"host": "RAHUL-BOUDH\\SQLEXPRESS",
"database": "baidb",
"user": "rahul",
"password": "rahul"
}
templates/index.html:
<!-- templates/index.html: -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dr. Recon</title>
<link rel="stylesheet" href="/static/style.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/
all.min.css"
integrity="sha512-
9usAa10IRO0HhonpyAIVpjrylPvoDwiPUiKdWk5t3PyolY1cOd4DSE0Ga+ri4AuTroPR5aQvXU9xC6qOPnz
Feg=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<!-- NEW: Add Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- END NEW -->
</head>
<body>
<div class="container">
<aside class="sidebar collapsed" id="sidebar"> <!-- Start collapsed by
default -->
<header class="sidebar-header">
<h1 class="sidebar-title">Dr. Recon</h1>
<!-- Toggle Icon Button -->
<button class="sidebar-toggle-icon" id="sidebarToggleIcon" aria-
label="Toggle Sidebar">
<i class="fas fa-bars"></i> <!-- Initial icon -->
</button>
</header>
<nav class="sidebar-nav"> <!-- Add class to nav -->
<ul>
<li>
<button class="sidebar-button" data-section="dashboard">
<i class="fas fa-tachometer-alt"></i> <span class="button-
text">Dashboard</span>
</button>
</li>
<li>
<button class="sidebar-button" data-section="baiUpload">
<i class="fas fa-upload"></i> <span class="button-text">BAI
Upload</span>
</button>
</li>
<li>
<button class="sidebar-button" data-section="eraUpload">
<i class="fas fa-file-upload"></i> <span class="button-
text">ERA Upload</span>
</button>
</li>
<li>
<button class="sidebar-button" data-section="baiDetails">
<i class="fas fa-file-alt"></i> <span class="button-
text">BAI Files</span>
</button>
</li>
<li>
<button class="sidebar-button" data-section="eraDetails">
<i class="fas fa-file-alt"></i> <span class="button-
text">ERA Files</span>
</button>
</li>
<li>
<button class="sidebar-button" data-section="reconcile">
<i class="fas fa-check-double"></i> <span class="button-
text">Reconciled</span>
</button>
</li>
<li>
<button class="sidebar-button" data-section="nonReconcile">
<i class="fas fa-times-circle"></i> <span class="button-
text">Non-Reconciled</span>
</button>
</li>
</ul>
</nav>
</aside>
<main class="content" id="mainContent">
<!-- NEW: Dashboard Section -->
<!-- templates/index.html: Modified Dashboard Section -->
<section id="dashboard" class="content-section" style="display:none;">
<h2><i class="fas fa-tachometer-alt"></i> Reconciliation Dashboard</h2>
<!-- Date Filter Controls -->
<div class="filters-container dashboard-filters">
<div class="filter-item">
<label for="filterBaiMonthFrom">Filter Date Range:</label>
<input type="month" id="filterBaiMonthFrom" title="From Month">
<!-- Combined Label -->
</div>
<div class="filter-item">
<label for="filterBaiMonthTo" class="sr-only">To:</label> <!--
Hidden label for semantics -->
<input type="month" id="filterBaiMonthTo" title="To Month">
</div>
<div class="filter-item">
<button id="applyBaiDateFilterButton" class="button button-
small"><i class="fas fa-filter"></i> Apply Filter</button>
<button id="clearBaiDateFilterButton" class="button button-small
button-secondary"><i class="fas fa-times"></i> Clear Filter</button>
</div>
</div>
<!-- END FILTERS -->
<div class="dashboard-grid">
<!-- BAI Card -->
<div class="dashboard-card" id="baiDashboardCard">
<h3><i class="fas fa-file-alt"></i> BAI Reconciliation Summary
<span id="baiDateFilterLabel" class="filter-label">(All Time)</span></h3>
<div class="dashboard-content">
<div class="chart-container">
<canvas id="baiPieChart"></canvas>
</div>
<div class="stats-container">
<p>Total Details: <strong
id="totalBaiDetails">Loading...</strong></p>
<p>Reconciled Details: <strong
id="reconciledBaiDetails">Loading...</strong></p>
<p>Non-Reconciled Details: <strong
id="nonReconciledBaiDetails">Loading...</strong></p>
<hr>
<p>Total Amount: <strong
id="totalBaiAmount">Loading...</strong></p>
<p>Reconciled Amount: <strong
id="reconciledBaiAmount">Loading...</strong></p>
<p>Non-Reconciled Amount: <strong
id="nonReconciledBaiAmount">Loading...</strong></p>
</div>
</div>
</div>
<!-- ERA Card (Remains Unchanged - All Time) -->
<div class="dashboard-card" id="eraDashboardCard">
<h3><i class="fas fa-file-invoice-dollar"></i> ERA Reconciliation
Summary <span class="filter-label">(All Time)</span></h3>
<div class="dashboard-content">
<div class="chart-container">
<canvas id="eraPieChart"></canvas>
</div>
<div class="stats-container">
<p>Total ERAs: <strong
id="totalEraSummaries">Loading...</strong></p>
<p>Reconciled ERAs: <strong
id="reconciledEraSummaries">Loading...</strong></p>
<p>Non-Reconciled ERAs: <strong
id="nonReconciledEraSummaries">Loading...</strong></p>
<hr>
<p>Total Amount: <strong
id="totalEraAmount">Loading...</strong></p>
<p>Reconciled Amount: <strong
id="reconciledEraAmount">Loading...</strong></p>
<p>Non-Reconciled Amount: <strong
id="nonReconciledEraAmount">Loading...</strong></p>
</div>
</div>
</div>
</div>
<!-- *** NEW: Monthly Breakdown Chart Container (Initially Hidden) *** --
>
<div id="monthlyBreakdownContainer" class="dashboard-card"
style="display: none; margin-top: 30px; cursor: default;"> <!-- Container for
monthly pies -->
<h3><i class="fas fa-chart-pie"></i> Monthly File Reconciliation
Breakdown <span id="monthlyBreakdownFilterLabel" class="filter-label"></span></h3>
<div id="monthlyPieChartsArea" class="monthly-pie-charts-area">
<!-- Pie charts will be dynamically added here -->
</div>
<div id="monthlyBreakdownMessage" style="text-align: center; margin-
top: 10px; font-size: 0.9em; color: #6c757d;"></div> <!-- Message Area -->
</div>
<!-- *** END NEW CHART CONTAINER *** -->
<div id="dashboardMessage" style="margin-top: 15px;"></div> <!-- General
Dashboard Message Area -->
</section>
<!-- END Modified Dashboard Section -->
<!-- END NEW -->
<section id="baiUpload" class="content-section">
<h2><i class="fas fa-upload"></i> BAI File Upload</h2>
<p>Select a BAI file from your computer to upload and analyze.</p>
<input type="file" id="baiFile" name="file" />
<button class="button" id="uploadButton">Upload</button>
<div id="uploadMessage"></div>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
<div class="progress-label" id="progressLabel">0%</div>
</div>
</section>
<section id="eraUpload" class="content-section" style="display:none;">
<h2><i class="fas fa-file-upload"></i> ERA Upload</h2>
<p>Select an ERA file from your computer to upload and process.</p>
<input type="file" id="eraFile" name="file" />
<button class="button" id="eraUploadButton">Upload</button>
<div id="eraUploadMessage"></div>
<div class="progress-container">
<div class="progress-bar" id="eraProgressBar"></div>
<div class="progress-label" id="eraProgressLabel">0%</div>
</div>
</section>
<section id="baiDetails" class="content-section" style="display:none;">
<h2><i class="fas fa-file-alt"></i> BAI Files</h2>
<div class="filters-container">
<label for="detailViewSelect">View:</label>
<select id="detailViewSelect">
<option value="summary" selected>Summary View</option>
<option value="detailed">Detailed View</option>
</select>
<label for="filterDepositFrom">Deposit Date From:</label>
<input type="date" id="filterDepositFrom" />
<label for="filterDepositTo">To:</label>
<input type="date" id="filterDepositTo" />
<label for="filterReceiveFrom">Receive Date From:</label>
<input type="date" id="filterReceiveFrom" />
<label for="filterReceiveTo">To:</label>
<input type="date" id="filterReceiveTo" />
<label for="searchBy">Search By:</label>
<select id="searchBy"> <!-- Options dynamically populated by JS -->
</select>
<input type="text" id="fileSearch" placeholder="Enter search
value..." />
</div>
<div class="table-container">
<div id="summaryContainer">
<table id="fileSummaryTable">
<thead>
<!-- *** HEADERS RESTORED *** -->
<tr>
<th>Sr No.</th>
<th data-sort="fileName">File Name</th>
<th data-sort="sender">Sender</th>
<th data-sort="receiver">Receiver</th>
<th data-sort="depositDate">Deposit Date</th>
<th data-sort="receiveDate">Receive Date</th>
<th data-sort="totalAmount">Total Amount</th>
<th>Reconcile Status</th>
<th>Total Reconciled Amount</th> <!-- Restored -->
<th>Total Non-Reconciled Amount</th> <!-- Restored -->
<th>BAI Difference</th> <!-- Restored -->
</tr>
</thead>
<tbody><!-- Populated by JS --></tbody>
</table>
</div>
<div id="detailedAggregatedContainer" style="display:none;">
<!-- Populated by JS -->
</div>
</div>
</section>
<section id="eraDetails" class="content-section" style="display:none;">
<h2><i class="fas fa-file-alt"></i> ERA File</h2>
<!-- *** FILTERS ARE CORRECTLY HERE *** -->
<div class="filters-container">
<label for="eraDetailViewSelect">View:</label>
<select id="eraDetailViewSelect">
<option value="summary" selected>Summary View</option>
<option value="detailed">Detailed View</option>
</select>
<label for="eraSearchBy">Search By:</label>
<select id="eraSearchBy"> <!-- Options dynamically populated by JS -->
</select>
<label for="eraFileSearch">Search Value:</label>
<input type="text" id="eraFileSearch" placeholder="Enter search
value..." />
<label for="filterCheckDateFrom">Check Date From:</label>
<input type="date" id="filterCheckDateFrom" />
<label for="filterCheckDateTo">To:</label>
<input type="date" id="filterCheckDateTo" />
</div>
<div class="table-container" id="eraTableContainer">
<div id="eraSummaryContainer">
<table id="eraSummaryTable">
<thead>
<!-- *** HEADERS RESTORED *** -->
<tr>
<th>Sr No.</th>
<th class="era-context-menu-trigger-header">File Name</th>
<th>Payer Name</th>
<th>Payee Name</th>
<th>Payee ID</th>
<th>Check Date</th>
<th>Check/EFT</th>
<th>Check/EFT Number</th>
<th>Amount</th>
<th>Reconcile Status</th>
<th>Total Reconciled Amount</th> <!-- Restored -->
<th>Total Non-Reconciled Amount</th> <!-- Restored -->
</tr>
</thead>
<tbody><!-- Populated by JS --></tbody>
</table>
</div>
<div id="eraDetailedAggregatedContainer" style="display:none;">
<!-- Populated by JS -->
</div>
</div>
</section>
<!-- ... (reconcile, nonReconcile sections - same as previous step) ... -->
<!-- templates/index.html: Inside <section id="reconcile"> -->
<!-- templates/index.html: Inside <section id="reconcile"> -->
<!-- templates/index.html: Inside <section id="reconcile"> -->
<section id="reconcile" class="content-section" style="display:none;">
<h2><i class="fas fa-check-double"></i> Matched Reconciled Records</h2>
<!-- Main Controls: Run Recon, Type Filter, Message -->
<div class="reconcile-controls filters-container">
<button id="reconcileTriggerButton" class="button"><i class="fas fa-sync-
alt"></i> Run Reconciliation</button>
<div class="filter-item">
<label for="reconcileFilterType">Show:</label>
<select id="reconcileFilterType">
<option value="all" selected>All</option>
<option value="auto">Auto Reconciled</option>
<option value="manual">Manual Reconciled</option>
</select>
</div>
<div id="reconcileMessage" style="margin-top: 10px; flex-grow: 1;"></div>
</div>
<!-- Container for TWO tables -->
<div class="reconcile-tables-container">
<div class="reconcile-table"> <!-- Table for BAI -->
<h3>BAI Details (Matched)</h3>
<!-- BAI Reconciled Filters -->
<div class="table-filter-controls filters-container"> <!-- Specific
container for table filters -->
<div class="filter-item">
<label for="baiReconcileSearchBy">Search By:</label>
<select id="baiReconcileSearchBy"> <!-- Options populated by JS
--> </select>
<input type="text" id="baiReconcileSearch" placeholder="Filter
BAI...">
</div>
</div>
<div class="table-container">
<div id="baiReconcileTableContainer"></div> <!-- Target for BAI
table -->
</div>
</div>
<div class="reconcile-table"> <!-- Table for ERA -->
<h3>ERA Summary (Matched)</h3>
<!-- ERA Reconciled Filters -->
<div class="table-filter-controls filters-container"> <!-- Specific
container for table filters -->
<div class="filter-item">
<label for="eraReconcileSearchBy">Search By:</label>
<select id="eraReconcileSearchBy"> <!-- Options populated by JS
--> </select>
<input type="text" id="eraReconcileSearch" placeholder="Filter
ERA...">
</div>
</div>
<div class="table-container">
<div id="eraReconcileTableContainer"></div> <!-- Target for ERA
table -->
</div>
</div>
</div>
</section>
<!-- templates/index.html: Verify <section id="nonReconcile"> -->
<!-- templates/index.html: Verify <section id="nonReconcile"> -->
<section id="nonReconcile" class="content-section" style="display:none;">
<h2><i class="fas fa-times-circle"></i> Non-Reconciled Records</h2>
<p>These records could not be automatically matched or are partially
reconciled. Select one BAI Detail and one ERA Summary to reconcile manually.</p>
<!-- Manual Reconcile Button Container -->
<div id="manualReconcileControlsContainer" class="filters-container">
<div id="manualReconcileControls" class="filter-item">
<!-- Button added by JS -->
</div>
</div>
<!-- Container for the two tables -->
<div class="reconcile-tables-container">
<div class="reconcile-table"> <!-- Container for BAI Table -->
<h3>BAI Details (Unmatched/Partial)</h3>
<!-- BAI Non-Reconciled Filters -->
<div class="table-filter-controls filters-container"> <!-- Specific
container for table filters -->
<div class="filter-item">
<label for="baiNonReconcileSearchBy">Search By:</label>
<select id="baiNonReconcileSearchBy"> <!-- Options populated by
JS --> </select>
<input type="text" id="baiNonReconcileSearch"
placeholder="Filter BAI...">
</div>
</div>
<div class="table-container">
<div id="baiNonReconcileTableContainer"></div> <!-- Target for BAI
table -->
</div>
</div>
<div class="reconcile-table"> <!-- Container for ERA Table -->
<h3>ERA Summary (Unmatched)</h3>
<!-- ERA Non-Reconciled Filters -->
<div class="table-filter-controls filters-container"> <!-- Specific
container for table filters -->
<div class="filter-item">
<label for="eraNonReconcileSearchBy">Search By:</label>
<select id="eraNonReconcileSearchBy"> <!-- Options populated by
JS --> </select>
<input type="text" id="eraNonReconcileSearch"
placeholder="Filter ERA...">
</div>
</div>
<div class="table-container">
<div id="eraNonReconcileTableContainer"></div> <!-- Target for ERA
table -->
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Context Menu -->
<div id="contextMenu" class="context-menu" style="display: none;"></div>
<!-- Friendly View Modal -->
<div id="friendlyModal" class="modal">
<div class="modal-content">
<span class="close">×</span>
<pre id="friendlyViewContent"></pre>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>
templates/login.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
input:focus { --tw-ring-color: #2563eb; }
/* Basic Flash Message Styling (Tailwind compatible) */
.alert { padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid
transparent; border-radius: 0.375rem; }
.alert-danger { color: #721c24; background-color: #f8d7da; border-color:
#f5c6cb; }
.alert-warning { color: #856404; background-color: #fff3cd; border-color:
#ffeeba; }
.alert-success { color: #155724; background-color: #d4edda; border-color:
#c3e6cb; }
.alert-info { color: #0c5460; background-color: #d1ecf1; border-color:
#bee5eb; }
</style>
</head>
<body class="bg-gradient-to-br from-gray-100 via-white to-gray-200 min-h-screen
flex items-center justify-center p-4">
<main class="bg-white w-full max-w-md p-8 md:p-10 rounded-xl shadow-lg">
<h1 class="text-3xl font-bold text-center text-gray-800 mb-8">
Login to Your Account
</h1>
<!-- Display Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4">
{% for category, message in messages %}
<div class="alert alert-{{ category }}" role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- End Flash Messages -->
<!-- Point action to the /login endpoint -->
<form id="loginForm" action="{{ url_for('login') }}" method="POST"
novalidate>
<!-- Username or Email -->
<div class="mb-5">
<label for="identifier" class="block mb-2 text-sm font-medium text-
gray-700">Username or Email</label>
<input type="text" id="identifier" name="identifier"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-
transparent transition duration-200"
placeholder="Enter your username or email" required
value="{{ request.form.identifier or '' }}">
</div>
<!-- Password -->
<div class="mb-8 relative">
<label for="password" class="block mb-2 text-sm font-medium text-
gray-700">Password</label>
<input type="password" id="password" name="password"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-
transparent transition duration-200"
placeholder="Enter your password" required>
<!-- Add forgot password link if needed later -->
</div>
<!-- Submit Button -->
<button type="submit"
class="w-full bg-gradient-to-r from-blue-600 to-indigo-600
hover:from-blue-700 hover:to-indigo-700 text-white font-semibold py-3 px-4 rounded-
lg shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-
500 transition duration-300 ease-in-out">
Login
</button>
<!-- No Registration Link here as requested -->
</form>
</main>
</body>
</html>
static/script.js:
// static/script.js:
document.addEventListener('DOMContentLoaded', function () {
// --- Keep existing variables ---
const sidebarButtons = document.querySelectorAll('.sidebar-button');
const contentSections = document.querySelectorAll('.content-section');
const uploadButton = document.getElementById('uploadButton');
const eraFileSearchInput = document.getElementById('eraFileSearch');
const fileSearchInput = document.getElementById('fileSearch');
const reconcileTriggerButton = document.getElementById('reconcileTriggerButton');
const reconcileMessageDiv = document.getElementById('reconcileMessage');
const reconcileFilterType = document.getElementById('reconcileFilterType'); //
Existing type filter
let monthlyPieChartInstances = []; // UPDATED: Holds multiple monthly pie chart
instances
let monthlyBreakdownChartInstance = null; // NEW: Monthly chart instance
// --- NEW: Get new filter elements ---
const baiReconcileSearchBy = document.getElementById('baiReconcileSearchBy');
const baiReconcileSearch = document.getElementById('baiReconcileSearch');
const eraReconcileSearchBy = document.getElementById('eraReconcileSearchBy');
const eraReconcileSearch = document.getElementById('eraReconcileSearch');
const monthlyBreakdownContainer =
document.getElementById('monthlyBreakdownContainer'); // The main card container
const monthlyPieChartsArea = document.getElementById('monthlyPieChartsArea'); //
The inner area for charts
const baiNonReconcileSearchBy =
document.getElementById('baiNonReconcileSearchBy');
const baiNonReconcileSearch = document.getElementById('baiNonReconcileSearch');
const eraNonReconcileSearchBy =
document.getElementById('eraNonReconcileSearchBy');
const eraNonReconcileSearch = document.getElementById('eraNonReconcileSearch');
const filterBaiMonthFrom = document.getElementById('filterBaiMonthFrom');
const filterBaiMonthTo = document.getElementById('filterBaiMonthTo');
const applyBaiDateFilterButton =
document.getElementById('applyBaiDateFilterButton');
const clearBaiDateFilterButton =
document.getElementById('clearBaiDateFilterButton');
const baiDateFilterLabel = document.getElementById('baiDateFilterLabel'); // Label
on BAI card
const monthlyReconChartContainer =
document.getElementById('monthlyReconChartContainer'); // Container for new chart
const monthlyChartFilterLabel = document.getElementById('monthlyChartFilterLabel');
// Label on new chart
// static/script.js: Inside DOMContentLoaded
// ... (existing elements) ...
const monthlyBreakdownChartContainer =
document.getElementById('monthlyBreakdownChartContainer');
const monthlyBreakdownFilterLabel =
document.getElementById('monthlyBreakdownFilterLabel');
const monthlyBreakdownMessage = document.getElementById('monthlyBreakdownMessage');
// ... (rest of DOMContentLoaded) ...
// static/script.js: Near the top with other global variables
// ... other variables ...
let baiPieChartInstance = null; // NEW: Chart instance variables
let eraPieChartInstance = null; // NEW
let monthlyReconChartInstance = null; // NEW: Monthly chart instance
// static/script.js: Inside the DOMContentLoaded listener
// ... (get existing elements like sidebarButtons, contentSections, etc.) ...
// --- Get Sidebar Elements ---
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('mainContent');
const sidebarToggleIcon = document.getElementById('sidebarToggleIcon'); // Get
the ICON button
// --- Sidebar Toggle Event Listener (using the icon button) ---
if (sidebarToggleIcon && sidebar && mainContent) {
sidebarToggleIcon.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
// Update the icon based on the state
const icon = sidebarToggleIcon.querySelector('i');
if (sidebar.classList.contains('collapsed')) {
// Sidebar is now collapsed - show expand icon (e.g., chevron right)
icon.classList.remove('fa-bars');
icon.classList.add('fa-chevron-right');
sidebarToggleIcon.setAttribute('aria-label', 'Expand Sidebar'); // ARIA
label for accessibility
} else {
// Sidebar is now expanded - show collapse icon (e.g., bars or chevron
left)
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-bars'); // Or fa-chevron-left if preferred
sidebarToggleIcon.setAttribute('aria-label', 'Collapse Sidebar'); // ARIA
label
}
});
// --- Initial Icon Setup (based on starting state) ---
// Since we start collapsed by default now:
const initialIcon = sidebarToggleIcon.querySelector('i');
if (initialIcon) {
initialIcon.classList.remove('fa-bars');
initialIcon.classList.add('fa-chevron-right');
sidebarToggleIcon.setAttribute('aria-label', 'Expand Sidebar');
}
// --- End Initial Icon Setup ---
} else {
console.warn("Sidebar toggle elements not found.");
}
// ... (rest of DOMContentLoaded) ...
if (applyBaiDateFilterButton) {
applyBaiDateFilterButton.addEventListener('click', () => {
loadDashboardData(); // Reload data with filter values
});
}
if (clearBaiDateFilterButton) {
clearBaiDateFilterButton.addEventListener('click', () => {
if (filterBaiMonthFrom) filterBaiMonthFrom.value = '';
if (filterBaiMonthTo) filterBaiMonthTo.value = '';
loadDashboardData(); // Reload data without filters
});
}
// ... (rest of existing variables like allFiles, allEraFiles, etc.) ...
// --- Add event listeners for NEW filter elements ---
if (reconcileFilterType) reconcileFilterType.addEventListener('change',
applyAllReconciledFilters); // Use combined filter function
if (baiReconcileSearchBy) baiReconcileSearchBy.addEventListener('change',
applyAllReconciledFilters);
if (baiReconcileSearch) baiReconcileSearch.addEventListener('input',
applyAllReconciledFilters);
if (eraReconcileSearchBy) eraReconcileSearchBy.addEventListener('change',
applyAllReconciledFilters);
if (eraReconcileSearch) eraReconcileSearch.addEventListener('input',
applyAllReconciledFilters);
if (baiNonReconcileSearchBy) baiNonReconcileSearchBy.addEventListener('change',
applyAllNonReconciledFilters);
if (baiNonReconcileSearch) baiNonReconcileSearch.addEventListener('input',
applyAllNonReconciledFilters);
if (eraNonReconcileSearchBy) eraNonReconcileSearchBy.addEventListener('change',
applyAllNonReconciledFilters);
if (eraNonReconcileSearch) eraNonReconcileSearch.addEventListener('input',
applyAllNonReconciledFilters);
let allFiles = []; // Holds BAI summaries
let allEraFiles = []; // Holds ERA summaries
let currentSection = null;
let allReconciledPairs = []; // To store fetched reconciled data
let selectedBaiDetail = null; // { id: null, remainingAmount: null }
let selectedEraSummary = null; // { id: null, amount: null }
let allReconciledBai = [];
let allReconciledEra = [];
function updateEraSearchOptions(view) {
const searchBySelect = document.getElementById("eraSearchBy");
if (!searchBySelect) return;
const currentValue = searchBySelect.value;
searchBySelect.innerHTML = "";
let options = [];
// Define options for ERA Summary View
if (view === "summary") {
options = [
{ value: "file_name", text: "File Name" },
{ value: "payer_name", text: "Payer Name" },
{ value: "payee_name", text: "Payee Name" },
{ value: "payee_id", text: "Payee ID" },
{ value: "trn_number", text: "Check/EFT Number" },
{ value: "amount", text: "Amount" },
{ value: "check_eft", text: "Check/EFT Type" },
{ value: "reconcileStatus", text: "Reconcile Status" } // File-level status
];
// Define options for ERA Detailed View (Aggregated)
} else if (view === "detailed") {
options = [
// File-level properties available in detailed view context
{ value: "file_name", text: "File Name" },
{ value: "trn_number", text: "Check/EFT Number" },
// Detail-level properties from ERA_Detail
{ value: "patient_name", text: "Patient Name" },
{ value: "account_number", text: "ACNT" },
{ value: "icn", text: "ICN" },
{ value: "billed_amount", text: "Billed Amount" },
{ value: "paid_amount", text: "Paid Amount" },
// You could add file-level 'reconcileStatus' here too if needed
// { value: "reconcileStatus", text: "Reconcile Status (File)" }
];
}
options.forEach(opt => {
const optionElem = document.createElement("option");
optionElem.value = opt.value;
optionElem.textContent = opt.text;
searchBySelect.appendChild(optionElem);
});
const validValues = options.map(opt => opt.value);
if (validValues.includes(currentValue)) {
searchBySelect.value = currentValue;
} else if (validValues.length > 0) {
searchBySelect.value = validValues[0];
}
}
// Function to update Search By dropdown options based on view
function updateSearchByOptions(view) {
const searchBySelect = document.getElementById("searchBy");
if (!searchBySelect) return;
// Save current selected value
const currentValue = searchBySelect.value;
searchBySelect.innerHTML = "";
let options = [];
// Define options for BAI Summary View
if (view === "summary") {
options = [
{ value: "file_name", text: "File Name" },
{ value: "sender", text: "Sender" },
{ value: "receiver", text: "Receiver" },
{ value: "total_amount", text: "Total Amount" },
{ value: "reconcileStatus", text: "Reconcile Status" } // File-level status
];
// Define options for BAI Detailed View (Aggregated)
} else if (view === "detailed") {
options = [
{ value: "file_name", text: "Filename" }, // File-level property
{ value: "customer_reference", text: "Customer Reference" }, // Detail-
level property
{ value: "company_id", text: "Company ID (Payer ID)" }, // Detail-level
property
{ value: "payer_name", text: "Payer Name" }, // Detail-level property
{ value: "recipient_name", text: "Recipient Name" }, // Detail-level
property
{ value: "amount", text: "Amount" }, // Detail-level property
{ value: "reconcileStatus", text: "Reconcile Status" } // File-level
property
];
}
options.forEach(opt => {
const optionElem = document.createElement("option");
optionElem.value = opt.value;
optionElem.textContent = opt.text;
searchBySelect.appendChild(optionElem);
});
// Restore previous selection if it is still valid; otherwise set to the first
option.
const validValues = options.map(opt => opt.value);
if (validValues.includes(currentValue)) {
searchBySelect.value = currentValue;
} else if (validValues.length > 0) {
searchBySelect.value = validValues[0];
}
}
// static/script.js: Add these new functions
function updateReconcileSearchOptions() {
const baiSelect = document.getElementById('baiReconcileSearchBy');
const eraSelect = document.getElementById('eraReconcileSearchBy');
if (baiSelect) {
baiSelect.innerHTML = ''; // Clear existing
// Headers: File, Cust Ref, Orig Amt, Reconciled, Remaining, Status, Type
const baiOptions = [
{ value: "bai_file_name", text: "File Name" },
{ value: "customer_reference", text: "Cust Ref" },
{ value: "bai_display_amount", text: "Orig Amt" },
{ value: "bai_total_reconciled", text: "Reconciled Amt" },
{ value: "bai_remaining_amount", text: "Remaining Amt" },
{ value: "bai_status", text: "Status" },
{ value: "manual_reconciliation", text: "Type (Manual/Auto)" }
];
baiOptions.forEach(opt => baiSelect.add(new Option(opt.text, opt.value)));
}
if (eraSelect) {
eraSelect.innerHTML = ''; // Clear existing
// Headers: File Name, Check/EFT No., Check Date, Amount, Type
const eraOptions = [
{ value: "era_file_name", text: "File Name" },
{ value: "trn_number", text: "Check/EFT No." },
{ value: "era_check_date", text: "Check Date" },
{ value: "era_amount", text: "Amount" },
{ value: "manual_reconciliation", text: "Type (Manual/Auto)" }
];
eraOptions.forEach(opt => eraSelect.add(new Option(opt.text, opt.value)));
}
}
function updateNonReconcileSearchOptions() {
const baiSelect = document.getElementById('baiNonReconcileSearchBy');
const eraSelect = document.getElementById('eraNonReconcileSearchBy');
if (baiSelect) {
baiSelect.innerHTML = ''; // Clear existing
// Headers: File Name, Cust Ref., Receive Date, Total Amt, Reconciled Amt,
Remaining Amt, Status
const baiOptions = [
{ value: "bai_file_name", text: "File Name" },
{ value: "customer_reference", text: "Cust Ref." },
{ value: "bai_receive_date", text: "Receive Date" },
{ value: "bai_total_parsed_amount", text: "Total Amt" },
{ value: "bai_reconciled_amount", text: "Reconciled Amt" },
{ value: "bai_remaining_amount", text: "Remaining Amt" },
{ value: "bai_status", text: "Status" }
];
baiOptions.forEach(opt => baiSelect.add(new Option(opt.text, opt.value)));
}
if (eraSelect) {
eraSelect.innerHTML = ''; // Clear existing
// Headers: File Name, Check/EFT No., Check Date, Amount
const eraOptions = [
{ value: "era_file_name", text: "File Name" },
{ value: "trn_number", text: "Check/EFT No." },
{ value: "era_check_date", text: "Check Date" },
{ value: "era_amount", text: "Amount" }
];
eraOptions.forEach(opt => eraSelect.add(new Option(opt.text, opt.value)));
}
}
// Date formatting helper
function reformatDate(dateStr) {
if (!dateStr) return "";
dateStr = dateStr.trim();
// Handle YYYYMMDD -> MM/DD/YYYY
if (dateStr.length === 8 && /^\d{8}$/.test(dateStr)) {
return dateStr.substr(4, 2) + "/" + dateStr.substr(6, 2) + "/" +
dateStr.substr(0, 4);
// Handle YYMMDD -> MM/DD/YYYY (assuming 20xx)
} else if (dateStr.length === 6 && /^\d{6}$/.test(dateStr)) {
return dateStr.substr(2, 2) + "/" + dateStr.substr(4, 2) + "/20" +
dateStr.substr(0, 2);
// Handle existing MM/DD/YYYY or MM/DD/YY
} else if (dateStr.includes("/")) {
return dateStr;
}
return dateStr; // Return original if format unknown
}
function parseAmountStrToNum(amountStr) {
if (typeof amountStr !== 'string') return 0;
const cleaned = amountStr.replace(/[$,]/g, '').trim();
if (cleaned === '') return 0;
if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
return parseFloat(cleaned.substring(1, cleaned.length - 1)) * -1 || 0;
}
return parseFloat(cleaned) || 0;
}
// Convert MM/DD/YY or MM/DD/YYYY to YYYY-MM-DD for date input comparison
function mmddyyToIso(dateStr) {
if (!dateStr) return null;
const parts = dateStr.split("/");
if (parts.length !== 3) return null;
let month = parts[0].padStart(2, "0");
let day = parts[1].padStart(2, "0");
let year = parts[2];
// Handle 2-digit year, assume 20xx
if (year.length === 2) {
year = "20" + year;
} else if (year.length !== 4) {
return null; // Invalid year format
}
if (isNaN(parseInt(month)) || isNaN(parseInt(day)) || isNaN(parseInt(year))) {
return null;
}
return `${year}-${month}-${day}`;
}
function prepareSearchData(files) {
// Ensure files is an array before mapping
if (!Array.isArray(files)) {
console.error("prepareSearchData received non-array:", files);
return []; // Return empty array on error
}
const updatedFiles = files.map(file => {
// ... (keep existing mapping logic) ...
let searchDataParts = [
file.file_name || '',
file.sender || '',
file.receiver || '',
(file.total_amount || "").replace("$", "").replace(/,/g, ""),
(file.reconcileStatus || '').toLowerCase()
];
file.searchData = searchDataParts.join(" ").toLowerCase();
let totalAmtStr = (file.total_amount || "").replace("$", "").replace(/,/g,
"");
file.totalAmountNum = parseFloat(totalAmtStr) || 0;
return file;
});
return updatedFiles;
}
// --- BAI Handling ---
// Fetches details for *reconciliation calculations only* and prepares search
data
async function updateCustomerReferencesForRecon(files) {
const updatedFiles = await Promise.all(files.map(async (file) => {
try {
const res = await fetch(`/files/${file.id}`);
if (!res.ok) throw new Error(`Failed to fetch details for ${file.id}`);
const fileData = await res.json();
let recSum = 0;
let nonRecSum = 0;
let detailsTotal = 0;
// Initialize searchDataParts with summary-level fields
let searchDataParts = [
file.file_name || '',
file.sender || '',
file.receiver || '',
(file.total_amount || "").replace("$", "").replace(/,/g, "") // Add raw
amount for potential search
];
if (fileData.details && Array.isArray(fileData.details)) {
fileData.details.forEach(detailRow => {
// Add detail-level fields to searchDataParts for broad searching in
detailed view
if (detailRow.customer_reference)
searchDataParts.push(detailRow.customer_reference);
if (detailRow.company_id) searchDataParts.push(detailRow.company_id);
if (detailRow.payer_name) searchDataParts.push(detailRow.payer_name);
if (detailRow.recipient_name)
searchDataParts.push(detailRow.recipient_name);
if (detailRow.amount) searchDataParts.push((detailRow.amount ||
"").replace("$", "").replace(/,/g, "")); // Add raw amount
let amtStr = (detailRow.amount || "").replace("$", "").replace(/,/g,
"");
let amtNum = parseFloat(amtStr) || 0;
// *** Placeholder: Actual reconciliation logic would go here ***
// For now, assume all details are non-reconciled for calculation
purposes
nonRecSum += amtNum;
detailsTotal += amtNum;
});
}
file.reconciledSum = recSum;
file.nonReconciledSum = nonRecSum;
file.detailsTotal = detailsTotal;
let totalAmtStr = (file.total_amount || "").replace("$", "").replace(/,/g,
"");
let totalAmountNum = parseFloat(totalAmtStr) || 0;
file.totalAmountNum = totalAmountNum;
file.BAIDifference = totalAmountNum - file.detailsTotal;
// *** Placeholder: Determine file-level reconcileStatus based on recSum,
nonRecSum, totalAmountNum ***
let reconcileStatus = "NO"; // Default assumption
if (recSum === 0 && nonRecSum === 0 && totalAmountNum === 0) { // Or based
on whether details exist
reconcileStatus = "N/A";
} else if (Math.abs(recSum - totalAmountNum) < 0.01 && totalAmountNum !==
0) { // Assuming recSum represents matched amount
reconcileStatus = "YES";
} else if (recSum > 0 && recSum < totalAmountNum) {
reconcileStatus = "PARTIAL";
} else if (recSum > totalAmountNum) {
reconcileStatus = "OVER";
} else if (recSum === 0 && nonRecSum > 0) { // If there are details but
none are reconciled
reconcileStatus = "NO";
}
// Keep N/A if no details/amounts to reconcile
file.reconcileStatus = reconcileStatus; // Store the calculated file-level
status
// Add status to search data
searchDataParts.push(file.reconcileStatus.toLowerCase());
file.searchData = searchDataParts.join(" ").toLowerCase(); // Join all
searchable text
} catch (error) {
console.error("Error processing details for file id", file.id, ":", error);
file.reconciledSum = 0;
file.nonReconciledSum = 0;
file.detailsTotal = 0;
file.totalAmountNum = 0;
file.BAIDifference = 0;
file.reconcileStatus = "ERROR"; // Indicate error
// Basic search data in case of error
file.searchData = [file.file_name || '', file.sender || '', file.receiver
|| '', file.total_amount || ''].join(" ").toLowerCase();
}
return file;
}));
return updatedFiles;
}
// --- Sidebar Navigation ---
// --- Sidebar Navigation (MODIFIED) ---
// static/script.js: Modify the sidebar button click listener
sidebarButtons.forEach((button) => {
button.addEventListener("click", function (event) {
event.preventDefault();
sidebarButtons.forEach((btn) => btn.classList.remove("active"));
this.classList.add("active");
const sectionId = this.dataset.section;
contentSections.forEach((section) => (section.style.display = "none"));
const selectedSection = document.getElementById(sectionId);
if (selectedSection) {
selectedSection.style.display = "block";
currentSection = sectionId;
// Clear messages from other tabs
const messageDivs = document.querySelectorAll('#uploadMessage,
#eraUploadMessage, #reconcileMessage, #dashboardMessage');
messageDivs.forEach(div => { if(div) div.innerHTML = ''; });
if (sectionId === "dashboard") { // NEW: Handle dashboard selection
loadDashboardData(); // Load data for the dashboard
} else if (sectionId === "baiDetails") {
resetFiltersForSection(sectionId);
updateSearchByOptions(document.getElementById("detailViewSelect").value);
if (allFiles.length === 0) loadFileSummaries(); else applyFilters();
} else if (sectionId === "eraDetails") {
resetFiltersForSection(sectionId);
updateEraSearchOptions(document.getElementById("eraDetailViewSelect").value);
if (allEraFiles.length === 0) loadEraFileSummaries(); else
applyEraViewFilters();
} else if (sectionId === "reconcile") {
resetFiltersForSection(sectionId);
updateBaiReconcileSearchOptions();
updateEraReconcileSearchOptions();
loadReconciledData();
} else if (sectionId === "nonReconcile") {
resetFiltersForSection(sectionId);
updateBaiNonReconcileSearchOptions();
updateEraNonReconcileSearchOptions();
loadNonReconciledData();
} else {
resetFiltersForSection(sectionId); // Reset filters for other sections
like upload
}
}
});
});
function formatCurrency(value) {
const number = Number(value); // Convert Decimal string or number
if (isNaN(number)) {
return "$0.00";
}
return `$${number.toLocaleString('en-US', { minimumFractionDigits: 2,
maximumFractionDigits: 2 })}`;
}
// Function to load data for the dashboard
async function loadDashboardData() {
const dashboardMessage = document.getElementById('dashboardMessage');
const baiCard = document.getElementById('baiDashboardCard');
const eraCard = document.getElementById('eraDashboardCard');
const baiDateLabel = document.getElementById('baiDateFilterLabel');
// Get references to NEW monthly breakdown elements
const monthlyContainer =
document.getElementById('monthlyBreakdownChartContainer');
const monthlyLabel = document.getElementById('monthlyBreakdownFilterLabel');
const monthlyMsgDiv = document.getElementById('monthlyBreakdownMessage');
// --- Read Filter Values ---
const fromMonth = filterBaiMonthFrom?.value || ''; // Get YYYY-MM
const toMonth = filterBaiMonthTo?.value || ''; // Get YYYY-MM
// --- Reset UI ---
// Reset stats to loading state
document.getElementById('totalBaiDetails').textContent = 'Loading...';
document.getElementById('reconciledBaiDetails').textContent = 'Loading...';
document.getElementById('nonReconciledBaiDetails').textContent = 'Loading...';
document.getElementById('totalBaiAmount').textContent = 'Loading...';
document.getElementById('reconciledBaiAmount').textContent = 'Loading...';
document.getElementById('nonReconciledBaiAmount').textContent = 'Loading...';
document.getElementById('totalEraSummaries').textContent = 'Loading...';
document.getElementById('reconciledEraSummaries').textContent = 'Loading...';
document.getElementById('nonReconciledEraSummaries').textContent = 'Loading...';
document.getElementById('totalEraAmount').textContent = 'Loading...';
document.getElementById('reconciledEraAmount').textContent = 'Loading...';
document.getElementById('nonReconciledEraAmount').textContent = 'Loading...';
if (dashboardMessage) dashboardMessage.innerHTML = ''; // Clear previous messages
if (monthlyContainer) monthlyContainer.style.display = 'none'; // Hide monthly
chart initially
if (monthlyMsgDiv) monthlyMsgDiv.textContent = ''; // Clear monthly message
if (baiDateLabel) baiDateLabel.textContent = '(Loading...)';
if (monthlyLabel) monthlyLabel.textContent = '';
// --- Build Fetch URL ---
let url = '/dashboard_data';
const params = new URLSearchParams();
let filterApplied = false;
let filterLabelText = '(All Time)'; // Default label for BAI card
if (fromMonth && toMonth) {
if (/^\d{4}-\d{2}$/.test(fromMonth) && /^\d{4}-\d{2}$/.test(toMonth)) {
if (fromMonth <= toMonth) {
params.append('from_date', fromMonth);
params.append('to_date', toMonth);
url += `?${params.toString()}`;
filterApplied = true;
filterLabelText = `(${fromMonth} to ${toMonth})`; // Label for date
range
} else {
displayMessage(dashboardMessage, 'Invalid Date Range: "From" date
cannot be after "To" date.', 'error-message');
}
} else if (fromMonth || toMonth) {
displayMessage(dashboardMessage, 'Please select both From and To dates to
apply the filter.', 'info-message');
}
}
if (baiDateLabel) baiDateLabel.textContent = filterLabelText; // Update BAI card
label
try {
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: `HTTP error
${response.status}` }));
throw new Error(errorData.error || `HTTP error ${response.status}`);
}
const data = await response.json();
// --- Update Text Statistics (BAI reflects filter, ERA is all-time) ---
updateDashboardStats(data);
// --- Update/Create Pie Charts ---
createOrUpdateBaiPieChart(data.bai || {}); // BAI Pie reflects filter
createOrUpdateEraPieChart(data.era || {}); // ERA Pie is always all-time
// --- *** Handle Monthly Breakdown Chart *** ---
if (filterApplied && data.monthly_breakdown && data.monthly_breakdown.length
> 0) {
console.log("Monthly breakdown data found:", data.monthly_breakdown);
if (monthlyBreakdownContainer) monthlyBreakdownContainer.style.display =
'block'; // Show container
if (monthlyBreakdownFilterLabel) monthlyBreakdownFilterLabel.textContent =
filterLabelText; // Set label
createOrUpdateMonthlyPieCharts(data.monthly_breakdown); // *** CHANGED:
Call Pie chart function ***
if (monthlyBreakdownMessage) monthlyBreakdownMessage.textContent = ''; //
Clear any "no data" message
} else {
clearMonthlyPieCharts(); // *** CHANGED: Call function to clear multiple
pies ***
if (monthlyBreakdownContainer) monthlyBreakdownContainer.style.display =
'none'; // Hide if no data/filter
if (filterApplied && monthlyBreakdownMessage) {
monthlyBreakdownMessage.textContent = 'No monthly data available for
the selected period.';
} else if (monthlyBreakdownMessage) {
monthlyBreakdownMessage.textContent = 'Select a date range to view
monthly breakdown.';
}
}
// --- *** End Monthly Breakdown Handling *** ---
// --- Add Click Listeners (if not already added) ---
if (baiCard && !baiCard.dataset.listenerAdded) {
baiCard.addEventListener('click', () => {
const baiButton = document.querySelector('.sidebar-button[data-
section="baiDetails"]');
if (baiButton) baiButton.click();
});
baiCard.dataset.listenerAdded = 'true';
}
if (eraCard && !eraCard.dataset.listenerAdded) {
eraCard.addEventListener('click', () => {
const eraButton = document.querySelector('.sidebar-button[data-
section="eraDetails"]');
if (eraButton) eraButton.click();
});
eraCard.dataset.listenerAdded = 'true';
}
} catch (error) {
console.error("Error loading dashboard data:", error);
if (dashboardMessage) displayMessage(dashboardMessage, `Error loading
dashboard: ${error.message}`, 'error-message');
// Clear charts on error
clearChart(baiPieChartInstance, 'baiPieChart');
clearChart(eraPieChartInstance, 'eraPieChart');
clearChart(monthlyBreakdownChartInstance, 'monthlyBreakdownChart');
monthlyBreakdownChartInstance = null;
if (monthlyContainer) monthlyContainer.style.display = 'none';
if (monthlyMsgDiv) monthlyMsgDiv.textContent = 'Error loading monthly
data.';
if (baiDateLabel) baiDateLabel.textContent = '(Error)';
}
}
// static/script.js: Replace the old bar chart function with this new pie chart
function
/**
* Creates or updates multiple Pie charts for monthly breakdown.
* @param {Array} monthlyData - Array of objects, each representing a month's data.
*/
function createOrUpdateMonthlyPieCharts(monthlyData) {
const chartsArea = document.getElementById('monthlyPieChartsArea');
const messageDiv = document.getElementById('monthlyBreakdownMessage');
if (!chartsArea || !messageDiv) {
console.error("Monthly pie charts area or message div not found");
return;
}
// Clear previous charts and messages before creating new ones
clearMonthlyPieCharts(); // Use the dedicated clear function
chartsArea.innerHTML = ''; // Clear the area container
messageDiv.textContent = ''; // Clear message
if (!monthlyData || monthlyData.length === 0) {
messageDiv.textContent = 'No monthly data available for the selected
period.';
return;
}
// Keep track of the newly created chart instances
const newInstances = [];
monthlyData.forEach((monthInfo, index) => {
const monthYear = monthInfo.month_year;
// Calculate combined counts for the pie chart
const totalBai = monthInfo.total_bai || 0;
const reconciledBai = monthInfo.reconciled_bai || 0;
const totalEra = monthInfo.total_era || 0;
const reconciledEra = monthInfo.reconciled_era || 0;
const totalFiles = totalBai + totalEra;
const totalReconciledFiles = reconciledBai + reconciledEra;
const totalNonReconciledFiles = totalFiles - totalReconciledFiles;
// Combine reconciliation dates for the tooltip
const combinedDates = [monthInfo.bai_recon_dates, monthInfo.era_recon_dates]
.filter(Boolean) // Remove empty strings/nulls
.join('; ') || "N/A"; // Join with separator or show
N/A
// Create elements for this month's chart
const monthContainer = document.createElement('div');
monthContainer.classList.add('monthly-pie-item');
const title = document.createElement('h4');
title.textContent = monthYear;
monthContainer.appendChild(title);
const canvas = document.createElement('canvas');
// Ensure unique ID for each canvas
canvas.id = `monthlyPieCanvas-${monthYear}-${index}`;
monthContainer.appendChild(canvas);
chartsArea.appendChild(monthContainer); // Add to the main charts area
// Prepare data for this pie chart
const pieData = {
labels: [
`Reconciled (${totalReconciledFiles})`,
`Non-Reconciled (${totalNonReconciledFiles})`
],
datasets: [{
data: [totalReconciledFiles, totalNonReconciledFiles],
backgroundColor: [
'#2ecc71', // Green for reconciled
'#e74c3c' // Red for non-reconciled
],
borderColor: '#ffffff',
borderWidth: 1,
// Custom data for tooltip - we'll access combinedDates via index
}]
};
// Get context and create chart
const ctx = canvas.getContext('2d');
if (ctx) {
const pieChart = new Chart(ctx, {
type: 'pie',
data: pieData,
options: {
responsive: true,
maintainAspectRatio: true, // Maintain aspect ratio for pies
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 15,
padding: 15
}
},
title: {
display: false, // Title is already in the h4
},
tooltip: {
callbacks: {
// Display the count in the main label
label: function(context) {
return context.label || ''; // Label already has
count
},
// Display reconciliation dates in the tooltip footer
(afterLabel)
afterLabel: function(context) {
// Show dates only for the 'Reconciled' slice
if (context.label.startsWith('Reconciled')) {
// Find the correct date string using the
data index (which matches the monthInfo index)
const datesForMonth =
[monthlyData[context.dataIndex].bai_recon_dates,
monthlyData[context.dataIndex].era_recon_dates]
.filter(Boolean).join(';
') || "N/A";
// Split dates for better readability in
tooltip if needed, or just show string
// For now, just return the string. Add line
breaks if needed.
if (datesForMonth !== "N/A") {
return `Dates: $
{datesForMonth.replace(/, /g, '\n ')}`; // Add newline for readability
}
return "Dates: N/A";
}
return null; // No footer for non-reconciled
slice
}
}
}
}
}
});
newInstances.push(pieChart); // Store the instance
} else {
console.error(`Could not get context for canvas ${canvas.id}`);
}
});
// Update the global tracker
monthlyPieChartInstances = newInstances;
// static/script.js: Add this new function
/**
* Destroys existing monthly pie charts and clears the container.
*/
function clearMonthlyPieCharts() {
// Destroy existing chart instances
if (Array.isArray(monthlyPieChartInstances)) {
monthlyPieChartInstances.forEach(instance => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
monthlyPieChartInstances = []; // Reset the array
}
// Clear the container HTML
const chartsArea = document.getElementById('monthlyPieChartsArea');
if (chartsArea) {
chartsArea.innerHTML = ''; // Remove old canvases etc.
}
// Also clear the message div within the container
const messageDiv = document.getElementById('monthlyBreakdownMessage');
if (messageDiv) {
messageDiv.textContent = '';
}
}
// static/script.js: Add this new function
function createOrUpdateMonthlyReconChart(monthlyData) {
const ctx = document.getElementById('monthlyReconChart')?.getContext('2d');
const messageDiv = document.getElementById('monthlyChartMessage');
if (!ctx || !messageDiv) {
console.error("Monthly chart canvas or message div not found");
return;
}
messageDiv.textContent = ''; // Clear previous messages
if (!monthlyData || monthlyData.length === 0) {
messageDiv.textContent = 'No monthly BAI data available for the selected
period.';
clearChart(monthlyReconChartInstance, 'monthlyReconChart'); // Clear existing
chart
monthlyReconChartInstance = null;
return;
}
const labels = [];
const percentages = [];
const backgroundColors = [];
const borderColors = [];
// Define base colors - you can expand this array for more months
const baseColors = [
'#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6',
'#34495e', '#1abc9c', '#e67e22', '#95a5a6', '#d35400',
'#2980b9', '#c0392b'
];
monthlyData.forEach((monthInfo, index) => {
const monthYear = monthInfo.month_year;
const total = Number(monthInfo.total_monthly_amount || 0);
const reconciled = Number(monthInfo.reconciled_monthly_amount || 0);
let percentage = 0;
if (total > 0) {
percentage = (reconciled / total) * 100;
} else {
// Handle case with 0 total amount for the month (e.g., show 0% or 100%
if reconciled is also 0?)
// Let's show 0% if total is 0, unless reconciled is somehow positive
(data issue)
percentage = (reconciled > 0) ? 100 : 0; // Or handle as 'N/A'? Showing 0
might be clearer.
}
labels.push(`${monthYear} (${percentage.toFixed(1)}%)`); // Label includes
percentage
percentages.push(percentage); // Data for the chart (though we display % in
label)
// Assign colors cyclically
const colorIndex = index % baseColors.length;
backgroundColors.push(baseColors[colorIndex]);
borderColors.push('#ffffff'); // White border for segments
});
const chartData = {
labels: labels, // Labels now include the percentage
datasets: [{
label: 'Monthly Reconciliation %', // Tooltip label
data: percentages, // Raw percentage values
backgroundColor: backgroundColors,
borderColor: borderColors,
borderWidth: 1
}]
};
if (monthlyReconChartInstance) {
// Update existing chart
monthlyReconChartInstance.data = chartData;
monthlyReconChartInstance.update();
} else {
// Create new chart
monthlyReconChartInstance = new Chart(ctx, {
type: 'pie', // Or 'doughnut'
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
// Optional: Use a callback to format legend labels if
needed
// generateLabels: function(chart) { ... }
}
},
tooltip: {
callbacks: {
// Tooltip shows "Month (Percentage %): Value %"
label: function(context) {
let label = context.label || ''; // Already includes
percentage
// let value = context.parsed || 0; // Raw percentage
value
// return `${label}: ${value.toFixed(1)}%`;
return label; // Just return the label as it has the
percentage
}
}
},
title: { // Optional: Add a chart title
display: false, // Already have h3 title
text: 'Monthly BAI Reconciliation Percentage'
}
}
}
});
}
}
function createOrUpdateMonthlyBreakdownChart(monthlyData) {
const ctx = document.getElementById('monthlyBreakdownChart')?.getContext('2d');
const messageDiv = document.getElementById('monthlyBreakdownMessage'); // Get
message div again
if (!ctx || !messageDiv) {
console.error("Monthly breakdown chart canvas or message div not found");
return;
}
messageDiv.textContent = ''; // Clear message
if (!monthlyData || monthlyData.length === 0) {
messageDiv.textContent = 'No monthly data available for the selected
period.';
clearChart(monthlyBreakdownChartInstance, 'monthlyBreakdownChart');
monthlyBreakdownChartInstance = null;
return;
}
// Prepare data for Chart.js
const labels = monthlyData.map(item => item.month_year);
const totalBaiData = monthlyData.map(item => item.total_bai || 0);
const reconciledBaiData = monthlyData.map(item => item.reconciled_bai || 0);
const totalEraData = monthlyData.map(item => item.total_era || 0);
const reconciledEraData = monthlyData.map(item => item.reconciled_era || 0);
// Store reconciliation dates for tooltips (using index alignment)
const baiReconDates = monthlyData.map(item => item.bai_recon_dates || "");
const eraReconDates = monthlyData.map(item => item.era_recon_dates || "");
const chartData = {
labels: labels,
datasets: [
{
label: 'Total BAI Files',
data: totalBaiData,
backgroundColor: 'rgba(54, 162, 235, 0.6)', // Blue
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
// Custom property to link to dates - not standard, used in tooltip
callback
// We'll access baiReconDates/eraReconDates directly by index instead
},
{
label: 'Reconciled BAI Files',
data: reconciledBaiData,
backgroundColor: 'rgba(75, 192, 192, 0.6)', // Green
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
},
{
label: 'Total ERA Files',
data: totalEraData,
backgroundColor: 'rgba(255, 159, 64, 0.6)', // Orange
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 1
},
{
label: 'Reconciled ERA Files',
data: reconciledEraData,
backgroundColor: 'rgba(255, 99, 132, 0.6)', // Red
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
}
]
};
// Chart Configuration
const config = {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: { display: true, text: 'Month' }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Number of Files' },
ticks: {
// Ensure only whole numbers are shown on the Y-axis
stepSize: 1,
callback: function(value) { if (Number.isInteger(value))
{ return value; } },
}
}
},
plugins: {
legend: {
position: 'bottom',
},
tooltip: {
callbacks: {
// Custom tooltip label
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
label += context.parsed.y || 0; // The count
// Add reconciliation dates for reconciled bars
const dataIndex = context.dataIndex; // Index of the
month
if (context.dataset.label === 'Reconciled BAI Files' &&
baiReconDates[dataIndex]) {
label += ` (Dates: ${baiReconDates[dataIndex]})`;
} else if (context.dataset.label === 'Reconciled ERA
Files' && eraReconDates[dataIndex]) {
label += ` (Dates: ${eraReconDates[dataIndex]})`;
}
return label;
}
}
},
title: {
display: false, // Already have h3 title
// text: 'Monthly File Reconciliation Counts'
}
}
}
};
// Create or Update Chart
if (monthlyBreakdownChartInstance) {
monthlyBreakdownChartInstance.data = chartData; // Update data
monthlyBreakdownChartInstance.update();
} else {
monthlyBreakdownChartInstance = new Chart(ctx, config);
}
}
// Function to update the text stats on the dashboard
function updateDashboardStats(data) {
const bai = data.bai || {};
const era = data.era || {};
document.getElementById('totalBaiDetails').textContent =
bai.total_details?.toLocaleString() ?? '0';
document.getElementById('reconciledBaiDetails').textContent =
bai.reconciled_details?.toLocaleString() ?? '0';
document.getElementById('nonReconciledBaiDetails').textContent =
bai.non_reconciled_details?.toLocaleString() ?? '0';
document.getElementById('totalBaiAmount').textContent =
formatCurrency(bai.total_amount);
document.getElementById('reconciledBaiAmount').textContent =
formatCurrency(bai.reconciled_amount);
document.getElementById('nonReconciledBaiAmount').textContent =
formatCurrency(bai.non_reconciled_amount); // Use calculated non-rec amount
document.getElementById('totalEraSummaries').textContent =
era.total_summaries?.toLocaleString() ?? '0';
document.getElementById('reconciledEraSummaries').textContent =
era.reconciled_summaries?.toLocaleString() ?? '0';
document.getElementById('nonReconciledEraSummaries').textContent =
era.non_reconciled_summaries?.toLocaleString() ?? '0';
document.getElementById('totalEraAmount').textContent =
formatCurrency(era.total_amount);
document.getElementById('reconciledEraAmount').textContent =
formatCurrency(era.reconciled_amount);
document.getElementById('nonReconciledEraAmount').textContent =
formatCurrency(era.non_reconciled_amount); // Use calculated non-rec amount
}
// Function to create or update the BAI Pie Chart
function createOrUpdateBaiPieChart(baiData) {
const ctx = document.getElementById('baiPieChart')?.getContext('2d');
if (!ctx) return;
const reconciled = Number(baiData.reconciled_amount || 0);
// Calculate non-reconciled directly for the chart to handle potential
discrepancies
const nonReconciled = Math.max(0, Number(baiData.total_amount || 0) -
reconciled);
const chartData = {
labels: ['Reconciled Amount', 'Non-Reconciled Amount'],
datasets: [{
data: [reconciled, nonReconciled],
backgroundColor: [
'#2ecc71', // Green for reconciled
'#e74c3c', // Red for non-reconciled
// '#f39c12' // Optional: Color for discrepancy if needed
],
borderColor: '#fff',
borderWidth: 1
}]
};
if (baiPieChartInstance) {
// Update existing chart
baiPieChartInstance.data = chartData;
baiPieChartInstance.update();
} else {
// Create new chart
baiPieChartInstance = new Chart(ctx, {
type: 'pie',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.label || '';
if (label) {
label += ': ';
}
if (context.parsed !== null) {
label += formatCurrency(context.parsed);
}
return label;
}
}
}
}
}
});
}
}
// Function to create or update the ERA Pie Chart
function createOrUpdateEraPieChart(eraData) {
const ctx = document.getElementById('eraPieChart')?.getContext('2d');
if (!ctx) return;
const reconciled = Number(eraData.reconciled_amount || 0);
// Calculate non-reconciled directly for the chart
const nonReconciled = Math.max(0, Number(eraData.total_amount || 0) -
reconciled);
const chartData = {
labels: ['Reconciled Amount', 'Non-Reconciled Amount'],
datasets: [{
data: [reconciled, nonReconciled],
backgroundColor: [
'#2ecc71', // Green for reconciled
'#e74c3c', // Red for non-reconciled
],
borderColor: '#fff',
borderWidth: 1
}]
};
if (eraPieChartInstance) {
// Update existing chart
eraPieChartInstance.data = chartData;
eraPieChartInstance.update();
} else {
// Create new chart
eraPieChartInstance = new Chart(ctx, {
type: 'pie',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false, // Allow chart to fill container height
plugins: {
legend: {
position: 'bottom',
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.label || '';
if (label) {
label += ': ';
}
if (context.parsed !== null) {
label += formatCurrency(context.parsed);
}
return label;
}
}
}
}
}
});
}
}
// Helper to clear chart canvas if needed
function clearChart(chartInstance, canvasId) {
if (chartInstance) {
chartInstance.destroy();
// chartInstance = null; // Don't null here, null it based on ID below
// Optional: Clear canvas content
// const canvas = document.getElementById(canvasId);
// if (canvas) {
// const ctx = canvas.getContext('2d');
// ctx.clearRect(0, 0, canvas.width, canvas.height);
// }
}
// Reset instance based on canvasId
if (canvasId === 'baiPieChart') baiPieChartInstance = null;
if (canvasId === 'eraPieChart') eraPieChartInstance = null;
if (canvasId === 'monthlyBreakdownChart') monthlyBreakdownChartInstance =
null; // NEW
}
// --- Reset Filters when switching sections ---
// --- Reset Filters (MODIFIED) ---
function resetFiltersForSection(sectionId) {
if (sectionId === 'baiDetails') {
// ... (keep existing BAI reset logic) ...
const baiSearchInput = document.getElementById('fileSearch');
const depositFrom = document.getElementById('filterDepositFrom');
const depositTo = document.getElementById('filterDepositTo');
const receiveFrom = document.getElementById('filterReceiveFrom');
const receiveTo = document.getElementById('filterReceiveTo');
const detailViewSelect = document.getElementById('detailViewSelect');
if(baiSearchInput) baiSearchInput.value = '';
if(depositFrom) depositFrom.value = '';
if(depositTo) depositTo.value = '';
if(receiveFrom) receiveFrom.value = '';
if(receiveTo) receiveTo.value = '';
if(detailViewSelect) detailViewSelect.value = 'summary';
} else if (sectionId === 'eraDetails') {
// ... (keep existing ERA reset logic) ...
const eraSearchInput = document.getElementById('eraFileSearch');
const checkFrom = document.getElementById('filterCheckDateFrom');
const checkTo = document.getElementById('filterCheckDateTo');
const eraViewSelect = document.getElementById('eraDetailViewSelect');
const eraSearchBySelect = document.getElementById('eraSearchBy');
if(eraSearchInput) eraSearchInput.value = '';
if(checkFrom) checkFrom.value = '';
if(checkTo) checkTo.value = '';
if(eraViewSelect) eraViewSelect.value = 'summary';
if(eraSearchBySelect && eraSearchBySelect.options.length > 0) {
eraSearchBySelect.value = eraSearchBySelect.options[0].value;
}
} else if (sectionId === 'reconcile' || sectionId === 'nonReconcile') {
// No filters to reset on these pages currently
// Clear any previous reconciliation messages
if(reconcileMessageDiv) reconcileMessageDiv.innerHTML = '';
}
// --- REMOVED: reconcileFilterSelect reset ---
}
// --- BAI Upload ---
uploadButton.addEventListener("click", uploadBaiFile);
async function uploadBaiFile() {
const fileInput = document.getElementById("baiFile");
const file = fileInput.files[0];
const uploadMessage = document.getElementById("uploadMessage");
const progressBar = document.getElementById("progressBar");
const progressLabel = document.getElementById("progressLabel");
uploadMessage.textContent = "";
uploadMessage.className = "";
progressBar.style.width = "0%";
progressLabel.textContent = "0%";
if (!file) {
displayMessage(uploadMessage, "Please select a file.", "error-message");
return;
}
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", function (event) {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
progressBar.style.width = percentComplete + "%";
progressLabel.textContent = percentComplete + "%";
}
});
xhr.open("POST", "/upload_bai");
xhr.onload = function () {
progressBar.style.width = "100%";
progressLabel.textContent = "Processing...";
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status === 200 || xhr.status === 201) {
displayMessage(uploadMessage, data.message, "success-message");
// Refresh data only if the BAI details section is currently active
if (currentSection === 'baiDetails') {
loadFileSummaries(); // Reload and re-apply filters
} else {
// If not active, just clear the cache so it reloads next time
allFiles = [];
}
} else {
displayMessage(uploadMessage, "Upload failed: " + (data.error ||
"Unknown error"), "error-message");
progressBar.style.backgroundColor = "#dc3545";
}
} catch (e) {
displayMessage(uploadMessage, "Upload failed: Invalid response from
server.", "error-message");
progressBar.style.backgroundColor = "#dc3545";
} finally {
setTimeout(() => {
progressBar.style.width = "0%";
progressLabel.textContent = "0%";
progressBar.style.backgroundColor = "#2ecc71"; // Reset color
}, 3000);
fileInput.value = ""; // Clear the file input
}
};
xhr.onerror = function () {
displayMessage(uploadMessage, "Network error during upload.", "error-
message");
progressBar.style.width = "0%";
progressLabel.textContent = "0%";
progressBar.style.backgroundColor = "#dc3545";
fileInput.value = "";
};
xhr.send(formData);
}
// --- ERA Upload ---
const eraUploadButton = document.getElementById('eraUploadButton');
if (eraUploadButton) {
eraUploadButton.addEventListener('click', uploadEraFile);
}
async function uploadEraFile() {
const fileInput = document.getElementById("eraFile");
const file = fileInput.files[0];
const uploadMessage = document.getElementById("eraUploadMessage");
const progressBar = document.getElementById("eraProgressBar");
const progressLabel = document.getElementById("eraProgressLabel");
uploadMessage.textContent = "";
uploadMessage.className = "";
progressBar.style.width = "0%";
progressLabel.textContent = "0%";
if (!file) {
displayMessage(uploadMessage, "Please select a file.", "error-message");
return;
}
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", function (event) {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
progressBar.style.width = percentComplete + "%";
progressLabel.textContent = percentComplete + "%";
}
});
xhr.open("POST", "/upload_era");
xhr.onload = function () {
progressBar.style.width = "100%";
progressLabel.textContent = "Processing...";
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status === 200 || xhr.status === 201) {
displayMessage(uploadMessage, data.message, "success-message");
// Refresh data only if the ERA details section is currently active
if (currentSection === 'eraDetails') {
loadEraFileSummaries(); // Reload and re-apply filters
} else {
// If not active, just clear the cache so it reloads next time
allEraFiles = [];
}
} else {
displayMessage(uploadMessage, "Upload failed: " + (data.error ||
"Unknown error"), "error-message");
progressBar.style.backgroundColor = "#dc3545";
}
} catch (e) {
displayMessage(uploadMessage, "Upload failed: Invalid server
response.", "error-message");
progressBar.style.backgroundColor = "#dc3545";
} finally {
setTimeout(() => {
progressBar.style.width = "0%";
progressLabel.textContent = "0%";
progressBar.style.backgroundColor = "#2ecc71"; // Reset color
}, 3000);
fileInput.value = ""; // Clear the file input
}
};
xhr.onerror = function () {
displayMessage(uploadMessage, "Network error.", "error-message");
progressBar.style.width = "0%";
progressLabel.textContent = "0%";
progressBar.style.backgroundColor = "#dc3545";
fileInput.value = "";
};
xhr.send(formData);
}
// --- Load BAI Summaries (and pre-calculate recon status) ---
async function loadFileSummaries() {
const tableBody = document.querySelector("#fileSummaryTable tbody");
const detailedContainer =
document.getElementById("detailedAggregatedContainer");
const summaryContainer = document.getElementById("summaryContainer");
const currentView = document.getElementById("detailViewSelect")?.value ||
'summary'; // Default to summary
const colspan = 11; // Number of columns including restored ones
allFiles = []; // Reset before loading
// Display loading message
const loadingMessage = `<tr><td colspan="${colspan}">Loading BAI
summaries...</td></tr>`;
if (currentView === 'summary' && tableBody) {
tableBody.innerHTML = loadingMessage;
} else if (currentView === 'detailed' && detailedContainer) {
detailedContainer.innerHTML = '<p>Loading detailed aggregated
transactions...</p>';
if (summaryContainer) summaryContainer.style.display = "none";
} else if (tableBody) { // Fallback
tableBody.innerHTML = loadingMessage;
}
try {
// 1. Fetch initial summaries (includes basic backend-derived status)
const summaryResponse = await fetch("/files");
if (!summaryResponse.ok) {
let errorMsg = `HTTP error ${summaryResponse.status}`;
try { const errData = await summaryResponse.json(); errorMsg =
errData.error || errorMsg; } catch (e) {}
throw new Error(errorMsg);
}
let summaries = await summaryResponse.json();
if (!Array.isArray(summaries)) {
console.error("Invalid data received from /files:", summaries);
throw new Error("Invalid data format received for BAI summaries.");
}
// 2. Fetch details for each summary to calculate amounts
const detailFetchPromises = summaries.map(async (summary) => {
try {
const detailResponse = await fetch(`/files/${summary.id}`);
if (!detailResponse.ok) {
// Log specific error but continue, providing default sums
console.error(`Detail fetch failed for BAI ID ${summary.id}: $
{detailResponse.status}`);
throw new Error(`Detail fetch failed $
{detailResponse.status}`);
}
const detailData = await detailResponse.json();
let reconciledSum = 0;
let nonReconciledSum = 0;
let detailsTotal = 0;
if (detailData.details && Array.isArray(detailData.details)) {
detailData.details.forEach(detail => {
// Use the backend status ('Yes'/'No') to decide sum
category
const amountNum = parseAmountStrToNum(detail.amount);
detailsTotal += amountNum;
if (detail.reconcile_status === 'Yes') {
reconciledSum += amountNum;
} else {
nonReconciledSum += amountNum;
}
});
} else {
console.warn(`No details found for BAI ID ${summary.id} when
calculating sums.`);
}
// Add calculated amounts to the summary object
summary.reconciledSum = reconciledSum;
summary.nonReconciledSum = nonReconciledSum;
summary.detailsTotal = detailsTotal; // Store for potential use
const summaryTotalNum = parseAmountStrToNum(summary.total_amount);
summary.BAIDifference = summaryTotalNum - detailsTotal; //
Calculate difference
summary.totalAmountNum = summaryTotalNum;
// Prepare basic search data (using the status from /files fetch)
let searchDataParts = [
summary.file_name || '', summary.sender || '', summary.receiver
|| '',
(summary.total_amount || "").replace(/[$,]/g, ""),
(summary.reconcileStatus || '').toLowerCase() // Use
reconcileStatus from /files
];
summary.searchData = searchDataParts.join(" ").toLowerCase();
return summary; // Return the augmented summary object
} catch (error) {
// Log error and return summary with defaults/error state
console.error(`Error processing details for BAI ID ${summary.id}:`,
error);
summary.reconciledSum = 0;
summary.nonReconciledSum = 0;
summary.detailsTotal = 0;
summary.totalAmountNum = parseAmountStrToNum(summary.total_amount);
summary.BAIDifference = summary.totalAmountNum; // Diff is total if
details fail
summary.searchData = (summary.file_name || '').toLowerCase();
// Keep reconcileStatus if it was fetched initially
return summary;
}
});
// Wait for all detail fetches and calculations
const augmentedSummaries = await Promise.all(detailFetchPromises);
// Assign the final array back ONLY if Promise.all succeeded
allFiles = augmentedSummaries;
// 3. Apply filters and render
applyFilters();
} catch (error) {
console.error("Error in loadFileSummaries:", error);
allFiles = []; // Ensure empty array on major error
const errorMsg = `<tr><td colspan="${colspan}">Failed to load BAI
summaries: ${error.message}</td></tr>`;
if (tableBody) tableBody.innerHTML = errorMsg;
if (detailedContainer) detailedContainer.innerHTML = `<p class="error-
message">Failed to load BAI details: ${error.message}</p>`;
applyFilters(); // Render "No data" message
}
}
// --- Render BAI Summary Table ---
function renderFileSummaries(filesToRender) {
const tableBody = document.querySelector("#fileSummaryTable tbody");
if (!tableBody) return;
tableBody.innerHTML = "";
const colspan = 11; // Matches restored headers count
if (!Array.isArray(filesToRender) || filesToRender.length === 0) {
tableBody.innerHTML = `<tr><td colspan="${colspan}">No BAI files found
matching criteria.</td></tr>`;
return;
}
filesToRender.forEach((file, index) => {
const row = tableBody.insertRow();
row.insertCell().textContent = index + 1;
// File Name with context menu
const fileNameCell = row.insertCell();
fileNameCell.textContent = file.file_name || 'N/A';
fileNameCell.classList.add("file-name-cell");
fileNameCell.addEventListener("contextmenu", (event) => {
event.preventDefault(); showContextMenu(event, file);
});
row.insertCell().textContent = file.sender || 'N/A';
row.insertCell().textContent = file.receiver || 'N/A';
row.insertCell().textContent = reformatDate(file.deposit_date);
row.insertCell().textContent = reformatDate(file.receive_date);
row.insertCell().textContent = file.total_amount || '$0.00';
// Status (from backend /files)
const statusCell = row.insertCell();
statusCell.textContent = file.reconcileStatus || "N/A"; // Should be
YES/NO/PARTIAL/NA
// Add specific class based on status for styling
if (file.reconcileStatus === 'YES') statusCell.classList.add('status-
yes');
else if (file.reconcileStatus === 'NO') statusCell.classList.add('status-
no');
else if (file.reconcileStatus === 'PARTIAL')
statusCell.classList.add('status-partial'); // Add CSS for this if needed
// Calculated Amounts (Restored) - Format as currency
row.insertCell().textContent = file.reconciledSum !== undefined ? `$$
{file.reconciledSum.toFixed(2)}` : "N/A";
row.insertCell().textContent = file.nonReconciledSum !== undefined ? `$$
{file.nonReconciledSum.toFixed(2)}` : "N/A";
row.insertCell().textContent = file.BAIDifference !== undefined ? `$$
{file.BAIDifference.toFixed(2)}` : "N/A";
});
}
// --- Filter BAI Files function (MODIFIED to use file.reconcileStatus) ---
// This function now uses the status fetched via /files
// --- Filter BAI Files function (MODIFIED for Detailed View filtering) ---
function filterFiles(filesToFilter) { // filesToFilter is the augmented array
let searchTerm = (fileSearchInput.value || "").toLowerCase().trim();
let searchByElem = document.getElementById("searchBy");
let searchBy = searchByElem ? searchByElem.value : "file_name";
let depositFrom = document.getElementById("filterDepositFrom").value;
let depositTo = document.getElementById("filterDepositTo").value;
let receiveFrom = document.getElementById("filterReceiveFrom").value;
let receiveTo = document.getElementById("filterReceiveTo").value;
let currentView = document.getElementById("detailViewSelect").value;
// Use the passed filesToFilter array
return filesToFilter.filter((file) => {
// 1. Date Filtering (remains the same)
let formattedDeposit = reformatDate(file.deposit_date);
let depositIso = mmddyyToIso(formattedDeposit);
let matchDeposit = true;
if (depositFrom && depositIso) matchDeposit = matchDeposit && depositIso >=
depositFrom;
if (depositTo && depositIso) matchDeposit = matchDeposit && depositIso <=
depositTo;
if ((depositFrom || depositTo) && !depositIso) matchDeposit = false;
let formattedReceive = reformatDate(file.receive_date);
let receiveIso = mmddyyToIso(formattedReceive);
let matchReceive = true;
if (receiveFrom && receiveIso) matchReceive = matchReceive && receiveIso >=
receiveFrom;
if (receiveTo && receiveIso) matchReceive = matchReceive && receiveIso <=
receiveTo;
if ((receiveFrom || receiveTo) && !receiveIso) matchReceive = false;
if (!matchDeposit || !matchReceive) {
return false;
}
// 2. Search Term Filtering
let matchSearch = true;
if (searchTerm && searchBy) {
searchTerm = searchTerm.toLowerCase();
let summaryFieldValue = '';
let isStatusSearch = false;
if (currentView === 'summary') {
// --- Summary View Search Logic (Keep As Is) ---
switch (searchBy) {
case "file_name": summaryFieldValue = (file.file_name ||
"").toLowerCase(); break;
case "sender": summaryFieldValue = (file.sender ||
"").toLowerCase(); break;
case "receiver": summaryFieldValue = (file.receiver ||
"").toLowerCase(); break;
case "total_amount":
summaryFieldValue = (file.total_amount ||
"").replace(/[$,]/g, "").toLowerCase();
searchTerm = searchTerm.replace(/[$,]/g, "");
break;
case "reconcileStatus":
summaryFieldValue = (file.reconcileStatus ||
"No").toLowerCase(); // Use file-level status
isStatusSearch = true;
break;
default:
matchSearch = (file.searchData ||
"").toLowerCase().includes(searchTerm);
summaryFieldValue = 'handled_by_default';
}
if (matchSearch && summaryFieldValue !== 'handled_by_default') {
matchSearch = isStatusSearch ? (summaryFieldValue ===
searchTerm) : summaryFieldValue.includes(searchTerm);
}
// --- End Summary View Search Logic ---
} else { // currentView === 'detailed'
// --- Detailed View Search Logic (MODIFIED) ---
// Define which fields are considered file-level for filtering here
const fileLevelSearchFields = ["file_name"];
if (fileLevelSearchFields.includes(searchBy)) {
// Only apply file-level search if a file-level field is
selected
switch (searchBy) {
case "file_name":
matchSearch = (file.file_name ||
"").toLowerCase().includes(searchTerm);
break;
// Add other file-level fields here if needed in the future
default:
matchSearch = true; // Should not happen if logic is
correct
}
} else {
// If searchBy is a DETAIL-LEVEL field (like
customer_reference, amount, reconcileStatus, etc.)
// then the file *always* passes the search term filter at this
stage.
// The actual filtering of rows happens in
loadDetailedAggregatedTransactions.
matchSearch = true;
}
// --- End Detailed View Search Logic ---
}
}
// Final check combines date and search term results
return matchDeposit && matchReceive && matchSearch;
});
}
// --- BAI Context Menu ---
function showContextMenu(event, file) {
hideContextMenu(); // Hide any existing menu
const menu = document.getElementById("contextMenu");
if (!menu) return;
menu.innerHTML = ''; // Clear previous options
const ul = document.createElement("ul");
// Download BAI File Option
const downloadOption = document.createElement("li");
downloadOption.textContent = "Download BAI File";
downloadOption.addEventListener("click", () => {
downloadRawBaiFile(file.id, file.file_name);
hideContextMenu();
});
ul.appendChild(downloadOption);
// Detailed View Option
const detailedOption = document.createElement("li");
detailedOption.textContent = "Detailed View";
detailedOption.addEventListener("click", () => {
viewDetailed(file.id); // Open single file detailed view in new tab
hideContextMenu();
});
ul.appendChild(detailedOption);
// Reconcile Option - Navigates to Reconcile Tab, potentially pre-filtering
const reconcileOption = document.createElement("li");
reconcileOption.textContent = "Reconcile";
reconcileOption.addEventListener("click", () => {
// Optionally: Set a global variable or localStorage item to pre-filter
reconcile view by this file.id
window.reconcileFilterFileId = file.id; // Example using global (use with
caution)
// Simulate click on the Reconcile sidebar button
const reconcileButton = document.querySelector('.sidebar-button[data-
section="reconcile"]');
if (reconcileButton) {
reconcileButton.click(); // Navigate to the reconcile section
}
hideContextMenu();
});
ul.appendChild(reconcileOption);
// Friendly View Option
const friendlyOption = document.createElement("li");
friendlyOption.textContent = "Friendly View";
friendlyOption.addEventListener("click", () => {
viewFriendly(file.id); // Show modal
hideContextMenu();
});
ul.appendChild(friendlyOption);
menu.appendChild(ul);
menu.style.top = `${event.pageY}px`; // Position menu near cursor
menu.style.left = `${event.pageX}px`;
menu.style.display = "block"; // Show the menu
}
function hideContextMenu() {
const menu = document.getElementById("contextMenu");
if (menu) {
menu.style.display = "none";
}
}
// Hide context menu if clicked outside
document.addEventListener("click", (event) => {
const menu = document.getElementById("contextMenu");
// Check if the menu exists, is visible, and the click was outside the menu
if (menu && menu.style.display === 'block' && !menu.contains(event.target)) {
hideContextMenu();
}
});
// Download Raw BAI File
function downloadRawBaiFile(fileId, fileName) {
fetch(`/files/${fileId}`) // Fetch the specific file data which includes
raw_content
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
})
.then((data) => {
// Check if the expected structure and raw content exist
if (!data || !data.summary || data.summary.raw_content === undefined) {
throw new Error("Raw content not found in the server response.");
}
const raw = data.summary.raw_content;
const blob = new Blob([raw], { type: "text/plain;charset=utf-8" }); //
Create a blob
const url = URL.createObjectURL(blob); // Create a temporary URL for the
blob
const a = document.createElement("a"); // Create a link element
a.href = url;
// Sanitize filename and ensure .txt extension
const safeFileName = (fileName ||
`BAI_File_${fileId}`).replace(/[\\/*?:"<>|]/g, "_");
a.download = safeFileName.endsWith('.txt') ? safeFileName : `$
{safeFileName}.txt`;
document.body.appendChild(a); // Add link to body
a.click(); // Simulate click to trigger download
document.body.removeChild(a); // Clean up the link
URL.revokeObjectURL(url); // Release the object URL
})
.catch((error) => {
console.error("Error downloading raw BAI file:", error);
alert("Error downloading file: " + error.message); // User feedback
});
}
// Helper for extracting transaction details (remains largely the same)
// No changes needed here based on the requirement.
function extractTransactionDetails(trans, summary) {
// ... (keep existing implementation) ...
let trnVal = "";
let achVal = "";
let otherRef = null;
let recID = "";
let compID = "";
let payer = summary?.sender_name || "Unknown Sender";
let recipient = summary?.receiver_name || "Unknown Receiver";
const raw_continuations = trans?.continuations || [];
raw_continuations.forEach((cont) => {
const text = cont?.text?.trim() || "";
if (!text) return;
if (text.toUpperCase().startsWith("OTHER REFERENCE:")) {
try {
// Attempt to parse "OTHER REFERENCE: CompID CheckNum"
const ref_part = text.substring(text.indexOf(":") + 1).trim();
// Basic split, might need refinement based on actual BAI formats
const parts = ref_part.split(/\s+/, 2); // Split by whitespace, max 2
parts
if (parts.length >= 2) otherRef = { compID: parts[0], cheque:
parts[1] };
else if (parts.length == 1) otherRef = { compID: "", cheque: parts[0]
}; // Only check number?
} catch (e) { console.warn("Could not parse OTHER REFERENCE:", text,
e); }
} else if (text.toUpperCase().startsWith("TRN*")) {
// Example: TRN*1*CheckNumber~
const parts = text.split("*");
if (parts.length >= 3) trnVal = parts[2].split("~")[0].trim(); //
Extract check/trace number
} else if (text.toUpperCase().startsWith("ACH")) {
// Example: ACH: 123456789 or ACH Addenda Info
const match = text.match(/ACH[:\s]*(\S+)/i); // Get value after ACH:
or ACH space
achVal = match ? match[1] : text.substring(3).trim(); // Fallback to
rest of string
} else if (text.toUpperCase().startsWith("RECIPIENT ID:")) {
try { recID = text.substring(text.indexOf(":") + 1).trim(); } catch
(e) { console.warn("Could not parse RECIPIENT ID:", text, e); }
} else if (text.toUpperCase().startsWith("COMPANY ID:")) {
try { compID = text.substring(text.indexOf(":") + 1).trim(); } catch
(e) { console.warn("Could not parse COMPANY ID:", text, e); }
} else if (text.toUpperCase().startsWith("COMPANY NAME:")) {
try {
const name_part = text.substring(text.indexOf(":") + 1).trim();
// Handle potential multiple spaces separating parts
const payer_parts = name_part.split(/\s{2,}/); // Split on 2+
spaces
payer = payer_parts.length > 0 ? payer_parts[0].trim() :
name_part;
} catch (e) { console.warn("Could not parse COMPANY NAME:", text,
e); }
} else if (text.toUpperCase().startsWith("RECIPIENT NAME:")) {
try { recipient = text.substring(text.indexOf(":") + 1).trim(); }
catch (e) { console.warn("Could not parse RECIPIENT NAME:", text, e); }
}
});
let type_val = "Unknown", category = "Unknown";
let customerRef = trans?.customerReferenceNumber || ""; // Default to 16 record
ref
// Prioritize extracted references for type/category/ref determination
if (otherRef !== null) { // Cheque Deposit
type_val = "Deposit"; category = "Deposit"; customerRef = otherRef.cheque;
if (!compID) compID = otherRef.compID; // Use Company ID from reference if
available
} else if (trnVal) { // ACH Payment (often from 88 record TRN)
type_val = "ACH"; category = "Insurance Payment"; customerRef = trnVal;
} else if (achVal) { // ACH Payment (maybe from 88 record ACH)
type_val = "ACH"; category = "Insurance Payment"; customerRef = achVal;
} else if (recID) { // Could be various types, maybe Card deposit?
type_val = "Deposit"; category = "Credit Card"; customerRef = recID; //
Assumption
} else { // Fallback based on Type Code or amount sign
const tc = trans?.typeCode || "";
// Common BAI2 deposit codes
if (["165", "166", "169", "175", "277", "354", "399"].includes(tc))
{ type_val = "Deposit"; category = "Deposit"; }
// Common BAI2 ACH credit codes
else if (["206", "218", "275", "469", "475", "503"].includes(tc))
{ type_val = "ACH"; category = "ACH Payment"; }
// Common BAI2 withdrawal/debit codes
else if (["455", "466", "472", "495", "558", "699"].includes(tc))
{ type_val = "Withdrawal"; category = "Payment"; }
else { // Generic fallback
type_val = (trans?.amount || "0").startsWith('-') ? "Withdrawal" :
"Deposit";
category = type_val === "Deposit" ? "Deposit" : "Payment";
}
}
// Use 16 record ref as Company ID if nothing else found
if (!compID) compID = trans?.customerReferenceNumber || "";
return {
transaction_type: type_val,
category: category,
customer_reference: customerRef,
company_id: compID,
payer_name: payer, // Use parsed/default payer name
recipient_name: recipient, // Use parsed/default recipient name
amount: trans?.amount || "$0.00" // Ensure amount exists
};
}
// --- BAI Detailed View (Single File in New Tab) ---
function viewDetailed(fileId) {
fetch(`/files/${fileId}`) // Fetches summary and details (with status per
detail)
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
})
.then((data) => {
if (!data || !data.summary || !data.details) {
throw new Error("Incomplete data received for detailed view.");
}
let summary = data.summary;
let detailsList = Array.isArray(data.details) ? data.details : [];
let tableHtml = "<table id='detailedAggregatedTable'>";
tableHtml += "<thead><tr>";
// Define headers including Reconcile Status
const headers = [
"Filename", "Receive Date", "Type", "Category", "Customer Reference",
"Company ID (Payer ID)", "Payer Name", "Recipient Name", "Amount",
"Reconcile Status" // Added Status column
];
headers.forEach((text) => { tableHtml += `<th>${text}</th>`; });
tableHtml += "</tr></thead>";
tableHtml += "<tbody>";
if (detailsList.length > 0) {
detailsList.forEach((detailRow) => {
let row = "<tr>";
row += `<td>${summary.file_name || 'N/A'}</td>`;
row += `<td>${reformatDate(detailRow.receive_date) ||
reformatDate(summary.receive_date) || 'N/A'}</td>`;
row += `<td>${detailRow.transaction_type || 'N/A'}</td>`;
row += `<td>${detailRow.category || 'N/A'}</td>`;
row += `<td>${detailRow.customer_reference || 'N/A'}</td>`;
row += `<td>${detailRow.company_id || 'N/A'}</td>`;
row += `<td>${detailRow.payer_name || 'N/A'}</td>`;
row += `<td>${detailRow.recipient_name || 'N/A'}</td>`;
row += `<td>${detailRow.amount || '$0.00'}</td>`;
// Display status fetched from backend
row += `<td>${detailRow.reconcile_status || 'No'}</td>`; // Use
reconcile_status from backend
row += "</tr>";
tableHtml += row;
});
} else {
tableHtml += `<tr><td colspan="${headers.length}">No detailed
transactions found in BAI_Detail table for this file.</td></tr>`;
}
tableHtml += "</tbody></table>";
// --- New Tab Content (keep existing structure, just includes the modified
table) ---
let newTab = window.open("", "_blank");
// ... (rest of the new tab HTML generation, including styles, scripts for
CSV/PDF/Filter, etc.) ...
// Make sure the table ID used in the scripts matches the generated table
ID.
newTab.document.write(`
<html>
<head>
<title>BAI Detailed Transactions - ${summary.file_name || 'File ' +
fileId}</title>
<link rel="stylesheet" href="/static/style.css">
<style>
/* ... (keep existing styles) ... */
.status-yes { color: green; font-weight: bold; }
.status-no { color: red; }
</style>
<!-- Include jsPDF and autoTable libraries -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"><\/
script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.1/
jspdf.plugin.autotable.min.js"><\/script>
</head>
<body>
<div class="header-container">
<h2>BAI Detailed Transactions - ${summary.file_name || 'File ' +
fileId}</h2>
<div class="export-buttons">
<button onclick="exportDetailCSV()">Export To CSV</button>
<a href="/export_excel/${fileId}" target="_blank"><button>Export
To Excel</button></a>
<button onclick="exportDetailPDF()">Export To PDF</button>
<button onclick="window.opener.triggerRawBaiDownload(${fileId},
'${summary.file_name || ''}')">Download BAI File</button>
</div>
</div>
<div class="filters-container">
<label for="detailSearch">Search:</label>
<input type="text" id="detailSearch" placeholder="Filter table
content..." onkeyup="filterDetailTable()">
</div>
<div id="detailedTableContainer">
${tableHtml}
</div>
<script>
// ... (keep existing triggerRawBaiDownload, filterDetailTable,
exportDetailCSV, exportDetailPDF functions) ...
// Add styling based on status if desired
document.querySelectorAll('#detailedAggregatedTable tbody
td:last-child').forEach(td => {
if (td.textContent === 'Yes') {
td.classList.add('status-yes');
} else {
td.classList.add('status-no');
}
});
<\/script>
</body>
</html>
`);
newTab.document.close();
})
.catch((error) => {
console.error("Error fetching/displaying BAI detailed view:", error);
alert("Error fetching detailed view: " + error.message);
});
}
// Make download function callable from the new tab
window.triggerRawBaiDownload = downloadRawBaiFile;
// --- BAI Friendly View (Modal) ---
function viewFriendly(fileId) {
fetch(`/files/${fileId}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
})
.then((data) => {
if (!data || !data.summary) throw new Error("Summary data not found for
friendly view.");
const summary = data.summary;
// Pass both summary and details (if available) to the formatting
function
let friendlyText = formatFriendlyViewFromData(summary, data.details);
const modal = document.getElementById("friendlyModal");
const pre = document.getElementById("friendlyViewContent");
if (modal && pre) {
pre.textContent = friendlyText; // Set the formatted text
modal.style.display = "block"; // Show the modal
} else {
console.error("Friendly view modal elements not found.");
}
})
.catch((error) => {
console.error("Error fetching data for friendly view:", error);
alert("Error preparing friendly view: " + error.message);
});
}
// --- Format Friendly View ---
function formatFriendlyViewFromData(summary, detailsList) {
let output = "File Summary:\n";
output += "--------------------------------\n";
output += ` File Name : ${summary.file_name || 'N/A'}\n`;
output += ` Sender : ${summary.sender_name || summary.sender ||
'N/A'}\n`; // Use sender_name if available
output += ` Receiver : ${summary.receiver_name || summary.receiver ||
'N/A'}\n`; // Use receiver_name if available
output += ` Deposit Date : ${reformatDate(summary.deposit_date) ||
'N/A'}\n`;
output += ` Receive Date : ${reformatDate(summary.receive_date) ||
'N/A'}\n`;
output += ` Total File Amount: ${summary.total_amount || '$0.00'}\n\n`;
output += "Detailed Transactions (Processed):\n";
output += "--------------------------------\n";
if (detailsList && Array.isArray(detailsList) && detailsList.length > 0) {
detailsList.forEach((detail, index) => {
output += ` Transaction ${index + 1}:\n`;
output += ` Type : ${detail.transaction_type || 'N/A'}\n`;
output += ` Category : ${detail.category || 'N/A'}\n`;
output += ` Amount : ${detail.amount || '$0.00'}\n`;
output += ` Customer Ref : ${detail.customer_reference || 'N/A'}\
n`;
output += ` Company ID : ${detail.company_id || 'N/A'}\n`;
output += ` Payer Name : ${detail.payer_name || 'N/A'}\n`;
output += ` Recipient Name : ${detail.recipient_name || 'N/A'}\n\n`;
});
} else {
output += " No detailed transaction data found or processed for this file.\
n";
}
// Optionally add raw content if needed/available
// if (summary.raw_content) {
// output += "\nRaw File Content:\n";
// output += "--------------------------------\n";
// output += summary.raw_content;
// }
return output;
}
// --- Modal Close Logic ---
const closeModalButton = document.querySelector("#friendlyModal .close");
if (closeModalButton) {
closeModalButton.addEventListener("click", () => {
const modal = document.getElementById("friendlyModal");
if (modal) modal.style.display = "none";
});
}
// Close modal if clicked outside the content area
window.addEventListener("click", (event) => {
const friendlyModal = document.getElementById("friendlyModal");
if (event.target === friendlyModal) {
friendlyModal.style.display = "none";
}
});
// --- BAI Filtering Logic (File Level) ---
function filterFiles(filesToFilter) { // Accept the array as argument
let searchTerm = (fileSearchInput.value || "").toLowerCase().trim();
let searchByElem = document.getElementById("searchBy");
let searchBy = searchByElem ? searchByElem.value : "file_name";
let depositFrom = document.getElementById("filterDepositFrom").value;
let depositTo = document.getElementById("filterDepositTo").value;
let receiveFrom = document.getElementById("filterReceiveFrom").value;
let receiveTo = document.getElementById("filterReceiveTo").value;
let currentView = document.getElementById("detailViewSelect").value;
// Use the passed filesToFilter array
return filesToFilter.filter((file) => {
// ... (keep existing filtering logic based on dates and search term/field)
...
// 1. Date Filtering
let formattedDeposit = reformatDate(file.deposit_date);
let depositIso = mmddyyToIso(formattedDeposit);
let matchDeposit = true;
if (depositFrom && depositIso) { matchDeposit = matchDeposit && depositIso >=
depositFrom; }
if (depositTo && depositIso) { matchDeposit = matchDeposit && depositIso <=
depositTo; }
if ((depositFrom || depositTo) && !depositIso) { matchDeposit = false; }
let formattedReceive = reformatDate(file.receive_date);
let receiveIso = mmddyyToIso(formattedReceive);
let matchReceive = true;
if (receiveFrom && receiveIso) { matchReceive = matchReceive && receiveIso >=
receiveFrom; }
if (receiveTo && receiveIso) { matchReceive = matchReceive && receiveIso <=
receiveTo; }
if ((receiveFrom || receiveTo) && !receiveIso) { matchReceive = false; }
if (!matchDeposit || !matchReceive) {
return false;
}
// 2. Search Term Filtering
let matchSearch = true;
if (searchTerm && searchBy) {
searchTerm = searchTerm.toLowerCase();
let summaryFieldValue = '';
let isStatusSearch = false;
if (currentView === 'summary') {
switch (searchBy) {
case "file_name": summaryFieldValue = (file.file_name ||
"").toLowerCase(); break;
case "sender": summaryFieldValue = (file.sender ||
"").toLowerCase(); break;
case "receiver": summaryFieldValue = (file.receiver ||
"").toLowerCase(); break;
case "total_amount":
summaryFieldValue = (file.total_amount || "").replace("$",
"").replace(/,/g, "").toLowerCase();
searchTerm = searchTerm.replace("$", "").replace(/,/g, "");
break;
case "reconcileStatus":
summaryFieldValue = (file.reconcileStatus ||
"No").toLowerCase();
isStatusSearch = true;
break;
default: matchSearch = false; // Fallback or check file.searchData
if defined
}
if (matchSearch) {
matchSearch = isStatusSearch ? (summaryFieldValue ===
searchTerm) : summaryFieldValue.includes(searchTerm);
}
} else { // detailed view
if (searchBy === "reconcileStatus") {
const trimmedSearchTerm = searchTerm.trim();
const status = (file.reconcileStatus || file.reconcile_status ||
"N/A").toLowerCase().trim();
console.log("Detailed view reconcileStatus:", status, "search
term:", trimmedSearchTerm);
matchSearch = status === trimmedSearchTerm;
} else {
// Detail-level search: file passes here, row filtering happens
later in loadDetailedAggregatedTransactions
matchSearch = true;
}
}
}
return matchDeposit && matchReceive && matchSearch;
});
}
// --- Apply BAI Filters and Render ---
function applyFilters() {
let viewSelect = document.getElementById("detailViewSelect").value;
updateSearchByOptions(viewSelect);
const filesToFilter = Array.isArray(allFiles) ? allFiles : []; // Ensure it's
an array
let filteredFiles = filterFiles(filesToFilter);
let summaryContainer = document.getElementById("summaryContainer");
let detailedContainer = document.getElementById("detailedAggregatedContainer");
if (summaryContainer) summaryContainer.style.display = (viewSelect ===
"summary") ? "block" : "none";
if (detailedContainer) detailedContainer.style.display = (viewSelect ===
"detailed") ? "block" : "none";
if (viewSelect === "summary") {
renderFileSummaries(filteredFiles);
} else {
loadDetailedAggregatedTransactions(filteredFiles); // This function fetches
details again for the aggregated view
}
}
// --- Load BAI Aggregated Detailed View ---
async function loadDetailedAggregatedTransactions(filteredFiles) {
const detailedContainer =
document.getElementById("detailedAggregatedContainer");
if (!detailedContainer) return;
detailedContainer.innerHTML = "<p>Loading detailed aggregated
transactions...</p>";
const table = document.createElement("table");
table.id = "detailedAggregatedTable";
const thead = table.createTHead();
const headerRow = thead.insertRow();
// Headers including Reconcile Status
const headers = [
"Filename", "Receive Date", "Type", "Category", "Customer Reference",
"Company ID (Payer ID)", "Payer Name", "Recipient Name", "Amount",
"Reconcile Status" // Added Status
// Removed placeholder recon amount columns
];
headers.forEach(text =>
headerRow.appendChild(Object.assign(document.createElement("th"), { textContent:
text })));
const tbody = table.createTBody();
const newColspan = headers.length;
if (!filteredFiles || filteredFiles.length === 0) {
tbody.innerHTML = `<tr><td colspan="${newColspan}">No BAI files found
matching the selected criteria.</td></tr>`;
detailedContainer.innerHTML = '';
detailedContainer.appendChild(table);
return;
}
const searchTerm = (fileSearchInput.value || "").toLowerCase().trim();
const searchByElem = document.getElementById("searchBy");
const searchBy = searchByElem ? searchByElem.value : "file_name";
const fetchPromises = filteredFiles.map((file) =>
fetch(`/files/${file.id}`) // Fetches details including reconcile_status
.then((res) => res.ok ? res.json() : Promise.reject(new Error(`Fetch failed
${res.status}`)))
.catch((error) => {
console.error("Error fetching details for aggregated view, id", file.id,
":", error);
return { error: true, id: file.id };
})
);
const detailedResults = await Promise.all(fetchPromises);
let hasData = false;
detailedResults.forEach((result) => {
if (!result || result.error || !result.summary || !result.details || !
Array.isArray(result.details)) return;
const summary = result.summary;
const detailsList = result.details;
let rowsToAdd = detailsList.filter(detailRow => {
if (!searchTerm) return true;
// Apply filter based on searchBy (including the reconcile status)
let fieldValue = "";
let searchDetail = true; // Flag to indicate if we are searching detail
fields
switch(searchBy) {
case "file_name":
// File level filtering was already done by filterFiles
return true; // Include all rows from this file
case "customer_reference": fieldValue = detailRow.customer_reference ||
""; break;
case "company_id": fieldValue = detailRow.company_id || ""; break;
case "payer_name": fieldValue = detailRow.payer_name || ""; break;
case "recipient_name": fieldValue = detailRow.recipient_name || "";
break;
case "amount":
fieldValue = (detailRow.amount || "").replace("$", "").replace(/,/g,
"");
const cleanedSearchTerm = searchTerm.replace("$", "").replace(/,/g,
"");
return fieldValue.toLowerCase().includes(cleanedSearchTerm);
case "reconcileStatus": // ** CHECK STATUS **
fieldValue = (detailRow.reconcile_status || "No").toLowerCase(); //
Use fetched status
searchDetail = false; // Not a standard text field search
break;
// No case for 'reconcileStatus' or 'file_name' here - handled at file
level
default: return false; // Unknown search field for details
}
if (searchDetail) {
return fieldValue.toLowerCase().includes(searchTerm);
} else if (searchBy === 'reconcileStatus') {
return fieldValue === searchTerm; // Exact match for status (yes/no)
}
return false; // Default
});
rowsToAdd.forEach((detailRow) => {
hasData = true;
const row = tbody.insertRow();
row.insertCell().textContent = summary.file_name || 'N/A';
row.insertCell().textContent = reformatDate(detailRow.receive_date) ||
reformatDate(summary.receive_date) || 'N/A';
row.insertCell().textContent = detailRow.transaction_type || 'N/A';
row.insertCell().textContent = detailRow.category || 'N/A';
row.insertCell().textContent = detailRow.customer_reference || 'N/A';
row.insertCell().textContent = detailRow.company_id || 'N/A';
row.insertCell().textContent = detailRow.payer_name || 'N/A';
row.insertCell().textContent = detailRow.recipient_name || 'N/A';
row.insertCell().textContent = detailRow.amount || '$0.00';
// Display reconcile status fetched from backend
const statusCell = row.insertCell();
statusCell.textContent = detailRow.reconcile_status || 'No';
statusCell.classList.add(statusCell.textContent === 'Yes' ? 'status-
yes' : 'status-no');
// REMOVED placeholder recon amount columns
});
});
if (!hasData) {
tbody.innerHTML = `<tr><td colspan="${newColspan}">No detailed transaction
data found matching the filter criteria.</td></tr>`;
}
detailedContainer.innerHTML = '';
detailedContainer.appendChild(table);
}
// --- Event Listeners for BAI Filters ---
const detailViewSelect = document.getElementById("detailViewSelect");
const filterDepositFrom = document.getElementById("filterDepositFrom");
const filterDepositTo = document.getElementById("filterDepositTo");
const filterReceiveFrom = document.getElementById("filterReceiveFrom");
const filterReceiveTo = document.getElementById("filterReceiveTo");
const searchBySelect = document.getElementById("searchBy"); // Added listener for
searchBy change
// When the view (Summary/Detailed) changes, re-apply filters
if (detailViewSelect) {
detailViewSelect.addEventListener("change", applyFilters);
}
// When date filters change, re-apply filters
if (filterDepositFrom) filterDepositFrom.addEventListener("change",
applyFilters);
if (filterDepositTo) filterDepositTo.addEventListener("change", applyFilters);
if (filterReceiveFrom) filterReceiveFrom.addEventListener("change",
applyFilters);
if (filterReceiveTo) filterReceiveTo.addEventListener("change", applyFilters);
// When the search term input changes, re-apply filters
if (fileSearchInput) {
fileSearchInput.addEventListener("input", applyFilters); // "input" is better
than "keyup" for immediate feedback
}
// When the search criteria (Search By dropdown) changes, re-apply filters
if (searchBySelect) {
searchBySelect.addEventListener("change", applyFilters);
}
function prepareEraSearchData(eraSummaries) {
if (!Array.isArray(eraSummaries)) {
console.error("prepareEraSearchData received non-array:", eraSummaries);
return []; // Return empty array on error
}
const updatedSummaries = eraSummaries.map(summary => {
// ... (keep existing mapping logic) ...
let searchDataParts = [
summary.file_name || '', summary.payer_name || '', summary.payee_name
|| '', summary.payee_id || '',
summary.trn_number || '', (summary.amount || "").replace("$",
"").replace(/,/g, ""),
summary.check_eft || '', (summary.reconcile_status || '').toLowerCase()
];
summary.searchData = searchDataParts.join(" ").toLowerCase();
let summaryAmtStr = (summary.amount || "").replace("$", "").replace(/,/g,
"");
summary.summaryAmountNum = parseFloat(summaryAmtStr) || 0;
summary.reconcileStatus = summary.reconcile_status; // Map key
delete summary.reconcile_status;
return summary;
});
return updatedSummaries;
}
// --- Utility Function ---
function displayMessage(element, message, className) {
if (element) {
element.className = className; // Apply styling class
element.textContent = message; // Set the message text
}
}
// --- Initial Setup ---
// Activate the first sidebar button and display its corresponding section
const firstSidebarButton = document.querySelector(".sidebar-button");
if (firstSidebarButton) {
firstSidebarButton.classList.add("active");
const initialSectionId = firstSidebarButton.dataset.section;
const initialSection = document.getElementById(initialSectionId);
if (initialSectionId === 'dashboard') {
loadDashboardData(); // Load dashboard data on initial page load if it's the
default
}
if(initialSection) {
initialSection.style.display = 'block';
currentSection = initialSectionId;
resetFiltersForSection(initialSectionId); // Reset filters
if (initialSectionId === 'baiDetails') {
updateSearchByOptions('summary');
loadFileSummaries();
} else if (initialSectionId === 'eraDetails') {
updateEraSearchOptions('summary');
loadEraFileSummaries();
} else if (initialSectionId === 'reconcile') {
updateBaiReconcileSearchOptions(); // Populate BAI Reconciled Dropdown
updateEraReconcileSearchOptions(); // Populate ERA Reconciled Dropdown
loadReconciledData(); // Load reconciled on initial view
} else if (initialSectionId === 'nonReconcile') {
updateBaiNonReconcileSearchOptions(); // Populate BAI Non-Reconciled
Dropdown
updateEraNonReconcileSearchOptions(); // Populate ERA Non-Reconciled
Dropdown
loadNonReconciledData(); // Load non-reconciled on initial view
}
}
} else { // Fallback if no sidebar buttons exist
const uploadSection = document.getElementById('baiUpload');
if (uploadSection) {
uploadSection.style.display = 'block';
currentSection = 'baiUpload';
}
}
// Add a helper style for info messages
const styleSheet = document.styleSheets[0];
try { // Use try-catch in case insertRule fails
styleSheet.insertRule(".status-yes { color: #198754; font-weight: bold; }",
styleSheet.cssRules.length);
styleSheet.insertRule(".status-no { color: #dc3545; }",
styleSheet.cssRules.length);
styleSheet.insertRule(".info-message { color: #004085; background-color:
#cce5ff; border-color: #b8daff; padding: 10px 15px; border: 1px solid transparent;
border-radius: 6px; font-size: 0.95em; }", styleSheet.cssRules.length);
} catch (e) {
console.warn("Could not insert CSS rules:", e);
} // ===========================
// --- ERA Handling ---
// ===========================
// --- NEW: Calculate ERA Reconciliation Data ---
// Fetches ERA details and calculates sums based on Paid Amount.
async function calculateEraReconciliationData(eraSummaries) {
const updatedSummaries = await Promise.all(eraSummaries.map(async (summary) =>
{
try {
// Fetch detailed data for this ERA summary
const res = await fetch(`/era_files/${summary.id}`);
if (!res.ok) throw new Error(`Failed to fetch ERA details for $
{summary.id}`);
const eraData = await res.json();
let reconciledSum = 0; // Placeholder for future logic
let nonReconciledSum = 0;
let detailsTotalPaid = 0;
if (eraData.details && Array.isArray(eraData.details)) {
eraData.details.forEach(detailRow => {
let paidAmtStr = (detailRow.paid_amount || "").replace("$",
"").replace(/,/g, "");
let paidAmtNum = parseFloat(paidAmtStr) || 0;
detailsTotalPaid += paidAmtNum;
// Placeholder: Assume all detail amounts are initially 'non-
reconciled'
// until a mechanism to mark them as reconciled is implemented.
// if (detailRow.isReconciled) { // Example future check
// reconciledSum += paidAmtNum;
// } else {
// nonReconciledSum += paidAmtNum;
// }
});
// For now, assign total paid to non-reconciled
nonReconciledSum = detailsTotalPaid;
}
summary.reconciledSum = reconciledSum;
summary.nonReconciledSum = nonReconciledSum;
summary.detailsTotalPaid = detailsTotalPaid; // Total from detail lines
// Calculate ERA difference: Summary Total vs Sum of Detail Paid Amounts
let summaryAmtStr = (summary.amount || "").replace("$", "").replace(/,/g,
"");
let summaryAmountNum = parseFloat(summaryAmtStr) || 0;
summary.summaryAmountNum = summaryAmountNum; // Store parsed summary amount
summary.eraDifference = summaryAmountNum - summary.detailsTotalPaid; //
Might be useful later
// Determine reconcile status based on sums (Placeholder logic)
let reconcileStatus = "NO"; // Default
if (reconciledSum === 0 && nonReconciledSum === 0 && detailsTotalPaid === 0
) { // No details or zero amounts
// Could indicate no claims or claims with 0 paid amount
reconcileStatus = "N/A";
} else if (Math.abs(reconciledSum - summaryAmountNum) < 0.01 &&
nonReconciledSum < 0.01) { // All reconciled and sum matches
reconcileStatus = "YES";
} else if (reconciledSum > 0 && reconciledSum < summaryAmountNum) { //
Partially reconciled
reconcileStatus = "PARTIAL";
} else if (reconciledSum === 0 && nonReconciledSum > 0) { // None
reconciled yet
reconcileStatus = "NO";
} else if (reconciledSum > summaryAmountNum) {
reconcileStatus = "OVER"; // Reconciled more than summary total?
}
// Add more specific checks if needed
summary.reconcileStatus = reconcileStatus;
} catch (error) {
console.error("Error processing ERA details for file id", summary.id, ":",
error);
// Set defaults if fetch fails
summary.reconciledSum = 0;
summary.nonReconciledSum = 0;
summary.detailsTotalPaid = 0;
summary.summaryAmountNum = 0;
summary.eraDifference = 0;
summary.reconcileStatus = "ERROR";
}
return summary; // Return the updated summary object
}));
return updatedSummaries;
}
// --- Load ERA Summaries ---
// MODIFIED: Call the new reconciliation calculation function
async function loadEraFileSummaries() {
const tableBody = document.querySelector("#eraSummaryTable tbody");
const detailedContainer =
document.getElementById("eraDetailedAggregatedContainer");
const currentView = document.getElementById("eraDetailViewSelect")?.value ||
'summary';
const colspan = 12; // Matches restored headers
allEraFiles = []; // Reset
const loadingMessage = `<tr><td colspan="${colspan}">Loading ERA
summaries...</td></tr>`;
if (currentView === 'summary' && tableBody) {
tableBody.innerHTML = loadingMessage;
} else if (currentView === 'detailed' && detailedContainer) {
detailedContainer.innerHTML = '<p>Loading detailed aggregated ERA
claims...</p>';
const summaryContainer = document.getElementById('eraSummaryContainer');
if (summaryContainer) summaryContainer.style.display = "none";
} else if (tableBody) {
tableBody.innerHTML = loadingMessage;
}
try {
// 1. Fetch initial ERA summaries (includes reconcile_status)
const summaryResponse = await fetch("/era_files");
if (!summaryResponse.ok) throw new Error(`HTTP error $
{summaryResponse.status}`);
let summaries = await summaryResponse.json();
if (!Array.isArray(summaries)) throw new Error("Invalid data format for ERA
summaries.");
// 2. Fetch details for each summary to calculate amounts
const detailFetchPromises = summaries.map(async (summary) => {
try {
const detailResponse = await fetch(`/era_files/${summary.id}`);
if (!detailResponse.ok) throw new Error(`Detail fetch failed $
{detailResponse.status}`);
const eraData = await detailResponse.json(); // Contains summary +
details array
let reconciledSum = 0;
let nonReconciledSum = 0;
let detailsTotalPaid = 0;
// Use the reconcile_status from the *summary* fetch (via
/era_files)
// This determines if the *entire check* is reconciled.
const isCheckReconciled = summary.reconcile_status === 'Yes';
if (eraData.details && Array.isArray(eraData.details)) {
eraData.details.forEach(detail => {
const paidAmtNum = parseAmountStrToNum(detail.paid_amount);
detailsTotalPaid += paidAmtNum;
// Assign based on overall check status
if (isCheckReconciled) {
reconciledSum += paidAmtNum;
} else {
nonReconciledSum += paidAmtNum;
}
});
}
summary.reconciledSum = reconciledSum;
summary.nonReconciledSum = nonReconciledSum;
summary.detailsTotalPaid = detailsTotalPaid;
summary.summaryAmountNum = parseAmountStrToNum(summary.amount);
// Map status key for JS consistency
summary.reconcileStatus = summary.reconcile_status; // Keep status
from initial fetch
delete summary.reconcile_status;
// Prepare basic search data
let searchDataParts = [
summary.file_name || '', summary.payer_name || '',
summary.payee_name || '', summary.payee_id || '',
summary.trn_number || '', (summary.amount ||
"").replace(/[$,]/g, ""),
summary.check_eft || '', (summary.reconcileStatus ||
'').toLowerCase()
];
summary.searchData = searchDataParts.join(" ").toLowerCase();
return summary;
} catch (error) {
console.error(`Error processing details for ERA ID ${summary.id}:`,
error);
summary.reconciledSum = 0;
summary.nonReconciledSum = 0;
summary.detailsTotalPaid = 0;
summary.summaryAmountNum = parseAmountStrToNum(summary.amount);
// Keep original status if available, otherwise mark error
summary.reconcileStatus = summary.reconcile_status || 'ERROR';
delete summary.reconcile_status;
summary.searchData = (summary.file_name || '').toLowerCase();
return summary;
}
});
// Wait for all detail fetches/calculations
const augmentedSummaries = await Promise.all(detailFetchPromises);
allEraFiles = augmentedSummaries;
// 3. Apply filters and render
applyEraViewFilters();
} catch (error) {
console.error("Error in loadEraFileSummaries:", error);
allEraFiles = []; // Ensure empty on error
const errorMsg = `<tr><td colspan="${colspan}">Failed to load ERA
summaries: ${error.message}</td></tr>`;
if (tableBody) tableBody.innerHTML = errorMsg;
if (detailedContainer) detailedContainer.innerHTML = `<p class="error-
message">Failed to load ERA details: ${error.message}</p>`;
applyEraViewFilters(); // Render "No data" message
}
}
function updateBaiReconcileSearchOptions() {
const baiSelect = document.getElementById('baiReconcileSearchBy');
if (!baiSelect) return;
baiSelect.innerHTML = ''; // Clear existing
// Headers: File, Cust Ref, Orig Amt, Reconciled, Remaining, Status, Type
const baiOptions = [
{ value: "bai_file_name", text: "File Name" },
{ value: "customer_reference", text: "Cust Ref" },
{ value: "bai_display_amount", text: "Orig Amt" },
{ value: "bai_total_reconciled", text: "Reconciled Amt" },
{ value: "bai_remaining_amount", text: "Remaining Amt" },
{ value: "bai_status", text: "Status" },
{ value: "manual_reconciliation", text: "Type" } // Value
'manual_reconciliation' used in filtering
];
baiOptions.forEach(opt => baiSelect.add(new Option(opt.text, opt.value)));
}
function updateEraReconcileSearchOptions() {
const eraSelect = document.getElementById('eraReconcileSearchBy');
if (!eraSelect) return;
eraSelect.innerHTML = ''; // Clear existing
// Headers: File Name, Check/EFT No., Check Date, Amount, Type
const eraOptions = [
{ value: "era_file_name", text: "File Name" },
{ value: "trn_number", text: "Check/EFT No." },
{ value: "era_check_date", text: "Check Date" },
{ value: "era_amount", text: "Amount" },
{ value: "manual_reconciliation", text: "Type" } // Value
'manual_reconciliation' used in filtering
];
eraOptions.forEach(opt => eraSelect.add(new Option(opt.text, opt.value)));
}
function updateBaiNonReconcileSearchOptions() {
const baiSelect = document.getElementById('baiNonReconcileSearchBy');
if (!baiSelect) return;
baiSelect.innerHTML = ''; // Clear existing
// Headers: File Name, Cust Ref., Receive Date, Total Amt, Reconciled Amt,
Remaining Amt, Status
const baiOptions = [
{ value: "bai_file_name", text: "File Name" },
{ value: "customer_reference", text: "Cust Ref." },
{ value: "bai_receive_date", text: "Receive Date" },
{ value: "bai_total_parsed_amount", text: "Total Amt" },
{ value: "bai_reconciled_amount", text: "Reconciled Amt" },
{ value: "bai_remaining_amount", text: "Remaining Amt" },
{ value: "bai_status", text: "Status" }
];
baiOptions.forEach(opt => baiSelect.add(new Option(opt.text, opt.value)));
}
function updateEraNonReconcileSearchOptions() {
const eraSelect = document.getElementById('eraNonReconcileSearchBy');
if (!eraSelect) return;
eraSelect.innerHTML = ''; // Clear existing
// Headers: File Name, Check/EFT No., Check Date, Amount
const eraOptions = [
{ value: "era_file_name", text: "File Name" },
{ value: "trn_number", text: "Check/EFT No." },
{ value: "era_check_date", text: "Check Date" },
{ value: "era_amount", text: "Amount" }
];
eraOptions.forEach(opt => eraSelect.add(new Option(opt.text, opt.value)));
}
// --- Filter ERA Files ---
// (No changes needed in filterEraFiles function itself unless search criteria
changes)
function filterEraFiles() {
let searchTerm = (eraFileSearchInput?.value || "").toLowerCase().trim();
let searchByElem = document.getElementById("eraSearchBy");
let searchBy = searchByElem ? searchByElem.value : "file_name";
let checkFrom = document.getElementById("filterCheckDateFrom")?.value || "";
let checkTo = document.getElementById("filterCheckDateTo")?.value || "";
let currentView = document.getElementById("eraDetailViewSelect")?.value ||
"summary";
return allEraFiles.filter((file) => {
let formattedCheck = reformatDate(file.check_date);
let checkIso = mmddyyToIso(formattedCheck);
let matchCheck = true;
if (checkFrom && checkIso) { matchCheck = matchCheck && checkIso >=
checkFrom; }
if (checkTo && checkIso) { matchCheck = matchCheck && checkIso <= checkTo; }
if ((checkFrom || checkTo) && !checkIso) { matchCheck = false; }
if (!matchCheck) return false;
let matchSearch = true;
if (searchTerm && searchBy) {
searchTerm = searchTerm.toLowerCase();
let summaryFieldValue = '';
let isStatusSearch = false;
if (currentView === 'summary') {
switch (searchBy) {
case "file_name": summaryFieldValue = (file.file_name ||
"").toLowerCase(); break;
case "payer_name": summaryFieldValue = (file.payer_name ||
"").toLowerCase(); break;
case "payee_name": summaryFieldValue = (file.payee_name ||
"").toLowerCase(); break;
case "payee_id": summaryFieldValue = (file.payee_id ||
"").toLowerCase(); break;
case "trn_number": summaryFieldValue = (file.trn_number ||
"").toLowerCase(); break;
case "amount":
summaryFieldValue = (file.amount || "").replace("$",
"").replace(/,/g, "").toLowerCase();
searchTerm = searchTerm.replace("$", "").replace(/,/g, "");
break;
case "check_eft": summaryFieldValue = (file.check_eft ||
"").toLowerCase(); break;
case "reconcileStatus": // Search by status
summaryFieldValue = (file.reconcileStatus ||
"No").toLowerCase(); // Use prepped status
isStatusSearch = true;
break;
default: matchSearch = false;
}
if (matchSearch) {
if (isStatusSearch) {
matchSearch = summaryFieldValue === searchTerm; // Exact match
for status
} else {
matchSearch = summaryFieldValue.includes(searchTerm);
}
}
} else { // currentView === 'detailed'
// If searchBy is file-level, filter files here using fetched status
if (searchBy === "file_name") {
matchSearch = (file.file_name ||
"").toLowerCase().includes(searchTerm);
} else if (searchBy === "trn_number") {
matchSearch = (file.trn_number ||
"").toLowerCase().includes(searchTerm);
} else if (searchBy === "reconcileStatus") {
// Allow file to pass if searching by status in detailed view,
row filter handles it (or handle here)
let fileStatus = (file.reconcileStatus || "No").toLowerCase();
matchSearch = fileStatus === searchTerm; // Filter file based on
its status
}
else {
// For detail-level fields searched in detailed view: Let the file
PASS this filter.
matchSearch = true;
}
}
}
return matchCheck && matchSearch;
});
}
// --- Render ERA Summary Table ---
// MODIFIED: Render new columns, remove old, add context menu
function renderEraFileSummaries(filesToRender) {
const tableBody = document.querySelector("#eraSummaryTable tbody");
if (!tableBody) return;
tableBody.innerHTML = "";
const colspan = 12; // Matches restored headers
if (!Array.isArray(filesToRender) || filesToRender.length === 0) {
tableBody.innerHTML = `<tr><td colspan="${colspan}">No ERA files found
matching criteria.</td></tr>`;
return;
}
filesToRender.forEach((file, index) => {
const row = tableBody.insertRow();
row.insertCell().textContent = index + 1;
const fileNameCell = row.insertCell();
fileNameCell.textContent = file.file_name || 'N/A';
fileNameCell.classList.add("era-context-menu-trigger");
fileNameCell.addEventListener("contextmenu", (event) => {
event.preventDefault(); showEraContextMenu(event, file);
});
row.insertCell().textContent = file.payer_name || 'N/A';
row.insertCell().textContent = file.payee_name || 'N/A';
row.insertCell().textContent = file.payee_id || 'N/A';
row.insertCell().textContent = reformatDate(file.check_date) || 'N/A';
row.insertCell().textContent = file.check_eft || "Unknown";
row.insertCell().textContent = file.trn_number || 'N/A';
row.insertCell().textContent = file.amount || 'N/A';
// Status (from backend /era_files)
const statusCell = row.insertCell();
statusCell.textContent = file.reconcileStatus || "N/A"; // Use mapped
status
if (file.reconcileStatus === 'Yes') statusCell.classList.add('status-
yes');
else if (file.reconcileStatus === 'No') statusCell.classList.add('status-
no');
// No 'PARTIAL' expected for ERA check-level status from backend join
// Calculated Amounts (Restored)
row.insertCell().textContent = file.reconciledSum !== undefined ? `$$
{file.reconciledSum.toFixed(2)}` : "N/A";
row.insertCell().textContent = file.nonReconciledSum !== undefined ? `$$
{file.nonReconciledSum.toFixed(2)}` : "N/A";
});
}
// --- NEW: ERA Context Menu ---
function showEraContextMenu(event, file) {
hideContextMenu(); // Hide any existing menu (reuse BAI hide function)
const menu = document.getElementById("contextMenu"); // Reuse the same context
menu div
if (!menu) return;
menu.innerHTML = ''; // Clear previous items
const ul = document.createElement("ul");
// Download ERA File
const downloadOption = document.createElement("li");
downloadOption.textContent = "Download ERA File";
downloadOption.addEventListener("click", () => {
downloadEraFile(file.id, file.file_name);
hideContextMenu();
});
ul.appendChild(downloadOption);
// Detailed View (ERA)
const detailedOption = document.createElement("li");
detailedOption.textContent = "Detailed View";
detailedOption.addEventListener("click", () => {
viewEraDetailedNew(file.id); // Function to open ERA details in new tab
hideContextMenu();
});
ul.appendChild(detailedOption);
// Reconcile (ERA)
const reconcileOption = document.createElement("li");
reconcileOption.textContent = "Reconcile";
reconcileOption.addEventListener("click", () => {
window.reconcileFilterEraId = file.id; // Set global filter if needed for ERA
recon
const reconcileButton = document.querySelector('.sidebar-button[data-
section="reconcile"]');
if (reconcileButton) {
reconcileButton.click(); // Switch to Reconcile tab
}
hideContextMenu();
});
ul.appendChild(reconcileOption);
// NO Friendly view for ERA for now, unless requested
menu.appendChild(ul);
// Position the menu
menu.style.top = `${event.pageY}px`;
menu.style.left = `${event.pageX}px`;
menu.style.display = "block";
}
// --- Apply ERA Filters and Toggle View ---
function applyEraViewFilters() {
const viewSelect = document.getElementById("eraDetailViewSelect")?.value ||
"summary";
updateEraSearchOptions(viewSelect); // Update dropdown options based on view
*first*
const filesToFilter = Array.isArray(allEraFiles) ? allEraFiles : []; // Ensure
array
const filteredFiles = filterEraFiles(filesToFilter); // Now filter based on
current criteria
const summaryContainer = document.getElementById("eraSummaryContainer");
const detailedContainer =
document.getElementById("eraDetailedAggregatedContainer");
if (summaryContainer) summaryContainer.style.display = (viewSelect ===
"summary") ? "block" : "none";
if (detailedContainer) detailedContainer.style.display = (viewSelect ===
"detailed") ? "block" : "none";
if (viewSelect === "summary") {
renderEraFileSummaries(filteredFiles); // Render summary table
} else {
loadEraDetailedAggregatedTransactions(filteredFiles); // This fetches details
again
}
}
// --- Filter ERA Files function (expects filesToFilter array) ---
function filterEraFiles(filesToFilter) {
let searchTerm = (eraFileSearchInput?.value || "").toLowerCase().trim();
let searchByElem = document.getElementById("eraSearchBy");
let searchBy = searchByElem ? searchByElem.value : "file_name";
let checkFrom = document.getElementById("filterCheckDateFrom")?.value || "";
let checkTo = document.getElementById("filterCheckDateTo")?.value || "";
let currentView = document.getElementById("eraDetailViewSelect")?.value ||
"summary";
return filesToFilter.filter((file) => {
// Date filtering (remains same)
let formattedCheck = reformatDate(file.check_date);
let checkIso = mmddyyToIso(formattedCheck);
let matchCheck = true;
if (checkFrom && checkIso) matchCheck = matchCheck && checkIso >=
checkFrom;
if (checkTo && checkIso) matchCheck = matchCheck && checkIso <= checkTo;
if ((checkFrom || checkTo) && !checkIso) matchCheck = false;
if (!matchCheck) return false;
// Search term filtering
let matchSearch = true;
if (searchTerm && searchBy) {
searchTerm = searchTerm.toLowerCase();
let summaryFieldValue = '';
let isStatusSearch = false;
if (currentView === 'summary') {
switch (searchBy) {
case "file_name": summaryFieldValue = (file.file_name ||
"").toLowerCase(); break;
case "payer_name": summaryFieldValue = (file.payer_name ||
"").toLowerCase(); break;
case "payee_name": summaryFieldValue = (file.payee_name ||
"").toLowerCase(); break;
case "payee_id": summaryFieldValue = (file.payee_id ||
"").toLowerCase(); break;
case "trn_number": summaryFieldValue = (file.trn_number ||
"").toLowerCase(); break;
case "amount":
summaryFieldValue = (file.amount || "").replace(/[$,]/g,
"").toLowerCase();
searchTerm = searchTerm.replace(/[$,]/g, "");
break;
case "check_eft": summaryFieldValue = (file.check_eft ||
"").toLowerCase(); break;
case "reconcileStatus":
// Use the mapped file.reconcileStatus
summaryFieldValue = (file.reconcileStatus ||
"N/A").toLowerCase();
isStatusSearch = true;
break;
default:
matchSearch = (file.searchData ||
"").toLowerCase().includes(searchTerm);
summaryFieldValue = 'handled_by_default';
}
if (matchSearch && summaryFieldValue !== 'handled_by_default') {
matchSearch = isStatusSearch ? (summaryFieldValue ===
searchTerm) : summaryFieldValue.includes(searchTerm);
}
} else { // detailed view
if (searchBy === "file_name") {
matchSearch = (file.file_name ||
"").toLowerCase().includes(searchTerm);
} else if (searchBy === "trn_number") {
matchSearch = (file.trn_number ||
"").toLowerCase().includes(searchTerm);
} else if (searchBy === "reconcileStatus") {
// Use the mapped file.reconcileStatus
matchSearch = (file.reconcileStatus || "N/A").toLowerCase()
=== searchTerm;
} else {
matchSearch = true; // Detail-level search, file passes
}
}
}
return matchCheck && matchSearch;
});
}
// --- ERA Event Listeners ---
// **** MODIFIED: Add listener for new eraSearchBy dropdown ****
const eraDetailViewSelect = document.getElementById("eraDetailViewSelect");
// const eraFileSearch = document.getElementById("eraFileSearch"); // Already
have eraFileSearchInput
const filterCheckDateFrom = document.getElementById("filterCheckDateFrom");
const filterCheckDateTo = document.getElementById("filterCheckDateTo");
const eraSearchBySelect = document.getElementById("eraSearchBy"); // Get the new
dropdown
if (eraDetailViewSelect) eraDetailViewSelect.addEventListener("change",
applyEraViewFilters);
if (eraFileSearchInput) eraFileSearchInput.addEventListener("input",
applyEraViewFilters); // Use eraFileSearchInput
if (filterCheckDateFrom) filterCheckDateFrom.addEventListener("change",
applyEraViewFilters);
if (filterCheckDateTo) filterCheckDateTo.addEventListener("change",
applyEraViewFilters);
if (eraSearchBySelect) eraSearchBySelect.addEventListener("change",
applyEraViewFilters); // Add listener for Search By
// --- Download ERA File ---
function downloadEraFile(eraId, fileName) {
// Use the dedicated download endpoint which gets raw_content from ERA_Summary
fetch(`/download_era/${eraId}`)
.then((res) => {
if (!res.ok) {
// Try to read error message if response is JSON
return res.json().then(err => { throw new Error(err.error || `HTTP
error ${res.status}`); });
}
// Get filename from Content-Disposition header if possible
const disposition = res.headers.get('Content-Disposition');
let downloadFilename = fileName || `ERA_File_${eraId}.835`; // Fallback
filename
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
downloadFilename = matches[1].replace(/['"]/g, '');
}
}
return res.blob().then(blob => ({ blob, downloadFilename })); // Pass
blob and filename
})
.then(({ blob, downloadFilename }) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Use filename from header or fallback
a.download = downloadFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.catch((error) => {
console.error("Error downloading ERA file:", error);
alert("Error downloading ERA file: " + error.message);
});
}
// Format date helper used in ERA detailed view
function formatDateYYYYMMDD_JS(dateStr) { // Renamed to avoid conflict
if (!dateStr) return "N/A";
dateStr = dateStr.trim();
if (/^\d{8}$/.test(dateStr)) { // YYYYMMDD
let year = dateStr.substr(0, 4);
let month = dateStr.substr(4, 2);
let day = dateStr.substr(6, 2);
return `${month}/${day}/${year}`;
} else if (/^\d{6}$/.test(dateStr)) { // YYMMDD -> MM/DD/YYYY
let year = "20" + dateStr.substr(0, 2);
let month = dateStr.substr(2, 4);
let day = dateStr.substr(4, 6);
return `${month}/${day}/${year}`;
}
return dateStr; // Return original if not recognized
}
// --- ERA Detailed View (Single File in New Tab) ---
// MODIFIED: Renders table based on the 'details' array fetched from ERA_Detail.
function viewEraDetailedNew(eraId) {
fetch(`/era_files/${eraId}`) // Fetches summary (with status) and details
.then(res => {
if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
return res.json();
})
.then(data => {
if (!data || !data.summary || !data.details) {
throw new Error("Incomplete data received for ERA detailed
view.");
}
const summary = data.summary; // summary includes reconcile_status
const detailsList = data.details;
const fileName = summary.file_name || `ERA_${eraId}`;
const globalTrnNumber = summary.trn_number || "N/A";
// Get the reconciliation status from the summary data
const reconcileStatus = summary.reconcile_status || 'No'; // Status
applies to the whole check
// --- Build HTML Table from detailsList (No change needed here) ---
let tableHtml = "<div class='detailed-table-container'><table
class='detailed-table' id='eraDetailedTable'>";
// ... (keep table header/body generation as before) ...
tableHtml += "<thead><tr>";
const headers = ["Sr No.", "Patient Name", "ACNT", "ICN", "Billed
Amount", "Paid Amount", "From Date", "To Date"];
headers.forEach(h => tableHtml += `<th>${h}</th>`);
tableHtml += "</tr></thead><tbody>";
if (detailsList && detailsList.length > 0) {
detailsList.forEach((detailRow, index) => {
tableHtml += "<tr>";
tableHtml += `<td>${index + 1}</td>`;
tableHtml += `<td>${detailRow.patient_name || 'N/A'}</td>`;
tableHtml += `<td>${detailRow.account_number || 'N/A'}</td>`;
tableHtml += `<td>${detailRow.icn || 'N/A'}</td>`;
tableHtml += `<td>${detailRow.billed_amount || '$0.00'}</td>`;
tableHtml += `<td>${detailRow.paid_amount || '$0.00'}</td>`;
tableHtml += `<td>${detailRow.from_date || 'N/A'}</td>`;
tableHtml += `<td>${detailRow.to_date || 'N/A'}</td>`;
tableHtml += "</tr>";
});
} else {
tableHtml += `<tr><td colspan="${headers.length}">No claim details
found in ERA_Detail table.</td></tr>`;
}
tableHtml += "</tbody></table></div>";
// --- Create New Tab (MODIFIED to show status) ---
let newTab = window.open("", "_blank");
newTab.document.write(`
<html>
<head>
<title>Detailed ERA - ${fileName}</title>
<link rel="stylesheet" href="/static/style.css">
<style>
/* ... (keep existing styles) ... */
.summary-info strong.status-yes { color: green; }
.summary-info strong.status-no { color: red; }
.summary-info { display: flex; flex-wrap: wrap; gap: 5px
15px; align-items: center;} /* Better layout */
</style>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"><\/
script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-
autotable/3.5.25/jspdf.plugin.autotable.min.js"><\/script>
</head>
<body>
<div class="header-container">
<h2>Detailed ERA - ${fileName}</h2>
<div class="export-buttons">
<button onclick="exportEraDetailCSV()">Export To CSV</button>
<button onclick="exportEraDetailPDF()">Export To PDF</button>
<button onclick="window.opener.triggerEraDownload(${eraId},
'${fileName}')">Download ERA File</button>
</div>
</div>
<!-- Display Status in Summary Info -->
<div class="summary-info">
<span>Check/EFT Number (TRN):
<strong>${globalTrnNumber}</strong></span> |
<span>Payer: ${summary.payer_name || 'N/A'}</span> |
<span>Amount: ${summary.amount || 'N/A'}</span> |
<span>Status: <strong class="${reconcileStatus === 'Yes' ?
'status-yes' : 'status-no'}">${reconcileStatus}</strong></span>
</div>
<div class="filters-container">
<label for="eraDetailSearch">Search:</label>
<input type="text" id="eraDetailSearch" placeholder="Filter
table content..." onkeyup="filterEraDetailTable()">
</div>
<div id="eraDetailedContainer">
${tableHtml}
</div>
<script>
// ... (keep existing triggerEraDownload, filterEraDetailTable,
exportEraDetailCSV, exportEraDetailPDF functions) ...
<\/script>
</body>
</html>
`);
newTab.document.close();
})
.catch(error => {
console.error("Error fetching/displaying ERA detailed view:", error);
alert("Error fetching detailed ERA view: " + error.message);
});
}
// ... (keep triggerEraDownload) ...
// Expose functions to global scope for new tab interaction
window.viewEraDetailed = viewEraDetailedNew; // Make sure this is the intended
function name
window.triggerEraDownload = downloadEraFile;
// --- Load ERA Aggregated Detailed View ---
// Combines details from MULTIPLE filtered ERA files.
// static/script.js:
// ... (keep existing code above) ...
// --- Load ERA Aggregated Detailed View ---
// Combines details from MULTIPLE filtered ERA files.
// MODIFIED: Removed "Reconcile" column.
async function loadEraDetailedAggregatedTransactions(filteredFiles) {
const detailedContainer =
document.getElementById("eraDetailedAggregatedContainer");
if (!detailedContainer) return;
detailedContainer.innerHTML = "<p>Loading detailed aggregated ERA
claims...</p>";
const table = document.createElement("table");
table.id = "eraDetailedAggregatedTable";
table.classList.add("detailed-table");
const thead = table.createTHead();
const headerRow = thead.insertRow();
// Add Reconcile Status column
const headers = [
"Sr No.", "File Name", "Check/EFT Number", "Patient Name", "ACNT", "ICN",
"Billed Amount", "Paid Amount", "From Date", "To Date", "Reconcile Status" //
Added Status
];
headers.forEach(text =>
headerRow.appendChild(Object.assign(document.createElement("th"), { textContent:
text })));
const tbody = table.createTBody();
const newColspan = headers.length;
if (!filteredFiles || filteredFiles.length === 0) {
tbody.innerHTML = `<tr><td colspan="${newColspan}">No ERA files selected
for detailed view.</td></tr>`;
detailedContainer.innerHTML = '';
detailedContainer.appendChild(table);
return;
}
const searchTerm = (eraFileSearchInput?.value || "").toLowerCase().trim();
const searchByElem = document.getElementById("eraSearchBy");
const searchBy = searchByElem ? searchByElem.value : "file_name";
// Fetch full details ONLY if needed (e.g., if search term applies to details)
// Optimization: If search is only file-level, we might not need to fetch claim
details yet.
// But for simplicity now, fetch details for all filtered files.
const fetchPromises = filteredFiles.map((fileSummary) =>
fetch(`/era_files/${fileSummary.id}`) // Fetches summary (with status) and
claim details
.then((res) => res.ok ? res.json() : Promise.reject(new Error(`Fetch failed
${res.status}`)))
.then(data => ({ ...data, originalSummary: fileSummary })) // Keep original
summary too if needed
.catch((error) => {
console.error("Error fetching ERA details for aggregated view, id",
fileSummary.id, ":", error);
return { error: true, id: fileSummary.id, originalSummary: fileSummary };
// Include original summary even on error
})
);
const detailsResults = await Promise.all(fetchPromises);
let globalCounter = 1;
let hasData = false;
detailsResults.forEach((result) => {
// Use originalSummary if fetch failed but we still want to show *something*
based on file filters
const fileSummary = result.summary || result.originalSummary;
const detailsList = (result && !result.error && result.details) ?
result.details : []; // Use empty list if details failed/missing
const fileReconcileStatus = fileSummary.reconcile_status ||
fileSummary.reconcileStatus || 'No'; // Get status from summary
// If the file itself didn't match file-level filters (like status), skip its
details
// Note: filterEraFiles should handle this, but double-check might be needed
depending on complexity.
// Let's assume filterEraFiles worked correctly.
detailsList.forEach((detailRow) => {
let rowMatchesSearch = true;
if (searchTerm) {
const detailLevelFields = ["patient_name", "account_number", "icn",
"billed_amount", "paid_amount"];
if (detailLevelFields.includes(searchBy)) {
let fieldValue = "";
switch(searchBy) {
case "patient_name": fieldValue = (detailRow.patient_name ||
"").toLowerCase(); break;
case "account_number": fieldValue = (detailRow.account_number
|| "").toLowerCase(); break;
case "icn": fieldValue = (detailRow.icn || "").toLowerCase();
break;
case "billed_amount":
fieldValue = (detailRow.billed_amount || "").replace("$",
"").replace(/,/g, "").toLowerCase();
rowMatchesSearch =
fieldValue.includes(searchTerm.replace("$", "").replace(/,/g, ""));
break;
case "paid_amount":
fieldValue = (detailRow.paid_amount || "").replace("$",
"").replace(/,/g, "").toLowerCase();
rowMatchesSearch =
fieldValue.includes(searchTerm.replace("$", "").replace(/,/g, ""));
break;
default: rowMatchesSearch = false;
}
if (searchBy !== "billed_amount" && searchBy !== "paid_amount") {
rowMatchesSearch = fieldValue.includes(searchTerm);
}
}
// If searchBy is file-level or reconcileStatus, row matches if file
matched
}
if (rowMatchesSearch) {
hasData = true;
const row = tbody.insertRow();
row.insertCell().textContent = globalCounter++;
row.insertCell().textContent = fileSummary.file_name || 'N/A';
row.insertCell().textContent = fileSummary.trn_number || 'N/A';
row.insertCell().textContent = detailRow.patient_name || 'N/A';
row.insertCell().textContent = detailRow.account_number || 'N/A';
row.insertCell().textContent = detailRow.icn || 'N/A';
row.insertCell().textContent = detailRow.billed_amount || '$0.00';
row.insertCell().textContent = detailRow.paid_amount || '$0.00';
row.insertCell().textContent = detailRow.from_date || 'N/A';
row.insertCell().textContent = detailRow.to_date || 'N/A';
// Display file-level reconcile status for each claim row
const statusCell = row.insertCell();
statusCell.textContent = fileReconcileStatus;
statusCell.classList.add(fileReconcileStatus === 'Yes' ? 'status-yes'
: 'status-no');
}
}); // End loop through detailsList
}); // End loop through detailsResults
if (!hasData) {
tbody.innerHTML = `<tr><td colspan="${newColspan}">No detailed claim
information found matching the filter criteria.</td></tr>`;
}
detailedContainer.innerHTML = '';
detailedContainer.appendChild(table);
}
// ... (rest of script.js remains the same) ...
// =============================
// --- Reconciliation Section ---
// =============================
// --- Load Reconciliation Tables ---
function loadReconcileTables() {
// Check if data is loaded, if not, trigger loading first
const loadPromises = [];
if (allFiles.length === 0) {
loadPromises.push(loadFileSummaries());
}
if (allEraFiles.length === 0) {
loadPromises.push(loadEraFileSummaries());
}
// Once data is available (or loaded), populate the tables
Promise.all(loadPromises).then(() => {
loadBaiReconcileTable();
loadEraReconcileTable();
}).catch(error => {
console.error("Error loading data for reconciliation:", error);
// Display error in reconcile containers
const baiContainer =
document.getElementById("baiReconcileTableContainer");
const eraContainer =
document.getElementById("eraReconcileTableContainer");
if (baiContainer) baiContainer.innerHTML = "<p>Error loading BAI data for
reconciliation.</p>";
if (eraContainer) eraContainer.innerHTML = "<p>Error loading ERA data for
reconciliation.</p>";
});
}
// --- Load BAI Data for Reconciliation ---
// --- Event Listener for the Run Reconciliation Button ---
if (reconcileTriggerButton) {
reconcileTriggerButton.addEventListener('click', async () => {
displayMessage(reconcileMessageDiv, 'Running reconciliation...', 'info-
message'); // Use a neutral style
reconcileTriggerButton.disabled = true; // Prevent multiple clicks
try {
const response = await fetch('/reconcile_now', { method: 'POST' });
const result = await response.json();
if (response.ok) {
displayMessage(reconcileMessageDiv, result.message || `Reconciliation
complete. ${result.matched_count || 0} records matched.`, 'success-message');
// Refresh both reconciled and non-reconciled views after running
loadReconciledData();
loadNonReconciledData(); // Refresh non-reconciled as well
// Optionally refresh BAI/ERA summary views if reconcileStatus
calculation changes
allFiles = []; // Clear cache to force reload with potential status
updates
allEraFiles = [];
} else {
displayMessage(reconcileMessageDiv, `Reconciliation failed: $
{result.error || 'Unknown server error'}`, 'error-message');
}
} catch (error) {
console.error("Error triggering reconciliation:", error);
displayMessage(reconcileMessageDiv, `Reconciliation failed: Network or
client-side error (${error.message})`, 'error-message');
} finally {
reconcileTriggerButton.disabled = false; // Re-enable button
}
});
}
// --- NEW: Load Reconciled Data ---
// static/script.js: Replace this function
async function loadReconciledData() {
const baiContainer = document.getElementById("baiReconcileTableContainer");
const eraContainer = document.getElementById("eraReconcileTableContainer");
const filterDropdown = document.getElementById("reconcileFilterType");
// *Critical Check*: Exit if containers aren't found
if (!baiContainer || !eraContainer) {
console.error("Reconciled page error: Table container elements not
found!");
return;
}
console.log("loadReconciledData: Starting fetch...");
baiContainer.innerHTML = "<p>Loading matched BAI data...</p>";
eraContainer.innerHTML = "<p>Loading matched ERA data...</p>";
allReconciledBai = []; // Reset global arrays before fetch
allReconciledEra = [];
try {
const response = await fetch("/reconciled_data"); // Fetches
{bai_reconciled: [], era_reconciled: []}
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const data = await response.json();
console.log("loadReconciledData: Fetched data:", data); // Log fetched data
// Store fetched data, ensuring they are arrays
allReconciledBai = Array.isArray(data.bai_reconciled) ? data.bai_reconciled
: [];
allReconciledEra = Array.isArray(data.era_reconciled) ? data.era_reconciled
: [];
console.log(`loadReconciledData: Stored ${allReconciledBai.length} BAI
items and ${allReconciledEra.length} ERA items globally.`);
// --- NEW: Populate Search By dropdowns ---
updateReconcileSearchOptions();
// Ensure dropdown listener is attached (only once) - moved logic to main
DOMContentLoaded
// Apply filter (which includes rendering)
console.log("loadReconciledData: Calling applyAllReconciledFilters...");
applyAllReconciledFilters(); // Use the combined filter function
console.log("loadReconciledData: Finished applyAllReconciledFilters
call.");
} catch (error) {
console.error("Error loading reconciled data:", error);
baiContainer.innerHTML = `<p class="error-message">Error loading reconciled
BAI data: ${error.message}</p>`;
eraContainer.innerHTML = `<p class="error-message">Error loading reconciled
ERA data: ${error.message}</p>`;
allReconciledBai = []; // Clear arrays on error
allReconciledEra = [];
// Optionally try to render empty tables to clear loading messages
// applyAllReconciledFilters();
}
}
// static/script.js: Rename filterReconciledPairs to filterReconciledItems
function filterReconciledItems(items, filterType) {
if (!Array.isArray(items)) {
console.warn("filterReconciledItems: Input is not an array.", items);
return [];
}
console.log(`filterReconciledItems: Filtering ${items.length} items for type: "$
{filterType}"`);
if (filterType === 'all') {
console.log(`filterReconciledItems: Filter type 'all', returning all $
{items.length} items.`);
return items; // Return the original array if 'all'
}
// Determine if we are looking for manual (true) or auto (false)
const lookForManual = (filterType === 'manual');
console.log(`filterReconciledItems: Looking for manual = ${lookForManual}`);
const result = items.filter(item => {
// Get the manual_reconciliation flag from the item
const manualFlag = item.manual_reconciliation;
let isItemManual;
// Interpret the flag robustly (handles true/1 or false/0/null/undefined)
if (manualFlag === true || manualFlag === 1) {
isItemManual = true;
} else {
isItemManual = false;
}
// Return true if the item's type matches what we're looking for
return isItemManual === lookForManual;
});
console.log(`filterReconciledItems: Filter result count: ${result.length}`);
return result;
}
// --- Function to apply the filter and trigger rendering ---
function applyReconciledFilter() {
const filterDropdown = document.getElementById("reconcileFilterType");
const selectedType = filterDropdown ? filterDropdown.value : 'all';
console.log(`applyReconciledFilter: Applying filter type "${selectedType}"`);
// Filter both global lists
const filteredBai = filterReconciledItems(allReconciledBai, selectedType);
const filteredEra = filterReconciledItems(allReconciledEra, selectedType);
console.log(`applyReconciledFilter: Filtered counts - BAI: ${filteredBai.length},
ERA: ${filteredEra.length}`);
// Call the function to render the two tables with filtered data
renderReconciledTables(filteredBai, filteredEra);
}
// --- NEW: Render Reconciled Tables ---
// static/script.js: Replace renderReconciledTables with renderReconciledTable
// Renders a SINGLE table for reconciled pairs
function renderReconciledTables(baiData, eraData) {
const baiContainer = document.getElementById("baiReconcileTableContainer");
const eraContainer = document.getElementById("eraReconcileTableContainer");
// *Critical Check*: Exit if containers aren't found
if (!baiContainer || !eraContainer) {
console.error("Error rendering reconciled tables: Container elements not
found!");
return;
}
console.log(`renderReconciledTables: Rendering ${baiData.length} BAI rows and $
{eraData.length} ERA rows.`);
// --- Render BAI Table ---
const baiTable = document.createElement("table");
baiTable.id = "baiReconcileTable";
const baiThead = baiTable.createTHead();
const baiHeaderRow = baiThead.insertRow();
const baiHeaders = ["File", "Cust Ref", "Orig Amt", "Reconciled", "Remaining",
"Status", "Type", "Actions"];
baiHeaders.forEach(text =>
baiHeaderRow.appendChild(Object.assign(document.createElement("th"), { textContent:
text })));
const baiTbody = baiTable.createTBody();
if (baiData.length > 0) {
baiData.forEach(item => {
const row = baiTbody.insertRow();
row.insertCell().textContent = item.bai_file_name || 'N/A';
row.insertCell().textContent = item.customer_reference || 'N/A';
row.insertCell().textContent = item.bai_display_amount || '$0.00'; //
Original BAI amount string
row.insertCell().textContent = item.bai_total_reconciled !== null ? `$$
{Number(item.bai_total_reconciled).toFixed(2)}` : 'N/A'; // Total rec for this BAI
detail
row.insertCell().textContent = item.bai_remaining_amount !== null ? `$$
{Number(item.bai_remaining_amount).toFixed(2)}` : 'N/A';
row.insertCell().textContent = item.bai_status || 'N/A'; // 'Yes' or
'Partial'
const isManualRec = item.manual_reconciliation === true ||
item.manual_reconciliation === 1;
row.insertCell().textContent = isManualRec ? 'Manual' : 'Auto';
// Actions cell (Revert button)
const actionCell = row.insertCell();
const revertButton = document.createElement('button');
revertButton.textContent = 'Revert';
revertButton.classList.add('button', 'button-small', 'button-danger');
revertButton.dataset.reconciliationId = item.reconciliation_id;
revertButton.addEventListener('click', handleRevertClick); // Ensure
handleRevertClick exists
actionCell.appendChild(revertButton);
});
} else {
baiTbody.innerHTML = `<tr><td colspan="${baiHeaders.length}">No matched BAI
records found matching filter.</td></tr>`;
}
baiContainer.innerHTML = ''; // Clear loading message
baiContainer.appendChild(baiTable);
// --- Render ERA Table ---
const eraTable = document.createElement("table");
eraTable.id = "eraReconcileTable";
const eraThead = eraTable.createTHead();
const eraHeaderRow = eraThead.insertRow();
const eraHeaders = ["File Name", "Check/EFT No.", "Check Date", "Amount", "Type",
"Actions"]; // Added Actions
eraHeaders.forEach(text =>
eraHeaderRow.appendChild(Object.assign(document.createElement("th"), { textContent:
text })));
const eraTbody = eraTable.createTBody();
if (eraData.length > 0) {
eraData.forEach(item => {
const row = eraTbody.insertRow();
row.insertCell().textContent = item.era_file_name || 'N/A';
row.insertCell().textContent = item.trn_number || 'N/A';
row.insertCell().textContent = reformatDate(item.era_check_date) ||
'N/A';
row.insertCell().textContent = item.era_amount || 'N/A'; // ERA Summary
Amount
const isManualRec = item.manual_reconciliation === true ||
item.manual_reconciliation === 1;
row.insertCell().textContent = isManualRec ? 'Manual' : 'Auto';
// Actions cell (Revert button)
const actionCell = row.insertCell();
const revertButton = document.createElement('button');
revertButton.textContent = 'Revert';
revertButton.classList.add('button', 'button-small', 'button-danger');
revertButton.dataset.reconciliationId = item.reconciliation_id;
revertButton.addEventListener('click', handleRevertClick); // Ensure
handleRevertClick exists
actionCell.appendChild(revertButton);
});
} else {
eraTbody.innerHTML = `<tr><td colspan="${eraHeaders.length}">No matched ERA
records found matching filter.</td></tr>`;
}
eraContainer.innerHTML = ''; // Clear loading message
eraContainer.appendChild(eraTable);
}
async function handleRevertClick(event) {
const button = event.target;
const reconciliationId = button.dataset.reconciliationId;
if (!reconciliationId) {
alert("Error: Could not find reconciliation ID.");
return;
}
if (!confirm(`Are you sure you want to revert reconciliation record ID $
{reconciliationId}?`)) {
return; // User cancelled
}
button.disabled = true;
button.textContent = 'Reverting...';
try {
const response = await fetch('/revert_reconciliation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reconciliation_id: reconciliationId })
});
const result = await response.json();
if (response.ok) {
alert(result.message || "Revert successful!");
// Refresh both views
loadReconciledData();
loadNonReconciledData();
} else {
alert(`Revert failed: ${result.error || 'Unknown server error'}`);
button.disabled = false; // Re-enable on failure
button.textContent = 'Revert';
}
} catch (error) {
console.error("Revert error:", error);
alert(`Revert failed: Network or client-side error (${error.message})`);
button.disabled = false;
button.textContent = 'Revert';
}
}
// --- NEW: Load Non-Reconciled Data ---
// static/script.js: Replace this function
// static/script.js: Replace this function
async function loadNonReconciledData() {
console.log("loadNonReconciledData: Function start"); // DEBUG
// Get containers *first*
const baiContainer = document.getElementById("baiNonReconcileTableContainer");
const eraContainer = document.getElementById("eraNonReconcileTableContainer");
const nonReconcileSection = document.getElementById('nonReconcile');
const manualControlsContainer =
document.getElementById('manualReconcileControls'); // Get the container for the
button
// *Critical Check*: Exit if containers aren't found
if (!baiContainer || !eraContainer || !nonReconcileSection || !
manualControlsContainer) {
console.error("Error: Non-reconciled page elements not found (containers or
button placeholder)!");
return;
}
// Now it's safe to proceed
baiContainer.innerHTML = "<p>Loading unmatched BAI data...</p>";
eraContainer.innerHTML = "<p>Loading unmatched ERA data...</p>";
console.log("loadNonReconciledData: Cleared table containers"); // DEBUG
// Ensure button container is empty before adding the button
manualControlsContainer.innerHTML = '';
// Reset global arrays
allNonReconciledBai = [];
allNonReconciledEra = [];
console.log("loadNonReconciledData: Reset global arrays"); // DEBUG
try {
console.log("loadNonReconciledData: Fetching from /non_reconciled_data"); //
DEBUG
const response = await fetch("/non_reconciled_data");
if (!response.ok) {
// Try to get error text if possible
let errorText = `HTTP error ${response.status}`;
try {
const errData = await response.json();
errorText = errData.error || errorText;
} catch(e) { /* Ignore if response is not JSON */ }
throw new Error(errorText);
}
const data = await response.json();
console.log("loadNonReconciledData: Fetched data:", data); // DEBUG: Check
the actual data received
// Store fetched data
allNonReconciledBai = Array.isArray(data.bai_unreconciled) ?
data.bai_unreconciled : [];
allNonReconciledEra = Array.isArray(data.era_unreconciled) ?
data.era_unreconciled : [];
console.log(`loadNonReconciledData: Stored BAI=${allNonReconciledBai.length},
ERA=${allNonReconciledEra.length}`); // DEBUG
// --- Add the Reconcile button ---
const reconcileButton = document.createElement('button');
reconcileButton.id = 'manualReconcileButton';
reconcileButton.textContent = 'Reconcile Selected';
reconcileButton.classList.add('button');
reconcileButton.disabled = true;
reconcileButton.addEventListener('click', handleManualReconcileClick);
manualControlsContainer.appendChild(reconcileButton);
console.log("loadNonReconciledData: Added reconcile button"); // DEBUG
// --- Populate the specific Search By dropdowns for this page ---
updateBaiNonReconcileSearchOptions();
updateEraNonReconcileSearchOptions();
console.log("loadNonReconciledData: Updated search options"); // DEBUG
// Render tables with initial (unfiltered) data
console.log("loadNonReconciledData: Calling
applyAllNonReconciledFilters"); // DEBUG
applyAllNonReconciledFilters(); // Call the combined filter/render function
console.log("loadNonReconciledData: Finished
applyAllNonReconciledFilters"); // DEBUG
} catch (error) {
console.error("Error loading non-reconciled data:", error); // Log the full
error
baiContainer.innerHTML = `<p class="error-message">Error loading unmatched
BAI data: ${error.message}</p>`;
eraContainer.innerHTML = `<p class="error-message">Error loading unmatched
ERA data: ${error.message}</p>`;
manualControlsContainer.innerHTML = ''; // Clear button container on error
allNonReconciledBai = []; // Clear arrays
allNonReconciledEra = [];
}
console.log("loadNonReconciledData: Function end"); // DEBUG
}
// static/script.js: Add these new functions
// Generic helper function to filter an array based on search criteria
function filterTableData(dataArray, searchBy, searchTerm) {
if (!Array.isArray(dataArray)) return [];
if (!searchTerm) return dataArray; // No search term, return all
searchTerm = searchTerm.toLowerCase().trim();
return dataArray.filter(item => {
let value = item[searchBy];
// Handle special cases (like boolean/type flags, dates, amounts)
if (searchBy === 'manual_reconciliation') {
const isManual = value === true || value === 1;
const searchType = searchTerm === 'manual' ? true : (searchTerm ===
'auto' ? false : null);
return searchType !== null ? isManual === searchType : true; // Only
filter if search term is 'manual' or 'auto'
} else if (typeof value === 'number' || searchBy.includes('_amount') ||
searchBy.includes('total_amount')) {
// Amount fields: strip $, ',', convert to string for includes check
value = String(item[searchBy] || '').replace(/[$,]/g, "");
} else if (searchBy.includes('_date')) {
// Date fields: use reformatDate if needed or compare raw string
value = String(reformatDate(item[searchBy] || '')).toLowerCase();
} else {
// Default: convert to string and lowercase for text search
value = String(item[searchBy] || '').toLowerCase();
}
return value.includes(searchTerm);
});
}
// --- NEW: Combined Filter/Render Function for Reconciled Page ---
// static/script.js: Replace this function
// --- Combined Filter/Render Function for Reconciled Page ---
function applyAllReconciledFilters() {
console.log("Applying all reconciled filters...");
const typeFilter = document.getElementById('reconcileFilterType')?.value ||
'all';
// Get specific filter values for BAI Reconciled table
const baiSearchBy = document.getElementById('baiReconcileSearchBy')?.value;
const baiSearchTerm = document.getElementById('baiReconcileSearch')?.value || '';
// Get specific filter values for ERA Reconciled table
const eraSearchBy = document.getElementById('eraReconcileSearchBy')?.value;
const eraSearchTerm = document.getElementById('eraReconcileSearch')?.value || '';
// 1. Filter by Type (Manual/Auto/All) - Use existing function
let typeFilteredBai = filterReconciledItems(allReconciledBai, typeFilter);
let typeFilteredEra = filterReconciledItems(allReconciledEra, typeFilter);
console.log(`After type filter: BAI=${typeFilteredBai.length}, ERA=$
{typeFilteredEra.length}`);
// 2. Apply Text Search Filters INDEPENDENTLY
let finalFilteredBai = filterTableData(typeFilteredBai, baiSearchBy,
baiSearchTerm);
let finalFilteredEra = filterTableData(typeFilteredEra, eraSearchBy,
eraSearchTerm);
console.log(`After text filter: BAI=${finalFilteredBai.length}, ERA=$
{finalFilteredEra.length}`);
// 3. Render the filtered results
renderReconciledTables(finalFilteredBai, finalFilteredEra);
}
// --- NEW: Combined Filter/Render Function for Non-Reconciled Page ---
// static/script.js: Replace this function
// --- Combined Filter/Render Function for Non-Reconciled Page ---
function applyAllNonReconciledFilters() {
console.log("Applying all non-reconciled filters..."); // DEBUG
// Get specific filter values for BAI Non-Reconciled table
const baiSearchBy = document.getElementById('baiNonReconcileSearchBy')?.value;
const baiSearchTerm = document.getElementById('baiNonReconcileSearch')?.value ||
'';
// Get specific filter values for ERA Non-Reconciled table
const eraSearchBy = document.getElementById('eraNonReconcileSearchBy')?.value;
const eraSearchTerm = document.getElementById('eraNonReconcileSearch')?.value ||
'';
console.log(`BAI Filter: By='${baiSearchBy}', Term='${baiSearchTerm}'`); // DEBUG
console.log(`ERA Filter: By='${eraSearchBy}', Term='${eraSearchTerm}'`); // DEBUG
// 1. Apply Text Search Filters INDEPENDENTLY to the global arrays
let filteredBai = filterTableData(allNonReconciledBai, baiSearchBy,
baiSearchTerm);
let filteredEra = filterTableData(allNonReconciledEra, eraSearchBy,
eraSearchTerm);
console.log(`After filtering: BAI=${filteredBai.length}, ERA=$
{filteredEra.length}`); // DEBUG
// 2. Render the filtered results
console.log("Calling renderNonReconciledTables with filtered data"); // DEBUG
renderNonReconciledTables(filteredBai, filteredEra);
// 3. Reset selections whenever filters change (important!)
selectedBaiDetail = null;
selectedEraSummary = null;
updateManualReconcileButtonState();
// Clear visual selection highlights
document.querySelectorAll('#baiNonReconcileTable tbody tr.selected').forEach(r
=> r.classList.remove('selected'));
document.querySelectorAll('#eraNonReconcileTable tbody tr.selected').forEach(r
=> r.classList.remove('selected'));
document.querySelectorAll('#baiNonReconcileTable
input[type="checkbox"]:checked').forEach(c => c.checked = false);
document.querySelectorAll('#eraNonReconcileTable
input[type="checkbox"]:checked').forEach(c => c.checked = false);
console.log("Selections reset after filtering."); // DEBUG
}
function updateManualReconcileButtonState() {
const reconcileButton = document.getElementById('manualReconcileButton');
if (reconcileButton) {
// Enable only if one BAI AND one ERA are selected
reconcileButton.disabled = !(selectedBaiDetail && selectedEraSummary);
}
}
// --- NEW: Render Non-Reconciled Tables ---
// static/script.js: Modify renderNonReconciledTables function temporarily
function renderNonReconciledTables(baiData, eraData) {
console.log("renderNonReconciledTables: Rendering BAI data:", baiData); // DEBUG:
See what data arrives
console.log("renderNonReconciledTables: Rendering ERA data:", eraData); // DEBUG:
See what data arrives
const baiContainer = document.getElementById("baiNonReconcileTableContainer");
const eraContainer = document.getElementById("eraNonReconcileTableContainer");
// *Critical Check*: Exit if containers aren't found
if (!baiContainer || !eraContainer) {
console.error("Error rendering non-reconciled tables: Container elements not
found!");
return;
}
console.log("renderNonReconciledTables: Containers found."); // DEBUG
selectedBaiDetail = null; // Reset selections on re-render
selectedEraSummary = null;
updateManualReconcileButtonState(); // Update button state
// --- Render BAI Table (Unmatched/Partial with Remaining > 0) ---
const baiTable = document.createElement("table");
baiTable.id = "baiNonReconcileTable";
const baiThead = baiTable.createTHead();
const baiHeaderRow = baiThead.insertRow();
// ** Using the headers requested: Select, File, CustRef, RcvDate, Total,
Reconciled, Remaining, Status **
const baiHeaders = ["Select", "File Name", "Cust Ref.", "Receive Date", "Total
Amt", "Reconciled Amt", "Remaining Amt", "Status"];
baiHeaders.forEach(text =>
baiHeaderRow.appendChild(Object.assign(document.createElement("th"), { textContent:
text })));
const baiTbody = baiTable.createTBody();
let baiRowsRendered = 0;
console.log(`renderNonReconciledTables: BAI data length: ${baiData ?
baiData.length : 'null'}`); // DEBUG
if (baiData && baiData.length > 0) { // Check if baiData is valid array
baiData.forEach((item, index) => {
console.log(`Rendering BAI Row ${index}:`, item); // DEBUG: Log each item
// Backend query already filtered status != 'Yes' and remaining > 0.005
baiRowsRendered++;
const row = baiTbody.insertRow();
// Ensure amounts are numbers before proceeding
const remainingAmt = Number(item.bai_remaining_amount || 0);
const totalAmt = Number(item.bai_total_parsed_amount || 0);
const reconciledAmt = Number(item.bai_reconciled_amount || 0);
row.dataset.baiId = item.bai_detail_id;
row.dataset.remainingAmount = remainingAmt; // Store numeric remaining
amount
// Select Cell
const selectCell = row.insertCell();
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.classList.add('bai-select-checkbox');
chk.dataset.baiId = item.bai_detail_id;
chk.addEventListener('change', handleBaiCheckboxChange);
selectCell.appendChild(chk);
// Data Cells
row.insertCell().textContent = item.bai_file_name || 'N/A';
row.insertCell().textContent = item.customer_reference || 'N/A';
row.insertCell().textContent = reformatDate(item.bai_receive_date) ||
'N/A';
row.insertCell().textContent = `$${totalAmt.toFixed(2)}`; // Display
Total
row.insertCell().textContent = `$${reconciledAmt.toFixed(2)}`; //
Display Reconciled
row.insertCell().textContent = `$${remainingAmt.toFixed(2)}`; // Display
Remaining
row.insertCell().textContent = item.bai_status || 'No'; // 'No' or
'Partial'
});
}
if (baiRowsRendered === 0) {
console.log("renderNonReconciledTables: No BAI rows rendered."); // DEBUG
baiTbody.innerHTML = `<tr><td colspan="${baiHeaders.length}">No BAI records
available for manual reconciliation.</td></tr>`;
}
baiContainer.innerHTML = '';
baiContainer.appendChild(baiTable);
console.log("renderNonReconciledTables: Finished rendering BAI table."); // DEBUG
// --- Render ERA Table (Unmatched) ---
console.log(`renderNonReconciledTables: ERA data length: ${eraData ?
eraData.length : 'null'}`); // DEBUG
const eraTable = document.createElement("table");
eraTable.id = "eraNonReconcileTable";
const eraThead = eraTable.createTHead();
const eraHeaderRow = eraThead.insertRow();
const eraHeaders = ["Select", "File Name", "Check/EFT No.", "Check Date",
"Amount"];
eraHeaders.forEach(text =>
eraHeaderRow.appendChild(Object.assign(document.createElement("th"), { textContent:
text })));
const eraTbody = eraTable.createTBody();
let eraRowsRendered = 0;
if (eraData && eraData.length > 0) { // Check if eraData is valid array
eraData.forEach((item, index) => {
console.log(`Rendering ERA Row ${index}:`, item); // DEBUG: Log each item
// Backend query already filtered for ERAs not in Reconciliation table
eraRowsRendered++;
const row = eraTbody.insertRow();
row.dataset.eraId = item.era_summary_id;
const eraAmount = parseAmountStrToNum(item.era_amount); // Parse amount
for validation
row.dataset.amount = eraAmount; // Store numeric amount
const selectCell = row.insertCell();
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.classList.add('era-select-checkbox');
chk.dataset.eraId = item.era_summary_id;
chk.addEventListener('change', handleEraCheckboxChange);
selectCell.appendChild(chk);
row.insertCell().textContent = item.era_file_name || 'N/A';
row.insertCell().textContent = item.trn_number || 'N/A';
row.insertCell().textContent = reformatDate(item.era_check_date) ||
'N/A';
row.insertCell().textContent = item.era_amount || 'N/A'; // Display
original string amount
});
}
if (eraRowsRendered === 0) {
console.log("renderNonReconciledTables: No ERA rows rendered."); // DEBUG
eraTbody.innerHTML = `<tr><td colspan="${eraHeaders.length}">No unreconciled
ERA records found.</td></tr>`;
}
eraContainer.innerHTML = '';
eraContainer.appendChild(eraTable);
console.log("renderNonReconciledTables: Finished rendering ERA table."); //
DEBUG
// --- Checkbox Handlers ---
function handleBaiCheckboxChange(event) {
const currentCheckbox = event.target;
const baiId = currentCheckbox.dataset.baiId;
const tableBody = currentCheckbox.closest('tbody');
// Remove selected class from all rows first
tableBody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
if (currentCheckbox.checked) {
// Uncheck all other BAI checkboxes
tableBody.querySelectorAll('.bai-select-checkbox').forEach(chk => {
if (chk !== currentCheckbox) chk.checked = false;
});
const row = currentCheckbox.closest('tr');
selectedBaiDetail = {
id: baiId,
remainingAmount: parseFloat(row.dataset.remainingAmount) // Get remaining
amount from row dataset
};
row.classList.add('selected'); // Highlight selected row
} else {
selectedBaiDetail = null;
// Class removed above already
}
updateManualReconcileButtonState();
}
function handleEraCheckboxChange(event) {
const currentCheckbox = event.target;
const eraId = currentCheckbox.dataset.eraId;
const tableBody = currentCheckbox.closest('tbody');
// Remove selected class from all rows first
tableBody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
if (currentCheckbox.checked) {
// Uncheck all other ERA checkboxes
tableBody.querySelectorAll('.era-select-checkbox').forEach(chk => {
if (chk !== currentCheckbox) chk.checked = false;
});
const row = currentCheckbox.closest('tr');
selectedEraSummary = {
id: eraId,
amount: parseFloat(row.dataset.amount) // Get amount from row dataset
};
row.classList.add('selected'); // Highlight selected row
} else {
selectedEraSummary = null;
// Class removed above already
}
updateManualReconcileButtonState();
}
// --- Manual Reconcile Button Click ---
async function handleManualReconcileClick() {
if (!selectedBaiDetail || !selectedEraSummary) {
alert("Please select one BAI record and one ERA record.");
return;
}
const baiId = selectedBaiDetail.id;
const baiRemaining = selectedBaiDetail.remainingAmount;
const eraId = selectedEraSummary.id;
const eraAmount = selectedEraSummary.amount;
// --- Client-side validation ---
if (eraAmount <= 0) {
alert("Cannot reconcile with zero or negative ERA amount.");
return;
}
// Check if ERA amount is greater than BAI remaining (using tolerance)
if (eraAmount > baiRemaining + 0.005) {
alert(`ERA amount ($${eraAmount.toFixed(2)}) cannot be greater than BAI
remaining amount ($${baiRemaining.toFixed(2)}).`);
return;
}
// --- Confirmation Popup ---
const amountToReconcile = eraAmount; // In manual, we reconcile the full ERA
amount if <= BAI remaining
const baiWillBeRemaining = baiRemaining - amountToReconcile;
const confirmMsg = `Are you sure you want to reconcile?\n\n` +
`BAI Detail ID: ${baiId} (Remaining: $$
{baiRemaining.toFixed(2)})\n` +
`ERA Summary ID: ${eraId} (Amount: $${eraAmount.toFixed(2)})\
n\n` +
`Amount to Reconcile: $${amountToReconcile.toFixed(2)}\n` +
`BAI Remaining After: $${baiWillBeRemaining.toFixed(2)}\n`;
if (!confirm(confirmMsg)) {
return; // User cancelled
}
// --- Call Backend API ---
const reconcileButton = document.getElementById('manualReconcileButton');
reconcileButton.disabled = true;
reconcileButton.textContent = 'Reconciling...';
try {
const response = await fetch('/manual_reconcile', { // Ensure this matches
your Flask route
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bai_detail_id: baiId,
era_summary_id: eraId
})
});
const result = await response.json();
if (response.ok) {
alert(result.message || "Reconciliation successful!");
// Refresh both non-reconciled and reconciled views to reflect changes
loadNonReconciledData();
loadReconciledData();
} else {
alert(`Reconciliation failed: ${result.error || 'Unknown server
error'}`);
// Re-enable button on failure *before* potential reload
reconcileButton.disabled = false;
reconcileButton.textContent = 'Reconcile Selected';
}
} catch (error) {
console.error("Manual reconcile error:", error);
alert(`Reconciliation failed: Network or client-side error ($
{error.message})`);
reconcileButton.disabled = false;
reconcileButton.textContent = 'Reconcile Selected';
}
// No finally block needed here as success case triggers reload which resets
button state.
}
// --- Ensure the Non-Reconcile tab loads data on click/initial load ---
// (Make sure the sidebar click handler and initial load logic call
loadNonReconciledData)
}); // End DOMContentLoaded
static/style.css:
/* static/style.css: Complete Code */
/* General Styling */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
background-color: #f4f6f9; /* Lighter grey background */
color: #2c3e50; /* Dark blue-grey text */
line-height: 1.6;
font-size: 16px; /* Base font size */
}
/* Main Container */
.container {
display: flex;
min-height: 100vh;
}
/* Sidebar Base Styling - CORRECTED */
.sidebar {
width: 260px; /* Full width */
background-color: #2c3e50;
color: #ecf0f1;
padding: 0; /* Remove overall padding, apply to inner elements */
box-sizing: border-box;
box-shadow: 2px 0px 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
flex-shrink: 0; /* Prevent sidebar from shrinking smaller than its
content/width */
transition: width 0.3s ease; /* Transition width */
overflow: hidden; /* Prevent content spill */
position: relative; /* Needed for absolute positioning of icon if desired */
}
/* Sidebar Header */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between; /* Space between title and icon */
padding: 15px 20px; /* Padding inside header */
border-bottom: 1px solid #34495e;
flex-shrink: 0; /* Prevent header from shrinking */
min-height: 60px; /* Ensure consistent header height */
box-sizing: border-box;
}
.sidebar-title {
font-size: 1.5em; /* Slightly adjusted */
margin: 0;
font-weight: 600;
color: #fff;
white-space: nowrap; /* Prevent title wrapping */
overflow: hidden; /* Hide title overflow */
transition: opacity 0.2s ease 0.1s, width 0.2s ease 0.1s; /* Fade and collapse
title */
}
.sidebar-toggle-icon {
background: none;
border: none;
color: #bdc3c7; /* Icon color */
font-size: 1.3em; /* Icon size */
cursor: pointer;
padding: 5px;
line-height: 1;
transition: color 0.2s ease;
flex-shrink: 0; /* Prevent icon shrinking */
}
.sidebar-toggle-icon:hover {
color: #fff;
}
/* Sidebar Navigation */
.sidebar-nav {
flex-grow: 1; /* Allow nav to fill space */
overflow-y: auto; /* Allow scrolling if content overflows */
overflow-x: hidden; /* Hide horizontal overflow */
padding: 15px 0; /* Padding above/below nav items */
}
.sidebar-nav ul {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-nav ul li {
margin-bottom: 5px;
}
/* Sidebar Buttons */
.sidebar-button {
display: flex;
align-items: center;
gap: 15px; /* Maintain gap */
width: 100%;
padding: 12px 20px; /* Padding for icon and text */
text-align: left;
background-color: transparent;
color: #bdc3c7;
border: none;
outline: none;
border-radius: 0; /* Remove radius if full width */
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
box-sizing: border-box;
font-size: 0.95em;
white-space: nowrap; /* Prevent button text wrap */
overflow: hidden; /* Hide text overflow */
}
.sidebar-button i {
width: 20px; /* Consistent icon width */
text-align: center;
flex-shrink: 0; /* Prevent icon shrinking */
}
.sidebar-button .button-text {
transition: opacity 0.2s ease, width 0.2s ease; /* Transition for text fade and
collapse */
opacity: 1;
white-space: nowrap;
overflow: hidden;
}
.sidebar-button:hover {
background-color: #34495e;
color: #fff;
}
.sidebar-button.active {
background-color: #1abc9c;
color: #fff;
font-weight: 500;
}
/* --- Sidebar Collapsed State --- CORRECTED */
.sidebar.collapsed {
width: 65px; /* Width when collapsed */
}
.sidebar.collapsed .sidebar-header {
justify-content: center; /* Center the icon */
padding: 15px 0;
}
.sidebar.collapsed .sidebar-title {
opacity: 0;
width: 0;
margin: 0;
pointer-events: none; /* Prevent interaction */
}
.sidebar.collapsed .sidebar-nav {
padding: 15px 0;
}
.sidebar.collapsed .sidebar-button {
justify-content: center; /* Center the icon */
padding: 12px 0;
gap: 0;
}
.sidebar.collapsed .sidebar-button .button-text {
opacity: 0;
width: 0;
pointer-events: none; /* Prevent interaction */
}
/* Content Area - Relies on Flexbox */
#mainContent { /* Use ID for specificity */
flex: 1; /* IMPORTANT: Takes remaining space */
/* BASE padding */
padding: 25px 30px;
box-sizing: border-box;
overflow-y: auto; /* Allow content scrolling if needed */
/* NO dynamic padding-left here */
}
/* Content Section */
.content-section {
padding: 25px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
margin-bottom: 25px;
}
.content-section h2 {
font-size: 1.5em;
margin-top: 0;
margin-bottom: 20px;
color: #2c3e50;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.content-section h2 i {
color: #3498db;
}
.content-section p {
color: #555;
margin-bottom: 15px;
}
/* Buttons */
.button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
background-color: #3498db;
color: #fff;
text-decoration: none;
border-radius: 6px;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
border: none;
cursor: pointer;
margin-right: 8px;
font-size: 0.95em;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.button:hover {
background-color: #2980b9;
box-shadow: 0 2px 5px rgba(0,0,0,0.15);
}
.button-small {
padding: 4px 8px;
font-size: 0.8em;
}
.button-danger {
background-color: #dc3545; /* Red */
}
.button-danger:hover {
background-color: #c82333; /* Darker red */
}
.button:disabled {
background-color: #ccc;
cursor: not-allowed;
box-shadow: none;
}
/* Input File Styling */
input[type="file"] {
margin-bottom: 15px;
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 6px;
display: block;
max-width: 400px;
}
/* Progress Bar */
.progress-container {
width: 100%;
background-color: #e9ecef;
border-radius: 6px;
height: 20px;
margin-top: 10px;
margin-bottom: 15px;
overflow: hidden;
position: relative;
border: 1px solid #d6dadf;
}
.progress-bar {
background-color: #2ecc71;
height: 100%;
width: 0%;
transition: width 0.4s ease, background-color 0.4s ease;
display: flex;
align-items: center;
justify-content: center;
}
.progress-bar.error {
background-color: #e74c3c;
}
.progress-label {
color: #fff;
font-size: 0.85em;
font-weight: 500;
text-shadow: 1px 1px 1px rgba(0,0,0,0.2);
}
/* Table Styling */
.table-container {
overflow-x: auto;
border: 1px solid #dee2e6;
border-radius: 8px;
margin-top: 15px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: none;
border-bottom: 1px solid #dee2e6;
padding: 12px 15px;
text-align: left;
font-size: 0.9em;
vertical-align: middle;
white-space: nowrap;
}
td:first-child, th:first-child { padding-left: 20px; }
td:last-child, th:last-child { padding-right: 20px; }
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
cursor: default;
position: sticky;
top: 0;
z-index: 10;
border-bottom-width: 2px;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:nth-child(even) {
background-color: #fdfdfe;
}
tbody tr:hover {
background-color: #f1f3f5;
}
/* Status Styling in Tables */
.status-yes { color: #198754; font-weight: bold; }
.status-no { color: #dc3545; }
.status-partial { color: #ffc107; } /* Example yellow for partial */
/* Action Icon Style */
.action-icon {
font-size: 1.1em;
cursor: pointer;
margin: 0 6px;
color: #546e7a;
transition: color 0.2s ease;
display: inline-block;
vertical-align: middle;
}
.action-icon:hover {
color: #3498db;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1050;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #fff;
margin: 5% auto;
padding: 25px 30px;
border: none;
width: 80%;
max-width: 900px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
position: relative;
}
.close {
position: absolute;
top: 10px;
right: 15px;
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
}
.close:hover,
.close:focus {
color: #555;
text-decoration: none;
}
#friendlyViewContent {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 0.9em;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 60vh;
overflow-y: auto;
}
/* Message Styles */
#uploadMessage, #eraUploadMessage, #reconcileMessage {
padding: 10px 15px;
margin-top: 10px;
margin-bottom: 15px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 0.95em;
}
.success-message {
color: #0f5132;
background-color: #d1e7dd;
border-color: #badbcc;
}
.error-message {
color: #842029;
background-color: #f8d7da;
border-color: #f5c2c7;
}
.info-message {
color: #004085;
background-color: #cce5ff;
border-color: #b8daff;
}
/* Filters Container Styling (General) */
.filters-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px 20px;
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.filters-container label {
font-weight: 500;
margin-right: 5px;
color: #495057;
font-size: 0.9em;
}
.filters-container select,
.filters-container input[type="date"],
.filters-container input[type="text"] {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 0.9em;
height: 38px;
box-sizing: border-box;
}
.filters-container input[type="text"] {
min-width: 180px;
}
.filters-container .filter-item {
display: flex;
align-items: center;
gap: 5px;
}
/* Reconciliation/Non-Reconciliation Page Specific Layout */
.reconcile-tables-container {
display: flex;
gap: 25px;
flex-wrap: wrap;
margin-top: 15px; /* Space above the pair of tables */
}
.reconcile-table {
flex: 1;
min-width: 400px; /* Min width before stacking */
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 5px rgba(0,0,0,0.07);
border: 1px solid #e9ecef;
display: flex; /* Use flex to manage internal layout */
flex-direction: column;
}
.reconcile-table h3 {
margin-top: 0;
font-size: 1.15em; /* Smaller heading */
color: #34495e;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
margin-bottom: 15px;
flex-shrink: 0; /* Prevent shrinking */
}
.reconcile-table .table-container {
flex-grow: 1; /* Allow table container to fill space */
overflow-y: auto; /* Add scroll if table is long */
}
/* Style for filter controls placed above individual tables */
.table-filter-controls {
padding: 10px 15px;
margin-bottom: 10px; /* Space between filters and table */
background-color: #f8f9fa; /* Light background */
border-radius: 6px;
border: 1px solid #e9ecef;
justify-content: flex-start; /* Align filters to the left */
flex-shrink: 0; /* Prevent shrinking */
}
.table-filter-controls .filter-item {
margin-right: 15px;
}
.table-filter-controls input[type="text"],
.table-filter-controls select {
max-width: 200px; /* Optional: constrain width */
}
/* Adjust manual reconcile button container */
#manualReconcileControlsContainer {
padding-bottom: 15px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
justify-content: flex-start;
}
/* Styles for Reconcile Page Top Controls */
.reconcile-controls {
/* display: flex; */ /* Inherited */
/* align-items: center; */ /* Inherited */
/* gap: 15px; */ /* Inherited */
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
/* Context Menu Styles */
.context-menu {
display: none;
position: absolute;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 180px;
border-radius: 5px;
padding: 5px 0;
}
.context-menu ul {
list-style: none;
margin: 0;
padding: 0;
}
.context-menu ul li {
padding: 9px 15px;
cursor: pointer;
font-size: 0.9em;
color: #333;
transition: background-color 0.15s ease;
}
.context-menu ul li:hover {
background-color: #f0f0f0;
}
/* File name cell specific style for context menu trigger */
.file-name-cell, .era-context-menu-trigger {
font-weight: 500;
color: #007bff;
cursor: context-menu !important;
}
/* Style for selected rows in non-reconcile tables */
#baiNonReconcileTable tbody tr.selected,
#eraNonReconcileTable tbody tr.selected {
background-color: #d1ecf1 !important; /* Light blue background for selected */
}
/* Padding for checkbox cells */
#baiNonReconcileTable td:first-child, #baiNonReconcileTable th:first-child,
#eraNonReconcileTable td:first-child, #eraNonReconcileTable th:first-child {
padding-left: 15px; /* Ensure space for checkbox */
padding-right: 5px;
text-align: center;
}
/* Responsive Design Adjustments */
@media (max-width: 992px) {
/* General filter adjustments */
.filters-container {
gap: 10px;
}
/* Stack reconcile/non-reconcile tables */
.reconcile-table {
min-width: 100%;
}
}
@media (max-width: 768px) {
.container {
flex-direction: column;
}
/* Sidebar always expanded on mobile */
.sidebar {
width: 100% !important;
height: auto;
position: static;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: none !important; /* Disable transition on mobile */
}
.sidebar.collapsed {
width: 100% !important; /* Override collapsed state */
}
.sidebar-header {
justify-content: space-between !important; /* Always show title/icon */
padding: 15px 20px !important;
}
.sidebar-title {
opacity: 1 !important;
width: auto !important;
}
.sidebar-nav ul {
display: flex; flex-wrap: wrap; justify-content: center;
}
.sidebar-nav ul li { margin: 5px; }
.sidebar-button {
width: auto;
padding: 8px 12px !important;
font-size: 0.9em;
justify-content: flex-start !important; /* Align text left */
gap: 10px !important; /* Restore gap */
}
.sidebar-button .button-text {
opacity: 1 !important; /* Always show text */
width: auto !important;
}
/* Reset main content padding */
#mainContent {
padding: 20px 15px !important; /* Reset padding */
}
/* General content adjustments */
.content-section { padding: 20px; }
.content-section h2 { font-size: 1.3em; }
table { font-size: 0.85em; }
th, td { padding: 10px 8px; white-space: normal;} /* Allow wrapping */
td:first-child, th:first-child { padding-left: 10px; }
td:last-child, th:last-child { padding-right: 10px; }
/* Stack general filters */
.filters-container {
flex-direction: column; align-items: stretch; gap: 10px; padding: 10px;
}
.filters-container select, .filters-container input { width: 100%; margin-
right: 0;}
/* Stack individual table filters */
.table-filter-controls {
flex-direction: column; align-items: stretch; gap: 10px; padding: 10px;
}
.table-filter-controls .filter-item { margin-right: 0; }
.table-filter-controls input[type="text"], .table-filter-controls select {
max-width: none; /* Allow full width */
width: 100%;
}
.modal-content { width: 95%; margin: 5% auto; }
}
/* Final cleanup: Hide old button just in case */
.sidebar-toggle-button {
display: none;
}
/* static/style.css: Add these styles */
/* --- Dashboard Styles --- */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); /* Responsive grid
*/
gap: 30px; /* Spacing between cards */
margin-top: 15px;
}
.dashboard-card {
background-color: #fff;
border-radius: 8px;
padding: 20px 25px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
display: flex;
flex-direction: column;
cursor: pointer; /* Indicate clickable */
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.dashboard-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dashboard-card h3 {
margin-top: 0;
margin-bottom: 15px;
font-size: 1.2em;
color: #34495e;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.dashboard-card h3 i {
color: #3498db; /* Match section header icon color */
}
.dashboard-content {
display: flex;
flex-direction: column; /* Stack chart and stats vertically */
gap: 20px; /* Space between chart and stats */
flex-grow: 1; /* Allow content to fill card height */
}
.chart-container {
position: relative;
/* max-width: 300px; /* Control chart size */
height: 250px; /* Fixed height for chart area */
margin: 0 auto 15px auto; /* Center chart horizontally */
width: 100%; /* Take full width of its flex container */
max-width: 300px; /* But don't let it get too huge */
}
.stats-container {
/* No specific styling needed unless more complex layout required */
font-size: 0.95em;
line-height: 1.7;
}
.stats-container p {
margin: 5px 0;
color: #555;
}
.stats-container strong {
color: #2c3e50;
float: right; /* Align numbers to the right */
margin-left: 10px;
}
.stats-container hr {
border: none;
border-top: 1px solid #eee;
margin: 10px 0;
}
/* Responsive Adjustments for Dashboard */
@media (max-width: 992px) {
.dashboard-grid {
grid-template-columns: 1fr; /* Stack cards on smaller screens */
gap: 20px;
}
.chart-container {
height: 220px; /* Slightly smaller height */
}
}
@media (max-width: 576px) {
.dashboard-card {
padding: 15px 20px;
}
.chart-container {
height: 200px;
}
.stats-container strong {
float: none; /* Don't float on very small screens */
display: inline-block; /* Keep inline */
}
}
/* static/style.css: Additions for Dashboard Filters and Monthly Chart */
.dashboard-filters {
margin-bottom: 25px; /* Space below filters */
padding: 15px; /* Consistent padding */
border-bottom: 1px solid #e9ecef; /* Separator */
}
.dashboard-filters .filter-item {
margin-bottom: 0; /* Remove bottom margin if filters wrap */
}
.button-secondary {
background-color: #6c757d; /* Grey */
}
.button-secondary:hover {
background-color: #5a6268;
}
/* Label to show filter status on cards/charts */
.filter-label {
font-size: 0.7em;
font-weight: normal;
color: #6c757d;
margin-left: 8px;
vertical-align: middle;
}
#monthlyReconChartContainer .chart-container {
height: 300px; /* Adjust height as needed */
max-width: 450px; /* Adjust width as needed */
}
#monthlyChartMessage {
font-size: 0.9em;
color: #6c757d;
}
/* Minor adjustment for dashboard card h3 */
.dashboard-card h3 {
display: flex;
align-items: center;
justify-content: space-between; /* Align title and label */
flex-wrap: wrap; /* Allow label to wrap if needed */
}
/* static/style.css: Additions for Monthly Breakdown Chart */
#monthlyBreakdownChartContainer .chart-container {
height: 350px; /* Taller for bar chart */
max-width: 95%; /* Allow wider chart */
margin: 0 auto 15px auto;
}
#monthlyBreakdownMessage {
font-size: 0.9em;
color: #6c757d;
}
/* Ensure filter label on this chart is styled */
#monthlyBreakdownChartContainer h3 .filter-label {
font-size: 0.7em;
font-weight: normal;
color: #6c757d;
margin-left: 8px;
vertical-align: middle;
}
/* static/style.css: Add these styles at the end */
/* --- Styles for Monthly Pie Chart Breakdown --- */
.monthly-pie-charts-area {
display: flex; /* Arrange pies horizontally */
flex-wrap: wrap; /* Allow wrapping to next line */
gap: 30px; /* Spacing between pie chart items */
justify-content: center; /* Center items if they don't fill the row */
padding-top: 15px; /* Space below the main title */
}
.monthly-pie-item {
flex: 0 1 calc(33.33% - 20px); /* Aim for 3 pies per row, adjust calc for gap */
min-width: 280px; /* Minimum width before wrapping */
max-width: 350px; /* Maximum width */
text-align: center; /* Center the title */
margin-bottom: 20px; /* Space below each item */
}
.monthly-pie-item h4 {
margin-top: 0;
margin-bottom: 10px;
font-size: 1.05em;
color: #495057;
font-weight: 600;
}
/* Ensure canvas respects container */
.monthly-pie-item canvas {
max-width: 100%;
height: auto !important; /* Let Chart.js handle height based on aspect ratio */
}
/* Responsive adjustments for monthly pies */
@media (max-width: 1200px) {
.monthly-pie-item {
flex: 0 1 calc(50% - 15px); /* 2 pies per row */
}
}
@media (max-width: 768px) {
.monthly-pie-charts-area {
gap: 20px;
}
.monthly-pie-item {
flex: 0 1 100%; /* 1 pie per row */
min-width: 250px;
max-width: 400px; /* Allow slightly larger on mobile */
}
}
/* Tooltip styling adjustments if needed */
.chartjs-tooltip {
/* Example: Increase max width if dates are long */
/* max-width: 300px !important; */
white-space: pre-line !important; /* Allow wrapping within tooltip */
}