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

1329 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
import {
UiButton,
UiModal,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
ActivityFilter,
ActivityType,
ImportContactsResult,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
type TypeFilter = 'all' | ActivityType;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
const TABS: Array<{ key: ActivityFilter; label: string }> = [
{ key: 'today', label: 'امروز' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'upcoming', label: 'بعداً' },
{ key: 'done', label: 'انجام‌شده' },
{ key: 'all', label: 'همه' },
];
const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
{ key: 'all', label: 'همه انواع' },
{ key: 'call', label: 'تماس' },
{ key: 'meeting', label: 'جلسه' },
{ key: 'task', label: 'وظیفه' },
{ key: 'note', label: 'پیام' },
];
const PAGE_SIZE = 6;
const route = useRoute();
const router = useRouter();
const filter = computed<ActivityFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
if (['today', 'overdue', 'upcoming', 'done', 'all'].includes(value)) {
return value as ActivityFilter;
}
return 'today';
});
const page = computed(() => {
const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1;
});
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const typeFilter = ref<TypeFilter>('all');
const filterOpen = ref(false);
const ideasOpen = ref(false);
const activityModal = ref<{
type?: ActivityType;
title?: string;
} | null>(null);
const menuId = ref<string | null>(null);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const counts = ref<Record<ActivityFilter, number>>({
today: 0,
overdue: 0,
upcoming: 0,
done: 0,
all: 0,
});
const typeLabel: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'پیام',
};
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const statusLabel: Record<UiStatus, string> = {
done: 'انجام شد',
progress: 'در حال انجام',
planned: 'برنامه‌ریزی شده',
overdue: 'عقب‌افتاده',
canceled: 'لغو شده',
};
const statusIcon: Record<UiStatus, 'check' | 'clock' | 'calendar' | 'warning'> = {
done: 'check',
progress: 'clock',
planned: 'calendar',
overdue: 'warning',
canceled: 'warning',
};
const items = computed(() => {
const rows = data.value?.items ?? [];
if (typeFilter.value === 'all') return rows;
return rows.filter((row) => row.type === typeFilter.value);
});
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
const listEmpty = computed(() => !loading.value && !items.value.length);
function isLastItem(id: string) {
const rows = items.value;
return rows.length > 0 && rows[rows.length - 1]?.id === id;
}
function isOverdue(item: ActivityDto) {
if (item.status !== 'open' || !item.dueAt) return false;
return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime();
}
function uiStatus(item: ActivityDto): UiStatus {
if (item.status === 'done') return 'done';
if (item.status === 'canceled') return 'canceled';
if (isOverdue(item)) return 'overdue';
if (item.dueAt && calendarDaysBetween(new Date(item.dueAt), new Date()) === 0) {
return 'progress';
}
return 'planned';
}
function clockText(item: ActivityDto) {
if (!item.dueAt) return '—';
return formatClockFa(new Date(item.dueAt));
}
function dayText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const days = calendarDaysBetween(new Date(item.dueAt), new Date());
if (days === 0) return 'امروز';
if (days === 1) return 'دیروز';
if (days === -1) return 'فردا';
return formatJalaliDay(new Date(item.dueAt));
}
function dateText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
return formatJalaliDay(new Date(item.dueAt));
}
function subtitle(item: ActivityDto) {
return item.deal?.title || item.description || '';
}
async function loadCounts() {
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
const results = await Promise.all(
keys.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: key, limit: 1 },
}),
),
);
const next = { ...counts.value };
keys.forEach((key, i) => {
next[key] = results[i]?.data.total ?? 0;
});
counts.value = next;
}
async function load() {
loading.value = true;
error.value = '';
menuId.value = null;
try {
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: {
filter: filter.value,
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
},
});
data.value = res;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فعالیت‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function setFilter(next: ActivityFilter) {
filterOpen.value = false;
void router.replace({
name: 'activities',
query: {
...route.query,
filter: next === 'today' ? undefined : next,
page: undefined,
},
});
}
function setPage(next: number) {
const safe = Math.min(Math.max(1, next), pageCount.value);
void router.replace({
name: 'activities',
query: {
...route.query,
page: safe === 1 ? undefined : String(safe),
},
});
}
function onSearch() {
void router.replace({
name: 'activities',
query: {
...route.query,
q: q.value.trim() || undefined,
page: undefined,
},
});
}
function setTypeFilter(next: TypeFilter) {
typeFilter.value = next;
filterOpen.value = false;
}
async function complete(id: string) {
menuId.value = null;
try {
await api.patch(`/activities/${id}/complete`);
await Promise.all([load(), loadCounts()]);
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود.';
}
}
function closePopovers() {
filterOpen.value = false;
menuId.value = null;
}
async function exportExcel() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const { data } = await api.get<Blob>('/activities/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-activities.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
} 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 {
busy.value = false;
}
}
watch(
() => [route.query.filter, route.query.q, route.query.page],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
},
);
onMounted(() => {
void load();
void loadCounts();
});
</script>
<template>
<section class="page" @click="closePopovers">
<nav class="crumbs" aria-label="مسیر">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
<span>‹</span>
<span>فعالیت‌ها</span>
</nav>
<UiPageHeader
title="فعالیت‌ها"
lead="تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا."
>
<template #actions>
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<UiButton variant="primary" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</UiButton>
<RouterLink :to="{ name: 'activities-calendar' }">
<UiButton variant="ghost">
<UiIcon name="calendar" :size="16" />
تقویم
</UiButton>
</RouterLink>
</template>
</UiPageHeader>
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
<button
v-for="tab in TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: filter === tab.key }"
:aria-selected="filter === tab.key"
@click="setFilter(tab.key)"
>
<span>{{ tab.label }}</span>
<em>{{ toFaDigits(counts[tab.key]) }}</em>
</button>
</div>
<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" />
فیلتر
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover">
<button
v-for="opt in TYPE_FILTERS"
:key="opt.key"
type="button"
:class="{ on: typeFilter === opt.key }"
@click="setTypeFilter(opt.key)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-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>{{ trulyEmpty ? 'هنوز فعالیتی ثبت نشده!' : 'فعالیتی در این فهرست نیست' }}</h2>
<p>
{{
trulyEmpty
? 'اولین تماس، جلسه یا وظیفه‌ات رو ثبت کن تا همه‌چیز رو منظم دنبال کنی.'
: 'فیلتر یا جستجو را عوض کنید، یا یک فعالیت جدید بسازید.'
}}
</p>
<UiButton block type="button" @click="activityModal = {}">
{{ trulyEmpty ? 'اولین فعالیتت رو ثبت کن' : 'فعالیت جدید' }}
</UiButton>
<button class="btn ghost wide" type="button" @click="ideasOpen = !ideasOpen">
<UiIcon name="lightbulb" :size="18" />
ایده‌هایی برای شروع
</button>
<div v-if="ideasOpen" class="idea-list">
<button type="button" @click="activityModal = { type: 'call', title: 'تماس پیگیری' }">
تماس پیگیری
</button>
<button type="button" @click="activityModal = { type: 'meeting', title: 'جلسه دمو' }">
جلسه دمو
</button>
<button type="button" @click="activityModal = { type: 'task', title: 'وظیفه یادآوری' }">
وظیفه یادآوری
</button>
</div>
</div>
<template v-else>
<div class="list-head">
<strong>{{ filterLabel }} — {{ toFaDigits(items.length) }} فعالیت</strong>
<RouterLink class="cal-mini" :to="{ name: 'activities-calendar' }" aria-label="تقویم">
<UiIcon name="calendar" :size="16" />
</RouterLink>
</div>
<div class="table desktop-only" role="table">
<div class="thead" role="row">
<span>مشتری / عنوان فعالیت</span>
<span>نوع فعالیت</span>
<span>زمان شروع</span>
<span>کارشناس</span>
<span>وضعیت</span>
<span />
</div>
<article
v-for="item in items"
:key="item.id"
class="trow"
:class="{ 'menu-open': menuId === item.id }"
role="row"
>
<div class="title-cell">
<UiIcon name="star" :size="16" class="star" />
<div>
<RouterLink class="title" :to="{ name: 'activity-detail', params: { id: item.id } }">
{{ item.title }}
</RouterLink>
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
</div>
</div>
<div class="type-cell">
<span class="type-pill" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="13" />
{{ typeLabel[item.type] }}
</span>
</div>
<div class="when" :class="{ late: uiStatus(item) === 'overdue' }">
<strong>{{ clockText(item) }}</strong>
<span>{{ dayText(item) }}</span>
</div>
<div class="who">
<strong>{{ item.assignee?.fullName || '—' }}</strong>
<span v-if="item.company || item.person">
<UiIcon name="building" :size="12" />
{{ item.company?.name || item.person?.fullName }}
</span>
</div>
<div>
<span class="status" :class="uiStatus(item)">
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
{{ statusLabel[uiStatus(item)] }}
</span>
</div>
<div class="row-more" @click.stop>
<button
class="icon-more"
type="button"
aria-label="عملیات"
@click="menuId = menuId === item.id ? null : item.id"
>
<UiIcon name="dots-v" :size="16" />
</button>
<div
v-if="menuId === item.id"
class="popover"
:class="{ 'drop-up': isLastItem(item.id) }"
>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
مشاهده
</RouterLink>
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }" @click="menuId = null">
ویرایش
</RouterLink>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
</button>
</div>
</div>
</article>
</div>
<div class="cards mobile-only">
<article v-for="item in items" :key="item.id" class="card">
<div class="card-top">
<div class="card-main">
<time :class="item.type">{{ clockText(item) }}</time>
<RouterLink class="title" :to="{ name: 'activity-detail', params: { id: item.id } }">
{{ item.title }}
<UiIcon name="star" :size="14" class="star" />
</RouterLink>
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
<p class="meta-line">
<span v-if="item.person">
<UiIcon name="user" :size="13" />
{{ item.person.fullName }}
</span>
<span v-if="item.company">
<UiIcon name="building" :size="13" />
{{ item.company.name }}
</span>
</p>
</div>
<div class="card-side">
<button
class="icon-more"
type="button"
aria-label="عملیات"
@click.stop="menuId = menuId === item.id ? null : item.id"
>
<UiIcon name="dots-v" :size="16" />
</button>
<span class="type-ico" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="18" />
</span>
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
<div
v-if="menuId === item.id"
class="popover"
:class="{ 'drop-up': isLastItem(item.id) }"
@click.stop
>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
مشاهده
</RouterLink>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
</button>
</div>
</div>
</div>
<footer class="card-foot">
<span>
<UiIcon name="calendar" :size="14" />
{{ dateText(item) }}
</span>
<button
v-if="item.status === 'open'"
class="done-btn"
type="button"
@click="complete(item.id)"
>
<UiIcon name="check" :size="14" />
انجام شد
</button>
<span v-else class="status" :class="uiStatus(item)">
{{ statusLabel[uiStatus(item)] }}
</span>
</footer>
</article>
</div>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
@update:page="setPage"
/>
</template>
<UiModal
:open="Boolean(activityModal)"
title="فعالیت جدید"
@close="activityModal = null"
>
<ActivityFormView
v-if="activityModal"
embedded
:initial-type="activityModal.type"
:initial-title="activityModal.title"
@saved="activityModal = null; void load(); void loadCounts()"
@cancel="activityModal = null"
/>
</UiModal>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.crumbs {
display: flex;
gap: 0.35rem;
align-items: center;
color: #94a3b8;
font-size: 0.78rem;
}
.crumbs a {
color: inherit;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: #0f766e;
font-weight: 700;
font-size: 0.8rem;
}
h1 {
margin: 0.15rem 0 0.3rem;
font-size: 1.85rem;
}
.lead,
.muted,
.sub,
.error {
margin: 0;
}
.lead,
.muted,
.sub {
color: #64748b;
}
.error {
color: #b91c1c;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.head-actions .excel-btn .btn-full {
display: inline;
}
.head-actions .excel-btn .btn-short {
display: none;
}
.head-actions,
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
height: var(--control-h);
min-height: var(--control-h);
padding: 0 0.85rem;
border-radius: var(--radius);
font-weight: 600;
font-size: var(--fs-sm);
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.btn.primary {
background: var(--accent);
color: #fff;
}
.btn.ghost {
background: #fff;
color: var(--ink-secondary);
border-color: var(--line);
}
.btn.ghost:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.btn.wide {
width: min(100%, 320px);
}
.tabs {
display: flex;
gap: 0.2rem;
overflow-x: auto;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: none;
padding: 0.7rem 0.85rem;
color: #64748b;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
box-shadow: inset 0 -2px 0 transparent;
}
.tabs button em {
font-style: normal;
font-size: 0.72rem;
background: #f1f5f9;
border-radius: 999px;
padding: 0.05rem 0.4rem;
}
.tabs button.on {
color: #0f766e;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.tabs button.on em {
background: #ccfbf1;
}
.search {
flex: 1 1 200px;
min-width: 180px;
max-width: 400px;
height: var(--control-h);
min-height: var(--control-h);
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
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 {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.filter-wrap,
.row-more,
.card-side {
position: relative;
}
.filter-btn .chev {
transform: rotate(90deg);
}
.popover {
position: absolute;
top: calc(100% + 6px);
inset-inline-end: 0;
min-width: 11rem;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
z-index: 20;
}
.popover.drop-up {
top: auto;
bottom: calc(100% + 6px);
}
.popover a,
.popover button {
display: block;
width: 100%;
text-align: start;
border: 0;
background: none;
padding: 0.6rem 0.7rem;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
color: #0f172a;
}
.popover a:hover,
.popover button:hover,
.popover button.on {
background: #f0fdfa;
color: #0f766e;
}
.empty-card {
background: #fff;
border-radius: var(--radius);
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;
}
.idea-list {
display: grid;
gap: 0.4rem;
width: min(100%, 320px);
}
.idea-list a,
.idea-list button {
border: 0;
width: 100%;
text-align: right;
font: inherit;
cursor: pointer;
background: #f8fafc;
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
color: #0f766e;
font-weight: 700;
}
.list-head {
display: flex;
justify-content: space-between;
align-items: center;
color: #0f766e;
}
.cal-mini {
color: #0f766e;
}
.table {
background: #fff;
border-radius: var(--radius);
overflow: visible;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.thead,
.trow {
display: grid;
grid-template-columns: minmax(0, 2.2fr) 7.6rem 7.2rem 8.4rem 8.6rem 2.4rem;
gap: 0.8rem;
align-items: center;
padding: 0.95rem 1.1rem;
}
.thead {
color: #94a3b8;
font-size: 0.78rem;
font-weight: 600;
border-bottom: 1px solid #f1f5f9;
border-radius: var(--radius) var(--radius) 0 0;
}
.trow {
position: relative;
border-bottom: 1px solid #f8fafc;
}
.trow:last-child {
border-bottom: 0;
border-radius: 0 0 var(--radius) var(--radius);
}
.trow.menu-open {
z-index: 12;
}
.title-cell {
display: flex;
gap: 0.55rem;
min-width: 0;
}
.star {
color: #cbd5e1;
margin-top: 0.2rem;
}
.title {
color: inherit;
font-weight: 600;
overflow-wrap: anywhere;
}
.sub {
margin: 0.2rem 0 0;
font-size: 0.8rem;
}
.type-cell {
min-width: 0;
}
.type-pill {
display: inline-flex;
align-items: center;
gap: 0.28rem;
border-radius: 999px;
padding: 0.12rem 0.5rem;
font-size: 0.72rem;
font-weight: 700;
}
.type-pill.call,
.type-ico.call,
time.call {
background: #ecfdf5;
color: #0f766e;
}
.type-pill.meeting,
.type-ico.meeting,
time.meeting {
background: #f5f3ff;
color: #6d28d9;
}
.type-pill.task,
.type-ico.task,
time.task {
background: #fff7ed;
color: #c2410c;
}
.type-pill.note,
.type-ico.note,
time.note {
background: #eff6ff;
color: #2563eb;
}
.when,
.who {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.when strong,
.who strong {
font-size: 0.9rem;
}
.when span,
.who span {
color: #94a3b8;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.when.late {
color: #dc2626;
}
.when.late span {
color: #ef4444;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.28rem;
border-radius: 999px;
padding: 0.28rem 0.55rem;
font-size: 0.74rem;
font-weight: 700;
white-space: nowrap;
}
.status.done {
background: #dcfce7;
color: #166534;
}
.status.progress {
background: #dbeafe;
color: #1d4ed8;
}
.status.planned {
background: #ffedd5;
color: #c2410c;
}
.status.overdue,
.status.canceled {
background: #fee2e2;
color: #b91c1c;
}
.icon-more {
width: 32px;
height: 32px;
border: 0;
background: none;
color: #64748b;
border-radius: var(--radius);
display: grid;
place-items: center;
cursor: pointer;
}
.cards {
display: grid;
gap: 0.75rem;
}
.card {
background: #fff;
border: 1px solid #eef2f6;
border-radius: var(--radius);
padding: 0.9rem 0.95rem 0.75rem;
}
.card-top {
display: flex;
gap: 0.7rem;
justify-content: space-between;
}
.card-main {
min-width: 0;
flex: 1;
}
.card-main time {
display: inline-block;
background: none;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.card-main .title {
display: flex;
align-items: flex-start;
gap: 0.3rem;
}
.meta-line {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin: 0.45rem 0 0;
color: #64748b;
font-size: 0.78rem;
}
.meta-line span {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.card-side {
display: grid;
justify-items: center;
gap: 0.35rem;
flex-shrink: 0;
}
.type-ico {
width: 42px;
height: 42px;
border-radius: 999px;
display: grid;
place-items: center;
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid #f1f5f9;
color: #64748b;
font-size: 0.78rem;
}
.card-foot span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.done-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 0;
background: #ecfdf5;
color: #0f766e;
border-radius: 999px;
padding: 0.38rem 0.7rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
color: #64748b;
font-size: 0.8rem;
}
.pages {
display: flex;
gap: 0.3rem;
}
.pages button {
min-width: 32px;
height: 32px;
border: 0;
background: #fff;
border-radius: var(--radius);
cursor: pointer;
}
.pages button.on {
background: #14b8a6;
color: #fff;
}
.pages button:disabled {
opacity: 0.4;
}
.mobile-only {
display: none;
}
@media (max-width: 899px) {
.crumbs,
.desktop-only,
.tabs button em,
.pager .per {
display: none;
}
.mobile-only {
display: grid;
}
.page-head {
flex-direction: column;
}
.head-actions {
width: 100%;
}
.head-actions .btn {
flex: 1;
}
.head-actions .excel-btn .btn-full {
display: none;
}
.head-actions .excel-btn .btn-short {
display: inline;
}
h1 {
font-size: 1.55rem;
}
.tabs {
border-bottom: 0;
gap: 0.4rem;
}
.tabs button {
background: #fff;
border-radius: 999px;
box-shadow: none;
padding: 0.48rem 0.8rem;
}
.tabs button.on {
background: #ccfbf1;
box-shadow: none;
}
.tools {
align-items: stretch;
}
.search {
flex: 1 1 auto;
}
.list-head {
padding-top: 0.2rem;
}
}
@media (max-width: 599px) {
.thead,
.trow {
grid-template-columns: 1fr;
}
}
</style>