Unify invoices and quotes UI with discount, tax, and credit notes.

Match list/form mockups on desktop and mobile, wire real APIs for billing fields, and merge proforma into the invoices section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 17:53:50 +03:30
co-authored by Cursor
parent 7862b6f374
commit 2ece79b3d6
15 changed files with 3101 additions and 1995 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -507
View File
@@ -1,511 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import type {
CompanyDto,
CreateQuoteRequest,
DealLineItemInput,
OrganizationDto,
PaginatedResponse,
PersonDto,
ProductDto,
QuoteDto,
QuoteStatus,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
const route = useRoute();
const router = useRouter();
const id = computed(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(() => route.name === 'quote-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const number = ref('');
const title = ref('پیش‌فاکتور');
const status = ref<QuoteStatus>('draft');
const notes = ref('');
const validUntil = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const companyId = ref('');
const dealId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const lines = ref<LineDraft[]>([]);
const statusLabel: Record<QuoteStatus, string> = {
draft: 'پیش‌نویس',
sent: 'ارسال‌شده',
accepted: 'تأییدشده',
rejected: 'ردشده',
expired: 'منقضی',
};
const totalAmount = computed(() =>
toLineInputs().reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
);
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function addLine() {
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
async function load() {
loading.value = true;
error.value = '';
try {
const fromDealId =
typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: typeof route.query.dealId === 'string'
? route.query.dealId
: '';
const [peopleRes, companiesRes, productsRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PaginatedResponse<CompanyDto>>('/companies', { params: { limit: 100 } }),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
if (!isNew.value && id.value) {
const { data } = await api.get<QuoteDto>(`/quotes/${id.value}`);
number.value = data.number;
title.value = data.title;
status.value = data.status as QuoteStatus;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
validUntil.value = data.validUntil ? data.validUntil.slice(0, 10) : '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
} else if (fromDealId) {
dealId.value = fromDealId;
title.value = 'پیش‌فاکتور';
} else {
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
const person = people.value.find((p) => p.id === personId.value);
if (person?.company?.id) companyId.value = person.company.id;
}
if (!lines.value.length) {
lines.value = [newLine()];
}
}
} catch {
error.value = 'بارگذاری فرم پیش‌فاکتور ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateQuoteRequest {
const fromDealId =
isNew.value && typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: undefined;
return {
title: title.value.trim(),
status: status.value,
notes: notes.value || undefined,
validUntil: validUntil.value ? new Date(validUntil.value).toISOString() : null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId: dealId.value || null,
fromDealId: fromDealId || null,
lineItems: toLineInputs(),
currency: currency.value || 'IRR',
};
}
async function save() {
if (!title.value.trim()) {
error.value = 'عنوان را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<QuoteDto>('/quotes', payload());
await router.replace({ name: 'quote-detail', params: { id: data.id } });
} else {
await api.patch(`/quotes/${id.value}`, payload());
await load();
}
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این پیش‌فاکتور حذف شود؟')) return;
await api.delete(`/quotes/${id.value}`);
await router.push({ name: 'quotes' });
}
function printQuote() {
window.print();
}
onMounted(load);
import InvoiceFormView from './InvoiceFormView.vue';
</script>
<template>
<section class="page">
<header class="page-head no-print">
<div class="head-copy">
<p class="crumb">
<RouterLink :to="{ name: 'quotes' }">پیش‌فاکتورها</RouterLink>
<span>›</span>
<strong>{{ isNew ? 'پیش‌فاکتور جدید' : number || 'پیش‌فاکتور' }}</strong>
</p>
<h1>{{ isNew ? 'پیش‌فاکتور جدید' : number || 'پیش‌فاکتور' }}</h1>
</div>
<div class="head-actions">
<button v-if="!isNew" class="btn ghost" type="button" @click="printQuote">چاپ</button>
<RouterLink
v-if="!isNew && id"
class="btn ghost"
:to="{ name: 'invoice-new', query: { fromQuoteId: id } }"
>
صدور فاکتور
</RouterLink>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<div class="print-banner">
<strong>منتو</strong>
<span v-if="number">{{ number }}</span>
</div>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option v-for="(label, key) in statusLabel" :key="key" :value="key">{{ label }}</option>
</select>
</div>
<div class="field">
<label>اعتبار تا</label>
<JalaliDateInput v-model="validUntil" />
</div>
<div class="field">
<label>ارز</label>
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label>شرکت</label>
<select v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field wide">
<label>یادداشت</label>
<textarea v-model="notes" rows="3" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>ردیف‌های پیش‌فاکتور</h3>
<button class="btn btn-ghost no-print" type="button" @click="addLine">افزودن ردیف</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select v-model="line.productId" class="no-print" @change="onPickProduct(line)">
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
<button class="btn btn-ghost no-print" type="button" @click="removeLine(line.key)">حذف</button>
</div>
<p class="total"><strong>جمع:</strong> {{ formatMoney(totalAmount) }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions no-print">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
<InvoiceFormView />
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 860px;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
.crumb {
margin: 0 0 0.25rem;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
}
.crumb a {
color: #0f766e;
font-weight: 700;
text-decoration: none;
}
.head-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: clamp(1.25rem, 2.5vw, 1.55rem);
font-weight: 800;
}
.btn.ghost {
display: inline-flex;
align-items: center;
background: #fff;
color: #0f766e;
border: 1px solid #99f6e4;
border-radius: 12px;
min-height: 42px;
padding: 0.55rem 1rem;
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: none;
}
.form,
.lines-block {
display: grid;
gap: 0.85rem;
background: #fff;
border-radius: 18px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.lines-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.lines-head h3 {
margin: 0;
font-size: 1rem;
}
.line-row {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
gap: 0.45rem;
}
.total {
margin: 0;
text-align: start;
font-size: 1.05rem;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.error {
color: #b42318;
}
.print-banner {
display: none;
}
@media (max-width: 700px) {
.grid,
.line-row {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.print-banner {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.page {
max-width: none;
}
input,
select,
textarea {
border: 0;
padding: 0.2rem 0;
}
}
</style>
+10 -565
View File
@@ -1,580 +1,25 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import type { PaginatedResponse, QuoteDto, QuoteStatus } from '@mento/shared';
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<QuoteDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const status = ref<QuoteStatus | 'all'>(
typeof route.query.status === 'string' &&
['draft', 'sent', 'accepted', 'rejected', 'expired'].includes(route.query.status)
? (route.query.status as QuoteStatus)
: 'all',
);
const filterOpen = ref(false);
const statusLabel: Record<QuoteStatus, string> = {
draft: 'پیش‌نویس',
sent: 'ارسال‌شده',
accepted: 'تأییدشده',
rejected: 'ردشده',
expired: 'منقضی',
};
const items = computed(() => data.value?.items ?? []);
const listEmpty = computed(() => !loading.value && !items.value.length);
const statusFilterLabel = computed(() =>
status.value === 'all' ? 'همه وضعیت‌ها' : statusLabel[status.value],
);
async function load() {
loading.value = true;
error.value = '';
filterOpen.value = false;
try {
const { data: res } = await api.get<PaginatedResponse<QuoteDto>>('/quotes', {
params: {
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
dealId: typeof route.query.dealId === 'string' ? route.query.dealId : undefined,
limit: 50,
},
});
data.value = res;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری پیش‌فاکتورها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function onSearch() {
onMounted(() => {
void router.replace({
name: 'quotes',
query: {
...route.query,
q: q.value.trim() || undefined,
status: status.value === 'all' ? undefined : status.value,
},
name: 'invoices',
query: { ...route.query, doc: 'quote' },
});
}
function setStatus(next: QuoteStatus | 'all') {
status.value = next;
filterOpen.value = false;
onSearch();
}
watch(
() => [route.query.q, route.query.status, route.query.dealId],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
status.value =
typeof route.query.status === 'string' &&
['draft', 'sent', 'accepted', 'rejected', 'expired'].includes(route.query.status)
? (route.query.status as QuoteStatus)
: 'all';
void load();
},
);
onMounted(load);
});
</script>
<template>
<section class="page" @click="filterOpen = false">
<header class="page-head">
<div class="head-copy">
<h1>پیش‌فاکتورها</h1>
<p class="lead">از روی معامله یا جداگانه پیش‌فاکتور بسازید و وضعیت را پیگیری کنید.</p>
</div>
<RouterLink class="add-btn" :to="{ name: 'quote-new', query: route.query }" aria-label="پیش‌فاکتور جدید">
<span>+</span>
</RouterLink>
</header>
<div class="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="18" />
<input v-model="q" type="search" placeholder="جستجو در عنوان یا شماره…" />
</form>
<div class="filter-wrap" @click.stop>
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
<UiIcon name="filter" :size="16" />
<span class="btn-full">{{ statusFilterLabel }}</span>
<span class="btn-short">وضعیت</span>
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover">
<button type="button" :class="{ on: status === 'all' }" @click="setStatus('all')">
همه وضعیت‌ها
</button>
<button
v-for="(label, key) in statusLabel"
:key="key"
type="button"
:class="{ on: status === key }"
@click="setStatus(key as QuoteStatus)"
>
{{ label }}
</button>
</div>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="listEmpty" class="empty-card">
<div class="art" aria-hidden="true">
<svg viewBox="0 0 168 148" fill="none">
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<h2>هنوز پیش‌فاکتوری نیست</h2>
<p>اولین پیش‌فاکتور را بسازید تا پیشنهادهای قیمت اینجا جمع شوند.</p>
<RouterLink class="btn primary wide" :to="{ name: 'quote-new', query: route.query }">
<span>+</span>
پیش‌فاکتور جدید
</RouterLink>
</div>
<template v-else>
<div class="list-head">
<strong>{{ toFaDigits(items.length) }} پیش‌فاکتور</strong>
</div>
<div class="table-wrap desktop-only">
<table class="data-table">
<thead>
<tr>
<th>عنوان</th>
<th>شماره</th>
<th>وضعیت</th>
<th>مشتری / معامله</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="quote in items" :key="quote.id">
<td>
<RouterLink class="title" :to="{ name: 'quote-detail', params: { id: quote.id } }">
{{ quote.title }}
</RouterLink>
</td>
<td class="muted">{{ quote.number }}</td>
<td>
<span class="badge" :class="quote.status">
{{ statusLabel[quote.status as QuoteStatus] || quote.status }}
</span>
</td>
<td class="muted">
<span v-if="quote.person">{{ quote.person.fullName }}</span>
<span v-if="quote.person && quote.deal"> · </span>
<span v-if="quote.deal">{{ quote.deal.title }}</span>
<span v-if="!quote.person && !quote.deal">—</span>
</td>
<td>
<strong>{{ formatMoney(quote.totalAmount, quote.currency) }}</strong>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cards mobile-only">
<RouterLink
v-for="quote in items"
:key="quote.id"
class="m-card"
:to="{ name: 'quote-detail', params: { id: quote.id } }"
>
<div class="m-top">
<strong>{{ quote.title }}</strong>
<span class="badge" :class="quote.status">
{{ statusLabel[quote.status as QuoteStatus] || quote.status }}
</span>
</div>
<p class="muted">{{ quote.number }}</p>
<p v-if="quote.person || quote.deal" class="meta">
<span v-if="quote.person">{{ quote.person.fullName }}</span>
<span v-if="quote.deal"> · {{ quote.deal.title }}</span>
</p>
<div class="m-foot">
<strong>{{ formatMoney(quote.totalAmount, quote.currency) }}</strong>
</div>
</RouterLink>
</div>
</template>
</section>
<p class="muted">در حال انتقال به فاکتورها…</p>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
overflow-x: clip;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
.lead,
.muted,
.error,
.meta {
margin: 0;
}
.lead,
.muted,
.meta {
color: #64748b;
}
.error {
color: #b91c1c;
}
.add-btn {
width: 48px;
height: 48px;
border-radius: 999px;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
text-decoration: none;
font-size: 1.6rem;
font-weight: 700;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.28);
flex-shrink: 0;
}
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
}
.search {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0 0.85rem;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
background: none;
padding: 0.75rem 0;
font: inherit;
}
.filter-wrap {
position: relative;
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 42px;
padding: 0.55rem 1rem;
border-radius: 12px;
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
}
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.wide {
width: min(100%, 320px);
}
.filter-btn .chev {
transform: rotate(90deg);
}
.btn-short {
display: none;
}
.popover {
position: absolute;
top: calc(100% + 6px);
inset-inline-end: 0;
min-width: 11rem;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
z-index: 8;
}
.popover button {
display: block;
width: 100%;
text-align: start;
border: 0;
background: none;
padding: 0.6rem 0.7rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
color: #0f172a;
}
.popover button:hover,
.popover button.on {
background: #f0fdfa;
color: #0f766e;
}
.empty-card {
background: #fff;
border-radius: 22px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
padding: 2.4rem 1.2rem 2rem;
display: grid;
justify-items: center;
text-align: center;
gap: 0.7rem;
}
.art svg {
width: 148px;
height: auto;
display: block;
}
.empty-card h2 {
margin: 0.2rem 0 0;
font-size: 1.15rem;
}
.empty-card > p {
max-width: 22rem;
color: #94a3b8;
line-height: 1.8;
}
.list-head {
color: #0f766e;
}
.table-wrap {
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
.data-table th,
.data-table td {
padding: 0.9rem 1rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
}
.data-table th {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 700;
background: #f8fafc;
}
.title {
color: #0f172a;
font-weight: 700;
text-decoration: none;
}
.title:hover {
color: #0f766e;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
background: #f1f5f9;
color: #475569;
white-space: nowrap;
}
.badge.draft {
background: #f1f5f9;
color: #64748b;
}
.badge.sent {
background: #ecfeff;
color: #0e7490;
}
.badge.accepted {
background: #ecfdf5;
color: #047857;
}
.badge.rejected {
background: #fef2f2;
color: #b91c1c;
}
.badge.expired {
background: #fff7ed;
color: #c2410c;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.cards {
display: grid;
gap: 0.65rem;
}
.m-card {
background: #fff;
border-radius: 16px;
padding: 0.95rem 1rem;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
text-decoration: none;
color: inherit;
display: grid;
gap: 0.35rem;
min-width: 0;
}
.m-top {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: flex-start;
}
.m-top strong {
min-width: 0;
overflow-wrap: anywhere;
}
.m-foot {
margin-top: 0.25rem;
}
@media (max-width: 900px) {
.page-head {
align-items: center;
}
.tools {
flex-wrap: wrap;
}
.search {
flex: 1 1 100%;
}
.btn-full {
display: none;
}
.btn-short {
display: inline;
}
.desktop-only {
display: none;
}
.mobile-only {
display: grid;
}
}
@media (max-width: 520px) {
.add-btn {
width: 44px;
height: 44px;
}
.m-card {
padding: 0.85rem;
}
.muted {
color: var(--muted);
padding: 1rem;
}
</style>