feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
+238
-588
@@ -1,22 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
UiPageHeader,
|
||||
UiEmptyState,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
TicketDto,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { FilterOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
const TICKET_COL_LABELS = [
|
||||
'موضوع',
|
||||
'شماره',
|
||||
'وضعیت',
|
||||
'اولویت',
|
||||
'مشتری / مسئول',
|
||||
'SLA',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('tickets', [...TICKET_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'tickets');
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -32,7 +61,6 @@ const status = ref<TicketStatus | 'all'>(
|
||||
: 'all',
|
||||
);
|
||||
const slaOnly = ref(route.query.sla === 'overdue');
|
||||
const filterOpen = ref(false);
|
||||
|
||||
const statusLabel: Record<TicketStatus, string> = {
|
||||
open: 'باز',
|
||||
@@ -49,11 +77,28 @@ const priorityLabel: Record<TicketPriority, string> = {
|
||||
urgent: 'فوری',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'همه وضعیتها' },
|
||||
...Object.entries(statusLabel).map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
|
||||
const items = computed(() => data.value?.items ?? []);
|
||||
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
const statusFilterLabel = computed(() =>
|
||||
status.value === 'all' ? 'همه وضعیتها' : statusLabel[status.value],
|
||||
);
|
||||
|
||||
function statusTagColor(s: TicketStatus | string) {
|
||||
if (s === 'open') return 'cyan';
|
||||
if (s === 'in_progress') return 'orange';
|
||||
if (s === 'waiting') return 'gold';
|
||||
if (s === 'resolved' || s === 'closed') return 'success';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function priorityTagColor(p: TicketPriority | string) {
|
||||
if (p === 'low') return 'default';
|
||||
if (p === 'normal') return 'cyan';
|
||||
if (p === 'high' || p === 'urgent') return 'error';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function formatSla(iso: string | null) {
|
||||
if (!iso) return '';
|
||||
@@ -62,10 +107,41 @@ function formatSla(iso: string | null) {
|
||||
return formatJalaliDateTime(d);
|
||||
}
|
||||
|
||||
function personAssigneeText(ticket: TicketDto) {
|
||||
const parts: string[] = [];
|
||||
if (ticket.person) parts.push(ticket.person.fullName);
|
||||
if (ticket.assignee) parts.push(ticket.assignee.fullName);
|
||||
return parts.length ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
function slaText(ticket: TicketDto) {
|
||||
if (ticket.slaBreached) return { text: 'تأخیر SLA', color: 'error' as const };
|
||||
if (ticket.firstRespondedAt) return { text: 'پاسخ داده شد', color: 'success' as const };
|
||||
if (ticket.slaDueAt) return { text: `مهلت ${formatSla(ticket.slaDueAt)}`, color: 'processing' as const };
|
||||
return { text: '—', color: 'default' as const };
|
||||
}
|
||||
|
||||
const columns = computed<TableColumnsType<TicketDto>>(() => {
|
||||
const all: TableColumnsType<TicketDto> = [
|
||||
{ title: 'موضوع', key: 'موضوع' },
|
||||
{ title: 'شماره', key: 'شماره', dataIndex: 'number' },
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'اولویت', key: 'اولویت' },
|
||||
{ title: 'مشتری / مسئول', key: 'مشتری / مسئول' },
|
||||
{ title: 'SLA', key: 'SLA' },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!TICKET_COL_LABELS.includes(key as (typeof TICKET_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
filterOpen.value = false;
|
||||
try {
|
||||
const { data: res } = await api.get<PaginatedResponse<TicketDto>>('/tickets', {
|
||||
params: {
|
||||
@@ -98,9 +174,8 @@ function onSearch() {
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(next: TicketStatus | 'all') {
|
||||
status.value = next;
|
||||
filterOpen.value = false;
|
||||
function onStatusChange(value: unknown) {
|
||||
status.value = (value == null ? 'all' : String(value)) as TicketStatus | 'all';
|
||||
onSearch();
|
||||
}
|
||||
|
||||
@@ -109,6 +184,14 @@ function toggleSla() {
|
||||
onSearch();
|
||||
}
|
||||
|
||||
function goNew() {
|
||||
void router.push({ name: 'ticket-new' });
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
void router.push({ name: 'ticket-detail', params: { id } });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.q, route.query.status, route.query.sla],
|
||||
() => {
|
||||
@@ -127,581 +210,148 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" @click="filterOpen = false">
|
||||
<UiPageHeader
|
||||
title="تیکتها"
|
||||
lead="درخواستهای پشتیبانی را ثبت و پیگیری کنید."
|
||||
>
|
||||
<template #actions>
|
||||
<RouterLink :to="{ name: 'ticket-new' }">
|
||||
<UiButton variant="primary">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
تیکت جدید
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">تیکتها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
درخواستهای پشتیبانی را ثبت و پیگیری کنید.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button type="primary" @click="goNew">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تیکت جدید
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<div class="tools">
|
||||
<form class="search" @submit.prevent="onSearch">
|
||||
<UiIcon name="search" :size="18" />
|
||||
<input v-model="q" type="search" placeholder="جستجو در موضوع یا شماره…" />
|
||||
</form>
|
||||
<button
|
||||
class="btn ghost sla-btn"
|
||||
type="button"
|
||||
:class="{ on: slaOnly }"
|
||||
@click="toggleSla"
|
||||
>
|
||||
فقط SLA
|
||||
</button>
|
||||
<div class="filter-wrap" @click.stop>
|
||||
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
<span class="btn-full">{{ statusFilterLabel }}</span>
|
||||
<span class="btn-short">وضعیت</span>
|
||||
<UiIcon name="chevron" :size="14" class="chev" />
|
||||
</button>
|
||||
<div v-if="filterOpen" class="popover">
|
||||
<button type="button" :class="{ on: status === 'all' }" @click="setStatus('all')">
|
||||
همه وضعیتها
|
||||
</button>
|
||||
<button
|
||||
v-for="(label, key) in statusLabel"
|
||||
:key="key"
|
||||
type="button"
|
||||
:class="{ on: status === key }"
|
||||
@click="setStatus(key as TicketStatus)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="onSearch">
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="12" :lg="10">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو در موضوع یا شماره…"
|
||||
style="width: 100%"
|
||||
@pressEnter="onSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="6">
|
||||
<a-form-item label="وضعیت">
|
||||
<a-select
|
||||
:value="status"
|
||||
:options="statusOptions"
|
||||
style="width: 100%"
|
||||
@update:value="onStatusChange"
|
||||
>
|
||||
<template #suffixIcon><FilterOutlined /></template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="8">
|
||||
<a-form-item label=" ">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button :type="slaOnly ? 'primary' : 'default'" @click="toggleSla">
|
||||
فقط SLA
|
||||
</a-button>
|
||||
<a-button type="primary" html-type="submit">اعمال</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="listEmpty"
|
||||
title="هنوز تیکتی ثبت نشده است"
|
||||
description="اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند."
|
||||
>
|
||||
<template #action>
|
||||
<RouterLink :to="{ name: 'ticket-new' }">
|
||||
<UiButton variant="primary">
|
||||
<UiIcon name="plus" :size="16" />
|
||||
تیکت جدید
|
||||
</UiButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
|
||||
<template v-else>
|
||||
<div class="list-head">
|
||||
<strong>{{ toFaDigits(items.length) }} تیکت</strong>
|
||||
</div>
|
||||
|
||||
<div class="desktop-only">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="tickets" align="end" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap desktop-only">
|
||||
<table v-table-cols="'tickets'" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>موضوع</th>
|
||||
<th>شماره</th>
|
||||
<th>وضعیت</th>
|
||||
<th>اولویت</th>
|
||||
<th>مشتری / مسئول</th>
|
||||
<th>SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ticket in items" :key="ticket.id" :class="{ breached: ticket.slaBreached }">
|
||||
<td>
|
||||
<RouterLink class="title" :to="{ name: 'ticket-detail', params: { id: ticket.id } }">
|
||||
{{ ticket.subject }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="muted">{{ ticket.number }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="ticket.status">
|
||||
{{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="prio" :class="ticket.priority">
|
||||
{{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">
|
||||
<span v-if="ticket.person">{{ ticket.person.fullName }}</span>
|
||||
<span v-if="ticket.person && ticket.assignee"> · </span>
|
||||
<span v-if="ticket.assignee">{{ ticket.assignee.fullName }}</span>
|
||||
<span v-if="!ticket.person && !ticket.assignee">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="ticket.slaBreached" class="sla bad">تأخیر SLA</span>
|
||||
<span v-else-if="ticket.firstRespondedAt" class="sla ok">پاسخ داده شد</span>
|
||||
<span v-else-if="ticket.slaDueAt" class="sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="cards mobile-only">
|
||||
<RouterLink
|
||||
v-for="ticket in items"
|
||||
:key="ticket.id"
|
||||
class="m-card"
|
||||
:class="{ breached: ticket.slaBreached }"
|
||||
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="listEmpty" :bordered="false">
|
||||
<a-empty
|
||||
description="هنوز تیکتی ثبت نشده است. اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند."
|
||||
>
|
||||
<div class="m-top">
|
||||
<strong>{{ ticket.subject }}</strong>
|
||||
<span class="badge" :class="ticket.status">
|
||||
{{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">
|
||||
{{ ticket.number }} ·
|
||||
{{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
|
||||
</p>
|
||||
<p v-if="ticket.person || ticket.assignee" class="meta">
|
||||
<span v-if="ticket.person">{{ ticket.person.fullName }}</span>
|
||||
<span v-if="ticket.assignee"> · {{ ticket.assignee.fullName }}</span>
|
||||
</p>
|
||||
<div class="m-foot">
|
||||
<span v-if="ticket.slaBreached" class="sla bad">تأخیر SLA</span>
|
||||
<span v-else-if="ticket.firstRespondedAt" class="sla ok">پاسخ داده شد</span>
|
||||
<span v-else-if="ticket.slaDueAt" class="sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<a-button type="primary" @click="goNew">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تیکت جدید
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</a-card>
|
||||
|
||||
<a-card v-else-if="!error" :bordered="false">
|
||||
<a-flex justify="space-between" align="center" :gap="12" :style="{ marginBottom: '12px' }">
|
||||
<a-typography-text strong>
|
||||
{{ toFaDigits(items.length) }} تیکت
|
||||
</a-typography-text>
|
||||
<TableColumnsMenu table-key="tickets" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 720 }"
|
||||
:custom-row="(record: TicketDto) => ({
|
||||
style: record.slaBreached
|
||||
? { background: 'var(--ant-color-error-bg)' }
|
||||
: undefined,
|
||||
onClick: () => goDetail(record.id),
|
||||
})"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'موضوع'">
|
||||
<a-typography-link @click.stop="goDetail((record as TicketDto).id)">
|
||||
{{ (record as TicketDto).subject }}
|
||||
</a-typography-link>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شماره'">
|
||||
<a-typography-text type="secondary">
|
||||
{{ (record as TicketDto).number }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag
|
||||
:color="statusTagColor((record as TicketDto).status)"
|
||||
:bordered="false"
|
||||
>
|
||||
{{
|
||||
statusLabel[(record as TicketDto).status as TicketStatus] ||
|
||||
(record as TicketDto).status
|
||||
}}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'اولویت'">
|
||||
<a-tag
|
||||
:color="priorityTagColor((record as TicketDto).priority)"
|
||||
:bordered="false"
|
||||
>
|
||||
{{
|
||||
priorityLabel[(record as TicketDto).priority as TicketPriority] ||
|
||||
(record as TicketDto).priority
|
||||
}}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مشتری / مسئول'">
|
||||
<a-typography-text type="secondary">
|
||||
{{ personAssigneeText(record as TicketDto) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'SLA'">
|
||||
<a-tag
|
||||
v-if="slaText(record as TicketDto).text !== '—'"
|
||||
:color="slaText(record as TicketDto).color"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ slaText(record as TicketDto).text }}
|
||||
</a-tag>
|
||||
<a-typography-text v-else type="secondary">—</a-typography-text>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted,
|
||||
.error,
|
||||
.meta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted,
|
||||
.meta {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-decoration: none;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.28);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 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 {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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.ghost.on {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.btn.wide {
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.sla-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-btn .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.btn-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-end: 0;
|
||||
min-width: 11rem;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.popover button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.9rem 1rem;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.data-table tr.breached td {
|
||||
background: color-mix(in srgb, #fef2f2 55%, #fff);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.prio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.open,
|
||||
.prio.normal {
|
||||
background: #ecfeff;
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.badge.in_progress {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.badge.waiting {
|
||||
background: #fefce8;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.badge.resolved,
|
||||
.badge.closed {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.prio.low {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.prio.high,
|
||||
.prio.urgent {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.sla {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.sla.bad {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.sla.ok {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-card.breached {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, #b91c1c 25%, transparent),
|
||||
0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.m-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.m-top strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m-foot {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tools {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-short {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.add-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
}
|
||||
|
||||
.sla-btn {
|
||||
font-size: 0.85rem;
|
||||
padding-inline: 0.7rem;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user