|
@@ -0,0 +1,683 @@
|
|
|
|
|
+function render() {
|
|
|
|
|
+ // prd 月度工时填报页面 — JSX 渲染层
|
|
|
|
|
+ // 版本:v2.0 | 日期:2026-03-05
|
|
|
|
|
+
|
|
|
|
|
+ if (!window._this || !window._this.state || !window._this.state.selectedYear) {
|
|
|
|
|
+ var bootText = (window._this && window._this.getTexts) ? window._this.getTexts().loading : 'loading...';
|
|
|
|
|
+ return <div style={{ padding: 40, textAlign: 'center', color: '#888' }}>{bootText}</div>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const _this = window._this;
|
|
|
|
|
+ const T = _this.getTexts();
|
|
|
|
|
+ const {
|
|
|
|
|
+ selectedYear, selectedMonth,
|
|
|
|
|
+ projectRows = [],
|
|
|
|
|
+ billableData = {}, nonBillableData = {}, otherData = {},
|
|
|
|
|
+ hourDetailMap = { billable: {}, nonBillable: {}, other: {} },
|
|
|
|
|
+ cellStatusMap = {},
|
|
|
|
|
+ lockedDays = {},
|
|
|
|
|
+ userInfo = {},
|
|
|
|
|
+ userId = '',
|
|
|
|
|
+ isExternalUser = false,
|
|
|
|
|
+ isViewMode = false,
|
|
|
|
|
+ isExternalStaff = false,
|
|
|
|
|
+ personnelTypeVerified = false,
|
|
|
|
|
+ isRestrictedExternalProjectMonth = false,
|
|
|
|
|
+ isMobile = false,
|
|
|
|
|
+ holidayMap = {},
|
|
|
|
|
+ inputDefaultVersion = 0,
|
|
|
|
|
+ } = _this.state;
|
|
|
|
|
+ const normalTimecardBlocked = !personnelTypeVerified || isExternalStaff;
|
|
|
|
|
+ const normalBlockMessage = !personnelTypeVerified ? T.personnelTypeCheckFailed
|
|
|
|
|
+ : isExternalStaff ? T.externalStaffTimecardBlocked
|
|
|
|
|
+ : isRestrictedExternalProjectMonth ? T.restrictedExternalProjectMonth : '';
|
|
|
|
|
+
|
|
|
|
|
+ // prd 其他工时分类(与 JS 常量保持一致)
|
|
|
|
|
+ const OTHER_CATEGORIES = [
|
|
|
|
|
+ { name: 'Unassigned' }, { name: 'Annual Leave' }, { name: 'Sick Leave' },
|
|
|
|
|
+ { name: 'Personal Leave' }, { name: 'Time off in lieu of overtime' },
|
|
|
|
|
+ { name: 'Marketing' }, { name: 'Business Development' }, { name: 'Training' },
|
|
|
|
|
+ { name: 'Recruiting' }, { name: 'Internal Project' },
|
|
|
|
|
+ { name: 'Administrative Activities' }, { name: 'Travel non billable' }, { name: 'Others' },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // ── 用户信息 ─────────────────────────────────────────────
|
|
|
|
|
+ const loginUser = window.loginUser || {};
|
|
|
|
|
+ // prd URL 指定成员时只显示该成员信息,档案缺失也不回退到当前登录人。
|
|
|
|
|
+ const userName = userInfo.userName || (isExternalUser ? (userInfo.userId || userId || '—') : (loginUser.userName || '—'));
|
|
|
|
|
+ const deptName = userInfo.deptName || (isExternalUser ? '—' : (loginUser.deptName || '—'));
|
|
|
|
|
+ const workNo = userInfo.workNo || (isExternalUser ? '—' : (loginUser.businessWorkNo || '—'));
|
|
|
|
|
+
|
|
|
|
|
+ const MONTH_NAMES = T.monthNames;
|
|
|
|
|
+ const WD = T.weekdays;
|
|
|
|
|
+
|
|
|
|
|
+ // ── 日期与周分组 ─────────────────────────────────────────
|
|
|
|
|
+ const daysInMonth = new Date(selectedYear, selectedMonth, 0).getDate();
|
|
|
|
|
+ const allDays = [];
|
|
|
|
|
+ for (let d = 1; d <= daysInMonth; d++) {
|
|
|
|
|
+ const dt = new Date(selectedYear, selectedMonth - 1, d);
|
|
|
|
|
+ const dow = dt.getDay();
|
|
|
|
|
+ // prd 综合节假日判断:调休→休息日,加班→工作日,未维护→原逻辑
|
|
|
|
|
+ const hType = holidayMap[d];
|
|
|
|
|
+ const isNaturalWe = dow === 0 || dow === 6;
|
|
|
|
|
+ const isDayOff = hType === '调休' ? true : hType === '加班' ? false : isNaturalWe;
|
|
|
|
|
+ allDays.push({ day: d, dow, wl: WD[dow], isWe: isDayOff, holidayType: hType || null });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const weeks = [];
|
|
|
|
|
+ let cw = [];
|
|
|
|
|
+ allDays.forEach((d) => { cw.push(d); if (d.dow === 0) { weeks.push(cw); cw = []; } });
|
|
|
|
|
+ if (cw.length > 0) weeks.push(cw);
|
|
|
|
|
+
|
|
|
|
|
+ // ── 布局常量 ─────────────────────────────────────────────
|
|
|
|
|
+ // prd 移动端缩窄首列与日期列,让一屏可视面积更合理;输入区域同步加高(详见 inpS)
|
|
|
|
|
+ const FW = isMobile ? 128 : 220;
|
|
|
|
|
+ const DW = isMobile ? 42 : 44;
|
|
|
|
|
+ const HW = isMobile ? 46 : 52;
|
|
|
|
|
+ const totalDataCols = weeks.reduce((s, w) => s + w.length + 1, 0) + 1;
|
|
|
|
|
+
|
|
|
|
|
+ // ── 工具函数 ─────────────────────────────────────────────
|
|
|
|
|
+ const fmt = (n) => (n > 0 ? n : '');
|
|
|
|
|
+ const gv = (data, ri, day) => {
|
|
|
|
|
+ const v = data[ri] && data[ri][day];
|
|
|
|
|
+ return v !== undefined ? v : '';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // prd 单元格编辑状态:section_rowIndex_day
|
|
|
|
|
+ // prd 周末可编辑(加班工时),不再返回 'weekend' 禁用
|
|
|
|
|
+ const editSt = (section, ri, day, isWe) => {
|
|
|
|
|
+ if (lockedDays[day]) return lockedDays[day];
|
|
|
|
|
+ // prd 入职当日可填,入职日期之前的所有工时区域禁止填写
|
|
|
|
|
+ if (_this.isBeforeEmploymentDate(selectedYear, selectedMonth, day)) return 'preEmployment';
|
|
|
|
|
+ // prd 指定项目的外部成员,正常 TimeCard 整月不可填写
|
|
|
|
|
+ if (normalTimecardBlocked || isRestrictedExternalProjectMonth) return 'disabled';
|
|
|
|
|
+ // prd 外部人员:非项目工时和其他工时不可编辑
|
|
|
|
|
+ if (isExternalStaff && (section === 'nonBillable' || section === 'other')) return 'disabled';
|
|
|
|
|
+ // prd 成员下线:billable/nonBillable 行 day > offlineDay 的单元格返回 offline(灰底 + 不可编辑)
|
|
|
|
|
+ if (section === 'billable' || section === 'nonBillable') {
|
|
|
|
|
+ const proj = projectRows[ri] || {};
|
|
|
|
|
+ const key = section + '_' + ri + '_' + day;
|
|
|
|
|
+ const st = cellStatusMap[key];
|
|
|
|
|
+ if (st === 'approved') return 'approved';
|
|
|
|
|
+ if (st === 'submitted') return 'submitted';
|
|
|
|
|
+ if (proj.lockedAllDays || proj.isHistoryOnly) return 'offline';
|
|
|
|
|
+ if (proj.offlineDay != null && day > proj.offlineDay) return 'offline';
|
|
|
|
|
+ }
|
|
|
|
|
+ const key = section + '_' + ri + '_' + day;
|
|
|
|
|
+ const st = cellStatusMap[key];
|
|
|
|
|
+ if (st === 'approved') return 'approved';
|
|
|
|
|
+ if (st === 'submitted') return 'submitted';
|
|
|
|
|
+ return 'editable';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const cellBg = (es) => ({
|
|
|
|
|
+ approved: '#fffbe6', submitted: '#e6f7ff', disabled: '#f0f0f0',
|
|
|
|
|
+ offline: '#f0f0f0', preEmployment: '#f0f0f0',
|
|
|
|
|
+ }[es] || '#fff');
|
|
|
|
|
+
|
|
|
|
|
+ // ── 计算 ─────────────────────────────────────────────────
|
|
|
|
|
+ // fixme 所有取值统一用 safeNum 兼容 null/undefined/NaN
|
|
|
|
|
+ const safeNum = (v) => { const n = parseFloat(v); return isNaN(n) ? 0 : n; };
|
|
|
|
|
+ const rowSum = (data, ri) => {
|
|
|
|
|
+ const r = data[ri] || {};
|
|
|
|
|
+ return Object.keys(r).reduce((s, d) => s + safeNum(r[d]), 0);
|
|
|
|
|
+ };
|
|
|
|
|
+ const rowWkSum = (data, ri, wk) => {
|
|
|
|
|
+ const r = data[ri] || {};
|
|
|
|
|
+ return wk.reduce((s, d) => s + safeNum(r[d.day]), 0);
|
|
|
|
|
+ };
|
|
|
|
|
+ const secDay = (data, day) =>
|
|
|
|
|
+ Object.keys(data).reduce((s, r) => s + safeNum(data[r] && data[r][day]), 0);
|
|
|
|
|
+ const secWk = (data, wk) => wk.reduce((s, d) => s + secDay(data, d.day), 0);
|
|
|
|
|
+ const secMonth = (data) =>
|
|
|
|
|
+ Object.keys(data).reduce((s, r) => {
|
|
|
|
|
+ const row = data[r] || {};
|
|
|
|
|
+ return s + Object.keys(row).reduce((rs, d) => rs + safeNum(row[d]), 0);
|
|
|
|
|
+ }, 0);
|
|
|
|
|
+ const dayGrand = (day) => secDay(billableData, day) + secDay(nonBillableData, day) + secDay(otherData, day);
|
|
|
|
|
+ const wkGrand = (wk) => wk.reduce((s, d) => s + dayGrand(d.day), 0);
|
|
|
|
|
+ const detailFor = (section, ri, day) => {
|
|
|
|
|
+ const rows = hourDetailMap[section] || {};
|
|
|
|
|
+ return (rows[ri] && rows[ri][day]) || { approved: 0, change: 0 };
|
|
|
|
|
+ };
|
|
|
|
|
+ const selectDisplayBase = (approved, submitted, draft, change) => {
|
|
|
|
|
+ // prd 与动作层一致,审批/变更存在时基数只取已审批,不混入暂存或已提交。
|
|
|
|
|
+ if (approved !== 0 || change !== 0) return approved;
|
|
|
|
|
+ return approved + change > 0 ? approved : (submitted > 0 ? submitted : draft);
|
|
|
|
|
+ };
|
|
|
|
|
+ const selectDisplayBaseStatus = (approved, submitted, draft, change) => {
|
|
|
|
|
+ if (approved !== 0 || change !== 0) return 'approved';
|
|
|
|
|
+ return approved + change > 0 ? 'approved' : (submitted > 0 ? 'submitted' : (draft > 0 ? 'draft' : ''));
|
|
|
|
|
+ };
|
|
|
|
|
+ const detailSum = (section, ri, days) => {
|
|
|
|
|
+ const rows = hourDetailMap[section] || {};
|
|
|
|
|
+ const rowIndexes = ri === undefined ? Object.keys(rows) : [String(ri)];
|
|
|
|
|
+ return rowIndexes.reduce((result, rowIndex) => {
|
|
|
|
|
+ const row = rows[rowIndex] || {};
|
|
|
|
|
+ days.forEach((day) => {
|
|
|
|
|
+ const detail = row[day.day || day] || {};
|
|
|
|
|
+ const approved = safeNum(detail.approved);
|
|
|
|
|
+ const submitted = safeNum(detail.submitted);
|
|
|
|
|
+ const draft = safeNum(detail.draft);
|
|
|
|
|
+ const change = safeNum(detail.change);
|
|
|
|
|
+ const base = selectDisplayBase(approved, submitted, draft, change);
|
|
|
|
|
+ const baseStatus = selectDisplayBaseStatus(approved, submitted, draft, change);
|
|
|
|
|
+ result.summary += base;
|
|
|
|
|
+ result.change += change;
|
|
|
|
|
+ if (baseStatus === 'approved' || (baseStatus === 'submitted' && result.baseStatus !== 'approved')
|
|
|
|
|
+ || (baseStatus === 'draft' && !result.baseStatus)) {
|
|
|
|
|
+ result.baseStatus = baseStatus;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, { summary: 0, change: 0, baseStatus: '' });
|
|
|
|
|
+ };
|
|
|
|
|
+ const allDetailSum = (days) => ['billable', 'nonBillable', 'other'].reduce((result, section) => {
|
|
|
|
|
+ const sectionTotal = detailSum(section, undefined, days);
|
|
|
|
|
+ result.summary += sectionTotal.summary;
|
|
|
|
|
+ result.change += sectionTotal.change;
|
|
|
|
|
+ if (sectionTotal.baseStatus === 'approved' || (sectionTotal.baseStatus === 'submitted' && result.baseStatus !== 'approved')
|
|
|
|
|
+ || (sectionTotal.baseStatus === 'draft' && !result.baseStatus)) {
|
|
|
|
|
+ result.baseStatus = sectionTotal.baseStatus;
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }, { summary: 0, change: 0, baseStatus: '' });
|
|
|
|
|
+ const monthDays = allDays.map((d) => d.day);
|
|
|
|
|
+ const monthDetailTotal = allDetailSum(monthDays);
|
|
|
|
|
+ const monthGrandTotal = monthDetailTotal.summary + monthDetailTotal.change;
|
|
|
|
|
+ const formatHours = (n) => {
|
|
|
|
|
+ const value = safeNum(n);
|
|
|
|
|
+ return value === 0 ? '0' : String(Number(value.toFixed(2)));
|
|
|
|
|
+ };
|
|
|
|
|
+ const formatPair = (base, change) => {
|
|
|
|
|
+ const baseText = formatHours(base);
|
|
|
|
|
+ const changeText = formatHours(change);
|
|
|
|
|
+ return change !== 0 ? baseText + '/' + changeText : baseText;
|
|
|
|
|
+ };
|
|
|
|
|
+ const formatStoredPair = (detail) => {
|
|
|
|
|
+ const approved = safeNum(detail && detail.approved);
|
|
|
|
|
+ const submitted = safeNum(detail && detail.submitted);
|
|
|
|
|
+ const draft = safeNum(detail && detail.draft);
|
|
|
|
|
+ const change = safeNum(detail && detail.change);
|
|
|
|
|
+ const base = selectDisplayBase(approved, submitted, draft, change);
|
|
|
|
|
+ return base !== 0 || change !== 0 ? formatPair(base, change) : '';
|
|
|
|
|
+ };
|
|
|
|
|
+ const formatAggregate = (fallback, detail) => {
|
|
|
|
|
+ if (detail.summary !== 0 || detail.change !== 0) return formatPair(detail.summary, detail.change);
|
|
|
|
|
+ return fmt(fallback);
|
|
|
|
|
+ };
|
|
|
|
|
+ // prd 含「工时/变更」的值略缩小字号,避免拆分后在窄列中显得拥挤
|
|
|
|
|
+ const renderHourText = (value, fontSize, baseStatus) => {
|
|
|
|
|
+ const text = value === undefined || value === null ? '' : String(value);
|
|
|
|
|
+ const pairSize = text.indexOf('/') >= 0 ? Math.max(8, fontSize - 1) : fontSize;
|
|
|
|
|
+ if (text.indexOf('/') < 0) return <span style={{ fontSize: pairSize }}>{text}</span>;
|
|
|
|
|
+ const parts = text.split('/');
|
|
|
|
|
+ const baseColor = baseStatus === 'draft' ? '#1a1a1a' : (baseStatus === 'submitted' ? '#1677ff' : '');
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span style={{ fontSize: pairSize }}>
|
|
|
|
|
+ <span style={baseColor ? { color: baseColor } : {}}>{parts[0]}</span>
|
|
|
|
|
+ /
|
|
|
|
|
+ <span>{parts.slice(1).join('/')}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+ const cellDisplay = (section, ri, day, value, status) => {
|
|
|
|
|
+ if (status === 'approved') return formatStoredPair(detailFor(section, ri, day));
|
|
|
|
|
+ return fmt(value);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ── 样式 ─────────────────────────────────────────────────
|
|
|
|
|
+ const btnS = (p, disabled) => ({
|
|
|
|
|
+ display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
|
|
|
+ padding: '6px 16px', borderRadius: 6, fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
|
|
|
|
|
+ border: p ? 'none' : '1px solid #d9d9d9',
|
|
|
|
|
+ background: disabled ? '#f5f5f5' : (p ? '#d4380d' : '#fff'),
|
|
|
|
|
+ color: disabled ? '#bfbfbf' : (p ? '#fff' : '#333'),
|
|
|
|
|
+ fontWeight: p ? 600 : 400,
|
|
|
|
|
+ opacity: disabled ? 0.72 : 1,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // prd 全局样式:
|
|
|
|
|
+ // 1. 隐藏 number input 上下箭头(避免移动端误触)
|
|
|
|
|
+ // 2. 滚动容器开启 iOS 惯性滚动 + 双向 pan
|
|
|
|
|
+ // 3. 输入框统一 touch-action: manipulation,去掉 300ms 点击延迟
|
|
|
|
|
+ // 4. 输入框保持与桌面端一致的字号和高度,避免移动端显示被放大
|
|
|
|
|
+ const hideSpinnerCSS =
|
|
|
|
|
+ 'input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}' +
|
|
|
|
|
+ 'input[type=number]{-moz-appearance:textfield}' +
|
|
|
|
|
+ '.tc-scroll{-webkit-overflow-scrolling:touch;overflow-x:auto;overflow-y:visible;touch-action:pan-x pan-y;}' +
|
|
|
|
|
+ '.tc-input{touch-action:manipulation;-webkit-tap-highlight-color:transparent;}' +
|
|
|
|
|
+ '.tc-input:focus{border-color:#1677ff !important;}' +
|
|
|
|
|
+ '@media (max-width:768px){.tc-header-bar{flex-wrap:wrap;gap:8px;padding:12px 14px !important;}.tc-header-actions button{padding:8px 14px !important;font-size:14px !important;}.tc-info-grid{grid-template-columns:repeat(2,1fr) !important;gap:10px 16px !important;padding:12px 14px !important;}.tc-rules-bar{padding:10px 14px !important;}.tc-rules-status{flex-wrap:wrap;gap:8px !important;}}';
|
|
|
|
|
+
|
|
|
|
|
+ const inpS = (es) => ({
|
|
|
|
|
+ // prd 输入框统一保持 36×24,移动端不放大字体
|
|
|
|
|
+ width: 36, height: 24, textAlign: 'center',
|
|
|
|
|
+ border: '1px solid ' + (es === 'editable' ? '#d9d9d9' : 'transparent'),
|
|
|
|
|
+ borderRadius: 3, fontSize: 12, outline: 'none',
|
|
|
|
|
+ background: cellBg(es), color: es === 'editable' ? '#1a1a1a' : '#999',
|
|
|
|
|
+ cursor: es === 'editable' ? 'text' : 'not-allowed', padding: 0,
|
|
|
|
|
+ MozAppearance: 'textfield',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const fixTd = (bg) => ({
|
|
|
|
|
+ padding: '5px 8px', borderBottom: '1px solid #f0f0f0', borderRight: '1px solid #e8e8e8',
|
|
|
|
|
+ verticalAlign: 'middle', position: 'sticky', left: 0, zIndex: 2,
|
|
|
|
|
+ background: bg || '#fff', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
|
|
|
+ whiteSpace: 'nowrap', width: FW, minWidth: FW, fontSize: 12,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const dayTd = (isWe, err, offline, status) => ({
|
|
|
|
|
+ padding: 1, textAlign: 'center', borderBottom: '1px solid #f0f0f0',
|
|
|
|
|
+ borderRight: '1px solid #f5f5f5', minWidth: DW,
|
|
|
|
|
+ background: offline ? '#f0f0f0' : (status && status !== 'editable'
|
|
|
|
|
+ ? cellBg(status)
|
|
|
|
|
+ : (isWe ? '#f0f0f0' : (err ? '#fff2f0' : '#fff'))),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const hrsTd = (bg) => ({
|
|
|
|
|
+ padding: '4px 2px', textAlign: 'center', fontWeight: 700, fontSize: 12,
|
|
|
|
|
+ borderBottom: '1px solid #e8e8e8', borderRight: '2px solid #d9d9d9',
|
|
|
|
|
+ background: bg || '#f0f5ff', color: '#2f54eb', minWidth: HW,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ── Wk分组行 + 日期列头行 ────────────────────────────────
|
|
|
|
|
+ // fixme 宜搭环境无 React.Fragment,改用数组返回多行
|
|
|
|
|
+ const renderWkAndDayHeaders = () => [
|
|
|
|
|
+ <tr key="wk_row">
|
|
|
|
|
+ <td style={{
|
|
|
|
|
+ padding: '4px 8px', fontSize: 11, fontWeight: 600, color: '#595959',
|
|
|
|
|
+ background: '#fff', borderBottom: '1px solid #d9d9d9', borderRight: '1px solid #e8e8e8',
|
|
|
|
|
+ position: 'sticky', left: 0, zIndex: 3, width: FW, minWidth: FW,
|
|
|
|
|
+ }}> </td>
|
|
|
|
|
+ {weeks.map((wk, wi) => (
|
|
|
|
|
+ <td key={'wk_' + wi} colSpan={wk.length + 1} style={{
|
|
|
|
|
+ padding: '3px 0', textAlign: 'center', fontSize: 11, fontWeight: 700,
|
|
|
|
|
+ background: '#fff', color: '#2B5173',
|
|
|
|
|
+ borderBottom: '1px solid #d9d9d9', borderRight: '2px solid #e8e8e8',
|
|
|
|
|
+ }}>Wk{wi + 1}</td>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <td style={{
|
|
|
|
|
+ padding: '3px 2px', textAlign: 'center', fontSize: 10, fontWeight: 700,
|
|
|
|
|
+ background: '#fff', color: '#2B5173', borderBottom: '1px solid #d9d9d9',
|
|
|
|
|
+ }}>Hours</td>
|
|
|
|
|
+ </tr>,
|
|
|
|
|
+ <tr key="day_row">
|
|
|
|
|
+ <td style={{
|
|
|
|
|
+ padding: '4px 8px', fontSize: 11, fontWeight: 600, color: '#595959',
|
|
|
|
|
+ background: '#f5f5f5', borderBottom: '2px solid #e8e8e8', borderRight: '1px solid #e8e8e8',
|
|
|
|
|
+ position: 'sticky', left: 0, zIndex: 3, width: FW, minWidth: FW,
|
|
|
|
|
+ }}>{T.colProjectCategory}</td>
|
|
|
|
|
+ {weeks.map((wk, wi) => {
|
|
|
|
|
+ const cells = wk.map((d) => {
|
|
|
|
|
+ // prd 加班日(补班)用蓝色主题,调休和原周末用红色主题
|
|
|
|
|
+ const isWork = d.holidayType === '加班';
|
|
|
|
|
+ const bgColor = isWork ? '#e6f0ff' : (d.isWe ? '#fff1f0' : '#fafafa');
|
|
|
|
|
+ const txColor = isWork ? '#3389FF' : (d.isWe ? '#cf1322' : '#595959');
|
|
|
|
|
+ return (
|
|
|
|
|
+ <td key={d.day} style={{
|
|
|
|
|
+ padding: '2px 1px', textAlign: 'center', fontSize: 10, fontWeight: 600,
|
|
|
|
|
+ background: bgColor, color: txColor,
|
|
|
|
|
+ borderBottom: '2px solid #e8e8e8', borderRight: '1px solid #f0f0f0', minWidth: DW,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.1 }}>{d.day}</div>
|
|
|
|
|
+ <div style={{ fontSize: 9, lineHeight: 1.1 }}>{d.wl}</div>
|
|
|
|
|
+ {d.isWe && !d.holidayType && <div style={{ fontSize: 8, color: '#cf1322', lineHeight: 1 }}>{T.dayOff}</div>}
|
|
|
|
|
+ {d.holidayType === '调休' && <div style={{ fontSize: 8, color: '#cf1322', lineHeight: 1 }}>{T.dayOff}</div>}
|
|
|
|
|
+ {d.holidayType === '加班' && <div style={{ fontSize: 8, color: '#3389FF', lineHeight: 1 }}>{T.dayOT}</div>}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ cells.push(
|
|
|
|
|
+ <td key={'h_' + wi} style={{
|
|
|
|
|
+ padding: '2px 2px', textAlign: 'center', fontSize: 10, fontWeight: 700,
|
|
|
|
|
+ background: '#e8ecf8', color: '#2f54eb',
|
|
|
|
|
+ borderBottom: '2px solid #e8e8e8', borderRight: '2px solid #d9d9d9', minWidth: HW,
|
|
|
|
|
+ }}>Hours</td>
|
|
|
|
|
+ );
|
|
|
|
|
+ return cells;
|
|
|
|
|
+ })}
|
|
|
|
|
+ <td style={{
|
|
|
|
|
+ padding: '2px 2px', textAlign: 'center', fontSize: 9, fontWeight: 700,
|
|
|
|
|
+ background: '#e8ecf8', color: '#1d39c4', borderBottom: '2px solid #e8e8e8',
|
|
|
|
|
+ }}>Subtotal</td>
|
|
|
|
|
+ </tr>,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // ── 数据行渲染 ──────────────────────────────────────────
|
|
|
|
|
+ const renderDataRow = (key, section, label, data, ri, bg) => (
|
|
|
|
|
+ <tr key={key} style={{ background: bg || '#fff' }}>
|
|
|
|
|
+ <td style={fixTd(bg)}>{label}</td>
|
|
|
|
|
+ {weeks.map((wk, wi) => {
|
|
|
|
|
+ const cells = wk.map(({ day, isWe }) => {
|
|
|
|
|
+ const es = editSt(section, ri, day, isWe);
|
|
|
|
|
+ const dis = es !== 'editable';
|
|
|
|
|
+ const val = gv(data, ri, day);
|
|
|
|
|
+ const displayDetail = detailFor(section, ri, day);
|
|
|
|
|
+ const displayValue = cellDisplay(section, ri, day, val, es);
|
|
|
|
|
+ // prd 仅可编辑工作日校验不足8h标红,禁用日保持灰色状态
|
|
|
|
|
+ const err = !isExternalStaff && !isWe && es === 'editable'
|
|
|
|
|
+ && dayGrand(day) > 0 && dayGrand(day) < 8 && val > 0;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <td key={day} style={dayTd(isWe, err, es === 'offline' || es === 'preEmployment', es)}>
|
|
|
|
|
+ {es === 'approved' ? (
|
|
|
|
|
+ <span style={{ display: 'inline-block', minWidth: isMobile ? 38 : 36, color: '#ad6800', fontWeight: 700 }}>
|
|
|
|
|
+ {renderHourText(displayValue, 12, selectDisplayBaseStatus(
|
|
|
|
|
+ safeNum(displayDetail.approved), safeNum(displayDetail.submitted),
|
|
|
|
|
+ safeNum(displayDetail.draft), safeNum(displayDetail.change)
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <input key={selectedYear + '_' + selectedMonth + '_' + inputDefaultVersion + '_' + section + '_' + ri + '_' + day}
|
|
|
|
|
+ className="tc-input"
|
|
|
|
|
+ type="number" min="0" max="24" step="0.5"
|
|
|
|
|
+ /* prd 移动端弹出数字键盘:inputMode=decimal(带小数点)+ pattern 兜底老安卓 */
|
|
|
|
|
+ inputMode="decimal"
|
|
|
|
|
+ pattern="[0-9.]*"
|
|
|
|
|
+ disabled={dis} defaultValue={val}
|
|
|
|
|
+ title={es === 'preEmployment' ? T.preEmploymentLocked : ''}
|
|
|
|
|
+ /* ppExt 桌面 onBlur 即可;移动端键盘"完成"按钮也会触发 blur;额外 onChange 兜底输入法不触发 blur 的场景 */
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ if (!dis) {
|
|
|
|
|
+ _this.updateCellValue(section, ri, day, e.target.value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onBlur={(e) => {
|
|
|
|
|
+ if (!dis) {
|
|
|
|
|
+ _this.updateCellValue(section, ri, day, e.target.value);
|
|
|
|
|
+ _this.$('JSX_timecard').forceUpdate();
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={inpS(es)} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ const weekDetail = detailSum(section, ri, wk.map((item) => item.day));
|
|
|
|
|
+ cells.push(<td key={'wh_' + wi} style={hrsTd()}>{renderHourText(formatAggregate(
|
|
|
|
|
+ rowWkSum(data, ri, wk), weekDetail
|
|
|
|
|
+ ), 12)}</td>);
|
|
|
|
|
+ return cells;
|
|
|
|
|
+ })}
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const monthDetail = detailSum(section, ri, monthDays);
|
|
|
|
|
+ return <td style={{ ...hrsTd('#e8ecf8'), fontSize: 13, color: '#1d39c4' }}>{renderHourText(formatAggregate(
|
|
|
|
|
+ rowSum(data, ri), monthDetail
|
|
|
|
|
+ ), 13)}</td>;
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // ── 小计行渲染 ──────────────────────────────────────────
|
|
|
|
|
+ const renderSubRow = (label, data, bg, lc, section) => {
|
|
|
|
|
+ const mt = secMonth(data);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <tr style={{ background: bg }}>
|
|
|
|
|
+ <td style={{ ...fixTd(bg), fontWeight: 700, fontSize: 12, color: lc }}>{label}</td>
|
|
|
|
|
+ {weeks.map((wk, wi) => {
|
|
|
|
|
+ const cells = wk.map(({ day, isWe }) => {
|
|
|
|
|
+ const n = secDay(data, day);
|
|
|
|
|
+ const dayDetail = detailSum(section, undefined, [day]);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <td key={day} style={{
|
|
|
|
|
+ padding: '4px 2px', textAlign: 'center', fontWeight: 700, fontSize: 12,
|
|
|
|
|
+ color: n > 0 ? lc : '#ccc', background: isWe ? '#f0f0f0' : bg,
|
|
|
|
|
+ borderBottom: '1px solid #e8e8e8', borderRight: '1px solid #f0f0f0',
|
|
|
|
|
+ }}>{renderHourText(formatAggregate(n, dayDetail), 12)}</td>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ const ws = secWk(data, wk);
|
|
|
|
|
+ const weekDetail = detailSum(section, undefined, wk.map((item) => item.day));
|
|
|
|
|
+ cells.push(<td key={'ws_' + wi} style={{ ...hrsTd(bg), color: ws > 0 ? lc : '#ccc' }}>{renderHourText(formatAggregate(
|
|
|
|
|
+ ws, weekDetail
|
|
|
|
|
+ ), 12)}</td>);
|
|
|
|
|
+ return cells;
|
|
|
|
|
+ })}
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const monthDetail = detailSum(section, undefined, monthDays);
|
|
|
|
|
+ return <td style={{ ...hrsTd(bg), color: mt > 0 ? lc : '#ccc', fontSize: 13 }}>{renderHourText(formatAggregate(
|
|
|
|
|
+ mt, monthDetail
|
|
|
|
|
+ ), 13)}</td>;
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ── 区域标题行 ──────────────────────────────────────────
|
|
|
|
|
+ const renderSecHdr = (label, bgColor) => (
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ {/* fixme colSpan 全宽 td 上的 sticky 无效(包含块=整行,无横向移动空间),标题文字须包内层 sticky div 才能左右滚动时锁定 */}
|
|
|
|
|
+ <td colSpan={1 + totalDataCols} style={{
|
|
|
|
|
+ padding: 0, fontWeight: 700, fontSize: 12,
|
|
|
|
|
+ background: bgColor, color: '#fff',
|
|
|
|
|
+ borderBottom: '1px solid ' + bgColor, letterSpacing: 0.5,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ position: 'sticky', left: 0, display: 'inline-block', padding: '6px 14px' }}>{label}</div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // ══════════════════════════════════════════════════════════
|
|
|
|
|
+ // 主渲染
|
|
|
|
|
+ // ══════════════════════════════════════════════════════════
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif',
|
|
|
|
|
+ fontSize: 13, background: '#f0f2f5', minHeight: '100vh',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <style dangerouslySetInnerHTML={{ __html: hideSpinnerCSS }} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* ══ 顶部:标题 + 按钮 ══ */}
|
|
|
|
|
+ <div style={{ background: '#fff', boxShadow: '0 1px 6px rgba(0,0,0,0.08)', marginBottom: 8 }}>
|
|
|
|
|
+ <div className="tc-header-bar" style={{
|
|
|
|
|
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
|
|
|
+ padding: '18px 24px', borderBottom: '1px solid #f0f0f0',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
|
|
|
+ <h1 style={{ fontSize: isMobile ? 17 : 22, fontWeight: 700, margin: 0, letterSpacing: -0.5 }}>
|
|
|
|
|
+ Timecard – {selectedYear} {MONTH_NAMES[selectedMonth - 1]}
|
|
|
|
|
+ </h1>
|
|
|
|
|
+ <button style={btnS(false, false)} onClick={() => _this.prevMonth()}>◀</button>
|
|
|
|
|
+ <button style={btnS(false, false)} onClick={() => _this.nextMonth()}>▶</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/* prd URL同时含userId和month参数时为查看模式,隐藏操作按钮 */}
|
|
|
|
|
+ {!isViewMode && (
|
|
|
|
|
+ <div className="tc-header-actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const actionDisabled = _this.state._checkingMonthClosed
|
|
|
|
|
+ || _this.state.isMonthClosed
|
|
|
|
|
+ || normalTimecardBlocked
|
|
|
|
|
+ || isRestrictedExternalProjectMonth
|
|
|
|
|
+ || _this.state._savingDraft
|
|
|
|
|
+ || _this.state._submitting;
|
|
|
|
|
+ return [
|
|
|
|
|
+ <button key="save" style={btnS(false, actionDisabled)} disabled={actionDisabled} onClick={() => _this.saveDraft()}>💾 {T.btnSaveDraft}</button>,
|
|
|
|
|
+ <button key="submit" style={btnS(true, actionDisabled)} disabled={actionDisabled} onClick={() => _this.submitForApproval()}>📤 {T.btnSubmit}</button>,
|
|
|
|
|
+ ];
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {normalBlockMessage && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ padding: '10px 24px', background: '#fff2f0', color: '#a8071a',
|
|
|
|
|
+ borderBottom: '1px solid #ffccc7', fontSize: 13, fontWeight: 600, lineHeight: 1.6,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {normalBlockMessage}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 员工信息 — prd 移动端 4列 → 2列(CSS @media 覆盖) */}
|
|
|
|
|
+ <div className="tc-info-grid" style={{
|
|
|
|
|
+ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)',
|
|
|
|
|
+ gap: '0 24px', padding: '16px 24px',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>{T.empName}</div>
|
|
|
|
|
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{userName}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>{T.empWorkNo}</div>
|
|
|
|
|
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{workNo}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>{T.empDept}</div>
|
|
|
|
|
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{deptName}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="tc-month-summary-grid" style={{
|
|
|
|
|
+ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
|
|
|
|
+ gap: '0 24px', minWidth: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3, whiteSpace: 'nowrap' }}>{T.monthSummaryTotal}</div>
|
|
|
|
|
+ <div style={{ fontWeight: 700, fontSize: 16, color: '#1677ff' }}>{monthDetailTotal.summary !== 0 ? formatHours(monthDetailTotal.summary) + 'h' : '—'}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3, whiteSpace: 'nowrap' }}>{T.monthChangeTotal}</div>
|
|
|
|
|
+ <div style={{ fontWeight: 700, fontSize: 16, color: '#ad6800' }}>{monthDetailTotal.change !== 0 ? formatHours(monthDetailTotal.change) + 'h' : '—'}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3, whiteSpace: 'nowrap' }}>{T.monthGrandTotal}</div>
|
|
|
|
|
+ <div style={{ fontWeight: 700, fontSize: 16, color: '#262626' }}>{monthGrandTotal !== 0 ? formatHours(monthGrandTotal) + 'h' : '—'}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* ══ 填报规则 + 工时状态 ══ */}
|
|
|
|
|
+ <div className="tc-rules-bar" style={{
|
|
|
|
|
+ background: '#fffbe6', padding: '10px 24px', fontSize: 12, color: '#876800',
|
|
|
|
|
+ borderTop: '1px solid #ffe58f', borderBottom: '1px solid #ffe58f', marginBottom: 12,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ marginBottom: 6 }}>
|
|
|
|
|
+ <strong>{T.rulesTitle}</strong>
|
|
|
|
|
+ {T.ruleWorkday}<strong>8h</strong>
|
|
|
|
|
+ {T.ruleHoliday}<strong>36h</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="tc-rules-status" style={{ display: 'flex', alignItems: 'center', gap: 16, color: '#595959', flexWrap: 'wrap' }}>
|
|
|
|
|
+ <strong style={{ color: '#876800' }}>{T.statusTitle}</strong>
|
|
|
|
|
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
|
|
|
+ <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#fff', border: '1px solid #d9d9d9' }} />
|
|
|
|
|
+ {T.statusEditable}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
|
|
|
+ <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#e6f7ff', border: '1px solid #91d5ff' }} />
|
|
|
|
|
+ {T.statusSubmitted}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
|
|
|
+ <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#fffbe6', border: '1px solid #ffe58f' }} />
|
|
|
|
|
+ {T.statusApproved}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
|
|
|
+ <span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 2, background: '#f0f0f0', border: '1px solid #d9d9d9' }} />
|
|
|
|
|
+ {T.statusReadonly}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* ══ 主表格 — prd 滚动容器加 tc-scroll 启用 iOS 惯性滚动 + touch-action ══ */}
|
|
|
|
|
+ <div className="tc-scroll" style={{
|
|
|
|
|
+ background: '#fff',
|
|
|
|
|
+ boxShadow: '0 1px 6px rgba(0,0,0,0.08)', borderRadius: 8, position: 'relative',
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <table style={{
|
|
|
|
|
+ borderCollapse: 'collapse', tableLayout: 'fixed',
|
|
|
|
|
+ minWidth: FW + weeks.reduce((s, w) => s + w.length * DW + HW, 0) + HW,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <colgroup>
|
|
|
|
|
+ <col style={{ width: FW }} />
|
|
|
|
|
+ {weeks.map((wk, wi) => {
|
|
|
|
|
+ const cols = wk.map((_, di) => <col key={wi + '_' + di} style={{ width: DW }} />);
|
|
|
|
|
+ cols.push(<col key={'wh_' + wi} style={{ width: HW }} />);
|
|
|
|
|
+ return cols;
|
|
|
|
|
+ })}
|
|
|
|
|
+ <col style={{ width: HW }} />
|
|
|
|
|
+ </colgroup>
|
|
|
|
|
+
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {/* ════ 项目工时 Project Hours ════ */}
|
|
|
|
|
+ {renderSecHdr(T.sectionBillable, '#2B5173')}
|
|
|
|
|
+ {renderWkAndDayHeaders()}
|
|
|
|
|
+ {projectRows.length === 0 && (
|
|
|
|
|
+ <tr><td colSpan={1 + totalDataCols} style={{ padding: '8px 20px', color: '#bbb', fontSize: 12, fontStyle: 'italic' }}>{T.noProjectData}</td></tr>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {projectRows.map((row, ri) =>
|
|
|
|
|
+ renderDataRow('b_' + ri, 'billable',
|
|
|
|
|
+ <span>
|
|
|
|
|
+ <span style={{ color: '#8c8c8c', marginRight: 6, fontFamily: 'monospace', fontSize: 11 }}>{row.code || ''}</span>
|
|
|
|
|
+ {row.name || ''}
|
|
|
|
|
+ </span>,
|
|
|
|
|
+ billableData, ri)
|
|
|
|
|
+ )}
|
|
|
|
|
+ {renderSubRow('Sub total (Billable)', billableData, '#e6f7ff', '#1677ff', 'billable')}
|
|
|
|
|
+
|
|
|
|
|
+ {/* ════ 非项目工时 Non-Project Hours ════ */}
|
|
|
|
|
+ {renderSecHdr(T.sectionNonBillable, '#2B5173')}
|
|
|
|
|
+ {renderWkAndDayHeaders()}
|
|
|
|
|
+ {projectRows.length === 0 && (
|
|
|
|
|
+ <tr><td colSpan={1 + totalDataCols} style={{ padding: '8px 20px', color: '#bbb', fontSize: 12, fontStyle: 'italic' }}>{T.noProjectData}</td></tr>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {projectRows.map((row, ri) =>
|
|
|
|
|
+ renderDataRow('nb_' + ri, 'nonBillable',
|
|
|
|
|
+ <span>
|
|
|
|
|
+ <span style={{ color: '#8c8c8c', marginRight: 6, fontFamily: 'monospace', fontSize: 11 }}>{row.code || ''}</span>
|
|
|
|
|
+ {row.name || ''}
|
|
|
|
|
+ </span>,
|
|
|
|
|
+ nonBillableData, ri)
|
|
|
|
|
+ )}
|
|
|
|
|
+ {renderSubRow('Sub total (Non Billable)', nonBillableData, '#fff7e6', '#d46b08', 'nonBillable')}
|
|
|
|
|
+
|
|
|
|
|
+ {/* ════ 其他工时 Other Activities ════ */}
|
|
|
|
|
+ {renderSecHdr(T.sectionOther, '#2B5173')}
|
|
|
|
|
+ {renderWkAndDayHeaders()}
|
|
|
|
|
+ {OTHER_CATEGORIES.map((cat, ri) =>
|
|
|
|
|
+ renderDataRow('o_' + ri, 'other',
|
|
|
|
|
+ <span>{cat.name}</span>,
|
|
|
|
|
+ otherData, ri)
|
|
|
|
|
+ )}
|
|
|
|
|
+ {renderSubRow('Sub total (Other)', otherData, '#f9f0ff', '#531dab', 'other')}
|
|
|
|
|
+
|
|
|
|
|
+ {/* ════ Hours Summary / Change ════ */}
|
|
|
|
|
+ <tr style={{ background: '#141414' }}>
|
|
|
|
|
+ <td style={{
|
|
|
|
|
+ padding: '8px 14px', fontWeight: 700, fontSize: 14, color: '#fff',
|
|
|
|
|
+ position: 'sticky', left: 0, background: '#141414',
|
|
|
|
|
+ borderTop: '2px solid #333', zIndex: 2, width: FW, minWidth: FW,
|
|
|
|
|
+ }}>Hours / Change</td>
|
|
|
|
|
+ {weeks.map((wk, wi) => {
|
|
|
|
|
+ const cells = wk.map(({ day, isWe }) => {
|
|
|
|
|
+ const n = dayGrand(day);
|
|
|
|
|
+ const dayDetail = allDetailSum([day]);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <td key={day} style={{
|
|
|
|
|
+ padding: '7px 1px', textAlign: 'center', fontWeight: 700, fontSize: 12,
|
|
|
|
|
+ color: n > 0 ? '#fff' : '#444',
|
|
|
|
|
+ background: isWe ? '#1a1a1a' : '#141414', borderTop: '2px solid #333', borderRight: '1px solid #333',
|
|
|
|
|
+ }}>{renderHourText(formatAggregate(n, dayDetail), 12)}</td>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ const wt = wkGrand(wk);
|
|
|
|
|
+ const weekDetail = allDetailSum(wk.map((item) => item.day));
|
|
|
|
|
+ cells.push(
|
|
|
|
|
+ <td key={'gw_' + wi} style={{
|
|
|
|
|
+ padding: '7px 2px', textAlign: 'center', fontWeight: 700, fontSize: 13,
|
|
|
|
|
+ color: wt > 0 ? '#ffd666' : '#444',
|
|
|
|
|
+ background: '#1a1a1a', borderTop: '2px solid #333', borderRight: '2px solid #333',
|
|
|
|
|
+ }}>{renderHourText(formatAggregate(wt, weekDetail), 13)}</td>
|
|
|
|
|
+ );
|
|
|
|
|
+ return cells;
|
|
|
|
|
+ })}
|
|
|
|
|
+ <td style={{
|
|
|
|
|
+ padding: '7px 4px', textAlign: 'center', fontWeight: 700, fontSize: 15,
|
|
|
|
|
+ color: '#ffd666', background: '#141414', borderTop: '2px solid #333',
|
|
|
|
|
+ }}>{renderHourText(monthDetailTotal.summary !== 0 || monthDetailTotal.change !== 0
|
|
|
|
|
+ ? formatPair(monthDetailTotal.summary, monthDetailTotal.change)
|
|
|
|
|
+ : '—', 15)}</td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|