Dockerfile 改用 standalone 模式 next.config.js 添加 output standalone 修复 cssnano-simple 构建失败:globals.css 注释含 */ 被误解析 tailwind.config.js 覆盖 boxShadow 为 rgba 格式 + 禁用 ringWidth 组件修复:替换 / 语法为 inline style 或自定义类 otel.ts 添加 webpackIgnore 跳过 optionalDependencies 静态分析 .env.example 默认禁用 mock 新增 .dockerignore 和 docs/nextstep.md 删除 postcss.config.js 验证:Docker 镜像构建成功,容器 healthy,typecheck + lint 零错误
129 lines
3.2 KiB
TypeScript
129 lines
3.2 KiB
TypeScript
// AttendanceWarningBanner:考勤异常预警横幅
|
||
// 依据:参考项目差距补齐 - 考勤异常预警
|
||
// - 缺勤≥3 高危(红色)
|
||
// - 迟到≥3 中危(黄色)
|
||
// - 出勤率<90% 高危(红色)
|
||
// - 可关闭(本地 state,不持久化)
|
||
|
||
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import type { AttendanceRecord } from "@/types";
|
||
import { cn, formatPercent } from "@/lib/utils";
|
||
|
||
interface AttendanceWarningBannerProps {
|
||
records: AttendanceRecord[];
|
||
}
|
||
|
||
interface AttendanceStats {
|
||
present: number;
|
||
late: number;
|
||
absent: number;
|
||
leave: number;
|
||
total: number;
|
||
rate: number;
|
||
}
|
||
|
||
function calculateStats(records: AttendanceRecord[]): AttendanceStats {
|
||
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
|
||
for (const r of records) {
|
||
counts[r.status]++;
|
||
}
|
||
const total = records.length;
|
||
const attended = counts.present + counts.late;
|
||
const rate = total > 0 ? attended / total : 1;
|
||
return { ...counts, total, rate };
|
||
}
|
||
|
||
interface WarningItem {
|
||
message: string;
|
||
level: "warning" | "danger";
|
||
}
|
||
|
||
export function AttendanceWarningBanner({
|
||
records,
|
||
}: AttendanceWarningBannerProps): JSX.Element | null {
|
||
const [dismissed, setDismissed] = useState(false);
|
||
|
||
const warnings = useMemo<WarningItem[]>(() => {
|
||
const stats = calculateStats(records);
|
||
const items: WarningItem[] = [];
|
||
|
||
if (stats.absent >= 3) {
|
||
items.push({
|
||
message: `本月缺勤 ${stats.absent} 次,超过预警阈值(3 次)`,
|
||
level: "danger",
|
||
});
|
||
}
|
||
|
||
if (stats.late >= 3) {
|
||
items.push({
|
||
message: `本月迟到 ${stats.late} 次,超过预警阈值(3 次)`,
|
||
level: "warning",
|
||
});
|
||
}
|
||
|
||
if (stats.total > 0 && stats.rate < 0.9) {
|
||
items.push({
|
||
message: `本月出勤率偏低(${formatPercent(stats.rate)}),低于 90%`,
|
||
level: "danger",
|
||
});
|
||
}
|
||
|
||
return items;
|
||
}, [records]);
|
||
|
||
if (dismissed || warnings.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const hasDanger = warnings.some((w) => w.level === "danger");
|
||
|
||
return (
|
||
<div
|
||
role="alert"
|
||
className={cn(
|
||
"rounded border p-4",
|
||
hasDanger ? "border-danger" : "border-warning",
|
||
)}
|
||
style={{
|
||
backgroundColor: hasDanger
|
||
? "hsl(var(--danger) / 0.1)"
|
||
: "hsl(var(--warning) / 0.1)",
|
||
}}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 space-y-1">
|
||
<p className="font-serif text-base">
|
||
{hasDanger ? "考勤异常预警" : "考勤提醒"}
|
||
</p>
|
||
<ul className="space-y-1 text-sm">
|
||
{warnings.map((w, idx) => (
|
||
<li
|
||
key={idx}
|
||
className={cn(
|
||
w.level === "danger" ? "text-danger" : "text-warning",
|
||
)}
|
||
>
|
||
{w.message}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDismissed(true)}
|
||
className="flex-shrink-0 rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
|
||
aria-label="关闭预警"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default AttendanceWarningBanner;
|
||
|
||
export { calculateStats };
|