Files
mbmanager-dashboard/src/views/pages/WarehouseItems/WarehouseItemStockView.vue
T
2026-08-30 11:25:31 +03:30

697 lines
24 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>
import { onMounted, ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ConfirmPopup from 'primevue/confirmpopup';
import { WarehouseItemService } from '@/service/WarehouseItemService';
import { WarehouseService } from '@/service/WarehouseService';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const itemId = computed(() => route.params.itemId);
const stockData = ref(null);
const warehouses = ref([]);
const loading = ref(false);
const showTransactionDialog = ref(false);
const showTransactionHistory = ref(false);
const submitting = ref(false);
const transactions = ref([]);
const transactionsLoading = ref(false);
const totalTransactions = ref(0);
const txForm = ref({
type: 'IN',
warehouseId: '',
quantity: null,
reason: '',
note: ''
});
const IN_REASONS = ['خرید از تامین‌کننده', 'برگشت از کارگاه', 'انتقال از انبار دیگر', 'اهدا / دریافت رایگان', 'موجودی اولیه', 'سایر'];
const OUT_REASONS = ['مصرف در پروژه', 'انتقال به انبار دیگر', 'فروش / واگذاری', 'ضایعات', 'مفقودی', 'سایر'];
const reasons = computed(() => (txForm.value.type === 'IN' ? IN_REASONS : OUT_REASONS));
onMounted(async () => {
await Promise.all([loadStock(), loadWarehouses()]);
});
const loadStock = async () => {
loading.value = true;
try {
stockData.value = await WarehouseItemService.getStockByItem(itemId.value);
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری موجودی', life: 3000 });
} finally {
loading.value = false;
}
};
const loadWarehouses = async () => {
try {
warehouses.value = await WarehouseService.listWarehouses({ isActive: 'true' });
} catch (e) {
console.error('Failed to load warehouses', e);
}
};
const loadTransactions = async () => {
transactionsLoading.value = true;
try {
const result = await WarehouseItemService.listTransactions({ itemId: itemId.value, limit: 50 });
transactions.value = result.transactions || [];
totalTransactions.value = result.total || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری تراکنش‌ها', life: 3000 });
} finally {
transactionsLoading.value = false;
}
};
const openTransactionDialog = (type = 'IN') => {
txForm.value = { type, warehouseId: '', quantity: null, reason: '', note: '' };
showTransactionDialog.value = true;
};
const submitTransaction = async () => {
if (!txForm.value.warehouseId || !txForm.value.quantity || !txForm.value.reason) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'لطفاً تمام فیلدهای ضروری را پر کنید', life: 3000 });
return;
}
submitting.value = true;
try {
await WarehouseItemService.createTransaction({
itemId: itemId.value,
warehouseId: txForm.value.warehouseId,
type: txForm.value.type,
quantity: txForm.value.quantity,
reason: txForm.value.reason,
note: txForm.value.note
});
toast.add({
severity: 'success',
summary: 'موفق',
detail: txForm.value.type === 'IN' ? 'موجودی با موفقیت افزوده شد' : 'موجودی با موفقیت کسر شد',
life: 3000
});
showTransactionDialog.value = false;
await loadStock();
if (showTransactionHistory.value) await loadTransactions();
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 5000
});
} finally {
submitting.value = false;
}
};
const toggleHistory = async () => {
showTransactionHistory.value = !showTransactionHistory.value;
if (showTransactionHistory.value && transactions.value.length === 0) {
await loadTransactions();
}
};
const confirmDeleteTransaction = (tx, event) => {
confirm.require({
target: event.currentTarget,
message: 'حذف این تراکنش موجودی انبار را برگشت می‌دهد. مطمئنید؟',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'خیر', severity: 'secondary', outlined: true },
acceptProps: { label: 'بله، حذف شود', severity: 'danger' },
accept: async () => {
try {
await WarehouseItemService.deleteTransaction(tx._id);
toast.add({ severity: 'success', summary: 'موفق', detail: 'تراکنش حذف شد', life: 3000 });
await Promise.all([loadStock(), loadTransactions()]);
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: e.response?.data?.message || 'خطا در حذف', life: 4000 });
}
}
});
};
const formatDate = (d) => {
if (!d) return '—';
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(d));
};
const getWarehouseName = (id) => {
const w = warehouses.value.find((w) => w._id === id);
return w ? w.name : id;
};
</script>
<template>
<ConfirmPopup />
<div class="stock-view-page">
<!-- ─── Back + Title ──────────────────────────────────────────── -->
<div class="page-header">
<Button icon="pi pi-arrow-right" text rounded @click="router.back()" />
<div class="header-text" v-if="stockData">
<h1 class="page-title">
<span class="item-name-fa">{{ stockData.item.nameFa }}</span>
<span class="item-name-en">{{ stockData.item.nameEn }}</span>
</h1>
<p class="page-subtitle">مدیریت موجودی انبار</p>
</div>
<div class="header-actions" v-if="stockData">
<Button icon="pi pi-plus" label="افزودن موجودی" severity="success" @click="openTransactionDialog('IN')" />
<Button icon="pi pi-minus" label="کسر موجودی" severity="danger" outlined @click="openTransactionDialog('OUT')" />
<Button :icon="showTransactionHistory ? 'pi pi-times' : 'pi pi-history'" :label="showTransactionHistory ? 'بستن تاریخچه' : 'تاریخچه تراکنش‌ها'" severity="secondary" outlined @click="toggleHistory" />
</div>
</div>
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<div v-else-if="stockData" class="content-grid">
<!-- ─── Total Summary Card ────────────────────────────────── -->
<div class="summary-card">
<div class="summary-icon">
<i class="pi pi-database"></i>
</div>
<div class="summary-details">
<span class="summary-label">موجودی کل (تمام انبارها)</span>
<div class="summary-value">
<span class="qty">{{ stockData.totalQuantity.toLocaleString('fa-IR') }}</span>
<span class="unit">{{ stockData.unit }}</span>
</div>
<span class="unit-en-label">{{ stockData.unitEn }}</span>
</div>
<div class="summary-locations">
<i class="pi pi-map-marker"></i>
{{ stockData.stocks.length }} انبار فعال
</div>
</div>
<!-- ─── Per-warehouse stock table ─────────────────────────── -->
<div class="card stock-table-card">
<h3 class="card-title">
<i class="pi pi-warehouse me-2"></i>
موجودی به تفکیک انبار
</h3>
<DataTable :value="stockData.stocks" stripedRows responsiveLayout="scroll">
<template #empty>
<div class="empty-state">
<i class="pi pi-inbox"></i>
<p>هنوز هیچ موجودی ثبت نشده است</p>
</div>
</template>
<Column header="انبار">
<template #body="{ data }">
<div class="warehouse-info">
<div class="warehouse-name">{{ data.warehouse?.name || '—' }}</div>
<div class="warehouse-code" v-if="data.warehouse?.code">{{ data.warehouse.code }}</div>
</div>
</template>
</Column>
<Column header="آدرس">
<template #body="{ data }">
<span class="warehouse-address">{{ data.warehouse?.location?.address || '—' }}</span>
</template>
</Column>
<Column header="موجودی فعلی">
<template #body="{ data }">
<div class="stock-quantity" :class="{ 'zero-stock': data.quantity <= 0, 'low-stock': data.quantity > 0 && data.quantity < 5 }">
<span class="qty-value">{{ data.quantity.toLocaleString('fa-IR') }}</span>
<span class="qty-unit">{{ data.unit }}</span>
</div>
</template>
</Column>
<Column header="عملیات" style="width: 12rem">
<template #body="{ data }">
<div class="row-actions">
<Button
icon="pi pi-plus"
size="small"
severity="success"
text
rounded
v-tooltip.top="'افزودن موجودی'"
@click="
() => {
txForm.warehouseId = data.warehouse?._id;
openTransactionDialog('IN');
txForm.warehouseId = data.warehouse?._id;
}
"
/>
<Button
icon="pi pi-minus"
size="small"
severity="danger"
text
rounded
v-tooltip.top="'کسر موجودی'"
@click="
() => {
txForm.warehouseId = data.warehouse?._id;
openTransactionDialog('OUT');
txForm.warehouseId = data.warehouse?._id;
}
"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<!-- ─── Transaction History ────────────────────────────────── -->
<Transition name="slide-fade">
<div class="card history-card" v-if="showTransactionHistory">
<h3 class="card-title">
<i class="pi pi-history me-2"></i>
تاریخچه تراکنش‌ها
<span class="history-count">({{ totalTransactions }} تراکنش)</span>
</h3>
<DataTable :value="transactions" :loading="transactionsLoading" stripedRows responsiveLayout="scroll">
<template #empty>
<div class="empty-state"><p>هیچ تراکنشی یافت نشد</p></div>
</template>
<Column header="نوع" style="width: 8%">
<template #body="{ data }">
<Tag :severity="data.type === 'IN' ? 'success' : 'danger'" :value="data.type === 'IN' ? 'ورود' : 'خروج'" />
</template>
</Column>
<Column header="مقدار">
<template #body="{ data }">
<span class="qty-value" :class="data.type === 'IN' ? 'in-qty' : 'out-qty'"> {{ data.type === 'OUT' ? '-' : '+' }}{{ data.quantity.toLocaleString('fa-IR') }} {{ data.unit }} </span>
</template>
</Column>
<Column header="انبار">
<template #body="{ data }">{{ data.warehouse?.name || '—' }}</template>
</Column>
<Column header="دلیل">
<template #body="{ data }">
<span class="reason-text">{{ data.reason }}</span>
<small class="note-text" v-if="data.note"> — {{ data.note }}</small>
</template>
</Column>
<Column header="کاربر">
<template #body="{ data }">{{ data.actorName || data.actor?.name || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatDate(data.date) }}</template>
</Column>
<Column style="width: 5%">
<template #body="{ data }">
<Button icon="pi pi-trash" text rounded severity="danger" size="small" v-tooltip.top="'حذف تراکنش'" @click="confirmDeleteTransaction(data, $event)" />
</template>
</Column>
</DataTable>
</div>
</Transition>
</div>
<!-- ─── Transaction Dialog ────────────────────────────────────── -->
<Dialog v-model:visible="showTransactionDialog" :header="txForm.type === 'IN' ? 'افزودن موجودی' : 'کسر موجودی'" :style="{ width: '500px' }" modal dir="rtl">
<div class="tx-form">
<!-- Type toggle -->
<div class="type-toggle">
<button class="type-btn" :class="{ active: txForm.type === 'IN', 'in-type': true }" @click="txForm.type = 'IN'" type="button"><i class="pi pi-arrow-down"></i> ورود به انبار</button>
<button class="type-btn" :class="{ active: txForm.type === 'OUT', 'out-type': true }" @click="txForm.type = 'OUT'" type="button"><i class="pi pi-arrow-up"></i> خروج از انبار</button>
</div>
<div class="form-field">
<label>انبار <span class="required">*</span></label>
<Select v-model="txForm.warehouseId" :options="warehouses" optionLabel="name" optionValue="_id" placeholder="انتخاب انبار..." class="w-full" />
</div>
<div class="form-field">
<label>مقدار ({{ stockData?.unit }}) <span class="required">*</span></label>
<InputNumber v-model="txForm.quantity" :min="0.001" :minFractionDigits="0" :maxFractionDigits="3" class="w-full" placeholder="مقدار را وارد کنید" :pt="{ input: { dir: 'ltr' } }" />
</div>
<div class="form-field">
<label>دلیل <span class="required">*</span></label>
<div class="reason-chips">
<button v-for="r in reasons" :key="r" class="reason-chip" :class="{ active: txForm.reason === r }" type="button" @click="txForm.reason = r">{{ r }}</button>
</div>
<InputText v-model="txForm.reason" placeholder="یا دلیل سفارشی وارد کنید..." class="w-full mt-2" />
</div>
<div class="form-field">
<label>یادداشت (اختیاری)</label>
<Textarea v-model="txForm.note" rows="2" class="w-full" autoResize placeholder="توضیحات تکمیلی..." />
</div>
<!-- Stock preview for OUT -->
<div class="stock-warning" v-if="txForm.type === 'OUT' && txForm.warehouseId">
<i class="pi pi-info-circle"></i>
<span>
موجودی فعلی در این انبار:
<strong> {{ (stockData.stocks.find((s) => s.warehouse?._id === txForm.warehouseId)?.quantity || 0).toLocaleString('fa-IR') }} {{ stockData?.unit }} </strong>
</span>
</div>
</div>
<template #footer>
<Button label="انصراف" icon="pi pi-times" text @click="showTransactionDialog = false" />
<Button
:label="txForm.type === 'IN' ? 'افزودن موجودی' : 'کسر موجودی'"
:icon="txForm.type === 'IN' ? 'pi pi-plus' : 'pi pi-minus'"
:severity="txForm.type === 'IN' ? 'success' : 'danger'"
:loading="submitting"
@click="submitTransaction"
/>
</template>
</Dialog>
</div>
</template>
<style scoped>
.stock-view-page {
padding: 1.5rem;
direction: rtl;
}
/* Header */
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-text {
flex: 1;
}
.page-title {
margin: 0;
font-size: 1.4rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
}
.item-name-fa {
color: var(--text-color);
}
.item-name-en {
font-size: 0.95rem;
color: var(--text-color-secondary);
direction: ltr;
display: inline-block;
font-weight: 400;
}
.page-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.loading-container {
display: flex;
justify-content: center;
padding: 4rem;
}
/* Content grid */
.content-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Summary card */
.summary-card {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 16px;
padding: 1.75rem 2rem;
color: #fff;
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.35);
}
.summary-icon {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
flex-shrink: 0;
}
.summary-details {
flex: 1;
}
.summary-label {
font-size: 0.875rem;
opacity: 0.85;
display: block;
margin-bottom: 0.3rem;
}
.summary-value {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.summary-value .qty {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
}
.summary-value .unit {
font-size: 1.1rem;
opacity: 0.9;
}
.unit-en-label {
font-size: 0.8rem;
opacity: 0.7;
}
.summary-locations {
font-size: 0.875rem;
opacity: 0.85;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* Table card */
.stock-table-card,
.history-card {
border-radius: 16px;
}
.card-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.history-count {
font-size: 0.8rem;
color: var(--text-color-secondary);
font-weight: 400;
}
/* Warehouse cells */
.warehouse-name {
font-weight: 600;
}
.warehouse-code {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-family: monospace;
}
.warehouse-address {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
/* Stock quantity */
.stock-quantity {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.qty-value {
font-size: 1.2rem;
font-weight: 700;
}
.qty-unit {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
.zero-stock .qty-value {
color: #ef4444;
}
.low-stock .qty-value {
color: #f59e0b;
}
.in-qty {
color: #22c55e;
font-weight: 700;
}
.out-qty {
color: #ef4444;
font-weight: 700;
}
.row-actions {
display: flex;
gap: 0.25rem;
}
.reason-text {
font-size: 0.875rem;
}
.note-text {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-color-secondary);
}
/* Slide animation */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-12px);
}
/* TX form */
.tx-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.required {
color: #ef4444;
}
.type-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
border-radius: 12px;
overflow: hidden;
}
.type-btn {
padding: 0.75rem;
border: 2px solid var(--surface-border);
background: var(--surface-50);
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
color: var(--text-color);
}
.type-btn.in-type.active {
background: #dcfce7;
border-color: #22c55e;
color: #16a34a;
}
.type-btn.out-type.active {
background: #fee2e2;
border-color: #ef4444;
color: #dc2626;
}
.type-btn:hover {
border-color: var(--primary-color);
}
.reason-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reason-chip {
border: 1.5px solid var(--surface-border);
background: var(--surface-50);
border-radius: 20px;
padding: 4px 12px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-color);
}
.reason-chip:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.reason-chip.active {
background: var(--primary-50);
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
}
.stock-warning {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #92400e;
}
</style>