Files
CRM_Montzar/apps/web/src/views/ProductFormView.vue
T
MahdiandCursor 5f4cc47c41 fix: share the dashboard assistant and count leads from data
The hero ask box now uses the same confirm-before-create path as the assistant, count questions use real filters, and lead mobile, Jalali follow-ups, and rial labels stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:45:29 +03:30

549 lines
14 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.
This file contains Unicode characters that might be confused with other characters. 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 { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { currencySuffix } from '@/lib/money';
import MoneyInput from '@/components/MoneyInput.vue';
import {
UiBadge,
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSegmented,
UiSelect,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import type {
CreateProductRequest,
OrganizationDto,
ProductDto,
} from '@mento/shared';
const CATEGORIES = [
'کارساخته',
'طلا و جواهر',
'سکه',
'خدمات',
'نرم‌افزار',
'آموزش',
'سایر',
];
const UNITS = ['عدد', 'گرم', 'مثقال', 'سوت', 'کیلوگرم', 'ساعت', 'بسته'];
const props = withDefaults(
defineProps<{
embedded?: boolean;
entityId?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: []; cancel: [] }>();
const route = useRoute();
const router = useRouter();
const editId = computed(() => {
if (props.embedded) return props.entityId ?? '';
return typeof route.params.id === 'string' ? route.params.id : '';
});
const isEdit = computed(() => Boolean(editId.value));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const enabledCurrencies = ref<string[]>(['IRR']);
const kind = ref<'product' | 'service'>('product');
const kindOptions = [
{ value: 'product' as const, label: 'کالا / محصول' },
{ value: 'service' as const, label: 'خدمت' },
];
const name = ref('');
const category = ref('');
const sku = ref('');
const unitPrice = ref('');
const currency = ref('IRR');
const unit = ref('عدد');
const stockQty = ref('');
const description = ref('');
const isActive = ref(true);
const brand = ref('');
const model = ref('');
const country = ref('');
const tagInput = ref('');
const tags = ref<string[]>([]);
const title = computed(() => (isEdit.value ? 'ویرایش محصول' : 'افزودن محصول جدید'));
const submitLabel = computed(() =>
saving.value
? 'در حال ثبت…'
: isEdit.value
? 'ذخیره تغییرات'
: 'ثبت محصول',
);
function addTag() {
const t = tagInput.value.trim();
if (!t) return;
if (!tags.value.includes(t)) tags.value = [...tags.value, t].slice(0, 20);
tagInput.value = '';
}
function removeTag(t: string) {
tags.value = tags.value.filter((x) => x !== t);
}
function onTagKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}
async function loadOrg() {
const { data } = await api.get<OrganizationDto>('/organizations/current');
enabledCurrencies.value = data.enabledCurrencies?.length
? data.enabledCurrencies
: [data.baseCurrency || 'IRR'];
currency.value = data.baseCurrency || 'IRR';
}
async function loadProduct() {
if (!editId.value) return;
loading.value = true;
try {
const { data } = await api.get<ProductDto>(`/products/${editId.value}`);
kind.value = data.kind;
name.value = data.name;
category.value = data.category ?? '';
sku.value = data.sku ?? '';
unitPrice.value = data.unitPrice == null ? '' : String(data.unitPrice);
currency.value = data.currency || 'IRR';
unit.value = data.unit ?? 'عدد';
stockQty.value = data.stockQty == null ? '' : String(data.stockQty);
description.value = data.description ?? '';
isActive.value = data.isActive;
brand.value = data.brand ?? '';
model.value = data.model ?? '';
country.value = data.country ?? '';
tags.value = data.tags ?? [];
} catch {
error.value = 'بارگذاری محصول ناموفق بود.';
} finally {
loading.value = false;
}
}
function buildPayload(): CreateProductRequest {
return {
kind: kind.value,
name: name.value.trim(),
sku: sku.value.trim() || undefined,
description: description.value.trim() || undefined,
category: category.value.trim() || undefined,
unit: unit.value.trim() || undefined,
stockQty: stockQty.value === '' ? null : Number(stockQty.value),
brand: brand.value.trim() || undefined,
model: model.value.trim() || undefined,
country: country.value.trim() || undefined,
tags: tags.value,
unitPrice: unitPrice.value === '' ? null : Number(unitPrice.value),
currency: currency.value || 'IRR',
isActive: isActive.value,
};
}
async function submit() {
if (!name.value.trim()) {
error.value = 'نام محصول را وارد کنید.';
return;
}
if (!category.value.trim()) {
error.value = 'دسته‌بندی را انتخاب کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload = buildPayload();
if (isEdit.value) {
await api.patch(`/products/${editId.value}`, payload);
} else {
await api.post('/products', payload);
}
if (props.embedded) {
emit('saved');
return;
}
await router.push({ name: 'products' });
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ثبت محصول ناموفق بود');
} else {
error.value = 'ثبت محصول ناموفق بود';
}
} finally {
saving.value = false;
}
}
function cancel() {
if (props.embedded) {
emit('cancel');
return;
}
void router.push({ name: 'products' });
}
onMounted(async () => {
try {
await loadOrg();
await loadProduct();
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
}
});
</script>
<template>
<section class="crm-page product-form-page" :class="{ embedded }" dir="rtl">
<UiPageHeader
v-if="!embedded"
:title="title"
description="تعریف و ویرایش کالا و خدمات، قیمت‌گذاری و مدیریت موجودی انبار"
>
<template #breadcrumb>
<RouterLink :to="{ name: 'products' }" class="breadcrumb-link">
محصولات
</RouterLink>
<span class="breadcrumb-separator">/</span>
<span>{{ title }}</span>
</template>
<template #actions>
<UiButton :loading="saving" @click="submit">
<template #icon>
<UiIcon name="check-plain" />
</template>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">
انصراف
</UiButton>
</template>
</UiPageHeader>
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
<form class="crm-form product-form" @submit.prevent="submit">
<div class="form-layout-grid">
<div class="main-column">
<UiCard title="مشخصات اصلی کالا / خدمت" class="section-card">
<div class="kind-selection-row">
<span class="kind-label">ماهیت آیتم:</span>
<UiSegmented v-model="kind" :options="kindOptions" />
</div>
<div class="crm-form-grid-2">
<UiField label="نام کالا یا خدمت" required class="col-span-2">
<UiInput
v-model="name"
required
placeholder="مثلاً لپ‌تاپ لنوو مدل ThinkPad یا پشتیبانی سالانه نرم‌افزار"
/>
</UiField>
<UiField label="دسته‌بندی" required>
<UiSelect v-model="category" required>
<option disabled value="">انتخاب دسته‌بندی</option>
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
</UiSelect>
</UiField>
<UiField label="کد کالا / شناسه انبار (SKU)" required>
<UiInput
v-model="sku"
required
placeholder="MNT-001"
dir="ltr"
/>
</UiField>
<UiField label="قیمت پایه / واحد" required>
<div class="price-input-wrapper">
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ واحد" />
<span class="currency-unit-label">
{{ currencySuffix(currency) }}
</span>
</div>
</UiField>
<UiField label="ارز قیمت‌گذاری">
<UiSelect v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</UiSelect>
</UiField>
<UiField label="واحد سنجش" required>
<UiSelect v-model="unit" required>
<option v-for="u in UNITS" :key="u" :value="u">{{ u }}</option>
</UiSelect>
</UiField>
<UiField v-if="kind === 'product'" label="موجودی اولیه در انبار">
<UiInput
v-model="stockQty"
type="number"
min="0"
placeholder="مثلاً ۱۲۰"
dir="ltr"
/>
</UiField>
</div>
<div class="status-toggle-box">
<label class="toggle-switch-label">
<input v-model="isActive" type="checkbox" />
<span class="toggle-text">این آیتم فعال و در فاکتورها قابل انتخاب باشد</span>
</label>
</div>
</UiCard>
<UiCard title="شرح و توضیحات محصول" class="section-card">
<UiField label="توضیحات کامل کالا یا شرایط خدمت">
<UiTextarea
v-model="description"
:rows="4"
placeholder="مشخصات فنی، گارانتی، اقلام همراه یا شرایط ارائه خدمت..."
/>
</UiField>
</UiCard>
</div>
<div class="side-column">
<UiCard title="اطلاعات تکمیلی و بازرگانی" class="section-card">
<div class="side-form-stack">
<UiField label="برند یا تولیدکننده">
<UiInput v-model="brand" placeholder="نام برند تجاری" />
</UiField>
<UiField label="مدل یا سری ساخت">
<UiInput v-model="model" placeholder="سری و مدل" />
</UiField>
<UiField label="کشور سازنده">
<UiInput v-model="country" placeholder="مثلاً ایران، آلمان" />
</UiField>
<UiField label="برچسب‌ها (تگ)" hint="برای ثبت هر برچسب Enter بزنید">
<UiInput
v-model="tagInput"
placeholder="افزودن برچسب..."
@keydown="onTagKey"
/>
<div v-if="tags.length" class="tags-container">
<UiBadge
v-for="t in tags"
:key="t"
variant="neutral"
class="tag-badge"
>
<span>{{ t }}</span>
<button type="button" class="tag-remove-btn" @click="removeTag(t)">×</button>
</UiBadge>
</div>
</UiField>
</div>
</UiCard>
</div>
</div>
<div class="form-footer-actions">
<UiButton type="submit" :loading="saving">
<template #icon>
<UiIcon name="check-plain" />
</template>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
انصراف
</UiButton>
</div>
</form>
</section>
</template>
<style scoped>
.product-form-page {
display: flex;
flex-direction: column;
gap: var(--sp-6);
width: 100%;
max-width: 100%;
}
.product-form-page.embedded {
max-width: none;
gap: var(--sp-4);
}
.embedded-actions {
display: flex;
gap: var(--sp-2);
}
.breadcrumb-link {
color: var(--color-text-secondary);
text-decoration: none;
}
.breadcrumb-link:hover {
color: var(--color-brand-600);
}
.breadcrumb-separator {
margin: 0 var(--sp-2);
color: var(--color-text-muted);
}
.product-form {
display: flex;
flex-direction: column;
gap: var(--sp-6);
}
.form-layout-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--sp-6);
align-items: start;
}
.main-column,
.side-column {
display: flex;
flex-direction: column;
gap: var(--sp-6);
}
.section-card {
padding: var(--sp-6);
}
.kind-selection-row {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding-bottom: var(--sp-4);
border-bottom: 1px solid var(--color-border);
}
.kind-label {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.col-span-2 {
grid-column: span 2;
}
.price-input-wrapper {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.price-input-wrapper :deep(input) {
flex: 1;
}
.currency-unit-label {
font-size: var(--fs-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.status-toggle-box {
margin-top: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--color-border);
}
.toggle-switch-label {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
cursor: pointer;
user-select: none;
}
.toggle-text {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.side-form-stack {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.tag-badge {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-1) var(--sp-2);
}
.tag-remove-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: var(--fs-sm);
color: var(--color-text-muted);
line-height: 1;
}
.tag-remove-btn:hover {
color: var(--color-danger-text);
}
.form-footer-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-3);
margin-top: var(--sp-2);
}
@media (max-width: 900px) {
.form-layout-grid {
grid-template-columns: 1fr;
}
.col-span-2 {
grid-column: span 1;
}
}
</style>