Files
admin-web/src/modules/appointment-calendar/AppointmentCalendarView.vue
T

276 lines
29 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/adminDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
import type { LeaveGuard } from './useWorkDraft'
import WorkCalendarPanel from './WorkCalendarPanel.vue'
import WorkDetailPanel from './WorkDetailPanel.vue'
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
import type { PlanningSourceLookup } from './planningApi'
import { appointmentExternalTarget } from './workNavigation'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
const savedAnchor = stringQuery('workAnchor')
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
const anchor = ref(initialAnchor())
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
const navigationError = ref('')
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
const range = computed(() => calendarRange(anchor.value, mode.value))
const heading = computed(() => {
const value = scope.value
if (!value) return ''
if (value.kind === 'global') return viewLabels[value.view]
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
if (value.kind === 'container') return `${value.label} · 本柜待办`
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
return `查找:${value.keyword}`
})
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
const panelExpanded = ref(false), panelHeading = ref<HTMLElement | null>(null), panelBody = ref<HTMLElement | null>(null)
const selectedDate = computed(() => scope.value?.kind === 'day' || scope.value?.kind === 'arrivals' ? scope.value.date : undefined)
const selectedType = computed(() => scope.value?.kind === 'day' && scope.value.types.length === 1 ? scope.value.types[0] : undefined)
const panelTitle = computed(() => sourceLink.value ? '货源与预约' : detailKey.value ? '事项详情' : heading.value)
const listVisible = computed(() => !sourceLink.value && !detailKey.value && scope.value?.kind !== 'arrivals')
let panelTrigger: HTMLElement | null = null
watch(panelOpen, async open => {
if (open) {
panelTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick(); panelHeading.value?.focus()
} else {
panelExpanded.value = false
await nextTick(); if (panelTrigger?.isConnected) panelTrigger.focus()
}
})
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
async function loadCalendar() {
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
if (!types.value.length) { calendarLoading.value = false; return }
calendarLoading.value = true
try {
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
if (request === calendarRequest) calendar.value = result
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
finally { if (request === calendarRequest) calendarLoading.value = false }
}
async function loadSummary() {
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
finally { if (request === summaryRequest) summaryLoading.value = false }
}
function itemQuery(): WorkItemsQuery | null {
const value = scope.value
if (!value || value.kind === 'arrivals') return null
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
if (value.kind === 'search') query.keyword = value.keyword
return query
}
async function loadItems() {
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
itemsLoading.value = true
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
finally { if (request === itemsRequest) itemsLoading.value = false }
}
async function loadArrivals() {
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
if (!reference.value) { referenceLoading.value = false; return }
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
referenceLoading.value = true
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
finally { if (request === referenceRequest) referenceLoading.value = false }
}
async function setScope(value: Scope) {
if (!await canLeave()) return
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
}
async function openItem(key: string, action?: WorkAction) {
if (!await canLeave()) return
navigationError.value = ''
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
}
async function back() {
if (!await canLeave()) return
const origin = origins.value.pop()
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
if (!origin) await loadItems()
}
async function closePanel() {
if (!await canLeave()) return
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
}
async function filterCalendar(change: () => void) {
if (!await canLeave()) return
change()
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
await Promise.all([loadCalendar(), loadArrivals()])
}
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
async function changePage(next: number) { page.value = next; await loadItems() }
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
const container = result.item.containers[0]
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
} else { detailKey.value = origins.value.pop() || null; initialAction.value = null; sourceLink.value = null }
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
}
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
async function external(path: string) {
if (!await canLeave()) return
const value = scope.value
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(path, context))
}
onMounted(() => {
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
detailKey.value = stringQuery('workItem') || null
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
})
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
sourceLink.value = null; linkError.value = ''
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
if (sourceKey || orderId || customerId || destinationKey) {
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
}
}, { immediate: true })
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
</script>
<template>
<AdminPage class="admin-density appointment-work" spacing="compact">
<div class="work-toolbar">
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
</div>
<p v-if="linkError" role="alert">{{ linkError }}</p>
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
<div class="work-layout">
<WorkCalendarPanel :selected-date="selectedDate" :selected-type="selectedType" :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })">
<template #inbox>
<nav class="work-inbox" aria-label="全局待办">
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong>{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>不受日历筛选影响 · 数量可重叠</span>
</nav>
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
</template>
</WorkCalendarPanel>
<aside v-if="panelOpen" class="work-panel" :class="{ 'is-expanded': panelExpanded }" aria-label="工作明细与事项操作">
<header class="panel-header">
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><div class="panel-tools"><button type="button" :aria-expanded="panelExpanded" @click="panelExpanded = !panelExpanded">{{ panelExpanded ? '收起面板' : '展开面板' }}</button><button type="button" aria-label="关闭工作面板" @click="closePanel">关闭 ×</button></div></div>
<h3 ref="panelHeading" tabindex="-1">{{ panelTitle }}</h3>
<template v-if="!detailKey && !sourceLink">
<p class="scope-description">{{ scopeDescription }}</p>
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
</template>
</header>
<div ref="panelBody" class="panel-body">
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
<template v-else-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
<template v-else>
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
<p v-if="itemsLoading" role="status">正在读取明细…</p>
<template v-else-if="items && !itemsError">
<article v-for="item in items.records" :key="item.workItemKey" class="work-row">
<div class="work-row-action"><el-button v-if="item.nextAction" type="primary" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div>
<div class="work-row-content"><div class="item-heading"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><span class="item-status">{{ workStatus(item) }}</span></div><p>{{ typeLabels[item.type] }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}<span v-if="item.followupDate"> · 跟进 {{ item.followupDate }}</span></p><p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p></div>
</article>
<p v-if="!items.records.length">当前范围没有工作事项。</p>
</template>
</template>
</div>
<footer v-if="listVisible && items && !itemsError && !itemsLoading" class="panel-footer"><span>共 {{ items.total }} 项</span><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></footer>
</aside>
</div>
</AdminPage>
</template>
<style scoped>
.work-toolbar, .work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; }
.work-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); }
.work-heading h2 { margin: 0; }
.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-inbox { padding: var(--admin-space-2) var(--admin-space-5); }
.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }
.work-inbox strong { font-variant-numeric: tabular-nums; }
.work-search input { width: 280px; }
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }
.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }
.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible, .panel-header h3:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
.work-layout { position: relative; min-width: 0; }
.work-panel { position: absolute; top: 0; right: 0; z-index: 4; width: min(680px, 100%); height: min(720px, calc(100dvh - 260px)); min-height: 320px; box-sizing: border-box; display: flex; flex-direction: column; border: 1px solid var(--cc-border); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); overflow-wrap: anywhere; }
.work-panel.is-expanded { width: 100%; }
.panel-header { flex: none; padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.panel-nav, .panel-tools { display: flex; align-items: center; gap: var(--admin-space-3); }
.panel-tools { margin-left: auto; }
.panel-header h3 { font-size: var(--admin-font-section); margin: var(--admin-space-2) 0; }
.scope-description { margin: var(--admin-space-1) 0; }
.panel-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--admin-space-5) var(--admin-space-3); }
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); font-size: var(--admin-font-caption); }
.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--cc-border); }
.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-2); line-height: 20px; }
.work-row-action :deep(.el-button > span) { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; line-height: inherit; }
.item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
.item-status { flex: none; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); line-height: 1.6; }
.appointment-work .work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-color-primary); background: transparent; font-weight: 600; }
.work-row-content .work-warning { color: var(--el-color-warning-dark-2); }
.work-pagination, .bucket-buttons { display: flex; align-items: center; gap: var(--admin-space-2); flex-wrap: wrap; }
.bucket-buttons { margin-top: var(--admin-space-2); }
.bucket-buttons button { font-size: var(--admin-font-caption); }
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
</style>