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

934 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 { useEscClose } from '@/lib/modal-stack';
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);
useEscClose(() => filterSheet.value, () => { filterSheet.value = 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">
<div class="sheet" role="dialog" aria-modal="true" aria-label="فیلترها">
<header>
<button type="button" class="icon-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @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>