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

674 lines
17 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 { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import type {
CreateProductRequest,
OrganizationDto,
ProductDto,
} from '@mento/shared';
const CATEGORIES = [
'طلا و جواهر',
'سکه',
'خدمات',
'نرم‌افزار',
'آموزش',
'سایر',
];
const UNITS = ['عدد', 'گرم', 'مثقال', 'سوت', 'کیلوگرم', 'ساعت', 'بسته'];
const route = useRoute();
const router = useRouter();
const editId = computed(() =>
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 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);
}
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() {
void router.push({ name: 'products' });
}
onMounted(async () => {
try {
await loadOrg();
await loadProduct();
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
}
});
</script>
<template>
<section class="page">
<header class="page-head">
<button type="button" class="back mob-only" aria-label="بازگشت" @click="cancel">
<UiIcon name="chevron" :size="20" />
</button>
<div class="title-row">
<span class="title-ico" aria-hidden="true">
<UiIcon name="box" :size="22" />
</span>
<div>
<h1>{{ title }}</h1>
<p class="lead">اطلاعات محصول را وارد کنید</p>
</div>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-if="error" class="error">{{ error }}</p>
<form v-else class="form" @submit.prevent="submit">
<div class="kind-seg mob-only">
<button
type="button"
:class="{ on: kind === 'product' }"
@click="kind = 'product'"
>
<UiIcon name="box" :size="16" />
محصول
</button>
<button
type="button"
:class="{ on: kind === 'service' }"
@click="kind = 'service'"
>
<UiIcon name="briefcase" :size="16" />
خدمت
</button>
</div>
<div class="grid">
<section class="card main">
<h2>اطلاعات اصلی</h2>
<label class="field kind-desk">
<span>نوع</span>
<div class="kind-seg">
<button
type="button"
:class="{ on: kind === 'product' }"
@click="kind = 'product'"
>
<UiIcon name="box" :size="16" />
محصول
</button>
<button
type="button"
:class="{ on: kind === 'service' }"
@click="kind = 'service'"
>
<UiIcon name="briefcase" :size="16" />
خدمت
</button>
</div>
</label>
<label class="field">
<span>نام محصول <em>*</em></span>
<div class="control">
<UiIcon name="box" :size="18" />
<input v-model="name" required placeholder="مثلاً انگشتر طلا مدل ونوس" />
</div>
</label>
<label class="field">
<span>دسته‌بندی <em>*</em></span>
<div class="control">
<UiIcon name="layers" :size="18" />
<select v-model="category" required>
<option disabled value="">دسته‌بندی را انتخاب کنید</option>
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
</label>
<label class="field">
<span>کد / SKU <em>*</em></span>
<div class="control">
<UiIcon name="flag" :size="18" />
<input v-model="sku" required placeholder="MNT-001" dir="ltr" />
</div>
</label>
<div class="row-2">
<label class="field">
<span>قیمت واحد <em>*</em></span>
<div class="control price">
<UiIcon name="dollar" :size="18" />
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ را وارد کنید" />
<span class="suffix">{{ currency === 'IRR' || currency === 'IRT' ? 'تومان' : currency }}</span>
</div>
</label>
<label class="field">
<span>ارز</span>
<div class="control">
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
</label>
</div>
<div class="row-2">
<label class="field">
<span>واحد سنجش <em>*</em></span>
<div class="control">
<UiIcon name="layers" :size="18" />
<select v-model="unit" required>
<option v-for="u in UNITS" :key="u" :value="u">{{ u }}</option>
</select>
</div>
</label>
<label class="field">
<span>موجودی</span>
<div class="control">
<UiIcon name="bag" :size="18" />
<input v-model="stockQty" type="number" min="0" placeholder="مثلاً ۱۲۰" dir="ltr" />
</div>
</label>
</div>
<div class="status-row">
<span>وضعیت محصول</span>
<label class="switch">
<input v-model="isActive" type="checkbox" />
<span class="track" />
<span class="switch-text">این محصول فعال باشد</span>
</label>
</div>
</section>
<div class="side">
<section class="card">
<h2>توضیحات</h2>
<label class="field">
<div class="control area">
<UiIcon name="document" :size="18" />
<textarea
v-model="description"
rows="5"
placeholder="توضیحات محصول را وارد کنید..."
/>
</div>
</label>
</section>
<section class="card desk-only">
<h2>اطلاعات تکمیلی</h2>
<label class="field">
<span>برند</span>
<div class="control">
<UiIcon name="building" :size="18" />
<input v-model="brand" placeholder="برند را وارد کنید" />
</div>
</label>
<label class="field">
<span>مدل</span>
<div class="control">
<UiIcon name="grid" :size="18" />
<input v-model="model" placeholder="مدل محصول" />
</div>
</label>
<label class="field">
<span>کشور سازنده</span>
<div class="control">
<UiIcon name="target" :size="18" />
<input v-model="country" placeholder="کشور سازنده" />
</div>
</label>
<label class="field">
<span>برچسب‌ها</span>
<div class="control">
<UiIcon name="star" :size="18" />
<input
v-model="tagInput"
placeholder="برچسب را وارد کنید و Enter بزنید"
@keydown="onTagKey"
/>
</div>
<div v-if="tags.length" class="tags">
<button
v-for="t in tags"
:key="t"
type="button"
class="tag"
@click="removeTag(t)"
>
{{ t }} ×
</button>
</div>
</label>
</section>
</div>
</div>
<footer class="actions">
<button class="btn primary" type="submit" :disabled="saving">
<UiIcon name="check" :size="18" />
{{ submitLabel }}
</button>
<button class="btn ghost desk-only" type="button" @click="cancel">انصراف</button>
</footer>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 1100px;
}
.page-head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.back {
border: 1px solid var(--line);
background: var(--surface);
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
transform: scaleX(-1);
}
.title-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.title-ico {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--accent-soft);
color: var(--accent);
}
h1 {
margin: 0;
font-size: 1.45rem;
}
.lead {
margin: 0.2rem 0 0;
color: var(--muted);
}
.form {
display: grid;
gap: 1rem;
}
.grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1rem;
align-items: start;
}
.side {
display: grid;
gap: 1rem;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem 1.1rem;
display: grid;
gap: 0.85rem;
}
.card h2 {
margin: 0;
font-size: 1rem;
}
.field {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
}
.field em {
color: #dc2626;
font-style: normal;
}
.control {
display: flex;
align-items: center;
gap: 0.45rem;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.55rem 0.75rem;
background: #fff;
color: var(--muted);
}
.control input,
.control select,
.control textarea {
border: 0;
outline: 0;
width: 100%;
background: transparent;
color: var(--text);
font: inherit;
}
.control.area {
align-items: flex-start;
}
.control.price {
padding-inline-end: 0.35rem;
}
.control.price :deep(input) {
border: 0;
outline: 0;
background: transparent;
width: 100%;
}
.suffix {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
padding: 0.25rem 0.45rem;
background: #f8fafc;
border-radius: 8px;
}
.row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.kind-seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
background: #f1f5f9;
border-radius: 12px;
padding: 0.3rem;
}
.kind-seg button {
border: 0;
background: transparent;
border-radius: 10px;
padding: 0.55rem 0.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
color: #475569;
font-weight: 700;
}
.kind-seg button.on {
background: var(--accent);
color: #fff;
}
.status-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
color: var(--muted);
font-size: 0.9rem;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.55rem;
cursor: pointer;
color: var(--text);
}
.switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.track {
width: 44px;
height: 26px;
border-radius: 999px;
background: #cbd5e1;
position: relative;
transition: 0.15s ease;
}
.track::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: 0.15s ease;
}
.switch input:checked + .track {
background: var(--accent);
}
.switch input:checked + .track::after {
right: 21px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.35rem;
}
.tag {
border: 0;
background: var(--accent-soft);
color: var(--accent);
border-radius: 999px;
padding: 0.25rem 0.55rem;
cursor: pointer;
font-size: 0.8rem;
}
.actions {
display: flex;
gap: 0.55rem;
justify-content: flex-start;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 12px;
padding: 0.75rem 1.15rem;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
}
.btn.primary {
background: var(--accent);
color: #fff;
min-width: 160px;
justify-content: center;
}
.btn.ghost {
background: var(--surface);
color: var(--text);
border: 1px solid var(--line);
}
.error {
color: #b91c1c;
}
.muted {
color: var(--muted);
}
.mob-only {
display: none;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
.row-2 {
grid-template-columns: 1fr;
}
.desk-only {
display: none;
}
.mob-only {
display: grid;
}
.kind-desk {
display: none;
}
.back.mob-only {
display: grid;
}
.actions .btn.primary {
width: 100%;
}
}
@media (min-width: 901px) {
.mob-only {
display: none !important;
}
}
</style>