Files
CRM_Montzar/apps/web/src/views/InventoryView.vue
T

375 lines
8.8 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 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>