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>
549 lines
14 KiB
Vue
549 lines
14 KiB
Vue
<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>
|