Files
crm/apps/web/src/views/ProductsSettingsView.vue
T

825 lines
21 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, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney } from '@/lib/money';
import UiButton from '@/components/ui/UiButton.vue';
import UiModal from '@/components/ui/UiModal.vue';
import UiIcon from '@/components/UiIcon.vue';
import ProductFormView from '@/views/ProductFormView.vue';
import type { ProductDto } from '@mento/shared';
type SortKey = 'name' | 'newest' | 'price';
type StatusFilter = 'all' | 'active' | 'inactive';
const PAGE_SIZE = 10;
const router = useRouter();
const loading = ref(false);
const error = ref('');
const products = ref<ProductDto[]>([]);
const q = ref('');
const category = ref('');
const statusFilter = ref<StatusFilter>('all');
const sort = ref<SortKey>('name');
const page = ref(1);
const selected = ref<Set<string>>(new Set());
const menuId = ref<string | null>(null);
const sortOpen = ref(false);
const moreOpen = ref(false);
const productModal = ref(false);
const categories = computed(() => {
const set = new Set<string>();
for (const p of products.value) {
if (p.category?.trim()) set.add(p.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b, 'fa'));
});
const filtered = computed(() => {
let list = [...products.value];
const query = q.value.trim().toLowerCase();
if (query) {
list = list.filter((p) => {
const hay = [p.name, p.sku, p.description, p.category]
.filter(Boolean)
.join(' ')
.toLowerCase();
return hay.includes(query);
});
}
if (category.value) {
list = list.filter((p) => p.category === category.value);
}
if (statusFilter.value === 'active') list = list.filter((p) => p.isActive);
if (statusFilter.value === 'inactive') list = list.filter((p) => !p.isActive);
list.sort((a, b) => {
if (sort.value === 'newest') {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
if (sort.value === 'price') {
return (b.unitPrice ?? -1) - (a.unitPrice ?? -1);
}
return a.name.localeCompare(b.name, 'fa');
});
return list;
});
const pageCount = computed(() =>
Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)),
);
const pageItems = computed(() => {
const start = (page.value - 1) * PAGE_SIZE;
return filtered.value.slice(start, start + PAGE_SIZE);
});
const pageFrom = computed(() =>
filtered.value.length ? (page.value - 1) * PAGE_SIZE + 1 : 0,
);
const pageTo = computed(() =>
Math.min(page.value * PAGE_SIZE, filtered.value.length),
);
const allPageSelected = computed(
() =>
pageItems.value.length > 0 &&
pageItems.value.every((p) => selected.value.has(p.id)),
);
watch([q, category, statusFilter, sort], () => {
page.value = 1;
closeMenus();
});
function closeMenus() {
menuId.value = null;
sortOpen.value = false;
moreOpen.value = false;
}
function toggleSelectAll() {
const next = new Set(selected.value);
if (allPageSelected.value) {
for (const p of pageItems.value) next.delete(p.id);
} else {
for (const p of pageItems.value) next.add(p.id);
}
selected.value = next;
}
function toggleSelect(id: string) {
const next = new Set(selected.value);
if (next.has(id)) next.delete(id);
else next.add(id);
selected.value = next;
}
function formatPrice(value: number | null, currency: string) {
if (value == null) return '—';
return formatMoney(value, currency);
}
function formatDate(iso: string) {
try {
return formatJalaliNumeric(new Date(iso));
} catch {
return '—';
}
}
function formatTime(iso: string) {
try {
return formatClockFa(new Date(iso));
} catch {
return '';
}
}
function currencyLabel(code: string) {
if (code === 'IRR' || code === 'IRT') return 'تومان';
return code;
}
async function load() {
loading.value = true;
error.value = '';
closeMenus();
try {
const { data } = await api.get<ProductDto[]>('/products');
products.value = data;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری محصولات ناموفق بود.',
)
: 'بارگذاری محصولات ناموفق بود.';
} finally {
loading.value = false;
}
}
async function toggleActive(product: ProductDto) {
closeMenus();
await api.patch(`/products/${product.id}`, { isActive: !product.isActive });
await load();
}
async function removeProduct(product: ProductDto) {
closeMenus();
if (!confirm(`محصول «${product.name}» حذف شود؟`)) return;
await api.delete(`/products/${product.id}`);
selected.value.delete(product.id);
await load();
}
async function removeSelected() {
closeMenus();
const ids = Array.from(selected.value);
if (!ids.length) return;
if (!confirm(`${toFaDigits(ids.length)} محصول انتخاب‌شده حذف شوند؟`)) return;
await Promise.all(ids.map((id) => api.delete(`/products/${id}`)));
selected.value = new Set();
await load();
}
function goEdit(id: string) {
closeMenus();
void router.push({ name: 'product-edit', params: { id } });
}
onMounted(load);
</script>
<template>
<section class="page" @click="closeMenus">
<header class="page-head">
<div class="head-copy">
<div class="title-row">
<span class="title-ico" aria-hidden="true">
<UiIcon name="box" :size="22" />
</span>
<div>
<h1>محصولات</h1>
<p class="lead">مدیریت محصولات، قیمت‌ها و موجودی</p>
</div>
</div>
</div>
<div class="head-actions" @click.stop>
<UiButton type="button" @click="productModal = true">
<UiIcon name="plus" :size="16" />
محصول جدید
</UiButton>
<div class="more-wrap">
<button
type="button"
class="icon-btn"
aria-label="عملیات بیشتر"
@click="moreOpen = !moreOpen"
>
<UiIcon name="dots" :size="18" />
</button>
<div v-if="moreOpen" class="menu">
<button type="button" :disabled="!selected.size" @click="removeSelected">
حذف انتخاب‌شده‌ها
</button>
<button type="button" @click="load">بروزرسانی لیست</button>
</div>
</div>
</div>
</header>
<div class="toolbar" @click.stop>
<label class="search">
<UiIcon name="search" :size="18" />
<input
v-model="q"
type="search"
placeholder="جستجو در نام، کد یا توضیحات..."
/>
</label>
<select v-model="category" class="filter-select">
<option value="">همه دسته‌بندی‌ها</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="statusFilter" class="filter-select">
<option value="all">همه وضعیت‌ها</option>
<option value="active">فعال</option>
<option value="inactive">غیرفعال</option>
</select>
<div class="sort-wrap">
<button type="button" class="ghost-btn" @click="sortOpen = !sortOpen">
<UiIcon name="filter" :size="16" />
مرتب‌سازی
</button>
<div v-if="sortOpen" class="menu">
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'; sortOpen = false">
نام
</button>
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'; sortOpen = false">
جدیدترین
</button>
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'; sortOpen = false">
قیمت
</button>
</div>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<!-- Desktop table -->
<div class="table-wrap desk">
<table>
<thead>
<tr>
<th class="check">
<input
type="checkbox"
:checked="allPageSelected"
@change="toggleSelectAll"
/>
</th>
<th>نام محصول</th>
<th>کد</th>
<th>دسته‌بندی</th>
<th>قیمت</th>
<th>موجودی</th>
<th>وضعیت</th>
<th>تاریخ ایجاد</th>
<th class="ops">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="p in pageItems" :key="p.id">
<td class="check">
<input
type="checkbox"
:checked="selected.has(p.id)"
@change="toggleSelect(p.id)"
/>
</td>
<td>
<div class="name-cell">
<span class="thumb" :class="p.kind">
<UiIcon :name="p.kind === 'service' ? 'briefcase' : 'box'" :size="20" />
</span>
<div>
<strong>{{ p.name }}</strong>
<span v-if="p.description" class="desc">{{ p.description }}</span>
</div>
</div>
</td>
<td class="sku" dir="ltr">{{ p.sku || '—' }}</td>
<td>
<span v-if="p.category" class="pill">{{ p.category }}</span>
<span v-else class="muted">—</span>
</td>
<td class="price">
<strong>{{ formatPrice(p.unitPrice, p.currency) }}</strong>
<small>{{ currencyLabel(p.currency) }}</small>
</td>
<td class="stock">{{ p.stockQty != null ? toFaDigits(p.stockQty) : '—' }}</td>
<td>
<span class="status" :class="p.isActive ? 'on' : 'off'">
<i />
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</td>
<td class="when">
<span>{{ formatDate(p.createdAt) }}</span>
<small>{{ formatTime(p.createdAt) }}</small>
</td>
<td class="ops" @click.stop>
<div class="row-more">
<button
type="button"
class="icon-btn"
aria-label="عملیات"
@click="menuId = menuId === p.id ? null : p.id"
>
<UiIcon name="dots" :size="16" />
</button>
<div v-if="menuId === p.id" class="menu">
<button type="button" @click="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<p v-if="!pageItems.length" class="empty">محصولی یافت نشد.</p>
</div>
<!-- Mobile cards -->
<div class="mob-list">
<article v-for="p in pageItems" :key="p.id" class="mob-card" @click.stop>
<span class="thumb" :class="p.kind">
<UiIcon :name="p.kind === 'service' ? 'briefcase' : 'box'" :size="22" />
</span>
<div class="mob-body">
<strong>{{ p.name }}</strong>
<span class="sku" dir="ltr">{{ p.sku || '—' }}</span>
<span v-if="p.category" class="pill soft">{{ p.category }}</span>
<span class="mob-price">
{{ formatPrice(p.unitPrice, p.currency) }}
{{ currencyLabel(p.currency) }}
</span>
</div>
<div class="mob-side">
<div class="row-more">
<button
type="button"
class="icon-btn"
aria-label="عملیات"
@click="menuId = menuId === p.id ? null : p.id"
>
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuId === p.id" class="menu left">
<button type="button" @click="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</div>
</div>
<span class="status" :class="p.isActive ? 'on' : 'off'">
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
</article>
<p v-if="!pageItems.length" class="empty">محصولی یافت نشد.</p>
</div>
<footer v-if="filtered.length" class="pager">
<p class="muted">
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
{{ toFaDigits(filtered.length) }} مورد
</p>
<div class="pages">
<button
type="button"
class="page-btn"
:disabled="page <= 1"
@click="page -= 1"
>
‹
</button>
<button
v-for="n in pageCount"
:key="n"
type="button"
class="page-btn"
:class="{ on: page === n }"
@click="page = n"
>
{{ toFaDigits(n) }}
</button>
<button
type="button"
class="page-btn"
:disabled="page >= pageCount"
@click="page += 1"
>
›
</button>
</div>
</footer>
</template>
<UiModal :open="productModal" title="محصول جدید" @close="productModal = false">
<ProductFormView
v-if="productModal"
embedded
@saved="productModal = false; void load()"
@cancel="productModal = false"
/>
</UiModal>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
position: relative;
padding-bottom: 4.5rem;
}
.page-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.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);
flex-shrink: 0;
}
h1 {
margin: 0;
font-size: 1.55rem;
}
.lead {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.head-actions {
display: flex;
gap: 0.45rem;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 0;
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.95rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
}
.btn.primary {
background: var(--accent);
color: #fff;
}
.icon-btn,
.ghost-btn {
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
}
.icon-btn {
width: var(--control-h);
height: var(--control-h);
}
.ghost-btn {
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.85rem;
font-weight: 600;
box-sizing: border-box;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.search {
flex: 1 1 220px;
display: flex;
align-items: center;
gap: 0.45rem;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.75rem;
color: var(--muted);
box-sizing: border-box;
}
.search input {
border: 0;
outline: 0;
background: transparent;
width: 100%;
color: var(--text);
}
.filter-select {
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.75rem;
box-sizing: border-box;
color: var(--text);
min-width: 140px;
}
.more-wrap,
.sort-wrap,
.row-more {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 20;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
padding: 0.35rem;
display: grid;
}
.menu.left {
left: auto;
right: 0;
}
.menu button {
border: 0;
background: transparent;
text-align: right;
padding: 0.55rem 0.7rem;
border-radius: 8px;
cursor: pointer;
color: var(--text);
}
.menu button:hover,
.menu button.on {
background: var(--accent-soft);
color: var(--accent);
}
.menu button.danger {
color: #b91c1c;
}
.table-wrap {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 900px;
}
th,
td {
padding: 0.85rem 0.75rem;
border-bottom: 1px solid var(--line);
text-align: right;
vertical-align: middle;
font-size: 0.92rem;
}
th {
color: var(--muted);
font-weight: 600;
background: #f8fafc;
white-space: nowrap;
}
tr:last-child td {
border-bottom: 0;
}
.check {
width: 42px;
}
.name-cell {
display: flex;
gap: 0.7rem;
align-items: flex-start;
}
.thumb {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--accent-soft);
color: var(--accent);
flex-shrink: 0;
}
.thumb.service {
background: #e0f2fe;
color: #0369a1;
}
.name-cell strong,
.mob-body strong {
display: block;
}
.desc {
display: block;
margin-top: 0.15rem;
color: var(--muted);
font-size: 0.82rem;
}
.sku {
font-family: ui-monospace, monospace;
color: #475569;
direction: ltr;
text-align: left;
}
.pill {
display: inline-flex;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #f1f5f9;
color: #334155;
font-size: 0.8rem;
font-weight: 600;
}
.pill.soft {
background: var(--accent-soft);
color: var(--accent);
margin-top: 0.25rem;
}
.price,
.when {
display: grid;
gap: 0.1rem;
}
.price small,
.when small {
color: var(--muted);
font-size: 0.78rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.22rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
}
.status i {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status.on {
background: #ccfbf1;
color: #0f766e;
}
.status.off {
background: #fee2e2;
color: #b91c1c;
}
.ops {
width: 64px;
}
.mob-list {
display: none;
gap: 0.65rem;
}
.mob-card {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: start;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.85rem;
}
.mob-body {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.mob-price {
margin-top: 0.25rem;
font-weight: 700;
color: #0f172a;
}
.mob-side {
display: grid;
justify-items: end;
gap: 0.45rem;
}
.pager {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.pages {
display: flex;
gap: 0.35rem;
align-items: center;
}
.page-btn {
min-width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
cursor: pointer;
}
.page-btn.on {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.empty,
.muted {
color: var(--muted);
}
.error {
color: #b91c1c;
}
.fab {
display: none;
position: fixed;
left: 1.1rem;
bottom: 1.1rem;
width: 56px;
height: 56px;
border-radius: 999px;
background: var(--accent);
color: #fff;
place-items: center;
text-decoration: none;
box-shadow: 0 10px 24px rgba(13, 148, 136, 0.35);
z-index: 30;
}
@media (max-width: 900px) {
.desk {
display: none;
}
.mob-list {
display: grid;
}
.fab {
display: grid;
}
.head-actions .btn.primary {
display: none;
}
.toolbar .filter-select:nth-of-type(2) {
display: none;
}
}
</style>