diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx
new file mode 100644
index 0000000000..19b907292c
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx
@@ -0,0 +1,18 @@
+import type { FC } from "react"
+import { useEditNotificationMutation } from "../mutations"
+import { useNotificationWriteForm } from "../write-form"
+import { useNotificationDetailsContext } from "./provider"
+export const NotificationEditCard: FC = () => {
+ const { notification } = useNotificationDetailsContext()
+ const edit = useEditNotificationMutation()
+
+ const FormComponent = useNotificationWriteForm({
+ label: "Oppdater varsling",
+ onSubmit: (data) => {
+ const { ...notificationData } = data
+ edit.mutate({ id: notification.id, input: notificationData})
+ },
+ defaultValues: { ...notification }
+ })
+ return
+}
diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx
new file mode 100644
index 0000000000..9764e9a6f5
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx
@@ -0,0 +1,31 @@
+"use client"
+import { Loader } from "@mantine/core"
+import { type PropsWithChildren, use, useMemo } from "react"
+import { NotificationDetailsContext } from "./provider"
+
+import { useTRPC } from "@/lib/trpc-client"
+import { useQuery } from "@tanstack/react-query"
+
+export default function NotificationDetailsLayout({
+ children,
+ params,
+}: PropsWithChildren<{ params: Promise<{ id: string }> }>) {
+ const trpc = useTRPC()
+ const { id } = use(params)
+ const { data, isLoading } = useQuery(trpc.notification.get.queryOptions(id))
+ const value = useMemo(
+ () =>
+ !data || isLoading
+ ? null
+ : {
+ notification: data,
+ },
+ [data, isLoading]
+ )
+
+ if (value === null) {
+ return
+ }
+
+ return {children}
+}
diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/page.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/page.tsx
new file mode 100644
index 0000000000..c50ca2958d
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/page.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import { Box, CloseButton, Group, Tabs, Title } from "@mantine/core"
+import { IconPhoto } from "@tabler/icons-react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { NotificationEditCard } from "./edit-card"
+import { useNotificationDetailsContext } from "./provider"
+
+const SIDEBAR_LINKS = [
+ {
+ icon: IconPhoto,
+ label: "Info",
+ slug: "info",
+ component: NotificationEditCard,
+ },
+] as const
+
+export default function NotificationDetailsPage() {
+ const { notification } = useNotificationDetailsContext()
+ const router = useRouter()
+
+ const searchParams = useSearchParams()
+ const currentTab = searchParams.get("tab") || SIDEBAR_LINKS[0].slug
+
+ const handleTabChange = (value: string | null) => {
+ const params = new URLSearchParams(searchParams.toString())
+ params.set("tab", value ?? SIDEBAR_LINKS[0].slug)
+ router.replace(`/varsler/${notification.id}?${params.toString()}`)
+ }
+
+ return (
+
+
+ router.back()} />
+ {notification.title}
+
+
+
+
+ {SIDEBAR_LINKS.map(({ label, icon: Icon, slug }) => (
+ }>
+ {label}
+
+ ))}
+
+ {SIDEBAR_LINKS.map(({ slug, component: Component }) => (
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx
new file mode 100644
index 0000000000..96a7656e1c
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx
@@ -0,0 +1,16 @@
+"use client"
+
+import type { Notification } from "@dotkomonline/types"
+import { createContext, useContext } from "react"
+
+export const NotificationDetailsContext = createContext<{
+ notification: Notification
+} | null>(null)
+
+export const useNotificationDetailsContext = () => {
+ const ctx = useContext(NotificationDetailsContext)
+ if (ctx === null) {
+ throw new Error("useNotificationDetailsContext called without Provider in tree")
+ }
+ return ctx
+}
diff --git a/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx b/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx
new file mode 100644
index 0000000000..dc30fd0ad9
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx
@@ -0,0 +1,73 @@
+import { GenericTable } from "@/components/GenericTable"
+import { Anchor } from "@mantine/core"
+import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"
+import Link from "next/link"
+import { useMemo } from "react"
+import { mapNotificationPayloadTypeToLabel, mapNotificationTypeToLabel, Notification } from "node_modules/@dotkomonline/rpc/src/modules/notification/notification"
+import { DateTooltip } from "@/components/DateTooltip"
+
+interface AllNotificationsTableProps {
+ notifications: Notification[]
+}
+
+export const AllNotificationsTable = ({ notifications }: AllNotificationsTableProps) => {
+ const columnHelper = createColumnHelper()
+ const columns = useMemo(
+ () => [
+ columnHelper.accessor((notification) => notification.title, {
+ id: "title",
+ header: () => "Tittel",
+ sortingFn: "alphanumeric",
+ cell: (info) => (
+
+ {info.getValue()}
+
+ ),
+ }),
+
+ columnHelper.accessor((notification) => notification.shortDescription, {
+ id: "shortDescription",
+ header: () => "Kort beskrivelse",
+ cell: (info) => info.getValue(),
+ sortingFn: "alphanumeric",
+ }),
+
+ columnHelper.accessor((notification) => notification.type, {
+ id: "type",
+ header: () => "Type",
+ cell: (info) => mapNotificationTypeToLabel(info.getValue()),
+ sortingFn: "alphanumeric",
+ }),
+ columnHelper.accessor((notification) => notification.payloadType, {
+ id: "payloadType",
+ header: () => "Payload type",
+ cell: (info) => mapNotificationPayloadTypeToLabel(info.getValue()),
+ sortingFn: "alphanumeric",
+ }),
+ columnHelper.accessor((notification) => notification.createdAt, {
+ id: "createdAt",
+ header: () => "Opprettet",
+ cell: (info) => ,
+ sortingFn: "datetime",
+ }),
+ ],
+ [columnHelper]
+
+ )
+
+ const tableOptions = useMemo(
+ () => ({
+ data: notifications,
+ getCoreRowModel: getCoreRowModel(),
+ columns,
+ }),
+ [notifications, columns]
+ )
+
+ const table = useReactTable(tableOptions)
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx b/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx
new file mode 100644
index 0000000000..bf1384fa09
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx
@@ -0,0 +1,26 @@
+import { type ContextModalProps, modals } from "@mantine/modals"
+import type { FC } from "react"
+import { useCreateNotificationMutation } from "../mutations"
+import { useNotificationWriteForm } from "../write-form"
+
+export const CreateNotificationModal: FC = ({ context, id }) => {
+ const close = () => context.closeModal(id)
+ const create = useCreateNotificationMutation()
+ const FormComponent = useNotificationWriteForm({
+ onSubmit: (data) => {
+ create.mutate(
+ data
+ ),
+ close()
+ },
+ })
+ return
+}
+
+export const useCreateNotificationModal = () => () =>
+ modals.openContextModal({
+ modal: "notification/create",
+ title: "Legg inn ny varsling",
+ size: "lg",
+ innerProps: {},
+ })
diff --git a/apps/dashboard/src/app/(internal)/varslinger/mutations.ts b/apps/dashboard/src/app/(internal)/varslinger/mutations.ts
new file mode 100644
index 0000000000..5663479974
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/mutations.ts
@@ -0,0 +1,67 @@
+import { env } from "@/lib/env"
+import { useQueryNotification } from "@/lib/notifications"
+import { useTRPC } from "@/lib/trpc-client"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+
+export const useCreateNotificationMutation = () => {
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const notification = useQueryNotification()
+ return useMutation(
+ trpc.notification.create.mutationOptions({
+ onMutate: () => {
+ notification.loading({
+ title: "Lager varsling...",
+ message: "Varslingen blir opprettet, og du vil bli videresendt til varslingssiden.",
+ })
+ },
+ onSuccess: async (data) => {
+ await queryClient.invalidateQueries({ queryKey: trpc.notification.findMany.queryKey() });
+
+
+ notification.complete({
+ title: "Varsling opprettet",
+ message: `Varslingen "${data.title}" har blitt opprettet.`,
+ })
+ },
+ onError: (err) => {
+ notification.fail({
+ title: "Feil oppsto",
+ message: `En feil oppsto under opprettelse av varslingen: ${err.toString()}.`,
+ })
+ },
+ })
+ )
+}
+
+export const useEditNotificationMutation = () => {
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const notification = useQueryNotification()
+
+ return useMutation(
+ trpc.notification.edit.mutationOptions({
+ onMutate: () => {
+ notification.loading({
+ title: "Oppdaterer varsling...",
+ message: "Varslingen blir oppdatert.",
+ })
+ },
+ onSuccess: async (data) => {
+ await queryClient.invalidateQueries(trpc.notification.get.queryOptions(data.id))
+
+ notification.complete({
+ title: "Varslingen oppdatert",
+ message: `Varslingen "${data.title}" har blitt oppdatert.`,
+ })
+ },
+ onError: (err) => {
+ notification.fail({
+ title: "Feil oppsto",
+ message: `En feil oppsto under oppdatering av varslingen: ${err.toString()}.`,
+ })
+ },
+ })
+ )
+}
+
diff --git a/apps/dashboard/src/app/(internal)/varslinger/page.tsx b/apps/dashboard/src/app/(internal)/varslinger/page.tsx
new file mode 100644
index 0000000000..c107ed80a0
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/page.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import { Box, Button, Skeleton, Stack } from "@mantine/core"
+import { AllNotificationsTable } from "./all-notification-table"
+import { useCreateNotificationModal } from "./modals/create-notification"
+import { useNotificationAllQuery } from "./queries"
+
+
+export default function NotificationPage() {
+ const { notifications, isLoading: isNotificationsLoading } = useNotificationAllQuery()
+ const open = useCreateNotificationModal()
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/dashboard/src/app/(internal)/varslinger/queries.ts b/apps/dashboard/src/app/(internal)/varslinger/queries.ts
new file mode 100644
index 0000000000..e20309fecc
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/queries.ts
@@ -0,0 +1,18 @@
+import { useTRPC } from "@/lib/trpc-client"
+import { Pageable } from "@dotkomonline/rpc"
+import { useInfiniteQuery } from "@tanstack/react-query"
+import { useMemo } from "react"
+
+export const useNotificationAllQuery = (page?: Pageable) => {
+ const trpc = useTRPC()
+
+ const { data, ...query } = useInfiniteQuery({
+ ...trpc.notification.findMany.infiniteQueryOptions({ ...page }),
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ select: (res) => res.pages.flatMap((p) => p.items),
+ })
+
+ return { notifications: useMemo(() => data ?? [], [data]), ...query }
+}
+
+
diff --git a/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx b/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx
new file mode 100644
index 0000000000..48c379147f
--- /dev/null
+++ b/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx
@@ -0,0 +1,83 @@
+import { useFormBuilder } from "@/components/forms/Form"
+import { createRichTextInput } from "@/components/forms/RichTextInput/RichTextInput"
+import { createSelectInput } from "@/components/forms/SelectInput"
+import { createTextInput } from "@/components/forms/TextInput"
+import { mapNotificationPayloadTypeToLabel, mapNotificationTypeToLabel, NotificationPayloadTypeSchema, NotificationTypeSchema, NotificationWriteSchema } from "node_modules/@dotkomonline/rpc/src/modules/notification/notification"
+import { type z } from "zod"
+import { useGroupAllQuery } from "../grupper/queries"
+
+const NOTIFICATION_FORM_DATA_TYPE = Object.values(NotificationTypeSchema.Values).map((type) => ({
+ value: type,
+ label: mapNotificationTypeToLabel(type),
+}))
+
+const NOTIFICATION_FORM_DATA_PAYLOAD_TYPE = Object.values(NotificationPayloadTypeSchema.Values).map((type) => ({
+ value: type,
+ label: mapNotificationPayloadTypeToLabel(type),
+}))
+
+const NOTIFICATION_FORM_DEFAULT_VALUES: Partial = {recipientIds: [], taskId: null }
+
+type NotificationWriteFormSchema = z.infer
+
+interface UseNotificationWriteFormProps {
+ onSubmit(data: z.infer): void
+ defaultValues?: Partial
+ label?: string
+}
+
+export const useNotificationWriteForm = ({
+ onSubmit,
+ label = "Legg inn ny varsling",
+ defaultValues = NOTIFICATION_FORM_DEFAULT_VALUES,
+}: UseNotificationWriteFormProps) => {
+
+ const { groups } = useGroupAllQuery()
+
+ return useFormBuilder({
+ schema: NotificationWriteSchema,
+ defaultValues,
+ onSubmit,
+ label,
+ fields: {
+ title: createTextInput({
+ label: "Tittel",
+ placeholder: "Juleball Påmeldingen er åpen!",
+ required: true,
+ }),
+ shortDescription: createTextInput({
+ label: "Kort beskrivelse",
+ placeholder: "En kort beskrivelse av varslingen",
+ required: true,
+ }),
+ content: createRichTextInput({
+ label: "Innhold",
+ required: true,
+ }),
+ type: createSelectInput ({
+ data: NOTIFICATION_FORM_DATA_TYPE,
+ label: "Type",
+ placeholder: "Velg type",
+ required: true,
+ }),
+ payload: createTextInput ({
+ label: "Payload",
+ placeholder: "Paylaod",
+ required: false,
+ }),
+ payloadType: createSelectInput ({
+ label: "payload type",
+ data: NOTIFICATION_FORM_DATA_PAYLOAD_TYPE,
+ placeholder: "Velg type",
+ required: true,
+ }),
+ actorGroupId: createSelectInput({
+ label: "Ansvarlig gruppe",
+ placeholder: "Velg gruppe",
+ data: groups.map((group) => ({ value: group.slug, label: group.abbreviation })),
+ searchable: true,
+ required: true,
+ }),
+ },
+ })
+}
\ No newline at end of file
diff --git a/apps/dashboard/src/app/ApplicationShell.tsx b/apps/dashboard/src/app/ApplicationShell.tsx
index ab53a3c8cd..40fd0eda93 100644
--- a/apps/dashboard/src/app/ApplicationShell.tsx
+++ b/apps/dashboard/src/app/ApplicationShell.tsx
@@ -38,6 +38,7 @@ import {
IconMoneybag,
IconPhoto,
IconPhotoShare,
+ IconBell,
IconSkull,
IconUserMinus,
IconUsersGroup,
@@ -99,6 +100,11 @@ const navigations = [
icon: IconUsersGroup,
href: "/brukere",
},
+ {
+ label: "Varslinger",
+ icon: IconBell,
+ href: "/varslinger",
+ },
{
label: "Plakatbestilling",
icon: IconPhotoShare,
diff --git a/apps/dashboard/src/app/ModalProvider.tsx b/apps/dashboard/src/app/ModalProvider.tsx
index 5ae3577eab..af022f4645 100644
--- a/apps/dashboard/src/app/ModalProvider.tsx
+++ b/apps/dashboard/src/app/ModalProvider.tsx
@@ -25,6 +25,7 @@ import type { FC, PropsWithChildren } from "react"
import { QRCodeScannedModal } from "@/app/(internal)/arrangementer/components/qr-code-scanned-modal"
import { NotifyAttendeesModal } from "@/app/(internal)/arrangementer/components/notify-attendees-modal"
import { CreateGroupMemberModal } from "@/app/(internal)/grupper/modals/create-group-member-modal"
+import { CreateNotificationModal } from "@/app/(internal)/varslinger/modals/create-notification"
const modals = {
"event/attendance/attendee/create": ManualCreateUserAttendModal,
@@ -50,6 +51,7 @@ const modals = {
"user/membership/create": CreateMembershipModal,
"user/membership/update": EditMembershipModal,
"image/upload": UploadImageModal,
+ "notification/create": CreateNotificationModal,
} as const
export const ModalProvider: FC = ({ children }) => (
diff --git a/apps/dashboard/src/components/forms/Form.tsx b/apps/dashboard/src/components/forms/Form.tsx
index fa80445ee7..dcdc8f15b1 100644
--- a/apps/dashboard/src/components/forms/Form.tsx
+++ b/apps/dashboard/src/components/forms/Form.tsx
@@ -56,9 +56,14 @@ export function useFormBuilder({