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