Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4972,6 +4972,21 @@
"trigger": "Trigger",
"when": "When"
},
"groupJoinRequest": {
"title": "Group Join Request",
"approve": "Approve",
"decline": "Decline",
"requester": "Requester",
"group": "Group",
"requestedDate": "Requested",
"message": "Message from requester",
"resolved": "This join request task is closed.",
"declineTitle": "Decline Join Request",
"declinePrompt": "Optionally provide a reason for declining this join request.",
"reasonOptional": "Reason (optional)",
"approveError": "Unable to approve join request.",
"declineError": "Unable to decline join request."
},
"myCards": {
"noCards": "You have no assigned cards.",
"title": "My Cards"
Expand Down Expand Up @@ -5145,4 +5160,4 @@
"title": "Workflows"
}
}
}
}
2 changes: 1 addition & 1 deletion src/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const DashboardPage = () => {
<Groups personId={UserHelper.person?.id || ""} title={Locale.label("dashboard.myGroups")} />
</Grid>
<Grid size={GRID_SIZES.mainContent}>
<TaskList compact={true} status={Locale.label("tasks.taskPage.open")} />
<TaskList compact={true} status="Open" />
</Grid>
</Grid>
</Stack>
Expand Down
4 changes: 3 additions & 1 deletion src/serving/tasks/TaskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HeaderPrimaryButton, HeaderSecondaryButton } from "../../components/ui"
import { ContentPicker } from "./components/ContentPicker";
import UserContext from "../../UserContext";
import { RequestedChanges } from "./components/RequestedChanges";
import { GroupJoinRequestTask } from "./components/GroupJoinRequestTask";
import { TaskReminderEdit } from "./components/TaskReminderEdit";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Person as PersonIcon, Group as GroupIcon, CheckCircle as CompletedIcon, RadioButtonUnchecked as OpenIcon, Checklist as ChecklistIcon } from "@mui/icons-material";
Expand Down Expand Up @@ -99,7 +100,7 @@ export const TaskPage = () => {
<PageHeader
icon={<ChecklistIcon />}
title={`#${task.data.taskNumber} - ${task.data?.title}`}
subtitle={`${Locale.label("tasks.taskPage.created")} ${DateHelper.getDisplayDuration(DateHelper.toDate(task.data?.dateCreated))} ${Locale.label("tasks.taskPage.ago")} ${Locale.label("tasks.taskPage.by")} ${task.data.createdByLabel} • ${Locale.label("tasks.taskPage.associated")}: ${task.data.associatedWithLabel || Locale.label("tasks.taskPage.notSpec")} • ${Locale.label("tasks.taskPage.assigned")}: ${task.data.assignedToLabel || Locale.label("tasks.taskPage.unassigned")}`}>
subtitle={`${Locale.label("tasks.taskPage.created")} ${task.data?.dateCreated ? `${DateHelper.getDisplayDuration(new Date(task.data.dateCreated))} ${Locale.label("tasks.taskPage.ago")}` : ""}${task.data?.createdByLabel ? ` ${Locale.label("tasks.taskPage.by")} ${task.data.createdByLabel}` : ""} • ${Locale.label("tasks.taskPage.associated")}: ${task.data?.associatedWithLabel || Locale.label("tasks.taskPage.notSpec")} • ${Locale.label("tasks.taskPage.assigned")}: ${task.data?.assignedToLabel || Locale.label("tasks.taskPage.unassigned")}`}>
<Stack direction="row" spacing={1}>
<Button
variant={task.data.status === "Open" ? "contained" : "outlined"}
Expand Down Expand Up @@ -155,6 +156,7 @@ export const TaskPage = () => {

<Box sx={{ p: 3 }}>
{task.data.taskType === "directoryUpdate" && <RequestedChanges task={task.data} />}
{task.data.taskType === "groupJoinRequest" && <GroupJoinRequestTask task={task.data} />}
<Box sx={{ mb: 2 }}>
<TaskReminderEdit taskId={task.data.id || ""} dueDate={task.data.dueDate} />
</Box>
Expand Down
276 changes: 276 additions & 0 deletions src/serving/tasks/components/GroupJoinRequestTask.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import React, { useState, useCallback } from "react";
import { useNavigate, Link } from "react-router-dom";
import { ApiHelper, Locale, DateHelper } from "@churchapps/apphelper";
import { type TaskInterface } from "@churchapps/helpers";
import {
Card,
CardContent,
Typography,
Stack,
Box,
Button,
Paper,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert,
Chip
} from "@mui/material";
import {
GroupAdd as JoinRequestIcon,
CheckCircle as ApproveIcon,
Cancel as DeclineIcon,
Group as GroupIcon,
Person as PersonIcon,
ChatBubbleOutline as MessageIcon,
CalendarToday as CalendarIcon
} from "@mui/icons-material";
import { useQueryClient } from "@tanstack/react-query";

interface Props {
task: TaskInterface;
}

export const GroupJoinRequestTask: React.FC<Props> = ({ task }) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [declineOpen, setDeclineOpen] = useState(false);
const [declineReason, setDeclineReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");

const requestData = React.useMemo(() => {
try {
return JSON.parse(task.data || "{}");
} catch {
return {};
}
}, [task.data]);

const requestId = requestData.requestId;
const groupId = requestData.groupId || task.assignedToId;
const groupName = requestData.groupName || task.assignedToLabel || "Group";
const personId = requestData.personId || task.associatedWithId;
const personName = requestData.personName || task.associatedWithLabel || "Requester";
const message = requestData.message;

const isClosed = task.status === "Closed" || task.status === Locale.label("tasks.taskPage.closed");

console.log(requestData, '--requestData');

const handleApprove = useCallback(async () => {
setSubmitting(true);
setError("");
try {
if (requestId) {
await ApiHelper.post(`/groupjoinrequests/${requestId}/approve`, {}, "MembershipApi");
}
const updatedTask: TaskInterface = {
...task,
status: "Closed",
dateClosed: new Date()
};
await ApiHelper.post("/tasks", [updatedTask], "DoingApi");
queryClient.invalidateQueries({ queryKey: ["/tasks/" + task.id, "DoingApi"] });
queryClient.invalidateQueries({ queryKey: ["/tasks", "DoingApi"] });
queryClient.invalidateQueries({ queryKey: ["/tasks/closed", "DoingApi"] });
queryClient.invalidateQueries({ queryKey: ["/groupjoinrequests/pending", "MembershipApi"] });
navigate("/serving/tasks");
} catch {
setError(Locale.label("tasks.groupJoinRequest.approveError") || "Unable to approve join request.");
} finally {
setSubmitting(false);
}
}, [requestId, task, queryClient, navigate]);

const handleDecline = useCallback(async () => {
setSubmitting(true);
setError("");
try {
if (requestId) {
await ApiHelper.post(`/groupjoinrequests/${requestId}/decline`, { declineReason: declineReason || undefined }, "MembershipApi");
}
const updatedTask: TaskInterface = {
...task,
status: "Closed",
dateClosed: new Date()
};
await ApiHelper.post("/tasks", [updatedTask], "DoingApi");
setDeclineOpen(false);
setDeclineReason("");
queryClient.invalidateQueries({ queryKey: ["/tasks/" + task.id, "DoingApi"] });
queryClient.invalidateQueries({ queryKey: ["/tasks", "DoingApi"] });
queryClient.invalidateQueries({ queryKey: ["/tasks/closed", "DoingApi"] });
queryClient.invalidateQueries({ queryKey: ["/groupjoinrequests/pending", "MembershipApi"] });
navigate("/serving/tasks");
} catch {
setError(Locale.label("tasks.groupJoinRequest.declineError") || "Unable to decline join request.");
} finally {
setSubmitting(false);
}
}, [requestId, declineReason, task, queryClient, navigate]);

return (
<Card
sx={{
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
mb: 3,
transition: "all 0.2s ease-in-out",
"&:hover": { boxShadow: 2 }
}}
data-testid="group-join-request-task"
>
<CardContent>
<Stack spacing={3}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<JoinRequestIcon sx={{ color: "primary.main", fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{Locale.label("tasks.groupJoinRequest.title") || "Group Join Request"}
</Typography>
<Chip
label={task.status}
size="small"
color={isClosed ? "default" : "warning"}
sx={{ fontWeight: 600 }}
/>
</Stack>
{!isClosed && (
<Stack direction="row" spacing={1}>
<Button
variant="contained"
color="success"
startIcon={<ApproveIcon />}
disabled={submitting}
onClick={handleApprove}
data-testid="approve-join-request-button"
sx={{ borderRadius: 2, textTransform: "none", fontWeight: 600 }}
>
{Locale.label("tasks.groupJoinRequest.approve") || "Approve"}
</Button>
<Button
variant="outlined"
color="error"
startIcon={<DeclineIcon />}
disabled={submitting}
onClick={() => setDeclineOpen(true)}
data-testid="decline-join-request-button"
sx={{ borderRadius: 2, textTransform: "none", fontWeight: 600 }}
>
{Locale.label("tasks.groupJoinRequest.decline") || "Decline"}
</Button>
</Stack>
)}
</Box>

{error && <Alert severity="error" onClose={() => setError("")}>{error}</Alert>}

<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
<Stack spacing={2}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<PersonIcon sx={{ color: "primary.main", fontSize: 22 }} />
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{Locale.label("tasks.groupJoinRequest.requester") || "Requester"}:{" "}
{personId ? (
<Typography component={Link} to={`/people/${personId}`} sx={{ color: "primary.main", textDecoration: "none", fontWeight: 600, "&:hover": { textDecoration: "underline" } }}>
{personName}
</Typography>
) : (
personName
)}
</Typography>
</Box>

<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<GroupIcon sx={{ color: "secondary.main", fontSize: 22 }} />
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{Locale.label("tasks.groupJoinRequest.group") || "Group"}:{" "}
{groupId ? (
<Typography component={Link} to={`/groups/${groupId}`} sx={{ color: "primary.main", textDecoration: "none", fontWeight: 600, "&:hover": { textDecoration: "underline" } }}>
{groupName}
</Typography>
) : (
groupName
)}
</Typography>
</Box>

{task.dateCreated && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<CalendarIcon sx={{ color: "text.secondary", fontSize: 20 }} />
<Typography variant="body2" color="text.secondary">
{Locale.label("tasks.groupJoinRequest.requestedDate") || "Requested"}: {DateHelper.getDisplayDuration(new Date(task.dateCreated))} {Locale.label("tasks.taskPage.ago")} ({new Date(task.dateCreated).toLocaleString()})
</Typography>
</Box>
)}

{message && (
<Box sx={{ mt: 1, p: 2, bgcolor: "action.hover", borderRadius: 1.5 }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<MessageIcon sx={{ fontSize: 18, color: "text.secondary", mt: 0.3 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", display: "block", mb: 0.5 }}>
{Locale.label("tasks.groupJoinRequest.message") || "Message from requester"}:
</Typography>
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
"{message}"
</Typography>
</Box>
</Stack>
</Box>
)}
</Stack>
</Paper>

{isClosed && (
<Box
sx={{
p: 2,
backgroundColor: "action.selected",
borderRadius: 1.5,
textAlign: "center"
}}
>
<Typography variant="body2" sx={{ color: "text.secondary", fontWeight: 600 }}>
{Locale.label("tasks.groupJoinRequest.resolved") || "This join request task is closed."}
</Typography>
</Box>
)}
</Stack>
</CardContent>

<Dialog open={declineOpen} onClose={() => setDeclineOpen(false)} fullWidth maxWidth="sm" data-testid="decline-dialog">
<DialogTitle>{Locale.label("tasks.groupJoinRequest.declineTitle") || "Decline Join Request"}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2, color: "text.secondary" }}>
{Locale.label("tasks.groupJoinRequest.declinePrompt") || "Optionally provide a reason for declining this join request."}
</Typography>
<TextField
autoFocus
fullWidth
multiline
minRows={3}
maxRows={5}
label={Locale.label("tasks.groupJoinRequest.reasonOptional") || "Reason (optional)"}
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
slotProps={{ htmlInput: { maxLength: 500 } }}
data-testid="decline-reason-input"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setDeclineOpen(false); setDeclineReason(""); }} disabled={submitting}>
{Locale.label("common.cancel") || "Cancel"}
</Button>
<Button onClick={handleDecline} variant="contained" color="error" disabled={submitting} data-testid="confirm-decline-button">
{Locale.label("tasks.groupJoinRequest.decline") || "Decline"}
</Button>
</DialogActions>
</Dialog>
</Card>
);
};
13 changes: 8 additions & 5 deletions src/serving/tasks/components/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ export const TaskList = memo((props: Props) => {
const [showAdd, setShowAdd] = React.useState(false);
const [tab, setTab] = React.useState(0);
const context = React.useContext(UserContext);
const isClosed = props.status === "Closed" || props.status === Locale.label("tasks.taskPage.closed");
const queryStatus = isClosed ? "Closed" : "Open";

const tasks = useQuery<TaskInterface[]>({
queryKey: props.status === Locale.label("tasks.taskPage.closed") ? ["/tasks/closed", "DoingApi"] : ["/tasks", "DoingApi"],
queryKey: isClosed ? ["/tasks/closed", "DoingApi"] : ["/tasks", "DoingApi"],
placeholderData: []
});

Expand All @@ -58,12 +60,12 @@ export const TaskList = memo((props: Props) => {
}, [groupMembers.data]);

const groupTasks = useQuery<TaskInterface[]>({
queryKey: ["/tasks/loadForGroups", "DoingApi", groupIds, props.status],
queryKey: ["/tasks/loadForGroups", "DoingApi", groupIds, queryStatus],
enabled: groupIds.length > 0,
placeholderData: [],
queryFn: async () => {
if (groupIds.length === 0) return [];
return ApiHelper.post("/tasks/loadForGroups", { groupIds, status: props.status }, "DoingApi");
return ApiHelper.post("/tasks/loadForGroups", { groupIds, status: queryStatus }, "DoingApi");
}
});

Expand Down Expand Up @@ -135,8 +137,9 @@ export const TaskList = memo((props: Props) => {
<Stack direction="row" alignItems="center" spacing={1} sx={{ mt: 1 }}>
<CalendarIcon sx={{ fontSize: 16, color: "text.secondary" }} />
<Typography variant="caption" color="text.secondary">
#{task.taskNumber} {Locale.label("tasks.taskPage.opened")} {DateHelper.getDisplayDuration(DateHelper.toDate(task.dateCreated))} {Locale.label("tasks.taskPage.ago")}{" "}
{Locale.label("tasks.taskPage.by")} {task.createdByLabel}
#{task.taskNumber} {Locale.label("tasks.taskPage.opened")}{" "}
{task.dateCreated ? `${DateHelper.getDisplayDuration(new Date(task.dateCreated))} ${Locale.label("tasks.taskPage.ago")}` : ""}
{task.createdByLabel ? ` ${Locale.label("tasks.taskPage.by")} ${task.createdByLabel}` : ` ${Locale.label("tasks.taskPage.by")} ${task.associatedWithLabel}`}
</Typography>
</Stack>
</Box>
Expand Down
3 changes: 3 additions & 0 deletions src/serving/tasks/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export * from "../../../components";
export { NewTask } from "./NewTask";
export { SelectGroup } from "./SelectGroup";
export { GroupJoinRequestTask } from "./GroupJoinRequestTask";
export { RequestedChanges } from "./RequestedChanges";