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

932 lines
35 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import {
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
UiButton,
UiDropdownMenu,
UiModal,
UiPageHeader,
UiPagination,
UiEmptyState,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import InvoiceFormView from '@/views/InvoiceFormView.vue';
import type {
InvoiceDto,
PaginatedResponse,
PersonDto,
QuoteDto,
} from '@mento/shared';
type DocTab = 'all' | 'invoice' | 'quote' | 'credit';
type PayFilter = 'all' | 'paid' | 'unpaid' | 'pending' | 'expired';
type BillingRow = {
id: string;
entityId: string;
personId: string | null;
companyId: string | null;
doc: 'invoice' | 'quote' | 'credit';
number: string;
date: string;
dueOrValid: string | null;
customerName: string;
personName: string;
amount: number;
currency: string;
statusKey: PayFilter | 'quote';
statusLabel: string;
href: { name: string; params: { id: string } };
};
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const invoices = ref<InvoiceDto[]>([]);
const quotes = ref<QuoteDto[]>([]);
const people = ref<PersonDto[]>([]);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const docTab = ref<DocTab>(
route.query.doc === 'quote'
? 'quote'
: route.query.doc === 'credit'
? 'credit'
: route.query.doc === 'invoice'
? 'invoice'
: 'all',
);
const payFilter = ref<PayFilter>('all');
const personId = ref(
typeof route.query.personId === 'string' ? route.query.personId : '',
);
const from = ref(typeof route.query.from === 'string' ? route.query.from : '');
const to = ref(typeof route.query.to === 'string' ? route.query.to : '');
const amountMin = ref('');
const amountMax = ref('');
const page = ref(1);
const pageSize = usePageSize('invoices', 10);
const selected = ref<Set<string>>(new Set());
const menuId = ref<string | null>(null);
const filterSheet = ref(false);
const addOpen = ref(false);
const invoiceModal = ref<{ kind: 'invoice' | 'credit' | 'quote' } | null>(null);
function mapInvoiceStatus(inv: InvoiceDto): { key: PayFilter; label: string } {
if (inv.status === 'paid') return { key: 'paid', label: 'پرداخت شده' };
if (inv.status === 'draft') return { key: 'pending', label: 'در انتظار تایید' };
if (inv.status === 'canceled') return { key: 'expired', label: 'لغو شده' };
if (inv.status === 'partially_paid') return { key: 'unpaid', label: 'پرداخت جزئی' };
return { key: 'unpaid', label: 'پرداخت نشده' };
}
function mapQuoteStatus(qt: QuoteDto): { key: PayFilter | 'quote'; label: string } {
if (qt.status === 'expired') return { key: 'expired', label: 'منقضی شده' };
if (qt.status === 'sent') return { key: 'pending', label: 'در انتظار تایید' };
if (qt.status === 'accepted') return { key: 'paid', label: 'تأیید شده' };
if (qt.status === 'rejected') return { key: 'unpaid', label: 'رد شده' };
return { key: 'quote', label: 'پیش فاکتور' };
}
function customerOf(row: {
company?: { id?: string; name: string } | null;
person?: {
id?: string;
firstName?: string;
lastName?: string;
fullName?: string;
} | null;
}) {
const company = row.company?.name?.trim() || '';
const person = row.person
? (
row.person.fullName ||
`${row.person.firstName ?? ''} ${row.person.lastName ?? ''}`
).trim()
: '';
return {
customerName: company || person || 'بدون مشتری',
personName: company && person ? person : '',
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
};
}
const rows = computed<BillingRow[]>(() => {
const invRows: BillingRow[] = invoices.value.map((inv) => {
const st = mapInvoiceStatus(inv);
const cust = customerOf(inv);
const kind = inv.kind === 'credit' ? 'credit' : 'invoice';
return {
id: `inv:${inv.id}`,
entityId: inv.id,
personId: cust.personId,
companyId: cust.companyId,
doc: kind,
number: inv.number,
date: inv.issuedAt || inv.createdAt,
dueOrValid: inv.dueDate,
customerName: cust.customerName,
personName: cust.personName,
amount: inv.payableAmount ?? inv.totalAmount,
currency: inv.currency,
statusKey: st.key,
statusLabel: st.label,
href: { name: 'invoice-detail', params: { id: inv.id } },
};
});
const quoteRows: BillingRow[] = quotes.value.map((qt) => {
const st = mapQuoteStatus(qt);
const cust = customerOf(qt);
return {
id: `qt:${qt.id}`,
entityId: qt.id,
personId: cust.personId,
companyId: cust.companyId,
doc: 'quote',
number: qt.number,
date: qt.issuedAt || qt.createdAt,
dueOrValid: qt.validUntil,
customerName: cust.customerName,
personName: cust.personName,
amount: qt.payableAmount ?? qt.totalAmount,
currency: qt.currency,
statusKey: st.key,
statusLabel: st.label,
href: { name: 'quote-detail', params: { id: qt.id } },
};
});
return [...invRows, ...quoteRows].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
});
const counts = computed(() => ({
all: rows.value.length,
invoice: rows.value.filter((r) => r.doc === 'invoice').length,
quote: rows.value.filter((r) => r.doc === 'quote').length,
credit: rows.value.filter((r) => r.doc === 'credit').length,
}));
const filtered = computed(() => {
let list = rows.value;
if (docTab.value !== 'all') list = list.filter((r) => r.doc === docTab.value);
if (payFilter.value !== 'all') {
list = list.filter((r) => r.statusKey === payFilter.value);
}
if (personId.value) {
list = list.filter((r) => r.personId === personId.value);
}
if (from.value) {
const t = new Date(from.value).getTime();
list = list.filter((r) => new Date(r.date).getTime() >= t);
}
if (to.value) {
const t = new Date(to.value).getTime() + 86400000 - 1;
list = list.filter((r) => new Date(r.date).getTime() <= t);
}
const amin = Number(amountMin.value);
const amax = Number(amountMax.value);
if (Number.isFinite(amin) && amountMin.value !== '') {
list = list.filter((r) => Math.abs(r.amount) >= amin);
}
if (Number.isFinite(amax) && amountMax.value !== '') {
list = list.filter((r) => Math.abs(r.amount) <= amax);
}
if (q.value.trim()) {
list = list.filter((r) =>
matchesSearch(q.value, [r.number, r.customerName, r.personName, r.statusLabel, String(r.amount)]),
);
}
return list;
});
const pageCount = computed(() =>
Math.max(1, Math.ceil(filtered.value.length / pageSize.value)),
);
const pageItems = computed(() => {
const start = (page.value - 1) * pageSize.value;
return filtered.value.slice(start, start + pageSize.value);
});
function docLabel(doc: BillingRow['doc']) {
if (doc === 'quote') return 'پیش فاکتور';
if (doc === 'credit') return 'فاکتور برگشتی';
return 'فاکتور';
}
function statusIcon(key: BillingRow['statusKey']) {
if (key === 'paid') return 'check' as const;
if (key === 'pending') return 'clock' as const;
if (key === 'unpaid') return 'warning' as const;
if (key === 'expired') return 'minus' as const;
return 'document' as const;
}
function formatDate(iso: string | null | undefined) {
if (!iso) return '—';
try {
return formatJalaliNumeric(new Date(iso));
} catch {
return '—';
}
}
function money(n: number, currency: string) {
return formatMoney(n, currency);
}
function closeMenus() {
menuId.value = null;
addOpen.value = false;
}
function setDocTab(tab: DocTab) {
docTab.value = tab;
page.value = 1;
void router.replace({
name: 'invoices',
query: {
...route.query,
doc: tab === 'all' ? undefined : tab,
},
});
}
function clearFilters() {
payFilter.value = 'all';
personId.value = '';
from.value = '';
to.value = '';
amountMin.value = '';
amountMax.value = '';
q.value = '';
page.value = 1;
filterSheet.value = false;
}
function applyFilters() {
page.value = 1;
filterSheet.value = false;
}
function toggleSelectAll() {
const next = new Set(selected.value);
if (pageItems.value.every((r) => next.has(r.id))) {
for (const r of pageItems.value) next.delete(r.id);
} else {
for (const r of pageItems.value) next.add(r.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;
}
async function load() {
loading.value = true;
error.value = '';
closeMenus();
try {
const [invRes, quoteRes, peopleRes] = await Promise.all([
api.get<PaginatedResponse<InvoiceDto>>('/invoices', { params: { limit: 100 } }),
api.get<PaginatedResponse<QuoteDto>>('/quotes', { params: { limit: 100 } }),
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
]);
invoices.value = invRes.data.items;
quotes.value = quoteRes.data.items;
people.value = peopleRes.data.items;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری اسناد ناموفق بود.',
)
: 'بارگذاری اسناد ناموفق بود.';
} finally {
loading.value = false;
}
}
watch([docTab, pageSize], () => {
page.value = 1;
});
watch(
() => route.query.doc,
(doc) => {
if (doc === 'quote' || doc === 'credit' || doc === 'invoice') docTab.value = doc;
else if (!doc) docTab.value = 'all';
},
);
onMounted(load);
</script>
<template>
<section class="page" @click="closeMenus">
<UiPageHeader
title="فاکتور ها"
lead="مدیریت فاکتورهای فروش، پیش فاکتور و خدمات"
>
<template #actions>
<div class="head-actions" @click.stop>
<div class="add-split desk-only">
<UiButton type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">
<UiIcon name="plus" :size="16" />
افزودن فاکتور
</UiButton>
<button type="button" class="btn primary split" aria-label="گزینه‌های افزودن" @click="addOpen = !addOpen">
<UiIcon name="chevron" :size="14" />
</button>
<div v-if="addOpen" class="menu">
<button type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">فاکتور جدید</button>
<button type="button" @click="invoiceModal = { kind: 'quote' }; addOpen = false">پیش فاکتور جدید</button>
<button type="button" @click="invoiceModal = { kind: 'credit' }; addOpen = false">فاکتور برگشتی</button>
</div>
</div>
<UiButton class="mob-only" type="button" @click="invoiceModal = { kind: 'invoice' }">
<UiIcon name="plus" :size="16" />
افزودن
</UiButton>
</div>
</template>
</UiPageHeader>
<!-- Desktop underline tabs -->
<div class="tabs desk-only">
<button type="button" :class="{ on: docTab === 'all' }" @click="setDocTab('all')">
همه
<em>{{ toFaDigits(counts.all) }}</em>
</button>
<button type="button" :class="{ on: docTab === 'invoice' }" @click="setDocTab('invoice')">
فاکتور
<em>{{ toFaDigits(counts.invoice) }}</em>
</button>
<button type="button" :class="{ on: docTab === 'quote' }" @click="setDocTab('quote')">
پیش فاکتور
<em>{{ toFaDigits(counts.quote) }}</em>
</button>
<button type="button" :class="{ on: docTab === 'credit' }" @click="setDocTab('credit')">
فاکتور برگشتی
<em>{{ toFaDigits(counts.credit) }}</em>
</button>
</div>
<div class="toolbar">
<label class="search">
<UiIcon name="search" :size="18" />
<input
v-model="q"
type="search"
class="desk-ph"
placeholder="جستجو در شماره، مشتری، شرح ..."
@input="page = 1"
/>
<input
v-model="q"
type="search"
class="mob-ph"
placeholder="جستجو در شماره، مشتری، مبلغ ..."
@input="page = 1"
/>
</label>
<div class="filters desk-only">
<div class="filter-chip">
<UiIcon name="calendar" :size="16" />
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
<span class="sep">–</span>
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
</div>
<label class="filter-chip">
<UiIcon name="flag" :size="16" />
<select v-model="payFilter" @change="page = 1">
<option value="all">وضعیت</option>
<option value="paid">پرداخت شده</option>
<option value="unpaid">پرداخت نشده</option>
<option value="pending">در انتظار تایید</option>
<option value="expired">منقضی شده</option>
</select>
</label>
<label class="filter-chip">
<UiIcon name="user" :size="16" />
<select v-model="personId" @change="page = 1">
<option value="">مشتری</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName || `${p.firstName} ${p.lastName}` }}
</option>
</select>
</label>
</div>
<button type="button" class="ghost-btn more-filters" @click.stop="filterSheet = true">
<UiIcon name="filter" :size="16" />
<span class="desk-only">فیلترهای بیشتر</span>
</button>
</div>
<!-- Mobile pill tabs -->
<div class="pill-tabs mob-only">
<button type="button" :class="{ on: docTab === 'all' }" @click="setDocTab('all')">
همه {{ toFaDigits(counts.all) }}
</button>
<button type="button" :class="{ on: docTab === 'invoice' }" @click="setDocTab('invoice')">
فاکتور {{ toFaDigits(counts.invoice) }}
</button>
<button type="button" :class="{ on: docTab === 'quote' }" @click="setDocTab('quote')">
پیش فاکتور {{ toFaDigits(counts.quote) }}
</button>
<button type="button" :class="{ on: docTab === 'credit' }" @click="setDocTab('credit')">
فاکتور برگشتی {{ toFaDigits(counts.credit) }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<div class="tc-row desk">
<TableColumnsMenu table-key="invoices" align="end" />
</div>
<div class="table-wrap desk">
<table v-table-cols="'invoices'">
<thead>
<tr>
<th class="check">
<input
type="checkbox"
:checked="pageItems.length > 0 && pageItems.every((r) => selected.has(r.id))"
@change="toggleSelectAll"
/>
</th>
<th class="num-col">#</th>
<th>شماره فاکتور</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="(r, idx) in pageItems" :key="r.id">
<td class="check">
<input
type="checkbox"
:checked="selected.has(r.id)"
@change="toggleSelect(r.id)"
/>
</td>
<td class="num-col">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</td>
<td class="mono" dir="ltr">{{ r.number }}</td>
<td>{{ formatDate(r.date) }}</td>
<td>
<div class="cust">
<span class="avatar">{{ r.customerName.slice(0, 1) }}</span>
<div>
<strong>{{ r.customerName }}</strong>
<small v-if="r.personName">{{ r.personName }}</small>
</div>
</div>
</td>
<td><span class="type" :class="r.doc">{{ docLabel(r.doc) }}</span></td>
<td>
<span class="status" :class="r.statusKey">
<UiIcon :name="statusIcon(r.statusKey)" :size="13" />
{{ r.statusLabel }}
</span>
</td>
<td class="amount" :class="{ neg: r.amount < 0 }" dir="ltr">
{{ money(r.amount, r.currency) }}
</td>
<td>{{ r.currency }}</td>
<td :class="['due', r.statusKey]">{{ formatDate(r.dueOrValid) }}</td>
<td class="ops" @click.stop>
<span class="grip" aria-hidden="true"><UiIcon name="grip" :size="14" /></span>
<RouterLink class="icon-btn sm" :to="r.href" title="مشاهده">
<UiIcon name="document" :size="15" />
</RouterLink>
<RouterLink class="icon-btn sm" :to="r.href" title="ویرایش">
<UiIcon name="pencil" :size="15" />
</RouterLink>
<UiDropdownMenu
:open="menuId === r.id"
align="end"
@update:open="(v) => (menuId = v ? r.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm" @click.stop="toggle">
<UiIcon name="dots-v" :size="15" />
</button>
</template>
<RouterLink :to="r.href">باز کردن</RouterLink>
</UiDropdownMenu>
</td>
</tr>
</tbody>
</table>
<p v-if="!pageItems.length" class="empty">سندی یافت نشد.</p>
</div>
<div class="mob-list">
<article v-for="r in pageItems" :key="r.id" class="mob-card" @click.stop>
<div class="mob-card-top">
<RouterLink class="mob-info" :to="r.href">
<strong>{{ r.customerName }}</strong>
<span class="mono" dir="ltr">{{ r.number }}</span>
<span class="muted">{{ formatDate(r.date) }}</span>
</RouterLink>
<span class="status" :class="r.statusKey">
<UiIcon :name="statusIcon(r.statusKey)" :size="12" />
{{ r.statusLabel }}
</span>
<div class="mob-aside">
<UiDropdownMenu
:open="menuId === r.id"
align="end"
@update:open="(v) => (menuId = v ? r.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm ghost" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="r.href">باز کردن</RouterLink>
</UiDropdownMenu>
<span class="avatar">{{ r.customerName.slice(0, 1) }}</span>
</div>
</div>
<div class="mob-card-bottom">
<em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em>
</div>
</article>
<UiEmptyState
v-if="!pageItems.length"
title="سندی یافت نشد."
description="فاکتور، پیش‌فاکتور یا سند جدیدی ثبت کنید."
/>
</div>
<UiPagination
v-if="filtered.length"
:page="page"
:total-pages="pageCount"
:total-items="filtered.length"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="page = $event"
@update:page-size="pageSize = $event"
/>
</template>
<UiModal
:open="Boolean(invoiceModal)"
:title="
invoiceModal?.kind === 'quote'
? 'پیش فاکتور جدید'
: invoiceModal?.kind === 'credit'
? 'فاکتور برگشتی'
: 'فاکتور جدید'
"
@close="invoiceModal = null"
>
<InvoiceFormView
v-if="invoiceModal"
embedded
:initial-kind="invoiceModal.kind"
@saved="invoiceModal = null; void load()"
@cancel="invoiceModal = null"
/>
</UiModal>
<div v-if="filterSheet" class="sheet-backdrop" @click="filterSheet = false">
<div class="sheet" @click.stop>
<header>
<button type="button" class="icon-btn" @click="filterSheet = false">
<UiIcon name="x" :size="18" />
</button>
<button type="button" class="link" @click="clearFilters">حذف همه</button>
<strong>فیلترها</strong>
</header>
<div class="sheet-body">
<p class="lbl">نوع سند</p>
<div class="pill-row">
<button type="button" :class="{ on: docTab === 'all' }" @click="docTab = 'all'">همه</button>
<button type="button" :class="{ on: docTab === 'invoice' }" @click="docTab = 'invoice'">فاکتور</button>
<button type="button" :class="{ on: docTab === 'quote' }" @click="docTab = 'quote'">پیش فاکتور</button>
<button type="button" :class="{ on: docTab === 'credit' }" @click="docTab = 'credit'">فاکتور برگشتی</button>
</div>
<p class="lbl">وضعیت پرداخت</p>
<div class="pill-row">
<button type="button" :class="{ on: payFilter === 'all' }" @click="payFilter = 'all'">همه</button>
<button type="button" :class="{ on: payFilter === 'paid' }" @click="payFilter = 'paid'">پرداخت شده</button>
<button type="button" :class="{ on: payFilter === 'unpaid' }" @click="payFilter = 'unpaid'">پرداخت نشده</button>
<button type="button" :class="{ on: payFilter === 'pending' }" @click="payFilter = 'pending'">در انتظار تایید</button>
<button type="button" :class="{ on: payFilter === 'expired' }" @click="payFilter = 'expired'">منقضی شده</button>
</div>
<label class="field">
<span>مشتری</span>
<div class="control">
<UiIcon name="user" :size="16" />
<select v-model="personId">
<option value="">جستجو و انتخاب مشتری ...</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName || `${p.firstName} ${p.lastName}` }}
</option>
</select>
</div>
</label>
<p class="lbl">بازه تاریخ</p>
<div class="row-2">
<label class="field"><span>از</span><JalaliDateInput v-model="from" /></label>
<label class="field"><span>تا</span><JalaliDateInput v-model="to" /></label>
</div>
<p class="lbl">بازه مبلغ (ریال)</p>
<div class="row-2">
<label class="field"><span>حداقل مبلغ</span><input v-model="amountMin" type="number" min="0" dir="ltr" placeholder="حداقل مبلغ" /></label>
<label class="field"><span>حداکثر مبلغ</span><input v-model="amountMax" type="number" min="0" dir="ltr" placeholder="حداکثر مبلغ" /></label>
</div>
</div>
<footer>
<button type="button" class="btn ghost" @click="clearFilters">پاک کردن</button>
<button type="button" class="btn primary" @click="applyFilters">اعمال فیلتر</button>
</footer>
</div>
</div>
</section>
</template>
<style scoped>
.page {
--teal: #0d9488;
--teal-soft: #f0fdfa;
display: grid;
gap: 0.9rem;
position: relative;
width: 100%;
max-width: 100%;
}
.page-head {
display: flex;
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: var(--radius); display: grid; place-items: center;
background: var(--teal-soft); color: var(--teal);
}
h1 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.lead { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.9rem; }
.head-actions { position: relative; }
.add-split { display: flex; position: relative; }
.btn {
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.95rem; font-weight: 700; box-sizing: border-box;
display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; text-decoration: none;
}
.btn.primary { background: var(--teal); color: #fff; }
.btn.split {
border-radius: var(--radius) 0 0 var(--radius); padding-inline: 0.55rem;
border-inline-start: 1px solid rgba(255,255,255,.25);
}
.add-split .btn.primary:first-child { border-radius: 0 var(--radius) var(--radius) 0; }
.btn.ghost, .ghost-btn, .icon-btn {
border: 1px solid var(--line); background: var(--surface); color: var(--text);
border-radius: var(--radius); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;
}
.ghost-btn { min-height: var(--control-h); height: var(--control-h); padding: 0 0.8rem; font-weight: 600; box-sizing: border-box; }
.icon-btn { width: var(--control-h); height: var(--control-h); position: relative; }
.icon-btn.sm { width: 32px; height: 32px; }
.icon-btn.ghost { border: 0; background: transparent; color: #94a3b8; }
.add-doc { color: var(--teal); }
.plus-badge {
position: absolute; bottom: 6px; left: 6px;
background: var(--teal); color: #fff; border-radius: var(--radius); padding: 1px;
}
.menu {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 170px;
background: #fff; border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: 0 12px 28px rgba(15,23,42,.12); padding: 0.35rem; display: grid;
}
.menu.left { left: auto; right: 0; }
.menu a,
.menu button {
display: block; width: 100%;
border: 0; background: transparent; text-align: right; padding: 0.55rem 0.7rem;
border-radius: var(--radius); color: var(--text); text-decoration: none; cursor: pointer;
}
.menu a:hover,
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
.tabs {
display: flex; gap: 0.35rem; overflow-x: auto; border-bottom: 1px solid var(--line);
}
.tabs button {
border: 0; background: transparent; padding: 0.65rem 0.85rem; cursor: pointer;
color: #64748b; font-weight: 700; display: inline-flex; gap: 0.4rem; align-items: center;
border-bottom: 2px solid transparent; white-space: nowrap;
}
.tabs button.on { color: var(--teal); border-bottom-color: var(--teal); }
.tabs em {
font-style: normal; font-size: 0.75rem; background: #f1f5f9; color: #64748b;
border-radius: 999px; padding: 0.1rem 0.45rem;
}
.tabs button.on em { background: var(--teal-soft); color: var(--teal); }
.pill-tabs {
display: none; gap: 0.45rem; overflow-x: auto; padding-bottom: 0.15rem;
}
.pill-tabs button {
flex: 0 0 auto; border: 0; border-radius: 999px; padding: 0.45rem 0.85rem;
background: #e0f2fe; color: #0369a1; font-weight: 700; cursor: pointer; white-space: nowrap;
}
.pill-tabs button.on { background: var(--teal); color: #fff; }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.search {
flex: 1 1 220px; max-width: 380px; display: flex; align-items: center; gap: 0.45rem;
border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.75rem; background: #fff; color: var(--muted); box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input { border: 0; outline: 0; width: 100%; background: transparent; color: var(--ink); font-size: var(--fs-sm); padding: 0; }
.search .mob-ph { display: none; }
.filters { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
.filter-chip {
display: inline-flex; align-items: center; gap: 0.3rem; flex-wrap: nowrap;
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.55rem;
background: #fff; color: var(--muted); min-height: var(--control-h); height: var(--control-h);
box-sizing: border-box; white-space: nowrap; font-size: var(--fs-sm);
transition: border-color var(--transition-fast);
}
.filter-chip:focus-within {
border-color: var(--accent);
}
.filter-chip select, .filter-chip :deep(button), .filter-chip :deep(input) {
border: 0; background: transparent; outline: 0; min-height: 0; height: 32px; color: var(--ink); font-size: var(--fs-sm);
}
.filter-chip :deep(.wrap) { width: auto; flex: 0 1 auto; }
.filter-chip :deep(.trigger) {
width: auto; min-width: 5.4rem; height: 32px; min-height: 32px;
padding: 0 0.2rem; border: 0; box-shadow: none;
}
.filter-chip :deep(.trigger span) { white-space: nowrap; }
.filter-chip :deep(.trigger .ico) { display: none; }
.filter-chip .sep { color: #cbd5e1; flex-shrink: 0; }
.table-wrap {
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); overflow: auto;
box-shadow: 0 8px 24px rgba(15,23,42,.04);
}
table { width: 100%; border-collapse: collapse; min-width: 980px; }
th, td { padding: 0.85rem 0.7rem; border-bottom: 1px solid #eef2f7; text-align: right; font-size: 0.9rem; vertical-align: middle; }
th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap; }
tbody tr:nth-child(even) { background: #fcfdff; }
.check { width: 42px; }
.mono { font-family: ui-monospace, monospace; direction: ltr; text-align: left; }
.cust { display: flex; gap: 0.55rem; align-items: center; }
.avatar {
width: 36px; height: 36px; border-radius: 999px; background: var(--teal-soft); color: var(--teal);
display: grid; place-items: center; font-weight: 600; flex-shrink: 0;
}
.cust strong { display: block; }
.cust small { color: var(--muted); }
.type {
display: inline-flex; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700;
}
.type.invoice { background: #ccfbf1; color: #0f766e; }
.type.quote { background: #e0f2fe; color: #0369a1; }
.type.credit { background: #fee2e2; color: #b91c1c; }
.status {
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.22rem 0.55rem;
border-radius: 999px; font-size: 0.78rem; font-weight: 700; white-space: nowrap;
}
.status.paid { background: #dcfce7; color: #15803d; }
.status.unpaid { background: #fee2e2; color: #b91c1c; }
.status.pending { background: #fef3c7; color: #b45309; }
.status.expired { background: #f1f5f9; color: #64748b; }
.status.quote { background: #e0f2fe; color: #0369a1; }
.amount { font-weight: 700; }
.amount.neg, .neg { color: #b91c1c; }
.due.paid { color: #15803d; }
.due.pending { color: #b45309; }
.due.unpaid { color: #b91c1c; }
.ops { position: relative; white-space: nowrap; width: 140px; }
.grip { display: inline-flex; color: #cbd5e1; margin-inline-end: 0.15rem; vertical-align: middle; }
.mob-list { display: none; gap: 0.65rem; }
.mob-card {
position: relative;
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.9rem;
box-shadow: 0 6px 16px rgba(15,23,42,.04);
display: grid; gap: 0.65rem;
}
.mob-card-top {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.55rem;
align-items: start;
}
.mob-info { text-decoration: none; color: inherit; display: grid; gap: 0.12rem; min-width: 0; }
.mob-info .mono, .mob-info .muted { font-size: 0.8rem; color: var(--muted); }
.mob-aside { display: grid; justify-items: center; gap: 0.35rem; }
.mob-card-bottom { display: flex; justify-content: flex-start; }
.mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
.pager {
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center;
}
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
.page-size {
display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.88rem;
}
.page-size select {
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.3rem 0.5rem; background: #fff;
}
.pages { display: flex; gap: 0.3rem; }
.page-btn {
min-width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid var(--line);
background: #fff; cursor: pointer;
}
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
.fab {
display: none; position: fixed; left: 1rem; right: 1rem; bottom: calc(1rem + var(--mobile-tabbar-h)); z-index: 40;
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
box-shadow: 0 12px 28px rgba(13,148,136,.35);
}
.sheet-backdrop {
position: fixed; inset: 0; background: rgba(15,23,42,.45); z-index: 50;
display: grid; align-items: end;
}
.sheet {
background: #fff; border-radius: var(--radius, 10px) var(--radius, 10px) 0 0; padding: 16px; max-height: 85vh; overflow: auto;
}
.sheet header {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
}
.sheet header strong { justify-self: end; font-size: 16px; font-weight: 500; color: var(--ink); }
.link { border: 0; background: transparent; color: var(--teal); font-weight: 700; cursor: pointer; }
.lbl { margin: 0.85rem 0 0.4rem; font-weight: 700; color: #334155; }
.pill-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.pill-row button {
border: 1px solid var(--line); background: #f8fafc; border-radius: 999px; padding: 0.4rem 0.7rem;
cursor: pointer; font-weight: 600;
}
.pill-row button.on { background: var(--teal); color: #fff; border-color: transparent; }
.field { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; }
.field input, .field select, .control select {
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.55rem 0.7rem; background: #fff; color: var(--text);
}
.control { position: relative; display: flex; align-items: center; }
.control :deep(.ico) {
position: absolute; inset-inline-start: 0.7rem; color: #94a3b8; pointer-events: none;
}
.control select { width: 100%; padding-inline-start: 2.2rem; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; }
.sheet footer { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; margin-top: 1rem; }
.empty, .muted, .error { font-size: 0.9rem; }
.error { color: #b91c1c; }
.muted, .empty { color: var(--muted); }
.mob-only { display: none; }
@media (max-width: 900px) {
.desk, .desk-only { display: none !important; }
.mob-list, .mob-only, .fab, .pill-tabs { display: flex; }
.mob-list { display: grid; }
.search .desk-ph { display: none; }
.search .mob-ph { display: block; }
.toolbar { flex-wrap: nowrap; }
.more-filters {
width: var(--control-h); height: var(--control-h); padding: 0; flex-shrink: 0; border-radius: var(--radius);
}
.page { padding-bottom: 6rem; }
}
</style>