You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
389 lines
11 KiB
389 lines
11 KiB
/**
|
|
* 企业端 · 收到的投递(工作台列表)
|
|
*
|
|
* 这是企业侧投递链路的第一层:一行一个**投递**(不是候选人),
|
|
* 展示状态、岗位、时间与下一步动作。状态变更与候选人反馈都从详情页发起。
|
|
*
|
|
* 设计取舍:
|
|
* - 筛选条上的计数来自**独立于筛选的**计数口,切到「面试中」不会让其他状态计数归零
|
|
* - 用 useFocusEffect 在返回时刷新:详情页改完状态后回到列表,徽标必须已经跟着变,
|
|
* 否则用户会以为刚才那次操作没生效
|
|
*/
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useFocusEffect, useRouter } from 'expo-router';
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import {
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { EmptyState, ErrorState, LoadingState } from '../src/components/StateView';
|
|
import {
|
|
APPLICATION_STATUS_LABELS,
|
|
COMPANY_APPLICATION_FILTERS,
|
|
ENTERPRISE_DISPLAY_NAME,
|
|
} from '../src/constants';
|
|
import { useCompanyApplications } from '../src/hooks';
|
|
import { applicationStatusColors, colors, spacing } from '../src/theme';
|
|
import type { ApplicationStatus, CompanyApplicationItem } from '../src/types';
|
|
|
|
/** 把 ISO 时间压成「9-28 10:12」这种一眼能排序的相对短格式 */
|
|
function formatAppliedAt(iso?: string): string {
|
|
if (!iso) return '';
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
const mm = date.getMonth() + 1;
|
|
const dd = date.getDate();
|
|
const hh = date.getHours().toString().padStart(2, '0');
|
|
const mi = date.getMinutes().toString().padStart(2, '0');
|
|
return `${mm}-${dd} ${hh}:${mi}`;
|
|
}
|
|
|
|
/* ===================== 子组件 ===================== */
|
|
|
|
function StatusBadge({ status }: { status: ApplicationStatus }) {
|
|
const tone = applicationStatusColors[status];
|
|
return (
|
|
<View style={[styles.badge, { backgroundColor: tone.bg }]}>
|
|
<Text style={[styles.badgeText, { color: tone.color }]}>
|
|
{APPLICATION_STATUS_LABELS[status]}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** 单行投递。整行可点 → 进详情做状态流转与反馈 */
|
|
function ApplicationRow({
|
|
item,
|
|
onPress,
|
|
}: {
|
|
item: CompanyApplicationItem;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable style={styles.row} onPress={onPress}>
|
|
<View style={styles.avatar}>
|
|
<Text style={styles.avatarText}>{item.candidateName.slice(0, 1)}</Text>
|
|
</View>
|
|
<View style={styles.rowMain}>
|
|
<View style={styles.rowTop}>
|
|
<Text style={styles.name} numberOfLines={1}>
|
|
{item.candidateName}
|
|
</Text>
|
|
<StatusBadge status={item.status} />
|
|
</View>
|
|
<Text style={styles.rowMeta} numberOfLines={1}>
|
|
{[item.candidateSchool, item.candidateEducation, item.candidateGraduationYear
|
|
? `${item.candidateGraduationYear} 届`
|
|
: undefined]
|
|
.filter(Boolean)
|
|
.join(' · ') || '学历信息未公开'}
|
|
</Text>
|
|
<View style={styles.rowBottom}>
|
|
<View style={styles.jobChip}>
|
|
<Ionicons name="briefcase-outline" size={11} color={colors.textSecondary} />
|
|
<Text style={styles.jobChipText} numberOfLines={1}>
|
|
{item.jobTitle}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.time}>{formatAppliedAt(item.createdAt)}</Text>
|
|
</View>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={18} color={colors.textHint} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/* ===================== 页面 ===================== */
|
|
|
|
export default function EnterpriseApplicationsScreen() {
|
|
const router = useRouter();
|
|
const [status, setStatus] = useState<ApplicationStatus | null>(null);
|
|
const [keyword, setKeyword] = useState('');
|
|
|
|
const { applications, total, counts, loading, error, refresh, isEmpty } =
|
|
useCompanyApplications({ status, keyword });
|
|
|
|
/**
|
|
* 从详情页返回时刷新:状态改了、反馈写了,列表上的数字与徽标都要跟着变。
|
|
*
|
|
* refresh 的引用会随筛选条件(keyword)变化,直接写进 useCallback 依赖的话,
|
|
* 每敲一个字都会重跑一次 focus effect。故用 ref 取最新值,effect 依赖留空。
|
|
*/
|
|
const refreshRef = useRef(refresh);
|
|
refreshRef.current = refresh;
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
refreshRef.current();
|
|
}, []),
|
|
);
|
|
|
|
const allCount = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
|
|
{/* 顶部导航 */}
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={8}>
|
|
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
|
|
</Pressable>
|
|
<View style={styles.headerCenter}>
|
|
<Text style={styles.headerCompany}>{ENTERPRISE_DISPLAY_NAME}</Text>
|
|
<Text style={styles.headerTitle}>收到的投递</Text>
|
|
</View>
|
|
<View style={styles.headerBtn} />
|
|
</View>
|
|
|
|
{/* 关键词搜索 */}
|
|
<View style={styles.searchWrap}>
|
|
<Ionicons name="search-outline" size={16} color={colors.textHint} />
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
placeholder="搜索候选人姓名 / 学校 / 岗位"
|
|
placeholderTextColor={colors.textHint}
|
|
value={keyword}
|
|
onChangeText={setKeyword}
|
|
returnKeyType="search"
|
|
/>
|
|
{keyword.length > 0 ? (
|
|
<Pressable onPress={() => setKeyword('')} hitSlop={8} accessibilityLabel="清空搜索">
|
|
<Ionicons name="close-circle" size={16} color={colors.textHint} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
|
|
{/* 状态筛选:计数来自独立计数口,不随当前筛选变化 */}
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.filterRow}
|
|
>
|
|
{COMPANY_APPLICATION_FILTERS.map((filter) => {
|
|
const active = filter.value === status;
|
|
const count = filter.value === null ? allCount : counts[filter.value] ?? 0;
|
|
return (
|
|
<Pressable
|
|
key={filter.label}
|
|
style={[styles.filterChip, active && styles.filterChipActive]}
|
|
onPress={() => setStatus(filter.value)}
|
|
>
|
|
<Text style={[styles.filterText, active && styles.filterTextActive]}>
|
|
{filter.label} {count}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
|
|
{/* 列表区:四态齐全 */}
|
|
{loading && applications.length === 0 ? (
|
|
<LoadingState text="正在加载投递…" />
|
|
) : error ? (
|
|
<ErrorState message="投递列表加载失败" onRetry={refresh} />
|
|
) : isEmpty ? (
|
|
<EmptyState
|
|
icon="file-tray-outline"
|
|
title={keyword || status ? '没有符合条件的投递' : '还没有收到投递'}
|
|
subtitle={
|
|
keyword || status
|
|
? '换个筛选条件或清空搜索词再试'
|
|
: '岗位发布后,收到的投递会在这里汇总'
|
|
}
|
|
actionLabel={keyword || status ? '清空筛选' : undefined}
|
|
onAction={
|
|
keyword || status
|
|
? () => {
|
|
setKeyword('');
|
|
setStatus(null);
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<ScrollView
|
|
style={styles.list}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<Text style={styles.summary}>
|
|
共 {total} 条投递
|
|
{status ? ` · 已按「${APPLICATION_STATUS_LABELS[status]}」筛选` : ''}
|
|
</Text>
|
|
{applications.map((item) => (
|
|
<ApplicationRow
|
|
key={item.applicationId}
|
|
item={item}
|
|
onPress={() => router.push(`/application-detail?id=${item.applicationId}`)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: {
|
|
flex: 1,
|
|
backgroundColor: colors.pageBg,
|
|
},
|
|
/* ---- 顶部导航 ---- */
|
|
header: {
|
|
height: 48,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
headerBtn: {
|
|
width: 48,
|
|
height: 48,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
headerCenter: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
},
|
|
headerCompany: {
|
|
fontSize: 11,
|
|
color: colors.textHint,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 17,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
},
|
|
/* ---- 搜索 ---- */
|
|
searchWrap: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
height: 36,
|
|
marginHorizontal: spacing.framework,
|
|
paddingHorizontal: spacing.card,
|
|
borderRadius: spacing.card,
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
fontSize: 13,
|
|
color: colors.textPrimary,
|
|
padding: 0,
|
|
},
|
|
/* ---- 筛选 ---- */
|
|
filterRow: {
|
|
paddingHorizontal: spacing.framework,
|
|
paddingVertical: spacing.card,
|
|
gap: spacing.card,
|
|
},
|
|
filterChip: {
|
|
paddingHorizontal: 12,
|
|
height: 30,
|
|
borderRadius: 15,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.white,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
filterChipActive: {
|
|
backgroundColor: colors.primaryLight,
|
|
borderColor: colors.primary,
|
|
},
|
|
filterText: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary,
|
|
},
|
|
filterTextActive: {
|
|
color: colors.primaryText,
|
|
fontWeight: '600',
|
|
},
|
|
/* ---- 列表 ---- */
|
|
list: {
|
|
flex: 1,
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: spacing.framework,
|
|
paddingBottom: 24,
|
|
},
|
|
summary: {
|
|
marginBottom: spacing.card,
|
|
fontSize: 12,
|
|
color: colors.textHint,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.card,
|
|
backgroundColor: colors.white,
|
|
borderRadius: spacing.card,
|
|
padding: spacing.card + 4,
|
|
marginBottom: spacing.card,
|
|
},
|
|
avatar: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.primaryLight,
|
|
},
|
|
avatarText: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: colors.primaryText,
|
|
},
|
|
rowMain: {
|
|
flex: 1,
|
|
},
|
|
rowTop: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.card,
|
|
},
|
|
name: {
|
|
flexShrink: 1,
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
},
|
|
badge: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
borderRadius: spacing.card,
|
|
},
|
|
badgeText: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
},
|
|
rowMeta: {
|
|
marginTop: 4,
|
|
fontSize: 12,
|
|
color: colors.textSecondary,
|
|
},
|
|
rowBottom: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginTop: 6,
|
|
},
|
|
jobChip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
flexShrink: 1,
|
|
},
|
|
jobChipText: {
|
|
fontSize: 11,
|
|
color: colors.textSecondary,
|
|
},
|
|
time: {
|
|
fontSize: 11,
|
|
color: colors.textHint,
|
|
},
|
|
});
|
|
|