Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add expense comments #165

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@
"cancel": "Cancel",
"reimbursement": "Reimbursement"
},
"ExpenseComments": {
"title": "Comments",
"noComments": "This expense does not contain any comments yet.",
"noActiveParticipant": "Select an active participant to add and edit comments",
"addComment": "Add Comment",
"editComment": "Edit Comment",
"commentPlaceholder": "Enter your comment",
"addCaption": "Add Comment",
"addingCaption": "Adding",
"saveCaption": "Save Comment",
"savingCaption": "Saving",
"cancel": "Cancel"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "The file is too big",
Expand Down
13 changes: 13 additions & 0 deletions messages/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@
"cancel": "Peruuta",
"reimbursement": "Velanmaksu"
},
"ExpenseComments": {
"title": "Comments",
"noComments": "This expense does not contain any comments yet.",
"noActiveParticipant": "Select an active participant to add and edit comments",
"addComment": "Add Comment",
"editComment": "Edit Comment",
"commentPlaceholder": "Enter your comment",
"addCaption": "Add Comment",
"addingCaption": "Adding",
"saveCaption": "Save Comment",
"savingCaption": "Tallennetaan…",
"cancel": "Peruuta"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Tiedosto on liian suuri",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "ExpenseComment" (
"id" TEXT NOT NULL,
"comment" TEXT NOT NULL,
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expenseId" TEXT,
"participantId" TEXT NOT NULL,

CONSTRAINT "ExpenseComment_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "ExpenseComment" ADD CONSTRAINT "ExpenseComment_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ExpenseComment" ADD CONSTRAINT "ExpenseComment_participantId_fkey" FOREIGN KEY ("participantId") REFERENCES "Participant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ model Participant {
groupId String
expensesPaidBy Expense[]
expensesPaidFor ExpensePaidFor[]
expenseComments ExpenseComment[]
}

model Category {
Expand All @@ -54,9 +55,20 @@ model Expense {
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
comments ExpenseComment[]
notes String?
}

model ExpenseComment {
id String @id
comment String
time DateTime @default(now())
Expense Expense? @relation(fields: [expenseId], references: [id])
expenseId String?
participantId String
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
}

model ExpenseDocument {
id String @id
url String
Expand Down
168 changes: 168 additions & 0 deletions src/app/groups/[groupId]/expenses/[expenseId]/edit/comment-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
'use client'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer'
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'
import { Textarea } from '@/components/ui/textarea'
import { getComment, getGroup } from '@/lib/api'
import { useActiveUser, useMediaQuery } from '@/lib/hooks'
import { CommentFormValues, commentFormSchema } from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'

export type ModalProps = {
isOpen: boolean
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
comment?: NonNullable<Awaited<ReturnType<typeof getComment>>>
onCreate: (values: CommentFormValues, participantId: string) => Promise<void>
onUpdate: (values: CommentFormValues, commentId: string) => Promise<void>
onCancel: () => void
updateOpen: (open: boolean) => void
}

export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
comment?: NonNullable<Awaited<ReturnType<typeof getComment>>>
onCreate: (values: CommentFormValues, participantId: string) => Promise<void>
onUpdate: (values: CommentFormValues, commentId: string) => Promise<void>
onCancel: () => void
className?: string
}

export function CommentModal({
isOpen,
group,
comment,
onCreate,
onUpdate,
onCancel,
updateOpen,
}: ModalProps) {
const t = useTranslations('ExpenseComments')
const isDesktop = useMediaQuery('(min-width: 768px)')
const dialogTitle = comment ? t('editComment') : t('addComment')

if (isDesktop) {
return (
<Dialog open={isOpen} onOpenChange={updateOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<CommentForm
group={group}
comment={comment}
onCreate={onCreate}
onUpdate={onUpdate}
onCancel={onCancel}
/>
</DialogContent>
</Dialog>
)
}

return (
<Drawer open={isOpen} onOpenChange={updateOpen}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{dialogTitle}</DrawerTitle>
</DrawerHeader>
<CommentForm
group={group}
comment={comment}
onCreate={onCreate}
onUpdate={onUpdate}
onCancel={onCancel}
className="px-4"
/>
</DrawerContent>
</Drawer>
)
}

export function CommentForm({
group,
comment,
onCreate,
onUpdate,
onCancel,
className,
}: Props) {
const t = useTranslations('ExpenseComments')
const isCreate = comment === undefined
const activeUserId = useActiveUser(group.id)

const form = useForm<CommentFormValues>({
resolver: zodResolver(commentFormSchema),
defaultValues: comment
? {
comment: comment.comment,
}
: {
comment: '',
},
values: comment ? { comment: comment.comment } : { comment: '' },
})

const submit = async (values: CommentFormValues) => {
if (comment) {
return onUpdate(values, comment!.id)
} else {
return onCreate(values, activeUserId!)
}
}

return activeUserId == 'none' ? (
<div>{t('noActiveParticipant')}</div>
) : (
<Form {...form}>
<form
className={cn('grid items-start gap-4', className)}
onSubmit={form.handleSubmit(submit)}
>
<FormField
control={form.control}
name="comment"
render={({ field }) => {
return (
<FormItem className="sm:order-1">
<FormControl>
<Textarea
className="text-base"
{...field}
placeholder={t('commentPlaceholder')}
/>
</FormControl>
</FormItem>
)
}}
/>
<div className="sm:order-2">
<SubmitButton
loadingContent={isCreate ? t('addingCaption') : t('savingCaption')}
>
<Save className="w-4 h-4 mr-2" />
{isCreate ? t('addCaption') : t('saveCaption')}
</SubmitButton>
<Button type="button" variant="ghost" onClick={onCancel}>
{t('cancel')}
</Button>
</div>
</form>
</Form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client'
import { Button } from '@/components/ui/button'
import { getComment, getGroup } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
import { formatDate } from '@/lib/utils'
import { Edit2, Trash2 } from 'lucide-react'
import { useLocale } from 'next-intl'

export type Props = {
comment: NonNullable<Awaited<ReturnType<typeof getComment>>>
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
onDelete: (commentId: string) => Promise<void>
onClick: (
comment: NonNullable<Awaited<ReturnType<typeof getComment>>>,
) => void
}

export function CommentItem({ comment, group, onDelete, onClick }: Props) {
const activeUserId = useActiveUser(group.id)
const locale = useLocale()

return (
<div className="flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch">
<div className="flex-1">
<div className="mb-1">{comment.comment}</div>
<div className="text-xs text-muted-foreground">
by {comment.participant.name},{' '}
{formatDate(comment.time, locale, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
{comment.participantId == activeUserId ? (
<Button
variant="ghost"
onClick={() => {
onClick(comment)
}}
>
<Edit2 className="w-4 h-4" />
</Button>
) : (
<></>
)}
<Button variant="ghost" onClick={() => onDelete(comment.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)
}
Loading
Loading