647 lines
17 KiB
TypeScript
647 lines
17 KiB
TypeScript
import { create } from "zustand";
|
|
import { loadDataApi, saveDataApi, getUniqueIdApi, s3UploadFileApi } from "@/request/api";
|
|
import {
|
|
scheduleTodoReminder,
|
|
cancelTodoReminders,
|
|
requestNotificationPermission,
|
|
} from "@/helpers/notification";
|
|
import {
|
|
Todo,
|
|
TodoFile,
|
|
TodoLog,
|
|
TodoLogType,
|
|
TodoRating,
|
|
TodoRepeatType,
|
|
TodoSaveChanges,
|
|
TodoStatus,
|
|
TodoFamilyMember,
|
|
} from "@/types/todo";
|
|
|
|
const TABLES = {
|
|
todo: "b_todo",
|
|
todoLog: "b_todo_log",
|
|
todoRating: "b_todo_rating",
|
|
todoFile: "b_todo_file",
|
|
} as const;
|
|
|
|
interface TodoState {
|
|
todos: Todo[];
|
|
todoLogs: TodoLog[];
|
|
todoRatings: TodoRating[];
|
|
todoFiles: TodoFile[];
|
|
members: TodoFamilyMember[];
|
|
|
|
clear: () => void;
|
|
|
|
fetchAll: (familyId: number) => Promise<void>;
|
|
fetchTodos: (familyId: number) => Promise<void>;
|
|
fetchTodoLogs: (familyId: number, todoId?: number) => Promise<void>;
|
|
fetchTodoRatings: (familyId: number, todoId?: number) => Promise<void>;
|
|
fetchTodoFiles: (familyId: number, todoId?: number) => Promise<void>;
|
|
fetchMembers: (familyId: number) => Promise<void>;
|
|
|
|
saveTodoChanges: (changes: TodoSaveChanges<Todo>) => Promise<boolean>;
|
|
saveTodoLogChanges: (changes: TodoSaveChanges<TodoLog>) => Promise<boolean>;
|
|
saveTodoRatingChanges: (changes: TodoSaveChanges<TodoRating>) => Promise<boolean>;
|
|
saveTodoFileChanges: (changes: TodoSaveChanges<TodoFile>) => Promise<boolean>;
|
|
|
|
uploadFile: (familyId: number, file: any) => Promise<string | null>;
|
|
|
|
createTodo: (params: {
|
|
familyId: number;
|
|
userId: number;
|
|
name: string;
|
|
description?: string;
|
|
priority: number;
|
|
assigneeId?: number | null;
|
|
deadline?: string | null;
|
|
repeatType?: number;
|
|
remindAt?: string | null;
|
|
}) => Promise<number | null>;
|
|
|
|
claimTodo: (params: { todoId: number; familyId: number; userId: number }) => Promise<boolean>;
|
|
rejectTodo: (params: { todoId: number; familyId: number; userId: number }) => Promise<boolean>;
|
|
transferTodo: (params: {
|
|
todoId: number;
|
|
familyId: number;
|
|
userId: number;
|
|
newAssigneeId: number | null;
|
|
}) => Promise<boolean>;
|
|
completeTodo: (params: {
|
|
todoId: number;
|
|
familyId: number;
|
|
userId: number;
|
|
completeNote?: string;
|
|
fileInserts?: Partial<TodoFile>[];
|
|
}) => Promise<boolean>;
|
|
rateTodo: (params: {
|
|
todoId: number;
|
|
familyId: number;
|
|
userId: number;
|
|
rating: number;
|
|
content?: string;
|
|
}) => Promise<boolean>;
|
|
}
|
|
|
|
function normalizeChanges<T>(changes: TodoSaveChanges<T>) {
|
|
return {
|
|
inserts: changes.inserts ?? [],
|
|
updates: changes.updates ?? [],
|
|
deletes: changes.deletes ?? [],
|
|
};
|
|
}
|
|
|
|
function nextDeadline(
|
|
now: Date,
|
|
repeatType: TodoRepeatType,
|
|
oldDeadline?: string | null,
|
|
): string | null {
|
|
if (repeatType === TodoRepeatType.None || !oldDeadline) return null;
|
|
const d = new Date(oldDeadline);
|
|
switch (repeatType) {
|
|
case TodoRepeatType.Daily:
|
|
d.setDate(d.getDate() + 1);
|
|
break;
|
|
case TodoRepeatType.Weekly:
|
|
d.setDate(d.getDate() + 7);
|
|
break;
|
|
case TodoRepeatType.Monthly:
|
|
d.setMonth(d.getMonth() + 1);
|
|
break;
|
|
}
|
|
return d.toISOString();
|
|
}
|
|
|
|
export const useTodoStore = create<TodoState>((set, get) => ({
|
|
todos: [],
|
|
todoLogs: [],
|
|
todoRatings: [],
|
|
todoFiles: [],
|
|
members: [],
|
|
|
|
clear: () => set({ todos: [], todoLogs: [], todoRatings: [], todoFiles: [], members: [] }),
|
|
|
|
fetchAll: async (familyId: number) => {
|
|
if (!familyId) return;
|
|
await Promise.all([
|
|
get().fetchTodos(familyId),
|
|
get().fetchTodoLogs(familyId),
|
|
get().fetchTodoRatings(familyId),
|
|
get().fetchTodoFiles(familyId),
|
|
get().fetchMembers(familyId),
|
|
]);
|
|
},
|
|
|
|
fetchTodos: async (familyId: number) => {
|
|
if (!familyId) return;
|
|
const res = await loadDataApi({
|
|
view_name: TABLES.todo,
|
|
search_condition: "family_id = ?",
|
|
search_columns: [],
|
|
order_by: "sort_number asc, id asc",
|
|
args: [familyId],
|
|
});
|
|
if (res.isSuccess) set({ todos: res.data as Todo[] });
|
|
},
|
|
|
|
fetchTodoLogs: async (familyId: number, todoId?: number) => {
|
|
if (!familyId) return;
|
|
const conditions = ["family_id = ?"];
|
|
const args: any[] = [familyId];
|
|
if (todoId !== undefined) {
|
|
conditions.push("todo_id = ?");
|
|
args.push(todoId);
|
|
}
|
|
const res = await loadDataApi({
|
|
view_name: TABLES.todoLog,
|
|
search_condition: conditions.join(" and "),
|
|
search_columns: [],
|
|
order_by: "create_time asc, id asc",
|
|
args,
|
|
});
|
|
if (res.isSuccess) set({ todoLogs: res.data as TodoLog[] });
|
|
},
|
|
|
|
fetchTodoRatings: async (familyId: number, todoId?: number) => {
|
|
if (!familyId) return;
|
|
const conditions = ["family_id = ?"];
|
|
const args: any[] = [familyId];
|
|
if (todoId !== undefined) {
|
|
conditions.push("todo_id = ?");
|
|
args.push(todoId);
|
|
}
|
|
const res = await loadDataApi({
|
|
view_name: TABLES.todoRating,
|
|
search_condition: conditions.join(" and "),
|
|
search_columns: [],
|
|
order_by: "id asc",
|
|
args,
|
|
});
|
|
if (res.isSuccess) set({ todoRatings: res.data as TodoRating[] });
|
|
},
|
|
|
|
fetchTodoFiles: async (familyId: number, todoId?: number) => {
|
|
if (!familyId) return;
|
|
const conditions = ["family_id = ?"];
|
|
const args: any[] = [familyId];
|
|
if (todoId !== undefined) {
|
|
conditions.push("todo_id = ?");
|
|
args.push(todoId);
|
|
}
|
|
const res = await loadDataApi({
|
|
view_name: TABLES.todoFile,
|
|
search_condition: conditions.join(" and "),
|
|
search_columns: [],
|
|
order_by: "sort_order asc, id asc",
|
|
args,
|
|
});
|
|
if (res.isSuccess) set({ todoFiles: res.data as TodoFile[] });
|
|
},
|
|
|
|
fetchMembers: async (familyId: number) => {
|
|
if (!familyId) return;
|
|
const res = await loadDataApi({
|
|
view_name: "v_family_member",
|
|
search_condition: "family_id = ?",
|
|
search_columns: ["user_id", "nickname", "avatar"],
|
|
order_by: "id asc",
|
|
args: [familyId],
|
|
});
|
|
if (res.isSuccess) set({ members: (res.data as TodoFamilyMember[]) ?? [] });
|
|
},
|
|
|
|
saveTodoChanges: async (changes: TodoSaveChanges<Todo>) => {
|
|
const res = await saveDataApi([
|
|
{ table_name: TABLES.todo, key_field: "id", ...normalizeChanges(changes) },
|
|
]);
|
|
return res.isSuccess;
|
|
},
|
|
saveTodoLogChanges: async (changes: TodoSaveChanges<TodoLog>) => {
|
|
const res = await saveDataApi([
|
|
{ table_name: TABLES.todoLog, key_field: "id", ...normalizeChanges(changes) },
|
|
]);
|
|
return res.isSuccess;
|
|
},
|
|
saveTodoRatingChanges: async (changes: TodoSaveChanges<TodoRating>) => {
|
|
const res = await saveDataApi([
|
|
{ table_name: TABLES.todoRating, key_field: "id", ...normalizeChanges(changes) },
|
|
]);
|
|
return res.isSuccess;
|
|
},
|
|
saveTodoFileChanges: async (changes: TodoSaveChanges<TodoFile>) => {
|
|
const res = await saveDataApi([
|
|
{ table_name: TABLES.todoFile, key_field: "id", ...normalizeChanges(changes) },
|
|
]);
|
|
return res.isSuccess;
|
|
},
|
|
|
|
uploadFile: async (familyId: number, file: any) => {
|
|
if (!familyId) return null;
|
|
const res = await s3UploadFileApi([file], { familyId, module: "todo" });
|
|
if (!res.isSuccess) return null;
|
|
return res.data?.[0] ?? null;
|
|
},
|
|
|
|
createTodo: async ({
|
|
familyId,
|
|
userId,
|
|
name,
|
|
description,
|
|
priority,
|
|
assigneeId,
|
|
deadline,
|
|
repeatType,
|
|
remindAt,
|
|
}) => {
|
|
if (!familyId) return null;
|
|
const [idRes, logIdRes] = await Promise.all([getUniqueIdApi(1), getUniqueIdApi(1)]);
|
|
const todoId = idRes.data?.[0] ?? 0;
|
|
const logId = logIdRes.data?.[0] ?? 0;
|
|
if (!todoId) return null;
|
|
const now = new Date().toISOString();
|
|
const ops: any[] = [
|
|
{
|
|
table_name: TABLES.todo,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: todoId,
|
|
family_id: familyId,
|
|
name,
|
|
description: description ?? "",
|
|
priority,
|
|
assignee_id: assigneeId ?? null,
|
|
deadline: deadline ?? null,
|
|
repeat_type: repeatType ?? 0,
|
|
remind_at: remindAt ?? null,
|
|
status: assigneeId ? TodoStatus.InProgress : TodoStatus.Unclaimed,
|
|
create_time: now,
|
|
create_by: userId,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
},
|
|
];
|
|
if (logId)
|
|
ops.push({
|
|
table_name: TABLES.todoLog,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: logId,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
type: assigneeId ? TodoLogType.Assign : TodoLogType.Create,
|
|
content: assigneeId ? String(assigneeId) : name,
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
const res = await saveDataApi(ops);
|
|
if (res.isSuccess) {
|
|
set((s) => ({
|
|
todos: [
|
|
...s.todos,
|
|
{
|
|
id: todoId,
|
|
family_id: familyId,
|
|
name,
|
|
description: description ?? "",
|
|
status: assigneeId ? TodoStatus.InProgress : TodoStatus.Unclaimed,
|
|
priority,
|
|
repeat_type: repeatType ?? 0,
|
|
remind_at: remindAt ?? null,
|
|
assignee_id: assigneeId ?? null,
|
|
deadline: deadline ?? null,
|
|
sort_number: s.todos.length,
|
|
create_time: now,
|
|
create_by: userId,
|
|
} as Todo,
|
|
],
|
|
}));
|
|
// 预定提醒通知
|
|
if (remindAt) {
|
|
requestNotificationPermission().then((ok) => {
|
|
if (ok) scheduleTodoReminder(todoId, name, remindAt);
|
|
});
|
|
}
|
|
return todoId;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
claimTodo: async ({ todoId, familyId, userId }) => {
|
|
if (!familyId) return false;
|
|
const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
|
|
const now = new Date().toISOString();
|
|
const ops: any[] = [
|
|
{
|
|
table_name: TABLES.todo,
|
|
key_field: "id",
|
|
inserts: [],
|
|
updates: [
|
|
{
|
|
id: todoId,
|
|
status: TodoStatus.InProgress,
|
|
assignee_id: userId,
|
|
update_time: now,
|
|
update_by: userId,
|
|
},
|
|
],
|
|
deletes: [],
|
|
},
|
|
];
|
|
if (logId)
|
|
ops.push({
|
|
table_name: TABLES.todoLog,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: logId,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
type: TodoLogType.Claim,
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
const res = await saveDataApi(ops);
|
|
if (res.isSuccess)
|
|
set((s) => ({
|
|
todos: s.todos.map((t) =>
|
|
t.id === todoId ? { ...t, status: TodoStatus.InProgress, assignee_id: userId } : t,
|
|
),
|
|
}));
|
|
return res.isSuccess;
|
|
},
|
|
|
|
rejectTodo: async ({ todoId, familyId, userId }) => {
|
|
if (!familyId) return false;
|
|
const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
|
|
const now = new Date().toISOString();
|
|
const ops: any[] = [
|
|
{
|
|
table_name: TABLES.todo,
|
|
key_field: "id",
|
|
inserts: [],
|
|
updates: [
|
|
{
|
|
id: todoId,
|
|
status: TodoStatus.Unclaimed,
|
|
assignee_id: null,
|
|
update_time: now,
|
|
update_by: userId,
|
|
},
|
|
],
|
|
deletes: [],
|
|
},
|
|
];
|
|
if (logId)
|
|
ops.push({
|
|
table_name: TABLES.todoLog,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: logId,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
type: TodoLogType.Reject,
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
const res = await saveDataApi(ops);
|
|
if (res.isSuccess)
|
|
set((s) => ({
|
|
todos: s.todos.map((t) =>
|
|
t.id === todoId ? { ...t, status: TodoStatus.Unclaimed, assignee_id: null } : t,
|
|
),
|
|
}));
|
|
return res.isSuccess;
|
|
},
|
|
|
|
transferTodo: async ({ todoId, familyId, userId, newAssigneeId }) => {
|
|
if (!familyId) return false;
|
|
const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
|
|
const now = new Date().toISOString();
|
|
const newStatus = newAssigneeId ? TodoStatus.InProgress : TodoStatus.Unclaimed;
|
|
const ops: any[] = [
|
|
{
|
|
table_name: TABLES.todo,
|
|
key_field: "id",
|
|
inserts: [],
|
|
updates: [
|
|
{
|
|
id: todoId,
|
|
status: newStatus,
|
|
assignee_id: newAssigneeId,
|
|
update_time: now,
|
|
update_by: userId,
|
|
},
|
|
],
|
|
deletes: [],
|
|
},
|
|
];
|
|
if (logId)
|
|
ops.push({
|
|
table_name: TABLES.todoLog,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: logId,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
type: TodoLogType.Transfer,
|
|
content: String(newAssigneeId ?? ""),
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
const res = await saveDataApi(ops);
|
|
if (res.isSuccess)
|
|
set((s) => ({
|
|
todos: s.todos.map((t) =>
|
|
t.id === todoId ? { ...t, status: newStatus, assignee_id: newAssigneeId } : t,
|
|
),
|
|
}));
|
|
return res.isSuccess;
|
|
},
|
|
|
|
completeTodo: async ({ todoId, familyId, userId, completeNote, fileInserts }) => {
|
|
if (!familyId) return false;
|
|
const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
|
|
const now = new Date().toISOString();
|
|
const ops: any[] = [
|
|
{
|
|
table_name: TABLES.todo,
|
|
key_field: "id",
|
|
inserts: [],
|
|
updates: [
|
|
{
|
|
id: todoId,
|
|
status: TodoStatus.Completed,
|
|
completed_at: now,
|
|
complete_note: completeNote ?? "",
|
|
update_time: now,
|
|
update_by: userId,
|
|
},
|
|
],
|
|
deletes: [],
|
|
},
|
|
];
|
|
if (logId) {
|
|
ops.push({
|
|
table_name: TABLES.todoLog,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: logId,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
type: TodoLogType.Complete,
|
|
content: completeNote ?? "",
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
}
|
|
// 附件
|
|
if (fileInserts && fileInserts.length > 0) {
|
|
ops.push({
|
|
table_name: TABLES.todoFile,
|
|
key_field: "id",
|
|
inserts: fileInserts.map((f) => ({
|
|
...f,
|
|
todo_log_id: logId,
|
|
todo_id: todoId,
|
|
create_by: userId,
|
|
create_time: now,
|
|
})),
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
}
|
|
|
|
const res = await saveDataApi(ops);
|
|
if (res.isSuccess) {
|
|
set((s) => ({
|
|
todos: s.todos.map((t) =>
|
|
t.id === todoId
|
|
? {
|
|
...t,
|
|
status: TodoStatus.Completed,
|
|
completed_at: now,
|
|
complete_note: completeNote ?? "",
|
|
}
|
|
: t,
|
|
),
|
|
}));
|
|
|
|
// 取消提醒通知
|
|
cancelTodoReminders(todoId);
|
|
|
|
// 重复任务:自动创建下一个实例
|
|
const todo = get().todos.find((t) => t.id === todoId);
|
|
if (todo && todo.repeat_type !== TodoRepeatType.None) {
|
|
const nextDl = nextDeadline(new Date(now), todo.repeat_type, todo.deadline);
|
|
const nextRemind = todo.remind_at
|
|
? nextDeadline(new Date(now), todo.repeat_type, todo.remind_at)
|
|
: null;
|
|
get().createTodo({
|
|
familyId,
|
|
userId,
|
|
name: todo.name,
|
|
description: todo.description,
|
|
priority: todo.priority,
|
|
assigneeId: todo.assignee_id,
|
|
deadline: nextDl,
|
|
repeatType: todo.repeat_type,
|
|
remindAt: nextRemind,
|
|
});
|
|
}
|
|
}
|
|
return res.isSuccess;
|
|
},
|
|
|
|
rateTodo: async ({ todoId, familyId, userId, rating, content }) => {
|
|
if (!familyId) return false;
|
|
const [rateId, logId] = await Promise.all([getUniqueIdApi(1), getUniqueIdApi(1)]);
|
|
const rid = rateId.data?.[0] ?? 0;
|
|
const lid = logId.data?.[0] ?? 0;
|
|
if (!rid) return false;
|
|
const now = new Date().toISOString();
|
|
const ops: any[] = [
|
|
{
|
|
table_name: TABLES.todoRating,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: rid,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
rater_id: userId,
|
|
rating,
|
|
content: content ?? "",
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
},
|
|
];
|
|
if (lid)
|
|
ops.push({
|
|
table_name: TABLES.todoLog,
|
|
key_field: "id",
|
|
inserts: [
|
|
{
|
|
id: lid,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
type: TodoLogType.Rate,
|
|
ref_id: rid,
|
|
create_by: userId,
|
|
create_time: now,
|
|
},
|
|
],
|
|
updates: [],
|
|
deletes: [],
|
|
});
|
|
const res = await saveDataApi(ops);
|
|
if (res.isSuccess)
|
|
set((s) => ({
|
|
todoRatings: [
|
|
...s.todoRatings,
|
|
{
|
|
id: rid,
|
|
family_id: familyId,
|
|
todo_id: todoId,
|
|
rater_id: userId,
|
|
rating,
|
|
content: content ?? "",
|
|
create_by: userId,
|
|
create_time: now,
|
|
} as TodoRating,
|
|
],
|
|
}));
|
|
return res.isSuccess;
|
|
},
|
|
}));
|