Files
CRM_Montzar/apps/web/src/views/ProductFormView.vue
T

547 lines
14 KiB
Vue
Raw Normal View History

<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 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">
{{ currency === 'IRR' || currency === 'IRT' ? 'تومان' : 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>