diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index d33a5b7..c101d83 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -18,8 +18,19 @@ export function useAppShell() { const smartEntrySessionId = ref(0) const { activeView, currentView, setView } = useNavigation() - const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = - useRequests() + const { + requests, + loading: requestsLoading, + error: requestsError, + search, + filters, + ranges, + activeRange, + filteredRequests, + approveRequest, + rejectRequest, + reload: reloadRequests + } = useRequests() const { messages, draft, @@ -32,22 +43,23 @@ export function useAppShell() { handleUpload, openChat, openNewChat - } = - useChat(activeView) + } = useChat(activeView) const { toast } = useToast() const docSearch = ref('') const customRange = ref({ start: '2024-07-06', end: '2024-07-12' }) const travelPrompts = ['生成差旅摘要', '识别报销风险', '核对审批链', '提取随附票据', '生成沟通建议'] - const selectedTravelRequest = computed(() => { + const selectedRequest = computed(() => { const requestId = String(route.params.requestId || '') if (!requestId) { return null } - const rawRequest = requests.value.find((item) => String(item.id) === requestId) + const rawRequest = requests.value.find( + (item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId + ) return normalizeRequestForUi(rawRequest) }) @@ -56,14 +68,40 @@ export function useAppShell() { const topBarView = computed(() => { if (detailMode.value) { return { - title: '差旅申请详情', - desc: '查看申请单、票据、审批意见与风控提示。' + title: '报销单详情', + desc: '查看报销明细、票据材料、审批进度与风险提示。' } } return currentView.value }) + const requestSummary = computed(() => + requests.value.reduce( + (summary, item) => { + const request = normalizeRequestForUi(item) + if (!request) { + return summary + } + + summary.total += 1 + + if (request.approvalKey === 'draft') { + summary.draft += 1 + } else if (request.approvalKey === 'in_progress') { + summary.inProgress += 1 + } else if (request.approvalKey === 'supplement') { + summary.supplement += 1 + } else if (request.approvalKey === 'completed') { + summary.completed += 1 + } + + return summary + }, + { total: 0, draft: 0, inProgress: 0, supplement: 0, completed: 0 } + ) + ) + const filteredDocuments = computed(() => { const key = docSearch.value.trim().toLowerCase() return documents.filter((doc) => !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)) @@ -104,7 +142,7 @@ export function useAppShell() { smartEntryContext.value = { prompt: payload.prompt ?? '', source: payload.source ?? 'workbench', - request: payload.request ?? selectedTravelRequest.value, + request: payload.request ?? selectedRequest.value, files: Array.isArray(payload.files) ? payload.files : [], conversation: payload.conversation ?? null } @@ -115,10 +153,18 @@ export function useAppShell() { smartEntryOpen.value = false } + async function handleDraftSaved(payload = {}) { + const claimNo = String(payload.claimNo || payload.claim_no || '').trim() + smartEntryOpen.value = false + await reloadRequests() + toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`) + router.push({ name: 'app-requests' }) + } + function openRequestDetail(request) { router.push({ name: 'app-request-detail', - params: { requestId: request.id } + params: { requestId: request.claimId || request.id } }) } @@ -126,6 +172,15 @@ export function useAppShell() { router.push({ name: 'app-requests' }) } + async function handleRequestUpdated() { + await reloadRequests() + } + + async function handleRequestDeleted() { + await reloadRequests() + router.push({ name: 'app-requests' }) + } + return { activeCase, activeRange, @@ -141,9 +196,12 @@ export function useAppShell() { filteredRequests, filters, handleApprove, + handleDraftSaved, handleNavigate, handleOpenChat, handleReject, + handleRequestDeleted, + handleRequestUpdated, handleUpload, messageList, messages, @@ -155,9 +213,13 @@ export function useAppShell() { openTravelCreate, prompts, ranges, + requestSummary, + requestsError, + requestsLoading, + reloadRequests, requests, search, - selectedTravelRequest, + selectedRequest, sendMessage, sending, setView, diff --git a/web/src/composables/useNavigation.js b/web/src/composables/useNavigation.js index e9d13c2..6598358 100644 --- a/web/src/composables/useNavigation.js +++ b/web/src/composables/useNavigation.js @@ -24,11 +24,11 @@ export const navItems = [ }, { id: 'requests', - label: '申请单', - navHint: '查看和管理申请单', + label: '个人报销', + navHint: '查看和管理个人报销', icon: icons.list, - title: '差旅申请与单据', - desc: '集中查看申请单状态、处理进度和风险提示。' + title: '个人报销', + desc: '集中查看草稿、审批进度、票据状态与风险提示。' }, { id: 'approval', diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index 23e2b93..be0aae8 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -1,60 +1,463 @@ import { computed, reactive, ref } from 'vue' -import { initialRequests } from '../data/requests.js' + +import { fetchExpenseClaims } from '../services/reimbursements.js' + +const EXPENSE_TYPE_LABELS = { + travel: '差旅费', + entertainment: '业务招待费', + office: '办公费', + meeting: '会务费', + training: '培训费', + hotel: '住宿费', + transport: '交通费', + meal: '餐费', + other: '其他费用' +} + +const REIMBURSEMENT_PROGRESS_LABELS = [ + '保存草稿', + '待提交', + 'AI验审', + '直属领导审批', + '财务审批', + '归档入账' +] + +function parseNumber(value) { + const nextValue = Number(value) + return Number.isFinite(nextValue) ? nextValue : 0 +} + +function toDate(value) { + if (!value) { + return null + } + + const nextDate = new Date(value) + return Number.isNaN(nextDate.getTime()) ? null : nextDate +} + +function formatDate(value) { + const nextDate = toDate(value) + if (!nextDate) { + return '' + } + + const year = nextDate.getFullYear() + const month = String(nextDate.getMonth() + 1).padStart(2, '0') + const day = String(nextDate.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function formatDateTime(value) { + const nextDate = toDate(value) + if (!nextDate) { + return '' + } + + const hours = String(nextDate.getHours()).padStart(2, '0') + const minutes = String(nextDate.getMinutes()).padStart(2, '0') + return `${formatDate(nextDate)} ${hours}:${minutes}` +} + +function formatAmount(value) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 0, + maximumFractionDigits: Number.isInteger(value) ? 0 : 2 + }).format(parseNumber(value)) +} + +function resolveTypeLabel(typeCode) { + return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other +} + +function resolveApprovalMeta(status) { + const normalized = String(status || '').trim().toLowerCase() + + if (normalized === 'draft') { + return { key: 'draft', label: '草稿', tone: 'draft' } + } + + if (['supplement', 'returned'].includes(normalized)) { + return { key: 'supplement', label: '待补充', tone: 'warning' } + } + + if (['approved', 'completed', 'paid'].includes(normalized)) { + return { key: 'completed', label: '已完成', tone: 'success' } + } + + if (['rejected', 'cancelled'].includes(normalized)) { + return { key: 'rejected', label: '已退回', tone: 'danger' } + } + + return { key: 'in_progress', label: '审批中', tone: 'info' } +} + +function resolveWorkflowNode(claim, approvalMeta) { + const rawNode = String(claim?.approval_stage || '').trim() + + if (rawNode) { + if (rawNode === '审批流转') { + return 'AI验审' + } + if (rawNode === '待补充') { + return approvalMeta.key === 'draft' ? '待提交' : 'AI验审' + } + return rawNode + } + + if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { + return '待提交' + } + + if (approvalMeta.key === 'completed') { + return '归档入账' + } + + return 'AI验审' +} + +function stringifyRiskFlag(value) { + if (typeof value === 'string') { + return value.trim() + } + + if (!value || typeof value !== 'object') { + return '' + } + + for (const key of ['message', 'label', 'reason', 'name']) { + const nextValue = String(value[key] || '').trim() + if (nextValue) { + return nextValue + } + } + + return '' +} + +function buildRiskSummary(riskFlags) { + if (!Array.isArray(riskFlags) || !riskFlags.length) { + return '无' + } + + const items = riskFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean) + return items.length ? items.join(';') : '无' +} + +function buildOccurredDisplay(claim) { + const itemDates = Array.isArray(claim?.items) + ? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean) + : [] + + if (!itemDates.length) { + return formatDate(claim?.occurred_at) || '待补充' + } + + const sortedDates = [...new Set(itemDates)].sort() + if (sortedDates.length === 1) { + return sortedDates[0] + } + + return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}` +} + +function resolveProgressCurrentIndex(approvalMeta, workflowNode) { + const normalizedNode = String(workflowNode || '').trim() + + if (approvalMeta.key === 'completed') { + return 5 + } + + if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) { + return 5 + } + if (normalizedNode.includes('财务')) { + return 4 + } + if ( + normalizedNode.includes('直属领导') + || normalizedNode.includes('领导审批') + || normalizedNode.includes('部门负责人') + || normalizedNode.includes('负责人审批') + ) { + return 3 + } + if (normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) { + return 2 + } + if (normalizedNode.includes('待提交')) { + return 1 + } + + if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { + return 1 + } + + return 2 +} + +function buildProgressSteps(approvalMeta, workflowNode) { + const currentIndex = resolveProgressCurrentIndex(approvalMeta, workflowNode) + const currentTime = + approvalMeta.key === 'completed' + ? '已完成' + : approvalMeta.key === 'supplement' + ? '待补充' + : approvalMeta.key === 'rejected' + ? '已退回' + : '进行中' + + return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => { + if (approvalMeta.key === 'completed') { + return { + index: index + 1, + label, + time: '已完成', + done: true, + active: true, + current: false + } + } + + if (index < currentIndex) { + return { + index: index + 1, + label, + time: '已完成', + done: true, + active: true, + current: false + } + } + + if (index === currentIndex) { + return { + index: index + 1, + label, + time: currentTime, + done: false, + active: true, + current: true + } + } + + return { + index: index + 1, + label, + time: '待处理', + done: false, + active: false, + current: false + } + }) +} + +function buildExpenseItems(claim, riskSummary) { + if (!Array.isArray(claim?.items)) { + return [] + } + + return claim.items.map((item, index) => { + const attachments = String(item?.invoice_id || '').trim() ? [String(item.invoice_id).trim()] : [] + const itemTypeLabel = resolveTypeLabel(item?.item_type || claim?.expense_type) + + return { + id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`), + time: formatDate(item?.item_date) || '待补充', + itemDate: formatDate(item?.item_date) || '', + itemType: String(item?.item_type || claim?.expense_type || '').trim() || 'other', + itemReason: String(item?.item_reason || claim?.reason || '').trim(), + itemLocation: String(item?.item_location || claim?.location || '').trim(), + itemAmount: parseNumber(item?.item_amount), + invoiceId: String(item?.invoice_id || '').trim(), + dayLabel: claim?.expense_type === 'travel' ? `第 ${index + 1} 项` : '业务发生项', + name: itemTypeLabel, + category: itemTypeLabel, + desc: String(item?.item_reason || claim?.reason || '').trim() || '待补充', + detail: String(item?.item_location || claim?.location || '').trim() || '待补充', + amount: formatAmount(item?.item_amount), + status: attachments.length ? '已识别' : '待补充', + tone: attachments.length ? 'ok' : 'bad', + attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '待上传', + attachmentHint: attachments.length ? attachments[0] : '暂无关联票据', + attachmentTone: attachments.length ? 'ok' : 'missing', + attachments, + riskLabel: riskSummary === '无' ? '无' : '待关注', + riskText: riskSummary === '无' ? '' : riskSummary, + riskTone: riskSummary === '无' ? 'low' : 'medium' + } + }) +} + +function mapExpenseClaimToRequest(claim) { + const typeCode = String(claim?.expense_type || '').trim() || 'other' + const typeLabel = resolveTypeLabel(typeCode) + const approvalMeta = resolveApprovalMeta(claim?.status) + const workflowNode = resolveWorkflowNode(claim, approvalMeta) + const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count)) + const riskSummary = buildRiskSummary(claim?.risk_flags_json) + const expenseItems = buildExpenseItems(claim, riskSummary) + const applyDateTime = claim?.submitted_at || claim?.created_at + + return { + id: String(claim?.claim_no || claim?.id || '').trim(), + claimNo: String(claim?.claim_no || claim?.id || '').trim(), + claimId: String(claim?.id || '').trim(), + status: String(claim?.status || '').trim(), + person: String(claim?.employee_name || '').trim() || '待补充', + dept: String(claim?.department_name || '').trim() || '待补充', + departmentName: String(claim?.department_name || '').trim() || '待补充', + employeeName: String(claim?.employee_name || '').trim() || '待补充', + entity: '', + typeCode, + typeLabel, + detailVariant: typeCode === 'travel' ? 'travel' : 'general', + title: String(claim?.reason || '').trim() || `${typeLabel}报销`, + sceneLabel: typeLabel, + sceneTarget: String(claim?.location || '').trim() || '待补充', + location: String(claim?.location || '').trim() || '待补充', + relatedCustomer: '', + occurredDisplay: buildOccurredDisplay(claim), + occurredAt: claim?.occurred_at || '', + applyTime: formatDateTime(applyDateTime) || '待补充', + submittedAt: applyDateTime || '', + createdAt: claim?.created_at || '', + amount: parseNumber(claim?.amount), + workflowNode, + approvalKey: approvalMeta.key, + approvalStatus: approvalMeta.label, + approvalTone: approvalMeta.tone, + secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', + secondaryStatusValue: invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据', + secondaryStatusTone: invoiceCount > 0 ? 'success' : 'warning', + riskSummary, + attachmentSummary: invoiceCount > 0 ? `${invoiceCount} 张票据` : '无', + expenseTableSummary: expenseItems.length + ? (invoiceCount > 0 + ? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据` + : `共 ${expenseItems.length} 条费用明细,待补充票据`) + : '暂无费用明细', + note: String(claim?.reason || '').trim(), + progressSteps: buildProgressSteps(approvalMeta, workflowNode), + expenseItems + } +} + +function getWeekStart(date) { + const nextDate = new Date(date) + const day = nextDate.getDay() || 7 + nextDate.setHours(0, 0, 0, 0) + nextDate.setDate(nextDate.getDate() - day + 1) + return nextDate +} + +function resolveRangeMatch(activeRange, item) { + if (activeRange === 'custom' || activeRange === '本月') { + if (activeRange !== '本月') { + return true + } + } + + const targetDate = toDate(item?.submittedAt || item?.createdAt || item?.occurredAt) + if (!targetDate) { + return true + } + + const now = new Date() + const targetDay = formatDate(targetDate) + + if (activeRange === '今日') { + return targetDay === formatDate(now) + } + + if (activeRange === '本周') { + const weekStart = getWeekStart(now) + const nextWeekStart = new Date(weekStart) + nextWeekStart.setDate(nextWeekStart.getDate() + 7) + return targetDate >= weekStart && targetDate < nextWeekStart + } + + if (activeRange === '本月') { + return ( + targetDate.getFullYear() === now.getFullYear() + && targetDate.getMonth() === now.getMonth() + ) + } + + return true +} export function useRequests() { - const requests = ref(initialRequests) - const entityMap = { - 'Northstar China Ltd.': 'Northstar China Ltd.', - 'Northstar Singapore Pte.': 'Northstar Singapore Pte.', - 'Northstar US Inc.': 'Northstar US Inc.' - } + const requests = ref([]) + const loading = ref(false) + const error = ref('') const search = ref('') - const filters = reactive({ entity: '全部主体', category: '全部费用', risk: '全部风险' }) + const filters = reactive({ entity: '全部主体', category: '全部类型', risk: '全部状态' }) const ranges = ['今日', '本周', '本月'] const activeRange = ref('本周') const filteredRequests = computed(() => { const key = search.value.trim().toLowerCase() + return requests.value.filter((item) => { - const matchesSearch = !key || `${item.id}${item.person}${item.category}${item.risk}`.toLowerCase().includes(key) - const matchesEntity = filters.entity === '全部主体' || item.entity === entityMap[filters.entity] - const matchesCategory = filters.category === '全部费用' || item.category === filters.category - const matchesRisk = filters.risk === '全部风险' - || (filters.risk === '高风险' && item.status === 'danger') - || (filters.risk === '需解释' && item.status === 'warning') - || (filters.risk === '低风险' && item.status === 'success') - const matchesRange = activeRange.value === 'custom' - || activeRange.value === '本月' - || (activeRange.value === '本周' && item.range !== '本月') - || (activeRange.value === '今日' && item.range === '今日') - return matchesSearch && matchesEntity && matchesCategory && matchesRisk && matchesRange + const searchText = [ + item.id, + item.person, + item.typeLabel, + item.title, + item.sceneTarget, + item.riskSummary + ] + .filter(Boolean) + .join('') + .toLowerCase() + + const matchesSearch = !key || searchText.includes(key) + const matchesRange = resolveRangeMatch(activeRange.value, item) + + return matchesSearch && matchesRange }) }) - function updateRequest(requestId, updates) { - requests.value = requests.value.map((item) => (item.id === requestId ? { ...item, ...updates } : item)) + async function reload() { + loading.value = true + error.value = '' + + try { + const payload = await fetchExpenseClaims() + requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] + } catch (nextError) { + requests.value = [] + error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。' + } finally { + loading.value = false + } } function approveRequest(request) { - updateRequest(request.id, { - verdict: '已通过', - status: 'success', - risk: '已完成人工确认' - }) - return `${request.id} 已标记为通过,审计日志已更新。` + return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` } function rejectRequest(request) { - updateRequest(request.id, { - verdict: '已退回补件', - status: 'danger', - risk: '待申请人补充差旅行程与票据' - }) - return `${request.id} 已退回,系统将通知申请人补充材料。` + return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` } + void reload() + return { - requests, search, filters, ranges, activeRange, - filteredRequests, approveRequest, rejectRequest + requests, + loading, + error, + search, + filters, + ranges, + activeRange, + filteredRequests, + approveRequest, + rejectRequest, + reload } }