3 Sitoutukset e63043483f ... acacd955e3

Tekijä SHA1 Viesti Päivämäärä
  Mohammad Mahdi Salimi acacd955e3 feat: implement edit evaluation 2 viikkoa sitten
  Mohammad Mahdi Salimi 28ca0430fa feat: implement approve evaluation 2 viikkoa sitten
  Mohammad Mahdi Salimi a00eba6ebb feat: retry evaluation disabled in pending status 2 viikkoa sitten

+ 4 - 1
src/constants/evaluations/evaluations-constants.ts

@@ -1,5 +1,8 @@
-export const evaluationsStatus = {
+import { type EvaluationStatus } from '@/types/evaluations.types'
+
+export const evaluationsStatus: Record<EvaluationStatus, string> = {
   approved: 'تکمیل شده',
+  approved_ai: 'در انتظار تایید',
   pending: 'در حال بررسی',
   reject: 'رد شده',
 }

+ 35 - 2
src/features/evaluations/components/data-table-row-actions.tsx

@@ -1,6 +1,6 @@
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { type Row } from '@tanstack/react-table'
-import { Eye, RefreshCcw } from 'lucide-react'
+import { CircleCheck, Eye, Pencil, RefreshCcw } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import {
   DropdownMenu,
@@ -32,6 +32,16 @@ export function DataTableRowActions({ row }: Props) {
     setOpen('view')
   }
 
+  const handleShowConfirmApprove = () => {
+    setCurrentRow(evaluations)
+    setOpen('approve')
+  }
+
+  const handleEdit = () => {
+    setCurrentRow(evaluations)
+    setOpen('edit')
+  }
+
   return (
     <DropdownMenu>
       <DropdownMenuTrigger asChild>
@@ -48,7 +58,30 @@ export function DataTableRowActions({ row }: Props) {
           </DropdownMenuShortcut>
         </DropdownMenuItem>
 
-        <DropdownMenuItem onClick={handleShowConfirmRetry}>
+        <DropdownMenuItem
+          onClick={handleEdit}
+          disabled={evaluations?.status === 'pending'}
+        >
+          ویرایش ارزیابی
+          <DropdownMenuShortcut>
+            <Pencil size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        <DropdownMenuItem
+          onClick={handleShowConfirmApprove}
+          disabled={evaluations?.status !== 'approved_ai'}
+        >
+          تایید ارزیابی
+          <DropdownMenuShortcut>
+            <CircleCheck size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        <DropdownMenuItem
+          onClick={handleShowConfirmRetry}
+          disabled={evaluations?.status === 'pending'}
+        >
           ارزیابی مجدد
           <DropdownMenuShortcut>
             <RefreshCcw size={16} />

+ 66 - 0
src/features/evaluations/components/evaluations-approve-dialog.tsx

@@ -0,0 +1,66 @@
+import { ConfirmDialog } from '@/components/confirm-dialog'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { useUpdateEvaluationStatus } from '@/hooks/evaluations/useUpdateEvaluationStatus'
+import { type Evaluation } from '@/types/evaluations.types'
+import { CircleCheck } from 'lucide-react'
+import { toast } from 'sonner'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: Evaluation | null
+}
+
+export function EvaluationsApproveDialog({
+  open,
+  onOpenChange,
+  currentRow,
+}: Props) {
+  const { mutate, isPending } = useUpdateEvaluationStatus()
+
+  if (!currentRow) return null
+
+  const handleApprove = () => {
+    mutate(
+      {
+        id: currentRow.id,
+        payload: { status: 'approved' },
+      },
+      {
+        onSuccess: () => {
+          toast.success('ارزیابی با موفقیت تایید شد.')
+          onOpenChange(false)
+        },
+
+        onError: () => {
+          toast.error('خطا در تایید ارزیابی!')
+        },
+      }
+    )
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      title={
+        <span>
+          <CircleCheck className='me-1 inline-block' size={18} /> تایید ارزیابی
+        </span>
+      }
+      desc={
+        <Alert variant='default'>
+          <AlertTitle>توجه!</AlertTitle>
+          <AlertDescription>
+            با تایید این ارزیابی، وضعیت آن به تکمیل شده تغییر می‌کند. این عملیات
+            را با اطمینان انجام دهید.
+          </AlertDescription>
+        </Alert>
+      }
+      confirmText='تایید ارزیابی'
+      cancelBtnText='انصراف'
+      handleConfirm={handleApprove}
+      isLoading={isPending}
+    />
+  )
+}

+ 8 - 2
src/features/evaluations/components/evaluations-columns.tsx

@@ -119,9 +119,15 @@ export const evaluationColumns: ColumnDef<Evaluation>[] = [
           ? 'destructive'
           : status === 'pending'
             ? 'outline'
-            : 'secondary'
+            : status === 'approved_ai'
+              ? 'default'
+              : 'secondary'
 
-      return <Badge variant={variant}>{evaluationsStatus[status!]}</Badge>
+      return (
+        <Badge variant={variant}>
+          {status ? evaluationsStatus[status] : '-'}
+        </Badge>
+      )
     },
   },
 

+ 9 - 0
src/features/evaluations/components/evaluations-dialogs.tsx

@@ -1,4 +1,6 @@
 import { EvaluationsActionDialog } from './evaluations-action-dialog'
+import { EvaluationsApproveDialog } from './evaluations-approve-dialog'
+import { EvaluationsEditDialog } from './evaluations-edit-dialog'
 import { useEvaluations } from './evaluations-provider'
 import { EvaluationsRetryDialog } from './evaluations-retry-dialog'
 
@@ -7,12 +9,19 @@ export function EvaluationsDialogs() {
   return (
     <>
       <EvaluationsActionDialog />
+      <EvaluationsEditDialog />
 
       <EvaluationsRetryDialog
         open={open === 'retry'}
         onOpenChange={() => setOpen(null)}
         currentRow={currentRow}
       />
+
+      <EvaluationsApproveDialog
+        open={open === 'approve'}
+        onOpenChange={() => setOpen(null)}
+        currentRow={currentRow}
+      />
     </>
   )
 }

+ 303 - 0
src/features/evaluations/components/evaluations-edit-dialog.tsx

@@ -0,0 +1,303 @@
+import { useForm, useFieldArray } from 'react-hook-form'
+import { z } from 'zod'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import VideoPlayer from '@/components/video-player'
+
+import { useEvaluations } from './evaluations-provider'
+import { useEvaluation } from '@/hooks/evaluations/useEvaluation'
+import { useUpdateEvaluationDetails } from '@/hooks/evaluations/useUpdateEvaluationDetails'
+import { type Evaluation } from '@/types/evaluations.types'
+
+const schema = z.object({
+  pros_comment: z.string(),
+  cons_comment: z.string(),
+  suggestion: z.string(),
+  summary: z.string(),
+  score: z.number({ error: 'نمره ارزیابی الزامی است' }),
+  items: z.array(
+    z.object({
+      item_id: z.number(),
+      name: z.string(),
+      score: z.number({ error: 'نمره آیتم الزامی است' }),
+    })
+  ),
+})
+
+type FormValues = z.infer<typeof schema>
+
+function getDefaultValues(evaluation: Evaluation): FormValues {
+  return {
+    pros_comment: evaluation.pros_comment ?? '',
+    cons_comment: evaluation.cons_comment ?? '',
+    suggestion: evaluation.suggestion ?? '',
+    summary: evaluation.summary ?? '',
+    score: evaluation.score ? Number(evaluation.score) : 0,
+    items: evaluation.items.map((item) => ({
+      item_id: item.item.id,
+      name: item.item.name,
+      score: item.score,
+    })),
+  }
+}
+
+export function EvaluationsEditDialog() {
+  const { open, setOpen, currentRow } = useEvaluations()
+  const opened = open === 'edit'
+
+  const { data: evaluation, isLoading } = useEvaluation(
+    opened ? (currentRow?.id as number) : ''
+  )
+  const mediaSrc = evaluation?.media?.[0]?.url
+
+  const handleClose = () => setOpen(null)
+
+  return (
+    <Dialog open={opened} modal onOpenChange={handleClose}>
+      <DialogContent className='max-h-[90vh] max-w-5xl overflow-y-auto'>
+        <DialogHeader>
+          <DialogTitle>ویرایش ارزیابی</DialogTitle>
+        </DialogHeader>
+
+        {isLoading || !evaluation ? (
+          <div className='py-10 text-center'>در حال دریافت اطلاعات...</div>
+        ) : (
+          <div className='flex flex-col gap-4'>
+            {mediaSrc && <VideoPlayer src={mediaSrc} />}
+
+            {evaluation.items.length === 0 ? (
+              <div className='flex items-center justify-center gap-3 rounded-xl border bg-muted px-2 py-4 text-center text-sm font-bold text-primary'>
+                <AlertTriangle />
+                <span>ارزیابی برای این کاربر یافت نشد.</span>
+              </div>
+            ) : (
+              <EvaluationEditForm
+                key={evaluation.id}
+                evaluation={evaluation}
+                onClose={handleClose}
+              />
+            )}
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+type EvaluationEditFormProps = {
+  evaluation: Evaluation
+  onClose: () => void
+}
+
+function EvaluationEditForm({ evaluation, onClose }: EvaluationEditFormProps) {
+  const { mutate, isPending } = useUpdateEvaluationDetails()
+
+  const form = useForm<FormValues>({
+    resolver: zodResolver(schema),
+    defaultValues: getDefaultValues(evaluation),
+  })
+
+  const { fields } = useFieldArray({
+    control: form.control,
+    name: 'items',
+  })
+
+  const onSubmit = (values: FormValues) => {
+    mutate(
+      {
+        id: evaluation.id,
+        payload: {
+          pros_comment: values.pros_comment,
+          cons_comment: values.cons_comment,
+          suggestion: values.suggestion,
+          summary: values.summary,
+          score: values.score,
+          items: values.items.map((item) => ({
+            item_id: item.item_id,
+            score: item.score,
+          })),
+        },
+      },
+      {
+        onSuccess: () => {
+          toast.success('ویرایش ارزیابی با موفقیت انجام شد.')
+          onClose()
+        },
+        onError: () => {
+          toast.error('خطا در ویرایش ارزیابی!')
+        },
+      }
+    )
+  }
+
+  return (
+    <Form {...form}>
+      <form
+        onSubmit={form.handleSubmit(onSubmit)}
+        className='flex flex-col gap-4'
+      >
+        <h2>نتیجه ارزیابی</h2>
+        <div className='overflow-hidden rounded-2xl border border-slate-200'>
+          <Table>
+            <TableBody>
+              {fields.map((fieldItem, index) => (
+                <TableRow
+                  key={fieldItem.id}
+                  className='border-b border-slate-100 last:border-0 hover:bg-transparent'
+                >
+                  <TableCell className='p-3 text-right text-sm leading-5'>
+                    {fieldItem.name}
+                  </TableCell>
+                  <TableCell className='p-3'>
+                    <FormField
+                      control={form.control}
+                      name={`items.${index}.score`}
+                      render={({ field }) => (
+                        <FormItem>
+                          <div className='flex items-center justify-end gap-1'>
+                            <span className='text-foreground'>100 /</span>
+                            <FormControl>
+                              <Input
+                                type='number'
+                                className='h-8 w-20 text-center text-base'
+                                value={field.value ?? ''}
+                                onChange={(e) => {
+                                  const value = e.target.value
+                                  field.onChange(
+                                    value === '' ? 0 : Number(value)
+                                  )
+                                }}
+                              />
+                            </FormControl>
+                          </div>
+                          <FormMessage />
+                        </FormItem>
+                      )}
+                    />
+                  </TableCell>
+                </TableRow>
+              ))}
+              <TableRow className='border-b border-slate-100 last:border-0 hover:bg-transparent'>
+                <TableCell className='p-3 text-right text-sm leading-5 font-semibold'>
+                  مجموع عملکرد
+                </TableCell>
+                <TableCell className='p-3'>
+                  <FormField
+                    control={form.control}
+                    name='score'
+                    render={({ field }) => (
+                      <FormItem>
+                        <div className='flex items-center justify-end gap-1'>
+                          <span className='text-base font-semibold'>100 /</span>
+                          <FormControl>
+                            <Input
+                              type='number'
+                              className='h-8 w-20 text-center text-base font-semibold'
+                              value={field.value ?? ''}
+                              onChange={(e) => {
+                                const value = e.target.value
+                                field.onChange(
+                                  value === '' ? 0 : Number(value)
+                                )
+                              }}
+                            />
+                          </FormControl>
+                        </div>
+                        <FormMessage />
+                      </FormItem>
+                    )}
+                  />
+                </TableCell>
+              </TableRow>
+            </TableBody>
+          </Table>
+        </div>
+
+        <FormField
+          control={form.control}
+          name='pros_comment'
+          render={({ field }) => (
+            <FormItem className='flex flex-col gap-2'>
+              <FormLabel className='leading-6'>نقاط قوت:</FormLabel>
+              <FormControl>
+                <Textarea {...field} rows={4} />
+              </FormControl>
+              <FormMessage />
+            </FormItem>
+          )}
+        />
+
+        <FormField
+          control={form.control}
+          name='cons_comment'
+          render={({ field }) => (
+            <FormItem className='flex flex-col gap-2'>
+              <FormLabel className='leading-6'>نقاط ضعف:</FormLabel>
+              <FormControl>
+                <Textarea {...field} rows={4} />
+              </FormControl>
+              <FormMessage />
+            </FormItem>
+          )}
+        />
+
+        <FormField
+          control={form.control}
+          name='suggestion'
+          render={({ field }) => (
+            <FormItem className='flex flex-col gap-2'>
+              <FormLabel className='leading-6'>تمرین پیشنهادی:</FormLabel>
+              <FormControl>
+                <Textarea {...field} rows={4} />
+              </FormControl>
+              <FormMessage />
+            </FormItem>
+          )}
+        />
+
+        <FormField
+          control={form.control}
+          name='summary'
+          render={({ field }) => (
+            <FormItem className='flex flex-col gap-2'>
+              <FormLabel className='leading-6'>نتیجه:</FormLabel>
+              <FormControl>
+                <Textarea {...field} rows={4} />
+              </FormControl>
+              <FormMessage />
+            </FormItem>
+          )}
+        />
+
+        <div className='flex justify-end gap-3'>
+          <Button type='button' variant='outline' onClick={onClose}>
+            انصراف
+          </Button>
+          <Button type='submit' disabled={isPending}>
+            {isPending ? 'در حال ارسال...' : 'ذخیره تغییرات'}
+          </Button>
+        </div>
+      </form>
+    </Form>
+  )
+}

+ 1 - 1
src/features/evaluations/components/evaluations-provider.tsx

@@ -1,7 +1,7 @@
 import { type Evaluation } from '@/types/evaluations.types'
 import React, { createContext, useContext, useState } from 'react'
 
-type EvaluationsDialogType = 'retry' | 'view'
+type EvaluationsDialogType = 'retry' | 'view' | 'approve' | 'edit'
 
 interface EvaluationsContextType {
   open: EvaluationsDialogType | null

+ 1 - 0
src/features/evaluations/components/evaluations-table.tsx

@@ -121,6 +121,7 @@ export function EvaluationsTable({
             multiple: false,
             options: [
               { label: 'تکمیل شده', value: 'approved' },
+              { label: 'در انتظار تایید', value: 'approved_ai' },
               { label: 'در حال بررسی', value: 'pending' },
               { label: 'رد شده', value: 'reject' },
             ],

+ 28 - 0
src/hooks/evaluations/useUpdateEvaluationDetails.tsx

@@ -0,0 +1,28 @@
+import { queryKeys } from '@/core/react-query/keys'
+import { evaluationsService } from '@/services/evaluations.service'
+import { type UpdateEvaluationDetails } from '@/types/evaluations.types'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+type UpdateEvaluationDetailsVariables = {
+  id: number
+  payload: UpdateEvaluationDetails
+}
+
+export function useUpdateEvaluationDetails() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: UpdateEvaluationDetailsVariables) =>
+      evaluationsService.updateDetails(id, payload),
+
+    onSuccess: (_, variables) => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.detail(variables.id),
+      })
+
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.lists(),
+      })
+    },
+  })
+}

