From 76f36cf24c80d8f4c5344ca99722abb7a6e78a45 Mon Sep 17 00:00:00 2001 From: Gabriel Lemes Date: Sun, 9 Nov 2025 14:57:08 -0300 Subject: [PATCH 1/8] feat(approvals): implement pending requirements management with modal and mock data - Create pending button to open modal; - Create mocked data to populate the screen; - Create pending requirements cards; - Create skeleton to improve UI during page loading; - Create helpers (calculate date, schemas, types). --- .../pendentes/add-pending-button.tsx | 25 ++++ .../aprovacoes/pendentes/mock-data.ts | 67 +++++++++ .../(dashboard)/aprovacoes/pendentes/page.tsx | 4 +- .../patient-pending-requirements.tsx | 132 ++++++++++++++++++ .../pendentes/pending-requirements-modal.tsx | 104 ++++++++++++++ .../aprovacoes/pendentes/skeleton.tsx | 22 +++ src/components/ui/document-tag.tsx | 39 ++++++ src/constants/documents.ts | 11 ++ src/helpers/pending-helpers.ts | 13 ++ src/schemas/index.tsx | 5 + 10 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/aprovacoes/pendentes/add-pending-button.tsx create mode 100644 src/app/(dashboard)/aprovacoes/pendentes/mock-data.ts create mode 100644 src/app/(dashboard)/aprovacoes/pendentes/patient-pending-requirements.tsx create mode 100644 src/app/(dashboard)/aprovacoes/pendentes/pending-requirements-modal.tsx create mode 100644 src/app/(dashboard)/aprovacoes/pendentes/skeleton.tsx create mode 100644 src/components/ui/document-tag.tsx create mode 100644 src/constants/documents.ts create mode 100644 src/helpers/pending-helpers.ts diff --git a/src/app/(dashboard)/aprovacoes/pendentes/add-pending-button.tsx b/src/app/(dashboard)/aprovacoes/pendentes/add-pending-button.tsx new file mode 100644 index 00000000..5df14025 --- /dev/null +++ b/src/app/(dashboard)/aprovacoes/pendentes/add-pending-button.tsx @@ -0,0 +1,25 @@ +'use client' +import { PlusIcon } from 'lucide-react' +import React, { useState } from 'react' + +import { PendingRequirementsModal } from '@/app/(dashboard)/aprovacoes/pendentes/pending-requirements-modal' +import type { ButtonProps } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog/index' +import { DialogTrigger } from '@/components/ui/dialog/trigger' + +export function PendingButton(props: Readonly) { + const [isPendindModalOpen, setIsPendingModalOpen] = useState(false) + + return ( + + + + Adicionar pendência + + + {isPendindModalOpen && ( + + )} + + ) +} diff --git a/src/app/(dashboard)/aprovacoes/pendentes/mock-data.ts b/src/app/(dashboard)/aprovacoes/pendentes/mock-data.ts new file mode 100644 index 00000000..bb09582f --- /dev/null +++ b/src/app/(dashboard)/aprovacoes/pendentes/mock-data.ts @@ -0,0 +1,67 @@ +import { subDays } from 'date-fns' + +import { DocumentType } from '@/constants/documents' + +interface PatientPendingRequirement { + id: string + patientName: string + documentType: DocumentType + createdAt: Date | string +} + +export const mockPendingRequirements: PatientPendingRequirement[] = [ + { + id: '1', + patientName: 'Maria Silva', + documentType: 'laudo', + createdAt: subDays(new Date(), 1), + }, + { + id: '2', + patientName: 'João Santos', + documentType: 'laudo', + createdAt: subDays(new Date(), 4), + }, + { + id: '3', + patientName: 'Ana Oliveira', + documentType: 'laudo', + createdAt: subDays(new Date(), 8), + }, + { + id: '4', + patientName: 'Carlos Pereira', + documentType: 'laudo', + createdAt: subDays(new Date(), 2), + }, + { + id: '5', + patientName: 'Beatriz Costa', + documentType: 'laudo', + createdAt: subDays(new Date(), 5), + }, + { + id: '6', + patientName: 'Fernando Almeida', + documentType: 'laudo', + createdAt: subDays(new Date(), 9), + }, + { + id: '7', + patientName: 'Gabriela Rocha', + documentType: 'laudo', + createdAt: subDays(new Date(), 0), + }, + { + id: '8', + patientName: 'Roberto Lima', + documentType: 'laudo', + createdAt: subDays(new Date(), 6), + }, + { + id: '9', + patientName: 'Isabela Martins', + documentType: 'laudo', + createdAt: subDays(new Date(), 10), + }, +] diff --git a/src/app/(dashboard)/aprovacoes/pendentes/page.tsx b/src/app/(dashboard)/aprovacoes/pendentes/page.tsx index 2836e3da..01f7151b 100644 --- a/src/app/(dashboard)/aprovacoes/pendentes/page.tsx +++ b/src/app/(dashboard)/aprovacoes/pendentes/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next' +import { PatientPendingRequirements } from './patient-pending-requirements' + export const metadata: Metadata = { title: 'Envios pendentes', } export default function Page() { - return

Envios pendentes

+ return } diff --git a/src/app/(dashboard)/aprovacoes/pendentes/patient-pending-requirements.tsx b/src/app/(dashboard)/aprovacoes/pendentes/patient-pending-requirements.tsx new file mode 100644 index 00000000..ea16383d --- /dev/null +++ b/src/app/(dashboard)/aprovacoes/pendentes/patient-pending-requirements.tsx @@ -0,0 +1,132 @@ +'use client' +import { useQuery } from '@tanstack/react-query' +import { Calendar, CircleAlert } from 'lucide-react' + +import { PendingRequirementsSkeleton } from '@/app/(dashboard)/aprovacoes/pendentes/skeleton' +import { DataTableHeader } from '@/components/data-table/header' +import { DataTableHeaderActions } from '@/components/data-table/header/actions' +import { DataTableHeaderSearch } from '@/components/data-table/header/search' +import { Pagination } from '@/components/pagination' +import { Card } from '@/components/ui/card' +import { DocumentTag } from '@/components/ui/document-tag' +import { DocumentType } from '@/constants/documents' +import { + getPendingDays, + getPendingStatusColor, +} from '@/helpers/pending-helpers' +import { cn } from '@/utils/class-name-merge' +import { formatDate } from '@/utils/formatters/format-date' + +import { PendingButton } from './add-pending-button' +import { mockPendingRequirements } from './mock-data' + +interface PendingRequirement { + id: string + patientName: string + documentType: DocumentType + createdAt: Date | string +} + +const PENDING_STATUS_CLASSES: Record< + ReturnType, + string +> = { + error: 'bg-error/10 border-none', + warning: 'bg-warning/10 border-none', + default: 'bg-foreground-soft/10 border-none', +} + +const PENDING_TEXT_CLASSES: Record< + ReturnType, + string +> = { + error: 'text-error', + warning: 'text-warning', + default: 'text-foreground-soft', +} + +export function PatientPendingRequirements() { + const { data, isLoading } = useQuery({ + queryKey: ['pending-requirements'], + // TODO: replace with real API call + queryFn: async () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(mockPendingRequirements) + }, 2000) + }) + }, + }) + + if (isLoading) { + return + } + + if (!data || data.length === 0) + return ( +

+ Nenhuma pendência encontrada. +

+ ) + + return ( + <> + + + + + + + + + {data.map((requirement: PendingRequirement) => { + const daysPending = getPendingDays(requirement.createdAt) + const status = getPendingStatusColor(daysPending) + const cardColor = PENDING_STATUS_CLASSES[status] + const pendingTextColor = PENDING_TEXT_CLASSES[status] + const createdDate = new Date(requirement.createdAt as Date) + + return ( + +
+

+ {requirement.patientName} +

+ +
+

Pendência:

+ +
+ +
+ + Solicitado em: {formatDate(createdDate)} +
+
+ +
+ +

+ Pendente há {daysPending} {daysPending === 1 ? 'dia' : 'dias'} +

+
+
+ ) + })} +
+ + + ) +} diff --git a/src/app/(dashboard)/aprovacoes/pendentes/pending-requirements-modal.tsx b/src/app/(dashboard)/aprovacoes/pendentes/pending-requirements-modal.tsx new file mode 100644 index 00000000..988fefe9 --- /dev/null +++ b/src/app/(dashboard)/aprovacoes/pendentes/pending-requirements-modal.tsx @@ -0,0 +1,104 @@ +'use client' +import { zodResolver } from '@hookform/resolvers/zod' +import { FormProvider, useForm } from 'react-hook-form' +import { z } from 'zod' + +import { SelectInput } from '@/components/form/select-input' +import { TextInput } from '@/components/form/text-input' +import { Button } from '@/components/ui/button' +import { DialogClose } from '@/components/ui/dialog/close' +import { DialogContainer } from '@/components/ui/dialog/container' +import { DialogContent } from '@/components/ui/dialog/content' +import { DialogDescription } from '@/components/ui/dialog/description' +import { DialogFooter } from '@/components/ui/dialog/footer' +import { DialogHeader } from '@/components/ui/dialog/header' +import { DialogTitle } from '@/components/ui/dialog/title' +import { DOCUMENT_OPTIONS } from '@/constants/documents' +import { documentTypeSchema } from '@/schemas' + +import { FormContainer } from '../../../../components/form/form-container' +import { TextareaInput } from '../../../../components/form/textarea-input' + +interface PendingModalProps { + onOpenChange: (open: boolean) => void +} + +const pendingRequirementsFormSchema = z.object({ + patient_name: z.string().min(1, 'O nome do paciente é obrigatório'), + document_type: documentTypeSchema, + annotation: z + .string() + .max(500) + .nullable() + .transform((value) => (!value ? null : value)), +}) +type PendingRequirementsFormSchema = z.infer< + typeof pendingRequirementsFormSchema +> + +export function PendingRequirementsModal({ onOpenChange }: PendingModalProps) { + const formMethods = useForm({ + resolver: zodResolver(pendingRequirementsFormSchema), + defaultValues: { + patient_name: '', + document_type: 'laudo', + annotation: '', + }, + mode: 'onBlur', + }) + + async function submitForm(data: PendingRequirementsFormSchema) { + // TODO: Submit form data to API + console.log(data) + onOpenChange(false) + } + + return ( + + + Adicionar nova pendência + + Crie uma nova solicitação de pendência para este paciente e mantenha o + acompanhamento atualizado. + + + + + + + + + + + + + + + + Cancelar pendência + + + + + ) +} diff --git a/src/app/(dashboard)/aprovacoes/pendentes/skeleton.tsx b/src/app/(dashboard)/aprovacoes/pendentes/skeleton.tsx new file mode 100644 index 00000000..45f4687b --- /dev/null +++ b/src/app/(dashboard)/aprovacoes/pendentes/skeleton.tsx @@ -0,0 +1,22 @@ +import { Card } from '../../../../components/ui/card' +import { Skeleton } from '../../../../components/ui/skeleton' + +export function PendingRequirementsSkeleton() { + return ( +
+ + {Array.from({ length: 3 }).map((_, i) => ( + + + + +
+ + +
+
+ ))} +
+
+ ) +} diff --git a/src/components/ui/document-tag.tsx b/src/components/ui/document-tag.tsx new file mode 100644 index 00000000..b0ead16f --- /dev/null +++ b/src/components/ui/document-tag.tsx @@ -0,0 +1,39 @@ +import { DOCUMENT_TYPE, DocumentType } from '@/constants/documents' +import { + getPendingDays, + getPendingStatusColor, +} from '@/helpers/pending-helpers' +import { cn } from '@/utils/class-name-merge' + +import { Tag } from './tag' + +const PENDING_STATUS_CLASSES: Record< + ReturnType, + string +> = { + error: 'border-none bg-error/15 text-error', + warning: 'border-none bg-warning/15 text-warning', + default: 'border-none bg-foreground-soft/15 text-foreground-soft', +} + +interface DocumentTagProps extends React.ComponentProps<'div'> { + documentType: DocumentType + createdAt: Date | string +} + +export function DocumentTag({ + documentType, + createdAt, + className, + ...props +}: Readonly) { + const daysPending = getPendingDays(createdAt) + const status = getPendingStatusColor(daysPending) + const statusClass = PENDING_STATUS_CLASSES[status] + + return ( + + {DOCUMENT_TYPE[documentType]} + + ) +} diff --git a/src/constants/documents.ts b/src/constants/documents.ts new file mode 100644 index 00000000..256a1ff7 --- /dev/null +++ b/src/constants/documents.ts @@ -0,0 +1,11 @@ +import { convertObjectToOptions } from '@/helpers/convert-object-to-options' + +export const DOCUMENT_TYPE = { + laudo: 'Laudo', + outros: 'Outros', +} as const + +export type DocumentType = keyof typeof DOCUMENT_TYPE + +export const DOCUMENTS_TUPLE = Object.keys(DOCUMENT_TYPE) as ['laudo', 'outros'] +export const DOCUMENT_OPTIONS = convertObjectToOptions(DOCUMENT_TYPE) diff --git a/src/helpers/pending-helpers.ts b/src/helpers/pending-helpers.ts new file mode 100644 index 00000000..701dc295 --- /dev/null +++ b/src/helpers/pending-helpers.ts @@ -0,0 +1,13 @@ +import { differenceInDays } from 'date-fns' + +export const getPendingDays = (createdAt: Date | string): number => { + return differenceInDays(new Date(), new Date(createdAt)) +} + +export const getPendingStatusColor = ( + daysPending: number, +): 'error' | 'warning' | 'default' => { + if (daysPending >= 7) return 'error' + if (daysPending >= 4) return 'warning' + return 'default' +} diff --git a/src/schemas/index.tsx b/src/schemas/index.tsx index ef5d6c71..bc8cf4e7 100644 --- a/src/schemas/index.tsx +++ b/src/schemas/index.tsx @@ -1,5 +1,6 @@ import { z } from 'zod' +import { DOCUMENTS_TUPLE } from '@/constants/documents' import { UF_LIST, YES_OR_NO_TUPLE } from '@/constants/enums' import { CPF_REGEX, @@ -61,3 +62,7 @@ export const kinshipSchema = z export const yesOrNoSchema = z.enum(YES_OR_NO_TUPLE, { message: 'Selecione "Sim" ou "Não"', }) + +export const documentTypeSchema = z.enum(DOCUMENTS_TUPLE, { + message: 'Selecione um tipo de documento válido', +}) From 5209d1ddeefb51779f05060261c4866e6a4fcb5a Mon Sep 17 00:00:00 2001 From: Juliano Sill Date: Sun, 16 Nov 2025 22:54:55 -0300 Subject: [PATCH 2/8] chore(components): update dialog element sizes --- src/components/ui/dialog/container.tsx | 2 +- src/components/ui/dialog/description.tsx | 2 +- src/components/ui/dialog/header.tsx | 2 +- src/components/ui/dialog/title.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ui/dialog/container.tsx b/src/components/ui/dialog/container.tsx index a6dc81a4..91c2ab42 100644 --- a/src/components/ui/dialog/container.tsx +++ b/src/components/ui/dialog/container.tsx @@ -31,7 +31,7 @@ export function DialogContainer({ diff --git a/src/components/ui/dialog/description.tsx b/src/components/ui/dialog/description.tsx index 204e153b..16b0ff23 100644 --- a/src/components/ui/dialog/description.tsx +++ b/src/components/ui/dialog/description.tsx @@ -8,7 +8,7 @@ export function DialogDescription({ }: Readonly>) { return ( ) diff --git a/src/components/ui/dialog/header.tsx b/src/components/ui/dialog/header.tsx index 3fe2c30d..4f1f7d4b 100644 --- a/src/components/ui/dialog/header.tsx +++ b/src/components/ui/dialog/header.tsx @@ -30,7 +30,7 @@ export function DialogHeader({ )} -
{props.children}
+
{props.children}
) } diff --git a/src/components/ui/dialog/title.tsx b/src/components/ui/dialog/title.tsx index d7304719..d852b4b5 100644 --- a/src/components/ui/dialog/title.tsx +++ b/src/components/ui/dialog/title.tsx @@ -8,7 +8,7 @@ export function DialogTitle({ }: Readonly>) { return ( ) From 6f3ae264e05a52325aa21abecc845bcb078db51a Mon Sep 17 00:00:00 2001 From: Juliano Sill Date: Sun, 16 Nov 2025 22:55:12 -0300 Subject: [PATCH 3/8] chore(css): update warning color --- src/app/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index cc8badb2..3504c5d9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,7 +24,7 @@ --color-accent-foreground: #0a0d14; --color-success: #059669; - --color-warning: #f17b2c; + --color-warning: #a16207; --color-error: #df1c41; --color-ring: #008b62; From bde231df888781b190272da3703a93f39d21b84d Mon Sep 17 00:00:00 2001 From: Juliano Sill Date: Sun, 16 Nov 2025 22:56:04 -0300 Subject: [PATCH 4/8] chore(components): change font size of button, label and textarea --- src/components/ui/button.tsx | 6 +++--- src/components/ui/label.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 8032929a..3d6d7ee7 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Loader2Icon } from 'lucide-react' import { cn } from '@/utils/class-name-merge' const buttonVariants = cva( - 'ring-offset-background focus-visible:ring-ring inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-readonly:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-colors', + 'ring-offset-background focus-visible:ring-ring inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-readonly:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-colors', { variants: { variant: { @@ -23,9 +23,9 @@ const buttonVariants = cva( }, size: { default: 'h-10 min-h-10 px-4 [&_svg]:size-5', - xs: 'h-8 min-h-8 rounded-md px-2.5 text-xs [&_svg]:size-4', + xs: 'h-8 min-h-8 rounded-md px-2.5 text-sm [&_svg]:size-4', sm: 'h-9 min-h-9 px-4 [&_svg]:size-4', - lg: 'h-11 min-h-11 rounded-xl px-3 text-base [&_svg]:size-5', + lg: 'h-11 min-h-11 rounded-xl px-3 [&_svg]:size-5', icon: 'min-size-10 size-10 [&_svg]:size-5', }, }, diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 3d826853..c205fb64 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -14,7 +14,7 @@ export function Label({