diff --git a/action/app-actions.ts b/action/app-actions.ts index 0fb80fd0..b8ab74ce 100644 --- a/action/app-actions.ts +++ b/action/app-actions.ts @@ -1,7 +1,6 @@ 'use server' import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; -import { postMessage } from "@/app/[locale]/(protected)/app/chat/utils"; export const postMessageAction = async (id: string, message: string,) => { diff --git a/app/[locale]/(protected)/admin/settings/popup/component/popup-table.tsx b/app/[locale]/(protected)/admin/settings/popup/component/popup-table.tsx index aaab2881..b6dab3d0 100644 --- a/app/[locale]/(protected)/admin/settings/popup/component/popup-table.tsx +++ b/app/[locale]/(protected)/admin/settings/popup/component/popup-table.tsx @@ -28,7 +28,7 @@ import { useSearchParams } from "next/navigation"; import { close, loading } from "@/config/swal"; import { Link, useRouter } from "@/i18n/routing"; import columns from "./popup-column"; -import { listBanner, listStaticBanner } from "@/service/settings/settings"; +import { getListPopUp, listBanner, listStaticBanner } from "@/service/settings/settings"; import { listDataPopUp } from "@/service/broadcast/broadcast"; const PopUpListTable = () => { @@ -84,82 +84,73 @@ const PopUpListTable = () => { React.useEffect(() => { if (dataChange) { - router.push("/admin/settings/banner"); + router.push("/admin/settings/popup"); } - getListBanner(); + getPopUp(); }, [dataChange]); React.useEffect(() => { - getListBanner(); + getPopUp(); // getListStaticBanner(); }, [page, showData]); - async function getListBanner() { + async function getPopUp() { loading(); let temp: any; - // const response = await listDataPopUp( - // page - 1, - // showData, - // "", - // categoryFilter?.sort().join(","), - // statusFilter?.sort().join(",") - // ); - // const data = response?.data?.data?.content; - // console.log("banner", data); - // setGetData(data); - - const response = await listBanner(); - const data = response?.data?.data?.content; - console.log("banner", data); - setGetData(data); + const response = await getListPopUp(); + const data = response?.data?.data?.content; + console.log("banner", data); + setGetData(data); close(); } return ( <> - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {table && +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + ))} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {table?.getRowModel()?.rows?.length ? ( + table?.getRowModel()?.rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + } ); }; diff --git a/app/[locale]/(protected)/admin/settings/popup/component/table.tsx b/app/[locale]/(protected)/admin/settings/popup/component/table.tsx index acb856be..b797a310 100644 --- a/app/[locale]/(protected)/admin/settings/popup/component/table.tsx +++ b/app/[locale]/(protected)/admin/settings/popup/component/table.tsx @@ -237,7 +237,7 @@ const ContentListPopUp = () => { const { toast } = useToast(); - const handleBanner = async (ids: number[]) => { + const handlePopUp = async (ids: number[]) => { try { await Promise.all(ids.map((id) => setPopUp(id, true))); toast({ @@ -413,7 +413,7 @@ const ContentListPopUp = () => { Pilih Semua {selectedItems.length > 0 && ( - )} diff --git a/app/[locale]/(protected)/admin/survey/component/table.tsx b/app/[locale]/(protected)/admin/survey/component/table.tsx index 5e084b5a..ae1f97ff 100644 --- a/app/[locale]/(protected)/admin/survey/component/table.tsx +++ b/app/[locale]/(protected)/admin/survey/component/table.tsx @@ -54,22 +54,7 @@ import { Badge } from "@/components/ui/badge"; import { useRouter, useSearchParams } from "next/navigation"; import TablePagination from "@/components/table/table-pagination"; import columns from "./column"; -import { getPlanningPagination } from "@/service/agenda-setting/agenda-setting"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - getMediaBlastCampaignPage, - listDataMedia, -} from "@/service/broadcast/broadcast"; -import { listEnableCategory } from "@/service/content/content"; -import { Checkbox } from "@/components/ui/checkbox"; import { close, loading } from "@/config/swal"; -import { Link } from "@/i18n/routing"; -import { NewCampaignIcon } from "@/components/icon"; -import search from "../../../app/chat/components/search"; import { Select, SelectContent, @@ -79,7 +64,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Card, CardContent } from "@/components/ui/card"; import { Bar, BarChart, diff --git a/app/[locale]/(protected)/contributor/agenda-setting/event-modal.tsx b/app/[locale]/(protected)/contributor/agenda-setting/event-modal.tsx index db8ee9ef..c6016199 100644 --- a/app/[locale]/(protected)/contributor/agenda-setting/event-modal.tsx +++ b/app/[locale]/(protected)/contributor/agenda-setting/event-modal.tsx @@ -1,11 +1,11 @@ -// "use client"; +"use client"; import React, { useState, useEffect, useRef, Fragment } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useForm, Controller } from "react-hook-form"; import { cn, getCookiesDecrypt } from "@/lib/utils"; -import { format } from "date-fns"; +import { format, isDate } from "date-fns"; import { Popover, PopoverContent, @@ -28,6 +28,7 @@ import { ChevronDown, Music, } from "lucide-react"; +import { DateRange } from "react-day-picker"; import DeleteConfirmationDialog from "@/components/delete-confirmation-dialog"; import { CalendarCategory } from "./data"; import { @@ -95,8 +96,10 @@ const EventModal = ({ }) => { const roleId = Number(getCookiesDecrypt("urie")) || 0; const [detail, setDetail] = useState(); - const [startDate, setStartDate] = useState(new Date()); - const [endDate, setEndDate] = useState(new Date()); + const [date, setDate] = React.useState({ + from: new Date(), + to: new Date(), + }); const [isPending, startTransition] = React.useTransition(); const [listDest, setListDest] = useState([]); const [deleteModalOpen, setDeleteModalOpen] = useState(false); @@ -157,6 +160,7 @@ const EventModal = ({ const [wavesurfer, setWavesurfer] = useState(); const [isPlaying, setIsPlaying] = useState(false); const [isPublishing, setIsPublishing] = useState(false); + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); const { register, @@ -247,6 +251,10 @@ const EventModal = ({ fetchDetailData(); }, [event, setValue]); + useEffect(() => { + setIsDatePickerOpen(false); + }, [onClose]) + const handleCheckboxChange = (levelId: number) => { setCheckedLevels((prev) => { const updatedLevels = new Set(prev); @@ -343,10 +351,17 @@ const EventModal = ({ id: detailData?.id, title: data.title, description: data.description, +<<<<<<< HEAD agendaType: agendaTypeList.join(","), assignedToLevel: assignedToLevelList.join(","), startDate: format(startDate, "yyyy-MM-dd"), endDate: format(endDate, "yyyy-MM-dd"), +======= + agendaType: agendaTypeList.join(","), // <-- ubah array jadi string + assignedToLevel: assignedToLevelList.join(","), // <-- ubah array jadi string + startDate: date?.from ? format(date.from, "yyyy-MM-dd") : null, + endDate: date?.to ? format(date.to, "yyyy-MM-dd") : null, +>>>>>>> 249de7b5958cf7d04ba12a114318edbc1c614c6a }; console.log("Submitted Data:", reqData); @@ -423,13 +438,20 @@ const EventModal = ({ }; useEffect(() => { + console.log("Event data:", event); + console.log("Selected date:", selectedDate); + if (selectedDate) { - setStartDate(selectedDate.date); - setEndDate(selectedDate.date); + setDate({ + from: selectedDate.date, + to: selectedDate.date, + }); } if (event) { - setStartDate(event?.event?.start); - setEndDate(event?.event?.end); + setDate({ + from: event?.event?.start, + to: event?.event?.end, + }); const eventCalendar = event?.event?.extendedProps?.calendar; setAgendaType( eventCalendar || (categories?.length > 0 && categories[0].value) @@ -726,73 +748,47 @@ const EventModal = ({ )}
- - + + setIsDatePickerOpen(true)}> - - ( - setStartDate(date as Date)} - initialFocus - /> - )} - /> - - -
-
- - - - - - - ( - setEndDate(date as Date)} - initialFocus - /> - )} + + { + console.log("Date selected:", newDate); + setDate(newDate); + if (newDate?.from && newDate?.to) { + setIsDatePickerOpen(false); + } + }} + numberOfMonths={1} + className="rounded-md border" /> diff --git a/app/[locale]/(protected)/contributor/planning/mediahub/components/mediahub-table.tsx b/app/[locale]/(protected)/contributor/planning/mediahub/components/mediahub-table.tsx index 2dadb124..96719606 100644 --- a/app/[locale]/(protected)/contributor/planning/mediahub/components/mediahub-table.tsx +++ b/app/[locale]/(protected)/contributor/planning/mediahub/components/mediahub-table.tsx @@ -54,7 +54,6 @@ import { useRouter, useSearchParams } from "next/navigation"; import TablePagination from "@/components/table/table-pagination"; import columns from "./columns"; import { getPlanningSentPagination } from "@/service/planning/planning"; -import search from "@/app/[locale]/(protected)/app/chat/components/search"; import { CardHeader, CardTitle } from "@/components/ui/card"; import { useTranslations } from "next-intl"; import useTableColumns from "./columns"; diff --git a/app/[locale]/(protected)/dashboard/routine-task/components/recent-activity.tsx b/app/[locale]/(protected)/dashboard/routine-task/components/recent-activity.tsx index ad649f48..dfbfc559 100644 --- a/app/[locale]/(protected)/dashboard/routine-task/components/recent-activity.tsx +++ b/app/[locale]/(protected)/dashboard/routine-task/components/recent-activity.tsx @@ -11,7 +11,6 @@ import { import { DockIcon, ImageIcon, MicIcon, YoutubeIcon } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import React from "react"; -import search from "../../../app/chat/components/search"; import { useTranslations } from "next-intl"; type StatusFilter = string[]; diff --git a/app/[locale]/auth/forgot-password/layout.tsx b/app/[locale]/auth/forgot-password/layout.tsx new file mode 100644 index 00000000..2d394e06 --- /dev/null +++ b/app/[locale]/auth/forgot-password/layout.tsx @@ -0,0 +1,11 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Media Hub | POLRI", + description: "Media Hub merupakan situs resmi milik Divisi Humas Polri di mana di dalamnya berisi konten-konten yang dapat diakses secara gratis oleh Internal Polri, Jurnalis, Masyarakat Umum, dan KSP.", +}; +const Layout = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; + +export default Layout; \ No newline at end of file diff --git a/app/[locale]/auth/forgot-password/page.tsx b/app/[locale]/auth/forgot-password/page.tsx new file mode 100644 index 00000000..06636a14 --- /dev/null +++ b/app/[locale]/auth/forgot-password/page.tsx @@ -0,0 +1,48 @@ +import { Link } from "@/i18n/routing"; +import ForgotPass from "@/components/partials/auth/forgot-pass"; +import Image from "next/image"; + +const ForgotPassPage = () => { + return ( +
+
+
+
+ + + +
+
+ +
+
+
+
+
+
+ {/* + + */} +
+
+

Forgot Your Password?

+
+
Enter your Username and instructions will be sent to you!
+ + +
+ Forget It, + + Send me Back + + to The Sign In +
+
+
+
+
+
+ ); +}; + +export default ForgotPassPage; diff --git a/components/auth/identity-form.tsx b/components/auth/identity-form.tsx index 6adf6fef..a6493082 100644 --- a/components/auth/identity-form.tsx +++ b/components/auth/identity-form.tsx @@ -105,9 +105,7 @@ export const IdentityForm: React.FC = ({ {t("member", { defaultValue: "Member" })} * - {errors.association && ( + {/* {errors.association && (
{errors.association.message}
- )} + )} */}
diff --git a/components/auth/profile-form.tsx b/components/auth/profile-form.tsx index e5c48afe..cf9c5d19 100644 --- a/components/auth/profile-form.tsx +++ b/components/auth/profile-form.tsx @@ -122,6 +122,7 @@ export const ProfileForm: React.FC = ({ } const instituteData: InstituteData = { + id: "0", name: customInstituteName, address: instituteAddress, }; @@ -159,7 +160,7 @@ export const ProfileForm: React.FC = ({ - {institutes.map((institute) => ( + {institutes?.map((institute) => ( diff --git a/components/form/content/task-ta/video-update-form.tsx b/components/form/content/task-ta/video-update-form.tsx index c1ebd894..93716f82 100644 --- a/components/form/content/task-ta/video-update-form.tsx +++ b/components/form/content/task-ta/video-update-form.tsx @@ -48,7 +48,6 @@ import "swiper/css/thumbs"; import "swiper/css"; import "swiper/css/navigation"; import { FreeMode, Navigation, Pagination, Thumbs } from "swiper/modules"; -import { files } from "@/app/[locale]/(protected)/app/projects/[id]/data"; import { useDropzone } from "react-dropzone"; import Image from "next/image"; import { Icon } from "@iconify/react/dist/iconify.js"; diff --git a/components/form/content/video-update-form.tsx b/components/form/content/video-update-form.tsx index 6c50ecf3..3810fbe7 100644 --- a/components/form/content/video-update-form.tsx +++ b/components/form/content/video-update-form.tsx @@ -48,7 +48,6 @@ import "swiper/css/thumbs"; import "swiper/css"; import "swiper/css/navigation"; import { FreeMode, Navigation, Pagination, Thumbs } from "swiper/modules"; -import { files } from "@/app/[locale]/(protected)/app/projects/[id]/data"; import { useDropzone } from "react-dropzone"; import Image from "next/image"; import { Icon } from "@iconify/react/dist/iconify.js"; diff --git a/components/form/shared/form-checkbox.tsx b/components/form/shared/form-checkbox.tsx new file mode 100644 index 00000000..736556df --- /dev/null +++ b/components/form/shared/form-checkbox.tsx @@ -0,0 +1,244 @@ +/** + * FormCheckbox Component + * + * A reusable checkbox component that handles: + * - Single checkbox + * - Checkbox groups + * - Label + * - Controller (react-hook-form) + * - Error handling + * + * This component reduces code duplication for checkbox fields across forms. + */ + +import React from 'react'; +import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface CheckboxOption { + value: string | number; + label: string; + disabled?: boolean; +} + +export interface FormCheckboxProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> { + // Form control + control: Control; + name: TName; + + // Field configuration + label?: string; + required?: boolean; + + // Checkbox configuration + single?: boolean; // Single checkbox vs checkbox group + options?: CheckboxOption[]; // For checkbox groups + + // Styling + className?: string; + labelClassName?: string; + checkboxClassName?: string; + errorClassName?: string; + groupClassName?: string; + + // Layout + layout?: 'horizontal' | 'vertical'; + columns?: number; // For grid layout + + // Validation + validation?: { + required?: boolean | string; + }; + + // Additional props + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; + + // Custom render functions + renderOption?: (option: CheckboxOption, checked: boolean, onChange: (checked: boolean) => void) => React.ReactElement; +} + +// ============================================================================= +// COMPONENT +// ============================================================================= + +export function FormCheckbox< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + control, + name, + label, + required = false, + single = false, + options = [], + className = '', + labelClassName = '', + checkboxClassName = '', + errorClassName = '', + groupClassName = '', + layout = 'horizontal', + columns = 1, + validation, + disabled = false, + size = 'md', + renderOption, +}: FormCheckboxProps) { + + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + const getCheckboxSize = (size: 'sm' | 'md' | 'lg') => { + switch (size) { + case 'sm': + return 'h-4 w-4'; + case 'lg': + return 'h-6 w-6'; + default: + return 'h-5 w-5'; + } + }; + + const getGroupLayout = () => { + if (layout === 'vertical') { + return 'flex flex-col gap-2'; + } + + if (columns > 1) { + return `grid grid-cols-${columns} gap-2`; + } + + return 'flex flex-wrap gap-4'; + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + + const renderSingleCheckbox = (field: any, fieldState: { error?: FieldError }) => ( +
+ + +
+ ); + + const renderCheckboxGroup = (field: any, fieldState: { error?: FieldError }) => { + const selectedValues = field.value || []; + + const handleOptionChange = (optionValue: string | number, checked: boolean) => { + const newValues = checked + ? [...selectedValues, optionValue] + : selectedValues.filter((value: any) => value !== optionValue); + field.onChange(newValues); + }; + + const renderDefaultOption = (option: CheckboxOption) => { + const checked = selectedValues.includes(option.value); + + if (renderOption) { + return renderOption(option, checked, (checked) => handleOptionChange(option.value, checked)); + } + + return ( +
+ handleOptionChange(option.value, checked as boolean)} + disabled={disabled || option.disabled} + className={getCheckboxSize(size)} + /> + +
+ ); + }; + + return ( +
+ {options.map(renderDefaultOption)} +
+ ); + }; + + // ============================================================================= + // MAIN RENDER + // ============================================================================= + + return ( +
+ {/* Group Label */} + {label && !single && ( + + )} + + {/* Controller */} + ( +
+ {single ? + renderSingleCheckbox(field, fieldState) : + renderCheckboxGroup(field, fieldState) + } + + {/* Error Message */} + {fieldState.error && ( +

+ {fieldState.error.message} +

+ )} +
+ )} + /> +
+ ); +} + +export default FormCheckbox; \ No newline at end of file diff --git a/components/form/shared/form-components-demo.tsx b/components/form/shared/form-components-demo.tsx new file mode 100644 index 00000000..8981d0a1 --- /dev/null +++ b/components/form/shared/form-components-demo.tsx @@ -0,0 +1,280 @@ +/** + * Form Components Demo + * + * This component demonstrates how to use the new reusable form components. + * It shows examples of all the form field types and layout components. + */ + +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { + FormField, + FormSelect, + FormCheckbox, + FormRadio, + FormDatePicker, + FormSection, + FormGrid, + FormGridItem, + SelectOption, + CheckboxOption, + RadioOption, +} from './index'; + +// ============================================================================= +// SCHEMA +// ============================================================================= + +const demoFormSchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().min(10, 'Description must be at least 10 characters'), + category: z.string().min(1, 'Category is required'), + priority: z.string().min(1, 'Priority is required'), + tags: z.array(z.string()).min(1, 'At least one tag is required'), + status: z.string().min(1, 'Status is required'), + dueDate: z.date().optional(), + isPublic: z.boolean().default(false), + notifications: z.array(z.string()).default([]), +}); + +type DemoFormData = z.infer; + +// ============================================================================= +// OPTIONS DATA +// ============================================================================= + +const categoryOptions: SelectOption[] = [ + { value: 'feature', label: 'Feature Request' }, + { value: 'bug', label: 'Bug Report' }, + { value: 'improvement', label: 'Improvement' }, + { value: 'documentation', label: 'Documentation' }, +]; + +const priorityOptions: RadioOption[] = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'urgent', label: 'Urgent' }, +]; + +const tagOptions: CheckboxOption[] = [ + { value: 'frontend', label: 'Frontend' }, + { value: 'backend', label: 'Backend' }, + { value: 'ui', label: 'UI/UX' }, + { value: 'database', label: 'Database' }, + { value: 'security', label: 'Security' }, + { value: 'performance', label: 'Performance' }, +]; + +const statusOptions: SelectOption[] = [ + { value: 'draft', label: 'Draft' }, + { value: 'in-progress', label: 'In Progress' }, + { value: 'review', label: 'Under Review' }, + { value: 'completed', label: 'Completed' }, +]; + +const notificationOptions: CheckboxOption[] = [ + { value: 'email', label: 'Email Notifications' }, + { value: 'push', label: 'Push Notifications' }, + { value: 'sms', label: 'SMS Notifications' }, +]; + +// ============================================================================= +// COMPONENT +// ============================================================================= + +export function FormComponentsDemo() { + const form = useForm({ + resolver: zodResolver(demoFormSchema), + defaultValues: { + title: '', + description: '', + category: '', + priority: 'medium', + tags: [], + status: 'draft', + dueDate: undefined, + isPublic: false, + notifications: ['email'], + }, + }); + + const onSubmit = (data: DemoFormData) => { + console.log('Form submitted:', data); + alert('Form submitted! Check console for data.'); + }; + + return ( +
+
+

Form Components Demo

+

+ Examples of using the new reusable form components +

+
+ +
+ {/* Basic Information Section */} + + + + + + + + + + + + + + + + + {/* Priority and Status Section */} + + + + + + + + + + + + + {/* Tags and Settings Section */} + + + + + + + + + +
+ +
+ +
+ +
+
+
+
+ + {/* Form Actions */} + +
+ + +
+
+
+ + {/* Form Data Display */} + +

Current Form Data

+
+          {JSON.stringify(form.watch(), null, 2)}
+        
+
+
+ ); +} + +export default FormComponentsDemo; \ No newline at end of file diff --git a/components/form/shared/form-date-picker.tsx b/components/form/shared/form-date-picker.tsx new file mode 100644 index 00000000..fd6c9acc --- /dev/null +++ b/components/form/shared/form-date-picker.tsx @@ -0,0 +1,231 @@ +/** + * FormDatePicker Component + * + * A reusable date picker component that handles: + * - Single date selection + * - Date range selection + * - Label + * - Controller (react-hook-form) + * - Error handling + * + * This component reduces code duplication for date picker fields across forms. + */ + +import React from 'react'; +import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { CalendarIcon } from 'lucide-react'; +import { format } from 'date-fns'; +import { DateRange } from 'react-day-picker'; +import { cn } from '@/lib/utils'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface FormDatePickerProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> { + // Form control + control: Control; + name: TName; + + // Field configuration + label?: string; + placeholder?: string; + required?: boolean; + + // Date picker configuration + mode?: 'single' | 'range'; + numberOfMonths?: number; + + // Styling + className?: string; + labelClassName?: string; + buttonClassName?: string; + errorClassName?: string; + + // Validation + validation?: { + required?: boolean | string; + }; + + // Additional props + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; + + // Date formatting + dateFormat?: string; + + // Custom render functions + renderTrigger?: (props: { + field: any; + fieldState: { error?: FieldError }; + selectedDate: Date | DateRange | undefined; + placeholder: string; + }) => React.ReactElement; +} + +// ============================================================================= +// COMPONENT +// ============================================================================= + +export function FormDatePicker< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + control, + name, + label, + placeholder = 'Pick a date', + required = false, + mode = 'single', + numberOfMonths = 1, + className = '', + labelClassName = '', + buttonClassName = '', + errorClassName = '', + validation, + disabled = false, + size = 'md', + dateFormat = 'LLL dd, y', + renderTrigger, +}: FormDatePickerProps) { + + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + const getButtonSize = (size: 'sm' | 'md' | 'lg') => { + switch (size) { + case 'sm': + return 'h-8 text-sm'; + case 'lg': + return 'h-12 text-base'; + default: + return 'h-10 text-sm'; + } + }; + + const formatDate = (date: Date | DateRange | undefined): string => { + if (!date) return ''; + + if (mode === 'range' && 'from' in date) { + if (date.from) { + if (date.to) { + return `${format(date.from, dateFormat)} - ${format(date.to, dateFormat)}`; + } + return format(date.from, dateFormat); + } + } else if (date instanceof Date) { + return format(date, dateFormat); + } + + return ''; + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + + const renderDefaultTrigger = ( + field: any, + fieldState: { error?: FieldError }, + selectedDate: Date | DateRange | undefined, + placeholder: string + ) => ( + + ); + + // ============================================================================= + // MAIN RENDER + // ============================================================================= + + return ( +
+ {/* Label */} + {label && ( + + )} + + {/* Controller */} + { + const selectedDate = field.value; + + return ( +
+ + + {renderTrigger ? + renderTrigger({ field, fieldState, selectedDate, placeholder }) : + renderDefaultTrigger(field, fieldState, selectedDate, placeholder) + } + + + + + + + + {/* Error Message */} + {fieldState.error && ( +

+ {fieldState.error.message} +

+ )} +
+ ); + }} + /> +
+ ); +} + +export default FormDatePicker; \ No newline at end of file diff --git a/components/form/shared/form-field.tsx b/components/form/shared/form-field.tsx new file mode 100644 index 00000000..f81aecf1 --- /dev/null +++ b/components/form/shared/form-field.tsx @@ -0,0 +1,228 @@ +/** + * FormField Component + * + * A reusable form field component that handles the common pattern of: + * - Label + * - Controller (react-hook-form) + * - Input/Select/Textarea + * - Error handling + * + * This component reduces code duplication across all form components. + */ + +import React from 'react'; +import { Control, Controller, FieldError, FieldPath, FieldValues } from 'react-hook-form'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface FormFieldProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> { + // Form control + control: Control; + name: TName; + + // Field configuration + label?: string; + placeholder?: string; + type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'; + required?: boolean; + + // Field type + fieldType?: 'input' | 'textarea' | 'select'; + + // Styling + className?: string; + labelClassName?: string; + inputClassName?: string; + errorClassName?: string; + + // Validation + validation?: { + required?: boolean | string; + minLength?: { value: number; message: string }; + maxLength?: { value: number; message: string }; + pattern?: { value: RegExp; message: string }; + }; + + // Additional props + disabled?: boolean; + readOnly?: boolean; + autoComplete?: string; + size?: 'sm' | 'md' | 'lg'; + + // Custom render function + render?: (props: { + field: any; + fieldState: { error?: FieldError }; + }) => React.ReactElement; +} + +// ============================================================================= +// COMPONENT +// ============================================================================= + +export function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + control, + name, + label, + placeholder, + type = 'text', + required = false, + fieldType = 'input', + className = '', + labelClassName = '', + inputClassName = '', + errorClassName = '', + validation, + disabled = false, + readOnly = false, + autoComplete, + size = 'md', + render, +}: FormFieldProps) { + + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + const getInputSize = (size: 'sm' | 'md' | 'lg') => { + switch (size) { + case 'sm': + return 'h-8 text-sm'; + case 'lg': + return 'h-12 text-base'; + default: + return 'h-10 text-sm'; + } + }; + + const getTextareaSize = (size: 'sm' | 'md' | 'lg') => { + switch (size) { + case 'sm': + return 'min-h-[80px] text-sm'; + case 'lg': + return 'min-h-[120px] text-base'; + default: + return 'min-h-[100px] text-sm'; + } + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + + const renderInput = (field: any, fieldState: { error?: FieldError }) => { + const inputProps = { + ...field, + type, + placeholder, + disabled, + readOnly, + autoComplete, + className: cn( + 'w-full transition-colors', + getInputSize(size), + fieldState.error && 'border-destructive focus:border-destructive', + inputClassName + ), + }; + + return ; + }; + + const renderTextarea = (field: any, fieldState: { error?: FieldError }) => { + const textareaProps = { + ...field, + placeholder, + disabled, + readOnly, + autoComplete, + className: cn( + 'w-full resize-none transition-colors', + getTextareaSize(size), + fieldState.error && 'border-destructive focus:border-destructive', + inputClassName + ), + }; + + return