+ 28 - 0
src/hooks/evaluations/useUpdateEvaluationStatus.tsx

@@ -0,0 +1,28 @@
+import { queryKeys } from '@/core/react-query/keys'
+import { evaluationsService } from '@/services/evaluations.service'
+import { type UpdateEvaluationStatus } from '@/types/evaluations.types'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+type UpdateEvaluationStatusVariables = {
+  id: number
+  payload: UpdateEvaluationStatus
+}
+
+export function useUpdateEvaluationStatus() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: UpdateEvaluationStatusVariables) =>
+      evaluationsService.updateStatus(id, payload),
+
+    onSuccess: (_, variables) => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.detail(variables.id),
+      })
+
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.evaluations.lists(),
+      })
+    },
+  })
+}

+ 6 - 1
src/routes/_authenticated/evaluations/index.tsx

@@ -6,7 +6,12 @@ const evaluationsSearchSchema = z.object({
   page: z.coerce.number().optional().catch(1),
   pageSize: z.coerce.number().optional().catch(10),
   status: z
-    .union([z.literal('approved'), z.literal('pending'), z.literal('reject')])
+    .union([
+      z.literal('approved'),
+      z.literal('approved_ai'),
+      z.literal('pending'),
+      z.literal('reject'),
+    ])
     .optional()
     .catch(undefined),
 })

