Add a small internal warehouse and party balances when Tahesab is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:24:16 +03:30
co-authored by Cursor
parent 55432ff979
commit 212d02f664
19 changed files with 1449 additions and 6 deletions
+374
View File
@@ -0,0 +1,374 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import type {
InventorySummaryDto,
StockMovementDto,
StockMovementType,
} from '@mento/shared';
const auth = useAuthStore();
const canWrite = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const error = ref('');
const summary = ref<InventorySummaryDto | null>(null);
const movements = ref<StockMovementDto[]>([]);
const productId = ref('');
const moveType = ref<'purchase' | 'adjustment'>('purchase');
const quantity = ref('');
const note = ref('');
const saving = ref(false);
const typeLabel: Record<StockMovementType, string> = {
opening: 'اول دوره',
purchase: 'ورود خرید',
sale: 'خروج فروش',
credit: 'برگشت',
adjustment: 'اصلاح',
};
const selectedStock = computed(
() =>
summary.value?.products.find((p) => p.productId === productId.value)
?.stockQty ?? 0,
);
async function load() {
loading.value = true;
error.value = '';
try {
const [sum, moves] = await Promise.all([
api.get<InventorySummaryDto>('/inventory'),
api.get<StockMovementDto[]>('/inventory/movements'),
]);
summary.value = sum.data;
movements.value = moves.data;
if (!productId.value && sum.data.products[0]) {
productId.value = sum.data.products[0].productId;
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری انبار ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function submitMove() {
if (!canWrite.value || !productId.value || saving.value) return;
const qty = Number(quantity.value);
if (!Number.isFinite(qty)) {
error.value = 'مقدار را وارد کنید.';
return;
}
let sendQty = qty;
if (moveType.value === 'adjustment') {
sendQty = qty - selectedStock.value;
if (Math.abs(sendQty) < 0.0001) return;
}
saving.value = true;
error.value = '';
try {
await api.post('/inventory/movements', {
productId: productId.value,
type: moveType.value,
quantity: sendQty,
note: note.value.trim() || undefined,
});
quantity.value = '';
note.value = '';
await load();
} catch (e) {
const msg = axios.isAxiosError(e)
? (e.response?.data as { message?: string })?.message
: null;
error.value = typeof msg === 'string' ? msg : 'ثبت گردش ناموفق بود.';
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page" dir="rtl">
<header class="head">
<div>
<h1>انبار</h1>
<p class="sub">
موجودی کالا، ورود خرید و مانده وقتی ته‌حساب وصل نباشد
</p>
</div>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !summary" class="muted">در حال بارگذاری…</p>
<div v-else-if="summary && !summary.enabled" class="notice">
اتصال ته‌حساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده می‌شود.
</div>
<template v-else-if="summary">
<div class="kpis">
<article>
<span>صندوق (وصول‌ها)</span>
<strong>{{ formatMoney(summary.cashTotal, summary.currency) }}</strong>
</article>
<article>
<span>مانده طرف‌حساب‌ها</span>
<strong>{{
formatMoney(summary.receivablesTotal, summary.currency)
}}</strong>
</article>
<article>
<span>انبار</span>
<strong>{{ summary.warehouseName }}</strong>
</article>
</div>
<form v-if="canWrite" class="move-form" @submit.prevent="submitMove">
<select v-model="moveType">
<option value="purchase">ورود خرید</option>
<option value="adjustment">اصلاح موجودی (عدد جدید)</option>
</select>
<select v-model="productId">
<option value="" disabled>محصول</option>
<option
v-for="p in summary.products"
:key="p.productId"
:value="p.productId"
>
{{ p.name }}
</option>
</select>
<UiInput
v-model="quantity"
type="number"
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
/>
<UiInput v-model="note" placeholder="توضیح (اختیاری)" />
<UiButton type="submit" :disabled="saving || !productId">
{{ saving ? 'ثبت…' : 'ثبت گردش' }}
</UiButton>
</form>
<div class="grid">
<div class="card">
<h2>موجودی کالا</h2>
<p v-if="!summary.products.length" class="muted">محصولی نیست.</p>
<table v-else>
<thead>
<tr>
<th>کالا</th>
<th>واحد</th>
<th>موجودی</th>
</tr>
</thead>
<tbody>
<tr v-for="p in summary.products" :key="p.productId">
<td>
<strong>{{ p.name }}</strong>
<small v-if="p.sku">{{ p.sku }}</small>
</td>
<td>{{ p.unit || 'عدد' }}</td>
<td class="num" :class="{ low: p.stockQty <= 0 }">
{{ toFaDigits(String(p.stockQty)) }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>گردش اخیر</h2>
<p v-if="!movements.length" class="muted">گردشی ثبت نشده.</p>
<ul v-else class="moves">
<li v-for="m in movements" :key="m.id">
<div>
<strong>{{ m.productName }}</strong>
<span>{{ typeLabel[m.type] }}</span>
<small v-if="m.note">{{ m.note }}</small>
</div>
<em :class="{ in: m.quantity > 0, out: m.quantity < 0 }">
{{ m.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(m.quantity)) }}
</em>
<time>{{ formatJalaliNumeric(new Date(m.occurredAt)) }}</time>
</li>
</ul>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.25rem;
}
.sub,
.muted {
margin: 0.25rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
color: var(--danger, #dc2626);
font-weight: 600;
}
.notice,
.kpis article,
.card,
.move-form {
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.1rem;
}
.kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.kpis span {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-bottom: 0.35rem;
}
.kpis strong {
font-size: 1.05rem;
}
.move-form {
display: grid;
grid-template-columns: 10rem 1fr 8rem 1fr auto;
gap: 0.5rem;
align-items: center;
}
.move-form select {
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.6rem;
font: inherit;
background: #fff;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
th,
td {
text-align: right;
padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9;
}
td small {
display: block;
color: var(--muted);
}
.num {
font-weight: 700;
}
.low {
color: var(--danger, #dc2626);
}
.moves {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.65rem;
}
.moves li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.15rem 0.75rem;
font-size: 0.86rem;
}
.moves span,
.moves small,
.moves time {
display: block;
color: var(--muted);
font-size: 0.78rem;
}
.moves em {
font-style: normal;
font-weight: 800;
grid-row: span 2;
align-self: center;
}
.moves .in {
color: #16a34a;
}
.moves .out {
color: #dc2626;
}
@media (max-width: 900px) {
.kpis,
.grid,
.move-form {
grid-template-columns: 1fr;
}
}
</style>
+2
View File
@@ -23,6 +23,7 @@ import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -837,6 +838,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>