diff --git a/index.html b/index.html index 30a73eb..abc11e8 100644 --- a/index.html +++ b/index.html @@ -83,6 +83,28 @@ Tailwind CDN 运行时注入的样式可能排在本文件之后,所以用 !important 压住单边颜色。 */ .cut-edge { border-bottom: 2px dashed rgba(148, 163, 184, .55) !important; } + /* ===== 发薪日标记 ===== + 只做"标注"不做"改色":格子底色仍归班次所有(白班橙/夜班靛蓝), + 发薪日用内描边 + 右上角 ¥ 字符表达,避免两套语义抢同一块背景。 + 今天的 ring 优先级更高,遇到重合时只留 ¥ 角标。 */ + .pay-ring-a { box-shadow: inset 0 0 0 1.5px rgba(79, 70, 229, .38); } + .pay-ring-b { box-shadow: inset 0 0 0 1.5px rgba(217, 119, 6, .40); } + .pay-mark { + font-size: 9px; font-weight: 800; line-height: 1; + font-variant-numeric: tabular-nums; + } + .pay-mark.pay-a { color: #4f46e5; } + .pay-mark.pay-b { color: #d97706; } + + /* 定稿状态标签 */ + .stat-tag { + display: inline-flex; align-items: center; gap: 3px; + font-size: 10px; font-weight: 700; line-height: 1; + padding: 3px 7px; border-radius: 999px; white-space: nowrap; + } + .stat-draft { background: #FEF3C7; color: #B45309; } + .stat-final { background: #D1FAE5; color: #047857; } + /* ===== 日历格胶囊结构(第三轮) ===== 班次与工时整合为紧凑胶囊,避免 hInt/hFrac 拆分小数导致的折行; 左下实心属性点移除,仅保留右上状态点。 */ @@ -267,7 +289,8 @@
{{ day }} {{ day }} - + + ¥ @@ -303,6 +326,11 @@ 线下 → 次月{{ settings.payDayB }}号发

奖励与处罚统一在次月{{ settings.payDayB }}号结算

+
+ ¥当月{{ settings.payDayA }}号发薪 + · + ¥当月{{ settings.payDayB }}号发薪 +
@@ -316,12 +344,26 @@ 时薪 {{ settings.rate }} 元/h
-
-
-

{{ currentMonth + 1 }}月{{ settings.payDayB }}日

-

{{ prevLabel.month + 1 }}月下半月 {{ fmt(prevStats.h2) }}h + 奖惩 {{ signed(prevStats.adjustNet) }}

+
+
+
+

{{ currentMonth + 1 }}月{{ settings.payDayB }}日

+

{{ prevLabel.month + 1 }}月下半月 {{ fmt(prevStats.h2) }}h + 奖惩 {{ signed(prevStats.adjustNet) }}

+
+ {{ money(payoutThisB) }} +
+ +
+ + + {{ prevLabel.month + 1 }}月奖惩{{ prevStats.finalized ? '已定稿' : (prevStats.adjustStatus === 'draft' ? '草稿未定稿' : '待申报') }} + +
- {{ money(payoutThisB) }}
@@ -367,10 +409,6 @@

以下金额不计入当月工资

-
- 满勤奖{{ monthStats.workDays >= settings.fullDays ? '(' + fmt(monthStats.totalHours) + 'h×' + settings.fullBonusPerHour + ')' : '(未达' + settings.fullDays + '天)' }} - {{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }} -
查获奖励({{ monthStats.foundCount }}件) {{ monthStats.foundBonus > 0 ? '+' + money(monthStats.foundBonus) : '¥0' }} @@ -383,11 +421,49 @@ 其他扣款 {{ monthStats.otherPenalties > 0 ? '-' + money(monthStats.otherPenalties) : '¥0' }}
+ + +
+ + 日常累计 + (待申报) + + {{ signed(monthStats.rawReview) }} +
+ +
+ + 审核终稿 + + {{ monthStats.reviewDelta > 0 ? '上调' : '下调' }}{{ money(Math.abs(monthStats.reviewDelta)) }} + + + {{ signed(monthStats.reviewAmount) }} +
+ +
+ 满勤奖{{ monthStats.workDays >= settings.fullDays ? '(' + fmt(monthStats.totalHours) + 'h×' + settings.fullBonusPerHour + ')' : '(未达' + settings.fullDays + '天)' }} + {{ monthStats.fullBonus > 0 ? '+' + money(monthStats.fullBonus) : '¥0' }} +
+
奖惩净额 {{ signed(monthStats.adjustNet) }}
+ + +
+ + + {{ monthStats.finalized ? '已定稿' : (monthStats.adjustStatus === 'draft' ? '草稿未定稿' : '预估·未申报') }} + + +

处罚明细

@@ -406,6 +482,10 @@
本月下半月工时 {{ fmt(monthStats.h2) }}h{{ money(monthStats.h2 * settings.rate) }}
本月奖惩结算{{ signed(monthStats.adjustNet) }}
+
+ 奖惩口径 + {{ monthStats.finalized ? '已定稿' : '预估(未申报)' }} +

处罚已超过下半月工时收入,实发按 ¥0 计,差额可能顺延

@@ -591,6 +671,19 @@
+ +
+
+

+ 今日到账 + 奖惩待定稿 +

+

{{ selectedPayInfo.desc }}

+
+ {{ money(selectedPayInfo.amount) }} +
+
+
+ + +
+

+ 日常打卡累计(只读) +

+
+
查获奖励({{ adjustStats.foundCount }}件){{ adjustStats.foundBonus > 0 ? '+' + money(adjustStats.foundBonus) : '¥0' }}
+
漏查处罚({{ adjustStats.missedCount }}件){{ adjustStats.missedPenalty > 0 ? '-' + money(adjustStats.missedPenalty) : '¥0' }}
+
其他扣款{{ adjustStats.otherPenalties > 0 ? '-' + money(adjustStats.otherPenalties) : '¥0' }}
+
+ 累计小计 + {{ signed(adjustStats.rawReview) }} +
+
+

满勤奖由出勤天数客观判定,不在核对范围内(当前 {{ adjustStats.workDays }}/{{ settings.fullDays }} 天,{{ adjustStats.fullBonus > 0 ? '+' + money(adjustStats.fullBonus) : '未达标' }})。

+
+ + +
+

+ 审核终稿金额 +

+
+ ¥ + + +
+

奖多于罚填正数,罚多于奖填负数。例:累计 {{ signed(adjustStats.rawReview) }},审核只认 80 就填 80。

+ +
+ 与累计差额 + {{ adjustDelta > 0 ? '上调 +' : '下调 -' }}{{ money(Math.abs(adjustDelta)) }} +
+ + +
+ + +
+
+ 定稿后奖惩净额(含满勤奖) + {{ signed((Number(adjustDraft.finalAmount) || 0) + adjustStats.fullBonus) }} +
+

该金额将并入 {{ adjustTarget.month === 11 ? adjustTarget.year + 1 : adjustTarget.year }}年{{ adjustTarget.month === 11 ? 1 : adjustTarget.month + 2 }}月{{ settings.payDayB }}号发放。

+
+ +
+ + 已于 {{ (adjustStats.finalizedAt || '').slice(0, 16).replace('T', ' ') }} 定稿 +
+ +
+ + + +
+

存草稿不改变发放口径;定稿后按终稿金额计算。日常打卡记录不受影响。

+
+
+ +
@@ -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,