updates
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
|
||||
import { taskFormSchema, type TaskFormValues } from "./task.schema"
|
||||
import { TASK_ROUTES, TASK_TYPE_ROUTES, TASK_SECTION_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES } from "@garage/api"
|
||||
import { TaskStatus, TaskPriority } from "@garage/api"
|
||||
import { TaskTypeCrudDialog } from "./task-type-crud-dialog"
|
||||
import { TaskSectionCrudDialog } from "./task-section-crud-dialog"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const STATUS_OPTIONS = TaskStatus.map((s) => ({
|
||||
value: s,
|
||||
label: s.charAt(0).toUpperCase() + s.slice(1),
|
||||
}))
|
||||
|
||||
const PRIORITY_OPTIONS = TaskPriority.map((p,i) => ({
|
||||
value: i,
|
||||
label: p.charAt(0).toUpperCase() + p.slice(1),
|
||||
}))
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type TaskFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: TaskFormValues = {
|
||||
subject: "",
|
||||
description: "",
|
||||
task_type: null,
|
||||
task_section: null,
|
||||
owner: null,
|
||||
department: null,
|
||||
priority: "medium",
|
||||
due_date: "",
|
||||
status: "pending",
|
||||
job_card: null,
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): TaskFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
subject: d.subject || "",
|
||||
description: d.description || "",
|
||||
task_type: toRelation(d.task_type_id, d.task_type?.title),
|
||||
task_section: toRelation(d.task_section_id, d.task_section?.title),
|
||||
owner: toRelation(
|
||||
d.owner_id,
|
||||
d.owner ? `${d.owner.first_name ?? ""} ${d.owner.last_name ?? ""}`.trim() : undefined,
|
||||
),
|
||||
department: toRelation(d.department_id, d.department?.name),
|
||||
priority: d.priority || "medium",
|
||||
due_date: d.due_date ? d.due_date.split("T")[0] : "",
|
||||
status: d.status || "pending",
|
||||
job_card: toRelation(d.job_card_id, d.job_card?.title),
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: TaskFormValues) {
|
||||
return {
|
||||
subject: values.subject,
|
||||
description: values.description || undefined,
|
||||
task_type_id: toId(values.task_type),
|
||||
task_section_id: toId(values.task_section),
|
||||
owner_id: toId(values.owner),
|
||||
department_id: toId(values.department),
|
||||
priority: values.priority || undefined,
|
||||
due_date: values.due_date || undefined,
|
||||
status: values.status || undefined,
|
||||
job_card_id: toId(values.job_card),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskForm({ resourceId, initialData, onSuccess }: TaskFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<TaskFormValues, any>({
|
||||
schema: taskFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
queryKey: [TASK_ROUTES.BY_ID, resourceId],
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: TaskFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.tasks.update(resourceId, payload as any)
|
||||
: api.tasks.create(payload as any)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating task..." : "Creating task...",
|
||||
success: isEditing ? "Task updated successfully" : "Task created successfully",
|
||||
error: isEditing ? "Failed to update task" : "Failed to create task",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update task" : "Failed to create task"}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="subject"
|
||||
label="Subject"
|
||||
placeholder="e.g. Inspect brake pads"
|
||||
required
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Optional description..."
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Task Type with inline CRUD */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">Task Type</span>
|
||||
<TaskTypeCrudDialog />
|
||||
</div>
|
||||
<RhfAsyncSelectField
|
||||
name="task_type"
|
||||
label=""
|
||||
placeholder="Select task type..."
|
||||
queryKey={[TASK_TYPE_ROUTES.INDEX]}
|
||||
listFn={() => api.taskTypes.list()}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Section with inline CRUD */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">Task Section</span>
|
||||
<TaskSectionCrudDialog />
|
||||
</div>
|
||||
<RhfAsyncSelectField
|
||||
name="task_section"
|
||||
label=""
|
||||
placeholder="Select task section..."
|
||||
queryKey={[TASK_SECTION_ROUTES.INDEX]}
|
||||
listFn={() => api.taskSections.list()}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.title })}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RhfAsyncSelectField
|
||||
name="owner"
|
||||
label="Assigned To"
|
||||
placeholder="Select employee..."
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||
listFn={() => api.employees.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim(),
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department..."
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.name })}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RhfSelectField
|
||||
name="priority"
|
||||
label="Priority"
|
||||
placeholder="Select priority..."
|
||||
options={PRIORITY_OPTIONS as any}
|
||||
/>
|
||||
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status..."
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<RhfDateField name="due_date" label="Due Date" />
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="job_card"
|
||||
label="Job Card"
|
||||
placeholder="Select job card..."
|
||||
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
||||
listFn={() => api.jobCards.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{isPending
|
||||
? isEditing ? "Updating..." : "Creating..."
|
||||
: isEditing ? "Update Task" : "Add Task"}
|
||||
</Button>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { TASK_SECTION_ROUTES } from "@garage/api"
|
||||
import { TaskSectionForm } from "./task-section-form"
|
||||
|
||||
export function TaskSectionCrudDialog() {
|
||||
const api = useAuthApi()
|
||||
|
||||
return (
|
||||
<CrudDialog
|
||||
title="Task Section"
|
||||
queryKey={[TASK_SECTION_ROUTES.INDEX]}
|
||||
getClient={() => api.taskSections}
|
||||
resourceLabel="task section"
|
||||
columns={() => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "arrangement",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Order" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "is_default",
|
||||
header: () => <span>Default</span>,
|
||||
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
|
||||
enableSorting: false,
|
||||
},
|
||||
]}
|
||||
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||
<TaskSectionForm
|
||||
resourceId={resourceId}
|
||||
initialData={initialData}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextField, RhfCheckboxField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useEffect } from "react"
|
||||
|
||||
// ── Schema ──
|
||||
|
||||
const taskSectionSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
arrangement: z.string().optional(),
|
||||
is_default: z.boolean().optional(),
|
||||
})
|
||||
|
||||
type TaskSectionFormValues = z.infer<typeof taskSectionSchema>
|
||||
|
||||
// ── Props ──
|
||||
|
||||
type TaskSectionFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: any
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskSectionForm({ resourceId, initialData, onSuccess }: TaskSectionFormProps) {
|
||||
const api = useAuthApi()
|
||||
const isEditing = !!resourceId
|
||||
|
||||
const form = useForm<TaskSectionFormValues>({
|
||||
resolver: zodResolver(taskSectionSchema),
|
||||
defaultValues: { title: "", arrangement: "", is_default: false },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const d = initialData?.data ?? initialData
|
||||
form.reset({
|
||||
title: d.title ?? "",
|
||||
arrangement: d.arrangement != null ? String(d.arrangement) : "",
|
||||
is_default: d.is_default ?? false,
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
|
||||
const handleSubmit = async (values: TaskSectionFormValues) => {
|
||||
try {
|
||||
const promise = isEditing
|
||||
? api.taskSections.update(resourceId!, {
|
||||
title: values.title,
|
||||
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
|
||||
is_default: values.is_default,
|
||||
})
|
||||
: api.taskSections.create({
|
||||
title: values.title,
|
||||
arrangement: values.arrangement ? Number(values.arrangement) : undefined,
|
||||
is_default: values.is_default,
|
||||
})
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating..." : "Creating...",
|
||||
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||
error: isEditing ? "Failed to update" : "Failed to create",
|
||||
})
|
||||
|
||||
await promise
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
// toast already shown
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Pre-Work"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="arrangement"
|
||||
label="Arrangement Order"
|
||||
placeholder="e.g. 1"
|
||||
type="number"
|
||||
/>
|
||||
<RhfCheckboxField name="is_default" label="Set as default" />
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{form.formState.isSubmitting
|
||||
? isEditing ? "Updating..." : "Creating..."
|
||||
: isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { TASK_TYPE_ROUTES } from "@garage/api"
|
||||
import { TaskTypeForm } from "./task-type-form"
|
||||
|
||||
export function TaskTypeCrudDialog() {
|
||||
const api = useAuthApi()
|
||||
|
||||
return (
|
||||
<CrudDialog
|
||||
title="Task Type"
|
||||
queryKey={[TASK_TYPE_ROUTES.INDEX]}
|
||||
getClient={() => api.taskTypes}
|
||||
resourceLabel="task type"
|
||||
columns={() => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "is_default",
|
||||
header: () => <span>Default</span>,
|
||||
cell: ({ row }) => ((row.original as any).is_default ? "Yes" : "No"),
|
||||
enableSorting: false,
|
||||
},
|
||||
]}
|
||||
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||
<TaskTypeForm
|
||||
resourceId={resourceId}
|
||||
initialData={initialData}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextField, RhfCheckboxField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useEffect } from "react"
|
||||
|
||||
// ── Schema ──
|
||||
|
||||
const taskTypeSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
is_default: z.boolean().optional(),
|
||||
})
|
||||
|
||||
type TaskTypeFormValues = z.infer<typeof taskTypeSchema>
|
||||
|
||||
// ── Props ──
|
||||
|
||||
type TaskTypeFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: any
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskTypeForm({ resourceId, initialData, onSuccess }: TaskTypeFormProps) {
|
||||
const api = useAuthApi()
|
||||
const isEditing = !!resourceId
|
||||
|
||||
const form = useForm<TaskTypeFormValues>({
|
||||
resolver: zodResolver(taskTypeSchema),
|
||||
defaultValues: { title: "", is_default: false },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const d = initialData?.data ?? initialData
|
||||
form.reset({
|
||||
title: d.title ?? "",
|
||||
is_default: d.is_default ?? false,
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
|
||||
const handleSubmit = async (values: TaskTypeFormValues) => {
|
||||
try {
|
||||
const promise = isEditing
|
||||
? api.taskTypes.update(resourceId!, values)
|
||||
: api.taskTypes.create(values)
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating..." : "Creating...",
|
||||
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||
error: isEditing ? "Failed to update" : "Failed to create",
|
||||
})
|
||||
|
||||
await promise
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
// toast already shown
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Maintenance"
|
||||
required
|
||||
/>
|
||||
<RhfCheckboxField name="is_default" label="Set as default" />
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{form.formState.isSubmitting
|
||||
? isEditing ? "Updating..." : "Creating..."
|
||||
: isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
export const taskFormSchema = z.object({
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
description: z.string().optional(),
|
||||
task_type: relationFieldSchema,
|
||||
task_section: relationFieldSchema,
|
||||
owner: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
priority: z.string().optional(),
|
||||
due_date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
job_card: relationFieldSchema,
|
||||
})
|
||||
|
||||
export type TaskFormValues = z.infer<typeof taskFormSchema>
|
||||
Reference in New Issue
Block a user