1153 lines
26 KiB
Vue
1153 lines
26 KiB
Vue
<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 UiIcon from '@/components/UiIcon.vue';
|
||
import type {
|
||
ActivityDto,
|
||
ActivityFilter,
|
||
ActivityType,
|
||
PaginatedResponse,
|
||
} 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 menuId = ref<string | 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);
|
||
const rangeFrom = computed(() => (total.value ? (page.value - 1) * PAGE_SIZE + 1 : 0));
|
||
const rangeTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value));
|
||
|
||
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;
|
||
}
|
||
|
||
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>
|
||
|
||
<header class="page-head">
|
||
<div>
|
||
<p class="eyebrow">پیگیری</p>
|
||
<h1>فعالیتها</h1>
|
||
<p class="lead">تماس، جلسه و وظیفه — امروز و عقبافتادهها یکجا.</p>
|
||
</div>
|
||
<div class="head-actions">
|
||
<RouterLink class="btn primary" :to="{ name: 'activity-new' }">
|
||
<span>+</span>
|
||
فعالیت جدید
|
||
</RouterLink>
|
||
<RouterLink class="btn ghost" :to="{ name: 'activities-calendar' }">
|
||
<UiIcon name="calendar" :size="18" />
|
||
تقویم
|
||
</RouterLink>
|
||
</div>
|
||
</header>
|
||
|
||
<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-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>{{ trulyEmpty ? 'هنوز فعالیتی ثبت نشده!' : 'فعالیتی در این فهرست نیست' }}</h2>
|
||
<p>
|
||
{{
|
||
trulyEmpty
|
||
? 'اولین تماس، جلسه یا وظیفهات رو ثبت کن تا همهچیز رو منظم دنبال کنی.'
|
||
: 'فیلتر یا جستجو را عوض کنید، یا یک فعالیت جدید بسازید.'
|
||
}}
|
||
</p>
|
||
<RouterLink class="btn primary wide" :to="{ name: 'activity-new' }">
|
||
<span>+</span>
|
||
{{ trulyEmpty ? 'اولین فعالیتت رو ثبت کن' : 'فعالیت جدید' }}
|
||
</RouterLink>
|
||
<button class="btn ghost wide" type="button" @click="ideasOpen = !ideasOpen">
|
||
<UiIcon name="lightbulb" :size="18" />
|
||
ایدههایی برای شروع
|
||
</button>
|
||
<div v-if="ideasOpen" class="idea-list">
|
||
<RouterLink :to="{ name: 'activity-new', query: { type: 'call', title: 'تماس پیگیری' } }">
|
||
تماس پیگیری
|
||
</RouterLink>
|
||
<RouterLink :to="{ name: 'activity-new', query: { type: 'meeting', title: 'جلسه دمو' } }">
|
||
جلسه دمو
|
||
</RouterLink>
|
||
<RouterLink :to="{ name: 'activity-new', query: { type: 'task', title: 'وظیفه یادآوری' } }">
|
||
وظیفه یادآوری
|
||
</RouterLink>
|
||
</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" 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">
|
||
<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" @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>
|
||
|
||
<footer v-if="total" class="pager">
|
||
<p>نمایش {{ toFaDigits(rangeFrom) }} تا {{ toFaDigits(rangeTo) }} از {{ toFaDigits(total) }} فعالیت</p>
|
||
<div class="pages">
|
||
<button type="button" :disabled="page <= 1" @click="setPage(page - 1)">‹</button>
|
||
<button
|
||
v-for="n in pageCount"
|
||
:key="n"
|
||
type="button"
|
||
:class="{ on: n === page }"
|
||
@click="setPage(n)"
|
||
>
|
||
{{ toFaDigits(n) }}
|
||
</button>
|
||
<button type="button" :disabled="page >= pageCount" @click="setPage(page + 1)">›</button>
|
||
</div>
|
||
<p class="per">نمایش {{ toFaDigits(PAGE_SIZE) }} در هر صفحه</p>
|
||
</footer>
|
||
</template>
|
||
</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;
|
||
}
|
||
|
||
.head-actions,
|
||
.tools {
|
||
display: flex;
|
||
gap: 0.55rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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;
|
||
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,
|
||
.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: 12px;
|
||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||
padding: 0.35rem;
|
||
display: grid;
|
||
z-index: 8;
|
||
}
|
||
|
||
.popover a,
|
||
.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 a:hover,
|
||
.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;
|
||
}
|
||
|
||
.idea-list {
|
||
display: grid;
|
||
gap: 0.4rem;
|
||
width: min(100%, 320px);
|
||
}
|
||
|
||
.idea-list a {
|
||
background: #f8fafc;
|
||
border-radius: 10px;
|
||
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: 18px;
|
||
overflow: hidden;
|
||
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: 700;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
}
|
||
|
||
.trow {
|
||
border-bottom: 1px solid #f8fafc;
|
||
}
|
||
|
||
.title-cell {
|
||
display: flex;
|
||
gap: 0.55rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.star {
|
||
color: #cbd5e1;
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
.title {
|
||
color: inherit;
|
||
font-weight: 800;
|
||
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: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.cards {
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.card {
|
||
background: #fff;
|
||
border: 1px solid #eef2f6;
|
||
border-radius: 18px;
|
||
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: 800;
|
||
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: 8px;
|
||
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;
|
||
}
|
||
|
||
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>
|