Move cash payment compliance to SQLite accounting mirror
This commit is contained in:
@@ -153,6 +153,12 @@ def cash_payment_review(
|
||||
saved: int = 0,
|
||||
error: str = "",
|
||||
):
|
||||
"""Cash Payment Compliance from the local Accounting Mirror only.
|
||||
|
||||
TallyPrime is not contacted by this review route. The Local Agent is used
|
||||
only to read the client's local .act mirror database through the existing
|
||||
secure tunnel. Users refresh that mirror separately from /tools/tally.
|
||||
"""
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_partner(request, db, "accounting.tally.view")
|
||||
@@ -170,58 +176,68 @@ def cash_payment_review(
|
||||
fy_start_year = int(fy.split("-", 1)[0])
|
||||
start_text = date_from or date(fy_start_year, 4, 1).isoformat()
|
||||
end_text = date_to or today.isoformat()
|
||||
live_result = None
|
||||
|
||||
review = None
|
||||
cache_job = None
|
||||
mirror_status = None
|
||||
command_error = error or ""
|
||||
|
||||
if selected_client and node and online:
|
||||
try:
|
||||
status_response = request_agent_command(
|
||||
node.node_code,
|
||||
"phase4_status",
|
||||
_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||
timeout_seconds=20,
|
||||
if selected_client:
|
||||
if not node or not online:
|
||||
command_error = command_error or (
|
||||
"ERP Local Agent is offline. Cash Payment Review reads the client's local "
|
||||
"SQLite Accounting Mirror, so the storage agent must be online."
|
||||
)
|
||||
if status_response.get("ok"):
|
||||
live_result = status_response.get("result") or {}
|
||||
else:
|
||||
command_error = str(status_response.get("error") or "Local Agent status failed.")
|
||||
|
||||
if job_id:
|
||||
status = request_agent_command(
|
||||
else:
|
||||
storage_payload = _accounting_storage_payload(
|
||||
selected_client,
|
||||
_financial_year_for_date(date.fromisoformat(start_text)),
|
||||
)
|
||||
try:
|
||||
status_response = request_agent_command(
|
||||
node.node_code,
|
||||
"accounting_cash_payment_cache_status",
|
||||
{
|
||||
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||
"job_id": str(job_id),
|
||||
},
|
||||
"accounting_mirror_status",
|
||||
storage_payload,
|
||||
timeout_seconds=20,
|
||||
)
|
||||
if status.get("ok"):
|
||||
cache_job = (status.get("result") or {}).get("job")
|
||||
if status_response.get("ok"):
|
||||
mirror_status = status_response.get("result") or {}
|
||||
else:
|
||||
command_error = str(status.get("error") or "Could not read cash-payment cache status.")
|
||||
command_error = str(
|
||||
status_response.get("error") or "Could not read the local Accounting Mirror status."
|
||||
)
|
||||
except Exception as exc:
|
||||
command_error = str(exc)
|
||||
|
||||
if analyze and job_id and cache_job and cache_job.get("status") == "completed":
|
||||
result = request_agent_command(
|
||||
node.node_code,
|
||||
"accounting_cash_payment_cache_analyze",
|
||||
{
|
||||
**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),
|
||||
"job_id": str(job_id),
|
||||
"cash_limit": float(setting.cash_limit),
|
||||
"split_window_days": int(setting.split_window_days),
|
||||
"near_limit_percent": float(setting.near_limit_percent),
|
||||
},
|
||||
timeout_seconds=120,
|
||||
)
|
||||
if result.get("ok"):
|
||||
review = (result.get("result") or {}).get("cash_payment_review")
|
||||
else:
|
||||
command_error = str(result.get("error") or "Cash payment SQLite analysis failed.")
|
||||
except Exception as exc:
|
||||
command_error = str(exc)
|
||||
if analyze and not command_error:
|
||||
try:
|
||||
result = request_agent_command(
|
||||
node.node_code,
|
||||
"accounting_cash_payment_mirror_analyze",
|
||||
{
|
||||
**storage_payload,
|
||||
"date_from": start_text,
|
||||
"date_to": end_text,
|
||||
"cash_ledger_name": str(cash_ledger_name or "").strip(),
|
||||
"cash_limit": float(setting.cash_limit),
|
||||
"split_window_days": int(setting.split_window_days),
|
||||
"near_limit_percent": float(setting.near_limit_percent),
|
||||
"requested_by_user_id": int(user.id),
|
||||
},
|
||||
timeout_seconds=120,
|
||||
)
|
||||
if result.get("ok"):
|
||||
body = result.get("result") or {}
|
||||
review = body.get("cash_payment_review")
|
||||
mirror_status = {
|
||||
**(mirror_status or {}),
|
||||
"mirror": body.get("mirror") or (mirror_status or {}).get("mirror") or {},
|
||||
}
|
||||
else:
|
||||
command_error = str(
|
||||
result.get("error") or "Cash payment SQLite mirror analysis failed."
|
||||
)
|
||||
except Exception as exc:
|
||||
command_error = str(exc)
|
||||
|
||||
return _render(
|
||||
request,
|
||||
@@ -232,14 +248,15 @@ def cash_payment_review(
|
||||
selected_client=selected_client,
|
||||
storage_node=node,
|
||||
agent_online=online,
|
||||
live_result=live_result,
|
||||
live_result=None,
|
||||
mirror_status=mirror_status,
|
||||
selected_tally_guid=str(tally_guid or ""),
|
||||
date_from=start_text,
|
||||
date_to=end_text,
|
||||
setting=setting,
|
||||
review=review,
|
||||
cache_job=cache_job,
|
||||
active_job_id=str(job_id or ""),
|
||||
cache_job=None,
|
||||
active_job_id="",
|
||||
selected_cash_ledger_name=str(cash_ledger_name or ""),
|
||||
saved=bool(saved),
|
||||
command_error=command_error,
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · Tally</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · SQLite Mirror</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">{{ 'Cash Payment Entry Allocation' if page_mode == 'allocation' else 'Cash Payment Compliance Review' }}</h1>
|
||||
{% if page_mode == 'allocation' %}
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-600">Prepare and validate cash payment entry allocation using only actual source payment dates. The tool checks totals, the configured daily limit and the permitted source-date window; it never invents or changes dates.</p>
|
||||
{% else %}
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-600">Read-only Tally review for cash payments above the configured limit, same-day party aggregation and possible split-payment patterns.</p>
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-600">Analyse cash-payment compliance directly from the client's local SQLite Accounting Mirror. TallyPrime is not contacted during this review.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally</a>
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally Mirror</a>
|
||||
</div>
|
||||
|
||||
{% if saved %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Cash payment rule settings saved.</div>{% endif %}
|
||||
@@ -20,9 +20,20 @@
|
||||
{% if page_mode != 'allocation' %}
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Cash Payment Compliance Review</h2>
|
||||
<form id="cash-review-form" method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<input type="hidden" id="cash-csrf-token" value="{{ csrf_token }}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Review from Accounting Mirror</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">No Tally connection, company selection, extraction queue or day-by-day cache is used here.</p>
|
||||
</div>
|
||||
{% set mirror = (mirror_status.mirror if mirror_status and mirror_status.mirror else {}) %}
|
||||
{% if mirror.ready %}
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">SQLite Mirror Ready</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-800">Mirror Required</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
|
||||
<select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required onchange="this.form.submit()">
|
||||
@@ -30,66 +41,38 @@
|
||||
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase text-slate-500">Tally Company</label>
|
||||
<select id="cash-tally-company" name="tally_guid" class="mt-1 w-full rounded-xl border-slate-300 text-sm" {% if selected_client %}required{% endif %}>
|
||||
<option value="">Select open Tally company</option>
|
||||
{% set mapped_guids = [] %}
|
||||
{% if live_result and live_result.accounting %}
|
||||
{% for mapping in live_result.accounting.mappings or [] %}
|
||||
{% if mapping.tally_guid %}{% set _ = mapped_guids.append(mapping.tally_guid) %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if live_result and live_result.tally %}
|
||||
{% for company in live_result.tally.companies or [] %}
|
||||
{% set is_mapped = company.guid in mapped_guids %}
|
||||
<option value="{{ company.guid }}" data-company-name="{{ company.name or '' }}" data-open-company="1" {% if selected_tally_guid == company.guid or (not selected_tally_guid and is_mapped) %}selected{% endif %}>{{ company.name or company.guid }}{% if is_mapped %} • Mapped{% endif %}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if live_result and live_result.accounting %}
|
||||
{% for mapping in live_result.accounting.mappings or [] %}
|
||||
{% if mapping.tally_guid and mapping.tally_guid not in ((live_result.tally.companies or [])|map(attribute='guid')|list if live_result and live_result.tally else []) %}
|
||||
<option value="{{ mapping.tally_guid }}" data-company-name="{{ mapping.company_name or mapping.tally_company_name or '' }}" data-open-company="0" {% if selected_tally_guid == mapping.tally_guid %}selected{% endif %}>{{ mapping.company_name or mapping.tally_company_name or mapping.tally_guid }} • Mapped (not open)</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase text-slate-500">Ledger Scope</label>
|
||||
<select id="cash-ledger-scope" name="ledger_scope" class="mt-1 w-full rounded-xl border-slate-300 text-xs">
|
||||
<option value="cash" selected>Ledgers under Cash-in-Hand</option>
|
||||
<option value="all">All Tally ledgers — manual selection</option>
|
||||
</select>
|
||||
|
||||
<label class="mt-2 block text-xs font-semibold uppercase text-slate-500">Cash Ledger Selection</label>
|
||||
<select id="cash-ledger-select" name="cash_ledger_name" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required>
|
||||
<option value="">Select Cash ledger</option>
|
||||
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
|
||||
</select>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" id="cash-load-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Load Cash Ledgers</button>
|
||||
<button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh Master Data</button>
|
||||
</div>
|
||||
|
||||
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Load Cash Ledgers reads the common master from the client .act SQLite database only. Refresh Master Data explicitly refreshes that common master from TallyPrime.</p>
|
||||
<div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3">
|
||||
<div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing Cash ledger list…</span><span id="cash-ledger-progress-pct">0%</span></div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
|
||||
<div id="cash-master-step-1" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">1. Local Master</div><div class="mt-1 text-slate-500">.act SQLite</div></div>
|
||||
<div id="cash-master-step-2" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">2. Cash Filter</div><div class="mt-1 text-slate-500">Cash-in-Hand</div></div>
|
||||
<div id="cash-master-step-3" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">3. Transfer to VPS</div><div class="mt-1 text-slate-500">Tunnel result</div></div>
|
||||
<div id="cash-master-step-4" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">4. Ledger Ready</div><div class="mt-1 text-slate-500">Dropdown</div></div>
|
||||
</div>
|
||||
<div id="cash-master-meta" class="mt-2 text-[11px] text-slate-500">Master source: local .act SQLite. Normal loading never queries TallyPrime.</div>
|
||||
</div>
|
||||
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
|
||||
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
|
||||
<div class="md:col-span-2 xl:col-span-5"><button type="button" id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Review Cash Transactions</button><p class="mt-2 text-xs text-slate-500">Select the Tally company and Cash ledger from the local .act master snapshot. Only the selected ledger’s relevant transactions are then extracted from Tally into SQLite; compliance analysis runs from SQLite. Existing threshold rules, allocation workflow and dual VPS/Local Agent progress are preserved.</p></div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" name="analyze" value="1" class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50" {% if not selected_client or not mirror.ready %}disabled{% endif %}>Review Cash Payments</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if selected_client %}
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Company</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.company.company_name if mirror.ready and mirror.company else 'Not mirrored' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Period</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Vouchers Available</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.counts.voucher if mirror.ready and mirror.counts else 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Voucher Lines</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.counts.voucher_ledger_entry if mirror.ready and mirror.counts else 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if mirror.ready %}
|
||||
<p class="mt-3 break-all text-xs text-slate-500">Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}</p>
|
||||
{% else %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">Mirror this client first from <strong>Tally Accounting → Mirror Tally to SQLite</strong>. After that, Cash Payment Review works even if TallyPrime is closed.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
@@ -106,25 +89,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if cache_job %}
|
||||
<section class="rounded-2xl border border-sky-200 bg-sky-50 p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-sky-950">Local Tally SQLite Cache</h3>
|
||||
<p class="mt-1 text-sm text-sky-800">Status: <strong>{{ cache_job.status }}</strong>{% if cache_job.stage %} · {{ cache_job.stage }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="text-right text-sm text-sky-900"><div>{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)</div><div>{{ cache_job.vouchers_cached }} Cash-ledger voucher(s)</div>{% if cache_job.cash_ledgers %}<div>Ledger: {{ cache_job.cash_ledgers|join(', ') }}</div>{% endif %}</div>
|
||||
</div>
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-sky-100"><div class="h-full bg-sky-600" style="width:{{ cache_job.percent or 0 }}%"></div></div>
|
||||
{% if cache_job.error %}<p class="mt-3 text-sm font-medium text-amber-900">{{ cache_job.error }}</p>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if review %}
|
||||
<section class="space-y-4">
|
||||
{% if review.sqlite_cache %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Analysis completed from the Local Agent SQLite cache. TallyPrime was used only for the slow background extraction and was released before this analysis ran. {{ review.tally_request_count or 0 }} Tally request(s), {{ review.tally_retry_count or 0 }} retry/retries, and approximately {{ review.tally_pause_ms or 0 }} ms cooling between successful day reads.</div>
|
||||
{% endif %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted.
|
||||
{% if review.cash_ledgers %} Cash ledger(s): <strong>{{ review.cash_ledgers|join(', ') }}</strong>.{% endif %}
|
||||
{% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %}
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
|
||||
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ value }}</div></div>
|
||||
@@ -147,8 +118,6 @@
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if page_mode == 'allocation' %}
|
||||
@@ -177,394 +146,5 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
<div id="cash-review-progress" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
|
||||
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Cash Ledger Extraction & Compliance Progress</h3>
|
||||
<p id="cash-progress-text" class="mt-1 text-sm text-slate-600">Submitting a background extraction job to the ERP Local Agent…</p>
|
||||
</div>
|
||||
<div id="cash-progress-percent" class="text-lg font-semibold text-brand-700">0%</div>
|
||||
</div>
|
||||
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
|
||||
<div id="cash-progress-bar" class="h-full rounded-full bg-brand-600 transition-all duration-500" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
||||
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Days</div><div id="cash-progress-days" class="mt-1 font-semibold">0 / 0</div></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Cash vouchers</div><div id="cash-progress-vouchers" class="mt-1 font-semibold">0</div></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Tally requests</div><div id="cash-progress-requests" class="mt-1 font-semibold">0</div></div>
|
||||
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Current date</div><div id="cash-progress-date" class="mt-1 font-semibold">—</div></div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-2 text-xs md:grid-cols-5">
|
||||
<div id="erp-cash-step-1" class="rounded-lg bg-slate-100 px-3 py-2">1. Connect</div>
|
||||
<div id="erp-cash-step-2" class="rounded-lg bg-slate-100 px-3 py-2">2. Confirm ledger</div>
|
||||
<div id="erp-cash-step-3" class="rounded-lg bg-slate-100 px-3 py-2">3. Extract</div>
|
||||
<div id="erp-cash-step-4" class="rounded-lg bg-slate-100 px-3 py-2">4. Analyse</div>
|
||||
<div id="erp-cash-step-5" class="rounded-lg bg-slate-100 px-3 py-2">5. Complete</div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-slate-500">The confirmed Cash ledger comes from the common local .act master snapshot. The Local Agent then reads Tally in safe one-day batches and stores only vouchers containing that ledger in SQLite. Analysis runs from SQLite. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.</p>
|
||||
<div id="cash-progress-error" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"></div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" id="cash-progress-cancel" class="hidden rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900">Cancel Extraction</button>
|
||||
<button type="button" id="cash-progress-close" class="hidden rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('cash-review-form');
|
||||
const button = document.getElementById('cash-review-button');
|
||||
if (!form || !button) return;
|
||||
const overlay = document.getElementById('cash-review-progress');
|
||||
const bar = document.getElementById('cash-progress-bar');
|
||||
const pct = document.getElementById('cash-progress-percent');
|
||||
const text = document.getElementById('cash-progress-text');
|
||||
const days = document.getElementById('cash-progress-days');
|
||||
const vouchers = document.getElementById('cash-progress-vouchers');
|
||||
const requests = document.getElementById('cash-progress-requests');
|
||||
const currentDate = document.getElementById('cash-progress-date');
|
||||
const errorBox = document.getElementById('cash-progress-error');
|
||||
const closeButton = document.getElementById('cash-progress-close');
|
||||
const cancelButton = document.getElementById('cash-progress-cancel');
|
||||
let pollTimer = null;
|
||||
let activeJobId = '';
|
||||
let activeClientId = '';
|
||||
let activeDateFrom = '';
|
||||
const ledgerButton = document.getElementById('cash-load-ledgers');
|
||||
const ledgerSelect = document.getElementById('cash-ledger-select');
|
||||
const ledgerScope = document.getElementById('cash-ledger-scope');
|
||||
const ledgerHelp = document.getElementById('cash-ledger-help');
|
||||
let allLedgerRows = [];
|
||||
let cashLedgerRows = [];
|
||||
let lastLedgerMeta = {ledger_count:0, company_name:'', from_cache:false};
|
||||
|
||||
const ledgerRefreshButton = document.getElementById('cash-refresh-ledgers');
|
||||
const ledgerProgress = document.getElementById('cash-ledger-progress');
|
||||
const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar');
|
||||
const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct');
|
||||
const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage');
|
||||
const masterStep1 = document.getElementById('cash-master-step-1');
|
||||
const masterStep2 = document.getElementById('cash-master-step-2');
|
||||
const masterStep3 = document.getElementById('cash-master-step-3');
|
||||
const masterStep4 = document.getElementById('cash-master-step-4');
|
||||
const masterMeta = document.getElementById('cash-master-meta');
|
||||
let ledgerPollTimer = null;
|
||||
|
||||
function paintMasterSteps(step) {
|
||||
[masterStep1, masterStep2, masterStep3, masterStep4].forEach(function(el, idx) {
|
||||
if (!el) return;
|
||||
el.className = 'rounded-lg border px-2 py-2 text-center ' + (idx < step
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-700');
|
||||
});
|
||||
}
|
||||
|
||||
function renderLedgerOptions() {
|
||||
const previous = ledgerSelect.value;
|
||||
const rows = (ledgerScope && ledgerScope.value === 'all') ? allLedgerRows : cashLedgerRows;
|
||||
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
|
||||
rows.forEach(function(row){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=row.name;
|
||||
opt.textContent=row.name + (row.parent ? ' — '+row.parent : '') + (row.is_cash_candidate ? ' • Cash-in-Hand' : '');
|
||||
if (previous && previous===row.name) opt.selected=true;
|
||||
ledgerSelect.appendChild(opt);
|
||||
});
|
||||
if (previous && !rows.some(row => row.name === previous)) ledgerSelect.value='';
|
||||
const company = lastLedgerMeta.company_name || 'Tally';
|
||||
if (ledgerScope && ledgerScope.value === 'all') {
|
||||
ledgerHelp.textContent = String(rows.length)+' of '+String(lastLedgerMeta.ledger_count || allLedgerRows.length)+' ledger(s) available from '+company+'. Select the required ledger manually.';
|
||||
} else if (rows.length) {
|
||||
ledgerHelp.textContent = String(rows.length)+' Cash-in-Hand ledger(s) found in '+company+'. Select the ledger whose transactions should be reviewed.';
|
||||
} else if (allLedgerRows.length) {
|
||||
ledgerHelp.textContent = 'No Cash-in-Hand ledger was identified from Tally grouping. Choose “All Tally ledgers — manual selection” to select the required ledger.';
|
||||
} else {
|
||||
ledgerHelp.textContent = 'No ledgers are available yet for the selected Tally company.';
|
||||
}
|
||||
}
|
||||
|
||||
function populateLedgers(data) {
|
||||
allLedgerRows = Array.isArray(data.ledgers) ? data.ledgers.slice() : [];
|
||||
cashLedgerRows = Array.isArray(data.cash_candidates) ? data.cash_candidates.slice() : allLedgerRows.filter(row => row.is_cash_candidate);
|
||||
lastLedgerMeta = {
|
||||
ledger_count: Number(data.ledger_count || allLedgerRows.length || 0),
|
||||
company_name: String(data.company_name || ''),
|
||||
from_cache: Boolean(data.from_cache)
|
||||
};
|
||||
renderLedgerOptions();
|
||||
paintMasterSteps(4);
|
||||
if (masterMeta) {
|
||||
const synced = data.master_synced_at_utc ? new Date(data.master_synced_at_utc).toLocaleString() : 'time not available';
|
||||
const source = data.master_refreshed ? 'TallyPrime → local .act SQLite (refreshed now)' : 'local .act SQLite';
|
||||
masterMeta.textContent = 'Master source: ' + source + ' · Last master sync: ' + synced + ' · ' + String(data.total_ledgers_examined || data.ledger_count || 0) + ' ledger(s) in common snapshot.';
|
||||
}
|
||||
}
|
||||
|
||||
function updateLedgerProgress(job) {
|
||||
const p=Math.max(0,Math.min(100,Number(job.percent||0)));
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width=p+'%';
|
||||
ledgerProgressPct.textContent=Math.round(p)+'%';
|
||||
ledgerProgressStage.textContent=job.stage||job.status||'Working…';
|
||||
if (p >= 100) paintMasterSteps(4);
|
||||
else if (p >= 90) paintMasterSteps(3);
|
||||
else if (p >= 60) paintMasterSteps(2);
|
||||
else if (p >= 15) paintMasterSteps(1);
|
||||
}
|
||||
|
||||
async function pollLedgers(jobId, clientId, dateFrom, tallyGuid, companyName, selectedScope, forceRefresh) {
|
||||
try {
|
||||
const url=new URL('/tools/accounting/cash-payments/ledgers/progress',window.location.origin);
|
||||
url.searchParams.set('client_id',clientId);
|
||||
url.searchParams.set('job_id',jobId);
|
||||
url.searchParams.set('date_from',dateFrom);
|
||||
url.searchParams.set('tally_guid',tallyGuid || '');
|
||||
url.searchParams.set('company_name',companyName || '');
|
||||
url.searchParams.set('ledger_scope',selectedScope || 'cash');
|
||||
url.searchParams.set('force_refresh',forceRefresh ? '1' : '0');
|
||||
const response=await fetch(url.toString(),{headers:{'Accept':'application/json'},cache:'no-store'});
|
||||
const data=await response.json();
|
||||
if(!response.ok||!data.ok) throw new Error(data.error||'Could not read Cash ledger transfer progress.');
|
||||
const job=data.job||{};
|
||||
updateLedgerProgress(job);
|
||||
if(data.transport_pending){
|
||||
ledgerHelp.textContent='Local Agent work is queued/running. Waiting for the tunnel to deliver the ledger result to the VPS…';
|
||||
}
|
||||
if(job.status==='completed' && Array.isArray(data.ledgers) && data.ledgers.length >= 0){
|
||||
populateLedgers(data);
|
||||
ledgerButton.disabled=false;
|
||||
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width='100%';
|
||||
ledgerProgressPct.textContent='100%';
|
||||
ledgerProgressStage.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) transmitted to VPS';
|
||||
paintMasterSteps(4);
|
||||
ledgerHelp.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) received by VPS and ready for selection.';
|
||||
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1800);
|
||||
return;
|
||||
}
|
||||
if(job.status==='failed') throw new Error(job.error||'Cash ledger preparation failed.');
|
||||
ledgerPollTimer=window.setTimeout(
|
||||
()=>pollLedgers(jobId,clientId,dateFrom,tallyGuid,companyName,selectedScope,forceRefresh),
|
||||
1500
|
||||
);
|
||||
} catch(err) {
|
||||
ledgerHelp.textContent=err&&err.message?err.message:String(err);
|
||||
ledgerButton.disabled=false;
|
||||
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
ledgerProgressStage.textContent=ledgerHelp.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCashLedgers(forceRefresh) {
|
||||
const fd = new FormData(form);
|
||||
if (!fd.get('client_id') || !fd.get('tally_guid')) { ledgerHelp.textContent = 'Select a client and Tally company first.'; return; }
|
||||
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
|
||||
const companySelect = form.querySelector('select[name="tally_guid"]');
|
||||
const companyOption = companySelect && companySelect.options[companySelect.selectedIndex];
|
||||
const companyName = companyOption ? String(companyOption.dataset.companyName || companyOption.textContent || '').trim() : '';
|
||||
if (companyName) fd.set('company_name', companyName);
|
||||
const selectedScope = ledgerScope ? String(ledgerScope.value || 'cash') : 'cash';
|
||||
fd.set('ledger_scope', selectedScope);
|
||||
if(forceRefresh) fd.set('force_refresh','1');
|
||||
ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true;
|
||||
paintMasterSteps(0);
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width='15%'; ledgerProgressPct.textContent='15%';
|
||||
if (forceRefresh) {
|
||||
ledgerProgressStage.textContent='Refreshing common accounting masters from TallyPrime…';
|
||||
ledgerHelp.textContent='Refreshing the shared .act master snapshot once, then filtering the Cash-in-Hand hierarchy locally.';
|
||||
if (masterMeta) masterMeta.textContent='Master source: refreshing TallyPrime → local .act SQLite.';
|
||||
} else {
|
||||
ledgerProgressStage.textContent='Reading common ledger master from local SQLite…';
|
||||
ledgerHelp.textContent = selectedScope === 'all'
|
||||
? 'Reading all ledgers from the common local .act master snapshot…'
|
||||
: 'Reading local .act masters and selecting Cash-in-Hand ledgers…';
|
||||
if (masterMeta) masterMeta.textContent='Master source: local .act SQLite. TallyPrime is not queried for this normal load.';
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not load Tally ledgers.');
|
||||
if (Array.isArray(data.ledgers)) {
|
||||
populateLedgers(data);
|
||||
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
ledgerProgress.classList.remove('hidden');
|
||||
ledgerProgressBar.style.width='100%'; ledgerProgressPct.textContent='100%';
|
||||
ledgerProgressStage.textContent = data.cash_only
|
||||
? String(data.ledger_count || 0)+' Cash-in-Hand ledger(s) ready from SQLite'
|
||||
: String(data.ledger_count || 0)+' ledger(s) ready from SQLite';
|
||||
paintMasterSteps(4);
|
||||
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1200);
|
||||
return;
|
||||
}
|
||||
const job=data.job||{};
|
||||
if(!job.job_id) throw new Error('Local Agent did not return a ledger-sync job id.');
|
||||
updateLedgerProgress(job);
|
||||
if(data.transport_pending){ ledgerHelp.textContent='Command sent. Waiting for the Local Agent result to cross the tunnel back to VPS…'; }
|
||||
pollLedgers(
|
||||
job.job_id,
|
||||
String(fd.get('client_id')||''),
|
||||
String(fd.get('date_from')||''),
|
||||
String(fd.get('tally_guid')||''),
|
||||
companyName,
|
||||
selectedScope,
|
||||
Boolean(forceRefresh)
|
||||
);
|
||||
} catch(err) {
|
||||
ledgerHelp.textContent = err && err.message ? err.message : String(err);
|
||||
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
|
||||
}
|
||||
}
|
||||
if (ledgerButton) ledgerButton.addEventListener('click', function(){loadCashLedgers(false);});
|
||||
if (ledgerRefreshButton) ledgerRefreshButton.addEventListener('click', function(){loadCashLedgers(true);});
|
||||
const companySelect = document.getElementById('cash-tally-company');
|
||||
if (ledgerScope) ledgerScope.addEventListener('change', function(){
|
||||
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
|
||||
if (companySelect && companySelect.value) loadCashLedgers(false);
|
||||
});
|
||||
if (companySelect) companySelect.addEventListener('change', function(){
|
||||
allLedgerRows = []; cashLedgerRows = []; lastLedgerMeta = {ledger_count:0, company_name:'', from_cache:false};
|
||||
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
|
||||
ledgerProgress.classList.add('hidden');
|
||||
if (this.value) {
|
||||
ledgerHelp.textContent = 'Loading Cash-in-Hand ledgers for the selected Tally company…';
|
||||
loadCashLedgers(false);
|
||||
} else {
|
||||
ledgerHelp.textContent = 'Select an open Tally company first.';
|
||||
}
|
||||
});
|
||||
if (companySelect && companySelect.value) {
|
||||
window.setTimeout(function(){ loadCashLedgers(false); }, 250);
|
||||
}
|
||||
|
||||
function show() {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('flex');
|
||||
errorBox.classList.add('hidden');
|
||||
closeButton.classList.add('hidden');
|
||||
cancelButton.classList.add('hidden');
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollTimer) window.clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
function fail(message) {
|
||||
stopPolling();
|
||||
button.disabled = false;
|
||||
text.textContent = 'Extraction paused safely.';
|
||||
errorBox.textContent = message || 'The extraction did not complete.';
|
||||
errorBox.classList.remove('hidden');
|
||||
closeButton.classList.remove('hidden');
|
||||
cancelButton.classList.add('hidden');
|
||||
}
|
||||
function update(job) {
|
||||
const value = Math.max(0, Math.min(100, Number(job.percent || 0)));
|
||||
bar.style.width = value + '%';
|
||||
pct.textContent = value + '%';
|
||||
text.textContent = job.stage || job.status || 'Working…';
|
||||
days.textContent = String(job.completed_days || 0) + ' / ' + String(job.total_days || 0);
|
||||
vouchers.textContent = String(job.vouchers_cached || 0);
|
||||
requests.textContent = String(job.tally_requests || 0);
|
||||
currentDate.textContent = job.current_date || '—';
|
||||
const stage = String(job.stage || '').toLowerCase();
|
||||
const status = String(job.status || '').toLowerCase();
|
||||
const steps = [1,2,3,4,5].map(n => document.getElementById('erp-cash-step-'+n));
|
||||
steps.forEach(e => { if(e) e.className='rounded-lg bg-slate-100 px-3 py-2'; });
|
||||
function done(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-emerald-50 px-3 py-2 font-semibold text-emerald-700'; }
|
||||
function running(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-sky-50 px-3 py-2 font-semibold text-sky-700 ring-1 ring-sky-200'; }
|
||||
if (value > 0) running(1); if (value >= 10) {done(1); running(2);} if (value >= 20) {done(2); running(3);} if (stage.includes('analys')) {done(3); running(4);} if (status === 'completed' && stage.includes('analysis complete')) {done(1);done(2);done(3);done(4);done(5);} else if (status === 'completed') {done(1);done(2);done(3);running(4);}
|
||||
if (job.can_cancel) cancelButton.classList.remove('hidden'); else cancelButton.classList.add('hidden');
|
||||
}
|
||||
async function poll(jobId, clientId, dateFrom, tallyGuid, dateTo) {
|
||||
try {
|
||||
const url = new URL('/tools/accounting/cash-payments/cache/progress', window.location.origin);
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('job_id', jobId);
|
||||
url.searchParams.set('date_from', dateFrom);
|
||||
const response = await fetch(url.toString(), {headers: {'Accept': 'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not read extraction progress.');
|
||||
const job = data.job || {};
|
||||
update(job);
|
||||
if (job.status === 'completed') {
|
||||
text.textContent = 'SQLite extraction complete. Running cash-payment analysis locally…';
|
||||
bar.style.width = '100%'; pct.textContent = '100%';
|
||||
const target = new URL('/tools/accounting/cash-payments', window.location.origin);
|
||||
target.searchParams.set('client_id', clientId);
|
||||
target.searchParams.set('tally_guid', tallyGuid);
|
||||
target.searchParams.set('date_from', dateFrom);
|
||||
target.searchParams.set('date_to', dateTo);
|
||||
target.searchParams.set('job_id', jobId);
|
||||
target.searchParams.set('cash_ledger_name', String(document.getElementById('cash-ledger-select').value || ''));
|
||||
target.searchParams.set('analyze', '1');
|
||||
window.location.assign(target.toString());
|
||||
return;
|
||||
}
|
||||
if (job.status === 'cancelled') {
|
||||
fail('Extraction was cancelled safely. Already cached days remain available and can be resumed later.');
|
||||
return;
|
||||
}
|
||||
if (job.status === 'paused' || job.status === 'failed') {
|
||||
fail(job.error || 'Tally extraction paused. Restart/verify TallyPrime and click Review Cash Transactions again to resume.');
|
||||
return;
|
||||
}
|
||||
pollTimer = window.setTimeout(() => poll(jobId, clientId, dateFrom, tallyGuid, dateTo), 2000);
|
||||
} catch (err) {
|
||||
fail(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener('click', async function () {
|
||||
if (!form.reportValidity()) return;
|
||||
const fd = new FormData(form);
|
||||
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
|
||||
const selectedCompany = document.getElementById('cash-tally-company');
|
||||
const selectedCompanyOption = selectedCompany && selectedCompany.options[selectedCompany.selectedIndex];
|
||||
const selectedCompanyName = selectedCompanyOption ? String(selectedCompanyOption.dataset.companyName || selectedCompanyOption.textContent || '').replace(' • Mapped','').replace(' • Mapped (not open)','').trim() : '';
|
||||
if (selectedCompanyName) fd.set('company_name', selectedCompanyName);
|
||||
button.disabled = true;
|
||||
show();
|
||||
try {
|
||||
const response = await fetch('/tools/accounting/cash-payments/cache/start', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not start extraction.');
|
||||
const job = data.job || {};
|
||||
activeJobId = job.job_id || '';
|
||||
activeClientId = String(fd.get('client_id') || '');
|
||||
activeDateFrom = String(fd.get('date_from') || '');
|
||||
update(job);
|
||||
poll(job.job_id, fd.get('client_id'), fd.get('date_from'), fd.get('tally_guid'), fd.get('date_to'));
|
||||
} catch (err) {
|
||||
fail(err && err.message ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
cancelButton.addEventListener('click', async function () {
|
||||
if (!activeJobId || !activeClientId) return;
|
||||
cancelButton.disabled = true;
|
||||
text.textContent = 'Requesting a safe stop after the current Tally request…';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
|
||||
fd.set('client_id', activeClientId);
|
||||
fd.set('job_id', activeJobId);
|
||||
fd.set('date_from', activeDateFrom);
|
||||
const response = await fetch('/tools/accounting/cash-payments/cache/cancel', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not cancel extraction.');
|
||||
if (data.job) update(data.job);
|
||||
cancelButton.classList.add('hidden');
|
||||
} catch (err) {
|
||||
errorBox.textContent = err && err.message ? err.message : String(err);
|
||||
errorBox.classList.remove('hidden');
|
||||
cancelButton.disabled = false;
|
||||
}
|
||||
});
|
||||
closeButton.addEventListener('click', function () {
|
||||
overlay.classList.add('hidden'); overlay.classList.remove('flex');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user