parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]

Ports "گزارش" end to end (D24), the last missing app page.

In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.

API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
  after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
  Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
  balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
  جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
  the row rather than rendering a fabricated value (REP-01)

SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.

Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
  modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
  the reference's hasMCode() gate has no target equivalent

Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:49:49 +03:30
co-authored by Claude Opus 5
parent 5ad1083aee
commit 3f549f1749
99 changed files with 2230 additions and 104 deletions
@@ -0,0 +1,198 @@
<!--
One document of "جزئیات حساب".
Ported from henza_pwa/src/views/report/components/AccountingDetail.vue,
restyled into this app's design language. The LOGIC is the reference's and is
identical in user-app; only the styling differs.
Kept from the reference: شماره سند + تاریخ, وزن خالص, عیار, نوع سند,
شماره انگ, نام آزمایشگاه, قیمت, and the طلا in/out pair normalized to 750
(AccountingDetail.vue:6-85).
Rows the reference showed that the target has no equivalent for — نام کارساخته
(nO_kar), جنس فلز (jens_felez) and the two extra percentage lines — are
OMITTED rather than rendered blank or zero. They came from an external
accounting system the target does not have; the API returns null for them and
this component skips a row whose value is null, so the card never asserts
something it does not know. Recorded as REP-01.
Rows the target CAN fill and the reference could not are added at the end
(bank/account/destination), since those live on our own ledger entry.
-->
<template>
<article class="doc">
<header class="doc__head">
<span><span class="doc__muted">شماره سند:</span> {{ item.sh_factor }}</span>
<span><span class="doc__muted">تاریخ:</span> {{ jdate(item.tarikh) }}</span>
</header>
<dl class="doc__rows">
<div class="doc__row">
<dt>نوع سند</dt>
<dd>{{ item.no }}</dd>
</div>
<div class="doc__row">
<dt>وزن خالص</dt>
<dd dir="ltr">{{ item.vazn }}</dd>
</div>
<div v-if="item.ayar !== null" class="doc__row">
<dt>عیار</dt>
<dd dir="ltr">{{ item.ayar }}</dd>
</div>
<div v-if="item.sh_sharti" class="doc__row">
<dt>شماره انگ</dt>
<dd dir="ltr">{{ item.sh_sharti }}</dd>
</div>
<div v-if="item.name_az" class="doc__row">
<dt>نام آزمایشگاه</dt>
<dd>{{ item.name_az }}</dd>
</div>
<div v-if="item.Darsad_Talayee" class="doc__row">
<dt>اجرت (گرم)</dt>
<dd dir="ltr">{{ item.Darsad_Talayee }}</dd>
</div>
<div v-if="item.fi" class="doc__row">
<dt>قیمت هر گرم</dt>
<dd dir="ltr">{{ rial(item.fi) }}</dd>
</div>
<div v-if="item.fiF" class="doc__row">
<dt>مبلغ سند</dt>
<dd dir="ltr">{{ rial(item.fiF) }}</dd>
</div>
<div v-if="item.bankName" class="doc__row">
<dt>بانک</dt>
<dd>{{ item.bankName }}</dd>
</div>
<div v-if="item.accountName" class="doc__row">
<dt>نام حساب</dt>
<dd>{{ item.accountName }}</dd>
</div>
<div v-if="item.transferTo" class="doc__row">
<dt>مقصد حواله</dt>
<dd>{{ item.transferTo }}</dd>
</div>
<div v-if="item.receivedAt" class="doc__row">
<dt>تاریخ دریافت</dt>
<dd>{{ jdate(item.receivedAt) }}</dd>
</div>
<div v-if="item.note" class="doc__row">
<dt>توضیحات</dt>
<dd>{{ item.note }}</dd>
</div>
</dl>
<div class="doc__gold">
<span>طلا</span>
<div class="doc__stack">
<span dir="ltr" class="is-in">{{ item.tabdil750_v }}</span>
<span dir="ltr" class="is-out">{{ item.tabdil750_kh }}</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { jdate, price } from '../../utils/filters'
import type { ReportDetailRow } from '../../api/client'
defineProps<{ item: ReportDetailRow }>()
/** Same rule as the statement: group the whole part only. */
function rial(value: string): string {
const n = parseFloat(value)
if (!Number.isFinite(n)) return value
const whole = Math.trunc(Math.abs(n)).toString()
return (n < 0 ? '-' : '') + price(whole)
}
</script>
<style scoped lang="less">
/*
v2 styling. The template and script above are IDENTICAL to user-app's — only
this block differs. v2 draws the document as a flat, hairline-separated block
rather than an outlined card.
*/
.doc {
padding: 18px 2px;
background: transparent;
border: none;
border-bottom: 1px solid @hairline;
}
.doc:last-child {
border-bottom: none;
}
.doc__head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
}
.doc__muted {
color: @inkMuted;
font-weight: 500;
margin-left: 4px;
}
.doc__rows {
margin: 0;
display: flex;
flex-direction: column;
}
.doc__row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 0;
border-bottom: 1px dashed @hairlineSoft;
}
.doc__row:last-child {
border-bottom: none;
}
.doc__row dt {
font-size: 12.5px;
color: @inkMuted;
white-space: nowrap;
}
.doc__row dd {
margin: 0;
font-size: 13.5px;
font-weight: 600;
text-align: left;
}
.doc__gold {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed @hairline;
font-size: 13px;
font-weight: 700;
}
.doc__stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.is-in {
color: #16a34a;
}
.is-out {
color: #dc2626;
}
</style>