Thanks to visit codestin.com
Credit goes to www.scribd.com

0% found this document useful (0 votes)
29 views162 pages

Era

This document outlines a Flask application with configurations for database connections, user authentication, and logging functionalities. It includes the definition of a User model, input validation, and database schema management functions for initializing and updating database tables. Additionally, it specifies routes for user login and logout, along with error handling and security logging mechanisms.

Uploaded by

rabeb14347
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
29 views162 pages

Era

This document outlines a Flask application with configurations for database connections, user authentication, and logging functionalities. It includes the definition of a User model, input validation, and database schema management functions for initializing and updating database tables. Additionally, it specifies routes for user login and logout, along with error handling and security logging mechanisms.

Uploaded by

rabeb14347
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
You are on page 1/ 162

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 */
}

You might also like