diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index db7f65b0..c3f39802 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -58,3 +58,40 @@ flowchart LR
C -->|Declined| F[Resubmit if requested]
E -->|Accept| D[Merged dev]
```
+
+## Developer Guidelines
+
+### Clear and Helpful Commit Messages
+
+When you make a commit (a save point in our project), it's important to describe what you've changed in a way that's easy to understand. Use simple, active sentences. For example, say "Update button styles" instead of something vague like "changed styling." This helps everyone see what's been done just by looking at the commit history.
+Keep Your Branch Up-to-Date
+
+If you’re working on a feature in a separate branch, it’s a good idea to regularly pull in the latest changes from the main branch. This helps prevent conflicts later and keeps your work relevant. Also, if you’ve been working on a branch for a long time without pulling updates, let the team know why in your pull request. This way, everyone understands your reasoning.
+
+### Protecting the Main Branch
+
+We want to make sure only high-quality code makes it into our main branch, which is what goes into production. To do this, we’ll require all changes to go through a pull request (PR). This means your code needs to be reviewed by someone else, and automated checks will be run to catch any issues before they become bigger problems.
+
+### Keep Pull Requests Small and Focused
+
+When possible, try to keep your pull requests small and focused on one thing. This makes it easier for others to review your changes quickly and reduces the chance of missing issues. If a pull request is too big, it can take longer to review and might introduce more bugs.
+
+### Automated Testing
+
+Before you commit your code, it’s important to write tests to make sure everything works as expected. Automated tests help keep our project stable by catching issues early. You can use GitHub Actions to run these tests automatically, so you know everything is working before you merge your changes into the main branch.
+
+### Write and Keep Up Documentation
+
+Good documentation is key to helping everyone understand how our project works. A clear README file and other documents can save a lot of time. It’s helpful to document both public info (like how to set up the project on your computer) and private info (like how our servers are managed). This way, everyone can find the information they need.
+
+### Use GitHub Issues for Proposing Changes
+
+Before you start working on something new, it’s a good idea to open a GitHub issue to get feedback. This can help you refine your ideas and make sure you’re on the right track. It also helps avoid doing work that might need to be redone later. Getting early feedback can save time and make sure your efforts are aligned with the team.
+
+### Write Clear GitHub Issues
+
+When you create a GitHub issue, try to include all the information needed to understand the problem or task. If you don’t have all the details, ask questions and tag the right people. A well-written issue is half the work, making it easier for someone to jump in and help.
+
+### Communicate Clearly
+
+Good communication is key to working well together. Before you send a message or comment, think about how others will read it. Try to make your point clear and simple. If you’re in a hurry, it’s easy to be misunderstood, so take a moment to check if your message is clear and concise. This will help others understand you better and keep things moving smoothly.
diff --git a/LICENSE.md b/LICENSE.md
index 23a03bb1..fbc2f6d6 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -4,12 +4,12 @@ Copyright (c) 2025 STAPLE Development Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, subject to the following conditions:
1. **Attribution** must be given to the original authors of the STAPLE software.
-2. **Non-Commercial Use Only**: The Software may not be used, in whole or in part, for commercial purposes without prior written permission from the copyright holders.
+2. **Non-Commercial Use Only**: The Software may not be used, in whole or in part, for commercial purposes without prior written permission from the copyright holders.
- “Commercial purposes” include selling the Software, offering it as a service for a fee, or incorporating it into a commercial product or platform.
3. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
diff --git a/README.md b/README.md
index 499e9dc1..3bbeece8 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,25 @@
## STAPLE: Software for Scientists
-Cite: [](https://doi.org/10.5281/zenodo.13916969)
+*A research project management platform for open, transparent, and collaborative science.*
-(use https://doi.org/10.5281/zenodo.13916969 for the concept version, or use the specific doi for a release if desired).
+[](https://doi.org/10.5281/zenodo.13916969)
+[](LICENSE)
+[](https://github.com/STAPLE-verse/STAPLE/actions/workflows/dry-run.yml)
+[](https://github.com/STAPLE-verse/STAPLE?tab=contributing-ov-file)
+[]([https://your-slack-invite-link](https://join.slack.com/t/staple-talk/shared_invite/zt-25c08jrdt-f66do2kbIZExpAou5ZQYew))
-Docs:
+### 📖 Documentation:
-Contribute:
+👉 [Full installation and usage guide](https://staple.science/documentation/)
+
+### 📦 Citation
+
+If you use STAPLE in your work, please cite it using the concept DOI or a release-specific DOI:
+
+- Concept DOI: https://doi.org/10.5281/zenodo.13916969
+- For specific releases, use the DOI associated with that release (badge above links to the latest).
+
+### 🤝 Contributing
+
+We welcome contributions of all kinds — code, documentation, testing, and feedback.
+[See our contribution guidelines.](https://github.com/STAPLE-verse/STAPLE?tab=contributing-ov-file)
diff --git a/cron/cronJobMailer.mjs b/cron/cronJobMailer.mjs
index dad352eb..c46cae4d 100644
--- a/cron/cronJobMailer.mjs
+++ b/cron/cronJobMailer.mjs
@@ -7,8 +7,12 @@ import { resolver } from "@blitzjs/rpc"
const db = new PrismaClient() // Create Prisma client instance
+function fmtDate(date) {
+ return moment(date).format("MMM D, YYYY")
+}
+
// Helper function to create email content
-function createDailyNotification(email, notificationContent) {
+function createDailyNotification(email, notificationContent, overdueContent) {
const html_message = `
@@ -20,11 +24,15 @@ function createDailyNotification(email, notificationContent) {
- This email is to notify you about recent updates to your project.
- Here are new announcements, tasks, and other project updates:
-
+ This email is to notify you about overdue tasks and recent updates to your project(s).
+ You can view all notifications on the No new updates in the last 24 hours.
"
+
+ // Build overdue content for this recipient (if any)
+ const overdueProjects = groupedOverdues?.[email] || {}
+ const overdueContent =
+ Object.entries(overdueProjects)
+ .map(([projectName, rows]) => {
+ const projectHeader = `")
if (!response.ok) {
- console.error(`Failed to send email to ${email}:`, response.statusText)
+ console.error(
+ `Failed to send email to ${email}: ${response.status} ${response.statusText} — ${respText}`
+ )
} else {
- console.log(`Email sent successfully to ${email}`)
+ console.log(
+ `Email sent successfully to ${email}: ${response.status} — ${respText.substring(
+ 0,
+ 120
+ )}...`
+ )
}
emailCount++ // Increment the email count after sending each email
@@ -144,13 +263,18 @@ export async function sendGroupedNotifications(groupedNotifications) {
console.error(`Error sending email to ${email}:`, error)
}
}
+
+ console.log(`[Mailer] Processed ${allEmails.size} recipients.`)
}
// Function to fetch and send daily notifications
async function sendDailyNotifications() {
try {
- const groupedNotifications = await fetchAndGroupNotifications()
- await sendGroupedNotifications(groupedNotifications)
+ const [groupedNotifications, groupedOverdues] = await Promise.all([
+ fetchAndGroupNotifications(),
+ fetchAndGroupOverdueTasks(),
+ ])
+ await sendGroupedNotifications(groupedNotifications, groupedOverdues)
} catch (error) {
console.error("Error in sendDailyNotifications:", error)
}
diff --git a/db/migrations/migration_lock.toml b/db/migrations/migration_lock.toml
new file mode 100644
index 00000000..fbffa92c
--- /dev/null
+++ b/db/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "postgresql"
\ No newline at end of file
diff --git a/db/schema.prisma b/db/schema.prisma
index 0f7f75f5..f0c9c6f7 100644
--- a/db/schema.prisma
+++ b/db/schema.prisma
@@ -170,6 +170,13 @@ enum Status {
NOT_COMPLETED
}
+enum AutoAssignNew {
+ NONE
+ CONTRIBUTOR
+ TEAM
+ ALL
+}
+
model Task {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
@@ -200,6 +207,8 @@ model Task {
assignedMembers ProjectMember[] @relation("AssignedTasks")
elementId Int?
element Element? @relation(fields: [elementId], references: [id])
+ autoAssignNew AutoAssignNew @default(NONE)
+ anonymous Boolean @default(false)
}
model TaskLog {
diff --git a/integrations/emails.tsx b/integrations/emails.tsx
index c337b5bf..f3d8c002 100644
--- a/integrations/emails.tsx
+++ b/integrations/emails.tsx
@@ -236,7 +236,7 @@ alt="STAPLE Logo" height="200">
STAPLE Password Change
This email is to notify you that you recently updated your
- password. If you did not make this change, please
+ password at https://app.staple.science. If you did not make this change, please
contact us immediately.
If you need more help, you can reply to this email to create a ticket.
@@ -289,7 +289,7 @@ alt="STAPLE Logo" height="200">
STAPLE Profile Change
This email is to notify you that you recently updated your
- profile information. If you did not make this change, please
+ profile information at https://app.staple.science. If you did not make this change, please
contact us immediately.
If you need more help, you can reply to this email to create a ticket.
diff --git a/integrations/mailer.js b/integrations/mailer.js
index 592f524c..752162ca 100644
--- a/integrations/mailer.js
+++ b/integrations/mailer.js
@@ -3,6 +3,7 @@ import nodemailer from "nodemailer"
import * as aws from "@aws-sdk/client-ses"
import { Resend } from "resend"
+// use for Gmail
export async function Mailer(msg) {
const pass = process.env.EMAIL_PASS
@@ -20,6 +21,7 @@ export async function Mailer(msg) {
})
}
+// use for Amazon
export async function Amazon(msg) {
const ses = new aws.SES({
apiVersion: "2010-12-01",
@@ -42,6 +44,7 @@ export async function Amazon(msg) {
}
}
+// use for Resend
const resend = new Resend(process.env.RESEND_API_KEY)
export async function ResendMsg(msg) {
diff --git a/mailers/forgotPasswordMailer.ts b/mailers/forgotPasswordMailer.ts
index 61a311c6..302d17fa 100644
--- a/mailers/forgotPasswordMailer.ts
+++ b/mailers/forgotPasswordMailer.ts
@@ -22,4 +22,6 @@ export async function forgotPasswordMailer({ to, token }: ResetPasswordMailer) {
//send the email
await ResendMsg(createForgotPasswordMsg(to, resetUrl))
+ // await Amazon(createForgotPasswordMsg(to, resetUrl)) # or amazon
+ // await Mailer(createForgotPasswordMsg(to, resetUrl)) # or gmail
}
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 6e30ed5b..a9c64d9d 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -24,13 +24,75 @@
"forms": "Forms",
"roles": "Roles",
"admin": "Admin",
- "help": "Help"
+ "help": "Help",
+ "tooltips": {
+ "dashboard": "Home dashboard",
+ "projects": "View all projects",
+ "invitations": "Project invitations",
+ "tasks": "View all tasks",
+ "notifications": "View all notifications",
+ "forms": "Manage metadata forms",
+ "roles": "Manage role categories",
+ "help": "Get help"
+ }
}
},
"main": {
"welcome": "Welcome",
"dashboard": {
- "totalcontributors": "Total Contributors"
+ "upcomingtask": "Upcoming Tasks",
+ "overduetask": "Overdue Tasks",
+ "notifications": "Notifications",
+ "totalcontributors": "Total Contributors",
+ "alltasks": "Tasks",
+ "projects": "Projects",
+ "forms": "Forms",
+ "invites": "Invitations",
+ "roles": "Roles",
+ "lastupdatedprojects": "Last Updated Projects",
+ "alltasksbutton": "All Tasks",
+ "allprojectsbutton": "All Projects",
+ "allnotificationsbutton": "All Notifications",
+ "tooltips": {
+ "upcomingtask": "Three upcoming tasks for all projects",
+ "overduetask": "Overdue Tasks",
+ "notifications": "Notifications",
+ "totalcontributors": "Total Contributors",
+ "alltasks": "Tasks",
+ "projects": "Projects",
+ "forms": "Forms",
+ "invites": "Invitations",
+ "roles": "Roles",
+ "lastupdatedprojects": "Last Updated Projects"
+ }
}
+ },
+ "projects": {
+ "title": "All Projects",
+ "createproject": "Create Project"
+ },
+ "widgets": {
+ "upcomingtask": "No upcoming task",
+ "overduetask": "No overdue task"
+ },
+ "breadcrumbs": {
+ "projects": "Projects",
+ "home": "Home",
+ "main": "Main",
+ "invites": "Invitations",
+ "tasks": "Tasks",
+ "notifications": "Notifications",
+ "forms": "Forms",
+ "roles": "Roles",
+ "updates": "Administration",
+ "help": "Help",
+ "summary": "Summary",
+ "settings": "Setttings",
+ "tags": "Tags",
+ "notes": "Notes",
+ "milestones": "Milestones",
+ "contributors": "Contributors",
+ "teams": "Teams",
+ "edit": "Settings"
}
}
diff --git a/public/locales/es/common.json b/public/locales/es/common.json
index e253d3f0..3994caa2 100644
--- a/public/locales/es/common.json
+++ b/public/locales/es/common.json
@@ -24,13 +24,75 @@
"forms": "Formularios",
"roles": "Roles",
"admin": "Administración",
- "help": "Ayuda"
+ "help": "Ayuda",
+ "tooltips": {
+ "dashboard": "Tablero de inicio",
+ "projects": "Ver todos los proyectos",
+ "invitations": "Invitaciones a proyectos",
+ "tasks": "Ver todas las tareas",
+ "notifications": "Ver todas las notificaciones",
+ "forms": "Gestionar formularios de metadatos",
+ "roles": "Gestionar categorías de roles",
+ "help": "Obtener ayuda"
+ }
}
},
"main": {
"welcome": "Bienvenido",
"dashboard": {
- "totalcontributors": "Contribuidores Totales"
+ "upcomingtask": "Tareas Pendientes",
+ "overduetask": "Tareas Vencidas",
+ "notifications": "Notificaciones",
+ "totalcontributors": "Contribuidores Totales",
+ "alltasks": "Tareas",
+ "projects": "Proyectos",
+ "forms": "Formularios",
+ "invites": "Invitaciones",
+ "roles": "Roles",
+ "lastupdatedprojects": "Últimos Proyectos Actualizados",
+ "alltasksbutton": "Ver todas las tareas",
+ "allprojectsbutton": "Ver todos los proyectos",
+ "allnotificationsbutton": "Ver todas las notificaciones",
+ "tooltips": {
+ "upcomingtask": "Tres tareas próximas para todos los proyectos",
+ "overduetask": "Tres tareas vencidas para todos los proyectos",
+ "notifications": "Tres notificaciones recientes para todos los proyectos",
+ "totalcontributors": "Total de contribuidores únicos en todos los proyectos",
+ "alltasks": "Porcentaje de tareas completadas",
+ "projects": "Total de proyectos",
+ "forms": "Total de plantillas de metadatos",
+ "invites": "Total de invitaciones a proyectos",
+ "roles": "Total de etiquetas de roles",
+ "lastupdatedprojects": "Tres proyectos recientemente actualizados"
+ }
}
+ },
+ "projects": {
+ "title": "Todos los proyectos",
+ "createproject": "Crear proyecto"
+ },
+ "widgets": {
+ "upcomingtask": "No hay tareas pendientes",
+ "overduetask": "No overdue task TEST"
+ },
+ "breadcrumbs": {
+ "projects": "Proyectos",
+ "home": "Inicio",
+ "main": "Tablero",
+ "invites": "Invitaciones",
+ "tasks": "Tareas",
+ "notifications": "Notificaciones",
+ "forms": "Formularios",
+ "roles": "Roles",
+ "updates": "Administración",
+ "help": "Ayuda",
+ "summary": "Resumen",
+ "settings": "Configuración",
+ "tags": "Etiquetas",
+ "notes": "Notas",
+ "milestones": "Hitos",
+ "contributors": "Colaboradores",
+ "teams": "Equipos",
+ "edit": "Ajustes"
}
}
diff --git a/src/comments/components/ChatBox.tsx b/src/comments/components/ChatBox.tsx
index c89af6dd..81cd95a2 100644
--- a/src/comments/components/ChatBox.tsx
+++ b/src/comments/components/ChatBox.tsx
@@ -6,6 +6,9 @@ import { CommentWithAuthor } from "src/core/types"
import { getContributorName } from "src/core/utils/getName"
import { useParam } from "@blitzjs/next"
import { useCurrentContributor } from "src/contributors/hooks/useCurrentContributor"
+import ReactMarkdown from "react-markdown"
+import remarkGfm from "remark-gfm"
+import remarkBreaks from "remark-breaks"
interface ChatBoxProps {
initialComments?: CommentWithAuthor[]
@@ -20,6 +23,7 @@ export default function ChatBox({
}: ChatBoxProps) {
const [comments, setComments] = useState(initialComments)
const [newComment, setNewComment] = useState("")
+ const [mode, setMode] = useState<"edit" | "preview">("edit")
const chatRef = useRef(null)
const [addCommentMutation] = useMutation(addComment)
const [markCommentsAsReadMutation] = useMutation(markAsRead)
@@ -51,7 +55,7 @@ export default function ChatBox({
projectMemberId: currentContributor.id,
})
.then(() => {
- if (refetchComments) refetchComments()
+ if (refetchComments) void refetchComments()
})
.catch((error) => {
console.error("Failed to mark comments as read:", error)
@@ -71,14 +75,14 @@ export default function ChatBox({
})
setComments((prev) => [...prev, { ...createdComment, commentReadStatus: [] }]) // Ensure new comment has empty commentReadStatus
setNewComment("") // Clear input field
- if (refetchComments) refetchComments() // Trigger refresh
+ if (refetchComments) await refetchComments() // Trigger refresh
} catch (error) {
console.error("Failed to send comment:", error)
}
}
return (
-
+
{comments.length > 0 ? (
comments.map((comment) => {
@@ -110,7 +114,36 @@ export default function ChatBox({
: "bg-secondary text-secondary-content"
}`}
>
- {comment.content || "[No Content]"}
+
+
(
+
+ ),
+ ul: ({ node, ...props }) => ,
+ ol: ({ node, ...props }) => ,
+ p: ({ node, ...props }) =>
,
+ code: ({ inline, className, children, ...props }) =>
+ inline ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ ),
+ }}
+ >
+ {comment.content || "[No Content]"}
+
+
)
@@ -120,19 +153,92 @@ export default function ChatBox({
)}
- {/* Input Field */}
-
-
setNewComment(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSendComment()} // Send on Enter key press
- />
-
- Send
-
+ {/* Input Field (Markdown with Preview) */}
+
+ {/* Toolbar */}
+
+
+
+ setMode("edit")}
+ >
+ Edit
+
+ setMode("preview")}
+ >
+ Preview
+
+
+
+ Supports{" "}
+
+ Markdown
+ {" "}
+ formatting.
+
+
+
+ Enter to send • Shift+Enter for newline
+
+
+
+ {mode === "edit" ? (
+
)
diff --git a/src/comments/mutations/addComment.ts b/src/comments/mutations/addComment.ts
index 8257424d..4b205314 100644
--- a/src/comments/mutations/addComment.ts
+++ b/src/comments/mutations/addComment.ts
@@ -141,16 +141,6 @@ export default resolver.pipe(
},
})
- console.log("📌 AssignedTo ProjectMemberId:", taskLog.assignedToId)
- console.log(
- "📌 Creating commentReadStatus for:",
- relevantProjectMembers.map((member) => ({
- commentId: comment.id,
- projectMemberId: member.id,
- read: member.id === projectMemberId,
- }))
- )
-
// Create CommentReadStatus records
await db.commentReadStatus.createMany({
data: relevantProjectMembers.map((member) => ({
diff --git a/src/contributors/components/ContributorForm.tsx b/src/contributors/components/ContributorForm.tsx
index d50959ba..b7984da1 100644
--- a/src/contributors/components/ContributorForm.tsx
+++ b/src/contributors/components/ContributorForm.tsx
@@ -4,14 +4,13 @@ import { z } from "zod"
import { LabelSelectField } from "src/core/components/fields/LabelSelectField"
import { useQuery } from "@blitzjs/rpc"
import { MemberPrivileges } from "@prisma/client"
-import LabeledTextField from "src/core/components/fields/LabeledTextField"
import AddRoleInput from "src/roles/components/AddRoleInput"
import getProjectManagerUserIds from "src/projectmembers/queries/getProjectManagerUserIds"
import TooltipWrapper from "src/core/components/TooltipWrapper"
import { WithContext as ReactTags, SEPARATORS } from "react-tag-input"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
-import Card from "src/core/components/Card"
+import LabeledTextAreaField from "src/core/components/fields/LabeledTextAreaField"
interface ContributorFormProps> extends FormProps {
projectId: number
@@ -73,7 +72,7 @@ export function ContributorForm>(props: Contributo
}
const handleTagClick = (index: number) => {
- console.log("The tag at index " + index + " was clicked")
+ //console.log("The tag at index " + index + " was clicked")
}
const onClearAll = () => {
@@ -99,8 +98,14 @@ export function ContributorForm>(props: Contributo
)
}}
onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault() // Prevent form submission on Enter
+ if (e.key === "Enter" && !e.shiftKey) {
+ const el = e.target as HTMLElement
+ const tagName = (el.tagName || "").toLowerCase()
+ const inTextarea = tagName === "textarea"
+ const inReactTags = !!el.closest(".react-tags-wrapper")
+ if (!inTextarea && !inReactTags) {
+ e.preventDefault() // Prevent accidental form submit from text inputs/buttons
+ }
}
}}
>
@@ -116,12 +121,12 @@ export function ContributorForm>(props: Contributo
opacity={1}
/>
{!isEdit && (
-
)}
/^\d+$/.test(value)
@@ -15,12 +16,15 @@ export const BreadcrumbLabel = ({
}) => {
const type = prevSegment ? segmentToTypeMap[prevSegment] : undefined
const fallback = segment.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
+ const { t } = (useTranslation as any)()
const isDynamic = type && isNumeric(segment)
const compositeKey = type ? `${type}:${segment}` : segment
const label = namesCache[compositeKey]
- const displayLabel = label ?? fallback
+ // If there is a cached/dynamic label, use it as-is. Otherwise translate known static segments.
+ const displayLabel =
+ label ?? t(`breadcrumbs.${segment.toLowerCase()}`, { defaultValue: fallback })
const truncated =
displayLabel.length > maxLength ? displayLabel.slice(0, maxLength) + "..." : displayLabel
diff --git a/src/core/components/BreadcrumbList.tsx b/src/core/components/BreadcrumbList.tsx
index 2425e26f..556c6a4d 100644
--- a/src/core/components/BreadcrumbList.tsx
+++ b/src/core/components/BreadcrumbList.tsx
@@ -1,25 +1,30 @@
import Link from "next/link"
import { BreadcrumbItem } from "../types"
+import { useTranslation } from "react-i18next"
-export const BreadcrumbList = ({ items }: { items: BreadcrumbItem[] }) => (
-
-
-
- Home
-
-
- {items.map((crumb, index) => (
-
- {crumb.isLast ? (
- {crumb.label}
- ) : crumb.isValid ? (
-
- {crumb.label}
-
- ) : (
- {crumb.label}
- )}
+export const BreadcrumbList = ({ items }: { items: BreadcrumbItem[] }) => {
+ const { t } = (useTranslation as any)()
+
+ return (
+
+
+
+ {t("breadcrumbs.home")}
+
- ))}
-
-)
+ {items.map((crumb, index) => (
+
+ {crumb.isLast ? (
+ {crumb.label}
+ ) : crumb.isValid ? (
+
+ {crumb.label}
+
+ ) : (
+ {crumb.label}
+ )}
+
+ ))}
+
+ )
+}
diff --git a/src/core/components/CollapseCard.tsx b/src/core/components/CollapseCard.tsx
index 63461812..0c715897 100644
--- a/src/core/components/CollapseCard.tsx
+++ b/src/core/components/CollapseCard.tsx
@@ -24,10 +24,10 @@ const CollapseCard = ({
const tooltipId = tooltipContent ? uuidv4() : undefined
return (
-
+
-
-
{title}
+
+
{title}
{tooltipContent && (
-
-
{children}
+
+
{children}
{actions &&
{actions}
}
diff --git a/src/core/components/DateFormat.tsx b/src/core/components/DateFormat.tsx
index 787d8e07..f5852f6d 100644
--- a/src/core/components/DateFormat.tsx
+++ b/src/core/components/DateFormat.tsx
@@ -1,27 +1,47 @@
import { useCurrentUser } from "src/users/hooks/useCurrentUser"
interface DateFormatProps {
- date?: Date | null
+ date?: Date | string | number | null
+ /**
+ * full: previous default (date + time)
+ * date: month long + day + year
+ * dateShort: month short + day + year (good for tables)
+ */
+ preset?: "full" | "date" | "dateShort"
+ /** Optional locale override; defaults to the current user's language */
+ locale?: string
}
-export default function DateFormat({ date }: DateFormatProps) {
+export default function DateFormat({ date, preset = "full", locale: localeProp }: DateFormatProps) {
const currentUser = useCurrentUser()
- const locale = currentUser ? currentUser.language : "en-US"
+ const locale = localeProp || (currentUser ? currentUser.language : "en-US")
- return (
- <>
- {" "}
- {date
- ? date.toLocaleDateString(locale, {
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- //second: "2-digit",
- hour12: false, // Use 24-hour format
- })
- : ""}
- >
- )
+ if (date == null) return <>>
+
+ const d = date instanceof Date ? date : new Date(date)
+
+ const presets: Record, Intl.DateTimeFormatOptions> = {
+ full: {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ },
+ date: {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ },
+ dateShort: {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ },
+ }
+
+ const options = presets[preset] ?? presets.full
+
+ return <>{new Intl.DateTimeFormat(locale, options).format(d)}>
}
diff --git a/src/core/components/Modal.tsx b/src/core/components/Modal.tsx
index 9e37266b..5757be66 100644
--- a/src/core/components/Modal.tsx
+++ b/src/core/components/Modal.tsx
@@ -13,7 +13,10 @@ const Modal = ({ children, open, size = "max-w-lg" }: Props) => {
(
-
+
)}
className="fixed inset-0 flex items-center justify-center z-[1050] overflow-y-auto"
>
diff --git a/src/core/components/ToggleModal.tsx b/src/core/components/ToggleModal.tsx
index b7ba1cb2..da1c6d23 100644
--- a/src/core/components/ToggleModal.tsx
+++ b/src/core/components/ToggleModal.tsx
@@ -40,7 +40,7 @@ const ToggleModal = ({
{buttonLabel}
-
+
{modalTitle}
{children}
@@ -54,6 +54,7 @@ const ToggleModal = ({
+
)
diff --git a/src/core/components/fields/LabelSelectField.tsx b/src/core/components/fields/LabelSelectField.tsx
index 2d21be35..88f71099 100644
--- a/src/core/components/fields/LabelSelectField.tsx
+++ b/src/core/components/fields/LabelSelectField.tsx
@@ -41,7 +41,12 @@ export const LabelSelectField = forwardRefLoading...
}
-
+ const { t } = (useTranslation as any)()
return (
@@ -30,7 +31,7 @@ export default function Sidebar({
} max-w-[15ch] whitespace-nowrap overflow-ellipsis`}
title={sidebarTitle}
>
- {sidebarTitle ? sidebarTitle : "Home"}
+ {sidebarTitle ? sidebarTitle : t("sidebar.project.home")}
{expanded ? (
diff --git a/src/core/components/sidebar/SidebarTooltips.tsx b/src/core/components/sidebar/SidebarTooltips.tsx
index 68a7adf3..c079cad9 100644
--- a/src/core/components/sidebar/SidebarTooltips.tsx
+++ b/src/core/components/sidebar/SidebarTooltips.tsx
@@ -1,38 +1,39 @@
import React from "react"
import TooltipWrapper from "../TooltipWrapper"
-
-const tooltipContents = [
- { id: "project-dashboard-tooltip", content: "Project dashboard" },
- { id: "project-tags-tooltip", content: "Track members, milestones, and tasks" },
- { id: "project-tasks-tooltip", content: "Project tasks" },
- { id: "project-milestones-tooltip", content: "Group and track tasks" },
- {
- id: "project-projectMembers-tooltip",
- content: "Manage project members",
- },
- { id: "project-teams-tooltip", content: "Manage project teams" },
- {
- id: "project-credit-tooltip",
- content: "Manage project roles",
- },
- { id: "project-form-tooltip", content: "Manage project form metadata" },
- { id: "project-summary-tooltip", content: "View project summary" },
- {
- id: "project-settings-tooltip",
- content: "Manage project settings",
- },
- { id: "dashboard-tooltip", content: "Home dashboard" },
- { id: "projects-tooltip", content: "View all projects" },
- { id: "tasks-tooltip", content: "View all tasks" },
- { id: "forms-tooltip", content: "Manage metadata forms" },
- { id: "notifications-tooltip", content: "View all notifications" },
- { id: "roles-tooltip", content: "Manage role categories" },
- { id: "help-tooltip", content: "Get help" },
- { id: "project-notification-tooltip", content: "Project notifications" },
- { id: "invite-tooltip", content: "Project invitations" },
-]
+import { useTranslation } from "react-i18next"
const SidebarTooltips = () => {
+ const { t } = (useTranslation as any)()
+ const tooltipContents = [
+ { id: "project-dashboard-tooltip", content: "Project dashboard" },
+ { id: "project-tasks-tooltip", content: "Project tasks" },
+ { id: "project-milestones-tooltip", content: "Group and track tasks" },
+ {
+ id: "project-projectMembers-tooltip",
+ content: "Manage project members",
+ },
+ { id: "project-teams-tooltip", content: "Manage project teams" },
+ {
+ id: "project-credit-tooltip",
+ content: "Manage project roles",
+ },
+ { id: "project-form-tooltip", content: "Manage project form metadata" },
+ { id: "project-summary-tooltip", content: "View project summary" },
+ {
+ id: "project-settings-tooltip",
+ content: "Manage project settings",
+ },
+ { id: "dashboard-tooltip", content: t("sidebar.home.tooltips.dashboard") },
+ { id: "projects-tooltip", content: "View all projects" },
+ { id: "tasks-tooltip", content: "View all tasks" },
+ { id: "forms-tooltip", content: "Manage metadata forms" },
+ { id: "notifications-tooltip", content: "View all notifications" },
+ { id: "roles-tooltip", content: "Manage role categories" },
+ { id: "help-tooltip", content: "Get help" },
+ { id: "project-notification-tooltip", content: "Project notifications" },
+ { id: "invite-tooltip", content: "Project invitations" },
+ ]
+
return (
<>
{tooltipContents.map((tooltip) => (
diff --git a/src/core/hooks/useSidebar.ts b/src/core/hooks/useSidebar.ts
index abd2465a..062d2abc 100644
--- a/src/core/hooks/useSidebar.ts
+++ b/src/core/hooks/useSidebar.ts
@@ -45,12 +45,12 @@ export const getSidebarState = (
)
})
return {
- sidebarTitle: "Home",
+ sidebarTitle: t("sidebar.project.home"),
sidebarItems,
}
} else {
return {
- sidebarTitle: "Home",
+ sidebarTitle: t("sidebar.project.home"),
sidebarItems: HomeSidebarItems(t).filter((_, index) => index !== 7),
}
}
diff --git a/src/core/types.ts b/src/core/types.ts
index a8e17b99..916b2b93 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -174,6 +174,13 @@ export type BreadcrumbEntityType =
| "contributor"
| "form"
+export const BREADCRUMB_I18N_KEYS = {
+ projects: "breadcrumbs.projects",
+ home: "breadcrumbs.home",
+} as const
+
+export type BreadcrumbKnownKey = keyof typeof BREADCRUMB_I18N_KEYS
+
export type BreadcrumbItem = {
label: ReactNode
href: string
diff --git a/src/forms/components/FormPlayground.tsx b/src/forms/components/FormPlayground.tsx
index 7b495a11..0539da2c 100644
--- a/src/forms/components/FormPlayground.tsx
+++ b/src/forms/components/FormPlayground.tsx
@@ -44,8 +44,8 @@ const FormPlayground: React.FC = ({
saveForm(state)
}
const handleChange = (newSchema: object, newUiSchema: object) => {
- console.log("🧪 incoming schema", newSchema)
- console.log("🧪 incoming uischema", newUiSchema)
+ //("🧪 incoming schema", newSchema)
+ //console.log("🧪 incoming uischema", newUiSchema)
setState({
schema: newSchema,
diff --git a/src/invites/components/AllInvitesList.tsx b/src/invites/components/AllInvitesList.tsx
index d7449984..7b3f3cb2 100644
--- a/src/invites/components/AllInvitesList.tsx
+++ b/src/invites/components/AllInvitesList.tsx
@@ -3,6 +3,7 @@ import { useQuery } from "@blitzjs/rpc"
import getInvites from "../queries/getInvites"
import Table from "src/core/components/Table"
import { InvitePMColumns } from "../tables/columns/InvitePMColumns"
+import Card from "src/core/components/Card"
export const AllInvitesList = () => {
const projectId = useParam("projectId", "number")
@@ -13,8 +14,8 @@ export const AllInvitesList = () => {
})
return (
-
+
-
+
)
}
diff --git a/src/invites/components/InvitesList.tsx b/src/invites/components/InvitesList.tsx
index cba73a94..96350de3 100644
--- a/src/invites/components/InvitesList.tsx
+++ b/src/invites/components/InvitesList.tsx
@@ -14,7 +14,7 @@ export const InvitesListView = ({ invites }) => {
export const InvitesList = ({ currentUser }) => {
// Get invitations
const [invites] = useQuery(getInvites, {
- where: { email: currentUser!.email.toLowerCase() },
+ where: { email: { equals: currentUser!.email, mode: "insensitive" } },
orderBy: { id: "asc" },
include: { project: true },
})
diff --git a/src/invites/hooks/useInviteContributor.ts b/src/invites/hooks/useInviteContributor.ts
index 338e96b9..21b98862 100644
--- a/src/invites/hooks/useInviteContributor.ts
+++ b/src/invites/hooks/useInviteContributor.ts
@@ -13,17 +13,26 @@ export function useInviteContributor(projectId: number) {
const router = useRouter()
const currentUser = useCurrentUser()
- const handleEmailSending = async (emailData, successMessage, errorMessage) => {
+ const handleEmailSending = async (emailData, successMessage, errorMessage, silent = false) => {
const emailSent = await sendInvitationEmail(emailData)
+ if (silent) {
+ return emailSent
+ }
if (emailSent) {
toast.success(successMessage)
} else {
console.error(errorMessage)
toast.error(errorMessage)
}
+ return emailSent
}
- const handleSubmit = async (values: any) => {
+ const handleSubmit = async (
+ values: any,
+ options?: { silent?: boolean; skipRedirect?: boolean }
+ ) => {
+ const silent = !!options?.silent
+ const skipRedirect = !!options?.skipRedirect
try {
const projectMember = await createInviteMutation({
projectId: projectId,
@@ -40,35 +49,45 @@ export function useInviteContributor(projectId: number) {
switch (projectMember.code) {
case "already_added":
+ if (silent) {
+ return { ok: false, reason: "already_added" }
+ }
return { [FORM_ERROR]: "User is already a contributor on the project." }
case "restore_possible":
await handleEmailSending(
createReassignmentInvitation(values, currentUser, projectMember.projectmember),
"Reassignment invitation sent to the contributor!",
- "Failed to send reassignment email"
+ "Failed to send reassignment email",
+ silent
)
- break
+ return { ok: true, code: projectMember.code }
case "invite_sent":
await handleEmailSending(
createNewInvitation(values, currentUser, projectMember.projectmember),
"Contributor invited to the project!",
- "Failed to send invitation email"
+ "Failed to send invitation email",
+ silent
)
- break
+ return { ok: true, code: projectMember.code }
default:
- toast.error("Unexpected response code.")
- break
+ if (!silent) toast.error("Unexpected response code.")
+ return { ok: false, code: projectMember.code }
}
- // Redirect to ContributorsPage after handling the invitation
- await router.push(Routes.ContributorsPage({ projectId }))
+ if (!skipRedirect) {
+ await router.push(Routes.ContributorsPage({ projectId }))
+ }
} catch (error: any) {
console.error(error)
+ if (silent) {
+ return { ok: false, reason: error?.toString?.() ?? "unknown_error" }
+ }
return { [FORM_ERROR]: error.toString() }
}
+ return { ok: true }
}
return handleSubmit
diff --git a/src/invites/mutations/acceptInvite.ts b/src/invites/mutations/acceptInvite.ts
index 2783f365..252f3bb7 100644
--- a/src/invites/mutations/acceptInvite.ts
+++ b/src/invites/mutations/acceptInvite.ts
@@ -1,5 +1,5 @@
import { resolver } from "@blitzjs/rpc"
-import db from "db"
+import db, { AutoAssignNew, CompletedAs } from "db"
import { AcceptInviteSchema } from "../schemas"
import sendNotification from "src/notifications/mutations/sendNotification"
import { getPrivilegeText } from "src/core/utils/getPrivilegeText"
@@ -59,6 +59,81 @@ export default resolver.pipe(
})
}
+ // --- Auto-assign this member to tasks marked for contributors or all ---
+
+ const tasksToAutoAssign = await db.task.findMany({
+ where: {
+ projectId: invite.projectId,
+ autoAssignNew: { in: [AutoAssignNew.ALL, AutoAssignNew.CONTRIBUTOR] },
+ },
+ select: {
+ id: true,
+ name: true,
+ deadline: true,
+ autoAssignNew: true,
+ createdBy: { include: { users: true } },
+ },
+ })
+
+ try {
+ if (tasksToAutoAssign.length > 0) {
+ await Promise.all(
+ tasksToAutoAssign.map((t) =>
+ db.task.update({
+ where: { id: t.id },
+ data: {
+ assignedMembers: { connect: { id: projectMember.id } },
+ },
+ })
+ )
+ )
+
+ // Create TaskLog entries for these auto-assignments
+ await Promise.all(
+ tasksToAutoAssign.map((t) =>
+ db.taskLog.create({
+ data: {
+ taskId: t.id,
+ assignedToId: projectMember.id,
+ completedAs: CompletedAs.INDIVIDUAL,
+ },
+ })
+ )
+ )
+
+ // Send a notification for each auto-assigned task
+ await Promise.all(
+ tasksToAutoAssign.map(async (t) => {
+ const createdByUsername = t.createdBy?.users?.[0]
+ ? t.createdBy.users[0].firstName && t.createdBy.users[0].lastName
+ ? `${t.createdBy.users[0].firstName} ${t.createdBy.users[0].lastName}`
+ : t.createdBy.users[0].username
+ : "Auto Assigned"
+
+ await sendNotification(
+ {
+ templateId: "taskAssigned",
+ recipients: [userId],
+ data: {
+ taskName: t.name || "Unnamed Task",
+ createdBy: createdByUsername,
+ deadline: t.deadline || null,
+ },
+ projectId: invite.projectId,
+ routeData: {
+ path: Routes.ShowTaskPage({ projectId: invite.projectId, taskId: t.id }).href,
+ },
+ },
+ ctx
+ )
+ })
+ )
+ }
+ } catch (err) {
+ console.error("[acceptInvite] Auto-assign flow error", err)
+ }
+ // --- end auto-assign block ---
+
// Get information for the notification
const project = await db.project.findFirst({ where: { id: invite.projectId } })
if (!project) throw new Error("Project not found")
diff --git a/src/invites/mutations/createInvite.ts b/src/invites/mutations/createInvite.ts
index 3aca3e88..74fc33e8 100644
--- a/src/invites/mutations/createInvite.ts
+++ b/src/invites/mutations/createInvite.ts
@@ -15,6 +15,8 @@ export default resolver.pipe(
resolver.zod(CreateInviteSchema),
resolver.authorize(),
async (input) => {
+ input.email = input.email.toLowerCase()
+
let textResult
// Check if the user is already a soft-deleted project member
@@ -24,7 +26,7 @@ export default resolver.pipe(
name: null,
deleted: true,
users: {
- some: { email: input.email }, // Match by email
+ some: { email: { equals: input.email, mode: "insensitive" } }, // Match by email
},
},
})
@@ -35,7 +37,7 @@ export default resolver.pipe(
data: {
projectId: input.projectId,
privilege: input.privilege,
- email: input.email,
+ email: input.email.toLowerCase(),
invitationCode: generateToken(20),
addedBy: input.addedBy,
tags: input.tags ?? undefined,
@@ -58,7 +60,7 @@ export default resolver.pipe(
projectId: input.projectId,
name: null,
users: {
- some: { email: input.email }, // Use `some` to query an array field
+ some: { email: { equals: input.email, mode: "insensitive" } }, // Use `some` to query an array field
},
},
})
@@ -74,7 +76,7 @@ export default resolver.pipe(
data: {
projectId: input.projectId,
privilege: input.privilege,
- email: input.email,
+ email: input.email.toLowerCase(),
invitationCode: generateToken(20),
addedBy: input.addedBy,
tags: input.tags ?? undefined,
diff --git a/src/invites/queries/getInviteByCode.ts b/src/invites/queries/getInviteByCode.ts
new file mode 100644
index 00000000..ae8f6a60
--- /dev/null
+++ b/src/invites/queries/getInviteByCode.ts
@@ -0,0 +1,24 @@
+import { resolver } from "@blitzjs/rpc"
+import db from "db"
+import { z } from "zod"
+
+// Validate input: the invite code should be a non-empty string
+const GetInviteByCode = z.object({
+ code: z.string().min(1),
+})
+
+export default resolver.pipe(resolver.zod(GetInviteByCode), async ({ code }) => {
+ // Look up the invite by its unique code
+ const invite = await db.invitation.findFirst({
+ where: { invitationCode: code },
+ select: {
+ id: true,
+ projectId: true,
+ email: true,
+ privilege: true,
+ roles: true,
+ },
+ })
+
+ return invite
+})
diff --git a/src/milestones/components/MilestoneForm.tsx b/src/milestones/components/MilestoneForm.tsx
index cefa1b5e..01155589 100644
--- a/src/milestones/components/MilestoneForm.tsx
+++ b/src/milestones/components/MilestoneForm.tsx
@@ -10,6 +10,7 @@ import { Tooltip } from "react-tooltip"
import { WithContext as ReactTags, SEPARATORS } from "react-tag-input"
import ToggleModal from "src/core/components/ToggleModal"
import CheckboxFieldTable from "src/core/components/fields/CheckboxFieldTable"
+import { useForm, useFormState } from "react-final-form"
export type Tag = {
id: string
@@ -34,6 +35,37 @@ export function MilestoneForm>(props: MilestoneFor
}
})
+ const taskAllIds = taskOptions.map((o) => o.id)
+
+ const TasksSelectControls: React.FC = () => {
+ const form = useForm()
+ const { values } = useFormState()
+ const allSelected =
+ Array.isArray(values?.taskIds) &&
+ values.taskIds.length === taskAllIds.length &&
+ taskAllIds.length > 0
+ return (
+
+
+ form.change("taskIds", taskAllIds)}
+ >
+ {`Select all tasks (${taskAllIds.length})`}
+
+ form.change("taskIds", [])}
+ >
+ Clear
+
+
+
+ )
+ }
+
// information for tags
const initialTags = props.initialValues?.tags ?? []
@@ -66,7 +98,7 @@ export function MilestoneForm>(props: MilestoneFor
}
const handleTagClick = (index: number) => {
- console.log("The tag at index " + index + " was clicked")
+ //console.log("The tag at index " + index + " was clicked")
}
const onClearAll = () => {
@@ -140,7 +172,10 @@ export function MilestoneForm>(props: MilestoneFor
buttonClassName="w-1/2"
saveButton={true}
>
-
+
+
+
+
diff --git a/src/notifications/schemas.ts b/src/notifications/schemas.ts
index 8eefd19f..aff2f6e2 100644
--- a/src/notifications/schemas.ts
+++ b/src/notifications/schemas.ts
@@ -19,6 +19,12 @@ export const addedToProjectSchema = z.object({
privilege: z.string(),
})
+export const addedToTeamSchema = z.object({
+ teamName: z.string(),
+ addedBy: z.string(),
+ projectName: z.string(),
+})
+
export const changedAssignmentSchema = z.object({
taskName: z.string(),
assignmentStatus: z.string(),
@@ -29,6 +35,7 @@ export const changedAssignmentSchema = z.object({
export const templateToSchemaMap: Record = {
taskAssigned: taskAssignedSchema,
addedToProject: addedToProjectSchema,
+ addedToTeam: addedToTeamSchema,
changedAssignment: changedAssignmentSchema,
commentMade: commentMadeSchema,
// Add other mappings
diff --git a/src/notifications/templates/addedToTeam.hbs b/src/notifications/templates/addedToTeam.hbs
new file mode 100644
index 00000000..fd5d5c56
--- /dev/null
+++ b/src/notifications/templates/addedToTeam.hbs
@@ -0,0 +1,8 @@
+
+
You have been added to the team
+ {{teamName}}
+ by
+ {{addedBy}}
+ in
+ {{projectName}}
+
\ No newline at end of file
diff --git a/src/pages/api/viewer-downloads/head.ts b/src/pages/api/viewer-downloads/head.ts
index 3c26775a..44dc6039 100644
--- a/src/pages/api/viewer-downloads/head.ts
+++ b/src/pages/api/viewer-downloads/head.ts
@@ -14,7 +14,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
return
}
- const filePath = path.join(process.cwd(), "viewer-builds", `viewer_${jobId}.zip`)
+ const filePath = path.join(process.cwd(), "viewer-builds", `Project_Summary_${jobId}.zip`)
if (fs.existsSync(filePath)) {
res.status(200).end()
diff --git a/src/pages/api/viewer-downloads.ts b/src/pages/api/viewer-downloads/index.ts
similarity index 74%
rename from src/pages/api/viewer-downloads.ts
rename to src/pages/api/viewer-downloads/index.ts
index d511ae6f..86d1180d 100644
--- a/src/pages/api/viewer-downloads.ts
+++ b/src/pages/api/viewer-downloads/index.ts
@@ -9,13 +9,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: "Missing or invalid jobId" })
}
- const zipPath = path.join(process.cwd(), "viewer-builds", `viewer_${jobId}.zip`)
+ const zipPath = path.join(process.cwd(), "viewer-builds", `Project_Summary_${jobId}.zip`)
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: "ZIP not found or not ready" })
}
res.setHeader("Content-Type", "application/zip")
- res.setHeader("Content-Disposition", `attachment; filename=viewer_${jobId}.zip`)
+ res.setHeader("Content-Disposition", `attachment; filename=Project_Summary_${jobId}.zip`)
fs.createReadStream(zipPath).pipe(res)
}
diff --git a/src/pages/help/index.tsx b/src/pages/help/index.tsx
index 85150b22..97c9f67f 100644
--- a/src/pages/help/index.tsx
+++ b/src/pages/help/index.tsx
@@ -74,14 +74,9 @@ const HelpPage = () => {
-
STAPLE Presentations
- In a workshop? Use our google doc to leave notes.{" "}
-
- Leave notes here.
- {" "}
+
STAPLE Emails
+ Invitations and notifications come from app@staple.science. The email may go to spam
+ and may need to be marked as safe to ensure all notification emails are received.
diff --git a/src/pages/invites/index.tsx b/src/pages/invites/index.tsx
index 6459ab23..2036cc40 100644
--- a/src/pages/invites/index.tsx
+++ b/src/pages/invites/index.tsx
@@ -7,8 +7,9 @@ import Modal from "src/core/components/Modal"
import { InviteForm } from "src/invites/components/InviteForm"
import { InviteFormSchema } from "src/invites/schemas"
import toast from "react-hot-toast"
-import createProjectMember from "src/projectmembers/mutations/createProjectMember"
-import { useMutation } from "@blitzjs/rpc"
+import acceptInvite from "src/invites/mutations/acceptInvite"
+import getInviteByCode from "src/invites/queries/getInviteByCode"
+import { useMutation, invoke } from "@blitzjs/rpc"
import { Routes } from "@blitzjs/next"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import Card from "src/core/components/Card"
@@ -22,27 +23,36 @@ const InvitesPage = () => {
const handleToggleNewInviteModal = () => {
setOpenNewInviteModal((prev) => !prev)
}
- const [createProjectMemberMutation] = useMutation(createProjectMember)
+ const [acceptInviteMutation] = useMutation(acceptInvite)
const [formError, setFormError] = useState(null)
const handleInviteCode = async (values) => {
try {
- const invite = await createProjectMemberMutation({
- invitationCode: values.invitationCode,
- userId: values.userId,
- })
+ if (!currentUser?.id) {
+ setFormError("You must be logged in to accept an invite.")
+ return
+ }
- if (invite.code == "no_code") {
+ // 1) Look up the invite by code via RPC invoke
+ const invite = await invoke(getInviteByCode, { code: values.invitationCode })
+ if (!invite) {
setFormError("No matching invitation code found.")
- } else {
- await toast.promise(Promise.resolve(invite), {
+ return
+ }
+
+ // 2) Accept the invite via the canonical mutation
+ const result = await toast.promise(
+ acceptInviteMutation({ id: invite.id, userId: currentUser.id }),
+ {
loading: "Adding project...",
success: "Project added!",
- error: "Failed add project...",
- })
- setFormError(null)
- await router.push(Routes.ShowProjectPage({ projectId: invite.projectId }))
- }
+ error: "Failed to add project...",
+ }
+ )
+
+ setFormError(null)
+ const projectId = (result?.id as number | undefined) ?? invite.projectId
+ await router.push(Routes.ShowProjectPage({ projectId }))
} catch (error: any) {
console.error(error)
setFormError("An error occurred during the submission. Please try again.")
@@ -78,7 +88,7 @@ const InvitesPage = () => {
schema={InviteFormSchema}
submitText="Add Project"
className="flex flex-col w-full"
- onSubmit={(data) => handleInviteCode({ ...data, userId: currentUser?.id })}
+ onSubmit={(data) => handleInviteCode(data)}
userId={currentUser!.id}
cancelText="Close"
onCancel={handleToggleNewInviteModal}
diff --git a/src/pages/projects/[projectId]/contributors/index.tsx b/src/pages/projects/[projectId]/contributors/index.tsx
index 40c0f2c2..00cb230a 100644
--- a/src/pages/projects/[projectId]/contributors/index.tsx
+++ b/src/pages/projects/[projectId]/contributors/index.tsx
@@ -68,7 +68,7 @@ const ContributorsPage = () => {
className="btn btn-primary mb-2 mt-4"
href={Routes.NewContributorPage({ projectId: projectId! })}
>
- Invite Contributor
+ Invite Contributor(s)
{
>
View Invitations
+
+ Go to Roles
+
+
)}
}>
diff --git a/src/pages/projects/[projectId]/contributors/invites.tsx b/src/pages/projects/[projectId]/contributors/invites.tsx
index a7495c97..7904dd0d 100644
--- a/src/pages/projects/[projectId]/contributors/invites.tsx
+++ b/src/pages/projects/[projectId]/contributors/invites.tsx
@@ -6,6 +6,8 @@ import Layout from "src/core/layouts/Layout"
import useProjectMemberAuthorization from "src/projectprivileges/hooks/UseProjectMemberAuthorization"
import { MemberPrivileges } from "@prisma/client"
import { AllInvitesList } from "src/invites/components/AllInvitesList"
+import { Tooltip } from "react-tooltip"
+import { InformationCircleIcon } from "@heroicons/react/24/outline"
// issue 37
const InvitesPagePM = () => {
@@ -16,26 +18,49 @@ const InvitesPagePM = () => {
// @ts-expect-error children are clearly passed below
- Invited Contributors
+
+ Invited Contributors
+
+
+
- Loading... }>
-
-
-
+
- Invite Contributor
+ Invite Contributor(s)
View Contributors
+
+ Go to Roles
+
+
+
Loading... }>
+
+
)
diff --git a/src/pages/projects/[projectId]/contributors/new.tsx b/src/pages/projects/[projectId]/contributors/new.tsx
index cbfd0def..81af3ce0 100644
--- a/src/pages/projects/[projectId]/contributors/new.tsx
+++ b/src/pages/projects/[projectId]/contributors/new.tsx
@@ -11,38 +11,93 @@ import { useInviteContributor } from "src/invites/hooks/useInviteContributor"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
import Card from "src/core/components/Card"
+import { toast } from "react-hot-toast"
function NewContributor() {
const projectId = useParam("projectId", "number")
const handleSubmit = useInviteContributor(projectId!)
const router = useRouter()
+ // Allow multiple emails separated by commas, semicolons, spaces, or new lines
+ const multiSubmit = async (values: any) => {
+ const raw = (values?.email ?? "").toString()
+ const emails = raw
+ .split(/[\n,;\s]+/)
+ .map((e) => e.trim())
+ .filter(Boolean)
+
+ if (emails.length === 0) return
+
+ const loadingToast = toast.loading(
+ `Inviting ${emails.length} contributor${emails.length === 1 ? "" : "s"}...`
+ )
+
+ const results: string[] = []
+ const errors: string[] = []
+
+ // Run sequentially to preserve existing server validations and rate limits
+ for (const email of emails) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const res = await handleSubmit({ ...values, email }, { silent: true, skipRedirect: true })
+ if (res?.ok) {
+ results.push(email)
+ } else {
+ errors.push(email)
+ }
+ } catch {
+ errors.push(email)
+ }
+ }
+
+ const succeeded = results.length
+ const failed = errors.length
+ const message =
+ failed === 0
+ ? `✅ Successfully invited ${succeeded} contributor${succeeded === 1 ? "" : "s"}.`
+ : `✅ Invited ${succeeded} contributor${
+ succeeded === 1 ? "" : "s"
+ }, ❌ failed for ${failed}: ${errors.join(", ")}.`
+
+ toast.dismiss(loadingToast)
+ toast.success(message)
+
+ // After processing, take the user to the invites page where they can see statuses
+ await router.push(Routes.InvitesPagePM({ projectId: projectId! }))
+ }
+
return (
- Invite New Contributor
+ Invite New Contributor(s)
- Enter the email of the contributor you would like to add to the project. They will receive
- an email inviting them to join the project. You will not be able to add them to tasks or
- teams until they accept their invitation.
+ Enter the email(s) of the contributor(s) you want to add to the project. You can paste
+ multiple emails separated by commas, spaces, semicolons, or new lines. They will receive
+ invitations, and you can add them to tasks or teams after they accept. All contributors
+ entered together will have the same privilege, roles, and tags. You can add or change these
+ values after they accept your invite.
+
+
+ Each person will receive an email from app@staple.science. The email may go to spam and may
+ need to be marked as safe to ensure all notification emails are received.
router.push(Routes.InvitesPagePM({ projectId: projectId! }))}
diff --git a/src/pages/projects/[projectId]/summary/index.tsx b/src/pages/projects/[projectId]/summary/index.tsx
index c22f3e62..d2532e41 100644
--- a/src/pages/projects/[projectId]/summary/index.tsx
+++ b/src/pages/projects/[projectId]/summary/index.tsx
@@ -74,7 +74,7 @@ const Summary = () => {
//console.log("Submitting form data:", data) // Debug log
try {
const updatedProject = await updateProjectMutation({
- id: project.id,
+ id: projectId!,
name: project.name,
metadata: data.formData,
})
@@ -111,7 +111,7 @@ const Summary = () => {
try {
// Reset the metadata to an empty object
await updateProjectMutation({
- id: project.id,
+ id: projectId!,
name: project.name,
metadata: {}, // Reset metadata to an empty object
})
@@ -242,21 +242,21 @@ const Summary = () => {
project. If your project uses a STAPLE schema, you can view, edit, reset, or download the
metadata below.
- {project.formVersionId ? (
+ {project.formVersion ? (
<>
-
+
@@ -265,7 +265,7 @@ const Summary = () => {
uiSchema={getJsonSchema(project.formVersion?.uiSchema)}
metadata={project.metadata}
label={"Edit Project Metadata"}
- classNames="btn-info"
+ classNames="btn-info whitespace-nowrap"
onSubmit={handleJsonFormSubmit}
onError={handleJsonFormError}
resetHandler={handleResetMetadata}
@@ -278,11 +278,39 @@ const Summary = () => {
)}
-
+
- Use this section to view and download the complete project summary. You can export the
- full project JSON or launch an interactive viewer (coming soon).
+ Use this section to generate and download the Interactive Project Summary Viewer and other
+ exports. Both options below are interactive websites but will only contain information
+ about the project at the time of download.
+
+
+ Project JSON: a machine‑readable version of
+ your project that can be used to index data on search engines like Google or loaded into
+ our external project summary viewer (
+
+ link
+
+ ).
+
+
+ Shareable Summary (recommended): a
+ human‑readable, shareable snapshot of your project. It’s the same experience as our
+ external viewer, but bundled so you can keep everything together and share it offline or
+ host it yourself. The download is a .zip — Windows users must unzip it first
+ (Right‑click → Extract All), then open Home.html inside the extracted
+ folder. Once you click{" "}
+ Generate Shareable Summary , a new button
+ will appear when the .zip file is ready for download.
+
+
+
{/* buttons */}
@@ -312,8 +340,8 @@ const Summary = () => {
- Projects that use official STAPLE schemas will show those schemas here for download. This
- is helpful for reuse, documentation, or validation in other systems.
+ Projects that use official STAPLE schemas will show the JSON-LD schemas here for download.
+ These downloads are helpful for reuse, documentation, or validation in other systems.
{hasStapleSchema && projectJsonLd && (
diff --git a/src/pages/projects/[projectId]/tasks/[taskId].tsx b/src/pages/projects/[projectId]/tasks/[taskId].tsx
index 40d28b0b..b16bfcbd 100644
--- a/src/pages/projects/[projectId]/tasks/[taskId].tsx
+++ b/src/pages/projects/[projectId]/tasks/[taskId].tsx
@@ -79,6 +79,13 @@ const TaskContent = () => {
<>>
)}
+
+ Copy Task
+
+
>
)}
diff --git a/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx b/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx
index 76bb9adb..906d5be4 100644
--- a/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx
+++ b/src/pages/projects/[projectId]/tasks/[taskId]/edit.tsx
@@ -53,6 +53,8 @@ export const EditTask = () => {
formVersionId: task.formVersionId,
rolesId: rolesId,
milestoneId: task.milestoneId,
+ autoAssignNew: task.autoAssignNew ?? "NONE",
+ anonymous: task.anonymous ?? false,
tags: Array.isArray(task.tags)
? (task.tags as Tag[]).map((tag) => ({
id: tag.key, // Use 'key' as the ID
diff --git a/src/pages/projects/[projectId]/tasks/new.tsx b/src/pages/projects/[projectId]/tasks/new.tsx
index 348cc4ac..bc3ecc2b 100644
--- a/src/pages/projects/[projectId]/tasks/new.tsx
+++ b/src/pages/projects/[projectId]/tasks/new.tsx
@@ -6,10 +6,12 @@ import { FormTaskSchema } from "src/tasks/schemas"
import createTask from "src/tasks/mutations/createTask"
import { TaskForm } from "src/tasks/components/TaskForm"
import { FORM_ERROR } from "final-form"
-import { Suspense } from "react"
+import getTask from "src/tasks/queries/getTask"
+import { useQuery } from "@blitzjs/rpc"
import Layout from "src/core/layouts/Layout"
import toast from "react-hot-toast"
import { useCurrentContributor } from "src/contributors/hooks/useCurrentContributor"
+import Link from "next/link"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
@@ -21,9 +23,25 @@ const NewTaskPage = () => {
const { projectMember: currentContributor } = useCurrentContributor(projectId)
const handleNewTask = async (values) => {
+ const normTags = Array.isArray(values.tags)
+ ? (values.tags as any[])
+ .map((t: any) => {
+ const key = (typeof t === "string" ? t : t?.key ?? t?.id ?? t?.value ?? t?.text ?? "")
+ .toString()
+ .trim()
+ const value = (typeof t === "string" ? t : t?.value ?? t?.text ?? t?.id ?? t?.key ?? "")
+ .toString()
+ .trim()
+ if (!key) return null
+ return { key, value: value || key }
+ })
+ .filter((x: any): x is { key: string; value: string } => !!x)
+ : undefined
+
try {
const task = await createTaskMutation({
...values,
+ ...(normTags && normTags.length ? { tags: normTags } : {}),
projectId: projectId!,
createdById: currentContributor!.id,
})
@@ -43,6 +61,141 @@ const NewTaskPage = () => {
}
}
+ const mapTaskToInitialValues = (task: any) => {
+ if (!task) return undefined
+
+ const initial: any = {}
+
+ // Only copy simple text/date/ids
+ if (typeof task.name === "string" && task.name.trim()) initial.name = `${task.name} (Copy)`
+ if ("description" in task) initial.description = task.description ?? null
+ if ("milestoneId" in task) initial.milestoneId = task.milestoneId ?? null
+ if ("deadline" in task && task.deadline) initial.deadline = new Date(task.deadline)
+ if ("formVersionId" in task) initial.formVersionId = task.formVersionId ?? null
+ if ("startDate" in task && task.startDate) initial.startDate = new Date(task.startDate)
+ if ("containerId" in task) initial.containerId = task.containerId ?? null
+ if ("anonymous" in task) initial.anonymous = task.anonymous ?? false
+
+ // Map contributors/teams from assignedMembers if present (kept minimal)
+ if (Array.isArray((task as any).assignedMembers)) {
+ const assigned = (task as any).assignedMembers as any[]
+ const contributors: number[] = []
+ const teams: number[] = []
+ for (const m of assigned) {
+ const id = typeof m?.id === "number" ? m.id : undefined
+ const usersLen = Array.isArray(m?.users) ? m.users.length : 0
+ if (typeof id === "number") {
+ if (usersLen > 1) teams.push(id)
+ else contributors.push(id)
+ }
+ }
+ if (contributors.length) initial.projectMembersId = contributors
+ if (teams.length) initial.teamsId = teams
+ }
+
+ // Map roles if included
+ if (Array.isArray((task as any).roles)) {
+ const rids = (task as any).roles
+ .map((r: any) => (typeof r?.id === "number" ? r.id : undefined))
+ .filter((n: any) => typeof n === "number")
+ if (rids.length) initial.rolesId = rids
+ }
+
+ // Copy simple numeric ID arrays (no relation includes)
+ const toNumArray = (arr: any) =>
+ Array.isArray(arr)
+ ? arr
+ .map((v) => (v == null ? v : Number(v)))
+ .filter((v) => typeof v === "number" && !Number.isNaN(v))
+ : undefined
+
+ const roleIds = toNumArray((task as any).rolesId)
+ if (roleIds && roleIds.length) initial.rolesId = roleIds
+
+ const memberIds = toNumArray((task as any).projectMembersId)
+ if (memberIds && memberIds.length) initial.projectMembersId = memberIds
+
+ const teamIds = toNumArray((task as any).teamsId)
+ if (teamIds && teamIds.length) initial.teamsId = teamIds
+
+ // Copy tags in UI shape expected by the tag input ({ id, text })
+ if (Array.isArray((task as any).tags)) {
+ const src = (task as any).tags as any[]
+ const uiTags = src
+ .map((t) => {
+ if (!t) return undefined
+ if (typeof t === "object") {
+ if (
+ "id" in t &&
+ "text" in t &&
+ typeof t.id === "string" &&
+ typeof t.text === "string"
+ ) {
+ return { id: t.id, text: t.text }
+ }
+ if (
+ "key" in t &&
+ "value" in t &&
+ typeof (t as any).key === "string" &&
+ typeof (t as any).value === "string"
+ ) {
+ return { id: (t as any).key, text: (t as any).value }
+ }
+ }
+ if (typeof t === "string") return { id: t, text: t }
+ return undefined
+ })
+ .filter(Boolean)
+ if (uiTags.length) initial.tags = uiTags as { id: string; text: string }[]
+ }
+
+ return initial
+ }
+
+ // Wrapper to handle copyFromTaskId and fetch the source task if needed
+ const TaskFormWrapper = ({ projectId, onSubmit, onCancel, schema }: any) => {
+ const router = useRouter()
+ const copyFromTaskIdParam = router.query.copyFromTaskId
+ const copyFromTaskId =
+ typeof copyFromTaskIdParam === "string"
+ ? parseInt(copyFromTaskIdParam, 10)
+ : Array.isArray(copyFromTaskIdParam)
+ ? parseInt(copyFromTaskIdParam[0]!, 10)
+ : undefined
+
+ const [sourceTask] = useQuery(
+ getTask,
+ {
+ where: { id: copyFromTaskId ?? -1 },
+ include: {
+ assignedMembers: { include: { users: { select: { id: true } } } },
+ roles: { select: { id: true } },
+ },
+ },
+ {
+ enabled: Boolean(copyFromTaskId),
+ suspense: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ }
+ )
+
+ const initialValues = mapTaskToInitialValues(sourceTask)
+
+ return (
+
+ )
+ }
+
return (
// @ts-expect-error children are clearly passed below
@@ -59,17 +212,38 @@ const NewTaskPage = () => {
className="z-[1099] ourtooltips"
/>
- Loading...
}>
- router.push(Routes.TasksPage({ projectId: projectId! }))}
- cancelText="Cancel"
+
+
+ Go to Forms
+
+
+ Go to Roles
+
+
+
-
+
+ router.push(Routes.TasksPage({ projectId: projectId! }))}
+ />
)
diff --git a/src/pages/projects/index.tsx b/src/pages/projects/index.tsx
index a3a86a2f..ec0994c9 100644
--- a/src/pages/projects/index.tsx
+++ b/src/pages/projects/index.tsx
@@ -5,7 +5,9 @@ import Layout from "src/core/layouts/Layout"
import ProjectsList from "src/projects/components/ProjectsList"
import SearchButton from "src/core/components/SearchButton"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
+import { ShieldCheckIcon, UserIcon } from "@heroicons/react/24/solid"
import { Tooltip } from "react-tooltip"
+import { useTranslation } from "react-i18next"
const ProjectsPage = () => {
const [searchTerm, setSearchTerm] = useState("")
@@ -13,13 +15,13 @@ const ProjectsPage = () => {
const handleSearch = (currentSearch) => {
setSearchTerm(currentSearch)
}
-
+ const { t } = (useTranslation as any)()
return (
// @ts-expect-error children are clearly passed below
- Projects
+ {t("projects.title")}
{
- Create Project
+ {t("projects.createproject")}
+
+
+
+ Project Manager
+
+
+
+ Contributor
+
+
+
diff --git a/src/projectmembers/mutations/createProjectMember.ts b/src/projectmembers/mutations/createProjectMember.ts
deleted file mode 100644
index f4e52111..00000000
--- a/src/projectmembers/mutations/createProjectMember.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { resolver } from "@blitzjs/rpc"
-import db from "db"
-import { CreateProjectMemberSchema } from "../schemas"
-import sendNotification from "src/notifications/mutations/sendNotification"
-import { getPrivilegeText } from "src/core/utils/getPrivilegeText"
-import { Routes } from "@blitzjs/next"
-
-export default resolver.pipe(
- resolver.zod(CreateProjectMemberSchema),
- resolver.authorize(),
- async ({ invitationCode, userId }, ctx) => {
- let textResult
-
- // Get the invitation information based on the invitation code
- const projectInvite = await db.invitation.findFirst({
- where: {
- invitationCode: invitationCode,
- },
- include: {
- roles: true,
- },
- })
-
- if (projectInvite) {
- // Create projectMember without privilege
- const projectMember = await db.projectMember.create({
- data: {
- projectId: projectInvite.projectId,
- users: {
- connect: { id: userId },
- },
- },
- })
-
- // Assign roles to the project member
- if (projectInvite.roles && projectInvite.roles.length > 0) {
- await db.projectMember.update({
- where: { id: projectMember.id },
- data: {
- roles: {
- connect: projectInvite.roles.map((role) => ({ id: role.id })),
- },
- },
- })
- }
-
- // Create the project privilege for the project member
- await db.projectPrivilege.create({
- data: {
- userId: userId,
- projectId: projectInvite.projectId,
- privilege: projectInvite.privilege,
- },
- })
-
- // Get information for the notification
- const project = await db.project.findFirst({ where: { id: projectInvite.projectId } })
-
- if (!project) {
- throw new Error("Project not found.")
- }
-
- // Send notification
- await sendNotification(
- {
- templateId: "addedToProject",
- recipients: [userId],
- data: {
- projectName: project.name,
- addedBy: projectInvite.addedBy,
- privilege: getPrivilegeText(projectInvite.privilege),
- },
- projectId: projectMember.projectId,
- routeData: {
- path: Routes.ShowProjectPage({ projectId: projectInvite.projectId }).href,
- },
- },
- ctx
- )
-
- // Delete invitation(s) for that email and project Id
- await db.invitation.deleteMany({
- where: {
- email: projectInvite.email,
- projectId: projectInvite.projectId,
- },
- })
- textResult = {
- code: "worked",
- projectId: projectInvite.projectId,
- }
- } else {
- textResult = {
- code: "no_code",
- }
- }
-
- return textResult
- }
-)
diff --git a/src/projects/components/AnnouncementForm.tsx b/src/projects/components/AnnouncementForm.tsx
index fbda33d7..526e9837 100644
--- a/src/projects/components/AnnouncementForm.tsx
+++ b/src/projects/components/AnnouncementForm.tsx
@@ -9,6 +9,7 @@ import { ProjectMemberWithUsers } from "src/core/types"
import { useSeparateProjectMembers } from "src/projectmembers/hooks/useSeparateProjectMembers"
import getProjectMembers from "src/projectmembers/queries/getProjectMembers"
import { z } from "zod"
+import { useForm, useFormState } from "react-final-form"
export function AnnouncementForm>(props: FormProps) {
const projectId = useParam("projectId", "number") // ✅ Fetch `projectId` at the component level
@@ -49,6 +50,67 @@ export function AnnouncementForm>(props: FormProps
}
})
+ const contributorAllIds = contributorOptions.map((o) => o.id)
+ const teamAllIds = teamOptions.map((o) => o.id)
+
+ const ContributorsSelectAll: React.FC = () => {
+ const form = useForm()
+ const { values } = useFormState()
+ const allSelected =
+ Array.isArray(values?.projectMembersId) &&
+ values.projectMembersId.length === contributorAllIds.length &&
+ contributorAllIds.length > 0
+ return (
+
+
+ form.change("projectMembersId", contributorAllIds)}
+ >
+ {`Select all contributors (${contributorAllIds.length})`}
+
+ form.change("projectMembersId", [])}
+ >
+ Clear
+
+
+
+ )
+ }
+
+ const TeamsSelectAll: React.FC = () => {
+ const form = useForm()
+ const { values } = useFormState()
+ const allSelected =
+ Array.isArray(values?.teamsId) &&
+ values.teamsId.length === teamAllIds.length &&
+ teamAllIds.length > 0
+ return (
+
+
+ form.change("teamsId", teamAllIds)}
+ >
+ {`Select all teams (${teamAllIds.length})`}
+
+ form.change("teamsId", [])}
+ >
+ Clear
+
+
+
+ )
+ }
+
return (
diff --git a/src/projects/components/ProjectCard.tsx b/src/projects/components/ProjectCard.tsx
index 3c9dbafe..9e8ad8a1 100644
--- a/src/projects/components/ProjectCard.tsx
+++ b/src/projects/components/ProjectCard.tsx
@@ -1,4 +1,7 @@
+// NOTE: Ensure global CSS import exists once in your app entry (e.g., _app.tsx):
+// import "react-tooltip/dist/react-tooltip.css"
import React from "react"
+import { ShieldCheckIcon, UserIcon } from "@heroicons/react/24/solid"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import remarkBreaks from "remark-breaks"
@@ -6,26 +9,52 @@ import Link from "next/link"
import { Routes } from "@blitzjs/next"
import DateFormat from "src/core/components/DateFormat"
-const ProjectCard = ({ project }) => (
-
-
-
{project.name}
-
-
-
- {project.description || ""}
-
+const ProjectCard = ({ project }: { project: any }) => {
+ const myPrivilege = project?.ProjectPrivilege?.[0]?.privilege
+ return (
+
+
+
+ {myPrivilege === "PROJECT_MANAGER" && (
+ <>
+
+ >
+ )}
+ {myPrivilege === "CONTRIBUTOR" && (
+ <>
+
+ >
+ )}
+ {project.name}
-
- Last update:
-
-
-
- Open
-
+
+
+
+ {project.description || ""}
+
+
+
+ Last update:
+
+
+
+ Open
+
+
-
-)
+ )
+}
export default ProjectCard
diff --git a/src/projects/components/ProjectSchemaInput.tsx b/src/projects/components/ProjectSchemaInput.tsx
index a576702e..9bb4f92a 100644
--- a/src/projects/components/ProjectSchemaInput.tsx
+++ b/src/projects/components/ProjectSchemaInput.tsx
@@ -80,6 +80,7 @@ export const ProjectSchemaInput = ({
const schemas = typeduserForms
.filter((form) => form.formVersion)
.flatMap((form) => form.formVersion!)
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
// Add "(Default)" to the name of the default form
const options = schemas.map((schema) => ({
@@ -91,12 +92,10 @@ export const ProjectSchemaInput = ({
}))
// Extra columns for the select table
- const versionNumber = schemas.map((schema) => schema.version)
-
- const extraData = versionNumber.map((version) => ({
- version: version,
+ const extraData = schemas.map((schema) => ({
+ version: schema.version,
+ date: new Date(schema.createdAt).toLocaleDateString(),
}))
-
const extraColumns = [
{
id: "version",
@@ -104,6 +103,12 @@ export const ProjectSchemaInput = ({
accessorKey: "version",
cell: (info) =>
{info.getValue()} ,
},
+ {
+ id: "date",
+ header: "Created",
+ accessorKey: "date",
+ cell: (info) =>
{info.getValue()} ,
+ },
]
// Handle radio button selection
diff --git a/src/projects/components/ProjectsList.tsx b/src/projects/components/ProjectsList.tsx
index 784257d6..7e75e44b 100644
--- a/src/projects/components/ProjectsList.tsx
+++ b/src/projects/components/ProjectsList.tsx
@@ -44,6 +44,18 @@ export const ProjectsList = ({ searchTerm }) => {
const [{ projects, hasMore }] = usePaginatedQuery(getProjects, {
where: where,
+ include: currentUser?.id
+ ? {
+ ProjectPrivilege: {
+ where: { userId: currentUser.id },
+ select: { userId: true, privilege: true },
+ },
+ }
+ : {
+ ProjectPrivilege: {
+ select: { userId: true, privilege: true },
+ },
+ },
orderBy: { id: "desc" },
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
diff --git a/src/projects/mutations/createProject.ts b/src/projects/mutations/createProject.ts
index 61167ac2..39889e34 100644
--- a/src/projects/mutations/createProject.ts
+++ b/src/projects/mutations/createProject.ts
@@ -77,8 +77,17 @@ export default resolver.pipe(
container: {
connect: { id: firstContainerId }, // put it in default to do
},
- description:
- "You added a description form to your project. You can complete that form by going to the project > clicking on settings in the left hand menu > and then edit form data.",
+ description: `### Next step: Complete your project description form
+
+You just added a **Project Description Form** to this project.
+
+**How to complete it:**
+1. Go to your **Project**.
+2. Click **Settings** in the left-hand menu.
+3. Open **Edit Form Data** and complete the required fields.
+
+_Why this matters:_ Completing this form helps make your project transparent and searchable via metadata.
+`,
createdBy: {
connect: { id: projectMember.id },
},
diff --git a/src/roles/components/ContributorsTab.tsx b/src/roles/components/ContributorsTab.tsx
index 59251bfc..937d2b07 100644
--- a/src/roles/components/ContributorsTab.tsx
+++ b/src/roles/components/ContributorsTab.tsx
@@ -1,13 +1,14 @@
import { Suspense } from "react"
import { useQuery } from "@blitzjs/rpc"
import React from "react"
-import { useParam } from "@blitzjs/next"
+import { Routes, useParam } from "@blitzjs/next"
import getProjectMembers from "src/projectmembers/queries/getProjectMembers"
import { MultiSelectProvider } from "../../core/components/fields/MultiSelectContext"
import { RoleContributorTable } from "./RoleContributorTable"
import { AddRoleModal } from "./AddRoleModal"
import { ProjectMemberWithUsersAndRoles } from "src/core/types"
-import Card from "src/core/components/Card"
+import Link from "next/link"
+import { Tooltip } from "react-tooltip"
const ContributorsTab = () => {
const projectId = useParam("projectId", "number")
@@ -33,7 +34,19 @@ const ContributorsTab = () => {
Loading... }>
-
+
+
+ Go to Create Roles
+
+
{
const processedData = contributors.map((contributor) => ({
@@ -10,5 +10,7 @@ export const RoleContributorTable = ({ contributors }) => {
roleNames: contributor.roles ? contributor.roles.map((role) => role.name).join(", ") : "",
}))
- return
+ const columns = useRoleContributorTableColumns(processedData)
+
+ return
}
diff --git a/src/roles/components/RoleSelect.tsx b/src/roles/components/RoleSelect.tsx
index 97d0a59a..0c2aedbc 100644
--- a/src/roles/components/RoleSelect.tsx
+++ b/src/roles/components/RoleSelect.tsx
@@ -3,6 +3,7 @@ import { useQuery } from "@blitzjs/rpc"
import CheckboxFieldTable from "src/core/components/fields/CheckboxFieldTable"
import getRoles from "src/roles/queries/getRoles"
import { RoleWithUser } from "src/core/types"
+import { useForm, useFormState } from "react-final-form"
interface RoleSelectProps {
projectManagerIds: number[]
@@ -32,13 +33,41 @@ const RoleSelect: React.FC = ({ projectManagerIds }) => {
},
]
+ const allRoleIds = roleOptions.map((o) => o.id)
+ const form = useForm()
+ const { values } = useFormState()
+ const allSelected =
+ Array.isArray((values as any)?.rolesId) &&
+ (values as any).rolesId.length === allRoleIds.length &&
+ allRoleIds.length > 0
+
return (
-
+
+
+
+ form.change("rolesId", allRoleIds)}
+ >
+ {`Select all roles (${allRoleIds.length})`}
+
+ form.change("rolesId", [])}
+ >
+ Clear
+
+
+
+
+
)
}
diff --git a/src/roles/components/RoleTaskTable.tsx b/src/roles/components/RoleTaskTable.tsx
index 76c74cb6..bac58162 100644
--- a/src/roles/components/RoleTaskTable.tsx
+++ b/src/roles/components/RoleTaskTable.tsx
@@ -1,5 +1,5 @@
import Table from "src/core/components/Table"
-import { RoleTaskTableColumns } from "src/roles/tables/columns/RoleTaskTableColumns"
+import { useRoleTaskTableColumns } from "src/roles/tables/columns/RoleTaskTableColumns"
export const RoleTaskTable = ({ tasks }) => {
const processedData = tasks.map((task) => ({
@@ -9,5 +9,7 @@ export const RoleTaskTable = ({ tasks }) => {
rolesNames: task.roles ? task.roles.map((role) => role.name).join(", ") : "",
}))
- return
+ const columns = useRoleTaskTableColumns(processedData)
+
+ return
}
diff --git a/src/roles/components/TasksTab.tsx b/src/roles/components/TasksTab.tsx
index c6ef9b01..ef57df3d 100644
--- a/src/roles/components/TasksTab.tsx
+++ b/src/roles/components/TasksTab.tsx
@@ -1,11 +1,13 @@
import { Suspense } from "react"
import { useQuery } from "@blitzjs/rpc"
import getTasks from "src/tasks/queries/getTasks"
-import { useParam } from "@blitzjs/next"
+import { Routes, useParam } from "@blitzjs/next"
import React from "react"
import { AddRoleModal } from "./AddRoleModal"
import { RoleTaskTable } from "./RoleTaskTable"
import { MultiSelectProvider } from "src/core/components/fields/MultiSelectContext"
+import Link from "next/link"
+import { Tooltip } from "react-tooltip"
const TasksTab = () => {
const projectId = useParam("projectId", "number")
@@ -22,7 +24,19 @@ const TasksTab = () => {
Loading... }>
-
+
+
+ Go to Create Roles
+
+
diff --git a/src/roles/tables/columns/RoleContributorTableColumns.tsx b/src/roles/tables/columns/RoleContributorTableColumns.tsx
index 59d294e0..ad3d143b 100644
--- a/src/roles/tables/columns/RoleContributorTableColumns.tsx
+++ b/src/roles/tables/columns/RoleContributorTableColumns.tsx
@@ -1,6 +1,7 @@
-import React from "react"
+import React, { useMemo } from "react"
import { createColumnHelper } from "@tanstack/react-table"
import { MultiSelectCheckbox } from "src/core/components/fields/MultiSelectCheckbox"
+import { SelectAllCheckbox } from "src/core/components/fields/SelectAllCheckbox"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
@@ -15,47 +16,41 @@ export type ContributorRoleData = {
const columnHelper = createColumnHelper
()
// ColumnDefs
-export const RoleContributorTableColumns = [
- columnHelper.accessor("username", {
- id: "username",
- cell: (info) => {info.getValue()} ,
- header: "Username",
- }),
+export const useRoleContributorTableColumns = (data: ContributorRoleData[]) => {
+ const allIds = useMemo(() => data.map((item) => item.id), [data])
- columnHelper.accessor("firstname", {
- id: "firstname",
- cell: (info) => {info.getValue()} ,
- header: "First Name",
- }),
- columnHelper.accessor("lastname", {
- id: "lastaname",
- cell: (info) => {info.getValue()} ,
- header: "Last Name",
- }),
- columnHelper.accessor("roleNames", {
- id: "roleNames",
- header: "Roles",
- cell: (info) => {info.getValue()}
,
- enableColumnFilter: true,
- }),
- columnHelper.accessor("id", {
- id: "multiple",
- enableColumnFilter: false,
- enableSorting: false,
- cell: (info) => ,
- header: () => (
-
- Select
-
-
-
- ),
- }),
-]
+ return useMemo(
+ () => [
+ columnHelper.accessor("username", {
+ id: "username",
+ cell: (info) => {info.getValue()} ,
+ header: "Username",
+ }),
+
+ columnHelper.accessor("firstname", {
+ id: "firstname",
+ cell: (info) => {info.getValue()} ,
+ header: "First Name",
+ }),
+ columnHelper.accessor("lastname", {
+ id: "lastname",
+ cell: (info) => {info.getValue()} ,
+ header: "Last Name",
+ }),
+ columnHelper.accessor("roleNames", {
+ id: "roleNames",
+ header: "Roles",
+ cell: (info) => {info.getValue()}
,
+ enableColumnFilter: true,
+ }),
+ columnHelper.accessor("id", {
+ id: "multiple",
+ enableColumnFilter: false,
+ enableSorting: false,
+ cell: (info) => ,
+ header: () => ,
+ }),
+ ],
+ [allIds]
+ )
+}
diff --git a/src/roles/tables/columns/RoleTaskTableColumns.tsx b/src/roles/tables/columns/RoleTaskTableColumns.tsx
index ec60b3e2..8844d230 100644
--- a/src/roles/tables/columns/RoleTaskTableColumns.tsx
+++ b/src/roles/tables/columns/RoleTaskTableColumns.tsx
@@ -1,9 +1,10 @@
-import React from "react"
+import React, { useMemo } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import remarkBreaks from "remark-breaks"
import { createColumnHelper } from "@tanstack/react-table"
import { MultiSelectCheckbox } from "../../../core/components/fields/MultiSelectCheckbox"
+import { SelectAllCheckbox } from "../../../core/components/fields/SelectAllCheckbox"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
@@ -17,49 +18,43 @@ export type RoleTaskTableData = {
const columnHelper = createColumnHelper()
// ColumnDefs
-export const RoleTaskTableColumns = [
- columnHelper.accessor("name", {
- id: "name",
- cell: (info) => {info.getValue()} ,
- header: "Name",
- }),
- columnHelper.accessor("description", {
- id: "description",
- cell: (info) => {
- const value = info.getValue() || ""
- const truncated = value.length > 200 ? `${value.slice(0, 200)}...` : value
- return (
-
- {truncated}
-
- )
- },
- header: "Instructions",
- }),
- columnHelper.accessor("rolesNames", {
- id: "rolesNames",
- header: "Roles",
- cell: (info) => {info.getValue()} ,
- enableColumnFilter: true,
- }),
- columnHelper.accessor("id", {
- id: "multiple",
- enableColumnFilter: false,
- enableSorting: false,
- cell: (info) => ,
- header: () => (
-
- Select
-
-
-
- ),
- }),
-]
+export const useRoleTaskTableColumns = (data: RoleTaskTableData[]) => {
+ const allIds = useMemo(() => data.map((item) => item.id), [data])
+
+ return useMemo(
+ () => [
+ columnHelper.accessor("name", {
+ id: "name",
+ cell: (info) => {info.getValue()} ,
+ header: "Name",
+ }),
+ columnHelper.accessor("description", {
+ id: "description",
+ cell: (info) => {
+ const value = info.getValue() || ""
+ const truncated = value.length > 200 ? `${value.slice(0, 200)}...` : value
+ return (
+
+ {truncated}
+
+ )
+ },
+ header: "Instructions",
+ }),
+ columnHelper.accessor("rolesNames", {
+ id: "rolesNames",
+ header: "Roles",
+ cell: (info) => {info.getValue()} ,
+ enableColumnFilter: true,
+ }),
+ columnHelper.accessor("id", {
+ id: "multiple",
+ enableColumnFilter: false,
+ enableSorting: false,
+ cell: (info) => ,
+ header: () => ,
+ }),
+ ],
+ [allIds]
+ )
+}
diff --git a/src/summary/queries/getProjectData.ts b/src/summary/queries/getProjectData.ts
index 94b99225..24fcba63 100644
--- a/src/summary/queries/getProjectData.ts
+++ b/src/summary/queries/getProjectData.ts
@@ -11,20 +11,133 @@ const GetProjectData = z.object({
export default resolver.pipe(resolver.zod(GetProjectData), resolver.authorize(), async ({ id }) => {
const project = await db.project.findFirst({
where: { id },
- include: {
- formVersion: true,
- milestones: true,
+ select: {
+ // project timestamps
+ createdAt: true,
+ updatedAt: true,
+ // project metadata
+ name: true,
+ description: true,
+ abstract: true,
+ keywords: true,
+ citation: true,
+ publisher: true,
+ identifier: true,
+ // relations we want
tasks: {
- include: {
- milestone: true,
- roles: true,
- taskLogs: true,
- formVersion: true,
+ select: {
+ id: true,
+ formVersionId: true,
+ // task timestamps
+ createdAt: true,
+ updatedAt: true,
+ // createdBy & createdById
+ createdById: true,
+ createdBy: {
+ select: {
+ id: true,
+ name: true,
+ deleted: true,
+ users: {
+ select: {
+ institution: true,
+ username: true,
+ firstName: true,
+ lastName: true,
+ email: true,
+ },
+ },
+ },
+ },
+ // metadata
+ deadline: true,
+ startDate: true,
+ name: true,
+ description: true,
+ status: true,
+ // needed for anonymization downstream
+ anonymous: true,
+ milestoneId: true,
+ // relations on task
+ milestone: {
+ select: {
+ createdAt: true,
+ updatedAt: true,
+ name: true,
+ description: true,
+ startDate: true,
+ endDate: true,
+ // include minimal for back reference tasks is omitted to avoid cycles
+ },
+ },
+ formVersion: {
+ select: {
+ name: true,
+ schema: true,
+ uiSchema: true,
+ createdAt: true,
+ // keep relations shallow to avoid huge payloads
+ },
+ },
+ taskLogs: {
+ select: {
+ id: true,
+ createdAt: true,
+ status: true,
+ metadata: true,
+ completedAs: true,
+ assignedToId: true,
+ assignedTo: {
+ select: {
+ id: true,
+ name: true,
+ deleted: true,
+ },
+ },
+ completedById: true,
+ completedBy: {
+ select: {
+ id: true,
+ name: true,
+ deleted: true,
+ },
+ },
+ },
+ },
+ roles: {
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ taxonomy: true,
+ },
+ },
+ },
+ },
+ milestones: {
+ select: {
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+ name: true,
+ description: true,
+ startDate: true,
+ endDate: true,
+ // tasks of this milestone (ids only to prevent bloat)
+ task: {
+ select: {
+ id: true,
+ },
+ },
},
},
projectMembers: {
- // where: { deleted: false }, include people but anonymize
- include: {
+ // "keep project members like I have it"
+ select: {
+ id: true,
+ createdAt: true,
+ name: true,
+ deleted: true,
users: {
select: {
institution: true,
@@ -34,13 +147,24 @@ export default resolver.pipe(resolver.zod(GetProjectData), resolver.authorize(),
email: true,
},
},
- roles: true,
+ roles: {
+ select: { id: true, name: true, description: true, taxonomy: true },
+ },
+ },
+ },
+ metadata: true,
+ formVersion: {
+ select: {
+ name: true,
+ schema: true,
+ uiSchema: true,
+ createdAt: true,
+ // omit form, tasks, projects to avoid recursion unless needed later
},
},
},
})
if (!project) throw new NotFoundError()
-
return project
})
diff --git a/src/summary/utils/processProjectData.ts b/src/summary/utils/processProjectData.ts
index 092513cc..c9198cf2 100644
--- a/src/summary/utils/processProjectData.ts
+++ b/src/summary/utils/processProjectData.ts
@@ -24,6 +24,20 @@ export function cleanProjectData(project: any) {
return member
})
+ // Scrub task fields for anonymous tasks
+ if (Array.isArray(project.tasks)) {
+ project.tasks = project.tasks.map((task: any) => {
+ if (task && task.anonymous) {
+ return {
+ ...task,
+ name: "Anonymous task",
+ description: "Anonymous task",
+ }
+ }
+ return task
+ })
+ }
+
// Keep all task/user references as-is
return project
}
diff --git a/src/summary/utils/viewerWorker.js b/src/summary/utils/viewerWorker.js
index 0b544d33..f9e3b265 100644
--- a/src/summary/utils/viewerWorker.js
+++ b/src/summary/utils/viewerWorker.js
@@ -14,7 +14,7 @@ new QueueWorker(
"viewer-build",
async (job) => {
const { jobId, data } = job.data
- const jobFolder = path.join(buildOutputDir, `viewer_${jobId}`)
+ const jobFolder = path.join(buildOutputDir, `Project_Summary_${jobId}`)
// Ensure the buildOutputDir exists before creating the ZIP file
fs.mkdirSync(buildOutputDir, { recursive: true })
// Write project_summary.json
@@ -27,11 +27,11 @@ new QueueWorker(
// Inject data into HTML templates and copy to jobFolder
const templateFiles = [
- "index.html",
- "forms.html",
- "people_roles.html",
- "tasks.html",
- "timeline.html",
+ "Home.html",
+ "Form_Data.html",
+ "Contributors.html",
+ "Tasks.html",
+ "Events.html",
]
templateFiles.forEach((filename) => {
@@ -45,15 +45,13 @@ new QueueWorker(
})
// Zip the jobFolder contents
- const zipPath = path.join(buildOutputDir, `viewer_${jobId}.zip`)
+ const zipPath = path.join(buildOutputDir, `Project_Summary_${jobId}.zip`)
const output = fs.createWriteStream(zipPath)
const archive = archiver("zip", { zlib: { level: 9 } })
archive.pipe(output)
archive.directory(jobFolder, false)
await archive.finalize()
-
- console.log(`[ViewerWorker] ZIP created at ${zipPath}`)
},
{
connection: viewerQueue.opts.connection,
diff --git a/src/tags/components/TagPeopleTable.tsx b/src/tags/components/TagPeopleTable.tsx
index 140bc7d9..dc8471b6 100644
--- a/src/tags/components/TagPeopleTable.tsx
+++ b/src/tags/components/TagPeopleTable.tsx
@@ -12,8 +12,6 @@ export const TagPeopleTable = ({ people }: TagPeopleTableProps) => {
const projectId = useParam("projectId", "number")
const processedPeople: TagPeopleData[] = processTagPeople(people, projectId!)
- console.log("Processed people", processedPeople)
-
return (
diff --git a/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx b/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx
index dbe9232f..ef32aeb7 100644
--- a/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx
+++ b/src/tasklogs/tables/columns/TaskLogCompleteColumns.tsx
@@ -12,8 +12,12 @@ import {
ChatBubbleOvalLeftEllipsisIcon,
HandRaisedIcon,
InformationCircleIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+ ClockIcon,
} from "@heroicons/react/24/outline"
import TaskLogHistoryModal from "src/tasklogs/components/TaskLogHistoryModal"
+import DateFormat from "src/core/components/DateFormat"
// Column helper
const columnHelper = createColumnHelper
()
@@ -78,7 +82,7 @@ export const TaskLogCompleteColumns: ColumnDef<
)}
- {info.getValue()}
+
)
},
@@ -99,9 +103,47 @@ export const TaskLogCompleteColumns: ColumnDef<
id: "updatedAt",
}),
columnHelper.accessor("status", {
- cell: (info) => {info.getValue()} ,
+ cell: (info) => {
+ const value = info.getValue()
+ const isCompleted = value === "Completed"
+ return (
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
header: "Status",
id: "status",
+ enableColumnFilter: true,
+ enableSorting: true,
+ meta: {
+ filterVariant: "select",
+ },
+ }),
+ columnHelper.accessor("approved", {
+ cell: (info) => {
+ const value = info.getValue() as boolean | null
+ let icon: JSX.Element
+ if (value === true) {
+ icon =
+ } else if (value === false) {
+ icon =
+ } else {
+ icon =
+ }
+ return {icon}
+ },
+ header: "Approved",
+ id: "approved",
+ enableColumnFilter: true,
+ enableSorting: true,
+ meta: {
+ filterVariant: "select",
+ },
}),
columnHelper.accessor("taskHistory", {
cell: (info) => {
diff --git a/src/tasklogs/tables/columns/TaskLogFormColumns.tsx b/src/tasklogs/tables/columns/TaskLogFormColumns.tsx
index cb634900..676c0f4f 100644
--- a/src/tasklogs/tables/columns/TaskLogFormColumns.tsx
+++ b/src/tasklogs/tables/columns/TaskLogFormColumns.tsx
@@ -10,11 +10,14 @@ import { ShowTeamModal } from "src/teams/components/ShowTeamModal"
import TooltipWrapper from "src/core/components/TooltipWrapper"
import {
ChatBubbleOvalLeftEllipsisIcon,
+ ClockIcon,
HandRaisedIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline"
+import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"
import TaskLogHistoryModal from "src/tasklogs/components/TaskLogHistoryModal"
import { Tooltip } from "react-tooltip"
+import DateFormat from "src/core/components/DateFormat"
// Column helper
const columnHelper = createColumnHelper()
@@ -76,7 +79,7 @@ export const TaskLogFormColumns: ColumnDef
)}
- {info.getValue()}
+
)
},
@@ -97,7 +100,19 @@ export const TaskLogFormColumns: ColumnDef
{info.getValue()} ,
+ cell: (info) => {
+ const value = info.getValue()
+ const isCompleted = value === "Completed"
+ return (
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
header: "Status",
id: "status",
enableColumnFilter: true,
@@ -106,6 +121,27 @@ export const TaskLogFormColumns: ColumnDef {
+ const value = info.getValue() as boolean | null
+ let icon: JSX.Element
+ if (value === true) {
+ icon =
+ } else if (value === false) {
+ icon =
+ } else {
+ icon =
+ }
+ return {icon}
+ },
+ header: "Approved",
+ id: "approved",
+ enableColumnFilter: true,
+ enableSorting: true,
+ meta: {
+ filterVariant: "select",
+ },
+ }),
columnHelper.accessor("taskHistory", {
cell: (info) => {
return (
diff --git a/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx b/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx
index f3c611a1..5849ceec 100644
--- a/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx
+++ b/src/tasklogs/tables/columns/TaskLogHistoryCompleteColumns.tsx
@@ -1,3 +1,5 @@
+import DateFormat from "src/core/components/DateFormat"
+import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { ApproveDropdown } from "src/tasklogs/components/ApproveTask"
import { ProcessedTaskLogHistoryModal } from "../processing/processTaskLogs"
@@ -13,12 +15,24 @@ export const TaskLogHistoryCompleteColumns: ColumnDef {info.getValue()} ,
+ cell: (info) => ,
header: "Last Update",
id: "createdAt",
}),
columnHelper.accessor("status", {
- cell: (info) => {info.getValue()} ,
+ cell: (info) => {
+ const value = info.getValue()
+ const isCompleted = value === "Completed"
+ return (
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
header: "Status",
id: "status",
}),
diff --git a/src/tasklogs/tables/columns/TaskLogHistoryFormColumns.tsx b/src/tasklogs/tables/columns/TaskLogHistoryFormColumns.tsx
index 85ccb7db..ac7ebbb9 100644
--- a/src/tasklogs/tables/columns/TaskLogHistoryFormColumns.tsx
+++ b/src/tasklogs/tables/columns/TaskLogHistoryFormColumns.tsx
@@ -2,6 +2,8 @@ import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { JsonFormModal } from "src/core/components/JsonFormModal"
import { ProcessedTaskLogHistoryModal } from "../processing/processTaskLogs"
import { ApproveDropdown } from "src/tasklogs/components/ApproveTask"
+import DateFormat from "src/core/components/DateFormat"
+import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"
// Column helper
const columnHelper = createColumnHelper()
@@ -13,12 +15,24 @@ export const TaskLogHistoryFormColumns: ColumnDef[
id: "changedBy",
}),
columnHelper.accessor("lastUpdate", {
- cell: (info) => {info.getValue()} ,
+ cell: (info) => ,
header: "Last Update",
id: "createdAt",
}),
columnHelper.accessor("status", {
- cell: (info) => {info.getValue()} ,
+ cell: (info) => {
+ const value = info.getValue()
+ const isCompleted = value === "Completed"
+ return (
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
header: "Status",
id: "status",
}),
diff --git a/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx b/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx
index 05c9bd6e..37291935 100644
--- a/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx
+++ b/src/tasklogs/tables/columns/TaskLogProjectMemberColumns.tsx
@@ -10,6 +10,8 @@ import {
HandRaisedIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline"
+import DateFormat from "src/core/components/DateFormat"
+import { CheckCircleIcon, XCircleIcon, ClockIcon } from "@heroicons/react/24/outline"
import { TaskLogTaskCompleted } from "src/core/types"
import Link from "next/link"
import { Routes } from "@blitzjs/next"
@@ -53,7 +55,7 @@ export const TaskLogProjectMemberColumns: ColumnDef[] = [
)}
- {info.getValue()}
+
)
},
@@ -74,7 +76,19 @@ export const TaskLogProjectMemberColumns: ColumnDef
[] = [
id: "updatedAt",
}),
columnHelper.accessor("status", {
- cell: (info) => {info.getValue()} ,
+ cell: (info) => {
+ const value = info.getValue()
+ const isCompleted = value === "Completed"
+ return (
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
header: "Status",
id: "status",
enableColumnFilter: true,
@@ -83,6 +97,29 @@ export const TaskLogProjectMemberColumns: ColumnDef[] = [
filterVariant: "select",
},
}),
+ columnHelper.accessor("approved", {
+ cell: (info) => {
+ const value = info.getValue() as boolean | null
+ return (
+
+ {value === true ? (
+
+ ) : value === false ? (
+
+ ) : (
+
+ )}
+
+ )
+ },
+ header: "Approved",
+ id: "approved",
+ enableColumnFilter: true,
+ enableSorting: true,
+ meta: {
+ filterVariant: "select",
+ },
+ }),
columnHelper.accessor("taskHistory", {
cell: (info) => {
const { taskHistory, schema, ui, refetchTaskData } = info.row.original
diff --git a/src/tasklogs/tables/processing/processTaskLogs.ts b/src/tasklogs/tables/processing/processTaskLogs.ts
index 752a49de..da4fa963 100644
--- a/src/tasklogs/tables/processing/processTaskLogs.ts
+++ b/src/tasklogs/tables/processing/processTaskLogs.ts
@@ -8,8 +8,9 @@ import { CommentWithAuthor } from "src/core/types"
export type ProcessedIndividualTaskLog = {
projectMemberName: string
- lastUpdate: string
+ lastUpdate: Date | null
status: string
+ approved: boolean | null
taskLog: ExtendedTaskLog | undefined
firstLogId: number | undefined
comments: CommentWithAuthor[]
@@ -53,22 +54,13 @@ export function processIndividualTaskLogs(
return {
projectMemberName: getContributorName(projectMember),
- lastUpdate: latestLog
- ? latestLog.createdAt.toLocaleDateString(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
- })
- : "No update",
+ lastUpdate: latestLog ? latestLog.createdAt : null,
status: latestLog
? latestLog.status === "COMPLETED"
? "Completed"
: "Not Completed"
: "Unknown",
+ approved: latestLog ? latestLog.approved : null,
taskLog: latestLog,
firstLogId: firstLog?.id,
comments: taskLogComments,
@@ -95,8 +87,9 @@ export function processIndividualTaskLogs(
export type ProcessedTeamTaskLog = {
teamId: number
deletedTeam: boolean
- lastUpdate: string
+ lastUpdate: Date | null
status: string
+ approved: boolean | null
taskLog: ExtendedTaskLog | undefined
firstLogId: number | undefined
comments: CommentWithAuthor[]
@@ -143,22 +136,13 @@ export function processTeamTaskLogs(
return {
teamId: projectMember.id,
deletedTeam: projectMember.deleted,
- lastUpdate: latestLog
- ? latestLog.createdAt.toLocaleDateString(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
- })
- : "No update",
+ lastUpdate: latestLog ? latestLog.createdAt : null,
status: latestLog
? latestLog.status === "COMPLETED"
? "Completed"
: "Not Completed"
: "Unknown",
+ approved: latestLog ? latestLog.approved : null,
taskLog: latestLog,
users: projectMember.users,
firstLogId: firstLog?.id,
@@ -184,8 +168,9 @@ export function processTeamTaskLogs(
export type ProcessedTaskLogHistory = {
id: number
taskName: string
- lastUpdate: string
+ lastUpdate: Date
status: string
+ approved: boolean | null
taskLog: ExtendedTaskLog
taskHistory: ExtendedTaskLog[]
comments: CommentWithAuthor[]
@@ -219,16 +204,9 @@ export function processTaskLogHistory(
return {
id: latestLog!.id,
taskName: latestLog!.task.name ?? "Untitled Task",
- lastUpdate: latestLog!.createdAt.toLocaleDateString(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
- }),
+ lastUpdate: latestLog!.createdAt,
status: latestLog!.status === "COMPLETED" ? "Completed" : "Not Completed",
+ approved: latestLog.approved,
taskLog: latestLog,
taskHistory: logs as ExtendedTaskLog[],
comments: (comments ?? []).filter((c) => c.taskLogId === firstLog?.id),
@@ -253,7 +231,7 @@ export function processTaskLogHistory(
export type ProcessedTaskLogHistoryModal = {
id: number
projectMemberName: string
- lastUpdate: string
+ lastUpdate: Date
status: string
approved: boolean | null
privilege: MemberPrivileges
@@ -276,15 +254,7 @@ export function processTaskLogHistoryModal(
projectMemberName: taskLog.completedBy
? getContributorName(taskLog.completedBy)
: "Task created",
- lastUpdate: taskLog.createdAt.toLocaleDateString(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- hour12: false,
- }),
+ lastUpdate: taskLog!.createdAt,
status: taskLog.status === "COMPLETED" ? "Completed" : "Not Completed",
approved: taskLog.approved,
privilege: privilege,
diff --git a/src/tasks/components/TaskForm.tsx b/src/tasks/components/TaskForm.tsx
index 2aa31110..92ff41ee 100644
--- a/src/tasks/components/TaskForm.tsx
+++ b/src/tasks/components/TaskForm.tsx
@@ -21,7 +21,7 @@ import DateField from "src/core/components/fields/DateField"
import { InformationCircleIcon } from "@heroicons/react/24/outline"
import { Tooltip } from "react-tooltip"
import LabeledTextAreaField from "src/core/components/fields/LabeledTextAreaField"
-import { useForm } from "react-final-form"
+import { useForm, Field } from "react-final-form"
export type Tag = {
id: string
@@ -123,7 +123,7 @@ export function TaskForm>(props: TaskFormProps)
}
const handleTagClick = (index: number) => {
- console.log("The tag at index " + index + " was clicked")
+ // console.log("The tag at index " + index + " was clicked")
}
const onClearAll = () => {
@@ -132,6 +132,12 @@ export function TaskForm>(props: TaskFormProps)
const contributorIds = contributorOptions.map((c) => c.id)
const teamIds = teamOptions.map((t) => t.id)
+ const autoAssignOptions = [
+ { id: "NONE", name: "Do not auto-assign" },
+ { id: "CONTRIBUTOR", name: "Auto-assign new contributors" },
+ { id: "TEAM", name: "Auto-assign new teams" },
+ { id: "ALL", name: "Auto-assign new contributors & teams" },
+ ]
const ContributorsBulkButtons: React.FC<{
contributorCount: number
@@ -254,6 +260,54 @@ export function TaskForm>(props: TaskFormProps)
+ {/* Auto-assign new members/teams */}
+
+
+ Auto-assign future additions
+
+
+
+ }
+ description="Applies when new contributors or teams are added to this project."
+ options={autoAssignOptions}
+ optionText="name"
+ optionValue="id"
+ type="string" // 👈 add this line
+ />
+
+ {/* Anonymous toggle */}
+
+
+
+ Anonymous task{" "}
+
+
+ name="anonymous" type="checkbox" initialValue={false}>
+ {({ input }) => (
+
+ )}
+
+
+
+
diff --git a/src/tasks/components/TaskLogTable.tsx b/src/tasks/components/TaskLogTable.tsx
index eae129fc..71fad1a2 100644
--- a/src/tasks/components/TaskLogTable.tsx
+++ b/src/tasks/components/TaskLogTable.tsx
@@ -83,6 +83,7 @@ export const TaskLogTable = ({ contributorFilter }: TaskLogTableProps) => {
// Get columns definitions for tables
const individualColumns = task.formVersionId ? TaskLogFormColumns : TaskLogCompleteColumns
const allProcessedLogs = [...processedIndividualAssignments, ...processedTeamAssignments]
+
return (
diff --git a/src/tasks/components/TaskSchemaInput.tsx b/src/tasks/components/TaskSchemaInput.tsx
index da55870d..c6249a47 100644
--- a/src/tasks/components/TaskSchemaInput.tsx
+++ b/src/tasks/components/TaskSchemaInput.tsx
@@ -31,22 +31,37 @@ export const TaskSchemaInput = ({
const typedPmForms = pmForms as FormWithVersionAndUser[]
+ // Build a lookup from formVersion.id -> PM username for fast, reliable access
+ const versionIdToUsername = new Map
()
+ typedPmForms.forEach((form) => {
+ const username =
+ (form.user?.firstName && form.user?.lastName
+ ? `${form.user.firstName} ${form.user.lastName}`
+ : form.user?.username) ?? ""
+ const versions = Array.isArray(form.formVersion)
+ ? form.formVersion
+ : form.formVersion
+ ? [form.formVersion]
+ : []
+ versions.forEach((fv: any) => {
+ if (fv?.id != null) {
+ versionIdToUsername.set(fv.id, username)
+ }
+ })
+ })
+
const schemas = typedPmForms
.filter((form) => form.formVersion)
.flatMap((form) => form.formVersion!)
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
const options = schemas.map((schema) => ({ id: schema.id, label: schema.name }))
// Extra columns for the select table
- const versionNumber = schemas.map((schema) => schema.version)
-
- const pmNames = typedPmForms
- .filter((form) => form.formVersion) // Keep only forms where formVersion is defined
- .map((form) => form.user?.username) // Map to the username
-
- const extraData = versionNumber.map((version, index) => ({
- version: version,
- username: pmNames[index], // Safely access pmNames[index]
+ const extraData = schemas.map((schema) => ({
+ version: schema.version,
+ username: versionIdToUsername.get(schema.id) ?? "",
+ date: new Date(schema.createdAt).toLocaleDateString(),
}))
const extraColumns = [
@@ -62,6 +77,12 @@ export const TaskSchemaInput = ({
accessorKey: "username",
cell: (info) => {info.getValue()} ,
},
+ {
+ id: "date",
+ header: "Created",
+ accessorKey: "date",
+ cell: (info) => {info.getValue()} ,
+ },
]
return (
diff --git a/src/tasks/components/TaskSummary.tsx b/src/tasks/components/TaskSummary.tsx
index 969c20e6..e6960a19 100644
--- a/src/tasks/components/TaskSummary.tsx
+++ b/src/tasks/components/TaskSummary.tsx
@@ -6,7 +6,7 @@ import { TaskLogTable } from "./TaskLogTable"
// Create task summary for the PM
export const TaskSummary = ({ contributorFilter }: { contributorFilter?: number }) => {
return (
-
+
{/* overall project information */}
{!contributorFilter && (
diff --git a/src/tasks/hooks/useTaskBoardData.ts b/src/tasks/hooks/useTaskBoardData.ts
index dbc4e76a..76df52d6 100644
--- a/src/tasks/hooks/useTaskBoardData.ts
+++ b/src/tasks/hooks/useTaskBoardData.ts
@@ -48,8 +48,6 @@ export default function useTaskBoardData(projectId: number) {
},
})
- console.log(projectMember.id)
-
// Get data
const [columns, { refetch }]: [ColumnWithTasks[], any] = useQuery(getColumns, {
orderBy: { containerOrder: "asc" },
diff --git a/src/tasks/mutations/createTask.ts b/src/tasks/mutations/createTask.ts
index e93c817c..0a16b8b3 100644
--- a/src/tasks/mutations/createTask.ts
+++ b/src/tasks/mutations/createTask.ts
@@ -21,6 +21,8 @@ export default resolver.pipe(
projectMembersId,
teamsId,
rolesId,
+ autoAssignNew,
+ anonymous,
tags,
},
ctx
@@ -43,6 +45,8 @@ export default resolver.pipe(
containerTaskOrder,
startDate,
deadline,
+ autoAssignNew: autoAssignNew ?? "NONE",
+ anonymous: anonymous ?? false,
project: {
connect: { id: projectId },
},
diff --git a/src/tasks/mutations/updateTask.ts b/src/tasks/mutations/updateTask.ts
index f44a99fa..ef51e25c 100644
--- a/src/tasks/mutations/updateTask.ts
+++ b/src/tasks/mutations/updateTask.ts
@@ -85,13 +85,20 @@ export default resolver.pipe(
const safeProjectMembersId: number[] = projectMembersId || []
const safeTeamsId: number[] = teamsId || []
+ // Build a safe update payload: do not send null to Prisma enum fields
+ const { autoAssignNew, ...restData } = data as any
+ const updateData: any = {
+ ...restData,
+ tags: data.tags ? data.tags : undefined,
+ }
+ if (autoAssignNew !== null && autoAssignNew !== undefined) {
+ updateData.autoAssignNew = autoAssignNew
+ }
+
// Update task data
const task = await db.task.update({
where: { id },
- data: {
- ...data,
- tags: data.tags ? data.tags : undefined,
- },
+ data: updateData,
})
// Fetch existing assigned project members for the task
diff --git a/src/tasks/schemas.ts b/src/tasks/schemas.ts
index b355f65d..ffb0274b 100644
--- a/src/tasks/schemas.ts
+++ b/src/tasks/schemas.ts
@@ -1,4 +1,4 @@
-import { Status } from "@prisma/client"
+import { Status, AutoAssignNew } from "@prisma/client"
import { z } from "zod"
export const FormTaskSchema = z
@@ -13,6 +13,8 @@ export const FormTaskSchema = z
deadline: z.date().optional().nullable(),
formVersionId: z.number().optional().nullable(),
startDate: z.date().optional().nullable(),
+ autoAssignNew: z.nativeEnum(AutoAssignNew).optional().nullable(),
+ anonymous: z.boolean(),
})
.refine(
(data) => {
@@ -40,6 +42,8 @@ export const CreateTaskSchema = z.object({
projectMembersId: z.array(z.number()).optional().nullable(),
teamsId: z.array(z.number()).optional().nullable(),
rolesId: z.array(z.number()).optional().nullable(),
+ autoAssignNew: z.nativeEnum(AutoAssignNew).optional().nullable(),
+ anonymous: z.boolean(),
tags: z
.array(
z.object({
@@ -63,6 +67,8 @@ export const UpdateTaskSchema = z.object({
deadline: z.date().optional().nullable(),
rolesId: z.array(z.number()).optional().nullable(),
startDate: z.date().optional().nullable(),
+ autoAssignNew: z.nativeEnum(AutoAssignNew).optional().nullable(),
+ anonymous: z.boolean(),
tags: z
.array(
z.object({
diff --git a/src/tasks/tables/columns/AllTasksColumns.tsx b/src/tasks/tables/columns/AllTasksColumns.tsx
index b8b43ead..f3c7868e 100644
--- a/src/tasks/tables/columns/AllTasksColumns.tsx
+++ b/src/tasks/tables/columns/AllTasksColumns.tsx
@@ -49,6 +49,17 @@ export const AllTasksColumns = [
filterVariant: "range",
},
}),
+ columnHelperAll.accessor("approved", {
+ header: "Approved",
+ enableColumnFilter: true,
+ enableSorting: true,
+ cell: (info) => (
+ {info.getValue()}%
+ ),
+ meta: {
+ filterVariant: "range",
+ },
+ }),
columnHelperAll.accessor("newCommentsCount", {
header: "Comments",
id: "newComments",
diff --git a/src/tasks/tables/processing/processAllTasks.ts b/src/tasks/tables/processing/processAllTasks.ts
index 65c56703..9749b97a 100644
--- a/src/tasks/tables/processing/processAllTasks.ts
+++ b/src/tasks/tables/processing/processAllTasks.ts
@@ -5,6 +5,7 @@ export type AllTasksData = {
projectName: string
deadline: Date | null
completion: number
+ approved: number
hasNewComments: boolean
newCommentsCount: {
countTotal: number
@@ -21,23 +22,28 @@ export function processAllTasks(
latestTaskLog: TaskLogWithTaskProjectAndComments[],
originalTaskLogs?: TaskLogWithTaskProjectAndComments[]
): AllTasksData[] {
- const taskSummary: Record = {}
+ const taskSummary: Record = {}
// Initialize the summary for each taskLog
latestTaskLog.forEach((log) => {
- const { taskId, status } = log
+ const { taskId, status, approved } = log
// Initialize the summary for this taskId if it doesn't exist
if (!taskSummary[taskId]) {
- taskSummary[taskId] = { total: 0, completed: 0 }
+ taskSummary[taskId] = { total: 0, completed: 0, approved: 0 }
}
// Use type assertion to avoid TypeScript's undefined warning
- ;(taskSummary[taskId] as { total: number; completed: number }).total += 1
+ ;(taskSummary[taskId] as { total: number; completed: number; approved: number }).total += 1
// Increment the completed count if the status is "COMPLETED"
if (status === "COMPLETED") {
- ;(taskSummary[taskId] as { total: number; completed: number }).completed += 1
+ ;(
+ taskSummary[taskId] as { total: number; completed: number; approved: number }
+ ).completed += 1
+ }
+ if (approved === true) {
+ ;(taskSummary[taskId] as { total: number; completed: number; approved: number }).approved += 1
}
})
@@ -74,6 +80,7 @@ export function processAllTasks(
projectName: "Unknown Project",
deadline: null,
completion: 0,
+ approved: 0,
hasNewComments: false,
newCommentsCount: {
countTotal: 0,
@@ -87,8 +94,9 @@ export function processAllTasks(
}
}
- const { total, completed } = taskData
+ const { total, completed, approved } = taskData
const completionPercentage = total > 0 ? Math.round((completed / total) * 100) : 0
+ const approvedPercentage = total > 0 ? Math.round((approved / total) * 100) : 0
const unreadCount = commentSummary[Number(taskId)] || 0
const hasNewComments = unreadCount > 0
@@ -98,6 +106,7 @@ export function processAllTasks(
projectName: task?.project!.name || "Unknown Project",
deadline: task?.deadline || null,
completion: completionPercentage,
+ approved: approvedPercentage,
hasNewComments,
newCommentsCount: {
countTotal: unreadCount,
diff --git a/src/teams/components/AssignTeamMembers.tsx b/src/teams/components/AssignTeamMembers.tsx
index 561a5bae..59e9bffb 100644
--- a/src/teams/components/AssignTeamMembers.tsx
+++ b/src/teams/components/AssignTeamMembers.tsx
@@ -2,6 +2,7 @@ import { useQuery } from "@blitzjs/rpc"
import React from "react"
import getContributors from "src/contributors/queries/getContributors"
import CheckboxFieldTable from "src/core/components/fields/CheckboxFieldTable"
+import { useForm } from "react-final-form"
interface AssignTeamMembersProps {
projectId: number
@@ -17,10 +18,33 @@ const AssignTeamMembers: React.FC = ({ projectId }) => {
: `${contributor.users[0]?.username}`,
}))
+ const allMemberIds = options.map((o) => o.id)
+ const form = useForm()
+
return (
-
-
Add Team Members:
-
+
+
Add Team Members:
+
+
+ form.change("projectMemberUserIds", allMemberIds)}
+ >
+ {`Select all members (${allMemberIds.length})`}
+
+ form.change("projectMemberUserIds", [])}
+ >
+ Clear
+
+
+
+
+
+
)
}
diff --git a/src/teams/components/ShowTeamModal.tsx b/src/teams/components/ShowTeamModal.tsx
index f8e5cc64..5593d744 100644
--- a/src/teams/components/ShowTeamModal.tsx
+++ b/src/teams/components/ShowTeamModal.tsx
@@ -30,7 +30,7 @@ export const ShowTeamModal = ({ teamId, disabled }) => {
handleToggle()}
diff --git a/src/teams/components/TeamForm.tsx b/src/teams/components/TeamForm.tsx
index 3871b666..26124024 100644
--- a/src/teams/components/TeamForm.tsx
+++ b/src/teams/components/TeamForm.tsx
@@ -51,7 +51,7 @@ export function TeamForm>(props: TeamFormProps)
}
const handleTagClick = (index: number) => {
- console.log("The tag at index " + index + " was clicked")
+ //console.log("The tag at index " + index + " was clicked")
}
const onClearAll = () => {
diff --git a/src/teams/mutations/createTeam.ts b/src/teams/mutations/createTeam.ts
index 98c1cee9..f3fda5f3 100644
--- a/src/teams/mutations/createTeam.ts
+++ b/src/teams/mutations/createTeam.ts
@@ -1,12 +1,13 @@
import { resolver } from "@blitzjs/rpc"
-import db from "db"
+import db, { CompletedAs, AutoAssignNew } from "db"
import { CreateTeamSchema } from "../schemas"
+import { Routes } from "@blitzjs/next"
+import sendNotification from "src/notifications/mutations/sendNotification"
export default resolver.pipe(
resolver.zod(CreateTeamSchema),
resolver.authorize(),
- async ({ projectId, name, userIds, tags }) => {
- // TODO: in multi-tenant app, you must add validation to ensure correct tenant
+ async ({ projectId, name, userIds, tags }, ctx) => {
const team = await db.projectMember.create({
data: {
name,
@@ -22,6 +23,111 @@ export default resolver.pipe(
},
})
+ // Resolve project name and actor name for notifications
+ const project = await db.project.findFirst({
+ where: { id: projectId },
+ select: { name: true },
+ })
+
+ let addedBy = "Project Admin"
+ if (ctx?.session?.userId) {
+ const actor = await db.user.findFirst({
+ where: { id: ctx.session.userId },
+ select: { firstName: true, lastName: true, username: true },
+ })
+ if (actor) {
+ addedBy =
+ actor.firstName && actor.lastName
+ ? `${actor.firstName} ${actor.lastName}`
+ : actor.username || addedBy
+ }
+ }
+
+ await sendNotification(
+ {
+ templateId: "addedToTeam",
+ recipients: userIds,
+ data: {
+ teamName: name || "Unnamed Team",
+ projectName: project?.name || "Unnamed Project",
+ addedBy: addedBy,
+ },
+ projectId,
+ routeData: {
+ path: Routes.ShowTeamPage({ projectId, teamId: team.id }).href,
+ },
+ },
+ ctx
+ )
+
+ // Auto-assign this team to tasks configured to add all new teams
+ const tasksToAutoAssign = await db.task.findMany({
+ where: { projectId, autoAssignNew: { in: [AutoAssignNew.ALL, AutoAssignNew.TEAM] } },
+ select: { id: true, name: true, deadline: true, createdBy: { include: { users: true } } },
+ })
+
+ if (tasksToAutoAssign.length > 0) {
+ // Connect the team as an assigned member on each task
+ await Promise.all(
+ tasksToAutoAssign.map((t) =>
+ db.task.update({
+ where: { id: t.id },
+ data: {
+ assignedMembers: { connect: { id: team.id } },
+ },
+ })
+ )
+ )
+
+ // Create TaskLog entries for team assignments
+ await Promise.all(
+ tasksToAutoAssign.map((t) =>
+ db.taskLog.create({
+ data: {
+ taskId: t.id,
+ assignedToId: team.id,
+ completedAs: CompletedAs.TEAM,
+ },
+ })
+ )
+ )
+
+ // Notify team users that their team was added to each task
+ const uniqueUserIds = Array.from(new Set(userIds))
+
+ await Promise.all(
+ tasksToAutoAssign.map(async (t) => {
+ const task = await db.task.findFirst({
+ where: { id: t.id },
+ select: { name: true, deadline: true, createdBy: { include: { users: true } } },
+ })
+
+ const createdByUsername = task?.createdBy?.users?.[0]
+ ? task!.createdBy!.users![0].firstName && task!.createdBy!.users![0].lastName
+ ? `${task!.createdBy!.users![0].firstName} ${task!.createdBy!.users![0].lastName}`
+ : task!.createdBy!.users![0].username
+ : null
+
+ await sendNotification(
+ {
+ templateId: "taskAssigned",
+ recipients: uniqueUserIds,
+ data: {
+ taskName: task?.name || "Unnamed Task",
+ createdBy: createdByUsername || "Auto Assigned",
+ deadline: task?.deadline || null,
+ },
+ projectId,
+ routeData: {
+ path: Routes.ShowTaskPage({ projectId, taskId: t.id }).href,
+ },
+ },
+ ctx
+ )
+ })
+ )
+ }
+
return team
}
)
diff --git a/src/teams/mutations/updateTeam.ts b/src/teams/mutations/updateTeam.ts
index bfe3a5ec..9e5c3a57 100644
--- a/src/teams/mutations/updateTeam.ts
+++ b/src/teams/mutations/updateTeam.ts
@@ -1,11 +1,26 @@
import { resolver } from "@blitzjs/rpc"
import db from "db"
+import { Routes } from "@blitzjs/next"
+import sendNotification from "src/notifications/mutations/sendNotification"
import { UpdateTeamSchema } from "../schemas"
export default resolver.pipe(
resolver.zod(UpdateTeamSchema),
resolver.authorize(),
- async ({ id, name, userIds, tags }) => {
+ async ({ id, name, userIds, tags }, ctx) => {
+ // Fetch existing users and projectId for this team to compute newly added users
+ const existing = await db.projectMember.findFirst({
+ where: { id },
+ select: {
+ projectId: true,
+ name: true,
+ users: { select: { id: true } },
+ },
+ })
+
+ const existingUserIds = new Set((existing?.users || []).map((u) => u.id))
+ const newlyAddedUserIds = (userIds || []).filter((uid) => !existingUserIds.has(uid))
+
const team = await db.projectMember.update({
where: { id },
data: {
@@ -22,6 +37,47 @@ export default resolver.pipe(
},
})
+ // If any users were newly added, notify them
+ if (newlyAddedUserIds.length > 0 && existing?.projectId) {
+ // Resolve project name
+ const project = await db.project.findFirst({
+ where: { id: existing.projectId },
+ select: { name: true },
+ })
+
+ // Resolve actor name from ctx.session.userId
+ let addedBy = "Project Admin"
+ if (ctx?.session?.userId) {
+ const actor = await db.user.findFirst({
+ where: { id: ctx.session.userId },
+ select: { firstName: true, lastName: true, username: true },
+ })
+ if (actor) {
+ addedBy =
+ actor.firstName && actor.lastName
+ ? `${actor.firstName} ${actor.lastName}`
+ : actor.username || addedBy
+ }
+ }
+
+ await sendNotification(
+ {
+ templateId: "addedToTeam",
+ recipients: newlyAddedUserIds,
+ data: {
+ teamName: team.name || existing?.name || "Unnamed Team",
+ projectName: project?.name || "Unnamed Project",
+ addedby: addedBy,
+ },
+ projectId: existing.projectId,
+ routeData: {
+ path: Routes.ShowTeamPage({ projectId: existing.projectId, teamId: team.id }).href,
+ },
+ },
+ ctx
+ )
+ }
+
return team
}
)
diff --git a/src/widgets/components/widgets/MainLastProject.tsx b/src/widgets/components/widgets/MainLastProject.tsx
index 09da95b3..c2d3e726 100644
--- a/src/widgets/components/widgets/MainLastProject.tsx
+++ b/src/widgets/components/widgets/MainLastProject.tsx
@@ -6,19 +6,24 @@ import PrimaryLink from "src/core/components/PrimaryLink"
import Widget from "../Widget"
import { GetTableDisplay } from "src/core/components/GetWidgetDisplay"
import { projectColumns } from "../ColumnHelpers"
+import { useTranslation } from "react-i18next"
const LastProject: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const [{ projects }] = useQuery(getDashboardProjects, undefined)
-
+ const { t } = (useTranslation as any)()
return (
}
link={
-
+
}
tooltipId="tool-last-project"
- tooltipContent="Three recently updated projects"
+ tooltipContent={t("main.dashboard.tooltips.lastupdatedprojects")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainNotification.tsx b/src/widgets/components/widgets/MainNotification.tsx
index fab608a4..cf7471b6 100644
--- a/src/widgets/components/widgets/MainNotification.tsx
+++ b/src/widgets/components/widgets/MainNotification.tsx
@@ -11,13 +11,15 @@ import {
BellAlertIcon,
} from "@heroicons/react/24/outline"
import Link from "next/link"
+import { useTranslation } from "react-i18next"
const MainNotification: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const [{ notifications, countsByType }] = useQuery(getLatestUnreadNotifications, {})
+ const { t } = (useTranslation as any)()
return (
{["Task", "Comment", "Project", "Other"].map((type) => (
@@ -41,12 +43,12 @@ const MainNotification: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ si
link={
}
tooltipId="tool-notifications"
- tooltipContent="Number of notifications all projects, clear them by marking them read."
+ tooltipContent={t("main.dashboard.tooltips.notifications")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainOverDueTasks.tsx b/src/widgets/components/widgets/MainOverDueTasks.tsx
index d450979b..d628de77 100644
--- a/src/widgets/components/widgets/MainOverDueTasks.tsx
+++ b/src/widgets/components/widgets/MainOverDueTasks.tsx
@@ -5,21 +5,33 @@ import PrimaryLink from "src/core/components/PrimaryLink"
import { GetTableDisplay } from "src/core/components/GetWidgetDisplay"
import Widget from "../Widget"
import { tasksColumns } from "../ColumnHelpers"
+import { useTranslation } from "react-i18next"
const MainOverdueTasks: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }, ctx) => {
const [{ pastDueTasks }] = useQuery(getDashboardTasks, ctx, {
suspense: true, // Set to false if you want to handle loading and error states manually
})
+ const { t } = (useTranslation as any)()
return (
+
+ }
+ link={
+
}
- link={ }
tooltipId="tool-overdue"
- tooltipContent="Three overdue tasks for all projects"
+ tooltipContent={t("main.dashboard.tooltips.overduetask")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainTotalContributors.tsx b/src/widgets/components/widgets/MainTotalContributors.tsx
index cc87ccdf..1b17384a 100644
--- a/src/widgets/components/widgets/MainTotalContributors.tsx
+++ b/src/widgets/components/widgets/MainTotalContributors.tsx
@@ -56,7 +56,7 @@ const TotalContributors: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ s
/>
}
tooltipId="tool-contributors-total"
- tooltipContent="Total number of unique contributors across all projects"
+ tooltipContent={t("main.dashboard.tooltips.totalcontributors")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainTotalForms.tsx b/src/widgets/components/widgets/MainTotalForms.tsx
index d22feb82..6172fa13 100644
--- a/src/widgets/components/widgets/MainTotalForms.tsx
+++ b/src/widgets/components/widgets/MainTotalForms.tsx
@@ -7,6 +7,7 @@ import Widget from "../Widget"
import { BeakerIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"
import { useCurrentUser } from "src/users/hooks/useCurrentUser"
import getForms from "src/forms/queries/getForms"
+import { useTranslation } from "react-i18next"
const TotalForms: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const currentUser = useCurrentUser()
@@ -18,10 +19,10 @@ const TotalForms: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size })
},
orderBy: { id: "asc" },
})
-
+ const { t } = (useTranslation as any)()
return (
}
link={
= ({ size })
/>
}
tooltipId="tool-form-total"
- tooltipContent="Total number of metadata templates"
+ tooltipContent={t("main.dashboard.tooltips.forms")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainTotalInvites.tsx b/src/widgets/components/widgets/MainTotalInvites.tsx
index 3ddafb6e..e27b31a4 100644
--- a/src/widgets/components/widgets/MainTotalInvites.tsx
+++ b/src/widgets/components/widgets/MainTotalInvites.tsx
@@ -7,6 +7,7 @@ import Widget from "../Widget"
import { EnvelopeIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"
import { useCurrentUser } from "src/users/hooks/useCurrentUser"
import getInvites from "src/invites/queries/getInvites"
+import { useTranslation } from "react-i18next"
const TotalInvites: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const currentUser = useCurrentUser()
@@ -15,10 +16,10 @@ const TotalInvites: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }
where: { email: currentUser!.email },
orderBy: { id: "asc" },
})
-
+ const { t } = (useTranslation as any)()
return (
}
link={
= ({ size }
/>
}
tooltipId="tool-invites-total"
- tooltipContent="Number of project invites"
+ tooltipContent={t("main.dashboard.tooltips.invites")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainTotalProjects.tsx b/src/widgets/components/widgets/MainTotalProjects.tsx
index e35822d1..088630be 100644
--- a/src/widgets/components/widgets/MainTotalProjects.tsx
+++ b/src/widgets/components/widgets/MainTotalProjects.tsx
@@ -7,6 +7,7 @@ import Widget from "../Widget"
import { ArchiveBoxIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"
import { useCurrentUser } from "src/users/hooks/useCurrentUser"
import getProjects from "src/projects/queries/getProjects"
+import { useTranslation } from "react-i18next"
const TotalProjects: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const currentUser = useCurrentUser()
@@ -21,10 +22,10 @@ const TotalProjects: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size
},
},
})
-
+ const { t } = (useTranslation as any)()
return (
}
link={
= ({ size
/>
}
tooltipId="tool-project-total"
- tooltipContent="Total number of projects"
+ tooltipContent={t("main.dashboard.tooltips.projects")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainTotalRoles.tsx b/src/widgets/components/widgets/MainTotalRoles.tsx
index b1526dde..24b3c3d7 100644
--- a/src/widgets/components/widgets/MainTotalRoles.tsx
+++ b/src/widgets/components/widgets/MainTotalRoles.tsx
@@ -7,6 +7,7 @@ import Widget from "../Widget"
import { FingerPrintIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"
import { useCurrentUser } from "src/users/hooks/useCurrentUser"
import getRoles from "src/roles/queries/getRoles"
+import { useTranslation } from "react-i18next"
const TotalRoles: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const currentUser = useCurrentUser()
@@ -17,10 +18,10 @@ const TotalRoles: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size })
},
orderBy: { id: "asc" },
})
-
+ const { t } = (useTranslation as any)()
return (
}
link={
= ({ size })
/>
}
tooltipId="tool-roles-total"
- tooltipContent="Total number of role labels"
+ tooltipContent={t("main.dashboard.tooltips.roles")}
size={size}
/>
)
diff --git a/src/widgets/components/widgets/MainTotalTask.tsx b/src/widgets/components/widgets/MainTotalTask.tsx
index 24206ea6..e1b283a5 100644
--- a/src/widgets/components/widgets/MainTotalTask.tsx
+++ b/src/widgets/components/widgets/MainTotalTask.tsx
@@ -10,10 +10,13 @@ import getTaskLogs from "src/tasklogs/queries/getTaskLogs"
import { TaskLogWithTaskProjectAndComments } from "src/core/types"
import getLatestTaskLogs from "src/tasklogs/hooks/getLatestTaskLogs"
import { processAllTasks } from "src/tasks/tables/processing/processAllTasks"
+import { useTranslation } from "react-i18next"
const AllTaskTotal: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }) => {
const currentUser = useCurrentUser()
+ // Fetch all tasks
+ // Get latest logs that this user is involved in
// Fetch all tasks
// Get latest logs that this user is involved in
const [fetchedTaskLogs] = useQuery(getTaskLogs, {
@@ -45,7 +48,6 @@ const AllTaskTotal: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }
},
orderBy: { id: "asc" },
})
-
// Cast and handle the possibility of `undefined`
const taskLogs: TaskLogWithTaskProjectAndComments[] = (fetchedTaskLogs ??
[]) as TaskLogWithTaskProjectAndComments[]
@@ -74,9 +76,10 @@ const AllTaskTotal: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }
)
}, 0)
+ const { t } = (useTranslation as any)()
return (
}
link={
= ({ size }
/>
}
tooltipId="tool-tasks"
- tooltipContent="Percent of tasks completed and new comments on only tasks assigned to you"
+ tooltipContent={t("main.dashboard.tooltips.alltasks")}
size={size}
newCommentsCount={newCommentsCount}
/>
diff --git a/src/widgets/components/widgets/MainUpcomingTasks.tsx b/src/widgets/components/widgets/MainUpcomingTasks.tsx
index 3a251bbc..1805c830 100644
--- a/src/widgets/components/widgets/MainUpcomingTasks.tsx
+++ b/src/widgets/components/widgets/MainUpcomingTasks.tsx
@@ -6,21 +6,32 @@ import PrimaryLink from "src/core/components/PrimaryLink"
import { GetTableDisplay } from "src/core/components/GetWidgetDisplay"
import Widget from "../Widget"
import { tasksColumns } from "../ColumnHelpers"
+import { useTranslation } from "react-i18next"
const MainUpcomingTasks: React.FC<{ size: "SMALL" | "MEDIUM" | "LARGE" }> = ({ size }, ctx) => {
const [{ upcomingTasks }] = useQuery(getDashboardTasks, ctx, {
suspense: true, // Set to false if you want to handle loading and error states manually
})
-
+ const { t } = (useTranslation as any)()
return (
+
+ }
+ link={
+
}
- link={ }
tooltipId="tool-upcoming"
- tooltipContent="Three upcoming tasks for all projects"
+ tooltipContent={t("main.dashboard.tooltips.upcomingtask")}
size={size}
/>
)
diff --git a/src/widgets/mutations/addProjectManagerWidgets.ts b/src/widgets/mutations/addProjectManagerWidgets.ts
index d7a48632..6686d60d 100644
--- a/src/widgets/mutations/addProjectManagerWidgets.ts
+++ b/src/widgets/mutations/addProjectManagerWidgets.ts
@@ -22,7 +22,7 @@ export default resolver.pipe(
// If the user is already a project manager, return early
if (existingPrivilege) {
- console.log("User is already a project manager. No widgets will be added.")
+ //console.log("User is already a project manager. No widgets will be added.")
return
}
diff --git a/summary-viewer/people_roles.html b/summary-viewer/Contributors.html
similarity index 99%
rename from summary-viewer/people_roles.html
rename to summary-viewer/Contributors.html
index 3df1994e..7c91198e 100644
--- a/summary-viewer/people_roles.html
+++ b/summary-viewer/Contributors.html
@@ -1524,16 +1524,16 @@ ${name} Details
- Project Summary
+ Project Summary
- Contributors
+ Contributors
- Tasks
- Form Data
- Events
+ Tasks
+ Form Data
+ Events