+ 24 - 0
src/services/evaluations.service.ts

@@ -5,6 +5,8 @@ import type {
   GetEvaluationDetailResponse,
   GetEvaluationItems,
   GetEvaluationsResponse,
+  UpdateEvaluationDetails,
+  UpdateEvaluationStatus,
 } from '@/types/evaluations.types'
 
 const BASE_URL = '/admin/evaluations'
@@ -50,4 +52,26 @@ export const evaluationsService = {
     )
     return res?.data
   },
+
+  updateStatus: async (
+    evaluationId: number | string,
+    payload: UpdateEvaluationStatus
+  ) => {
+    const res = await http.post<ApiResponse<void>>(
+      `${BASE_URL}/${evaluationId}/status`,
+      payload
+    )
+    return res?.data
+  },
+
+  updateDetails: async (
+    evaluationId: number | string,
+    payload: UpdateEvaluationDetails
+  ) => {
+    const res = await http.post<ApiResponse<void>>(
+      `${BASE_URL}/${evaluationId}/details`,
+      payload
+    )
+    return res?.data
+  },
 }

+ 23 - 1
src/types/evaluations.types.ts

@@ -1,5 +1,11 @@
 import { type Pagination } from './common.types'
 
+export type EvaluationStatus =
+  | 'approved'
+  | 'approved_ai'
+  | 'pending'
+  | 'reject'
+
 export interface Evaluation {
   id: number
   score: null | string
@@ -7,7 +13,7 @@ export interface Evaluation {
   cons_comment: null | string
   suggestion: null | string
   summary: null | string
-  status: 'approved' | 'pending' | 'reject' | null
+  status: EvaluationStatus | null
   media: Media[]
   jalali_date: string
   items: Item[]
@@ -149,3 +155,19 @@ export interface CreateEvaluation {
     score: number
   }
 }
+
+export interface UpdateEvaluationStatus {
+  status: EvaluationStatus
+}
+
+export interface UpdateEvaluationDetails {
+  pros_comment: string
+  cons_comment: string
+  suggestion: string
+  summary: string
+  score: number
+  items: {
+    item_id: number
+    score: number
+  }[]
+}