修改样式
parent
19d8cf7e9b
commit
67c41f8e88
|
|
@ -7,6 +7,7 @@ public class ChannelExpansionItemDTO {
|
|||
|
||||
private Long id;
|
||||
private Long ownerUserId;
|
||||
private String owner;
|
||||
private String type;
|
||||
private String channelCode;
|
||||
private String name;
|
||||
|
|
@ -57,6 +58,14 @@ public class ChannelExpansionItemDTO {
|
|||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
public String getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(String owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public class SalesExpansionItemDTO {
|
|||
|
||||
private Long id;
|
||||
private Long ownerUserId;
|
||||
private String owner;
|
||||
private String type;
|
||||
private String employeeNo;
|
||||
private String name;
|
||||
|
|
@ -48,6 +49,14 @@ public class SalesExpansionItemDTO {
|
|||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
public String getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(String owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.unis.crm.dto.work.WorkTomorrowPlanItemDTO;
|
|||
import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest;
|
||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
||||
import com.unis.crm.mapper.ProfileMapper;
|
||||
import com.unis.crm.mapper.WorkMapper;
|
||||
import com.unis.crm.service.WorkService;
|
||||
import java.io.IOException;
|
||||
|
|
@ -67,6 +68,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
private static final String PLAN_ITEMS_METADATA_PREFIX = "[[WORK_PLAN_ITEMS]]";
|
||||
private static final String PLAN_ITEMS_METADATA_SUFFIX = "[[/WORK_PLAN_ITEMS]]";
|
||||
private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL);
|
||||
private static final String ONLY_SEE_ROLE_CODE = "only_see";
|
||||
private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报";
|
||||
private static final String TENCENT_COORD_TYPE_GPS = "1";
|
||||
private static final String TENCENT_COORD_TYPE_GCJ02 = "2";
|
||||
|
|
@ -77,6 +79,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
private static final DateTimeFormatter REPORT_DATE_TIME_TEXT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm");
|
||||
|
||||
private final WorkMapper workMapper;
|
||||
private final ProfileMapper profileMapper;
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Path checkInPhotoDirectory;
|
||||
|
|
@ -85,10 +88,12 @@ public class WorkServiceImpl implements WorkService {
|
|||
|
||||
public WorkServiceImpl(
|
||||
WorkMapper workMapper,
|
||||
ProfileMapper profileMapper,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${unisbase.app.upload-path}") String uploadPath,
|
||||
@Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) {
|
||||
this.workMapper = workMapper;
|
||||
this.profileMapper = profileMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
|
|
@ -132,6 +137,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
@Override
|
||||
public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) {
|
||||
requireUser(userId);
|
||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录");
|
||||
request.setLocationText(normalizeOptionalText(request.getLocationText()));
|
||||
request.setRemark(normalizeOptionalText(request.getRemark()));
|
||||
request.setBizType(normalizeBizType(request.getBizType()));
|
||||
|
|
@ -164,6 +170,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
@Transactional
|
||||
public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) {
|
||||
requireUser(userId);
|
||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||
normalizeDailyReportRequest(request);
|
||||
request.setTomorrowPlan(request.getTomorrowPlan().trim());
|
||||
request.setSourceType(normalizeOptionalText(request.getSourceType()));
|
||||
|
|
@ -234,6 +241,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
@Override
|
||||
public String uploadCheckInPhoto(Long userId, MultipartFile file) {
|
||||
requireUser(userId);
|
||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录");
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请先选择现场照片");
|
||||
}
|
||||
|
|
@ -283,6 +291,17 @@ public class WorkServiceImpl implements WorkService {
|
|||
}
|
||||
}
|
||||
|
||||
private void ensureWorkWriteAllowed(Long userId, String deniedMessage) {
|
||||
List<String> roleCodes = profileMapper.selectUserRoleCodes(userId);
|
||||
boolean onlySee = roleCodes != null && roleCodes.stream()
|
||||
.filter(roleCode -> roleCode != null && !roleCode.isBlank())
|
||||
.map(roleCode -> roleCode.trim().toLowerCase())
|
||||
.anyMatch(ONLY_SEE_ROLE_CODE::equals);
|
||||
if (onlySee) {
|
||||
throw new BusinessException(deniedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
|
||||
List<WorkHistoryItemDTO> historyItems = new ArrayList<>();
|
||||
if (historyType == null || historyType == HistoryType.CHECK_IN) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
select
|
||||
s.id,
|
||||
s.owner_user_id as ownerUserId,
|
||||
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
|
||||
'sales' as type,
|
||||
coalesce(s.employee_no, '无') as employeeNo,
|
||||
s.candidate_name as name,
|
||||
|
|
@ -90,6 +91,9 @@
|
|||
coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate,
|
||||
coalesce(s.remark, '无') as notes
|
||||
from crm_sales_expansion s
|
||||
left join sys_user u
|
||||
on u.user_id = s.owner_user_id
|
||||
and u.is_deleted = 0
|
||||
left join sys_dict_item office_dict
|
||||
on office_dict.type_code = 'tz_bsc'
|
||||
and office_dict.item_value = s.office_name
|
||||
|
|
@ -117,6 +121,7 @@
|
|||
select
|
||||
c.id,
|
||||
c.owner_user_id as ownerUserId,
|
||||
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
|
||||
'channel' as type,
|
||||
coalesce(c.channel_code, '') as channelCode,
|
||||
c.channel_name as name,
|
||||
|
|
@ -165,6 +170,9 @@
|
|||
coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate,
|
||||
coalesce(c.remark, '无') as notes
|
||||
from crm_channel_expansion c
|
||||
left join sys_user u
|
||||
on u.user_id = c.owner_user_id
|
||||
and u.is_deleted = 0
|
||||
left join lateral (
|
||||
select
|
||||
contact_name,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ import static org.mockito.Mockito.when;
|
|||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||
import com.unis.crm.mapper.ProfileMapper;
|
||||
import com.unis.crm.mapper.WorkMapper;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
|
@ -17,6 +20,7 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WorkServiceImplTest {
|
||||
|
|
@ -24,11 +28,14 @@ class WorkServiceImplTest {
|
|||
@Mock
|
||||
private WorkMapper workMapper;
|
||||
|
||||
@Mock
|
||||
private ProfileMapper profileMapper;
|
||||
|
||||
private WorkServiceImpl workService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
workService = new WorkServiceImpl(workMapper, new ObjectMapper(), "build/test-uploads", "");
|
||||
workService = new WorkServiceImpl(workMapper, profileMapper, new ObjectMapper(), "build/test-uploads", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -91,6 +98,43 @@ class WorkServiceImplTest {
|
|||
assertEquals("历史记录类型不支持", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveCheckIn_shouldRejectOnlySeeRole() {
|
||||
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see", "sales"));
|
||||
|
||||
BusinessException exception = assertThrows(
|
||||
BusinessException.class,
|
||||
() -> workService.saveCheckIn(17L, new CreateWorkCheckInRequest()));
|
||||
|
||||
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
|
||||
verify(workMapper, never()).insertCheckIn(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveDailyReport_shouldRejectOnlySeeRole() {
|
||||
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see", "sales"));
|
||||
|
||||
BusinessException exception = assertThrows(
|
||||
BusinessException.class,
|
||||
() -> workService.saveDailyReport(17L, new CreateWorkDailyReportRequest()));
|
||||
|
||||
assertEquals("当前角色仅可查看日报历史记录", exception.getMessage());
|
||||
verify(workMapper, never()).insertDailyReport(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadCheckInPhoto_shouldRejectOnlySeeRole() {
|
||||
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see"));
|
||||
|
||||
BusinessException exception = assertThrows(
|
||||
BusinessException.class,
|
||||
() -> workService.uploadCheckInPhoto(
|
||||
17L,
|
||||
new MockMultipartFile("file", "photo.jpg", "image/jpeg", new byte[] {1, 2, 3})));
|
||||
|
||||
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
|
||||
}
|
||||
|
||||
private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
|
||||
WorkHistoryItemDTO item = new WorkHistoryItemDTO();
|
||||
item.setId(id);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AdaptiveSelectOption = {
|
||||
|
|
@ -77,7 +78,9 @@ export function AdaptiveSelect({
|
|||
const [open, setOpen] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobileViewport();
|
||||
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const selectedValues = multiple
|
||||
? Array.isArray(value) ? value : []
|
||||
: typeof value === "string" && value ? [value] : [];
|
||||
|
|
@ -96,13 +99,27 @@ export function AdaptiveSelect({
|
|||
})
|
||||
: options;
|
||||
|
||||
const updateDesktopDropdownPosition = useCallback(() => {
|
||||
if (isMobile || !open || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDesktopDropdownStyle({
|
||||
top: rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, [isMobile, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
const targetNode = event.target as Node;
|
||||
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -121,6 +138,24 @@ export function AdaptiveSelect({
|
|||
};
|
||||
}, [isMobile, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) {
|
||||
setDesktopDropdownStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateDesktopDropdownPosition();
|
||||
|
||||
const handleViewportChange = () => updateDesktopDropdownPosition();
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
};
|
||||
}, [isMobile, open, updateDesktopDropdownPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isMobile) {
|
||||
return;
|
||||
|
|
@ -199,33 +234,43 @@ export function AdaptiveSelect({
|
|||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && !isMobile ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
className="absolute z-30 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
{searchable ? (
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
|
||||
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
|
||||
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
未找到匹配选项
|
||||
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<motion.div
|
||||
ref={desktopDropdownRef}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: desktopDropdownStyle.top,
|
||||
left: desktopDropdownStyle.left,
|
||||
width: desktopDropdownStyle.width,
|
||||
}}
|
||||
className="z-[160] rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
{searchable ? (
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
|
||||
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
|
||||
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
未找到匹配选项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ export interface ExpansionFollowUp {
|
|||
export interface SalesExpansionItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
owner?: string;
|
||||
type: "sales";
|
||||
employeeNo?: string;
|
||||
name?: string;
|
||||
|
|
@ -385,6 +386,7 @@ export interface RelatedProjectSummary {
|
|||
export interface ChannelExpansionItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
owner?: string;
|
||||
type: "channel";
|
||||
channelCode?: string;
|
||||
name?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
|
||||
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import {
|
||||
|
|
@ -54,8 +54,6 @@ type ChannelField =
|
|||
| "channelAttributeCustom"
|
||||
| "internalAttribute"
|
||||
| "contacts";
|
||||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||
|
||||
function createEmptyChannelContact(): ChannelExpansionContact {
|
||||
return {
|
||||
name: "",
|
||||
|
|
@ -192,6 +190,7 @@ function buildSalesExportHeaders(items: SalesExpansionItem[]) {
|
|||
const headers = [
|
||||
"工号",
|
||||
"姓名",
|
||||
"创建人",
|
||||
"联系方式",
|
||||
"代表处 / 办事处",
|
||||
"所属部门",
|
||||
|
|
@ -218,6 +217,7 @@ function buildSalesExportData(items: SalesExpansionItem[]) {
|
|||
const row = [
|
||||
normalizeExportText(item.employeeNo),
|
||||
normalizeExportText(item.name),
|
||||
normalizeExportText(item.owner),
|
||||
normalizeExportText(item.phone),
|
||||
normalizeExportText(item.officeName),
|
||||
normalizeExportText(item.dept),
|
||||
|
|
@ -249,6 +249,7 @@ function buildChannelExportHeaders(items: ChannelExpansionItem[]) {
|
|||
const headers = [
|
||||
"编码",
|
||||
"渠道名称",
|
||||
"创建人",
|
||||
"省份",
|
||||
"市",
|
||||
"办公地址",
|
||||
|
|
@ -285,6 +286,7 @@ function buildChannelExportData(items: ChannelExpansionItem[]) {
|
|||
const row = [
|
||||
normalizeExportText(item.channelCode),
|
||||
normalizeExportText(item.name),
|
||||
normalizeExportText(item.owner),
|
||||
normalizeExportText(item.province),
|
||||
normalizeExportText(item.city),
|
||||
normalizeExportText(item.officeAddress),
|
||||
|
|
@ -1698,25 +1700,25 @@ export default function Expansion() {
|
|||
{isOwnedByCurrentUser ? (
|
||||
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
||||
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||
isOwnedByCurrentUser
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||
</span>
|
||||
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
|
||||
{item.active ? "在职" : "离职"}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
|
||||
isOwnedByCurrentUser
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||
</span>
|
||||
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
|
||||
{item.active ? "在职" : "离职"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
|
||||
|
|
@ -1728,12 +1730,10 @@ export default function Expansion() {
|
|||
<span className="text-slate-400 dark:text-slate-500">跟进项目金额:</span>
|
||||
{formatRelatedProjectAmount(item.relatedProjects)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
|
||||
<button type="button" className={`${detailBadgeClass} px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px]`}>
|
||||
查看详情
|
||||
<ChevronRight className="crm-icon-sm" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<span className="text-slate-400 dark:text-slate-500">创建人:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
@ -1759,27 +1759,27 @@ export default function Expansion() {
|
|||
{isOwnedByCurrentUser ? (
|
||||
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
||||
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
|
||||
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||
isOwnedByCurrentUser
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||
</span>
|
||||
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
|
||||
{item.intent ? `${item.intent}意向` : "未评估"}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
|
||||
isOwnedByCurrentUser
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||
</span>
|
||||
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
|
||||
{item.intent ? `${item.intent}意向` : "未评估"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
|
||||
|
|
@ -1791,12 +1791,10 @@ export default function Expansion() {
|
|||
<span className="text-slate-400 dark:text-slate-500">跟进项目金额:</span>
|
||||
{formatRelatedProjectAmount(item.relatedProjects)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
|
||||
<button type="button" className={detailBadgeClass}>
|
||||
查看详情
|
||||
<ChevronRight className="crm-icon-sm" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<span className="text-slate-400 dark:text-slate-500">创建人:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Search, Plus, Download, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
||||
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||
|
||||
const CONFIDENCE_OPTIONS = [
|
||||
{ value: "A", label: "A" },
|
||||
{ value: "B", label: "B" },
|
||||
|
|
@ -1160,6 +1158,7 @@ export default function Opportunities() {
|
|||
const headers = [
|
||||
"项目编号",
|
||||
"项目名称",
|
||||
"创建人",
|
||||
"项目地",
|
||||
"最终用户",
|
||||
"建设类型",
|
||||
|
|
@ -1207,6 +1206,7 @@ export default function Opportunities() {
|
|||
worksheet.addRow([
|
||||
normalizeOpportunityExportText(item.code),
|
||||
normalizeOpportunityExportText(item.name),
|
||||
normalizeOpportunityExportText(item.owner),
|
||||
normalizeOpportunityExportText(item.projectLocation),
|
||||
normalizeOpportunityExportText(item.client),
|
||||
normalizeOpportunityExportText(item.type || "新建"),
|
||||
|
|
@ -1599,38 +1599,38 @@ export default function Opportunities() {
|
|||
{isOwnedByCurrentUser ? (
|
||||
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">项目编号:{opp.code || "待生成"}</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-end gap-2 pl-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||
isOwnedByCurrentUser
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||
</span>
|
||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||
{getConfidenceLabel(opp.confidence)}
|
||||
</span>
|
||||
<span className="crm-pill crm-pill-neutral">
|
||||
{opp.stage || "初步沟通"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||
opp.pushedToOms
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
|
||||
isOwnedByCurrentUser
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||
</span>
|
||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||
{getConfidenceLabel(opp.confidence)}
|
||||
</span>
|
||||
<span className="crm-pill crm-pill-neutral">
|
||||
{opp.stage || "初步沟通"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
|
||||
opp.pushedToOms
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1651,13 +1651,12 @@ export default function Opportunities() {
|
|||
<span className="shrink-0 text-slate-400 dark:text-slate-500">后续规划:</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.nextPlan || "暂无回写规划"}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<span className="shrink-0 text-slate-400 dark:text-slate-500">创建人:</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.owner || "无"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
|
||||
<button type="button" className={detailBadgeClass}>
|
||||
查看详情 <ChevronRight className="crm-icon-sm ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
|||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowUp, AtSign, Camera, CheckCircle2, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
|
||||
import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
|
|
@ -164,6 +164,10 @@ export default function Work() {
|
|||
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
|
||||
const [historyPresenterFilter, setHistoryPresenterFilter] = useState("all");
|
||||
const [historyPresenterKeyword, setHistoryPresenterKeyword] = useState("");
|
||||
const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false);
|
||||
const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState<string | null>(null);
|
||||
const [reportStatus, setReportStatus] = useState<string>();
|
||||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(() => readStoredUserProfile());
|
||||
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
||||
|
|
@ -198,6 +202,52 @@ export default function Work() {
|
|||
return options.filter((option) => option.label.toLowerCase().includes(keyword));
|
||||
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
|
||||
|
||||
const historyPresenterOptions = useMemo(() => {
|
||||
const presenterMap = new Map<string, { label: string; count: number }>();
|
||||
for (const item of historyData) {
|
||||
const presenter = extractHistoryPresenter(item.content);
|
||||
const normalizedName = presenter.name.trim();
|
||||
if (!presenter.label || !normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const existing = presenterMap.get(normalizedName);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
continue;
|
||||
}
|
||||
presenterMap.set(normalizedName, {
|
||||
label: `${presenter.label}:${normalizedName}`,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(presenterMap.entries())
|
||||
.map(([value, meta]) => ({ value, label: meta.label, count: meta.count }))
|
||||
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value, "zh-CN"));
|
||||
}, [historyData]);
|
||||
|
||||
const filteredHistoryData = useMemo(() => {
|
||||
if (historyPresenterFilter === "all") {
|
||||
return historyData;
|
||||
}
|
||||
return historyData.filter((item) => extractHistoryPresenter(item.content).name.trim() === historyPresenterFilter);
|
||||
}, [historyData, historyPresenterFilter]);
|
||||
|
||||
const historyGroups = useMemo(() => {
|
||||
const groups: Array<{ key: string; date: string; items: WorkHistoryItem[] }> = [];
|
||||
for (const item of filteredHistoryData) {
|
||||
const groupDate = item.date || "未标注日期";
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (lastGroup && lastGroup.date === groupDate) {
|
||||
lastGroup.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const groupSeed = item.id != null ? `${item.type}-${item.id}` : `${item.type}-${groups.length}`;
|
||||
groups.push({ key: `${groupDate}-${groupSeed}`, date: groupDate, items: [item] });
|
||||
}
|
||||
return groups;
|
||||
}, [filteredHistoryData]);
|
||||
|
||||
const loadReportTargets = useCallback(async () => {
|
||||
if (reportTargetsLoaded || reportTargetsLoading) {
|
||||
return;
|
||||
|
|
@ -296,6 +346,36 @@ export default function Work() {
|
|||
setHistoryDetailItem(nextItem);
|
||||
}, [historyData, historyDetailItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyPresenterFilter === "all") {
|
||||
return;
|
||||
}
|
||||
const stillExists = historyPresenterOptions.some((option) => option.value === historyPresenterFilter);
|
||||
if (!stillExists) {
|
||||
setHistoryPresenterFilter("all");
|
||||
}
|
||||
}, [historyPresenterFilter, historyPresenterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyPresenterPickerOpen && historyPresenterKeyword) {
|
||||
setHistoryPresenterKeyword("");
|
||||
}
|
||||
}, [historyPresenterKeyword, historyPresenterPickerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedHistoryGroupKey(null);
|
||||
}, [historySection, historyPresenterFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedHistoryGroupKey) {
|
||||
return;
|
||||
}
|
||||
const stillExists = historyGroups.some((group) => group.key === expandedHistoryGroupKey);
|
||||
if (!stillExists) {
|
||||
setExpandedHistoryGroupKey(null);
|
||||
}
|
||||
}, [expandedHistoryGroupKey, historyGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -888,21 +968,61 @@ export default function Work() {
|
|||
>
|
||||
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
|
||||
|
||||
{historyData.map((item, index) => (
|
||||
<div key={`${item.type}-${item.id}-${index}`}>
|
||||
<HistoryCard
|
||||
item={item}
|
||||
index={index}
|
||||
onOpen={() => setHistoryDetailItem(item)}
|
||||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!historyLoading || historyData.length > 0 ? (
|
||||
<HistoryToolbar
|
||||
section={historySection}
|
||||
presenterFilter={historyPresenterFilter}
|
||||
presenterOptions={historyPresenterOptions}
|
||||
filteredCount={filteredHistoryData.length}
|
||||
totalCount={historyData.length}
|
||||
onOpenPresenterPicker={() => setHistoryPresenterPickerOpen(true)}
|
||||
onPresenterFilterChange={(value) => {
|
||||
setHistoryPresenterFilter(value);
|
||||
setHistoryPresenterPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!historyLoading && historyData.length === 0 ? (
|
||||
{historyGroups.map((group) => {
|
||||
const groupExpanded = historyPresenterFilter !== "all" || expandedHistoryGroupKey === group.key;
|
||||
const canToggleGroup = historyPresenterFilter === "all";
|
||||
|
||||
return (
|
||||
<div key={group.key} className="space-y-3">
|
||||
<HistoryDateGroup
|
||||
date={group.date}
|
||||
count={group.items.length}
|
||||
expanded={groupExpanded}
|
||||
canToggle={canToggleGroup}
|
||||
onToggle={() => {
|
||||
if (!canToggleGroup) {
|
||||
return;
|
||||
}
|
||||
setExpandedHistoryGroupKey((current) => (current === group.key ? null : group.key));
|
||||
}}
|
||||
/>
|
||||
{groupExpanded
|
||||
? group.items.map((item, index) => (
|
||||
<div key={`${group.date}-${item.type}-${item.id}-${index}`}>
|
||||
<HistoryCard
|
||||
item={item}
|
||||
index={index}
|
||||
onOpen={() => setHistoryDetailItem(item)}
|
||||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!historyLoading && filteredHistoryData.length === 0 ? (
|
||||
<div className="crm-empty-panel">
|
||||
当前没有{getHistoryLabelBySection(historySection)}记录
|
||||
{historyPresenterFilter === "all"
|
||||
? `当前没有${getHistoryLabelBySection(historySection)}记录`
|
||||
: `当前筛选条件下没有${getHistoryLabelBySection(historySection)}记录`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1041,6 +1161,21 @@ export default function Work() {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{historyPresenterPickerOpen ? (
|
||||
<HistoryPresenterPickerModal
|
||||
section={historySection}
|
||||
presenterFilter={historyPresenterFilter}
|
||||
presenterKeyword={historyPresenterKeyword}
|
||||
presenterOptions={historyPresenterOptions}
|
||||
onClose={() => setHistoryPresenterPickerOpen(false)}
|
||||
onPresenterFilterChange={(value) => {
|
||||
setHistoryPresenterFilter(value);
|
||||
setHistoryPresenterPickerOpen(false);
|
||||
}}
|
||||
onPresenterKeywordChange={setHistoryPresenterKeyword}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewPhoto ? (
|
||||
<PhotoPreviewModal
|
||||
url={previewPhoto.url}
|
||||
|
|
@ -1463,6 +1598,226 @@ function WorkHistorySkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function HistoryToolbar({
|
||||
section,
|
||||
presenterFilter,
|
||||
presenterOptions,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
onOpenPresenterPicker,
|
||||
onPresenterFilterChange,
|
||||
}: {
|
||||
section: WorkSection;
|
||||
presenterFilter: string;
|
||||
presenterOptions: Array<{ value: string; label: string; count: number }>;
|
||||
filteredCount: number;
|
||||
totalCount: number;
|
||||
onOpenPresenterPicker: () => void;
|
||||
onPresenterFilterChange: (value: string) => void;
|
||||
}) {
|
||||
const currentPresenterLabel = presenterFilter === "all"
|
||||
? "全部人员"
|
||||
: presenterOptions.find((option) => option.value === presenterFilter)?.value ?? presenterFilter;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50/92 to-slate-100/85 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-900/96 dark:to-slate-950">
|
||||
<div className="flex flex-col gap-2.5 p-2.5 sm:gap-3 sm:p-3">
|
||||
<div className="rounded-2xl border border-white/80 bg-white/95 p-2 shadow-sm dark:border-slate-700 dark:bg-slate-900/80 sm:p-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
人员搜索
|
||||
</span>
|
||||
<span className="hidden text-[11px] text-slate-400 dark:text-slate-500 sm:inline">{presenterOptions.length} 人可选</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPresenterPicker}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-left transition-colors hover:border-violet-300 hover:bg-white dark:border-slate-700 dark:bg-slate-800/70 dark:hover:border-violet-500/40 dark:hover:bg-slate-800"
|
||||
>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-white">{currentPresenterLabel}</p>
|
||||
<span className="shrink-0 rounded-full bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-600 dark:bg-violet-500/10 dark:text-violet-300">
|
||||
筛选
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPresenterPickerModal({
|
||||
section,
|
||||
presenterFilter,
|
||||
presenterKeyword,
|
||||
presenterOptions,
|
||||
onClose,
|
||||
onPresenterFilterChange,
|
||||
onPresenterKeywordChange,
|
||||
}: {
|
||||
section: WorkSection;
|
||||
presenterFilter: string;
|
||||
presenterKeyword: string;
|
||||
presenterOptions: Array<{ value: string; label: string; count: number }>;
|
||||
onClose: () => void;
|
||||
onPresenterFilterChange: (value: string) => void;
|
||||
onPresenterKeywordChange: (value: string) => void;
|
||||
}) {
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const normalizedKeyword = presenterKeyword.trim().toLowerCase();
|
||||
const visiblePresenterOptions = normalizedKeyword
|
||||
? presenterOptions.filter((option) => option.label.toLowerCase().includes(normalizedKeyword) || option.value.toLowerCase().includes(normalizedKeyword))
|
||||
: presenterOptions;
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[92]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
|
||||
aria-label="关闭人员筛选"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute flex flex-col overflow-hidden border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900",
|
||||
isMobileViewport
|
||||
? "inset-x-0 bottom-0 max-h-[86dvh] rounded-t-3xl"
|
||||
: "left-1/2 top-1/2 h-[min(72vh,720px)] w-[min(520px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-3xl",
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{getHistoryLabelBySection(section)}人员筛选</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">输入姓名快速搜索,人员再多也不会把历史区域挤乱。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={presenterKeyword}
|
||||
onChange={(event) => onPresenterKeywordChange(event.target.value)}
|
||||
placeholder="搜索打卡人或提交人"
|
||||
className="h-11 w-full rounded-2xl border border-slate-200 bg-slate-50 pl-10 pr-3 text-sm text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPresenterFilterChange("all")}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-colors",
|
||||
presenterFilter === "all"
|
||||
? "border-violet-500 bg-violet-50 text-violet-700 dark:border-violet-500/50 dark:bg-violet-500/10 dark:text-violet-200"
|
||||
: "border-slate-200 bg-white text-slate-700 hover:border-violet-200 hover:bg-violet-50/70 dark:border-slate-800 dark:bg-slate-900/40 dark:text-slate-200 dark:hover:border-violet-500/30 dark:hover:bg-slate-800",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">全部人员</p>
|
||||
<p className="mt-1 text-xs opacity-70">查看全部{getHistoryLabelBySection(section)}记录</p>
|
||||
</div>
|
||||
{presenterFilter === "all" ? <CheckCircle2 className="h-4 w-4 shrink-0" /> : null}
|
||||
</button>
|
||||
|
||||
{visiblePresenterOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onPresenterFilterChange(option.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition-colors",
|
||||
presenterFilter === option.value
|
||||
? "border-violet-500 bg-violet-50 text-violet-700 dark:border-violet-500/50 dark:bg-violet-500/10 dark:text-violet-200"
|
||||
: "border-slate-200 bg-white text-slate-700 hover:border-violet-200 hover:bg-violet-50/70 dark:border-slate-800 dark:bg-slate-900/40 dark:text-slate-200 dark:hover:border-violet-500/30 dark:hover:bg-slate-800",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{option.value}</p>
|
||||
<p className="mt-1 text-xs opacity-70">{option.count} 条记录</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
||||
{option.count}
|
||||
</span>
|
||||
{presenterFilter === option.value ? <CheckCircle2 className="h-4 w-4" /> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{normalizedKeyword && visiblePresenterOptions.length === 0 ? (
|
||||
<div className="crm-empty-panel px-4 py-10">未找到匹配人员</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryDateGroup({
|
||||
date,
|
||||
count,
|
||||
expanded,
|
||||
canToggle,
|
||||
onToggle,
|
||||
}: {
|
||||
date: string;
|
||||
count: number;
|
||||
expanded: boolean;
|
||||
canToggle: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"sticky top-0 z-[1] flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50/96 px-3 py-2 text-left shadow-sm backdrop-blur transition-colors dark:border-slate-800 dark:bg-slate-900/96",
|
||||
canToggle
|
||||
? "hover:border-violet-200 hover:bg-white dark:hover:border-violet-500/30 dark:hover:bg-slate-900"
|
||||
: "cursor-default",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{formatHistoryGroupDate(date)}</p>
|
||||
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{date === "未标注日期" ? "未补充日期信息" : `共 ${count} 条记录`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-slate-500 shadow-sm dark:bg-slate-800 dark:text-slate-300">
|
||||
{count} 条
|
||||
</span>
|
||||
{canToggle ? (
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white text-slate-400 shadow-sm dark:bg-slate-800 dark:text-slate-300">
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform", expanded ? "rotate-180" : "")} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryCard({
|
||||
item,
|
||||
index,
|
||||
|
|
@ -2131,18 +2486,25 @@ function HistoryDetailModal({
|
|||
: "border-emerald-200 bg-emerald-50/90 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95]">
|
||||
<button
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn("absolute inset-0 bg-slate-900/45", !disableMobileMotion && "backdrop-blur-sm")}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn("fixed inset-0 z-[95] bg-slate-900/45", !disableMobileMotion && "backdrop-blur-sm")}
|
||||
aria-label="关闭历史详情"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 max-h-[86dvh] overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 md:inset-x-auto md:left-1/2 md:top-1/2 md:bottom-auto md:w-[min(760px,88vw)] md:max-h-[80vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||||
<div className="flex items-start justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{item.type || "历史记录详情"}</h3>
|
||||
<p className="crm-field-note mt-1">{[item.date, item.time].filter(Boolean).join(" ") || "无时间信息"}</p>
|
||||
<motion.div
|
||||
initial={{ x: "100%", y: 0 }}
|
||||
animate={{ x: 0, y: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-x-0 bottom-0 z-[96] flex h-[90dvh] w-full flex-col rounded-t-3xl border border-slate-200 bg-white shadow-2xl transition-opacity dark:border-slate-800 dark:bg-slate-900 sm:inset-y-0 sm:right-0 sm:left-auto sm:h-full sm:w-[min(760px,92vw)] sm:max-w-none sm:rounded-none sm:rounded-l-3xl sm:border-l lg:w-[min(820px,88vw)]"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" />
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.type || "历史记录详情"}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2154,8 +2516,14 @@ function HistoryDetailModal({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(86dvh-84px)] overflow-y-auto px-5 py-4 pb-[calc(env(safe-area-inset-bottom)+20px)] md:max-h-[calc(80vh-84px)] md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 pb-[calc(env(safe-area-inset-bottom)+20px)] sm:px-6 sm:py-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 dark:text-slate-500">
|
||||
{[item.date, item.time].filter(Boolean).join(" ") || "无时间信息"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{item.status ? (
|
||||
<span className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
|
|
@ -2224,8 +2592,8 @@ function HistoryDetailModal({
|
|||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2326,6 +2694,18 @@ function extractHistoryPresenter(content: string | undefined) {
|
|||
};
|
||||
}
|
||||
|
||||
function formatHistoryGroupDate(date: string) {
|
||||
if (!date || date === "未标注日期") {
|
||||
return "未标注日期";
|
||||
}
|
||||
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return date;
|
||||
}
|
||||
return format(parsed, "M月d日 EEEE", { locale: zhCN });
|
||||
}
|
||||
|
||||
function SummaryCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">
|
||||
|
|
|
|||
Loading…
Reference in New Issue