feat: 发薪日期多选+提前N天提醒+电子签设置区域修复

- Schema: 去掉 payrollFrequency,新增 payrollDays (JSON数组) + payrollReminderDays (Int)
- 设置页: 发薪日期改为1-28号多选按钮,新增提前提醒天数设置
- 设置页: 恢复电子签署设置区域(3个开关:规章制度/工资条/入职文件)
- 工作日历: 发薪日期作为 PAYROLL_DAY 事件显示
- 工作台: 提前N天提醒发薪日期,N可配置
- TaskCenter: 新增发薪提醒分类图标
- seed文件: 更新为 payrollDays 格式
This commit is contained in:
freedakgmail
2026-08-05 08:04:39 +08:00
parent 41b3030442
commit 6ec939dc7f
11 changed files with 180 additions and 25 deletions
+2 -1
View File
@@ -130,7 +130,8 @@ model Organization {
city String?
contactName String?
contactPhone String?
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
payrollDays Json @default("[]") // 每月发薪日期,如 [5, 20] 表示每月5号和20号
payrollReminderDays Int @default(3) // 发薪提前提醒天数
retirementReminderEnabled Boolean @default(false) // 退休提醒开关
esignPolicyEnabled Boolean @default(false) // 规章制度电子签
esignPayslipEnabled Boolean @default(false) // 工资条电子签
+5 -5
View File
@@ -49,7 +49,7 @@ interface OrgConfig {
adminName: string
contactName: string
contactPhone: string
payrollFrequency: number
payrollDays: number[]
retirementReminderEnabled: boolean
socialConfig: {
city: string
@@ -89,7 +89,7 @@ const ORGS: OrgConfig[] = [
adminName: '王建国',
contactName: '王建国',
contactPhone: '13800000010',
payrollFrequency: 1,
payrollDays: [10],
retirementReminderEnabled: true,
socialConfig: {
city: '北京',
@@ -122,7 +122,7 @@ const ORGS: OrgConfig[] = [
adminName: '李明华',
contactName: '李明华',
contactPhone: '13800000020',
payrollFrequency: 2,
payrollDays: [5, 20],
retirementReminderEnabled: true,
socialConfig: {
city: '深圳',
@@ -157,7 +157,7 @@ const ORGS: OrgConfig[] = [
adminName: '赵雪梅',
contactName: '赵雪梅',
contactPhone: '13800000030',
payrollFrequency: 1,
payrollDays: [10],
retirementReminderEnabled: false,
socialConfig: {
city: '杭州',
@@ -213,7 +213,7 @@ async function createOrg(orgConfig: OrgConfig) {
city: orgConfig.city,
contactName: orgConfig.contactName,
contactPhone: orgConfig.contactPhone,
payrollFrequency: orgConfig.payrollFrequency,
payrollDays: orgConfig.payrollDays,
retirementReminderEnabled: orgConfig.retirementReminderEnabled,
},
})
+1 -1
View File
@@ -78,7 +78,7 @@ async function main() {
plan: 'PRO',
maxEmployees: 50,
city: '上海',
payrollFrequency: 1,
payrollDays: [10],
},
})
console.log('企业已创建:', org.name)
+42
View File
@@ -285,6 +285,43 @@ router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, r
take: 10,
})
// 5. 发薪日提前提醒
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true, payrollReminderDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
const reminderDays = org?.payrollReminderDays ?? 3
const payrollReminderItems: any[] = []
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-indexed
for (const day of payrollDays) {
// 本月发薪日
const thisMonthPayday = new Date(currentYear, currentMonth, day)
const diffDays = Math.floor((thisMonthPayday.getTime() - now.getTime()) / 86400000)
if (diffDays >= 0 && diffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 1}-${day}`,
title: `发薪日(每月${day}号)${diffDays === 0 ? '今天' : `${diffDays}天后`}`,
subtitle: diffDays === 0 ? '今天发薪' : `还有${diffDays}`,
link: '/money',
})
}
// 下月发薪日(如果当月已过,看下月)
if (diffDays < 0) {
const nextMonthPayday = new Date(currentYear, currentMonth + 1, day)
const nextDiffDays = Math.floor((nextMonthPayday.getTime() - now.getTime()) / 86400000)
if (nextDiffDays >= 0 && nextDiffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 2}-${day}`,
title: `发薪日(下月${day}号)${nextDiffDays === 0 ? '今天' : `${nextDiffDays}天后`}`,
subtitle: `还有${nextDiffDays}`,
link: '/money',
})
}
}
}
// 按优先级分组
const actions: Array<{ category: string; priority: 'high' | 'medium' | 'low'; items: any[] }> = [
{
@@ -333,6 +370,11 @@ router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, r
link: '/special-status',
})),
},
{
category: '发薪提醒',
priority: 'medium',
items: payrollReminderItems,
},
]
// 过滤空分类
+1 -1
View File
@@ -115,7 +115,7 @@ router.get('/orgs', async (req: AuthRequest, res, next) => {
select: {
id: true, name: true, plan: true, maxEmployees: true,
city: true, contactName: true, contactPhone: true,
payrollFrequency: true, retirementReminderEnabled: true,
payrollDays: true, retirementReminderEnabled: true,
createdAt: true, updatedAt: true,
_count: {
select: { employees: true, users: true, contracts: true, payslips: true },
+5 -4
View File
@@ -28,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
try {
const org = await prisma.organization.findUnique({
where: { id: req.user!.orgId },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, createdAt: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, createdAt: true },
})
res.json({ success: true, data: org })
} catch (err) {
@@ -39,10 +39,11 @@ router.get('/org', async (req: AuthRequest, res, next) => {
// 更新企业信息
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const { name, payrollFrequency, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled } = req.body as { name?: string; payrollFrequency?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean }
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean }
const updateData: any = {}
if (name) updateData.name = name
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
if (payrollDays !== undefined) updateData.payrollDays = payrollDays
if (payrollReminderDays !== undefined) updateData.payrollReminderDays = payrollReminderDays
if (city !== undefined) updateData.city = city
if (contactName !== undefined) updateData.contactName = contactName
if (contactPhone !== undefined) updateData.contactPhone = contactPhone
@@ -53,7 +54,7 @@ router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true },
})
res.json({ success: true, data: org })
} catch (err) {
+21 -1
View File
@@ -1184,7 +1184,27 @@ export async function getMonthlyCalendar(orgId: string, month: string) {
}
}
// 7. 自定义日历事件
// 7. 发薪日期
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
for (const day of payrollDays) {
const dateStr = `${month}-${String(day).padStart(2, '0')}`
const payrollDate = new Date(parseInt(year), monthNum - 1, day)
if (payrollDate >= monthStart && payrollDate <= monthEnd) {
events.push({
date: dateStr,
type: 'PAYROLL_DAY',
title: `发薪日(每月${day}号)`,
actionUrl: '/money',
priority: 'medium',
})
}
}
// 8. 自定义日历事件
const customEvents = await prisma.calendarEvent.findMany({
where: {
orgId,
+2
View File
@@ -15,6 +15,7 @@ const EVENT_TYPE_COLORS: Record<string, string> = {
ANNIVERSARY: 'bg-green-100 text-green-700 border-green-200',
RISK_DEADLINE: 'bg-orange-100 text-orange-700 border-orange-200',
RETIREMENT: 'bg-purple-100 text-purple-700 border-purple-200',
PAYROLL_DAY: 'bg-emerald-100 text-emerald-700 border-emerald-200',
CUSTOM: 'bg-blue-100 text-blue-700 border-blue-200',
MEETING: 'bg-cyan-100 text-cyan-700 border-cyan-200',
TEAM_BUILDING: 'bg-pink-100 text-pink-700 border-pink-200',
@@ -29,6 +30,7 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
ANNIVERSARY: '入职周年',
RISK_DEADLINE: '风险截止',
RETIREMENT: '退休',
PAYROLL_DAY: '发薪日',
CUSTOM: '自定义',
MEETING: '会议',
TEAM_BUILDING: '团建',
+98 -10
View File
@@ -94,7 +94,8 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
name: '',
contactName: '',
contactPhone: '',
payrollFrequency: 1,
payrollDays: [] as number[],
payrollReminderDays: 3,
retirementReminderEnabled: false,
esignPolicyEnabled: false,
esignPayslipEnabled: false,
@@ -107,7 +108,8 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
name: orgData.name || '',
contactName: orgData.contactName || '',
contactPhone: orgData.contactPhone || '',
payrollFrequency: orgData.payrollFrequency || 1,
payrollDays: Array.isArray(orgData.payrollDays) ? orgData.payrollDays : [],
payrollReminderDays: orgData.payrollReminderDays ?? 3,
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
esignPolicyEnabled: orgData.esignPolicyEnabled || false,
esignPayslipEnabled: orgData.esignPayslipEnabled || false,
@@ -133,20 +135,106 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<div>
<Label></Label>
<Select value={String(form.payrollFrequency)} onChange={(e) => setForm({ ...form, payrollFrequency: Number(e.target.value) })}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</Select>
<p className="text-xs text-gray-500 mt-1"></p>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
onClick={() => {
const days = form.payrollDays.includes(day)
? form.payrollDays.filter(d => d !== day)
: [...form.payrollDays, day].sort((a, b) => a - b)
setForm({ ...form, payrollDays: days })
}}
className={`w-9 h-9 rounded-md text-xs font-medium border transition-colors ${
form.payrollDays.includes(day)
? 'bg-primary text-white border-primary'
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
}`}
>
{day}
</button>
))}
</div>
{form.payrollDays.length > 0 && (
<p className="text-xs text-gray-500 mt-2"> {form.payrollDays.map(d => `${d}`).join('、')} </p>
)}
</div>
<div>
<Label></Label>
<div className="flex items-center gap-3">
<Input type="number" min={0} max={30} value={form.payrollReminderDays} onChange={(e) => setForm({ ...form, payrollReminderDays: Number(e.target.value) })} className="!w-24" />
<span className="text-xs text-gray-500"></span>
</div>
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
{/* 电子签署设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<PenTool className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium"></div>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<button
type="button"
onClick={() => {
const next = !form.esignPolicyEnabled
setForm({ ...form, esignPolicyEnabled: next })
onSave({ esignPolicyEnabled: next })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${form.esignPolicyEnabled ? 'bg-primary' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form.esignPolicyEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium"></div>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<button
type="button"
onClick={() => {
const next = !form.esignPayslipEnabled
setForm({ ...form, esignPayslipEnabled: next })
onSave({ esignPayslipEnabled: next })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${form.esignPayslipEnabled ? 'bg-primary' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form.esignPayslipEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium"></div>
<p className="text-xs text-gray-500 mt-0.5">HR审批通过入职流程时自动创建入职文件签署</p>
</div>
<button
type="button"
onClick={() => {
const next = !form.esignOnboardingEnabled
setForm({ ...form, esignOnboardingEnabled: next })
onSave({ esignOnboardingEnabled: next })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${form.esignOnboardingEnabled ? 'bg-primary' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form.esignOnboardingEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
{/* 显示设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
+2 -1
View File
@@ -4,7 +4,7 @@
*/
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo } from 'lucide-react'
import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo, Wallet } from 'lucide-react'
import { dashboardApi } from '../../lib/api-services'
import { InlineAlert } from '../../components/ui/InlineAlert'
@@ -27,6 +27,7 @@ interface ActionGroup {
const categoryIcons: Record<string, typeof AlertCircle> = {
'待办事项': AlertCircle,
'发薪批次': Layers,
'发薪提醒': Wallet,
'合同到期': FileText,
'特殊状态': Heart,
}
+1 -1
View File
@@ -12,7 +12,7 @@ import Button from '../../components/ui/Button'
interface Org {
id: string; name: string; plan: string; maxEmployees: number
city: string | null; contactName: string | null; contactPhone: string | null
payrollFrequency: number; retirementReminderEnabled: boolean
payrollDays: number[]; retirementReminderEnabled: boolean
createdAt: string; updatedAt: string
employeeCount: number; userCount: number; contractCount: number; payslipCount: number
}