@@ -695,6 +875,12 @@ createApp({
const settings = reactive({ ...DEFAULTS });
const records = ref({});
+ // 月度奖惩终稿:key = 'YYYY-MM'
+ // { status:'draft'|'final', finalAmount:Number|null, note:String, finalizedAt:ISOString }
+ // 次月 1–2 号汇总申报后,实际审核金额可能与日常累计不同(如累计 100、审核只认 80)。
+ // 定稿后所有发放口径都以终稿为准,日常每日记录通道保持不变、也不锁定。
+ const monthAdjust = ref({});
+
// ===== 工时口径:6–12h,0.5 步进 =====
const HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
const hourOptions = [];
@@ -728,6 +914,10 @@ createApp({
foundBonus: sn(settings.foundBonus, DEFAULTS.foundBonus),
missedPenalty: sn(settings.missedPenalty, DEFAULTS.missedPenalty),
cutoff: Math.min(28, Math.max(1, Math.round(sn(settings.cutoff, DEFAULTS.cutoff)))),
+ // payDayA/payDayB 原本只用于文案展示,现在参与日历发薪日判定,必须进 cfg 做同样的洗值,
+ // 否则 payDayKind() 拿到 undefined,比较永远不命中,角标与内描边都不会出现。
+ payDayA: Math.min(31, Math.max(1, Math.round(sn(settings.payDayA, DEFAULTS.payDayA)))),
+ payDayB: Math.min(31, Math.max(1, Math.round(sn(settings.payDayB, DEFAULTS.payDayB)))),
dayHours: clampHours(sn(settings.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
nightHours: clampHours(sn(settings.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours)
}));
@@ -781,6 +971,8 @@ createApp({
const firstDayOfWeek = computed(() => new Date(currentYear.value, currentMonth.value, 1).getDay());
const monthPrefix = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}-`;
const keyOf = (y, m, d) => monthPrefix(y, m) + String(d).padStart(2, '0');
+ const monthKeyOf = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}`;
+ const getMonthAdjust = (y, m) => monthAdjust.value[monthKeyOf(y, m)] || null;
const getRecord = (day) => records.value[keyOf(currentYear.value, currentMonth.value, day)];
const prevMonthOf = (y, m) => m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 };
const nextMonthOf = (y, m) => m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 };
@@ -840,16 +1032,29 @@ createApp({
}
const totalHours = h1 + h2;
- // 奖惩池:满勤奖也算奖励,全部挂账,次月初出结果并随次月工资发放
+ // 满勤奖只由出勤天数客观判定,不参与人工审核修正
const fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
const foundBonus = foundCount * c.foundBonus;
const missedPenalty = missedCount * c.missedPenalty;
- const adjustNet = fullBonus + foundBonus - missedPenalty - otherPenalties;
+
+ // 待申报奖惩 = 查获 - 漏查 - 其他扣款(不含满勤奖),这是需要次月初核对的部分
+ const rawReview = foundBonus - missedPenalty - otherPenalties;
+
+ // 已定稿 → 用审核终稿金额;未定稿 → 用日常累计值(预估)
+ const adj = getMonthAdjust(year, month);
+ const finalized = !!(adj && adj.status === 'final' && Number.isFinite(Number(adj.finalAmount)));
+ const reviewAmount = finalized ? Number(adj.finalAmount) : rawReview;
+ const reviewDelta = finalized ? reviewAmount - rawReview : 0;
+ const adjustNet = fullBonus + reviewAmount;
return {
h1, h2, totalHours, workDays, dayShifts, nightShifts, restDays, leaveDays,
foundCount, missedCount, otherPenalties,
fullBonus, foundBonus, missedPenalty, adjustNet,
+ rawReview, reviewAmount, reviewDelta, finalized,
+ adjustStatus: adj ? adj.status : null,
+ adjustNote: adj && adj.note ? adj.note : '',
+ finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
basePay: totalHours * c.rate
};
};
@@ -896,6 +1101,35 @@ createApp({
return !!r && r.status === 'work' && (Number(r.contraband_missed || 0) > 0 || Number(r.other_penalty || 0) > 0);
};
+ // 发薪日判定:'a' = 当月 payDayA(上半月工时,线上);'b' = 当月 payDayB(上月下半月工时 + 上月奖惩,线下)
+ // 只做统计标注,不处理"遇周末顺延",实际到账日以单位为准。
+ const payDayKind = (day) => {
+ const c = cfg.value;
+ if (day === c.payDayA) return 'a';
+ if (day === c.payDayB) return 'b';
+ return null;
+ };
+
+ // 选中日如果是发薪日,打卡抽屉顶部展示该笔到账(不打断打卡主流程)
+ const selectedPayInfo = computed(() => {
+ const kind = payDayKind(selectedDay.value);
+ if (!kind) return null;
+ if (kind === 'a') {
+ return {
+ kind: 'a',
+ amount: payoutThisA.value,
+ desc: `本月上半月 ${fmt(monthStats.value.h1)}h · 纯工时`,
+ finalized: true
+ };
+ }
+ return {
+ kind: 'b',
+ amount: payoutThisB.value,
+ desc: `${prevLabel.value.month + 1}月下半月 ${fmt(prevStats.value.h2)}h + 奖惩 ${signed(prevStats.value.adjustNet)}`,
+ finalized: prevStats.value.finalized
+ };
+ });
+
// 上下半月分界格:7 列网格里 day+7 就在 day 正下方,
// 所以给 cutoff-6 … cutoff 这 7 格加底虚线,正好描出一条完整分界线,
// 而且不需要往网格里插额外元素,星期对齐不会被打买。
@@ -908,7 +1142,13 @@ createApp({
const r = getRecord(day);
const cls = [];
if (isCutEdge(day)) cls.push('cut-edge');
+ // 今天的 ring 与发薪日内描边都是 box-shadow,会互相覆盖,
+ // 所以重合时只保留今天的 ring,发薪日仍由右上角 ¥ 角标表达。
if (isToday(day)) cls.push('ring-2 ring-indigo-500/70');
+ else {
+ const pk = payDayKind(day);
+ if (pk) cls.push(pk === 'a' ? 'pay-ring-a' : 'pay-ring-b');
+ }
if (!r) cls.push('bg-white border-slate-100');
else if (r.status === 'work') cls.push(r.shift_type === 'night' ? 'bg-indigo-50/70 border-indigo-100' : 'bg-amber-50/70 border-amber-100');
else if (r.status === 'rest') cls.push('bg-slate-100/70 border-slate-200');
@@ -939,6 +1179,70 @@ createApp({
const openSettings = () => { settingsOpen.value = true; };
const closeSettings = () => { settingsOpen.value = false; };
+ // ===== 奖惩终稿修正入口 =====
+ // 日常每日记录通道不受影响;这里只覆盖"待申报奖惩总金额"。
+ // 场景:累计 100,次月初审核下来只认 80 → 定稿为 80。
+ const adjustOpen = ref(false);
+ const adjustTarget = ref({ year: today.getFullYear(), month: today.getMonth() });
+ const adjustDraft = ref({ finalAmount: null, note: '' });
+
+ const adjustStats = computed(() => aggregateMonth(adjustTarget.value.year, adjustTarget.value.month));
+ const adjustDelta = computed(() => {
+ const v = Number(adjustDraft.value.finalAmount);
+ if (!Number.isFinite(v)) return null;
+ return v - adjustStats.value.rawReview;
+ });
+
+ const persistAdjust = () => localStorage.setItem('work_month_adjust', JSON.stringify(monthAdjust.value));
+
+ const openAdjust = (year, month) => {
+ adjustTarget.value = { year, month };
+ const cur = getMonthAdjust(year, month);
+ const raw = aggregateMonth(year, month).rawReview;
+ adjustDraft.value = {
+ // 首次打开预填日常累计值,方便在它基础上改
+ finalAmount: cur && Number.isFinite(Number(cur.finalAmount)) ? Number(cur.finalAmount) : Math.round(raw * 10) / 10,
+ note: cur && cur.note ? cur.note : ''
+ };
+ adjustOpen.value = true;
+ };
+ const closeAdjust = () => { adjustOpen.value = false; };
+
+ const writeAdjust = (status) => {
+ const t = adjustTarget.value;
+ const v = Number(adjustDraft.value.finalAmount);
+ if (!Number.isFinite(v)) { showToast('请先填写终稿金额'); return false; }
+ monthAdjust.value[monthKeyOf(t.year, t.month)] = {
+ status,
+ finalAmount: v,
+ note: String(adjustDraft.value.note || '').slice(0, 60),
+ finalizedAt: status === 'final' ? new Date().toISOString() : ''
+ };
+ persistAdjust();
+ return true;
+ };
+
+ const saveAdjustDraft = () => {
+ if (!writeAdjust('draft')) return;
+ adjustOpen.value = false;
+ showToast('已存为草稿,尚未生效');
+ };
+
+ const finalizeAdjust = () => {
+ if (!writeAdjust('final')) return;
+ adjustOpen.value = false;
+ if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(30);
+ showToast(`已定稿 ${adjustTarget.value.month + 1}月奖惩`);
+ };
+
+ const unfinalizeAdjust = () => {
+ const t = adjustTarget.value;
+ delete monthAdjust.value[monthKeyOf(t.year, t.month)];
+ persistAdjust();
+ adjustOpen.value = false;
+ showToast('已撑销定稿,恢复日常累计口径');
+ };
+
const selectShift = (type) => {
draftRecord.value.shift_type = type;
draftRecord.value.hours = type === 'night' ? cfg.value.nightHours : cfg.value.dayHours;
@@ -1018,10 +1322,11 @@ createApp({
const bundle = () => JSON.stringify({
app: 'work-hours-tracker',
- version: 2,
+ version: 3,
exportedAt: new Date().toISOString(),
settings: { ...settings },
- records: records.value
+ records: records.value,
+ monthAdjust: monthAdjust.value
}, null, 2);
const exportData = () => {
@@ -1059,6 +1364,14 @@ createApp({
if (parsed.settings[k] !== undefined) settings[k] = parsed.settings[k];
}
}
+ // 奖惩终稿(v3 开始导出):同月份覆盖,旧版备份没此字段时保持现有
+ if (parsed.monthAdjust && typeof parsed.monthAdjust === 'object') {
+ for (const k in parsed.monthAdjust) {
+ if (!/^\d{4}-\d{2}$/.test(k)) continue;
+ monthAdjust.value[k] = parsed.monthAdjust[k];
+ }
+ persistAdjust();
+ }
persist();
return n;
};
@@ -1104,6 +1417,8 @@ createApp({
onMounted(() => {
const savedRecords = localStorage.getItem('work_records');
if (savedRecords) { try { records.value = JSON.parse(savedRecords) || {}; } catch (e) {} }
+ const savedAdjust = localStorage.getItem('work_month_adjust');
+ if (savedAdjust) { try { monthAdjust.value = JSON.parse(savedAdjust) || {}; } catch (e) {} }
const savedSettings = localStorage.getItem('work_settings');
if (savedSettings) {
try {
@@ -1126,6 +1441,9 @@ createApp({
payoutThisA, payoutThisB, payoutNextB, penaltyDetails,
fmt, money, signed, moneyParts, boardMain, buildReport, applyImport,
isToday, isCurrentMonth, getRecord, hasPenalty, cellClass, isCutEdge,
+ payDayKind, selectedPayInfo,
+ monthAdjust, adjustOpen, adjustTarget, adjustDraft, adjustStats, adjustDelta,
+ openAdjust, closeAdjust, saveAdjustDraft, finalizeAdjust, unfinalizeAdjust,
changeMonth, goToday, openDayDrawer, selectShift,
openSettings, closeSettings,
saveRecord, deleteRecord, copyReport,