This commit is contained in:
2026-04-07 06:32:40 +03:00
parent 24a44481a0
commit 11db1e6941
101 changed files with 54585 additions and 39160 deletions
+266
View File
@@ -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>