2026-09-10 22:24:16 +03:30
|
|
|
|
<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';
|
2026-09-12 01:33:07 +03:30
|
|
|
|
import {
|
|
|
|
|
|
UiBadge,
|
|
|
|
|
|
UiButton,
|
|
|
|
|
|
UiCard,
|
|
|
|
|
|
UiInput,
|
|
|
|
|
|
UiPageHeader,
|
|
|
|
|
|
UiSelect,
|
|
|
|
|
|
} from '@/components/ui';
|
|
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
2026-09-10 22:24:16 +03:30
|
|
|
|
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>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<section class="crm-page">
|
|
|
|
|
|
<UiPageHeader
|
|
|
|
|
|
title="انبار"
|
2026-09-17 16:46:00 +03:30
|
|
|
|
lead="موجودی کالا، ورود خرید و مانده وقتی تهحساب وصل نباشد"
|
2026-09-12 01:33:07 +03:30
|
|
|
|
>
|
|
|
|
|
|
<template #actions>
|
|
|
|
|
|
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
|
|
|
|
|
<UiIcon name="refresh" :size="16" />
|
|
|
|
|
|
بروزرسانی
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiPageHeader>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<p v-if="error" class="error-msg">{{ error }}</p>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
<p v-else-if="loading && !summary" class="muted">در حال بارگذاری…</p>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div v-else-if="summary && !summary.enabled" class="notice-box">
|
2026-09-10 22:24:16 +03:30
|
|
|
|
اتصال تهحساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده میشود.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="summary">
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="kpis-grid">
|
|
|
|
|
|
<UiCard padding="sm" class="kpi-card">
|
|
|
|
|
|
<span class="kpi-label">صندوق (وصولها)</span>
|
|
|
|
|
|
<strong class="kpi-value">{{ formatMoney(summary.cashTotal, summary.currency) }}</strong>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
<UiCard padding="sm" class="kpi-card">
|
|
|
|
|
|
<span class="kpi-label">مانده طرفحسابها</span>
|
|
|
|
|
|
<strong class="kpi-value">{{ formatMoney(summary.receivablesTotal, summary.currency) }}</strong>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
<UiCard padding="sm" class="kpi-card">
|
|
|
|
|
|
<span class="kpi-label">نام انبار</span>
|
|
|
|
|
|
<strong class="kpi-value">{{ summary.warehouseName }}</strong>
|
|
|
|
|
|
</UiCard>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiCard v-if="canWrite" padding="sm" class="move-form-card">
|
|
|
|
|
|
<form class="move-form" @submit.prevent="submitMove">
|
|
|
|
|
|
<UiSelect v-model="moveType">
|
|
|
|
|
|
<option value="purchase">ورود خرید</option>
|
|
|
|
|
|
<option value="adjustment">اصلاح موجودی (عدد جدید)</option>
|
|
|
|
|
|
</UiSelect>
|
|
|
|
|
|
<UiSelect v-model="productId">
|
|
|
|
|
|
<option value="" disabled>انتخاب محصول</option>
|
|
|
|
|
|
<option
|
|
|
|
|
|
v-for="p in summary.products"
|
|
|
|
|
|
:key="p.productId"
|
|
|
|
|
|
:value="p.productId"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ p.name }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</UiSelect>
|
|
|
|
|
|
<UiInput
|
|
|
|
|
|
v-model="quantity"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<UiInput v-model="note" placeholder="توضیح اختیاری…" />
|
|
|
|
|
|
<UiButton variant="primary" type="submit" :disabled="saving || !productId" :loading="saving">
|
|
|
|
|
|
ثبت گردش
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</UiCard>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="inventory-grid">
|
|
|
|
|
|
<UiCard>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="section-title">
|
|
|
|
|
|
<UiIcon name="box" :size="18" />
|
|
|
|
|
|
<h3>موجودی کالا</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<p v-if="!summary.products.length" class="muted empty-note">محصولی در انبار تعریف نشده است.</p>
|
|
|
|
|
|
<table v-else class="crm-table">
|
2026-09-10 22:24:16 +03:30
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<th>نام کالا</th>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
<th>واحد</th>
|
|
|
|
|
|
<th>موجودی</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="p in summary.products" :key="p.productId">
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<strong>{{ p.name }}</strong>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<small v-if="p.sku" class="sku-hint">{{ p.sku }}</small>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
</td>
|
|
|
|
|
|
<td>{{ p.unit || 'عدد' }}</td>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<td>
|
|
|
|
|
|
<UiBadge :variant="p.stockQty > 0 ? 'success' : 'danger'" size="sm">
|
|
|
|
|
|
{{ toFaDigits(String(p.stockQty)) }}
|
|
|
|
|
|
</UiBadge>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
</UiCard>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiCard>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="section-title">
|
|
|
|
|
|
<UiIcon name="clock" :size="18" />
|
|
|
|
|
|
<h3>گردش اخیر کالا</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<p v-if="!movements.length" class="muted empty-note">هیچ گردشی ثبت نشده است.</p>
|
|
|
|
|
|
<ul v-else class="moves-list">
|
2026-09-10 22:24:16 +03:30
|
|
|
|
<li v-for="m in movements" :key="m.id">
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="move-info">
|
2026-09-10 22:24:16 +03:30
|
|
|
|
<strong>{{ m.productName }}</strong>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<span class="move-type-tag">{{ typeLabel[m.type] }}</span>
|
|
|
|
|
|
<small v-if="m.note" class="move-note">{{ m.note }}</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="move-end">
|
|
|
|
|
|
<UiBadge :variant="m.quantity > 0 ? 'success' : 'danger'" size="sm">
|
|
|
|
|
|
{{ m.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(m.quantity)) }}
|
|
|
|
|
|
</UiBadge>
|
|
|
|
|
|
<time class="move-time">{{ formatJalaliNumeric(new Date(m.occurredAt)) }}</time>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
</UiCard>
|
2026-09-10 22:24:16 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.error-msg {
|
|
|
|
|
|
color: var(--color-danger);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-size: var(--fs-sm);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.notice-box {
|
|
|
|
|
|
background: var(--color-info-surface);
|
|
|
|
|
|
border: 1px solid var(--color-info);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
padding: var(--sp-3) var(--sp-4);
|
|
|
|
|
|
color: var(--color-info);
|
|
|
|
|
|
font-size: var(--fs-sm);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.kpis-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.kpi-card {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: var(--sp-1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.kpi-label {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.kpi-value {
|
|
|
|
|
|
font-size: var(--fs-lg);
|
|
|
|
|
|
color: var(--color-text-emphasis);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.move-form-card {
|
|
|
|
|
|
margin: var(--sp-2) 0;
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.move-form {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 10rem 1fr 8rem 1fr auto;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-3);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.inventory-grid {
|
2026-09-10 22:24:16 +03:30
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-4);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.section-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.section-title h3 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: var(--fs-base);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sku-hint {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.empty-note {
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
padding: var(--sp-4) 0;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.moves-list {
|
2026-09-10 22:24:16 +03:30
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-3);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.moves-list li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
padding: var(--sp-2) 0;
|
|
|
|
|
|
border-bottom: 1px solid var(--color-border-light);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.move-info {
|
2026-09-10 22:24:16 +03:30
|
|
|
|
display: grid;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-1);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.move-info strong {
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--color-text-emphasis);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.move-type-tag {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.move-note {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.move-end {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
gap: var(--sp-1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.move-time {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.muted {
|
|
|
|
|
|
color: var(--color-text-muted);
|
2026-09-10 22:24:16 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.kpis-grid,
|
|
|
|
|
|
.inventory-grid,
|
2026-09-10 22:24:16 +03:30
|
|
|
|
.move-form {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|