2026-02-24 23:44:40 +08:00
|
|
|
import { useState, useMemo } from 'react'
|
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
|
import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile'
|
|
|
|
|
import { AddOutline, DeleteOutline } from 'antd-mobile-icons'
|
|
|
|
|
import dayjs from 'dayjs'
|
2026-02-24 17:57:25 +08:00
|
|
|
import styles from './apply.module.css'
|
|
|
|
|
|
2026-02-24 23:44:40 +08:00
|
|
|
interface ItineraryItem {
|
|
|
|
|
startTime: Date | null
|
|
|
|
|
endTime: Date | null
|
|
|
|
|
departure: string
|
|
|
|
|
destination: string
|
|
|
|
|
transport: string
|
|
|
|
|
tripType: 'oneway' | 'roundtrip'
|
|
|
|
|
remark: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ApproverItem {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
avatar?: string
|
|
|
|
|
position?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 计算时长(天数)
|
|
|
|
|
const calculateDuration = (start: Date | null, end: Date | null): number => {
|
|
|
|
|
if (!start || !end) return 0
|
|
|
|
|
const startDate = dayjs(start)
|
|
|
|
|
const endDate = dayjs(end)
|
|
|
|
|
return endDate.diff(startDate, 'day') + 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 格式化时长显示
|
|
|
|
|
const formatDuration = (days: number): string => {
|
|
|
|
|
if (days <= 0) return ''
|
|
|
|
|
return `${days}天`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 行程类型选项
|
|
|
|
|
const tripTypeOptions = [
|
|
|
|
|
{ label: '单程', value: 'oneway' },
|
|
|
|
|
{ label: '往返', value: 'roundtrip' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// 交通工具选项
|
|
|
|
|
const transportOptions = [
|
|
|
|
|
{ label: '飞机', value: '飞机' },
|
|
|
|
|
{ label: '高铁', value: '高铁' },
|
|
|
|
|
{ label: '火车', value: '火车' },
|
|
|
|
|
{ label: '自驾', value: '自驾' },
|
|
|
|
|
{ label: '其他', value: '其他' },
|
|
|
|
|
]
|
|
|
|
|
|
2026-02-24 17:57:25 +08:00
|
|
|
export default function BusinessTripApply() {
|
2026-02-24 23:44:40 +08:00
|
|
|
const { t } = useTranslation()
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
|
|
|
|
const [reason, setReason] = useState('')
|
|
|
|
|
const [itineraries, setItineraries] = useState<ItineraryItem[]>([
|
|
|
|
|
{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: 'oneway', remark: '' }
|
|
|
|
|
])
|
|
|
|
|
const [approvers, setApprovers] = useState<ApproverItem[]>([])
|
|
|
|
|
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
|
|
|
|
|
|
|
|
|
|
// 计算总时长(累加所有行程的天数)
|
|
|
|
|
const totalDuration = useMemo(() => {
|
|
|
|
|
let total = 0
|
|
|
|
|
itineraries.forEach(item => {
|
|
|
|
|
const duration = calculateDuration(item.startTime, item.endTime)
|
|
|
|
|
total += duration
|
|
|
|
|
})
|
|
|
|
|
return total
|
|
|
|
|
}, [itineraries])
|
|
|
|
|
|
|
|
|
|
const handleAddItinerary = () => {
|
|
|
|
|
setItineraries([...itineraries, {
|
|
|
|
|
startTime: null,
|
|
|
|
|
endTime: null,
|
|
|
|
|
departure: '',
|
|
|
|
|
destination: '',
|
|
|
|
|
transport: '',
|
|
|
|
|
tripType: 'oneway',
|
|
|
|
|
remark: ''
|
|
|
|
|
}])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRemoveItinerary = (index: number) => {
|
|
|
|
|
if (itineraries.length === 1) {
|
|
|
|
|
Toast.show('至少保留一条行程')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setItineraries(itineraries.filter((_, i) => i !== index))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => {
|
|
|
|
|
const updated = [...itineraries]
|
|
|
|
|
updated[index] = { ...updated[index], [field]: value }
|
|
|
|
|
setItineraries(updated)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSelectApprovers = () => {
|
|
|
|
|
navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSelectCc = () => {
|
|
|
|
|
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSave = () => {
|
|
|
|
|
Toast.show('草稿已保存')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSubmit = () => {
|
|
|
|
|
if (!reason.trim()) {
|
|
|
|
|
Toast.show(t('businessTrip.pleaseEnterReason'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasValidItinerary = itineraries.some(item => item.startTime && item.destination.trim())
|
|
|
|
|
if (!hasValidItinerary) {
|
|
|
|
|
Toast.show('请至少填写一条完整行程')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Dialog.confirm({
|
|
|
|
|
content: t('businessTrip.confirmSubmit'),
|
|
|
|
|
onConfirm: async () => {
|
|
|
|
|
Toast.show(t('businessTrip.submitSuccess'))
|
|
|
|
|
navigate('/business-trip')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 17:57:25 +08:00
|
|
|
return (
|
|
|
|
|
<div className={styles.apply}>
|
2026-02-24 23:44:40 +08:00
|
|
|
<div className={styles.form}>
|
2026-02-24 17:57:25 +08:00
|
|
|
<div className={styles.group}>
|
2026-02-24 23:44:40 +08:00
|
|
|
<label className={styles.required}>
|
|
|
|
|
{t('businessTrip.reason')}
|
|
|
|
|
</label>
|
|
|
|
|
<TextArea
|
|
|
|
|
placeholder={t('businessTrip.pleaseEnterReason')}
|
|
|
|
|
value={reason}
|
|
|
|
|
onChange={setReason}
|
|
|
|
|
rows={3}
|
|
|
|
|
maxLength={500}
|
|
|
|
|
/>
|
2026-02-24 17:57:25 +08:00
|
|
|
</div>
|
2026-02-24 23:44:40 +08:00
|
|
|
|
2026-02-24 17:57:25 +08:00
|
|
|
<div className={styles.group}>
|
2026-02-24 23:44:40 +08:00
|
|
|
<label>{t('businessTrip.totalDuration')}</label>
|
|
|
|
|
<div className={styles.readonlyInput}>
|
|
|
|
|
{formatDuration(totalDuration) || '-'}
|
|
|
|
|
</div>
|
2026-02-24 17:57:25 +08:00
|
|
|
</div>
|
2026-02-24 23:44:40 +08:00
|
|
|
|
|
|
|
|
<div className={styles.section}>
|
|
|
|
|
<div className={styles.sectionHeader}>
|
|
|
|
|
<span className={styles.sectionTitle}>{t('businessTrip.itinerary')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{itineraries.map((item, index) => {
|
|
|
|
|
const itemDuration = calculateDuration(item.startTime, item.endTime)
|
|
|
|
|
return (
|
|
|
|
|
<div key={index} className={styles.itineraryItem}>
|
|
|
|
|
{itineraries.length > 1 && (
|
|
|
|
|
<div className={styles.itineraryHeader}>
|
|
|
|
|
<span className={styles.itineraryIndex}>{index + 1}</span>
|
|
|
|
|
<button
|
|
|
|
|
className={styles.removeBtn}
|
|
|
|
|
onClick={() => handleRemoveItinerary(index)}
|
|
|
|
|
>
|
|
|
|
|
<DeleteOutline />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className={styles.dateField}>
|
|
|
|
|
<label className={styles.required}>{t('businessTrip.itineraryStartTime')}</label>
|
|
|
|
|
<DatePicker
|
|
|
|
|
value={item.startTime}
|
|
|
|
|
onConfirm={(val) => handleUpdateItinerary(index, 'startTime', val)}
|
|
|
|
|
precision="minute"
|
|
|
|
|
>
|
|
|
|
|
{value => (
|
|
|
|
|
<div className={styles.dateClickable}>
|
|
|
|
|
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DatePicker>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.dateField}>
|
|
|
|
|
<label className={styles.required}>{t('businessTrip.itineraryEndTime')}</label>
|
|
|
|
|
<DatePicker
|
|
|
|
|
value={item.endTime}
|
|
|
|
|
onConfirm={(val) => handleUpdateItinerary(index, 'endTime', val)}
|
|
|
|
|
precision="minute"
|
|
|
|
|
{...(item.startTime && { min: item.startTime })}
|
|
|
|
|
>
|
|
|
|
|
{value => (
|
|
|
|
|
<div className={styles.dateClickable}>
|
|
|
|
|
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DatePicker>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.field}>
|
|
|
|
|
<label>{t('businessTrip.duration')}</label>
|
|
|
|
|
<div className={styles.readonlyInput}>
|
|
|
|
|
{formatDuration(itemDuration) || '-'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.field}>
|
|
|
|
|
<label>{t('businessTrip.itineraryDeparture')}</label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t('businessTrip.itineraryDeparture')}
|
|
|
|
|
value={item.departure}
|
|
|
|
|
onChange={val => handleUpdateItinerary(index, 'departure', val)}
|
|
|
|
|
clearable
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.field}>
|
|
|
|
|
<label className={styles.required}>{t('businessTrip.itineraryDestination')}</label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t('businessTrip.itineraryDestination')}
|
|
|
|
|
value={item.destination}
|
|
|
|
|
onChange={val => handleUpdateItinerary(index, 'destination', val)}
|
|
|
|
|
clearable
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.field}>
|
|
|
|
|
<label>{t('businessTrip.itineraryTransport')}</label>
|
|
|
|
|
<Selector
|
|
|
|
|
options={transportOptions}
|
|
|
|
|
value={item.transport ? [item.transport] : []}
|
|
|
|
|
onChange={(val) => handleUpdateItinerary(index, 'transport', val[0] || '')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.field}>
|
|
|
|
|
<label>{t('businessTrip.tripType')}</label>
|
|
|
|
|
<Selector
|
|
|
|
|
options={tripTypeOptions}
|
|
|
|
|
value={[item.tripType]}
|
|
|
|
|
onChange={(val) => handleUpdateItinerary(index, 'tripType', val[0] as 'oneway' | 'roundtrip')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.field}>
|
|
|
|
|
<label>{t('businessTrip.itineraryRemark')}</label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t('businessTrip.itineraryRemark')}
|
|
|
|
|
value={item.remark}
|
|
|
|
|
onChange={val => handleUpdateItinerary(index, 'remark', val)}
|
|
|
|
|
clearable
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
<button className={styles.addBtn} onClick={handleAddItinerary}>
|
|
|
|
|
<AddOutline />
|
|
|
|
|
{t('businessTrip.addItinerary')}
|
|
|
|
|
</button>
|
2026-02-24 17:57:25 +08:00
|
|
|
</div>
|
2026-02-24 23:44:40 +08:00
|
|
|
|
|
|
|
|
<div className={styles.section}>
|
|
|
|
|
<div className={styles.selector} onClick={handleSelectApprovers}>
|
|
|
|
|
<span className={styles.label}>{t('businessTrip.approvers')}</span>
|
|
|
|
|
<div className={styles.value}>
|
|
|
|
|
{approvers.length > 0 ? (
|
|
|
|
|
<div className={styles.selectedUsers}>
|
|
|
|
|
{approvers.map(u => (
|
|
|
|
|
<img key={u.id} src={u.avatar || '/default-avatar.png'} alt={u.name} className={styles.avatar} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className={styles.placeholder}>{t('businessTrip.selectApprovers')}</span>
|
|
|
|
|
)}
|
|
|
|
|
<AddOutline className={styles.icon} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.selector} onClick={handleSelectCc}>
|
|
|
|
|
<span className={styles.label}>{t('businessTrip.cc')}</span>
|
|
|
|
|
<div className={styles.value}>
|
|
|
|
|
{ccUsers.length > 0 ? (
|
|
|
|
|
<div className={styles.selectedUsers}>
|
|
|
|
|
{ccUsers.map(u => (
|
|
|
|
|
<img key={u.id} src={u.avatar || '/default-avatar.png'} alt={u.name} className={styles.avatar} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className={styles.placeholder}>{t('businessTrip.selectCc')}</span>
|
|
|
|
|
)}
|
|
|
|
|
<AddOutline className={styles.icon} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={styles.footer}>
|
|
|
|
|
<button className={styles.saveBtn} onClick={handleSave}>
|
|
|
|
|
{t('common.save')}
|
|
|
|
|
</button>
|
|
|
|
|
<button className={styles.submitBtn} onClick={handleSubmit}>
|
|
|
|
|
{t('common.submit')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-24 17:57:25 +08:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|