ADD: Standalone transaction creation page with sidebar menu entry

- Add CreateWarehouseTransaction.vue — full-page form to register IN/OUT transactions
  with item/warehouse dropdowns (searchable), quantity input, reason presets, date picker,
  live stock preview showing current balance for the selected item+warehouse combo,
  and input validation with inline error messages
- Add /warehouse-items/create-transaction route
- Add 'ثبت تراکنش' as first item in 'مدیریت انبار' sidebar group
This commit is contained in:
2026-08-11 20:12:34 +03:30
parent 703df418ce
commit c01fed2622
3 changed files with 578 additions and 0 deletions
+1
View File
@@ -25,6 +25,7 @@ const model = ref([
{
label: 'مدیریت انبار',
items: [
{ label: 'ثبت تراکنش', icon: 'pi pi-fw pi-plus-circle', to: '/warehouse-items/create-transaction' },
{ label: 'اقلام انبار', icon: 'pi pi-fw pi-box', to: '/warehouse-items/list' },
{ label: 'تراکنش‌های انبار', icon: 'pi pi-fw pi-history', to: '/warehouse-items/transactions' },
{ label: 'مکان‌های انبار', icon: 'pi pi-fw pi-map', to: '/warehouse-items/warehouses' }
+5
View File
@@ -100,6 +100,11 @@ const router = createRouter({
name: 'warehouse-item-stock',
component: () => import('@/views/pages/WarehouseItems/WarehouseItemStockView.vue')
},
{
path: '/warehouse-items/create-transaction',
name: 'create-warehouse-transaction',
component: () => import('@/views/pages/WarehouseItems/CreateWarehouseTransaction.vue')
},
{
path: '/warehouse-items/transactions',
name: 'warehouse-item-transactions',
@@ -0,0 +1,572 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { WarehouseItemService } from '@/service/WarehouseItemService';
import { WarehouseService } from '@/service/WarehouseService';
const router = useRouter();
const toast = useToast();
const items = ref([]);
const warehouses = ref([]);
const submitting = ref(false);
const submitted = ref(false);
const form = ref({
itemId: '',
warehouseId: '',
type: 'IN',
quantity: null,
reason: '',
note: '',
date: '',
});
// Stock preview for the selected item+warehouse combo
const stockPreview = ref(null);
const stockLoading = ref(false);
const IN_REASONS = [
'خرید از تامین‌کننده',
'برگشت از کارگاه',
'انتقال از انبار دیگر',
'اهدا / دریافت رایگان',
'موجودی اولیه',
'سایر',
];
const OUT_REASONS = [
'مصرف در پروژه',
'انتقال به انبار دیگر',
'فروش / واگذاری',
'ضایعات',
'مفقودی',
'سایر',
];
const reasons = computed(() => form.value.type === 'IN' ? IN_REASONS : OUT_REASONS);
const selectedItem = computed(() => items.value.find(i => i._id === form.value.itemId));
onMounted(async () => {
await Promise.all([loadItems(), loadWarehouses()]);
});
const loadItems = async () => {
try {
const res = await WarehouseItemService.listItems({ limit: 500, isActive: 'true' });
items.value = res.items || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری اقلام', life: 3000 });
}
};
const loadWarehouses = async () => {
try {
warehouses.value = await WarehouseService.listWarehouses({ isActive: 'true' });
} catch (e) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بارگذاری انبارها', life: 3000 });
}
};
// Load current stock when both item and warehouse are selected
const onItemOrWarehouseChange = async () => {
if (!form.value.itemId || !form.value.warehouseId) {
stockPreview.value = null;
return;
}
stockLoading.value = true;
try {
const data = await WarehouseItemService.getStockByItem(form.value.itemId);
const warehouseStock = data.stocks.find(s => s.warehouse?._id === form.value.warehouseId);
stockPreview.value = {
quantity: warehouseStock ? warehouseStock.quantity : 0,
unit: data.unit,
unitEn: data.unitEn,
};
} catch (e) {
stockPreview.value = null;
} finally {
stockLoading.value = false;
}
};
const onTypeChange = () => {
form.value.reason = '';
};
const isValid = computed(() => {
return form.value.itemId && form.value.warehouseId && form.value.quantity > 0 && form.value.reason;
});
const submitTransaction = async () => {
submitted.value = true;
if (!isValid.value) {
toast.add({ severity: 'warn', summary: 'توجه', detail: 'لطفاً تمام فیلدهای ضروری را پر کنید', life: 3000 });
return;
}
submitting.value = true;
try {
await WarehouseItemService.createTransaction({
itemId: form.value.itemId,
warehouseId: form.value.warehouseId,
type: form.value.type,
quantity: form.value.quantity,
reason: form.value.reason,
note: form.value.note,
date: form.value.date || undefined,
});
toast.add({
severity: 'success',
summary: 'موفق',
detail: form.value.type === 'IN' ? 'موجودی با موفقیت به انبار افزوده شد' : 'موجودی با موفقیت از انبار کسر شد',
life: 4000,
});
// Reset form
form.value = { itemId: '', warehouseId: '', type: 'IN', quantity: null, reason: '', note: '', date: '' };
submitted.value = false;
stockPreview.value = null;
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: e.response?.data?.message || 'عملیات ناموفق بود',
life: 5000,
});
} finally {
submitting.value = false;
}
};
const goToStockView = () => {
if (form.value.itemId) {
router.push(`/warehouse-items/stock/${form.value.itemId}`);
}
};
</script>
<template>
<div class="create-transaction-page">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon" :class="form.type === 'IN' ? 'icon-in' : 'icon-out'">
<i :class="form.type === 'IN' ? 'pi pi-arrow-circle-down' : 'pi pi-arrow-circle-up'"></i>
</div>
<div>
<h1 class="page-title">ثبت تراکنش انبار</h1>
<p class="page-subtitle">ورود یا خروج اقلام از انبار</p>
</div>
</div>
<div class="header-actions">
<Button
icon="pi pi-history"
label="تاریخچه تراکنش‌ها"
severity="secondary"
outlined
@click="router.push('/warehouse-items/transactions')"
/>
<Button
v-if="form.itemId"
icon="pi pi-chart-bar"
label="مشاهده موجودی قلم"
severity="info"
outlined
@click="goToStockView"
/>
</div>
</div>
<div class="form-layout">
<!-- Main form card -->
<div class="card form-card">
<!-- Type selector -->
<div class="type-selector">
<button
id="type-in"
class="type-card"
:class="{ active: form.type === 'IN' }"
type="button"
@click="form.type = 'IN'; onTypeChange()"
>
<div class="type-icon in-icon"><i class="pi pi-arrow-down"></i></div>
<div class="type-text">
<span class="type-label">ورود به انبار</span>
<span class="type-desc">افزودن موجودی</span>
</div>
</button>
<button
id="type-out"
class="type-card"
:class="{ active: form.type === 'OUT' }"
type="button"
@click="form.type = 'OUT'; onTypeChange()"
>
<div class="type-icon out-icon"><i class="pi pi-arrow-up"></i></div>
<div class="type-text">
<span class="type-label">خروج از انبار</span>
<span class="type-desc">کسر موجودی</span>
</div>
</button>
</div>
<Divider />
<!-- Item & Warehouse row -->
<div class="fields-grid">
<div class="form-field" :class="{ 'field-error': submitted && !form.itemId }">
<label for="item-select">قلم انبار <span class="required">*</span></label>
<Select
id="item-select"
v-model="form.itemId"
:options="items"
optionLabel="nameFa"
optionValue="_id"
placeholder="انتخاب قلم..."
class="w-full"
filter
filterPlaceholder="جستجو..."
@change="onItemOrWarehouseChange"
>
<template #option="{ option }">
<div class="item-option">
<span class="option-fa">{{ option.nameFa }}</span>
<span class="option-en">{{ option.nameEn }}</span>
<span class="option-unit">{{ option.unitFa }}</span>
</div>
</template>
</Select>
<small class="error-msg" v-if="submitted && !form.itemId">قلم انبار ضروری است</small>
</div>
<div class="form-field" :class="{ 'field-error': submitted && !form.warehouseId }">
<label for="warehouse-select">انبار <span class="required">*</span></label>
<Select
id="warehouse-select"
v-model="form.warehouseId"
:options="warehouses"
optionLabel="name"
optionValue="_id"
placeholder="انتخاب انبار..."
class="w-full"
@change="onItemOrWarehouseChange"
>
<template #option="{ option }">
<div class="warehouse-option">
<span>{{ option.name }}</span>
<span v-if="option.location?.address" class="option-addr">{{ option.location.address }}</span>
</div>
</template>
</Select>
<small class="error-msg" v-if="submitted && !form.warehouseId">انبار ضروری است</small>
</div>
</div>
<!-- Stock preview banner -->
<Transition name="fade">
<div v-if="stockPreview !== null" class="stock-preview" :class="{ 'stock-zero': stockPreview.quantity <= 0 }">
<i class="pi pi-database"></i>
<span>
موجودی فعلی در این انبار:
<strong>{{ stockPreview.quantity.toLocaleString('fa-IR') }} {{ stockPreview.unit }}</strong>
<span class="unit-en-small">({{ stockPreview.unitEn }})</span>
</span>
<span v-if="stockPreview.quantity <= 0 && form.type === 'OUT'" class="stock-alert">
<i class="pi pi-exclamation-triangle"></i> موجودی صفر است
</span>
<ProgressSpinner v-if="stockLoading" style="width:20px;height:20px" strokeWidth="5" />
</div>
</Transition>
<!-- Quantity & Date row -->
<div class="fields-grid">
<div class="form-field" :class="{ 'field-error': submitted && !form.quantity }">
<label for="quantity-input">
مقدار
<span v-if="selectedItem" class="unit-hint">({{ selectedItem.unitFa }} / {{ selectedItem.unitEn }})</span>
<span class="required">*</span>
</label>
<InputNumber
id="quantity-input"
v-model="form.quantity"
:min="0.001"
:minFractionDigits="0"
:maxFractionDigits="3"
class="w-full"
placeholder="مقدار را وارد کنید"
:pt="{ input: { dir: 'ltr', style: 'font-size:1.15rem; font-weight:600;' } }"
/>
<small class="error-msg" v-if="submitted && !form.quantity">مقدار ضروری است</small>
</div>
<div class="form-field">
<label for="date-input">تاریخ تراکنش <span class="optional">(اختیاری — پیش‌فرض: اکنون)</span></label>
<InputText id="date-input" v-model="form.date" type="datetime-local" class="w-full" dir="ltr" />
</div>
</div>
<!-- Reason -->
<div class="form-field" :class="{ 'field-error': submitted && !form.reason }">
<label>دلیل تراکنش <span class="required">*</span></label>
<div class="reason-chips">
<button
v-for="r in reasons"
:key="r"
class="reason-chip"
:class="{ active: form.reason === r }"
type="button"
@click="form.reason = r"
>{{ r }}</button>
</div>
<InputText
v-model="form.reason"
placeholder="یا دلیل سفارشی وارد کنید..."
class="w-full mt-2"
:class="{ 'p-invalid': submitted && !form.reason }"
/>
<small class="error-msg" v-if="submitted && !form.reason">دلیل تراکنش ضروری است</small>
</div>
<!-- Note -->
<div class="form-field">
<label for="note-input">یادداشت <span class="optional">(اختیاری)</span></label>
<Textarea id="note-input" v-model="form.note" rows="2" class="w-full" autoResize placeholder="توضیحات تکمیلی..." />
</div>
<!-- Submit -->
<div class="submit-row">
<Button
label="انصراف و پاک‌کردن فرم"
icon="pi pi-times"
severity="secondary"
outlined
@click="() => { form = { itemId: '', warehouseId: '', type: 'IN', quantity: null, reason: '', note: '', date: '' }; submitted = false; stockPreview = null; }"
/>
<Button
:label="form.type === 'IN' ? 'ثبت ورود به انبار' : 'ثبت خروج از انبار'"
:icon="form.type === 'IN' ? 'pi pi-plus-circle' : 'pi pi-minus-circle'"
:severity="form.type === 'IN' ? 'success' : 'danger'"
:loading="submitting"
size="large"
class="submit-btn"
@click="submitTransaction"
/>
</div>
</div>
<!-- Side info card -->
<div class="side-panel">
<div class="card info-card">
<h4 class="info-title"><i class="pi pi-info-circle"></i> راهنما</h4>
<ul class="info-list">
<li>
<i class="pi pi-arrow-down in-dot"></i>
<span><strong>ورود:</strong> کالایی وارد انبار می‌شود (خرید، برگشت کالا، انتقال از جای دیگر)</span>
</li>
<li>
<i class="pi pi-arrow-up out-dot"></i>
<span><strong>خروج:</strong> کالایی از انبار برداشت می‌شود (مصرف، فروش، ضایعات)</span>
</li>
<li>
<i class="pi pi-lock"></i>
<span>اگر موجودی کافی نباشد، تراکنش خروج ثبت نمی‌شود</span>
</li>
<li>
<i class="pi pi-user"></i>
<span>کاربر انجام‌دهنده به صورت خودکار ثبت می‌شود</span>
</li>
</ul>
</div>
<div class="card quick-links-card" v-if="form.itemId">
<h4 class="info-title"><i class="pi pi-link"></i> دسترسی سریع</h4>
<Button
icon="pi pi-chart-bar"
label="موجودی این قلم در همه انبارها"
class="w-full mb-2"
severity="info"
outlined
@click="goToStockView"
/>
<Button
icon="pi pi-box"
label="لیست همه اقلام"
class="w-full"
severity="secondary"
outlined
@click="router.push('/warehouse-items/list')"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.create-transaction-page { padding: 1.5rem; direction: rtl; }
/* Header */
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;
}
.header-left { display: flex; align-items: center; gap: 1rem; }
.header-icon {
width: 56px; height: 56px; border-radius: 16px;
display: flex; align-items: center; justify-content: center;
font-size: 1.6rem; color: #fff;
transition: all 0.3s;
}
.icon-in {
background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 4px 15px rgba(34,197,94,0.35);
}
.icon-out {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 4px 15px rgba(239,68,68,0.35);
}
.page-title { margin: 0; font-size: 1.5rem; font-weight: 700; }
.page-subtitle { margin: 0; font-size: 0.875rem; color: var(--text-color-secondary); }
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* Layout */
.form-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.form-layout { grid-template-columns: 1fr; }
}
/* Form card */
.form-card { border-radius: 16px; display: flex; flex-direction: column; gap: 1.25rem; }
/* Type selector */
.type-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.type-card {
border: 2px solid var(--surface-border);
background: var(--surface-50);
border-radius: 14px;
padding: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.25s;
text-align: right;
color: var(--text-color);
}
.type-card:hover { border-color: var(--primary-color); transform: translateY(-1px); }
.type-card.active { transform: translateY(-2px); box-shadow: 0 6px 20px -5px currentColor; }
.type-card:nth-child(1).active { border-color: #22c55e; background: rgba(34,197,94,0.07); box-shadow: 0 6px 20px -5px rgba(34,197,94,0.4); }
.type-card:nth-child(2).active { border-color: #ef4444; background: rgba(239,68,68,0.07); box-shadow: 0 6px 20px -5px rgba(239,68,68,0.4); }
.type-icon {
width: 44px; height: 44px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; color: #fff; flex-shrink: 0;
}
.in-icon { background: linear-gradient(135deg, #22c55e, #16a34a); }
.out-icon { background: linear-gradient(135deg, #ef4444, #dc2626); }
.type-text { display: flex; flex-direction: column; gap: 2px; }
.type-label { font-weight: 700; font-size: 1rem; }
.type-desc { font-size: 0.8rem; color: var(--text-color-secondary); }
/* Fields */
.fields-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 600px) { .fields-grid { grid-template-columns: 1fr; } }
.form-field { display: flex; flex-direction: column; gap: 0.4rem; }
.form-field label { font-weight: 600; font-size: 0.9rem; }
.required { color: #ef4444; }
.optional { font-size: 0.75rem; color: var(--text-color-secondary); font-weight: 400; }
.unit-hint { font-size: 0.8rem; color: #6366f1; font-weight: 600; margin-right: 0.25rem; }
.error-msg { color: #ef4444; font-size: 0.8rem; }
.field-error label { color: #ef4444; }
/* Stock preview */
.stock-preview {
display: flex; align-items: center; gap: 0.75rem;
background: rgba(99,102,241,0.08);
border: 1px solid rgba(99,102,241,0.25);
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.9rem;
}
.stock-preview.stock-zero {
background: rgba(239,68,68,0.08);
border-color: rgba(239,68,68,0.3);
color: #dc2626;
}
.stock-alert { margin-right: auto; font-size: 0.8rem; font-weight: 600; color: #ef4444; }
.unit-en-small { font-size: 0.8rem; color: var(--text-color-secondary); margin-right: 0.25rem; }
/* Reason chips */
.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: 5px 14px;
font-size: 0.82rem;
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: rgba(99,102,241,0.1);
border-color: #6366f1;
color: #6366f1;
font-weight: 600;
}
/* Submit row */
.submit-row {
display: flex; justify-content: space-between; align-items: center;
padding-top: 0.5rem; gap: 1rem;
}
.submit-btn { flex: 0 0 auto; }
/* Dropdown option templates */
.item-option { display: flex; align-items: center; gap: 0.5rem; }
.option-fa { font-weight: 600; }
.option-en { font-size: 0.8rem; color: var(--text-color-secondary); direction: ltr; }
.option-unit {
margin-right: auto;
font-size: 0.75rem;
background: rgba(99,102,241,0.1);
color: #6366f1;
padding: 2px 8px;
border-radius: 10px;
}
.warehouse-option { display: flex; flex-direction: column; }
.option-addr { font-size: 0.75rem; color: var(--text-color-secondary); }
/* Side panel */
.side-panel { display: flex; flex-direction: column; gap: 1rem; }
.info-card, .quick-links-card { border-radius: 14px; }
.info-title {
margin: 0 0 1rem 0;
font-size: 0.95rem;
font-weight: 700;
display: flex; align-items: center; gap: 0.5rem;
}
.info-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.info-list li { display: flex; align-items: flex-start; gap: 0.6rem; font-size: 0.85rem; line-height: 1.5; }
.in-dot { color: #22c55e; margin-top: 2px; }
.out-dot { color: #ef4444; margin-top: 2px; }
/* Transition */
.fade-enter-active, .fade-leave-active { transition: all 0.25s ease; }
.fade-enter-from { opacity: 0; transform: translateY(-6px); }
.fade-leave-to { opacity: 0; transform: translateY(-6px); }
</style>