825 lines
21 KiB
Vue
825 lines
21 KiB
Vue
<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>
|