diff --git a/app/routes/_layout+/assets.$assetId.reminders.tsx b/app/routes/_layout+/assets.$assetId.reminders.tsx
new file mode 100644
index 000000000..221c387d4
--- /dev/null
+++ b/app/routes/_layout+/assets.$assetId.reminders.tsx
@@ -0,0 +1,96 @@
+import { json } from "@remix-run/node";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
+import { z } from "zod";
+import RemindersTable from "~/components/asset-reminder/reminders-table";
+import type { HeaderData } from "~/components/layout/header/types";
+import { getPaginatedAndFilterableReminders } from "~/modules/asset-reminder/service.server";
+import { resolveRemindersActions } from "~/modules/asset-reminder/utils.server";
+import { getDateTimeFormat } from "~/utils/client-hints";
+import { makeShelfError } from "~/utils/error";
+import { data, error, getParams } from "~/utils/http.server";
+import {
+ PermissionAction,
+ PermissionEntity,
+} from "~/utils/permissions/permission.data";
+import { requirePermission } from "~/utils/roles.server";
+
+export async function loader({ context, request, params }: LoaderFunctionArgs) {
+ const authSession = context.getSession();
+ const userId = authSession.userId;
+
+ const { assetId } = getParams(params, z.object({ assetId: z.string() }), {
+ additionalData: { userId },
+ });
+
+ try {
+ const { organizationId } = await requirePermission({
+ userId,
+ request,
+ entity: PermissionEntity.asset,
+ action: PermissionAction.read,
+ });
+
+ const { reminders, totalReminders, page, perPage, totalPages } =
+ await getPaginatedAndFilterableReminders({
+ organizationId,
+ request,
+ where: { assetId },
+ });
+
+ const header: HeaderData = { title: "Reminders" };
+ const modelName = {
+ singular: "reminder",
+ plural: "reminders",
+ };
+
+ const assetReminders = reminders.map((reminder) => ({
+ ...reminder,
+ displayDate: getDateTimeFormat(request, {
+ dateStyle: "short",
+ timeStyle: "short",
+ }).format(reminder.alertDateTime),
+ }));
+
+ return json(
+ data({
+ header,
+ modelName,
+ items: assetReminders,
+ totalItems: totalReminders,
+ page,
+ perPage,
+ totalPages,
+ })
+ );
+ } catch (cause) {
+ const reason = makeShelfError(cause, { userId, assetId });
+ throw json(error(reason), { status: reason.status });
+ }
+}
+
+export async function action({ context, request }: ActionFunctionArgs) {
+ const authSession = context.getSession();
+ const userId = authSession.userId;
+
+ try {
+ const { organizationId } = await requirePermission({
+ userId,
+ request,
+ entity: PermissionEntity.asset,
+ action: PermissionAction.update,
+ });
+
+ return await resolveRemindersActions({
+ request,
+ organizationId,
+ userId,
+ });
+ } catch (cause) {
+ const reason = makeShelfError(cause, { userId });
+ return json(error(reason), { status: reason.status });
+ }
+}
+
+export default function AssetReminders() {
+ return ;
+}
diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx
index 20bfa1da8..58b3cb18e 100644
--- a/app/routes/_layout+/assets.$assetId.tsx
+++ b/app/routes/_layout+/assets.$assetId.tsx
@@ -6,8 +6,10 @@ import type {
} from "@remix-run/node";
import { redirect, json } from "@remix-run/node";
import { useLoaderData, Outlet } from "@remix-run/react";
+import { DateTime } from "luxon";
import mapCss from "maplibre-gl/dist/maplibre-gl.css?url";
import { z } from "zod";
+import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog";
import ActionsDropdown from "~/components/assets/actions-dropdown";
import { AssetImage } from "~/components/assets/asset-image";
import { AssetStatusBadge } from "~/components/assets/asset-status-badge";
@@ -24,14 +26,21 @@ import {
getAsset,
relinkQrCode,
} from "~/modules/asset/service.server";
+import { createAssetReminder } from "~/modules/asset-reminder/service.server";
import assetCss from "~/styles/asset.css?url";
import { appendToMetaTitle } from "~/utils/append-to-meta-title";
import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch";
-import { getDateTimeFormat } from "~/utils/client-hints";
+import { getDateTimeFormat, getHints } from "~/utils/client-hints";
import { sendNotification } from "~/utils/emitter/send-notification.server";
import { makeShelfError } from "~/utils/error";
-import { error, getParams, data, parseData } from "~/utils/http.server";
+import {
+ error,
+ getParams,
+ data,
+ parseData,
+ safeRedirect,
+} from "~/utils/http.server";
import {
PermissionAction,
PermissionEntity,
@@ -108,12 +117,13 @@ export async function action({ context, request, params }: ActionFunctionArgs) {
const { intent } = parseData(
formData,
- z.object({ intent: z.enum(["delete", "relink-qr-code"]) })
+ z.object({ intent: z.enum(["delete", "relink-qr-code", "set-reminder"]) })
);
const intent2ActionMap: { [K in typeof intent]: PermissionAction } = {
delete: PermissionAction.delete,
"relink-qr-code": PermissionAction.update,
+ "set-reminder": PermissionAction.update,
};
const { organizationId } = await requirePermission({
@@ -174,6 +184,41 @@ export async function action({ context, request, params }: ActionFunctionArgs) {
return json(data({ success: true }));
}
+ case "set-reminder": {
+ const { redirectTo, ...payload } = parseData(
+ formData,
+ setReminderSchema
+ );
+ const hints = getHints(request);
+
+ const fmt = "yyyy-MM-dd'T'HH:mm";
+
+ const alertDateTime = DateTime.fromFormat(
+ formData.get("alertDateTime")!.toString()!,
+ fmt,
+ {
+ zone: hints.timeZone,
+ }
+ ).toJSDate();
+
+ await createAssetReminder({
+ ...payload,
+ assetId: id,
+ alertDateTime,
+ organizationId,
+ createdById: userId,
+ });
+
+ sendNotification({
+ title: "Reminder created",
+ message: "A reminder for you asset has been created successfully.",
+ icon: { name: "success", variant: "success" },
+ senderId: authSession.userId,
+ });
+
+ return redirect(safeRedirect(redirectTo));
+ }
+
default: {
checkExhaustiveSwitch(intent);
return json(data(null));
@@ -205,6 +250,7 @@ export default function AssetDetailsPage() {
{ to: "overview", content: "Overview" },
{ to: "activity", content: "Activity" },
{ to: "bookings", content: "Bookings" },
+ { to: "reminders", content: "Reminders" },
];
/** Due to some conflict of types between prisma and remix, we need to use the SerializeFrom type
diff --git a/app/routes/_layout+/reminders._index.tsx b/app/routes/_layout+/reminders._index.tsx
new file mode 100644
index 000000000..10743b4e8
--- /dev/null
+++ b/app/routes/_layout+/reminders._index.tsx
@@ -0,0 +1,111 @@
+import { json } from "@remix-run/node";
+import type {
+ MetaFunction,
+ LoaderFunctionArgs,
+ ActionFunctionArgs,
+} from "@remix-run/node";
+import RemindersTable from "~/components/asset-reminder/reminders-table";
+import Header from "~/components/layout/header";
+import type { HeaderData } from "~/components/layout/header/types";
+import { getPaginatedAndFilterableReminders } from "~/modules/asset-reminder/service.server";
+import { resolveRemindersActions } from "~/modules/asset-reminder/utils.server";
+import { appendToMetaTitle } from "~/utils/append-to-meta-title";
+import { getDateTimeFormat } from "~/utils/client-hints";
+import { makeShelfError } from "~/utils/error";
+import { data, error } from "~/utils/http.server";
+import {
+ PermissionAction,
+ PermissionEntity,
+} from "~/utils/permissions/permission.data";
+import { requirePermission } from "~/utils/roles.server";
+
+export async function loader({ context, request }: LoaderFunctionArgs) {
+ const authSession = context.getSession();
+ const { userId } = authSession;
+
+ try {
+ const { organizationId } = await requirePermission({
+ userId,
+ request,
+ entity: PermissionEntity.assetReminders,
+ action: PermissionAction.read,
+ });
+
+ const { page, perPage, reminders, totalPages, totalReminders, search } =
+ await getPaginatedAndFilterableReminders({
+ organizationId,
+ request,
+ });
+
+ const header: HeaderData = { title: "Reminders" };
+ const modelName = {
+ singular: "reminder",
+ plural: "reminders",
+ };
+
+ const assetReminders = reminders.map((reminder) => ({
+ ...reminder,
+ displayDate: getDateTimeFormat(request, {
+ dateStyle: "short",
+ timeStyle: "short",
+ }).format(reminder.alertDateTime),
+ }));
+
+ return json(
+ data({
+ header,
+ modelName,
+ items: assetReminders,
+ totalItems: totalReminders,
+ page,
+ perPage,
+ totalPages,
+ searchFieldLabel: "Search reminders",
+ searchFieldTooltip: {
+ title: "Search reminders",
+ text: "Search reminders by reminder name, message, asset name or team member name. Separate your keywords by a comma(,) to search with OR condition. For example: searching 'Laptop, maintenance' will find reminders matching any of these terms.",
+ },
+ search,
+ })
+ );
+ } catch (cause) {
+ const reason = makeShelfError(cause, { userId });
+ throw json(error(reason), { status: reason.status });
+ }
+}
+
+export async function action({ context, request }: ActionFunctionArgs) {
+ const authSession = context.getSession();
+ const userId = authSession.userId;
+
+ try {
+ const { organizationId } = await requirePermission({
+ userId,
+ request,
+ entity: PermissionEntity.asset,
+ action: PermissionAction.update,
+ });
+
+ return await resolveRemindersActions({
+ request,
+ organizationId,
+ userId,
+ });
+ } catch (cause) {
+ const reason = makeShelfError(cause, { userId });
+ return json(error(reason), { status: reason.status });
+ }
+}
+
+export const meta: MetaFunction = ({ data }) => [
+ { title: appendToMetaTitle(data?.header.title) },
+];
+
+export default function Reminders() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/routes/_layout+/reminders.tsx b/app/routes/_layout+/reminders.tsx
new file mode 100644
index 000000000..35b380f65
--- /dev/null
+++ b/app/routes/_layout+/reminders.tsx
@@ -0,0 +1,16 @@
+import { Link, Outlet } from "@remix-run/react";
+import { ErrorContent } from "~/components/errors";
+
+export function loader() {
+ return null;
+}
+
+export const handle = {
+ breadcrumb: () => Reminders,
+};
+
+export default function RemindersPage() {
+ return ;
+}
+
+export const ErrorBoundary = () => ;
diff --git a/app/routes/api+/model-filters.ts b/app/routes/api+/model-filters.ts
index cb2d7f8cc..a690ad5d1 100644
--- a/app/routes/api+/model-filters.ts
+++ b/app/routes/api+/model-filters.ts
@@ -40,6 +40,7 @@ export const ModelFiltersSchema = z.discriminatedUnion("name", [
BasicModelFilters.extend({
name: z.literal("teamMember"),
deletedAt: z.string().nullable().optional(),
+ userWithAdminAndOwnerOnly: z.coerce.boolean().optional(), // To get only the teamMembers which are admin or owner
}),
BasicModelFilters.extend({
name: z.literal("booking"),
@@ -72,8 +73,8 @@ export async function loader({ context, request }: LoaderFunctionArgs) {
}
/** Validating parameters */
- const { name, queryKey, queryValue, selectedValues, ...filters } =
- parseData(searchParams, ModelFiltersSchema);
+ const modelFilters = parseData(searchParams, ModelFiltersSchema);
+ const { name, queryKey, queryValue, selectedValues } = modelFilters;
const where: Record = {
organizationId,
@@ -84,13 +85,32 @@ export async function loader({ context, request }: LoaderFunctionArgs) {
* - teamMember's name
* - teamMember's user firstName, lastName and email
*/
- if (name === "teamMember") {
+ if (modelFilters.name === "teamMember") {
where.OR.push(
{ name: { contains: queryValue, mode: "insensitive" } },
{ user: { firstName: { contains: queryValue, mode: "insensitive" } } },
{ user: { firstName: { contains: queryValue, mode: "insensitive" } } },
{ user: { email: { contains: queryValue, mode: "insensitive" } } }
);
+
+ where.deletedAt = modelFilters.deletedAt;
+ if (modelFilters.userWithAdminAndOwnerOnly) {
+ where.AND = [
+ { user: { isNot: null } },
+ {
+ user: {
+ userOrganizations: {
+ some: {
+ AND: [
+ { organizationId },
+ { roles: { hasSome: ["ADMIN", "OWNER"] } },
+ ],
+ },
+ },
+ },
+ },
+ ];
+ }
} else {
where.OR.push({
[queryKey]: { contains: queryValue, mode: "insensitive" },
@@ -98,7 +118,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) {
}
const queryData = (await db[name].dynamicFindMany({
- where: { ...where, ...filters },
+ where,
include:
/** We need user's information to resolve teamMember's name */
name === "teamMember"
diff --git a/app/routes/api+/reminders.team-members.ts b/app/routes/api+/reminders.team-members.ts
new file mode 100644
index 000000000..a9b4a7a9b
--- /dev/null
+++ b/app/routes/api+/reminders.team-members.ts
@@ -0,0 +1,71 @@
+import type { Prisma } from "@prisma/client";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { json } from "@remix-run/node";
+import { db } from "~/database/db.server";
+import { makeShelfError } from "~/utils/error";
+import { data, error } from "~/utils/http.server";
+import {
+ PermissionAction,
+ PermissionEntity,
+} from "~/utils/permissions/permission.data";
+import { requirePermission } from "~/utils/roles.server";
+
+const TEAM_MEMBER_INCLUDE = {
+ custodies: true,
+ user: {
+ select: {
+ id: true,
+ email: true,
+ firstName: true,
+ lastName: true,
+ profilePicture: true,
+ },
+ },
+} satisfies Prisma.TeamMemberInclude;
+
+export type ReminderTeamMember = Prisma.TeamMemberGetPayload<{
+ include: typeof TEAM_MEMBER_INCLUDE;
+}>;
+
+export async function loader({ context, request }: LoaderFunctionArgs) {
+ const authSession = context.getSession();
+ const userId = authSession.userId;
+
+ try {
+ const { organizationId } = await requirePermission({
+ userId,
+ request,
+ entity: PermissionEntity.teamMember,
+ action: PermissionAction.read,
+ });
+
+ const teamMembers = await db.teamMember.findMany({
+ where: {
+ deletedAt: null,
+ organizationId,
+ AND: [
+ { user: { isNot: null } },
+ {
+ user: {
+ userOrganizations: {
+ some: {
+ AND: [
+ { organizationId },
+ { roles: { hasSome: ["ADMIN", "OWNER"] } },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ orderBy: { createdAt: "desc" },
+ include: TEAM_MEMBER_INCLUDE,
+ });
+
+ return json(data({ teamMembers }));
+ } catch (cause) {
+ const reason = makeShelfError(cause, { userId });
+ return json(error(reason), { status: reason.status });
+ }
+}
diff --git a/app/utils/csv.server.ts b/app/utils/csv.server.ts
index 69cc295e1..b4b362917 100644
--- a/app/utils/csv.server.ts
+++ b/app/utils/csv.server.ts
@@ -359,6 +359,10 @@ export const buildCsvExportDataFromAssets = ({
case "availableToBook":
value = asset.availableToBook ? "Yes" : "No";
break;
+ case "upcomingReminder": {
+ value = asset.upcomingReminder?.displayDate;
+ break;
+ }
default:
checkExhaustiveSwitch(fieldName);
value = "";
diff --git a/app/utils/error.ts b/app/utils/error.ts
index 238533b40..df95725d9 100644
--- a/app/utils/error.ts
+++ b/app/utils/error.ts
@@ -91,7 +91,9 @@ export type FailureReason = {
| "Dev error" // Error that should never happen in production because it's a developer mistake
| "Environment" // Related to the environment setup
| "Image Import"
- | "Image Cache"; // Error related to the image import
+ | "Image Cache"
+ | "Asset Reminder"
+ | "Asset Scheduler"; // Error related to the image import
/**
* The message intended for the user.
* You can add new lines using \n which will be parsed into paragraphs in the html
diff --git a/app/utils/permissions/permission.data.ts b/app/utils/permissions/permission.data.ts
index 03c978446..dd08be866 100644
--- a/app/utils/permissions/permission.data.ts
+++ b/app/utils/permissions/permission.data.ts
@@ -33,6 +33,7 @@ export enum PermissionEntity {
note = "note",
scan = "scan",
custody = "custody",
+ assetReminders = "assetReminders",
}
//this will come from DB eventually
@@ -64,6 +65,7 @@ export const Role2PermissionMap: {
[PermissionEntity.note]: [],
[PermissionEntity.scan]: [],
[PermissionEntity.custody]: [],
+ [PermissionEntity.assetReminders]: [],
},
[OrganizationRoles.SELF_SERVICE]: {
[PermissionEntity.asset]: [PermissionAction.read, PermissionAction.custody],
@@ -94,5 +96,6 @@ export const Role2PermissionMap: {
[PermissionEntity.note]: [],
[PermissionEntity.scan]: [],
[PermissionEntity.custody]: [PermissionAction.read],
+ [PermissionEntity.assetReminders]: [],
},
};