Files
CRM_Montzar/apps/web/src/views/InventoryView.vue
T
2026-10-05 11:56:49 +03:30

389 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.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 {
UiBadge,
UiButton,
UiCard,
UiInput,
UiPageHeader,
UiSelect,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.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="crm-page">
<UiPageHeader
title="انبار"
lead="موجودی کالا، ورود خرید و مانده وقتی ته‌حساب وصل نباشد"
>
<template #actions>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
<UiIcon name="refresh" :size="16" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error-msg">{{ error }}</p>
<p v-else-if="loading && !summary" class="muted">در حال بارگذاری…</p>
<div v-else-if="summary && !summary.enabled" class="notice-box">
اتصال ته‌حساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده می‌شود.
</div>
<template v-else-if="summary">
<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>
</div>
<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>
<div class="inventory-grid">
<UiCard>
<template #header>
<div class="section-title">
<UiIcon name="box" :size="18" />
<h3>موجودی کالا</h3>
<TableColumnsMenu
v-if="summary.products.length"
table-key="inventory.stock"
align="end"
style="margin-inline-start: auto"
/>
</div>
</template>
<p v-if="!summary.products.length" class="muted empty-note">محصولی در انبار تعریف نشده است.</p>
<table v-else v-table-cols="'inventory.stock'" class="crm-table">
<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" class="sku-hint">{{ p.sku }}</small>
</td>
<td>{{ p.unit || 'عدد' }}</td>
<td>
<UiBadge :variant="p.stockQty > 0 ? 'success' : 'danger'" size="sm">
{{ toFaDigits(String(p.stockQty)) }}
</UiBadge>
</td>
</tr>
</tbody>
</table>
</UiCard>
<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">
<li v-for="m in movements" :key="m.id">
<div class="move-info">
<strong>{{ m.productName }}</strong>
<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>
</div>
</li>
</ul>
</UiCard>
</div>
</template>
</section>
</template>
<style scoped>
.error-msg {
color: var(--color-danger);
font-weight: 600;
font-size: var(--fs-sm);
margin: 0;
}
.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);
}
.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);
font-weight: 600;
}
.kpi-value {
font-size: var(--fs-lg);
color: var(--color-text-emphasis);
}
.move-form-card {
margin: var(--sp-2) 0;
}
.move-form {
display: grid;
grid-template-columns: 10rem 1fr 8rem 1fr auto;
gap: var(--sp-3);
align-items: center;
}
.inventory-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
}
.section-title {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.section-title h3 {
margin: 0;
font-size: var(--fs-base);
font-weight: 700;
}
.sku-hint {
display: block;
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.empty-note {
font-size: var(--fs-sm);
padding: var(--sp-4) 0;
text-align: center;
}
.moves-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-3);
}
.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 {
display: grid;
gap: var(--sp-1);
}
.move-info strong {
font-size: var(--fs-sm);
color: var(--color-text-emphasis);
}
.move-type-tag {
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.move-note {
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.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);
}
@media (max-width: 900px) {
.kpis-grid,
.inventory-grid,
.move-form {
grid-template-columns: 1fr;
}
}
</style>