Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت. Co-authored-by: Cursor <cursoragent@cursor.com>
832 lines
21 KiB
Vue
832 lines
21 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import { canManageSettings } from '@/lib/roles';
|
||
import {
|
||
UiButton,
|
||
UiEmptyState,
|
||
UiPageHeader,
|
||
UiPagination,
|
||
} from '@/components/ui';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import type {
|
||
LeadDto,
|
||
LeadImportJobDto,
|
||
LeadStageDto,
|
||
OrgUserDto,
|
||
PaginatedResponse,
|
||
} from '@mento/shared';
|
||
import { brand } from '@mento/shared';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const auth = useAuthStore();
|
||
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
||
|
||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||
const loading = ref(false);
|
||
const busy = ref(false);
|
||
const error = ref('');
|
||
const okMsg = ref('');
|
||
const data = ref<PaginatedResponse<LeadDto> | null>(null);
|
||
const stages = ref<LeadStageDto[]>([]);
|
||
const users = ref<OrgUserDto[]>([]);
|
||
const selected = ref<Set<string>>(new Set());
|
||
const sortOpen = ref(false);
|
||
const filterOpen = ref(false);
|
||
const assignOpen = ref(false);
|
||
const stageOpen = ref(false);
|
||
const assignUserId = ref('');
|
||
const bulkStageId = ref('');
|
||
const fileInput = ref<HTMLInputElement | null>(null);
|
||
const importJob = ref<LeadImportJobDto | null>(null);
|
||
let importTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
const page = computed(() => {
|
||
const raw = Number(route.query.page);
|
||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||
});
|
||
const sort = computed<SortKey>(() => {
|
||
const s = route.query.sort;
|
||
if (s === 'newest' || s === 'follow_up' || s === 'last_contact') return s;
|
||
return 'updated';
|
||
});
|
||
const stageId = computed(() =>
|
||
typeof route.query.stageId === 'string' ? route.query.stageId : '',
|
||
);
|
||
const assigneeId = computed(() =>
|
||
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
||
);
|
||
const unassigned = computed(
|
||
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
|
||
);
|
||
|
||
const total = computed(() => data.value?.total ?? 0);
|
||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||
const allSelected = computed(
|
||
() =>
|
||
Boolean(data.value?.items.length) &&
|
||
data.value!.items.every((l) => selected.value.has(l.id)),
|
||
);
|
||
const selectedIds = computed(() => [...selected.value]);
|
||
|
||
const sortLabels: Record<SortKey, string> = {
|
||
updated: 'آخرین بهروزرسانی',
|
||
newest: 'جدیدترین',
|
||
follow_up: 'پیگیری بعدی',
|
||
last_contact: 'آخرین تماس',
|
||
};
|
||
|
||
function formatDate(value: string | null) {
|
||
if (!value) return '—';
|
||
try {
|
||
return formatJalaliNumeric(new Date(value));
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function closePopups() {
|
||
sortOpen.value = false;
|
||
filterOpen.value = false;
|
||
assignOpen.value = false;
|
||
stageOpen.value = false;
|
||
}
|
||
|
||
function patchQuery(next: Record<string, string | undefined>) {
|
||
const query = { ...route.query, ...next };
|
||
for (const key of Object.keys(query)) {
|
||
if (query[key] == null || query[key] === '') delete query[key];
|
||
}
|
||
void router.replace({ name: 'leads', query });
|
||
}
|
||
|
||
function onSearch() {
|
||
patchQuery({ q: q.value.trim() || undefined, page: undefined });
|
||
}
|
||
|
||
function setSort(next: SortKey) {
|
||
sortOpen.value = false;
|
||
patchQuery({ sort: next === 'updated' ? undefined : next, page: undefined });
|
||
}
|
||
|
||
function setStageFilter(id: string) {
|
||
filterOpen.value = false;
|
||
patchQuery({ stageId: id || undefined, page: undefined });
|
||
}
|
||
|
||
function setAssigneeFilter(id: string) {
|
||
filterOpen.value = false;
|
||
patchQuery({
|
||
assigneeId: id || undefined,
|
||
unassigned: undefined,
|
||
page: undefined,
|
||
});
|
||
}
|
||
|
||
function setUnassigned(on: boolean) {
|
||
filterOpen.value = false;
|
||
patchQuery({
|
||
unassigned: on ? '1' : undefined,
|
||
assigneeId: undefined,
|
||
page: undefined,
|
||
});
|
||
}
|
||
|
||
function setPage(next: number) {
|
||
if (next < 1 || next > pageCount.value) return;
|
||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||
}
|
||
|
||
function toggleOne(id: string, checked: boolean) {
|
||
const next = new Set(selected.value);
|
||
if (checked) next.add(id);
|
||
else next.delete(id);
|
||
selected.value = next;
|
||
}
|
||
|
||
function toggleAll(checked: boolean) {
|
||
if (!checked) {
|
||
selected.value = new Set();
|
||
return;
|
||
}
|
||
selected.value = new Set(data.value?.items.map((l) => l.id) ?? []);
|
||
}
|
||
|
||
async function loadMeta() {
|
||
const [stagesRes, usersRes] = await Promise.all([
|
||
api.get<LeadStageDto[]>('/leads/stages'),
|
||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||
]);
|
||
stages.value = stagesRes.data;
|
||
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
closePopups();
|
||
try {
|
||
const { data: res } = await api.get<PaginatedResponse<LeadDto>>('/leads', {
|
||
params: {
|
||
q: q.value || undefined,
|
||
page: page.value,
|
||
limit: PAGE_SIZE,
|
||
sort: sort.value === 'updated' ? undefined : sort.value,
|
||
stageId: stageId.value || undefined,
|
||
assigneeId: assigneeId.value || undefined,
|
||
unassigned: unassigned.value ? '1' : undefined,
|
||
},
|
||
});
|
||
data.value = res;
|
||
selected.value = new Set(
|
||
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
|
||
);
|
||
} catch (e) {
|
||
error.value =
|
||
axios.isAxiosError(e) && !e.response
|
||
? 'اتصال به API برقرار نشد.'
|
||
: 'بارگذاری لیدها ناموفق بود.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function assignSelected() {
|
||
if (!assignUserId.value || !selectedIds.value.length) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/assign', {
|
||
leadIds: selectedIds.value,
|
||
assigneeId: assignUserId.value,
|
||
});
|
||
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید تخصیص داده شد.`;
|
||
selected.value = new Set();
|
||
assignOpen.value = false;
|
||
await load();
|
||
} catch {
|
||
error.value = 'تخصیص ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function bulkStageSelected() {
|
||
if (!bulkStageId.value || !selectedIds.value.length) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/bulk-stage', {
|
||
leadIds: selectedIds.value,
|
||
stageId: bulkStageId.value,
|
||
});
|
||
okMsg.value = `وضعیت ${toFaDigits(selectedIds.value.length)} لید بهروز شد.`;
|
||
selected.value = new Set();
|
||
stageOpen.value = false;
|
||
await load();
|
||
} catch {
|
||
error.value = 'تغییر وضعیت گروهی ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function archiveSelected() {
|
||
if (!selectedIds.value.length) return;
|
||
if (!confirm(`${toFaDigits(selectedIds.value.length)} لید بایگانی شود؟`)) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/archive', { leadIds: selectedIds.value });
|
||
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید بایگانی شد.`;
|
||
selected.value = new Set();
|
||
await load();
|
||
} catch {
|
||
error.value = 'بایگانی ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function exportExcel() {
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data: blob } = await api.get<Blob>('/leads/export', {
|
||
responseType: 'blob',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${brand.filePrefix}-leads.xlsx`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
error.value = 'خروجی اکسل ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function stopImportPoll() {
|
||
if (importTimer) {
|
||
clearInterval(importTimer);
|
||
importTimer = null;
|
||
}
|
||
}
|
||
|
||
async function pollImport(jobId: string) {
|
||
try {
|
||
const { data: job } = await api.get<LeadImportJobDto>(`/leads/imports/${jobId}`);
|
||
importJob.value = job;
|
||
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
|
||
stopImportPoll();
|
||
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
|
||
await load();
|
||
}
|
||
} catch {
|
||
stopImportPoll();
|
||
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
|
||
}
|
||
}
|
||
|
||
function startImportPoll(jobId: string) {
|
||
stopImportPoll();
|
||
void pollImport(jobId);
|
||
importTimer = setInterval(() => void pollImport(jobId), 1500);
|
||
}
|
||
|
||
async function onImportFile(ev: Event) {
|
||
const input = ev.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
okMsg.value = '';
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const { data: job } = await api.post<LeadImportJobDto>('/leads/import/file', form, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
importJob.value = job;
|
||
startImportPoll(job.id);
|
||
} catch {
|
||
error.value = 'ورود فایل ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function goDetail(id: string) {
|
||
void router.push({ name: 'lead-detail', params: { id } });
|
||
}
|
||
|
||
watch(
|
||
() => [
|
||
route.query.q,
|
||
route.query.page,
|
||
route.query.sort,
|
||
route.query.stageId,
|
||
route.query.assigneeId,
|
||
route.query.unassigned,
|
||
],
|
||
() => {
|
||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||
void load();
|
||
},
|
||
);
|
||
|
||
onMounted(async () => {
|
||
await loadMeta();
|
||
await load();
|
||
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
|
||
if (jobId) startImportPoll(jobId);
|
||
});
|
||
|
||
onBeforeUnmount(stopImportPoll);
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page" @click="closePopups">
|
||
<UiPageHeader title="لیدها" lead="مدیریت سرنخها، تخصیص و پیگیری">
|
||
<template #actions>
|
||
<RouterLink class="link-btn" :to="{ name: 'leads-board' }">
|
||
<UiButton variant="ghost" size="sm">کاریز لیدها</UiButton>
|
||
</RouterLink>
|
||
<UiButton
|
||
v-if="isAdmin"
|
||
variant="ghost"
|
||
size="sm"
|
||
:disabled="busy"
|
||
@click.stop="exportExcel"
|
||
>
|
||
خروجی اکسل
|
||
</UiButton>
|
||
<UiButton
|
||
v-if="isAdmin"
|
||
variant="ghost"
|
||
size="sm"
|
||
:disabled="busy"
|
||
@click.stop="fileInput?.click()"
|
||
>
|
||
ورود از فایل
|
||
</UiButton>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
accept=".xlsx,.xls,.csv"
|
||
hidden
|
||
@change="onImportFile"
|
||
/>
|
||
</template>
|
||
</UiPageHeader>
|
||
|
||
<div class="toolbar">
|
||
<form class="search" @submit.prevent="onSearch">
|
||
<UiIcon name="search" :size="16" />
|
||
<input v-model="q" type="search" placeholder="جستجو نام، موبایل، شهر…" />
|
||
</form>
|
||
<div class="toolbar-tools">
|
||
<div class="pop">
|
||
<button
|
||
class="tool"
|
||
type="button"
|
||
@click.stop="sortOpen = !sortOpen; filterOpen = false"
|
||
>
|
||
<UiIcon name="trend" :size="16" />
|
||
<span class="tool-label">مرتبسازی</span>
|
||
</button>
|
||
<ul v-if="sortOpen" class="menu">
|
||
<li v-for="(label, key) in sortLabels" :key="key">
|
||
<button
|
||
type="button"
|
||
:class="{ on: sort === key }"
|
||
@click.stop="setSort(key as SortKey)"
|
||
>
|
||
{{ label }}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div class="pop">
|
||
<button
|
||
class="tool"
|
||
type="button"
|
||
@click.stop="filterOpen = !filterOpen; sortOpen = false"
|
||
>
|
||
<UiIcon name="filter" :size="16" />
|
||
<span class="tool-label">فیلتر</span>
|
||
</button>
|
||
<ul v-if="filterOpen" class="menu wide">
|
||
<li class="menu-label">وضعیت</li>
|
||
<li>
|
||
<button
|
||
type="button"
|
||
:class="{ on: !stageId }"
|
||
@click.stop="setStageFilter('')"
|
||
>
|
||
همه
|
||
</button>
|
||
</li>
|
||
<li v-for="s in stages" :key="s.id">
|
||
<button
|
||
type="button"
|
||
:class="{ on: stageId === s.id }"
|
||
@click.stop="setStageFilter(s.id)"
|
||
>
|
||
{{ s.name }}
|
||
</button>
|
||
</li>
|
||
<li class="menu-label">کارشناس</li>
|
||
<li>
|
||
<button
|
||
type="button"
|
||
:class="{ on: !assigneeId && !unassigned }"
|
||
@click.stop="setAssigneeFilter('')"
|
||
>
|
||
همه
|
||
</button>
|
||
</li>
|
||
<li>
|
||
<button
|
||
type="button"
|
||
:class="{ on: unassigned }"
|
||
@click.stop="setUnassigned(true)"
|
||
>
|
||
بدون کارشناس
|
||
</button>
|
||
</li>
|
||
<li v-for="u in users" :key="u.id">
|
||
<button
|
||
type="button"
|
||
:class="{ on: assigneeId === u.id }"
|
||
@click.stop="setAssigneeFilter(u.id)"
|
||
>
|
||
{{ u.fullName }}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedIds.length" class="bulk-bar" @click.stop>
|
||
<span>{{ toFaDigits(selectedIds.length) }} مورد انتخابشده</span>
|
||
<template v-if="isAdmin">
|
||
<div class="pop">
|
||
<UiButton size="sm" variant="ghost" @click="assignOpen = !assignOpen">
|
||
تخصیص
|
||
</UiButton>
|
||
<div v-if="assignOpen" class="menu panel">
|
||
<select v-model="assignUserId">
|
||
<option value="">انتخاب کارشناس…</option>
|
||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||
{{ u.fullName }}
|
||
</option>
|
||
</select>
|
||
<UiButton size="sm" :disabled="!assignUserId || busy" @click="assignSelected">
|
||
اعمال
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
<div class="pop">
|
||
<UiButton size="sm" variant="ghost" @click="stageOpen = !stageOpen">
|
||
تغییر وضعیت
|
||
</UiButton>
|
||
<div v-if="stageOpen" class="menu panel">
|
||
<select v-model="bulkStageId">
|
||
<option value="">انتخاب وضعیت…</option>
|
||
<option v-for="s in stages" :key="s.id" :value="s.id">
|
||
{{ s.name }}
|
||
</option>
|
||
</select>
|
||
<UiButton size="sm" :disabled="!bulkStageId || busy" @click="bulkStageSelected">
|
||
اعمال
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<UiButton size="sm" variant="ghost" :disabled="busy" @click="archiveSelected">
|
||
بایگانی
|
||
</UiButton>
|
||
</div>
|
||
|
||
<p v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'" class="ok">
|
||
ورود فایل: {{ toFaDigits(importJob.processedCount) }} / {{ toFaDigits(importJob.totalCount) }}
|
||
</p>
|
||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||
|
||
<template v-else-if="data?.items.length">
|
||
<div class="table-wrap">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="check-col">
|
||
<input
|
||
type="checkbox"
|
||
:checked="allSelected"
|
||
@click.stop
|
||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||
/>
|
||
</th>
|
||
<th>نام</th>
|
||
<th>موبایل</th>
|
||
<th>شهر</th>
|
||
<th>وضعیت</th>
|
||
<th>کارشناس</th>
|
||
<th>آخرین تماس</th>
|
||
<th>پیگیری بعدی</th>
|
||
<th>منبع</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="lead in data.items"
|
||
:key="lead.id"
|
||
class="row-click"
|
||
@click="goDetail(lead.id)"
|
||
>
|
||
<td class="check-col" @click.stop>
|
||
<input
|
||
type="checkbox"
|
||
:checked="selected.has(lead.id)"
|
||
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
|
||
/>
|
||
</td>
|
||
<td>
|
||
<strong>{{ lead.title }}</strong>
|
||
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
|
||
</td>
|
||
<td dir="ltr">{{ lead.mobile || '—' }}</td>
|
||
<td>{{ lead.city || '—' }}</td>
|
||
<td>
|
||
<span class="stage-pill">{{ lead.stageName }}</span>
|
||
</td>
|
||
<td>{{ lead.assigneeName || '—' }}</td>
|
||
<td>{{ formatDate(lead.lastContactAt) }}</td>
|
||
<td>{{ formatDate(lead.nextFollowUpAt) }}</td>
|
||
<td>{{ lead.source || '—' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<UiPagination
|
||
v-if="total"
|
||
:page="page"
|
||
:total-pages="pageCount"
|
||
:total-items="total"
|
||
@update:page="setPage"
|
||
/>
|
||
</template>
|
||
|
||
<UiEmptyState
|
||
v-else-if="!loading"
|
||
title="لیدی ثبت نشده است."
|
||
description="از ورود فایل استفاده کنید یا لید جدید بسازید."
|
||
/>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: grid;
|
||
gap: 1rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
overflow-x: clip;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.link-btn {
|
||
text-decoration: none;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 0.55rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-tools {
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.search {
|
||
flex: 1 1 14rem;
|
||
min-width: 180px;
|
||
max-width: 400px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
padding: 0 0.75rem;
|
||
min-height: var(--control-h);
|
||
height: var(--control-h);
|
||
color: var(--muted);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.search:focus-within {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||
}
|
||
|
||
.search input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 0;
|
||
outline: none;
|
||
background: transparent;
|
||
font: inherit;
|
||
font-size: var(--fs-sm);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.tool {
|
||
min-height: var(--control-h);
|
||
height: var(--control-h);
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
border-radius: var(--radius);
|
||
padding: 0 0.75rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.pop {
|
||
position: relative;
|
||
}
|
||
|
||
.menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
inset-inline-end: 0;
|
||
z-index: 20;
|
||
margin: 0;
|
||
padding: 0.35rem;
|
||
list-style: none;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
|
||
min-width: 11rem;
|
||
max-height: 18rem;
|
||
overflow: auto;
|
||
}
|
||
|
||
.menu.wide {
|
||
min-width: 14rem;
|
||
}
|
||
|
||
.menu.panel {
|
||
display: grid;
|
||
gap: 0.5rem;
|
||
padding: 0.65rem;
|
||
min-width: 14rem;
|
||
}
|
||
|
||
.menu button {
|
||
width: 100%;
|
||
border: 0;
|
||
background: none;
|
||
text-align: start;
|
||
padding: 0.45rem 0.55rem;
|
||
border-radius: calc(var(--radius) - 2px);
|
||
font: inherit;
|
||
cursor: pointer;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.menu button.on,
|
||
.menu button:hover {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.menu-label {
|
||
padding: 0.35rem 0.55rem 0.15rem;
|
||
font-size: 0.75rem;
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.menu select {
|
||
width: 100%;
|
||
min-height: var(--control-h);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
padding: 0 0.5rem;
|
||
font: inherit;
|
||
}
|
||
|
||
.bulk-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
padding: 0.55rem 0.75rem;
|
||
background: var(--accent-soft);
|
||
border-radius: var(--radius);
|
||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||
}
|
||
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: #fff;
|
||
}
|
||
|
||
.data-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: var(--fs-sm);
|
||
}
|
||
|
||
.data-table th,
|
||
.data-table td {
|
||
padding: 0.65rem 0.75rem;
|
||
text-align: start;
|
||
border-bottom: 1px solid var(--line);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.data-table th {
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
background: color-mix(in srgb, var(--surface-2, #f8fafc) 80%, #fff);
|
||
}
|
||
|
||
.row-click {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.row-click:hover {
|
||
background: color-mix(in srgb, var(--accent-soft) 55%, #fff);
|
||
}
|
||
|
||
.check-col {
|
||
width: 2.25rem;
|
||
}
|
||
|
||
.sub {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 0.8rem;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.stage-pill {
|
||
display: inline-block;
|
||
padding: 0.15rem 0.55rem;
|
||
border-radius: 999px;
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.muted {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.ok {
|
||
color: #0f766e;
|
||
margin: 0;
|
||
}
|
||
|
||
.error {
|
||
color: #b91c1c;
|
||
margin: 0;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.tool-label {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|