Improve person assets UI and fix Tahesab asnad date/list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 11:08:46 +03:30
co-authored by Cursor
parent 43526cb980
commit 1ac23e13c1
4 changed files with 117 additions and 32 deletions
@@ -16,8 +16,17 @@ function asAsnadList(raw: unknown): Array<Record<string, unknown>> {
if (!raw) return [];
if (Array.isArray(raw)) return raw as Array<Record<string, unknown>>;
if (typeof raw === 'object') {
return Object.values(raw as Record<string, unknown>).filter(
(v) => v && typeof v === 'object',
const obj = raw as Record<string, unknown>;
for (const key of ['DoListAsnad', 'Asnad', 'asnad', 'Items', 'items', 'Data']) {
const nested = obj[key];
if (Array.isArray(nested)) {
return nested.filter((v) => v && typeof v === 'object') as Array<
Record<string, unknown>
>;
}
}
return Object.values(obj).filter(
(v) => v && typeof v === 'object' && !Array.isArray(v),
) as Array<Record<string, unknown>>;
}
return [];
@@ -68,8 +77,8 @@ export class TahesabDocumentsService {
);
const link = await this.requireLink(organizationId, personId, resolvedId);
const { api } = await this.connections.getApi(organizationId, resolvedId);
// Count_Last = -1 برای مانده ردیف‌به‌ردیف مثل همکار
const raw = await api.doListAsnad(-1, link.moshtariCode, from, to);
// Count_Last = سقف تعداد سند برگشتی (۰ در نمونه گاهی خالی برمی‌گرداند)
const raw = await api.doListAsnad(500, link.moshtariCode, from, to);
const items = asAsnadList(raw);
return {
connectionId: resolvedId,
@@ -222,11 +222,14 @@ export class TahesabApi {
fromShamsi: string,
toShamsi: string,
) {
// OpenAPI: Count_Last, Moshtari_Code, AzTarikhShamsi, TaTarikhShamsi, FilterNoSanad, Mande_Jens_Felez
return this.call('DoListAsnad', 'DoListAsnad', [
countLast,
moshtariCode,
fromShamsi,
toShamsi,
'',
0,
]);
}
@@ -3,7 +3,13 @@ import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import {
dateToJalali,
formatLocalDateInput,
parseLocalInput,
} from '@/lib/jalali';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type {
PersonAccountingLinkDto,
PersonAssetsDto,
@@ -16,6 +22,10 @@ const props = defineProps<{
personId: string;
}>();
const emit = defineEmits<{
linkChanged: [moshtariCode: number | null];
}>();
const loading = ref(false);
const error = ref('');
const ok = ref('');
@@ -26,6 +36,7 @@ const assets = ref<PersonAssetsDto | null>(null);
const activeTab = ref<'مانده حساب' | 'جزئیات حساب' | 'ثبت سند'>('مانده حساب');
const asnadLoading = ref(false);
const asnadItems = ref<Record<string, unknown>[]>([]);
/** مدل JalaliDateInput = تاریخ محلی میلادی YYYY-MM-DD */
const fromDate = ref('');
const toDate = ref('');
const manualCode = ref('');
@@ -120,18 +131,28 @@ function formatMandehAmount(amount: string | number, unit: string) {
return abs.toLocaleString('fa-IR', { maximumFractionDigits: 3 });
}
/** تبدیل تاریخ محلی میلادی به شمسی YYYY-MM-DD برای API ته‌حساب */
function toShamsiYmd(localIso: string): string | null {
const d = parseLocalInput(localIso);
if (!d) return null;
const { jy, jm, jd } = dateToJalali(d);
return `${jy}-${String(jm).padStart(2, '0')}-${String(jd).padStart(2, '0')}`;
}
function defaultJalaliRange() {
// تقریبی: کاربر می‌تواند عوض کند؛ پیش‌فرض بازه یک‌ماهه متنی
const now = new Date();
// بدون وابستگی سنگین: مقادیر نمونه قابل ویرایش
toDate.value = `${now.getFullYear() - 621}-01-01`;
// بهتر: از API تاریخ نخواهیم؛ فیلدها را خالی‌تر با placeholder بگذاریم
const y = 1405;
fromDate.value = `${y}-01-01`;
toDate.value = `${y}-12-29`;
sanad.value.year = y;
sanad.value.month = 1;
sanad.value.day = 1;
const from = new Date(now);
from.setMonth(from.getMonth() - 6);
fromDate.value = formatLocalDateInput(from);
toDate.value = formatLocalDateInput(now);
const j = dateToJalali(now);
sanad.value.year = j.jy;
sanad.value.month = j.jm;
sanad.value.day = j.jd;
}
function emitLinkCode() {
emit('linkChanged', currentLink.value?.moshtariCode ?? null);
}
async function loadConnectionsAndLinks() {
@@ -155,6 +176,12 @@ async function loadConnectionsAndLinks() {
) {
connectionId.value = def?.id ?? '';
}
if (!currentLink.value && links.value.length) {
const preferred =
links.value.find((l) => l.isDefaultConnection) ?? links.value[0];
if (preferred) connectionId.value = preferred.connectionId;
}
emitLinkCode();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
@@ -162,6 +189,7 @@ async function loadConnectionsAndLinks() {
'بارگذاری اتصال ته‌حساب ناموفق بود.',
)
: 'بارگذاری اتصال ته‌حساب ناموفق بود.';
emit('linkChanged', null);
} finally {
loading.value = false;
}
@@ -195,8 +223,10 @@ async function refreshAssets() {
async function loadAsnad() {
if (!connectionId.value || !currentLink.value) return;
if (!fromDate.value || !toDate.value) {
error.value = 'تاریخ از و تا را وارد کنید (شمسی YYYY-MM-DD).';
const from = toShamsiYmd(fromDate.value);
const to = toShamsiYmd(toDate.value);
if (!from || !to) {
error.value = 'تاریخ از و تا را از تقویم انتخاب کنید.';
return;
}
asnadLoading.value = true;
@@ -207,12 +237,16 @@ async function loadAsnad() {
{
params: {
connectionId: connectionId.value,
from: fromDate.value,
to: toDate.value,
from,
to,
},
timeout: 60_000,
},
);
asnadItems.value = data.items ?? [];
ok.value = asnadItems.value.length
? `${asnadItems.value.length} سند نمایش داده شد.`
: 'در این بازه سندی یافت نشد.';
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
@@ -315,6 +349,7 @@ async function unlink() {
`/integrations/tahesab/people/${props.personId}/links/${connectionId.value}`,
);
assets.value = null;
asnadItems.value = [];
await loadConnectionsAndLinks();
}
@@ -355,10 +390,17 @@ async function pushSanad() {
watch(connectionId, async () => {
matchCandidates.value = [];
asnadItems.value = [];
emitLinkCode();
if (currentLink.value) await refreshAssets();
else assets.value = null;
});
watch(activeTab, (t) => {
if (t === 'جزئیات حساب' && currentLink.value && !asnadItems.value.length) {
void loadAsnad();
}
});
onMounted(async () => {
defaultJalaliRange();
await loadConnectionsAndLinks();
@@ -374,8 +416,8 @@ onMounted(async () => {
</p>
<template v-else>
<div class="toolbar">
<label v-if="hasMultipleConnections" class="conn-pick">
<div v-if="hasMultipleConnections" class="toolbar">
<label class="conn-pick">
<span>اتصال</span>
<select v-model="connectionId">
<option v-for="c in connections" :key="c.id" :value="c.id">
@@ -383,19 +425,12 @@ onMounted(async () => {
</option>
</select>
</label>
<div v-else-if="connections[0]" class="muted">
اتصال: {{ connections[0].label }} · DB {{ connections[0].dbName }}
</div>
</div>
<div class="link-bar card">
<template v-if="currentLink">
<div>
<strong>لینک‌شده</strong>
<span class="muted">
کد ته‌حساب: {{ currentLink.moshtariCode }} · روش:
{{ currentLink.matchMethod }}
</span>
<strong>لینک‌شده به ته‌حساب</strong>
</div>
<div class="actions">
<button type="button" class="btn ghost" @click="refreshAssets">بروزرسانی مانده</button>
@@ -506,11 +541,11 @@ onMounted(async () => {
<div class="date-row">
<label>
از
<input v-model="fromDate" dir="ltr" placeholder="1405-01-01" />
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" />
</label>
<label>
تا
<input v-model="toDate" dir="ltr" placeholder="1405-12-29" />
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" />
</label>
<button type="button" class="btn" :disabled="asnadLoading" @click="loadAsnad">
{{ asnadLoading ? '…' : 'نمایش اسناد' }}
@@ -790,6 +825,10 @@ onMounted(async () => {
align-items: end;
margin-bottom: 0.75rem;
}
.date-row label {
min-width: 160px;
flex: 1;
}
.date-row label,
.sanad-form label {
display: grid;
+36 -2
View File
@@ -33,6 +33,7 @@ import type {
PaginatedResponse,
PersonDto,
PersonAiSummaryDto,
PersonAccountingLinkDto,
PipelineBoardDto,
ProjectBoardBoardDto,
ProjectCardDto,
@@ -94,6 +95,7 @@ const tickets = ref<TicketDto[]>([]);
const memories = ref<MemoryItemDto[]>([]);
const deals = ref<DealDto[]>([]);
const cards = ref<ProjectCardDto[]>([]);
const tahesabMoshtariCode = ref<number | null>(null);
const moreOpen = ref(false);
const filterOpen = ref(false);
@@ -372,6 +374,7 @@ async function load() {
moreOpen.value = false;
filterOpen.value = false;
aiSummary.value = null;
tahesabMoshtariCode.value = null;
try {
const [personRes, actRes, noteRes, quoteRes, memRes, boardRes] = await Promise.all([
api.get<PersonDto>(`/people/${id.value}`),
@@ -407,6 +410,9 @@ async function load() {
params: { personId: id.value, limit: 50 },
}),
api.get<ProjectBoardBoardDto>('/project-boards/board'),
api.get<PersonAccountingLinkDto[]>(
`/integrations/tahesab/people/${id.value}/links`,
),
]);
invoices.value =
extras[0].status === 'fulfilled' ? extras[0].value.data.items : [];
@@ -418,6 +424,12 @@ async function load() {
.flatMap((s) => s.cards)
.filter((c) => c.person?.id === id.value)
: [];
if (extras[3].status === 'fulfilled' && extras[3].value.data.length) {
const preferred =
extras[3].value.data.find((l) => l.isDefaultConnection) ??
extras[3].value.data[0];
tahesabMoshtariCode.value = preferred?.moshtariCode ?? null;
}
} catch {
error.value = 'بارگذاری پرونده مشتری ناموفق بود.';
person.value = null;
@@ -488,6 +500,10 @@ async function completeNext() {
}
}
function onTahesabLinkChanged(code: number | null) {
tahesabMoshtariCode.value = code;
}
async function downloadAttachment(noteId: string, att: NoteAttachmentDto) {
const { data } = await api.get<Blob>(
`/notes/${noteId}/attachments/${att.id}/download`,
@@ -568,6 +584,9 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div class="who">
<h2>
{{ fullName }}
<span v-if="tahesabMoshtariCode != null" class="tahesab-code" dir="ltr">{{
tahesabMoshtariCode
}}</span>
<span v-if="vip" class="vip-mark">VIP</span>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
@@ -601,7 +620,12 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div class="mob-id">
<CustomerMoodFace :mood="mood" :size="72" />
<div class="who">
<h2>{{ fullName }}</h2>
<h2>
{{ fullName }}
<span v-if="tahesabMoshtariCode != null" class="tahesab-code" dir="ltr">{{
tahesabMoshtariCode
}}</span>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
</div>
</div>
@@ -812,7 +836,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<PersonAssetsPanel :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
@@ -1071,6 +1095,16 @@ watch(() => route.query.tab, readTab, { immediate: true });
padding: 0.12rem 0.45rem;
}
.tahesab-code {
font-size: 0.85rem;
font-weight: 700;
color: #0f766e;
background: color-mix(in srgb, #0f766e 12%, transparent);
border-radius: 8px;
padding: 0.1rem 0.45rem;
letter-spacing: 0.02em;
}
.sub {
margin: 0.25rem 0 0.7rem;
color: #64748b;