首页修改卡片展示
parent
db41fe2a91
commit
14087b3a5a
|
|
@ -32,7 +32,11 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
||||||
try (Statement statement = connection.createStatement()) {
|
try (Statement statement = connection.createStatement()) {
|
||||||
statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint");
|
statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint");
|
||||||
statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)");
|
statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)");
|
||||||
|
statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
|
||||||
|
statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
|
||||||
|
statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
|
||||||
}
|
}
|
||||||
|
ensureArchivedAtStorage(connection);
|
||||||
ensureConfidenceGradeStorage(connection);
|
ensureConfidenceGradeStorage(connection);
|
||||||
migrateLegacyOmsProjectCode(connection);
|
migrateLegacyOmsProjectCode(connection);
|
||||||
log.info("Ensured compatibility columns exist for crm_opportunity");
|
log.info("Ensured compatibility columns exist for crm_opportunity");
|
||||||
|
|
@ -41,6 +45,22 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureArchivedAtStorage(Connection connection) throws SQLException {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("""
|
||||||
|
update crm_opportunity
|
||||||
|
set archived_at = case
|
||||||
|
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
|
||||||
|
else null
|
||||||
|
end
|
||||||
|
where archived_at is distinct from case
|
||||||
|
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
|
||||||
|
else null
|
||||||
|
end
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ensureConfidenceGradeStorage(Connection connection) throws SQLException {
|
private void ensureConfidenceGradeStorage(Connection connection) throws SQLException {
|
||||||
String dataType = findColumnDataType(connection, "crm_opportunity", "confidence_pct");
|
String dataType = findColumnDataType(connection, "crm_opportunity", "confidence_pct");
|
||||||
if (dataType == null) {
|
if (dataType == null) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package com.unis.crm.dto.dashboard;
|
package com.unis.crm.dto.dashboard;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
public class DashboardStatDTO {
|
public class DashboardStatDTO {
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
private Long value;
|
private BigDecimal value;
|
||||||
private String metricKey;
|
private String metricKey;
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
|
|
@ -14,11 +16,11 @@ public class DashboardStatDTO {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getValue() {
|
public BigDecimal getValue() {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setValue(Long value) {
|
public void setValue(BigDecimal value) {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
|
|
||||||
private Boolean archived;
|
private Boolean archived;
|
||||||
|
|
||||||
|
private OffsetDateTime archivedAt;
|
||||||
|
|
||||||
private Boolean pushedToOms;
|
private Boolean pushedToOms;
|
||||||
|
|
||||||
private OffsetDateTime omsPushTime;
|
private OffsetDateTime omsPushTime;
|
||||||
|
|
@ -89,6 +91,7 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
|| preSalesName != null
|
|| preSalesName != null
|
||||||
|| competitorName != null
|
|| competitorName != null
|
||||||
|| archived != null
|
|| archived != null
|
||||||
|
|| archivedAt != null
|
||||||
|| pushedToOms != null
|
|| pushedToOms != null
|
||||||
|| omsPushTime != null
|
|| omsPushTime != null
|
||||||
|| status != null
|
|| status != null
|
||||||
|
|
@ -231,6 +234,14 @@ public class UpdateOpportunityIntegrationRequest {
|
||||||
this.archived = archived;
|
this.archived = archived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getArchivedAt() {
|
||||||
|
return archivedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArchivedAt(OffsetDateTime archivedAt) {
|
||||||
|
this.archivedAt = archivedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public Boolean getPushedToOms() {
|
public Boolean getPushedToOms() {
|
||||||
return pushedToOms;
|
return pushedToOms;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import com.unis.crm.dto.dashboard.DashboardActivityDTO;
|
||||||
import com.unis.crm.dto.dashboard.DashboardStatDTO;
|
import com.unis.crm.dto.dashboard.DashboardStatDTO;
|
||||||
import com.unis.crm.dto.dashboard.DashboardTodoDTO;
|
import com.unis.crm.dto.dashboard.DashboardTodoDTO;
|
||||||
import com.unis.crm.dto.dashboard.UserWelcomeDTO;
|
import com.unis.crm.dto.dashboard.UserWelcomeDTO;
|
||||||
|
import com.unisbase.annotation.DataScope;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
@ -15,7 +16,11 @@ public interface DashboardMapper {
|
||||||
|
|
||||||
UserWelcomeDTO selectUserWelcome(@Param("userId") Long userId);
|
UserWelcomeDTO selectUserWelcome(@Param("userId") Long userId);
|
||||||
|
|
||||||
List<DashboardStatDTO> selectDashboardStats(@Param("userId") Long userId);
|
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||||
|
List<DashboardStatDTO> selectOpportunityStats(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||||
|
DashboardStatDTO selectMonthlyChannelStat(@Param("userId") Long userId);
|
||||||
|
|
||||||
List<DashboardTodoDTO> selectTodos(@Param("userId") Long userId);
|
List<DashboardTodoDTO> selectTodos(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
@ -35,7 +36,11 @@ public class DashboardServiceImpl implements DashboardService {
|
||||||
throw new BusinessException("未找到当前用户对应数据");
|
throw new BusinessException("未找到当前用户对应数据");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DashboardStatDTO> stats = dashboardMapper.selectDashboardStats(userId);
|
List<DashboardStatDTO> stats = new ArrayList<>(dashboardMapper.selectOpportunityStats(userId));
|
||||||
|
DashboardStatDTO monthlyChannelStat = dashboardMapper.selectMonthlyChannelStat(userId);
|
||||||
|
if (monthlyChannelStat != null) {
|
||||||
|
stats.add(monthlyChannelStat);
|
||||||
|
}
|
||||||
List<DashboardTodoDTO> todos = dashboardMapper.selectTodos(userId);
|
List<DashboardTodoDTO> todos = dashboardMapper.selectTodos(userId);
|
||||||
List<DashboardActivityDTO> activities = dashboardMapper.selectLatestActivities(userId);
|
List<DashboardActivityDTO> activities = dashboardMapper.selectLatestActivities(userId);
|
||||||
enrichActivityTimeText(activities);
|
enrichActivityTimeText(activities);
|
||||||
|
|
|
||||||
|
|
@ -629,11 +629,21 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (request.getDescription() != null) {
|
if (request.getDescription() != null) {
|
||||||
request.setDescription(trimToEmpty(request.getDescription()));
|
request.setDescription(trimToEmpty(request.getDescription()));
|
||||||
}
|
}
|
||||||
|
normalizeArchivedTime(request);
|
||||||
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
|
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
|
||||||
autoFillOmsPushTime(request);
|
autoFillOmsPushTime(request);
|
||||||
validateIntegrationOperatorRelations(request, target);
|
validateIntegrationOperatorRelations(request, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void normalizeArchivedTime(UpdateOpportunityIntegrationRequest request) {
|
||||||
|
if (request.getArchivedAt() != null && request.getArchived() == null) {
|
||||||
|
request.setArchived(Boolean.TRUE);
|
||||||
|
}
|
||||||
|
if (Boolean.FALSE.equals(request.getArchived())) {
|
||||||
|
request.setArchivedAt(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void autoFillOmsPushTime(UpdateOpportunityIntegrationRequest request) {
|
private void autoFillOmsPushTime(UpdateOpportunityIntegrationRequest request) {
|
||||||
if (Boolean.TRUE.equals(request.getPushedToOms()) && request.getOmsPushTime() == null) {
|
if (Boolean.TRUE.equals(request.getPushedToOms()) && request.getOmsPushTime() == null) {
|
||||||
request.setOmsPushTime(java.time.OffsetDateTime.now());
|
request.setOmsPushTime(java.time.OffsetDateTime.now());
|
||||||
|
|
|
||||||
|
|
@ -43,40 +43,37 @@
|
||||||
limit 1
|
limit 1
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectDashboardStats" resultType="com.unis.crm.dto.dashboard.DashboardStatDTO">
|
<select id="selectOpportunityStats" resultType="com.unis.crm.dto.dashboard.DashboardStatDTO">
|
||||||
select '本月新增商机' as name,
|
select '本月新增商机' as name,
|
||||||
count(1)::bigint as value,
|
coalesce(round(sum(o.amount) / 10000.0, 2), 0)::numeric as value,
|
||||||
'monthlyOpportunities' as metricKey
|
'monthlyOpportunities' as metricKey
|
||||||
from crm_opportunity
|
from crm_opportunity o
|
||||||
where owner_user_id = #{userId}
|
where date_trunc('month', o.created_at) = date_trunc('month', now())
|
||||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
|
||||||
|
|
||||||
union all
|
union all
|
||||||
|
|
||||||
select '已推送OMS项目' as name,
|
select '已推送OMS项目' as name,
|
||||||
count(1)::bigint as value,
|
coalesce(round(sum(o.amount) / 10000.0, 2), 0)::numeric as value,
|
||||||
'pushedOmsProjects' as metricKey
|
'pushedOmsProjects' as metricKey
|
||||||
from crm_opportunity
|
from crm_opportunity o
|
||||||
where owner_user_id = #{userId}
|
where coalesce(o.pushed_to_oms, false) = true
|
||||||
and coalesce(pushed_to_oms, false) = true
|
|
||||||
|
|
||||||
union all
|
union all
|
||||||
|
|
||||||
|
select '本月已签单商机金额' as name,
|
||||||
|
coalesce(round(sum(o.amount) / 10000.0, 2), 0)::numeric as value,
|
||||||
|
'monthlyWonOpportunities' as metricKey
|
||||||
|
from crm_opportunity o
|
||||||
|
where o.archived_at is not null
|
||||||
|
and date_trunc('month', o.archived_at) = date_trunc('month', now())
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectMonthlyChannelStat" resultType="com.unis.crm.dto.dashboard.DashboardStatDTO">
|
||||||
select '本月新增渠道' as name,
|
select '本月新增渠道' as name,
|
||||||
count(1)::bigint as value,
|
count(1)::numeric as value,
|
||||||
'monthlyChannels' as metricKey
|
'monthlyChannels' as metricKey
|
||||||
from crm_channel_expansion
|
from crm_channel_expansion c
|
||||||
where owner_user_id = #{userId}
|
where date_trunc('month', c.created_at) = date_trunc('month', now())
|
||||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select '本月打卡次数' as name,
|
|
||||||
count(1)::bigint as value,
|
|
||||||
'monthlyCheckins' as metricKey
|
|
||||||
from work_checkin
|
|
||||||
where user_id = #{userId}
|
|
||||||
and date_trunc('month', checkin_date::timestamp) = date_trunc('month', now())
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectTodos" resultType="com.unis.crm.dto.dashboard.DashboardTodoDTO">
|
<select id="selectTodos" resultType="com.unis.crm.dto.dashboard.DashboardTodoDTO">
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,15 @@
|
||||||
<if test="request.archived != null">
|
<if test="request.archived != null">
|
||||||
archived = #{request.archived},
|
archived = #{request.archived},
|
||||||
</if>
|
</if>
|
||||||
|
<if test="request.archivedAt != null">
|
||||||
|
archived_at = #{request.archivedAt},
|
||||||
|
</if>
|
||||||
|
<if test="request.archivedAt == null and request.archived != null">
|
||||||
|
archived_at = case
|
||||||
|
when #{request.archived} then coalesce(o.archived_at, now())
|
||||||
|
else null
|
||||||
|
end,
|
||||||
|
</if>
|
||||||
<if test="request.pushedToOms != null">
|
<if test="request.pushedToOms != null">
|
||||||
pushed_to_oms = #{request.pushedToOms},
|
pushed_to_oms = #{request.pushedToOms},
|
||||||
</if>
|
</if>
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,47 @@ const DASHBOARD_PREVIEW_COUNT = 5;
|
||||||
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
|
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
|
||||||
|
|
||||||
const baseStats = [
|
const baseStats = [
|
||||||
{ name: "本月新增商机", metricKey: "monthlyOpportunities", icon: TrendingUp, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-500/20" },
|
{ name: "本月新增商机", metricKey: "monthlyOpportunities", icon: TrendingUp, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-500/20", mobileVariant: "hero" },
|
||||||
{ name: "已推送OMS项目", metricKey: "pushedOmsProjects", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20" },
|
{ name: "本月已签单商机金额", metricKey: "monthlyWonOpportunities", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20", mobileVariant: "hero" },
|
||||||
{ name: "本月新增渠道", metricKey: "monthlyChannels", icon: Building2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20" },
|
{ name: "已推送OMS项目", metricKey: "pushedOmsProjects", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20", mobileVariant: "compact" },
|
||||||
{ name: "本月打卡次数", metricKey: "monthlyCheckins", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" },
|
{ name: "本月新增渠道", metricKey: "monthlyChannels", icon: Building2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20", mobileVariant: "compact" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: string; state?: { tab: "sales" | "channel" } }> = {
|
const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: string; state?: { tab: "sales" | "channel" } }> = {
|
||||||
monthlyOpportunities: { pathname: "/opportunities" },
|
monthlyOpportunities: { pathname: "/opportunities" },
|
||||||
pushedOmsProjects: { pathname: "/opportunities" },
|
pushedOmsProjects: { pathname: "/opportunities" },
|
||||||
monthlyChannels: { pathname: "/expansion", state: { tab: "channel" } },
|
monthlyChannels: { pathname: "/expansion", state: { tab: "channel" } },
|
||||||
monthlyCheckins: { pathname: "/work/checkin" },
|
monthlyWonOpportunities: { pathname: "/opportunities" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const amountMetricKeys = new Set<(typeof baseStats)[number]["metricKey"]>([
|
||||||
|
"monthlyOpportunities",
|
||||||
|
"pushedOmsProjects",
|
||||||
|
"monthlyWonOpportunities",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formatStatDisplay(metricKey: (typeof baseStats)[number]["metricKey"], value?: number) {
|
||||||
|
const numericValue = value ?? 0;
|
||||||
|
if (!amountMetricKeys.has(metricKey)) {
|
||||||
|
return {
|
||||||
|
value: String(numericValue),
|
||||||
|
unit: "个",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: numericValue.toFixed(2).replace(/\.?0+$/, ""),
|
||||||
|
unit: "万元",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileStatCardClass(mobileVariant: (typeof baseStats)[number]["mobileVariant"]) {
|
||||||
|
if (mobileVariant === "hero") {
|
||||||
|
return "col-span-2 min-h-[116px]";
|
||||||
|
}
|
||||||
|
return "col-span-1 min-h-[104px]";
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobileViewport = useIsMobileViewport();
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
|
@ -68,7 +96,7 @@ export default function Dashboard() {
|
||||||
setHistoryExpanded(false);
|
setHistoryExpanded(false);
|
||||||
}, [home.activities, home.todos]);
|
}, [home.activities, home.todos]);
|
||||||
|
|
||||||
const statMap = new Map((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
|
const statMap = new Map<string, number | undefined>((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
|
||||||
const stats = baseStats.map((stat) => ({
|
const stats = baseStats.map((stat) => ({
|
||||||
...stat,
|
...stat,
|
||||||
value: statMap.get(stat.metricKey),
|
value: statMap.get(stat.metricKey),
|
||||||
|
|
@ -146,30 +174,50 @@ export default function Dashboard() {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
|
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-4 min-[520px]:grid-cols-2 sm:gap-4 xl:grid-cols-4">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => {
|
||||||
<motion.button
|
const display = formatStatDisplay(stat.metricKey, stat.value);
|
||||||
key={stat.name}
|
const isHeroCard = isMobileViewport && stat.mobileVariant === "hero";
|
||||||
type="button"
|
const mobileClass = isMobileViewport ? getMobileStatCardClass(stat.mobileVariant) : "";
|
||||||
onClick={() => handleStatCardClick(stat.metricKey)}
|
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
return (
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<motion.button
|
||||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
|
key={stat.name}
|
||||||
className="crm-card min-h-[88px] rounded-xl p-3 text-left transition-shadow transition-colors hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
|
type="button"
|
||||||
whileTap={disableMobileMotion ? undefined : { scale: 0.98 }}
|
onClick={() => handleStatCardClick(stat.metricKey)}
|
||||||
aria-label={`查看${stat.name}`}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="flex items-center gap-2.5 sm:gap-4">
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
|
||||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}>
|
className={`crm-card group overflow-hidden rounded-xl border border-slate-200/80 bg-white/95 p-3 text-left shadow-[0_14px_40px_-28px_rgba(15,23,42,0.35)] transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_44px_-24px_rgba(109,40,217,0.22)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 min-[520px]:min-h-[128px] sm:rounded-2xl sm:p-5 ${mobileClass} ${!isMobileViewport ? "min-h-[104px]" : ""}`}
|
||||||
<stat.icon className={`h-4.5 w-4.5 ${stat.color} sm:h-6 sm:w-6`} />
|
whileTap={disableMobileMotion ? undefined : { scale: 0.98 }}
|
||||||
|
aria-label={`查看${stat.name}`}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-violet-200/70 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||||
|
<div className={`flex h-full flex-col justify-between ${isHeroCard ? "gap-4" : "gap-3"} min-[520px]:gap-4`}>
|
||||||
|
<div className="flex items-start gap-3 sm:gap-4">
|
||||||
|
<div className={`flex shrink-0 items-center justify-center rounded-xl ${stat.bg} shadow-inner shadow-white/60 ${isHeroCard ? "h-11 w-11" : "h-10 w-10"} min-[520px]:h-12 min-[520px]:w-12`}>
|
||||||
|
<stat.icon className={`${isHeroCard ? "h-5.5 w-5.5" : "h-5 w-5"} ${stat.color} min-[520px]:h-6 min-[520px]:w-6`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 pt-0.5">
|
||||||
|
<p className={`overflow-hidden font-semibold leading-5 text-slate-500 dark:text-slate-400 ${isHeroCard ? "text-[13px]" : "text-xs"} min-[520px]:max-h-10 min-[520px]:text-sm`}>
|
||||||
|
{stat.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between gap-2 min-[520px]:gap-3">
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<p className={`overflow-hidden text-ellipsis whitespace-nowrap font-bold leading-none tracking-[-0.04em] text-slate-900 dark:text-white ${isHeroCard ? "text-[30px]" : "text-[22px]"} min-[400px]:text-[26px] min-[520px]:text-[34px]`}>
|
||||||
|
{display.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[10px] font-medium tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-300 min-[520px]:px-2.5 min-[520px]:text-xs">
|
||||||
|
{display.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
</motion.button>
|
||||||
<p className="truncate text-[11px] font-medium leading-4 text-slate-500 dark:text-slate-400 sm:text-sm sm:leading-5">{stat.name}</p>
|
);
|
||||||
<p className="mt-1 text-lg font-bold leading-none text-slate-900 dark:text-white sm:text-2xl">{stat.value ?? 0}</p>
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
alter table crm_opportunity
|
||||||
|
add column if not exists archived_at timestamptz;
|
||||||
|
|
||||||
|
create index if not exists idx_crm_opportunity_archived_at
|
||||||
|
on crm_opportunity(archived_at);
|
||||||
|
|
||||||
|
comment on column crm_opportunity.archived_at is '归档时间';
|
||||||
|
|
||||||
|
update crm_opportunity
|
||||||
|
set archived_at = case
|
||||||
|
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
|
||||||
|
else null
|
||||||
|
end
|
||||||
|
where archived_at is distinct from case
|
||||||
|
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
|
||||||
|
else null
|
||||||
|
end;
|
||||||
|
|
@ -100,6 +100,7 @@ create table if not exists crm_opportunity (
|
||||||
source varchar(50),
|
source varchar(50),
|
||||||
competitor_name varchar(200),
|
competitor_name varchar(200),
|
||||||
archived boolean not null default false,
|
archived boolean not null default false,
|
||||||
|
archived_at timestamptz,
|
||||||
pushed_to_oms boolean not null default false,
|
pushed_to_oms boolean not null default false,
|
||||||
oms_push_time timestamptz,
|
oms_push_time timestamptz,
|
||||||
description text,
|
description text,
|
||||||
|
|
@ -310,6 +311,7 @@ create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunit
|
||||||
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
|
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
|
||||||
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
|
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
|
||||||
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
|
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
|
||||||
|
create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at);
|
||||||
create index if not exists idx_crm_opportunity_followup_opportunity_time
|
create index if not exists idx_crm_opportunity_followup_opportunity_time
|
||||||
on crm_opportunity_followup(opportunity_id, followup_time desc);
|
on crm_opportunity_followup(opportunity_id, followup_time desc);
|
||||||
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);
|
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ create table if not exists crm_opportunity (
|
||||||
source varchar(50),
|
source varchar(50),
|
||||||
competitor_name varchar(200),
|
competitor_name varchar(200),
|
||||||
archived boolean not null default false,
|
archived boolean not null default false,
|
||||||
|
archived_at timestamptz,
|
||||||
pushed_to_oms boolean not null default false,
|
pushed_to_oms boolean not null default false,
|
||||||
oms_push_time timestamptz,
|
oms_push_time timestamptz,
|
||||||
description text,
|
description text,
|
||||||
|
|
@ -366,6 +367,7 @@ create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunit
|
||||||
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
|
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
|
||||||
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
|
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
|
||||||
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
|
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
|
||||||
|
create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at);
|
||||||
create index if not exists idx_crm_opportunity_followup_opportunity_time
|
create index if not exists idx_crm_opportunity_followup_opportunity_time
|
||||||
on crm_opportunity_followup(opportunity_id, followup_time desc);
|
on crm_opportunity_followup(opportunity_id, followup_time desc);
|
||||||
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);
|
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);
|
||||||
|
|
@ -786,6 +788,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
|
||||||
('crm_opportunity', 'source', '商机来源'),
|
('crm_opportunity', 'source', '商机来源'),
|
||||||
('crm_opportunity', 'competitor_name', '竞品名称'),
|
('crm_opportunity', 'competitor_name', '竞品名称'),
|
||||||
('crm_opportunity', 'archived', '是否归档'),
|
('crm_opportunity', 'archived', '是否归档'),
|
||||||
|
('crm_opportunity', 'archived_at', '归档时间'),
|
||||||
('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),
|
('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),
|
||||||
('crm_opportunity', 'oms_push_time', '推送OMS时间'),
|
('crm_opportunity', 'oms_push_time', '推送OMS时间'),
|
||||||
('crm_opportunity', 'description', '商机说明/备注'),
|
('crm_opportunity', 'description', '商机说明/备注'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue