Files
admin-web/src/modules/customers/pages/CustomerDetailView.vue
T

357 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Building2, FileCheck2, FileText, UserRound, UsersRound } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
changePlatformTenantStatus,
downloadTenantQualification,
getPlatformTenant,
listPlatformTenantMembers,
listTenantQualifications,
qualificationFileName,
type PlatformTenantQualification,
type PlatformTenantMember,
type PlatformTenantResource,
type PlatformTenantStatus,
} from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { customerDialogTitle } from '../customerDialogTitle'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
import { CustomerAuditLogsPanel } from '@/modules/audit'
type DetailTab = 'basic' | 'billing' | 'logs'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const tenantId = computed(() => String(route.params.tenantId ?? ''))
const activeTab = ref<DetailTab>('basic')
const loading = ref(true)
const statusChanging = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customer = ref<PlatformTenantResource | null>(null)
const qualifications = ref<PlatformTenantQualification[]>([])
const members = ref<PlatformTenantMember[]>([])
let detailRequestSerial = 0
let disposed = false
const previews = ref<Record<string, PreviewState>>({})
const previewOpen = ref(false)
const previewName = ref('')
const previewUrl = ref('')
const previewKind = ref<'image' | 'pdf'>('image')
const tabList = ref<HTMLElement | null>(null)
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'billing', label: '计费规则', icon: '⚖️' },
{ key: 'logs', label: '操作日志', icon: '📝' },
]
const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType)))
function displayText(value?: string | null) { return value?.trim() || '—' }
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatCapital(value: number | string | null, currency: string | null) {
if (value === null || value === '') return '—'
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return '—'
const label = currency === 'USD' ? '美元' : currency === 'HKD' ? '港币' : '人民币'
return `${numericValue.toLocaleString('zh-CN')} ${label}`
}
function statusMeta(status: PlatformTenantStatus) {
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
if (status === 'ARCHIVED') return { label: '已归档', type: 'info' as const }
return { label: '已停用', type: 'info' as const }
}
function qualificationLabel(type: string) {
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type] || '未知资质类型'
}
function qualificationStatus(status: PlatformTenantQualification['status']) {
if (status === 'APPROVED') return { label: '已通过', type: 'success' as const }
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
return { label: '待审核', type: 'warning' as const }
}
function qualificationName(item: PlatformTenantQualification) {
try { return qualificationFileName(item.documentUrl) }
catch { return `${qualificationLabel(item.qualificationType)}文件` }
}
function clearPreviews() {
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
previews.value = {}
previewOpen.value = false
}
async function loadPreview(item: PlatformTenantQualification, notify = true) {
const state = previews.value[String(item.id)]
if (!state || state.loading) return false
state.loading = true
try {
const blob = await downloadTenantQualification(tenantId.value, item.documentUrl)
if (!blob.size) throw new Error('资质文件内容为空')
if (state.url) URL.revokeObjectURL(state.url)
state.url = URL.createObjectURL(blob)
state.kind = blob.type === 'application/pdf' || qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
state.failed = false
return true
} catch (error) {
state.failed = true
if (notify) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败'))
return false
} finally { state.loading = false }
}
async function openPreview(item: PlatformTenantQualification) {
const state = previews.value[String(item.id)]
if (!state) return
if (!state.url) await loadPreview(item)
if (!state.url) return
previewName.value = qualificationName(item)
previewUrl.value = state.url
previewKind.value = state.kind
previewOpen.value = true
}
async function loadDetail() {
const requestedTenantId = tenantId.value
const serial = ++detailRequestSerial
loading.value = true
loadError.value = null
clearPreviews()
customer.value = null
qualifications.value = []
members.value = []
try {
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
getPlatformTenant(requestedTenantId),
listTenantQualifications(requestedTenantId),
listPlatformTenantMembers(requestedTenantId),
])
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
customer.value = tenantResult
qualifications.value = qualificationResult
members.value = memberResult
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
} satisfies PreviewState]))
} catch (error) {
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
customer.value = null
qualifications.value = []
members.value = []
loadError.value = getApiErrorDetails(error, '客户详情加载失败,请稍后重试')
} finally {
if (!disposed && serial === detailRequestSerial) loading.value = false
}
}
async function changeStatus() {
if (!customer.value || statusChanging.value) return
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, customerDialogTitle(`${verb}客户`, customer.value), {
customClass: 'admin-density-confirm admin-workbench', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
})
statusChanging.value = true
customer.value = await changePlatformTenantStatus(tenantId.value, next)
ElMessage.success(`客户已${verb}`)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, `客户${verb}失败`))
} finally { statusChanging.value = false }
}
function goEdit() { void router.push({ name: 'customers', query: { edit: tenantId.value } }) }
function selectTab(key: DetailTab, button?: HTMLElement) {
activeTab.value = key
button?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
function handleTabKeydown(event: KeyboardEvent, key: DetailTab) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const current = tabs.findIndex((item) => item.key === key)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (current + 1) % tabs.length : (current - 1 + tabs.length) % tabs.length
const button = tabList.value?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]
button?.focus()
selectTab(tabs[next].key, button)
}
watch(tenantId, (value) => {
activeTab.value = 'basic'
if (value) void loadDetail()
else {
detailRequestSerial += 1
loading.value = false
customer.value = null
qualifications.value = []
members.value = []
}
}, { immediate: true })
onBeforeUnmount(() => {
disposed = true
detailRequestSerial += 1
clearPreviews()
})
</script>
<template>
<!--
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
STORY: 运营先确认企业状态,再查看客户资料、计费规则和操作日志。
FIRST VIEWPORT: 客户身份与动作居首,三个页签形成稳定内容索引。
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<AdminPage as="div" class="customer-detail-page admin-density customer-density admin-workbench">
<LoadErrorState v-if="loadError" title="客户详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<DetailPageLayout v-else v-loading="loading" aria-label="客户详情">
<template #header>
<header class="detail-header">
<el-button link class="back-button density-back" @click="router.push({ name: 'customers' })">返回列表</el-button>
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || '客户资料'">
<template #actions>
<div v-if="customer" class="header-actions">
<el-tag :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
<el-button v-if="access.can('platform:customer:edit') && customer.status !== 'ARCHIVED'" @click="goEdit">编辑信息</el-button>
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus">{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
</div>
</template>
</PageToolbar>
</header>
</template>
<!-- <template #summary>
<section class="overview-strip" aria-label="客户概况">
<CustomerOperatingMetricCard title="累计运单" :value="String(orderStatistics.data?.totalSubmittedOrders ?? '')" unit="票" :detail="`本月 ${orderStatistics.data?.currentMonthSubmittedOrders ?? 0} 票`" tone="blue" :loading="orderStatistics.loading" :failed="orderStatistics.failed" :unauthorized="!access.can('platform:order:list')"><template #icon><Ship :size="20" /></template></CustomerOperatingMetricCard>
<CustomerOperatingMetricCard title="累计应收" :value="cnyWan(financeStatistics.data?.totalReceivable)" unit="万元" :detail="`未结清 ${cnyWan(financeStatistics.data?.outstandingReceivable)} 万元`" tone="green" :loading="financeStatistics.loading" :failed="financeStatistics.failed" :unauthorized="!access.can('platform:finance:customer-account:list')"><template #icon><CircleDollarSign :size="20" /></template></CustomerOperatingMetricCard>
<CustomerOperatingMetricCard title="在途柜量" :value="decimalText(transitStatistics.data?.totalTeu)" unit="TEU" :detail="`${transitStatistics.data?.containerCount ?? 0} 柜${transitStatistics.data?.unknownContainerCount ? ` · 另有 ${transitStatistics.data.unknownContainerCount} 柜柜型未知,未计入TEU` : ''}`" tone="amber" :loading="transitStatistics.loading" :failed="transitStatistics.failed" :unauthorized="!access.can('platform:firstleg:container:list')"><template #icon><Container :size="20" /></template></CustomerOperatingMetricCard>
</section>
</template> -->
<div ref="tabList" class="detail-tabs density-tabs" role="tablist" aria-label="客户详情模块">
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-label="tab.label" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
</div>
<div v-if="customer" :id="`customer-panel-${activeTab}`" class="tab-panel" role="tabpanel" :aria-labelledby="`customer-tab-${activeTab}`" tabindex="0">
<template v-if="activeTab === 'basic'">
<div class="basic-layout">
<section class="detail-panel company-panel">
<div class="panel-title"><div><h2>企业基本信息</h2></div><Building2 :size="19" /></div>
<dl class="info-grid density-fields">
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
<div><dt>客户昵称</dt><dd>{{ displayText(customer.customerNickname) }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ displayText(customer.unifiedSocialCreditCode) }}</dd></div>
<div><dt>企业类型</dt><dd>{{ displayText(customer.enterpriseType) }}</dd></div>
<div><dt>法定代表人</dt><dd>{{ displayText(customer.legalRepresentative) }}</dd></div>
<div><dt>成立日期</dt><dd>{{ formatDate(customer.establishedDate).slice(0, 10) }}</dd></div>
<div><dt>注册资本</dt><dd>{{ formatCapital(customer.registeredCapital, customer.registeredCapitalCurrency) }}</dd></div>
<div class="wide"><dt>注册地址</dt><dd>{{ displayText(customer.registeredAddress) }}</dd></div>
</dl>
</section>
<section class="detail-panel qualification-panel">
<div class="panel-title"><div><h2>资质文件</h2><p>共 {{ qualifications.length }} 份</p></div><FileText :size="19" /></div>
<div v-if="qualifications.length" class="qualification-list">
<button v-for="item in qualifications" :key="String(item.id)" type="button" :disabled="previews[String(item.id)]?.loading" @click="openPreview(item)">
<span><strong>{{ qualificationLabel(item.qualificationType) }}</strong><small>{{ qualificationName(item) }}</small></span><el-tag :type="qualificationStatus(item.status).type" effect="light" size="small">{{ qualificationStatus(item.status).label }}</el-tag><span class="file-date">{{ item.validTo || '长期有效' }}</span><span class="file-preview-label">预览</span>
</button>
</div>
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
</section>
<section class="detail-panel contact-panel">
<div class="panel-title"><div><h2>联系与合作信息</h2></div><UserRound :size="19" /></div>
<dl class="info-grid info-grid--three density-fields">
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
<div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div>
<div><dt>销售人员</dt><dd>{{ displayText(customer.salesOwnerName) }}</dd></div><div><dt>入驻时间</dt><dd>{{ formatDate(customer.createdAt) }}</dd></div>
<div class="wide"><dt>内部备注</dt><dd>{{ displayText(customer.internalRemark) }}</dd></div>
</dl>
</section>
<section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户<InlineHelp title="账号信息说明"><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></InlineHelp></h2></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap workbench-evidence">
<table class="density-table"><thead><tr><th>账号</th><th>状态</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
<td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td>
<td><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
<td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td>
<td>{{ displayText(member.phone) }}</td>
<td>{{ displayText(member.email) }}</td>
<td><div v-if="member.roleNames?.length" class="role-list"><el-tag v-for="role in member.roleNames" :key="role" size="small" effect="plain">{{ role }}</el-tag></div><span v-else>—</span></td>
</tr></tbody>
</table>
</div>
<div v-else class="section-empty"><UsersRound :size="22" /><strong>暂无企业用户</strong><span>当前客户尚未创建企业成员账号</span></div>
</section>
</div>
</template>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" :dimension-initialization-status="customer.dimensionInitializationStatus" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
</div>
</DetailPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog customer-qualification-preview admin-workbench is-dialog" v-model="previewOpen" :title="customerDialogTitle('查看资质文件', customer)" fullscreenable width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
<p class="density-dialog-note customer-preview-filename">{{ previewName }}</p>
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.detail-tabs { border-bottom: 1px solid var(--workbench-border); }
.customer-detail-page { min-width: 0; }
.detail-header { display:flex; min-width:0; align-items:center; gap:var(--admin-space-5, 12px); }
.back-button { flex:0 0 auto; }
.detail-page-toolbar { min-width:0; flex:1; }
.header-actions { display:flex; flex:0 0 auto; gap:var(--admin-space-3, 8px); }
.header-actions :deep(.el-button + .el-button) { margin-left:0; }
.basic-layout { display:grid; align-items:start; }
.detail-panel { min-width:0; }
.contact-panel { grid-column:1 / -1; }
.user-panel { grid-column:1 / -1; }
.panel-title { display:flex; justify-content:space-between; border-bottom:1px solid var(--workbench-border); }
.panel-title h2 { margin:0; color:var(--workbench-text); }.panel-title p { margin:5px 0 0; color:var(--workbench-muted); }.panel-title > svg { color:var(--workbench-primary); }
.info-grid { margin:0; }.info-grid > div.wide { grid-column:1 / -1; }
.qualification-list { display:flex; flex-direction:column; }
.qualification-list button { display:grid; width:100%; align-items:center; background:var(--workbench-surface); color:inherit; text-align:left; cursor:pointer; }
.qualification-list button:hover { border-color:var(--workbench-accent-border); background:var(--workbench-surface-soft); }.qualification-list button:disabled { cursor:wait; opacity:.65; }
.qualification-list strong { display:block; }
.qualification-list small { display:block; }.qualification-list small { margin-top:3px; color:var(--workbench-muted); }.file-date { margin-top:3px; color:var(--workbench-muted); }.file-date { margin:0; white-space:nowrap; }
.section-empty { display:flex; align-items:center; justify-content:center; flex-direction:column; color:var(--workbench-muted); text-align:center; }
.module-empty { display:flex; align-items:center; justify-content:center; flex-direction:column; color:var(--workbench-muted); text-align:center; }
.section-empty strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); }
.module-empty strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); }.section-empty span { max-width:600px; margin:0; color:var(--workbench-muted); line-height:1.7; }.module-empty p { max-width:600px; margin:0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
.section-empty button { border:0; background:transparent; color:var(--workbench-primary); cursor:pointer; }.section-empty.is-error svg { color:#d92d20; }
.user-table-wrap { overflow-x:auto; }
table { min-width:720px; }
.user-identity { display:flex; align-items:center; gap:9px; }.user-identity > span { width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:50%; background:var(--workbench-accent-soft); color:var(--workbench-primary); font-weight:700; }.user-identity strong { display:block; }.user-identity small { display:block; }.user-identity small { margin-top:3px; color:var(--workbench-muted); }.role-list { display:flex; flex-wrap:wrap; gap:5px; }
.member-contact span { display:block; }
.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--workbench-muted); }.module-empty > svg { color:var(--workbench-muted); }.module-empty p { max-width:520px; font-size:var(--admin-font-caption, 12px); }
.contract-note { padding:var(--admin-space-6, 16px) 18px; border-radius:10px; background:var(--workbench-surface-soft); }.contract-note strong { color:var(--workbench-text); font-size:var(--admin-font-caption, 12px); }.contract-note p { margin:5px 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; }.info-grid--three { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
</style>