Files
calculator2026/src/features/ht/components/Ht.vue
T
2026-03-25 17:18:35 +08:00

1607 lines
59 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 { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
import { useKvStore } from '@/pinia/kv'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import {
formatDateTime,
isEntryRelatedToAnyContract,
normalizeContractsFromPayload,
normalizeOrder,
type ContractItem
} from '@/features/ht/contracts'
import {
applyImportedContractPiniaPayload,
buildContractPiniaPayload,
readContractRelatedForageEntries,
readContractRelatedKeyedEntries
} from '@/features/ht/importExport'
import type {
HourlyMethodStateLike,
HtFeeMainRowLike,
QuantityMethodStateLike,
RateMethodStateLike,
XmBaseInfoState,
XmScaleState
} from '@/features/ht/types'
import {
cloneJson,
CONTRACT_CONSULT_FACTOR_KEY_PREFIX,
CONTRACT_KEY_PREFIX,
CONTRACT_MAJOR_FACTOR_KEY_PREFIX,
CONTRACT_SEGMENT_FILE_EXTENSION,
CONTRACT_SEGMENT_VERSION,
formatExportTimestamp,
generateContractId,
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isContractSegmentPackage,
normalizeContractSegmentPackage,
PROJECT_INFO_KEY,
PROJECT_SCALE_KEY,
PRICING_KEY_PREFIXES,
rewriteKeyWithContractId,
SERVICE_KEY_PREFIX,
type ContractSegmentPackage
} from '@/lib/contractSegment'
import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
ToastAction,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport
} from 'reka-ui'
const STORAGE_KEY = 'ht-card-v1'
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore()
const { t, locale } = useI18n()
const contracts = ref<ContractItem[]>([])
const contractSearchKeyword = ref('')
const isListLayout = ref(false)
const contractDataMenuOpen = ref(false)
const contractDataMenuRef = ref<HTMLElement | null>(null)
const contractImportFileRef = ref<HTMLInputElement | null>(null)
const selectionMode = ref<'none' | 'export' | 'delete'>('none')
const selectedContractIds = ref<string[]>([])
const showCreateModal = ref(false)
const contractNameInput = ref('')
const editingContractId = ref<string | null>(null)
const toastOpen = ref(false)
const toastTitle = ref(t('ht.toastSuccessTitle'))
const toastText = ref('')
const deleteConfirmOpen = ref(false)
const pendingDeleteContractId = ref<string | null>(null)
const messageDialogOpen = ref(false)
const messageDialogTitle = ref('')
const messageDialogDesc = ref('')
const batchDeleteConfirmOpen = ref(false)
const modalOffset = ref({ x: 0, y: 0 })
let dragStartX = 0
let dragStartY = 0
let baseOffsetX = 0
let baseOffsetY = 0
const contractListScrollWrapRef = ref<HTMLElement | null>(null)
const contractListViewportRef = ref<HTMLElement | null>(null)
const showScrollTopFab = ref(false)
const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready')
const canManageContracts = ref(true)
const contractBudgetById = ref<Record<string, number | null>>({})
const contractBudgetLoading = ref(false)
let contractBudgetRefreshSeq = 0
let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
let contractListScrollBoundEl: HTMLElement | null = null
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
const CARD_ENTER_STEP_MS = 58
const CARD_ENTER_DURATION_MS = 560
const CARD_ENTER_MAX_INDEX = 24
const CARD_ENTER_TOTAL_MS = CARD_ENTER_DURATION_MS + CARD_ENTER_STEP_MS * CARD_ENTER_MAX_INDEX + 80
const SCROLL_TOP_FAB_THRESHOLD = 220
const buildDefaultContracts = (): ContractItem[] => [
]
const normalizedSearchKeyword = computed(() => contractSearchKeyword.value.trim().toLowerCase())
const isSelectingContracts = computed(() => selectionMode.value !== 'none')
const selectedContractCount = computed(() => selectedContractIds.value.length)
const hasContracts = computed(() => contracts.value.length > 0)
const filteredContracts = computed(() => {
if (!normalizedSearchKeyword.value) return contracts.value
return contracts.value.filter(item => {
const name = item.name.toLowerCase()
const id = item.id.toLowerCase()
return name.includes(normalizedSearchKeyword.value) || id.includes(normalizedSearchKeyword.value)
})
})
const isSearchingContracts = computed(() => Boolean(normalizedSearchKeyword.value))
const projectTotalBudget = computed(() => {
let hasValid = false
let total = 0
for (const contract of contracts.value) {
const fee = contractBudgetById.value[contract.id]
if (typeof fee !== 'number' || !Number.isFinite(fee)) continue
total += fee
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
})
const budgetRefreshSignature = computed(() => {
const ids = contracts.value.map(item => String(item.id || '').trim()).filter(Boolean)
if (ids.length === 0) return ''
return ids
.map(id => {
const additionalMainKey = `htExtraFee-${id}-additional-work`
const reserveMainKey = `htExtraFee-${id}-reserve`
const contractState = zxFwPricingStore.contracts[id] || null
const addMain = zxFwPricingStore.htFeeMainStates[additionalMainKey] || null
const reserveMain = zxFwPricingStore.htFeeMainStates[reserveMainKey] || null
const addMethods = zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null
const reserveMethods = zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
return JSON.stringify({
id,
contractState,
addMain,
reserveMain,
addMethods,
reserveMethods
})
})
.join('|')
})
const notify = (text: string) => {
toastTitle.value = t('ht.toastSuccessTitle')
toastText.value = text
toastOpen.value = false
requestAnimationFrame(() => {
toastOpen.value = true
})
}
const showMessageDialog = (title: string, description: string) => {
messageDialogTitle.value = title
messageDialogDesc.value = description
messageDialogOpen.value = true
}
const pendingDeleteContractName = computed(() => {
if (!pendingDeleteContractId.value) return ''
const target = contracts.value.find(item => item.id === pendingDeleteContractId.value)
return target?.name || pendingDeleteContractId.value
})
const batchDeleteCount = computed(() => {
const selectedSet = new Set(selectedContractIds.value)
return contracts.value.filter(item => selectedSet.has(item.id)).length
})
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const requestDeleteContract = (id: string) => {
pendingDeleteContractId.value = id
deleteConfirmOpen.value = true
}
const confirmDeleteContract = async () => {
const id = pendingDeleteContractId.value
if (!id) return
await deleteContract(id)
deleteConfirmOpen.value = false
pendingDeleteContractId.value = null
}
const closeContractDataMenu = () => {
contractDataMenuOpen.value = false
}
const loadProjectBaseState = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
canManageContracts.value = Boolean(typeof data?.projectIndustry === 'string' && data.projectIndustry.trim())
} catch (error) {
console.error('load project base state failed:', error)
canManageContracts.value = false
}
}
const getCurrentProjectIndustry = async (): Promise<string> => {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
return typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
}
const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value)
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
: '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let hasValid = false
let total = 0
for (const row of rows) {
const serviceBudget = toFiniteNumber(row?.serviceBudget)
if (serviceBudget != null) {
total += serviceBudget
hasValid = true
continue
}
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumber(row?.personnelCount)
const workday = toFiniteNumber(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
if (subtotal != null) return roundTo(subtotal, 2)
let hasValid = false
let total = 0
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteNumber(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumber(row?.quantity)
const unitPrice = toFiniteNumber(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const parts = [
toFiniteNumber(rateState?.budgetFee),
sumHourlyMethodFee(hourlyState),
sumQuantityMethodFee(quantityState)
]
const total = sumNullableNumbers(parts)
return total == null ? null : roundTo(total, 2)
}
const loadHtMainTotalFee = async (mainStorageKey: string) => {
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
const rowIds = rows
.map(row => String(row?.id || '').trim())
.filter(Boolean)
if (rowIds.length === 0) return null
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
const total = sumNullableNumbers(rowTotals)
return total == null ? null : roundTo(total, 2)
}
const loadContractBudgetFee = async (contractId: string) => {
await zxFwPricingStore.loadContract(contractId)
const serviceFee = zxFwPricingStore.getBaseSubtotal(contractId)
const [additionalFee, reserveFee] = await Promise.all([
loadHtMainTotalFee(`htExtraFee-${contractId}-additional-work`),
loadHtMainTotalFee(`htExtraFee-${contractId}-reserve`)
])
const parts = [serviceFee, additionalFee, reserveFee]
const total = sumNullableNumbers(parts)
return total == null ? null : roundTo(total, 2)
}
const refreshContractBudgets = async () => {
const seq = ++contractBudgetRefreshSeq
const ids = contracts.value.map(item => String(item.id || '').trim()).filter(Boolean)
if (ids.length === 0) {
contractBudgetById.value = {}
contractBudgetLoading.value = false
return
}
contractBudgetLoading.value = true
try {
const nextPairs = await Promise.all(
ids.map(async id => {
const fee = await loadContractBudgetFee(id)
return [id, fee] as const
})
)
if (seq !== contractBudgetRefreshSeq) return
contractBudgetById.value = Object.fromEntries(nextPairs)
} catch (error) {
console.error('refresh contract budgets failed:', error)
} finally {
if (seq === contractBudgetRefreshSeq) {
contractBudgetLoading.value = false
}
}
}
const scheduleRefreshContractBudgets = () => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
budgetRefreshTimer = setTimeout(() => {
void refreshContractBudgets()
}, 80)
}
const formatIndustryLabel = (code: string) => {
const trimmed = code.trim()
const target = industryTypeList.find(item => String(item.id || '').trim() === trimmed)
const name = target ? getIndustryDisplayName(target.id, locale.value) : ''
return name ? `${trimmed} ${name}` : trimmed
}
const isContractSelected = (contractId: string) =>
selectedContractIds.value.includes(contractId)
const toggleContractSelection = (contractId: string) => {
if (!isSelectingContracts.value) return
if (isContractSelected(contractId)) {
selectedContractIds.value = selectedContractIds.value.filter(id => id !== contractId)
return
}
selectedContractIds.value = [...selectedContractIds.value, contractId]
}
const exitContractSelectionMode = () => {
selectionMode.value = 'none'
selectedContractIds.value = []
}
const enterContractExportMode = () => {
if (!hasContracts.value) return
closeContractDataMenu()
selectionMode.value = 'export'
selectedContractIds.value = []
}
const enterContractDeleteMode = () => {
if (!hasContracts.value) return
closeContractDataMenu()
selectionMode.value = 'delete'
selectedContractIds.value = []
}
const triggerContractImport = () => {
if (!canManageContracts.value) return
closeContractDataMenu()
contractImportFileRef.value?.click()
}
const getContractListViewport = () => {
const viewport = contractListScrollWrapRef.value?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
) || null
contractListViewportRef.value = viewport
return viewport
}
const scrollContractsToBottom = (behavior: ScrollBehavior = 'smooth') => {
const viewport = getContractListViewport()
if (!viewport) return
viewport.scrollTo({
top: viewport.scrollHeight,
behavior
})
}
const scrollContractsToTop = (behavior: ScrollBehavior = 'smooth') => {
const viewport = getContractListViewport()
if (!viewport) return
viewport.scrollTo({
top: 0,
behavior
})
}
const updateScrollTopFabVisible = () => {
const viewport = contractListViewportRef.value
showScrollTopFab.value = Boolean(viewport && viewport.scrollTop > SCROLL_TOP_FAB_THRESHOLD)
}
const handleContractListScroll = () => {
updateScrollTopFabVisible()
}
const bindContractListScroll = () => {
const viewport = getContractListViewport()
if (contractListScrollBoundEl === viewport) {
updateScrollTopFabVisible()
return
}
if (contractListScrollBoundEl) {
contractListScrollBoundEl.removeEventListener('scroll', handleContractListScroll)
contractListScrollBoundEl = null
}
if (!viewport) {
showScrollTopFab.value = false
return
}
contractListScrollBoundEl = viewport
contractListScrollBoundEl.addEventListener('scroll', handleContractListScroll, { passive: true })
updateScrollTopFabVisible()
}
const unbindContractListScroll = () => {
if (contractListScrollBoundEl) {
contractListScrollBoundEl.removeEventListener('scroll', handleContractListScroll)
contractListScrollBoundEl = null
}
showScrollTopFab.value = false
}
const triggerCardEnterAnimation = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'enter'
cardEnterTimer = setTimeout(() => {
cardMotionState.value = 'ready'
cardEnterTimer = null
}, CARD_ENTER_TOTAL_MS)
}
const getCardEnterStyle = (index: number) => ({
'--ht-card-enter-delay': `${Math.min(index, CARD_ENTER_MAX_INDEX) * CARD_ENTER_STEP_MS}ms`
})
const getCardSelectStyle = (index: number) => ({
'--ht-card-select-delay': `${(index % 10) * 70}ms`
})
const getContractCardStyle = (index: number) => {
if (isSelectingContracts.value) {
return getCardSelectStyle(index)
}
if (cardMotionState.value === 'enter') {
return getCardEnterStyle(index)
}
return undefined
}
const saveContracts = async () => {
try {
contracts.value = normalizeOrder(contracts.value)
await kvStore.setItem(STORAGE_KEY, JSON.parse(JSON.stringify(contracts.value)))
} catch (error) {
console.error('save contracts failed:', error)
}
}
const initializeContractScaleData = async (contractId: string) => {
const source = await kvStore.getItem<XmScaleState>(PROJECT_SCALE_KEY)
const payload: XmScaleState = {
detailRows: Array.isArray(source?.detailRows) ? cloneJson(source.detailRows) : [],
roughCalcEnabled: Boolean(source?.roughCalcEnabled),
totalAmount:
typeof source?.totalAmount === 'number' && Number.isFinite(source.totalAmount)
? source.totalAmount
: null
}
await kvStore.setItem(`${CONTRACT_KEY_PREFIX}${contractId}`, payload)
}
const exportSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
showMessageDialog(t('ht.tipTitle'), t('ht.selectAtLeastOne'))
return
}
try {
const selectedSet = new Set(selectedContractIds.value)
const selectedContracts = contracts.value
.filter(item => selectedSet.has(item.id))
.map((item, index) => ({
...item,
order: index
}))
const localforageEntries = await readContractRelatedForageEntries(
kvStore,
selectedContracts.map(item => item.id)
)
const keyedEntries = readContractRelatedKeyedEntries(
zxFwPricingStore,
selectedContracts.map(item => item.id)
)
const piniaPayload = await buildContractPiniaPayload(zxFwPricingStore, selectedContracts.map(item => item.id))
const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) {
showMessageDialog(t('ht.exportFailedTitle'), t('ht.industryMissingForExport'))
return
}
const now = new Date()
const payload: ContractSegmentPackage = {
version: CONTRACT_SEGMENT_VERSION,
packageType: 'contract-segments',
exportedAt: now.toISOString(),
project: {
industry: projectIndustry
},
contracts: selectedContracts,
storage: {
localforageEntries,
keyedEntries
},
pinia: {
zxFwPricing: piniaPayload
}
}
const content = await encodeZwArchive(payload)
const binary = new Uint8Array(content.length)
binary.set(content)
const blob = new Blob([binary], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `contract-segments-${formatExportTimestamp(now)}${CONTRACT_SEGMENT_FILE_EXTENSION}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
notify(t('ht.exportSuccess', { count: selectedContracts.length }))
exitContractSelectionMode()
} catch (error) {
console.error('export selected contracts failed:', error)
showMessageDialog(t('ht.exportFailedTitle'), t('ht.retry'))
}
}
const importContractSegments = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const name = file.name.toLowerCase()
if (!name.endsWith(CONTRACT_SEGMENT_FILE_EXTENSION) && !name.endsWith('.zw')) {
throw new Error('INVALID_FILE_EXTENSION')
}
const buffer = await file.arrayBuffer()
const payload = await decodeZwArchive<ContractSegmentPackage>(buffer)
if (!isContractSegmentPackage(payload)) {
throw new Error('INVALID_CONTRACT_SEGMENT_PAYLOAD')
}
const normalizedPackage = normalizeContractSegmentPackage(payload)
const currentProjectIndustry = await getCurrentProjectIndustry()
if (!currentProjectIndustry) {
throw new Error('CURRENT_PROJECT_INDUSTRY_MISSING')
}
if (!normalizedPackage.projectIndustry) {
throw new Error('IMPORT_PACKAGE_INDUSTRY_MISSING')
}
if (normalizedPackage.projectIndustry !== currentProjectIndustry) {
throw new Error(`PROJECT_INDUSTRY_MISMATCH:${normalizedPackage.projectIndustry}:${currentProjectIndustry}`)
}
const importedContracts = normalizeContractsFromPayload(payload.contracts)
if (importedContracts.length === 0) {
throw new Error('EMPTY_CONTRACTS')
}
const importedEntries = normalizedPackage.localforageEntries
const importedKeyedEntries = normalizedPackage.keyedEntries
const importedContractIdSet = new Set(importedContracts.map(item => String(item.id || '').trim()).filter(Boolean))
const filteredImportedEntries = importedEntries.filter(entry =>
isEntryRelatedToAnyContract(entry.key, importedContractIdSet, isContractRelatedForageKey)
)
const filteredImportedKeyedEntries = importedKeyedEntries.filter(entry =>
isEntryRelatedToAnyContract(entry.key, importedContractIdSet, isContractRelatedKeyedStateKey)
)
const usedIds = new Set(contracts.value.map(item => item.id))
const oldToNewIdMap = new Map<string, string>()
const nextContracts: ContractItem[] = importedContracts.map((item, index) => {
const nextId = generateContractId(usedIds)
oldToNewIdMap.set(item.id, nextId)
return {
...item,
id: nextId,
order: contracts.value.length + index,
createdAt: item.createdAt || new Date().toISOString()
}
})
const rewrittenEntries = filteredImportedEntries.map(entry => {
let nextKey = entry.key
for (const [oldId, newId] of oldToNewIdMap.entries()) {
if (!nextKey.includes(oldId)) continue
nextKey = rewriteKeyWithContractId(nextKey, oldId, newId)
}
return {
key: nextKey,
value: entry.value
}
})
const rewrittenKeyedEntries = filteredImportedKeyedEntries.map(entry => {
let nextKey = entry.key
for (const [oldId, newId] of oldToNewIdMap.entries()) {
if (!nextKey.includes(oldId)) continue
nextKey = rewriteKeyWithContractId(nextKey, oldId, newId)
}
return {
key: nextKey,
value: entry.value
}
})
await Promise.all(rewrittenEntries.map(entry => kvStore.setItem(entry.key, entry.value)))
for (const entry of rewrittenKeyedEntries) {
zxFwPricingStore.setKeyState(entry.key, cloneJson(entry.value), { force: true })
}
await applyImportedContractPiniaPayload(zxFwPricingStore, normalizedPackage.piniaState, oldToNewIdMap)
contracts.value = [...contracts.value, ...nextContracts]
await saveContracts()
await Promise.all([
zxFwPricingStore.$persistNow?.(),
zxFwPricingKeysStore.$persistNow?.(),
zxFwPricingHtFeeStore.$persistNow?.()
])
await refreshContractBudgets()
notify(t('ht.importSuccess', { count: nextContracts.length }))
await nextTick()
scrollContractsToBottom()
} catch (error) {
console.error('import contract segments failed:', error)
const message = error instanceof Error ? error.message : ''
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
const [, importIndustry = '', currentIndustry = ''] = message.split(':')
showMessageDialog(
t('ht.importFailedTitle'),
t('ht.importIndustryMismatch', {
importIndustry: formatIndustryLabel(importIndustry),
currentIndustry: formatIndustryLabel(currentIndustry)
})
)
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
showMessageDialog(t('ht.importFailedTitle'), t('ht.importCurrentIndustryMissing'))
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
showMessageDialog(t('ht.importFailedTitle'), t('ht.importPackageIndustryMissing'))
} else {
showMessageDialog(t('ht.importFailedTitle'), t('ht.importFileInvalid'))
}
} finally {
input.value = ''
}
}
const removeForageKeysByContractId = async (store: typeof kvStore, contractId: string) => {
try {
const keys = await store.keys()
const targetKeys = keys.filter(key => key.includes(contractId))
await Promise.all(targetKeys.map(key => store.removeItem(key)))
} catch (error) {
console.error('remove forage keys by contract id failed:', contractId, error)
}
}
const removeRelatedTabsByContractId = (contractId: string) => {
const relatedTabIds = tabStore.tabs
.filter(tab => {
const propsContractId = tab?.props?.contractId
return (
tab.id === `contract-${contractId}` ||
tab.id.startsWith(`zxfw-edit-${contractId}-`) ||
propsContractId === contractId
)
})
.map(tab => tab.id)
for (const tabId of relatedTabIds) {
tabStore.removeTab(tabId)
}
}
const cleanupContractRelatedData = async (contractId: string) => {
zxFwPricingStore.removeContractData(contractId)
await Promise.all([
removeForageKeysByContractId(kvStore, contractId),
])
await Promise.all([
zxFwPricingStore.$persistNow?.(),
zxFwPricingKeysStore.$persistNow?.(),
zxFwPricingHtFeeStore.$persistNow?.()
])
}
const loadContracts = async () => {
try {
const saved = await kvStore.getItem<ContractItem[]>(STORAGE_KEY)
if (!saved || saved.length === 0) {
contracts.value = buildDefaultContracts()
await saveContracts()
return
}
const sorted = [...saved].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
contracts.value = normalizeOrder(sorted)
} catch (error) {
console.error('load contracts failed:', error)
contracts.value = buildDefaultContracts()
}
}
const openCreateModal = () => {
if (!canManageContracts.value) return
closeContractDataMenu()
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const openEditModal = (item: ContractItem) => {
closeContractDataMenu()
editingContractId.value = item.id
contractNameInput.value = item.name
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const closeCreateModal = () => {
showCreateModal.value = false
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
}
const createContract = async () => {
const name = contractNameInput.value.trim()
if (!name) return
if (editingContractId.value) {
const current = contracts.value.find(item => item.id === editingContractId.value)
if (!current) return
if (current.name === name) {
closeCreateModal()
return
}
contracts.value = contracts.value.map(item =>
item.id === editingContractId.value ? { ...item, name } : item
)
await saveContracts()
notify(t('ht.editSuccess'))
closeCreateModal()
return
}
const newContract: ContractItem = {
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name,
order: contracts.value.length,
createdAt: new Date().toISOString()
}
contracts.value = [...contracts.value, newContract]
await saveContracts()
try {
await initializeContractScaleData(newContract.id)
} catch (error) {
console.error('initialize contract scale failed:', error)
}
await refreshContractBudgets()
notify(t('ht.createSuccess'))
closeCreateModal()
await nextTick()
scrollContractsToBottom()
}
const deleteContract = async (id: string) => {
// 先关闭合同段及其咨询服务相关 tab,避免页面仍在运行时继续写回缓存
removeRelatedTabsByContractId(id)
await nextTick()
await cleanupContractRelatedData(id)
// 组件卸载时会异步保存一次,延迟再清一遍避免数据回写
await new Promise(resolve => setTimeout(resolve, 80))
await cleanupContractRelatedData(id)
contracts.value = contracts.value.filter(item => item.id !== id)
selectedContractIds.value = selectedContractIds.value.filter(item => item !== id)
await saveContracts()
await refreshContractBudgets()
notify(t('ht.deleteSuccess'))
}
const deleteSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
showMessageDialog(t('ht.tipTitle'), t('ht.selectAtLeastOne'))
return
}
const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) {
showMessageDialog(t('ht.tipTitle'), t('ht.noContractsToDelete'))
return
}
batchDeleteConfirmOpen.value = true
}
const confirmDeleteSelectedContracts = async () => {
const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) {
batchDeleteConfirmOpen.value = false
showMessageDialog(t('ht.tipTitle'), t('ht.noContractsToDelete'))
return
}
try {
const targetIds = targets.map(item => item.id)
for (const id of targetIds) {
removeRelatedTabsByContractId(id)
}
await nextTick()
for (const id of targetIds) {
await cleanupContractRelatedData(id)
}
await new Promise(resolve => setTimeout(resolve, 80))
for (const id of targetIds) {
await cleanupContractRelatedData(id)
}
contracts.value = contracts.value.filter(item => !selectedSet.has(item.id))
selectedContractIds.value = selectedContractIds.value.filter(item => !selectedSet.has(item))
await saveContracts()
await refreshContractBudgets()
notify(t('ht.deleteBatchSuccess', { count: targetIds.length }))
exitContractSelectionMode()
} catch (error) {
console.error('delete selected contracts failed:', error)
showMessageDialog(t('ht.batchDeleteFailedTitle'), t('ht.retry'))
} finally {
batchDeleteConfirmOpen.value = false
}
}
const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => {
stopContractAutoScroll()
if (
event.oldIndex == null ||
event.newIndex == null ||
event.oldIndex === event.newIndex
) {
return
}
await saveContracts()
notify(t('ht.sortDone'))
}
const updateDragPointerPosition = (event: MouseEvent | DragEvent) => {
if (!isDraggingContracts.value) return
dragPointerClientY = event.clientY
}
const contractAutoScrollTick = () => {
if (!isDraggingContracts.value) {
contractAutoScrollRaf = 0
return
}
const viewport = contractListViewportRef.value || getContractListViewport()
const clientY = dragPointerClientY
if (viewport && clientY != null) {
const rect = viewport.getBoundingClientRect()
const edge = 88
const maxStep = 22
let delta = 0
if (clientY < rect.top + edge) {
const ratio = Math.max(0, Math.min(1, (rect.top + edge - clientY) / edge))
delta = -Math.ceil(maxStep * ratio)
} else if (clientY > rect.bottom - edge) {
const ratio = Math.max(0, Math.min(1, (clientY - (rect.bottom - edge)) / edge))
delta = Math.ceil(maxStep * ratio)
}
if (delta !== 0) {
viewport.scrollTop = Math.max(
0,
Math.min(viewport.scrollTop + delta, viewport.scrollHeight - viewport.clientHeight)
)
}
}
contractAutoScrollRaf = window.requestAnimationFrame(contractAutoScrollTick)
}
const startContractAutoScroll = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'ready'
getContractListViewport()
isDraggingContracts.value = true
dragPointerClientY = null
window.addEventListener('pointermove', updateDragPointerPosition as EventListener, { passive: true })
window.addEventListener('dragover', updateDragPointerPosition as EventListener, { passive: true })
if (contractAutoScrollRaf) cancelAnimationFrame(contractAutoScrollRaf)
contractAutoScrollRaf = window.requestAnimationFrame(contractAutoScrollTick)
}
const stopContractAutoScroll = () => {
isDraggingContracts.value = false
dragPointerClientY = null
window.removeEventListener('pointermove', updateDragPointerPosition as EventListener)
window.removeEventListener('dragover', updateDragPointerPosition as EventListener)
if (contractAutoScrollRaf) {
cancelAnimationFrame(contractAutoScrollRaf)
contractAutoScrollRaf = 0
}
}
const handleCardClick = (item: ContractItem) => {
if (isSelectingContracts.value) {
toggleContractSelection(item.id)
return
}
tabStore.openTab({
id: `contract-${item.id}`,
title: t('ht.contractTabTitle', { name: item.name }),
componentName: 'QuickCalcView',
props: { contractId: item.id, contractName: item.name }
})
}
const onDragMove = (event: MouseEvent) => {
modalOffset.value = {
x: baseOffsetX + (event.clientX - dragStartX),
y: baseOffsetY + (event.clientY - dragStartY)
}
}
const stopDrag = () => {
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', stopDrag)
}
const startDrag = (event: MouseEvent) => {
dragStartX = event.clientX
dragStartY = event.clientY
baseOffsetX = modalOffset.value.x
baseOffsetY = modalOffset.value.y
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', stopDrag)
}
const handleGlobalMouseDown = (event: MouseEvent) => {
if (!contractDataMenuOpen.value || !contractDataMenuRef.value) return
const target = event.target as Node
if (!contractDataMenuRef.value.contains(target)) {
contractDataMenuOpen.value = false
}
}
onMounted(async () => {
await loadProjectBaseState()
await loadContracts()
await refreshContractBudgets()
triggerCardEnterAnimation()
await nextTick()
bindContractListScroll()
window.addEventListener('mousedown', handleGlobalMouseDown)
})
onActivated(() => {
void loadProjectBaseState()
void refreshContractBudgets()
triggerCardEnterAnimation()
void nextTick(() => {
bindContractListScroll()
})
})
onBeforeUnmount(() => {
stopDrag()
stopContractAutoScroll()
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
unbindContractListScroll()
window.removeEventListener('mousedown', handleGlobalMouseDown)
if (cardEnterTimer) clearTimeout(cardEnterTimer)
void saveContracts()
})
watch(budgetRefreshSignature, (next, prev) => {
if (next === prev) return
scheduleRefreshContractBudgets()
})
</script>
<template>
<ToastProvider>
<TooltipProvider>
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="relative z-30 shrink-0 overflow-visible border-b bg-background/95 px-1 pb-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div class="mb-6 flex items-center justify-between pt-1">
<div class="space-y-1">
<h3 class="text-lg font-bold">{{ t('ht.title') }}</h3>
<div class="text-xs text-muted-foreground">
{{ t('ht.projectTotalBudget', { amount: contractBudgetLoading ? t('ht.budgetLoading') : formatBudgetAmount(projectTotalBudget) }) }}
</div>
</div>
<div class="flex items-center gap-2">
<template v-if="isSelectingContracts">
<div class="text-xs text-muted-foreground">{{ t('ht.selectedCount', { count: selectedContractCount }) }}</div>
<Button
variant="outline"
:disabled="selectedContractCount === 0"
@click="selectionMode === 'export' ? exportSelectedContracts() : deleteSelectedContracts()"
>
{{ selectionMode === 'export' ? t('ht.exportSelected') : t('ht.deleteSelected') }}
</Button>
<Button variant="ghost" @click="exitContractSelectionMode">
{{ t('ht.cancelSelect') }}
</Button>
</template>
<template v-else>
<Button class="whitespace-nowrap" :disabled="!canManageContracts" @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
{{ t('ht.addContract') }}
</Button>
<div ref="contractDataMenuRef" class="relative">
<Button
variant="outline"
size="icon"
class="h-10 w-10"
@click="contractDataMenuOpen = !contractDataMenuOpen"
>
<MoreHorizontal class="h-4 w-4" />
</Button>
<div
v-if="contractDataMenuOpen"
class="absolute right-0 top-full z-[80] mt-1 w-max rounded-md border bg-background p-1 shadow-md"
>
<button
class="block whitespace-nowrap rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!hasContracts"
@click="enterContractDeleteMode"
>
{{ t('ht.batchDelete') }}
</button>
<button
class="block whitespace-nowrap rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!hasContracts"
@click="enterContractExportMode"
>
{{ t('ht.exportContracts') }}
</button>
<button
class="block whitespace-nowrap rounded px-3 py-1.5 text-left text-sm"
:class="canManageContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!canManageContracts"
@click="triggerContractImport"
>
{{ t('ht.importContracts') }}
</button>
</div>
<input
ref="contractImportFileRef"
type="file"
class="hidden"
accept=".htzw,.zw"
@change="importContractSegments"
/>
</div>
</template>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<div class="w-full md:max-w-md">
<div class="flex items-center gap-2">
<input
v-model="contractSearchKeyword"
type="text"
:placeholder="t('ht.searchPlaceholder')"
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
/>
<Button
v-if="contractSearchKeyword"
variant="outline"
size="sm"
class="h-10 shrink-0 px-3"
@click="contractSearchKeyword = ''"
>
{{ t('ht.clearFilter') }}
</Button>
</div>
<div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground">
{{ t('ht.searchingHint', { filtered: filteredContracts.length, total: contracts.length }) }}
</div>
<div v-if="isSelectingContracts" class="mt-1 text-xs text-muted-foreground">
{{ selectionMode === 'export' ? t('ht.selectModeExportHint') : t('ht.selectModeDeleteHint') }}
</div>
<div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground">
{{ t('ht.setupRequiredHint') }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2 md:ml-auto">
<label class="inline-flex cursor-pointer items-center gap-2 text-xs text-muted-foreground select-none">
<span>{{ isListLayout ? t('ht.listLayout') : t('ht.gridLayout') }}</span>
<button
type="button"
role="switch"
:aria-checked="isListLayout"
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors active:scale-95"
:class="isListLayout ? 'bg-primary border-primary' : 'bg-muted border-border'"
@click="isListLayout = !isListLayout"
>
<span
class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-background shadow-sm transition-all"
:class="isListLayout ? 'left-6' : 'left-1'"
/>
</button>
</label>
</div>
</div>
</div>
<div ref="contractListScrollWrapRef" class="mt-4 flex-1 min-h-0">
<!-- 合同卡片列表滚动容器 ScrollArea 承载滚动行为与滚动条样式 -->
<ScrollArea :class="['ht-contract-scroll-area h-full', isDraggingContracts && 'is-dragging']">
<draggable
v-if="!isSearchingContracts && filteredContracts.length > 0"
:key="`contracts-${isListLayout ? 'list' : 'grid'}`"
v-model="contracts"
item-key="id"
:disabled="isSelectingContracts"
handle=".contract-drag-handle"
ghost-class="ht-sortable-ghost"
chosen-class="ht-sortable-chosen"
drag-class="ht-sortable-drag"
:class="[
'grid grid-cols-1 pb-4 pr-4 pt-3',
isListLayout ? 'gap-2' : 'gap-4',
!isListLayout && 'md:grid-cols-2 lg:grid-cols-3'
]"
animation="200"
@start="startContractAutoScroll"
@end="handleDragEnd"
>
<template #item="{ element, index }">
<Card
:class="[
'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
isSelectingContracts
? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter'
: 'ht-contract-card--ready',
isContractSelected(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0'
]"
:style="getContractCardStyle(index)"
@click="handleCardClick(element)"
>
<label
v-if="isSelectingContracts"
class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm"
@click.stop
>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
:checked="isContractSelected(element.id)"
@change.stop="toggleContractSelection(element.id)"
/>
</label>
<CardHeader
:class="[
'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-4'
]"
>
<CardTitle
:class="[
'text-sm font-medium',
isListLayout && 'mr-1.5 flex min-w-0 flex-1 items-center gap-1.5'
]"
>
<span :class="isListLayout ? 'min-w-0 truncate' : ''">{{ element.name }}</span>
<template v-if="isListLayout">
<span class="min-w-0 shrink text-[11px] leading-none font-normal text-muted-foreground truncate">
ID: {{ element.id }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
{{ t('ht.contractBudget', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
</span>
</template>
</CardTitle>
<div
v-if="!isSelectingContracts"
:class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']"
>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:class="[
'contract-drag-handle inline-flex cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted active:cursor-grabbing',
isListLayout ? 'h-6 w-6' : 'h-7 w-7'
]"
@click.stop
>
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('ht.dragSort') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="isListLayout ? 'h-6 w-6' : 'h-7 w-7'"
@click.stop="openEditModal(element)"
>
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('ht.edit') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="requestDeleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('ht.remove') }}</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
<div
v-if="!isListLayout"
:class="[
'px-6 text-xs text-muted-foreground',
'space-y-1 pb-1'
]"
>
<div class="break-all">{{ t('ht.idLabel', { id: element.id }) }}</div>
<div>{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}</div>
<div>{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}</div>
</div>
</Card>
</template>
</draggable>
<div
v-else-if="!isSearchingContracts && filteredContracts.length === 0"
class="mx-2 mb-4 rounded-2xl border border-dashed border-primary/30 bg-gradient-to-br from-primary/5 via-background to-muted/30 p-10 text-center shadow-sm"
>
<div class="text-lg font-semibold tracking-wide text-foreground">{{ t('ht.emptyTitle') }}</div>
<div class="mt-2 text-sm text-muted-foreground">{{ t('ht.emptyDesc') }}</div>
</div>
<div
v-else
:key="`contracts-search-${isListLayout ? 'list' : 'grid'}`"
:class="[
'grid grid-cols-1 pb-4 pr-4 pt-3',
isListLayout ? 'gap-2' : 'gap-4',
!isListLayout && 'md:grid-cols-2 lg:grid-cols-3'
]"
>
<Card
v-for="(element, index) in filteredContracts"
:key="element.id"
:class="[
'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
isSelectingContracts
? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter'
: 'ht-contract-card--ready',
isContractSelected(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0'
]"
:style="getContractCardStyle(index)"
@click="handleCardClick(element)"
>
<label
v-if="isSelectingContracts"
class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm"
@click.stop
>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
:checked="isContractSelected(element.id)"
@change.stop="toggleContractSelection(element.id)"
/>
</label>
<CardHeader
:class="[
'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-2'
]"
>
<CardTitle
:class="[
'text-sm font-medium',
isListLayout && 'mr-1.5 flex min-w-0 flex-1 items-center gap-1.5'
]"
>
<span :class="isListLayout ? 'min-w-0 truncate' : ''">{{ element.name }}</span>
<template v-if="isListLayout">
<span class="min-w-0 shrink text-[11px] leading-none font-normal text-muted-foreground truncate">
ID: {{ element.id }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
{{ t('ht.contractBudget', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
</span>
</template>
</CardTitle>
<div
v-if="!isSelectingContracts"
:class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']"
>
<TooltipRoot>
<TooltipTrigger as-child>
<span
:class="[
'inline-flex items-center justify-center rounded-md text-muted-foreground',
isListLayout ? 'h-6 w-6' : 'h-7 w-7'
]"
>
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</span>
</TooltipTrigger>
<TooltipContent side="top">{{ t('ht.dragSortSearchOff') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="isListLayout ? 'h-6 w-6' : 'h-7 w-7'"
@click.stop="openEditModal(element)"
>
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('ht.edit') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="requestDeleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('ht.remove') }}</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
<div
v-if="!isListLayout"
:class="[
'px-6 text-xs text-muted-foreground',
'space-y-1 pb-4'
]"
>
<div class="break-all">{{ t('ht.idLabel', { id: element.id }) }}</div>
<div>{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}</div>
<div>{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}</div>
</div>
</Card>
<div
v-if="filteredContracts.length === 0"
class="col-span-full rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground"
>
{{ t('ht.notFound') }}
</div>
</div>
</ScrollArea>
</div>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:aria-label="t('ht.backToTop')"
:class="[
'fixed bottom-8 right-8 z-40 inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-black/15 bg-white text-black shadow-[0_10px_24px_rgba(0,0,0,0.16)] transition-all duration-300 hover:scale-105 hover:border-black/30 hover:bg-black hover:text-white',
showScrollTopFab ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-3 opacity-0'
]"
@click="scrollContractsToTop()"
>
<ArrowUp class="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="left">{{ t('ht.backToTop') }}</TooltipContent>
</TooltipRoot>
<div
v-if="showCreateModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
>
<div
class="w-full max-w-md rounded-lg border bg-background shadow-xl"
:style="{ transform: `translate(${modalOffset.x}px, ${modalOffset.y}px)` }"
>
<div
class="flex items-center justify-between border-b px-5 py-4 cursor-move select-none"
@mousedown.prevent="startDrag"
>
<h4 class="text-base font-semibold">
{{ editingContractId ? t('ht.editContract') : t('ht.createContract') }}
</h4>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-2 px-5 py-4">
<label class="block text-sm font-medium text-foreground">{{ t('ht.contractName') }}</label>
<input
v-model="contractNameInput"
type="text"
:placeholder="t('ht.contractNamePlaceholder')"
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
@keydown.enter="createContract"
/>
</div>
<div class="flex items-center justify-end gap-2 px-5 py-3">
<Button variant="outline" @click="closeCreateModal">{{ t('common.cancel') }}</Button>
<Button :disabled="!contractNameInput.trim()" @click="createContract">
{{ editingContractId ? t('ht.save') : t('ht.ok') }}
</Button>
</div>
</div>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ t('ht.deleteSingleTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('ht.deleteSingleDesc', { name: pendingDeleteContractName }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" @click="pendingDeleteContractId = null">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteContract">{{ t('common.confirm') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="batchDeleteConfirmOpen" @update:open="batchDeleteConfirmOpen = $event">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ t('ht.deleteBatchTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('ht.deleteBatchDesc', { count: batchDeleteCount }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteSelectedContracts">{{ t('common.confirm') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="messageDialogOpen" @update:open="messageDialogOpen = $event">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ messageDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ messageDialogDesc }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogAction as-child>
<Button @click="messageDialogOpen = false">{{ t('tab.dialog.iKnow') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<ToastRoot
v-model:open="toastOpen"
:duration="1800"
class="group pointer-events-auto flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-foreground shadow-lg"
>
<div class="grid gap-1">
<ToastTitle class="text-sm font-semibold text-foreground">{{ toastTitle }}</ToastTitle>
<ToastDescription class="text-xs text-muted-foreground">{{ toastText }}</ToastDescription>
</div>
<ToastAction
:alt-text="t('tab.dialog.iKnow')"
class="ml-auto cursor-pointer inline-flex h-7 items-center rounded-md border border-border bg-muted px-2 text-xs text-foreground hover:bg-muted/80"
@click="toastOpen = false"
>
{{ t('tab.dialog.iKnow') }}
</ToastAction>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
</TooltipProvider>
</ToastProvider>
</template>
<style scoped src="@/features/ht/ht.css"></style>