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

1216 lines
27 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 from '@/components/ui/UiButton.vue';
import UiModal from '@/components/ui/UiModal.vue';
import UiIcon from '@/components/UiIcon.vue';
import ActivityFormView from '@/views/ActivityFormView.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 activityModal = ref<{
type?: ActivityType;
title?: string;
} | null>(null);
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 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;
}
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">
<UiButton type="button" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</UiButton>
<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>
<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>
<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>
<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;
}
.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: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
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: 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: 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,
.idea-list button {
border: 0;
width: 100%;
text-align: right;
font: inherit;
cursor: pointer;
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: 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: 700;
border-bottom: 1px solid #f1f5f9;
border-radius: 18px 18px 0 0;
}
.trow {
position: relative;
border-bottom: 1px solid #f8fafc;
}
.trow:last-child {
border-bottom: 0;
border-radius: 0 0 18px 18px;
}
.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: 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>