diff --git a/apps/admin/components/ui/checkbox.tsx b/apps/admin/components/ui/checkbox.tsx index bc68206..9dfd320 100644 --- a/apps/admin/components/ui/checkbox.tsx +++ b/apps/admin/components/ui/checkbox.tsx @@ -13,7 +13,7 @@ function Checkbox({ return ( - - - - - - + + + + + + + BrowserServerPOST /login with username and passwordGET/account protected resource with headerAuthorization: Bearer {JWT}Returns short-lived JWT and sets long livedrefreshToken cookie200Returns protected resource200GETprotected resource, after 5 minutes, with same tokenUnauthorized: Token expired401POST /token with valid refreshToken on cookiesReturns a new valid JWT and sets anotherrefreshToken200 \ No newline at end of file diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/employees/new/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/employees/new/page.tsx index 8ccfab8..dd35c1e 100644 --- a/apps/web/app/(protected)/dashboard/[subdomain]/employees/new/page.tsx +++ b/apps/web/app/(protected)/dashboard/[subdomain]/employees/new/page.tsx @@ -3,7 +3,7 @@ import { UserRoundPlus } from "lucide-react"; import { redirect, useParams, useRouter } from "next/navigation"; import { BackButton } from "@/components/dashboard/back-button"; -import { CreateEmployeeForm } from "@/components/dashboard/employees/new/create-employee-form"; +import { NewEmployeeForm } from "@/components/dashboard/employees/new/create-employee-form"; import { Heading } from "@/components/dashboard/heading"; import { useSession } from "@/lib/hooks/use-session"; @@ -20,18 +20,17 @@ export default function NewEmployeePage() { return (
- +
- router.push(`/dashboard/${params.subdomain}/employees`) } - session={session} />
); diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx new file mode 100644 index 0000000..adf551a --- /dev/null +++ b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/[id]/page.tsx @@ -0,0 +1,56 @@ +import { getServiceOrderById } from "@fixr/mock"; +import { ArrowLeft, Wrench } from "lucide-react"; +import { notFound } from "next/navigation"; +import { Heading } from "@/components/dashboard/heading"; +import { ServiceOrderDetailsLayout } from "@/components/dashboard/service-order"; +import { DashLink } from "@/components/dashboard/service-order/dash-link"; +import { Button } from "@/components/ui/button"; + +type Params = Promise<{ subdomain: string; id: string }>; + +export default async function ServiceOrderDetailsPage({ + params, +}: { + params: Params; +}) { + const { subdomain, id } = await params; + + const order = await getServiceOrderById(id); + + if (!order) { + notFound(); + } + + return ( +
+
+ + + + + Ordem de serviço{" "} + + #{order.orderNumber} + + + } + /> +
+ + +
+ ); +} diff --git a/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/new/page.tsx b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/new/page.tsx index 5c68384..b439fa1 100644 --- a/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/new/page.tsx +++ b/apps/web/app/(protected)/dashboard/[subdomain]/service-orders/new/page.tsx @@ -6,7 +6,7 @@ import { NewServiceOrderForm } from "@/components/dashboard/service-order/new-se export default function NewServiceOrderPage() { return (
- + ; @@ -18,11 +16,10 @@ export default async function ServiceOrdersPage({ description={"Controle as ordens de serviço de seus clientes"} title={"Ordens de serviço"} /> - + +
+ +
); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index aa392c2..557e2a9 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -181,3 +181,16 @@ border-radius: 10px; background-color: var(--border); } + +@keyframes working { + 0%, + 100% { + box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 18%, transparent); + transform: scale(1); + } + + 50% { + box-shadow: 0 0 0 4px color-mix(in oklab, var(--primary) 28%, transparent); + transform: scale(1.04); + } +} diff --git a/apps/web/components.json b/apps/web/components.json index b7b9791..fcfb3f8 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -18,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@diceui": "https://diceui.com/r/{name}.json" + } } diff --git a/apps/web/components/dashboard/account-popover.tsx b/apps/web/components/dashboard/account-popover.tsx index 136b5de..df4e83e 100644 --- a/apps/web/components/dashboard/account-popover.tsx +++ b/apps/web/components/dashboard/account-popover.tsx @@ -8,7 +8,7 @@ import { Avatar, type AvatarProps } from "../account/profile-avatar"; import { SignOutButton } from "../auth/signout-button"; import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { DashLink } from "./dash-link"; +import { DashLink } from "./service-order/dash-link"; export function AccountPopover({ session, diff --git a/apps/web/components/dashboard/dash-link.tsx b/apps/web/components/dashboard/dash-link.tsx deleted file mode 100644 index 3b17266..0000000 --- a/apps/web/components/dashboard/dash-link.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Link, { type LinkProps } from "next/link"; - -export function DashLink({ - subdomain, - href, - ...props -}: React.AnchorHTMLAttributes & - LinkProps & { subdomain: string }) { - return ; -} diff --git a/apps/web/components/dashboard/employees/data-table.tsx b/apps/web/components/dashboard/employees/data-table.tsx index df89b3d..68e977b 100644 --- a/apps/web/components/dashboard/employees/data-table.tsx +++ b/apps/web/components/dashboard/employees/data-table.tsx @@ -28,7 +28,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { DashLink } from "../dash-link"; +import { DashLink } from "../service-order/dash-link"; interface DataTableProps { columns: ColumnDef[]; diff --git a/apps/web/components/dashboard/employees/new/create-employee-form.tsx b/apps/web/components/dashboard/employees/new/create-employee-form.tsx index 5e798e0..3adcc79 100644 --- a/apps/web/components/dashboard/employees/new/create-employee-form.tsx +++ b/apps/web/components/dashboard/employees/new/create-employee-form.tsx @@ -1,9 +1,7 @@ "use client"; - import { cpf, unmask } from "@fixr/constants/masks"; import { defaultMessages, messages } from "@fixr/constants/messages"; import { roleLabels } from "@fixr/constants/roles"; -import type { userJWT } from "@fixr/schemas/auth"; import { createEmployeeSchema } from "@fixr/schemas/employees"; import type { ApiResponse } from "@fixr/schemas/utils"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -21,7 +19,8 @@ import { Plus, User, } from "lucide-react"; -import { useState } from "react"; +import { useParams } from "next/navigation"; +import { type ComponentPropsWithoutRef, useState } from "react"; import { useForm } from "react-hook-form"; import type { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -47,14 +46,13 @@ import { axios } from "@/lib/auth/axios"; import { api, tryCatch } from "@/lib/utils"; import { generateRandomPassword } from "@/lib/utils/generate-random-password"; -export function CreateEmployeeForm({ - session, +export function NewEmployeeForm({ onSuccess, -}: { - session: z.infer; - onSuccess: () => void; +}: ComponentPropsWithoutRef<"form"> & { + onSuccess?: () => void; }) { const [loading, setLoading] = useState(false); + const { subdomain } = useParams<{ subdomain: string }>(); const form = useForm>({ resolver: zodResolver(createEmployeeSchema), @@ -81,12 +79,7 @@ export function CreateEmployeeForm({ try { const { data: response, error } = await tryCatch< AxiosResponse - >( - axios.post( - api(`/companies/${session.company?.subdomain}/employees`), - formatted - ) - ); + >(axios.post(api(`/companies/${subdomain}/employees`), formatted)); if (error && error instanceof AxiosError) { const message = @@ -107,7 +100,7 @@ export function CreateEmployeeForm({ description: message.description, }); - onSuccess(); + onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["employeesData"] }); } finally { setLoading(false); diff --git a/apps/web/components/dashboard/service-order/dash-link.tsx b/apps/web/components/dashboard/service-order/dash-link.tsx new file mode 100644 index 0000000..59a40d7 --- /dev/null +++ b/apps/web/components/dashboard/service-order/dash-link.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +type DashLinkProps = Omit, "href"> & { + href: string; + subdomain: string; + children: ReactNode; +}; + +export function DashLink({ + href, + subdomain, + children, + ...props +}: DashLinkProps) { + const normalizedHref = href.startsWith("/") ? href : `/${href}`; + const hrefWithSubdomain = `/dashboard/${subdomain}${normalizedHref}`; + + return ( + + {children} + + ); +} diff --git a/apps/web/components/dashboard/service-order/index.ts b/apps/web/components/dashboard/service-order/index.ts new file mode 100644 index 0000000..798c776 --- /dev/null +++ b/apps/web/components/dashboard/service-order/index.ts @@ -0,0 +1,6 @@ +export { DashLink } from "./dash-link"; +export { ServiceOrderDetailsLayout } from "./layout"; +export { ServiceOrderLifecycle } from "./lifecycle"; +export { NewServiceOrderForm } from "./new-service-order-form"; +export { ServiceOrderDetailsCard } from "./service-order-details-card"; +export { ServiceOrdersTable } from "./service-order-table"; diff --git a/apps/web/components/dashboard/service-order/layout/cards.tsx b/apps/web/components/dashboard/service-order/layout/cards.tsx new file mode 100644 index 0000000..4c3af35 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/cards.tsx @@ -0,0 +1,150 @@ +import type { ServiceOrderRow } from "@fixr/mock"; +import { + ClipboardList, + FileUser, + History, + Image as ImageIcon, + Package, + User, + Wrench, +} from "lucide-react"; +import { ServiceOrderLifecycle } from "@/components/dashboard/service-order/lifecycle"; +import { ServiceOrderDetailsCard } from "@/components/dashboard/service-order/service-order-details-card"; +import { ServiceOrderImageGrid } from "@/components/dashboard/service-order/widgets/service-order-image-grid"; +import { + ServiceOrderKeyValueItem, + ServiceOrderKeyValueList, +} from "@/components/dashboard/service-order/widgets/service-order-key-value"; +import { ServiceOrderPartsList } from "@/components/dashboard/service-order/widgets/service-order-parts-list"; +import { ServiceOrderStatusBadge } from "../service-order-status-badge"; +import type { CardId } from "./utils/constants"; + +type CardsMap = Record; + +export function getCards(order: ServiceOrderRow): CardsMap { + return { + summary: ( + + + + + {order.status.label} + + } + /> + + + + + + ), + device: order.orderDetails ? ( + + + + {order.orderDetails.imei && ( + + )} + + + + ) : null, + technician: ( + + + + {order.notes && ( + + )} + + + ), + client: ( + + + + + + + + ), + lifecycle: ( + + {order.history.length > 0 ? ( + + ) : ( +

+ Nenhum histórico registrado. +

+ )} +
+ ), + parts: ( + + + + ), + images: ( + + + + ), + }; +} diff --git a/apps/web/components/dashboard/service-order/layout/droppable-column.tsx b/apps/web/components/dashboard/service-order/layout/droppable-column.tsx new file mode 100644 index 0000000..4a8f157 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/droppable-column.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import { cn } from "@/lib/utils"; + +interface DroppableColumnProps { + id: "left" | "right"; + children: React.ReactNode; +} + +export function DroppableColumn({ id, children }: DroppableColumnProps) { + const { setNodeRef, isOver } = useDroppable({ id }); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/components/dashboard/service-order/layout/index.ts b/apps/web/components/dashboard/service-order/layout/index.ts new file mode 100644 index 0000000..d9012bc --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/index.ts @@ -0,0 +1 @@ +export { ServiceOrderDetailsLayout } from "./service-order-details-layout"; diff --git a/apps/web/components/dashboard/service-order/layout/service-order-details-layout.tsx b/apps/web/components/dashboard/service-order/layout/service-order-details-layout.tsx new file mode 100644 index 0000000..dd6da28 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/service-order-details-layout.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import type { ServiceOrderRow } from "@fixr/mock"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { getCards } from "./cards"; +import { DroppableColumn } from "./droppable-column"; +import { SortableCard } from "./sortable-card"; +import type { CardId } from "./utils/constants"; +import { findContainer } from "./utils/dnd"; +import type { LayoutState } from "./utils/layout-state"; +import { useResizableLayout } from "./utils/use-resizable-layout"; + +interface Props { + order: ServiceOrderRow; + subdomain: string; +} + +/** + * Placeholder visible inside a column while a card is being dragged over it, + * showing exactly where the card will land once dropped. + */ +function DropPlaceholder() { + return ( +
+ Solte aqui para mover o painel +
+ ); +} + +export function ServiceOrderDetailsLayout({ order, subdomain }: Props) { + const availableCards = useMemo(() => { + const cards: CardId[] = [ + "summary", + "device", + "technician", + "client", + "lifecycle", + "parts", + "images", + ]; + return new Set( + cards.filter((card) => card !== "device" || order.orderDetails) + ); + }, [order.orderDetails]); + + const storageKey = `service-order-layout:${subdomain}:${order.id}`; + const [layout, setLayout] = useResizableLayout(storageKey, availableCards); + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + const [dragWidth, setDragWidth] = useState(null); + + /** Tracks mount state so the DragOverlay portal only renders client-side. */ + const [isMounted, setIsMounted] = useState(false); + useEffect(() => setIsMounted(true), []); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor) + ); + + const cards = useMemo(() => getCards(order), [order]); + + const renderCard = (cardId: CardId) => { + const content = cards[cardId]; + if (!content) { + return null; + } + return {content}; + }; + + const renderStaticCard = (cardId: CardId) => { + const content = cards[cardId]; + return content ?? null; + }; + + /** + * Determines at which position inside a column the drop placeholder + * should appear. Returns `null` when the place holder should be hidden. + * + * - If the over target is the column itself the card lands at the end. + * - If the over target is another card the placeholder goes before it. + */ + const getPlaceholderIndex = (columnId: "left" | "right") => { + if (!(activeId && overId)) { + return null; + } + const activeContainer = findContainer(layout, String(activeId)); + const overContainer = findContainer(layout, String(overId)); + if (overContainer !== columnId) return null; + if (activeContainer === overContainer && overId === activeId) return null; + const items = layout[columnId]; + if (overId === columnId) return items.length; + const index = items.indexOf(overId as CardId); + return index >= 0 ? index : items.length; + }; + + return ( + { + setActiveId(null); + setOverId(null); + setDragWidth(null); + }} + onDragEnd={({ active, over }) => { + setActiveId(null); + setOverId(null); + setDragWidth(null); + if (!over || active.id === over.id) return; + const activeContainer = findContainer(layout, String(active.id)); + const overContainer = findContainer(layout, String(over.id)); + if (!(activeContainer && overContainer)) return; + + setLayout((prev: LayoutState) => { + const next = { ...prev }; + const activeItems = [...next[activeContainer]]; + const overItems = [...next[overContainer]]; + const activeIndex = activeItems.indexOf(active.id as CardId); + const overIndex = + over.id === overContainer + ? overItems.length + : overItems.indexOf(over.id as CardId); + + if (activeContainer !== overContainer && activeItems.length === 1) { + return next; + } + + if (activeContainer === overContainer) { + next[activeContainer] = arrayMove( + activeItems, + activeIndex, + overIndex + ); + return next; + } + + activeItems.splice(activeIndex, 1); + overItems.splice(overIndex, 0, active.id as CardId); + next[activeContainer] = activeItems; + next[overContainer] = overItems; + return next; + }); + }} + onDragOver={({ over }) => { + setOverId(over?.id ? String(over.id) : null); + }} + onDragStart={({ active }) => { + setActiveId(active.id as CardId); + const element = document.querySelector( + `[data-card-id="${active.id}"]` + ); + if (element) { + setDragWidth(element.getBoundingClientRect().width); + } + }} + sensors={sensors} + > +
+ + + + + {layout.left.map((cardId, index) => ( +
+ {getPlaceholderIndex("left") === index && ( + + )} + {renderCard(cardId)} +
+ ))} + {getPlaceholderIndex("left") === layout.left.length && ( + + )} +
+
+
+ + + + + {layout.right.map((cardId, index) => ( +
+ {getPlaceholderIndex("right") === index && ( + + )} + {renderCard(cardId)} +
+ ))} + {getPlaceholderIndex("right") === layout.right.length && ( + + )} +
+
+
+
+
+
+ {layout.left.map(renderStaticCard)} + {layout.right.map(renderStaticCard)} +
+ {isMounted + ? createPortal( + + {activeId ? ( +
+ {cards[activeId]} +
+ ) : null} +
, + document.body + ) + : null} +
+ ); +} diff --git a/apps/web/components/dashboard/service-order/layout/sortable-card.tsx b/apps/web/components/dashboard/service-order/layout/sortable-card.tsx new file mode 100644 index 0000000..fe3bb5c --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/sortable-card.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CardId } from "./utils/constants"; + +interface SortableCardProps { + id: CardId; + children: React.ReactNode; +} + +export function SortableCard({ id, children }: SortableCardProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ + Arraste +
+ {children} +
+ ); +} diff --git a/apps/web/components/dashboard/service-order/layout/utils/constants.ts b/apps/web/components/dashboard/service-order/layout/utils/constants.ts new file mode 100644 index 0000000..614cd61 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/utils/constants.ts @@ -0,0 +1,9 @@ +/** Identifies which detail card can be placed in the resizable layout columns. */ +export type CardId = + | "summary" + | "device" + | "technician" + | "client" + | "lifecycle" + | "parts" + | "images"; diff --git a/apps/web/components/dashboard/service-order/layout/utils/dnd.ts b/apps/web/components/dashboard/service-order/layout/utils/dnd.ts new file mode 100644 index 0000000..203cba1 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/utils/dnd.ts @@ -0,0 +1,22 @@ +import type { CardId } from "./constants"; +import type { LayoutState } from "./layout-state"; + +/** + * Given a drag id (a column name or a card id), returns which column + * the item belongs to – or `null` if it isn't found anywhere. + */ +export function findContainer( + layout: LayoutState, + id: string +): "left" | "right" | null { + if (id === "left" || id === "right") { + return id; + } + if (layout.left.includes(id as CardId)) { + return "left"; + } + if (layout.right.includes(id as CardId)) { + return "right"; + } + return null; +} diff --git a/apps/web/components/dashboard/service-order/layout/utils/layout-state.ts b/apps/web/components/dashboard/service-order/layout/utils/layout-state.ts new file mode 100644 index 0000000..dc58938 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/utils/layout-state.ts @@ -0,0 +1,42 @@ +import type { CardId } from "./constants"; + +/** Describes which cards are in each column of the resizable layout. */ +export interface LayoutState { + left: CardId[]; + right: CardId[]; +} + +/** Default card arrangement when no saved layout exists yet. */ +export const defaultLayout: LayoutState = { + left: ["device", "lifecycle", "parts"], + right: ["summary", "technician", "client", "images"], +}; + +/** + * Ensures every available card appears exactly once across both columns, + * removing any cards that are no longer available (e.g. because the order + * lacks detail data) and inserting brand new cards into whicever column + * has room. + */ +export function normalizeLayout( + layout: LayoutState, + available: Set +): LayoutState { + const sanitize = (items: CardId[]) => + items.filter((item) => available.has(item)); + const left = sanitize(layout.left); + const right = sanitize(layout.right); + const missing = Array.from(available).filter( + (item) => !(left.includes(item) || right.includes(item)) + ); + const nextLeft = left.length > 0 ? left : missing.splice(0, 1); + const nextRight = right.length > 0 ? right : missing.splice(0, 1); + const withRemaining = [...missing]; + return { + left: [ + ...nextLeft, + ...withRemaining.splice(0, Math.max(0, 4 - nextLeft.length)), + ], + right: [...nextRight, ...withRemaining], + }; +} diff --git a/apps/web/components/dashboard/service-order/layout/utils/use-resizable-layout.ts b/apps/web/components/dashboard/service-order/layout/utils/use-resizable-layout.ts new file mode 100644 index 0000000..247aec2 --- /dev/null +++ b/apps/web/components/dashboard/service-order/layout/utils/use-resizable-layout.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import type { CardId } from "./constants"; +import type { LayoutState } from "./layout-state"; +import { defaultLayout, normalizeLayout } from "./layout-state"; + +/** + * Persists the user's resizable panel layout to localStorage. + * + * On mount the saved layout is read and normalised so it stays valid even + * when the available cards change. Every subsequent change is written back + * to localStorage so the arrangement survives page reloads. + */ +export function useResizableLayout(storageKey: string, available: Set) { + const [layout, setLayout] = useState(() => + normalizeLayout(defaultLayout, available) + ); + + useEffect(() => { + const stored = localStorage.getItem(storageKey); + if (!stored) { + return; + } + try { + const parsed = JSON.parse(stored) as LayoutState; + setLayout(normalizeLayout(parsed, available)); + } catch { + setLayout(normalizeLayout(defaultLayout, available)); + } + }, [storageKey, available]); + + useEffect(() => { + localStorage.setItem(storageKey, JSON.stringify(layout)); + }, [layout, storageKey]); + + return [layout, setLayout] as const; +} diff --git a/apps/web/components/dashboard/service-order/lifecycle/index.ts b/apps/web/components/dashboard/service-order/lifecycle/index.ts new file mode 100644 index 0000000..c9feda8 --- /dev/null +++ b/apps/web/components/dashboard/service-order/lifecycle/index.ts @@ -0,0 +1,16 @@ +import type { ServiceOrderStatusId } from "@/lib/utils/service-orders"; + +/** A single status transition recorded in the order history. */ +export interface HistoryEntry { + id: string; + status: ServiceOrderStatusId; + dateTime: string; + comment: string; +} + +export interface ServiceOrderLifecycleProps { + currentStatus: ServiceOrderStatusId; + history?: HistoryEntry[]; +} + +export { ServiceOrderLifecycle } from "./service-order-lifecycle"; diff --git a/apps/web/components/dashboard/service-order/lifecycle/service-order-lifecycle.tsx b/apps/web/components/dashboard/service-order/lifecycle/service-order-lifecycle.tsx new file mode 100644 index 0000000..34585a0 --- /dev/null +++ b/apps/web/components/dashboard/service-order/lifecycle/service-order-lifecycle.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { Check, ClipboardList, Clock, Wrench } from "lucide-react"; +import { + Timeline, + TimelineConnector, + TimelineContent, + TimelineDescription, + TimelineDot, + TimelineHeader, + TimelineItem, + TimelineTitle, +} from "@/components/ui/timeline"; +import type { ServiceOrderStatusId } from "@/lib/utils/service-orders"; +import type { HistoryEntry, ServiceOrderLifecycleProps } from "."; +import { + getConnectorClass, + getDotClass, + getDotIcon, + getPhaseStatus, + getStatusBadgeClass, + getStatusLabel, + resolveActivePhase, +} from "./utils/helpers"; + +export function ServiceOrderLifecycle({ + currentStatus, + history, +}: ServiceOrderLifecycleProps) { + const activeIndex = resolveActivePhase(currentStatus); + /** Groups history entries by their status id so each phase can show its own entries. */ + const historyByStatus = (history ?? []).reduce< + Partial> + >( + (acc, entry) => { + const group = acc[entry.status]; + if (group) { + group.push(entry); + } else { + acc[entry.status] = [entry]; + } + return acc; + }, + {} as Partial> + ); + + const phases = [ + { + id: "recognition", + title: "Reconhecimento", + description: "Análise do problema relatado", + status: getPhaseStatus(0, activeIndex), + statuses: ["registered", "analysis"] as ServiceOrderStatusId[], + icon: ClipboardList, + }, + { + id: "pending", + title: "Pendências", + description: "Análise de pendências da OS", + status: getPhaseStatus(1, activeIndex), + statuses: [ + "quote_pending", + "approval_pending", + "parts_pending", + ] as ServiceOrderStatusId[], + icon: Clock, + }, + { + id: "progress", + title: "Reparo em progresso", + description: "Um técnico foi designado ao serviço", + status: getPhaseStatus(2, activeIndex), + statuses: ["in_progress"] as ServiceOrderStatusId[], + icon: Wrench, + }, + { + id: "finished", + title: "Finalizado", + description: "Reparo finalizado pelo laboratório", + status: getPhaseStatus(3, activeIndex), + statuses: [ + "ready_for_pickup", + "finished", + "canceled", + ] as ServiceOrderStatusId[], + icon: Check, + }, + ] as const; + + return ( + + {phases.map((phase, idx) => { + const isCompleted = phase.status === "done"; + const isActive = phase.status === "active"; + const isCanceled = + currentStatus === "canceled" && phase.id === "finished"; + const isLast = idx === phases.length - 1; + const entries = phase.statuses.flatMap( + (status) => historyByStatus[status] ?? [] + ); + + const dotClass = getDotClass({ + isCanceled, + isCompleted, + isActive, + }); + + /** + * Tailwind arbitrary-value classes that position the conector line + * relative to the timeline dot. The values are drived by the CSS + * custom properties set on ``. + */ + const connectorSpacingClass = + "rounded-full !translate-y-[calc(var(--timeline-connector-gap)*4)] ![height:calc(100%-var(--timeline-connector-gap)*5)] ![left:calc(var(--timeline-dot-size)/2-1px)] ![width:2px]"; + + return ( + + + {getDotIcon({ + isCanceled, + isCompleted, + isActive, + Icon: phase.icon, + })} + + + {!isLast && ( + + )} + + + +
+ {phase.title} + + {getStatusLabel({ + isCanceled, + isCompleted, + isActive, + })} + +
+ + {phase.description} + +
+ + {entries.length > 0 && ( +
+
+
+ {entries.map((entry, entryIndex) => ( +
+ + {entryIndex < entries.length - 1 && ( + + )} + + {entry.dateTime} + +

+ {entry.comment} +

+
+ ))} +
+
+
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/components/dashboard/service-order/lifecycle/utils/constants.ts b/apps/web/components/dashboard/service-order/lifecycle/utils/constants.ts new file mode 100644 index 0000000..13b1271 --- /dev/null +++ b/apps/web/components/dashboard/service-order/lifecycle/utils/constants.ts @@ -0,0 +1,15 @@ +import type { ComponentType } from "react"; +import type { ServiceOrderStatusId } from "@/lib/utils/service-orders"; + +/** Represents the state of a single phase in the timeline. */ +export type PhaseStatus = "done" | "active" | "upcoming"; + +/** Configuration for a single phase in the service order lifecycle timeline. */ +export interface PhaseDefinition { + id: string; + title: string; + description: string; + status: PhaseStatus; + statuses: ServiceOrderStatusId[]; + icon: ComponentType<{ className?: string }>; +} diff --git a/apps/web/components/dashboard/service-order/lifecycle/utils/helpers.tsx b/apps/web/components/dashboard/service-order/lifecycle/utils/helpers.tsx new file mode 100644 index 0000000..6ae446d --- /dev/null +++ b/apps/web/components/dashboard/service-order/lifecycle/utils/helpers.tsx @@ -0,0 +1,150 @@ +import { Check, X } from "lucide-react"; +import type { ComponentType } from "react"; +import type { ServiceOrderStatusId } from "@/lib/utils/service-orders"; +import type { PhaseStatus } from "./constants"; + +/** Returns the Tailwind class for the timeline dot based on phase state. */ +export function getDotClass({ + isCanceled, + isCompleted, + isActive, +}: { + isCanceled: boolean; + isCompleted: boolean; + isActive: boolean; +}) { + if (isCanceled) { + return "bg-rose-500 !border-0"; + } + if (isCompleted) { + return "bg-green-500 !border-0"; + } + if (isActive) { + return "bg-primary ring-2 ring-offset-2 ring-primary/60 ring-offset-card !border-0"; + } + return "bg-primary/45 !border-0"; +} + +/** Returns the status label text for a phase based on its state. */ +export function getStatusLabel({ + isCanceled, + isCompleted, + isActive, +}: { + isCanceled: boolean; + isCompleted: boolean; + isActive: boolean; +}) { + if (isCanceled) { + return "Cancelado"; + } + if (isCompleted) { + return "Finalizado"; + } + if (isActive) { + return "Em progresso"; + } + return "Pendente"; +} + +/** Returns the Tailwind class for the status badge based on phase state. */ +export function getStatusBadgeClass({ + isCanceled, + isCompleted, + isActive, +}: { + isCanceled: boolean; + isCompleted: boolean; + isActive: boolean; +}) { + if (isCanceled) { + return "bg-rose-500/20 text-rose-700 dark:text-rose-300"; + } + if (isCompleted) { + return "bg-green-500/20 text-green-700 dark:text-green-300"; + } + if (isActive) { + return "bg-primary/20 text-primary-600 dark:text-primary-300"; + } + return "bg-primary/20 text-primary/60"; +} + +/** Returns the Tailwind class for the timeline connector line based on phase state. */ +export function getConnectorClass({ + isCompleted, + isActive, + connectorSpacingClass, +}: { + isCompleted: boolean; + isActive: boolean; + connectorSpacingClass: string; +}) { + if (isCompleted) { + return `bg-green-500 ${connectorSpacingClass}`; + } + if (isActive) { + return `bg-[linear-gradient(to_bottom,var(--primary)_50%,color-mix(in_oklab,var(--primary)_15%,transparent)_50%)] ${connectorSpacingClass}`; + } + return `bg-primary/45 ${connectorSpacingClass}`; +} + +/** Returns the icon element for the timeline dot based on phase state. */ +export function getDotIcon({ + isCanceled, + isCompleted, + isActive, + Icon, +}: { + isCanceled: boolean; + isCompleted: boolean; + isActive: boolean; + Icon: ComponentType<{ className?: string }>; +}) { + if (isCanceled) { + return ; + } + if (isCompleted) { + return ; + } + if (isActive) { + return ; + } + return null; +} + +/** Determines the phase status (done/active/upcoming) based on index comparison. */ +export function getPhaseStatus( + index: number, + activeIndex: number +): PhaseStatus { + if (index < activeIndex) { + return "done"; + } + if (index === activeIndex) { + return "active"; + } + return "upcoming"; +} + +/** Resolves the active phase index from the current service order status. */ +export function resolveActivePhase( + currentStatus: ServiceOrderStatusId +): number { + switch (currentStatus) { + case "registered": + case "analysis": + return 0; + case "quote_pending": + case "approval_pending": + case "parts_pending": + return 1; + case "in_progress": + return 2; + case "ready_for_pickup": + case "finished": + case "canceled": + return 3; + default: + return 0; + } +} diff --git a/apps/web/components/dashboard/service-order/new-service-order-form.tsx b/apps/web/components/dashboard/service-order/new-service-order-form.tsx index bff777d..d383d09 100644 --- a/apps/web/components/dashboard/service-order/new-service-order-form.tsx +++ b/apps/web/components/dashboard/service-order/new-service-order-form.tsx @@ -1,8 +1,10 @@ "use client"; +import { cpf, unmask } from "@fixr/constants/masks"; import { getDevices } from "@fixr/mock"; import { createOrderServiceSchema } from "@fixr/schemas/service-orders"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMaskito } from "@maskito/react"; import { useQuery } from "@tanstack/react-query"; import { ImagePlus, Trash2, UserPlus } from "lucide-react"; import { type ComponentPropsWithoutRef, useMemo, useState } from "react"; @@ -75,12 +77,19 @@ export function NewServiceOrderForm({ mode: "all", }); + const cpfMask = useMaskito({ options: { mask: cpf } }); + const handleCustomerCreated = (cpf: string) => { form.setValue("customerCPF", cpf); }; const onSubmit = (values: z.infer) => { - console.log("Ordem de serviço a ser criada:", values); + const formattedValues = { + ...values, + customerCPF: unmask.cpf(values.customerCPF), + }; + + console.log("Ordem de serviço a ser criada:", formattedValues); }; const [selectedMarca, setSelectedMarca] = useState(""); @@ -157,39 +166,22 @@ export function NewServiceOrderForm({ onSubmit={form.handleSubmit(onSubmit)} {...props} > -
-
+
+
( - + CPF do cliente { - field.onBlur(); - - const cpf = e.target.value.replace(/\D/g, ""); - if (!cpf) { - return; - } - const res = await fetch( - `/api/customers/exists?cpf=${cpf}` - ); - const data = await res.json(); - - if (data.exists) { - form.setError("customerCPF", { - type: "manual", - message: "Este CPF já está cadastrado.", - }); - } else { - form.clearErrors("customerCPF"); - } - }} + onInput={(e) => + form.setValue("customerCPF", e.currentTarget.value) + } + ref={cpfMask} /> @@ -199,7 +191,7 @@ export function NewServiceOrderForm({
- + @@ -226,7 +218,13 @@ export function NewServiceOrderForm({ Defeito relatado pelo cliente -