feat(ticket): add due date picker and contractor details to assign flow

This commit is contained in:
2026-07-03 16:07:08 +05:30
parent b6a0f04f1e
commit 26e15360f7
12 changed files with 723 additions and 64 deletions

73
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"libphonenumber-js": "^1.13.7", "libphonenumber-js": "^1.13.7",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
@@ -34,11 +35,13 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "19.2.0", "react": "19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -403,6 +406,12 @@
} }
} }
}, },
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
@@ -4780,6 +4789,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7979,6 +7998,32 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
@@ -9215,6 +9260,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
"version": "36.9.2", "version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
@@ -9422,21 +9480,6 @@
"optional": true "optional": true
} }
} }
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }

View File

@@ -31,6 +31,7 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"libphonenumber-js": "^1.13.7", "libphonenumber-js": "^1.13.7",
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
@@ -38,11 +39,13 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "19.2.0", "react": "19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0", "react-hook-form": "^7.79.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },

View File

@@ -1,56 +1,93 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Send, UserPlus } from 'lucide-react'; import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react';
import { DatePicker } from '@/components/form/DatePicker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect'; import { AssignableWorkerSelect } from '@/components/lookups/AssignableWorkerSelect';
import type { TicketDetail } from '@/types'; import type { AssignableTicketUser, TicketDetail } from '@/types';
import { useAssignTicketMutation } from '../../../hooks/useTicketQueries'; import { useAssignTicketMutation } from '../../../hooks/useTicketQueries';
import { TicketActionCard } from './TicketActionCard'; import { TicketActionCard } from './TicketActionCard';
export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) { export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) {
const [selectedUserId, setSelectedUserId] = useState(''); const [selectedUserId, setSelectedUserId] = useState('');
const [selectedContractor, setSelectedContractor] =
useState<AssignableTicketUser | null>(null);
const [dueDate, setDueDate] = useState<Date>();
const [assignNote, setAssignNote] = useState(''); const [assignNote, setAssignNote] = useState('');
const assignMutation = useAssignTicketMutation(ticket.id); const assignMutation = useAssignTicketMutation(ticket.id);
return ( return (
<TicketActionCard icon={UserPlus} title="Assign Ticket"> <TicketActionCard icon={Users} title="Assign to Contractor">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Contractor</Label> <Label>Contractor *</Label>
<AssignableWorkerSelect <AssignableWorkerSelect
value={selectedUserId} value={selectedUserId}
onValueChange={setSelectedUserId} onValueChange={(userId, user) => {
setSelectedUserId(userId);
setSelectedContractor(user);
}}
disabled={assignMutation.isPending} disabled={assignMutation.isPending}
/> />
</div> </div>
{selectedContractor ? (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Contractor Details</p>
{selectedContractor.phone_number ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Phone className="size-4 shrink-0" />
{selectedContractor.phone_number}
</p>
) : null}
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="size-4 shrink-0" />
<span className="truncate">{selectedContractor.email}</span>
</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
{selectedContractor.role.name}
</p>
</div>
) : null}
<div className="space-y-2"> <div className="space-y-2">
<Label>Note</Label> <Label>Due Date *</Label>
<DatePicker
value={dueDate}
onChange={setDueDate}
showTimeZone={false}
disabled={assignMutation.isPending}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Assignment Note (Optional)</Label>
<Textarea <Textarea
value={assignNote} value={assignNote}
onChange={(event) => setAssignNote(event.target.value)} onChange={(event) => setAssignNote(event.target.value)}
placeholder="Please inspect and repair" placeholder="Add any instructions or notes for the contractor..."
/> />
</div> </div>
<Button <Button
disabled={!selectedUserId || assignMutation.isPending} disabled={!selectedUserId || !dueDate || assignMutation.isPending}
onClick={() => { onClick={() => {
if (!selectedUserId) return; if (!selectedUserId || !dueDate) return;
assignMutation.mutate({ assignMutation.mutate({
assigned_to_user_id: Number(selectedUserId), assigned_to_user_id: Number(selectedUserId),
defect_class: ticket.defect_class, defect_class: ticket.defect_class,
due_at: dueDate.toISOString(),
note: assignNote || undefined, note: assignNote || undefined,
}); });
}} }}
className="w-full" className="w-full"
> >
<Send /> <Send />
Assign Assign Ticket
</Button> </Button>
</div> </div>
</TicketActionCard> </TicketActionCard>

View File

@@ -14,11 +14,9 @@ import {
ComboboxTrigger, ComboboxTrigger,
ComboboxValue, ComboboxValue,
} from '@/components/ui/combobox'; } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import type { import type { AsyncSelectOption, AsyncSelectProps } from './AsyncSelect.types';
AsyncSelectOption,
AsyncSelectProps,
} from './AsyncSelect.types';
import { VirtualSelectList } from './VirtualSelectList'; import { VirtualSelectList } from './VirtualSelectList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>; type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
@@ -30,6 +28,8 @@ export function AsyncSelect({
searchPlaceholder = 'Search...', searchPlaceholder = 'Search...',
emptyMessage = 'No items found.', emptyMessage = 'No items found.',
disabled = false, disabled = false,
triggerClassName,
renderSelectedOption,
portalContainer, portalContainer,
}: AsyncSelectProps) { }: AsyncSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -125,7 +125,10 @@ export function AsyncSelect({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full justify-between font-normal" className={cn(
'w-full justify-between font-normal',
triggerClassName,
)}
disabled={isDisabled} disabled={isDisabled}
> >
{isLoading && !selectedOption ? ( {isLoading && !selectedOption ? (
@@ -133,15 +136,21 @@ export function AsyncSelect({
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Loading... Loading...
</span> </span>
) : (
<>
{selectedOption && renderSelectedOption ? (
renderSelectedOption(selectedOption)
) : ( ) : (
<ComboboxValue placeholder={triggerPlaceholder} /> <ComboboxValue placeholder={triggerPlaceholder} />
)} )}
</>
)}
</Button> </Button>
} }
/> />
<ComboboxContent <ComboboxContent
container={portalContainer} container={portalContainer}
className="min-w-[var(--anchor-width)]" className="min-w-(--anchor-width)"
> >
<div className="border-b p-2"> <div className="border-b p-2">
<ComboboxInput <ComboboxInput

View File

@@ -1,8 +1,9 @@
import type { RefObject } from 'react'; import type { ReactNode, RefObject } from 'react';
export type AsyncSelectOption = { export type AsyncSelectOption = {
value: string; value: string;
label: string; label: string;
data?: unknown;
}; };
export type PaginatedResult<T> = { export type PaginatedResult<T> = {
@@ -47,5 +48,7 @@ export interface AsyncSelectProps {
searchPlaceholder?: string; searchPlaceholder?: string;
emptyMessage?: string; emptyMessage?: string;
disabled?: boolean; disabled?: boolean;
triggerClassName?: string;
renderSelectedOption?: (option: AsyncSelectOption) => ReactNode;
portalContainer?: RefObject<HTMLDivElement | null>; portalContainer?: RefObject<HTMLDivElement | null>;
} }

View File

@@ -0,0 +1,171 @@
'use client';
import * as React from 'react';
import { CalendarDays, ChevronDownIcon, Globe2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
const DEFAULT_TIME_ZONES = [
'UTC',
'Asia/Kolkata',
'Asia/Dubai',
'Asia/Singapore',
'Asia/Tokyo',
'Europe/London',
'America/New_York',
'America/Los_Angeles',
'Australia/Sydney',
] as const;
export interface DatePickerProps {
value?: Date;
defaultValue?: Date;
onChange?: (date: Date | undefined) => void;
timeZone?: string;
defaultTimeZone?: string;
onTimeZoneChange?: (timeZone: string) => void;
timeZones?: readonly string[];
showTimeZone?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
}
function getBrowserTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}
function formatDate(date: Date, timeZone: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeZone,
}).format(date);
}
function getTimeZoneLabel(timeZone: string) {
return timeZone.replaceAll('_', ' ');
}
export function DatePicker(props: DatePickerProps) {
const {
value,
defaultValue,
onChange,
timeZone,
defaultTimeZone,
onTimeZoneChange,
timeZones = DEFAULT_TIME_ZONES,
showTimeZone = true,
placeholder = 'Pick a date',
disabled = false,
className,
} = props;
const isDateControlled = Object.hasOwn(props, 'value');
const isTimeZoneControlled = Object.hasOwn(props, 'timeZone');
const [internalDate, setInternalDate] = React.useState(defaultValue);
const [internalTimeZone, setInternalTimeZone] = React.useState(
defaultTimeZone ?? 'UTC',
);
React.useEffect(() => {
if (!isTimeZoneControlled && !defaultTimeZone) {
setInternalTimeZone(getBrowserTimeZone());
}
}, [defaultTimeZone, isTimeZoneControlled]);
const selectedDate = isDateControlled ? value : internalDate;
const selectedTimeZone = timeZone ?? internalTimeZone;
const availableTimeZones = React.useMemo(
() => Array.from(new Set([selectedTimeZone, ...timeZones])).filter(Boolean),
[selectedTimeZone, timeZones],
);
const handleDateChange = (nextDate: Date | undefined) => {
if (!isDateControlled) setInternalDate(nextDate);
onChange?.(nextDate);
};
const handleTimeZoneChange = (nextTimeZone: string | null) => {
if (!nextTimeZone) return;
if (!isTimeZoneControlled) setInternalTimeZone(nextTimeZone);
onTimeZoneChange?.(nextTimeZone);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
data-empty={!selectedDate}
className={cn(
'w-64 justify-between text-left font-normal data-[empty=true]:text-muted-foreground',
className,
)}
>
<span className="flex min-w-0 items-center gap-2">
<CalendarDays className="size-4 shrink-0" />
<span className="truncate">
{selectedDate
? formatDate(selectedDate, selectedTimeZone)
: placeholder}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateChange}
defaultMonth={selectedDate}
timeZone={selectedTimeZone}
/>
{showTimeZone ? (
<div className="flex items-center gap-2 border-t p-3">
<Globe2 className="size-4 shrink-0 text-muted-foreground" />
<Select
value={selectedTimeZone}
onValueChange={handleTimeZoneChange}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1"
aria-label="Time zone"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{availableTimeZones.map((zone) => (
<SelectItem key={zone} value={zone}>
{getTimeZoneLabel(zone)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</PopoverContent>
</Popover>
);
}
export { DEFAULT_TIME_ZONES };

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { UserRound } from 'lucide-react';
import { AsyncSelect, usePaginatedSelect } from '@/components/async-select'; import { AsyncSelect, usePaginatedSelect } from '@/components/async-select';
import type { AsyncSelectOption } from '@/components/async-select'; import type { AsyncSelectOption } from '@/components/async-select';
import { ticketKeys } from '@/app/(modules)/ticket/queries/ticketKeys'; import { ticketKeys } from '@/app/(modules)/ticket/queries/ticketKeys';
@@ -14,6 +16,7 @@ function mapWorkerOption(user: AssignableTicketUser): AsyncSelectOption {
return { return {
value: String(user.id), value: String(user.id),
label: user.name || user.email || user.username, label: user.name || user.email || user.username,
data: user,
}; };
} }
@@ -58,14 +61,40 @@ export function AssignableWorkerSelect({
[...ticketKeys.assignableUsers(), 'selected', workerId] as const, [...ticketKeys.assignableUsers(), 'selected', workerId] as const,
}); });
const handleValueChange = (workerId: string) => {
const option = lookup.options.find((item) => item.value === workerId);
const user = option?.data as AssignableTicketUser | undefined;
if (user) onValueChange(workerId, user);
};
const selectedUser = lookup.selectedOption?.data as
| AssignableTicketUser
| undefined;
return ( return (
<AsyncSelect <AsyncSelect
lookup={lookup} lookup={lookup}
onValueChange={onValueChange} onValueChange={handleValueChange}
disabled={disabled} disabled={disabled}
placeholder="Select contractor" placeholder="Select contractor"
searchPlaceholder="Search contractors" searchPlaceholder="Search contractors"
emptyMessage="No assignable contractors found." emptyMessage="No assignable contractors found."
triggerClassName="h-auto min-h-14 py-2"
renderSelectedOption={(option) => (
<span className="flex min-w-0 items-center gap-2.5">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<UserRound className="size-5" aria-hidden />
</span>
<span className="min-w-0 text-left">
<span className="block truncate font-medium text-foreground">
{option.label}
</span>
<span className="block truncate text-xs text-muted-foreground">
{selectedUser?.role.name}
</span>
</span>
</span>
)}
/> />
); );
} }

View File

@@ -1,5 +1,7 @@
import type { AssignableTicketUser } from '@/types';
export interface AssignableWorkerSelectProps { export interface AssignableWorkerSelectProps {
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string, user: AssignableTicketUser) => void;
disabled?: boolean; disabled?: boolean;
} }

View File

@@ -1,54 +1,54 @@
import * as React from 'react'; import * as React from "react"
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from 'radix-ui'; import { Slot } from "radix-ui"
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60', "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:border-ring dark:border-input dark:bg-input/30 dark:hover:bg-input/50', "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80', "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: 'text-primary underline-offset-4 hover:underline', link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: 'size-9', icon: "size-9",
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8', "icon-sm": "size-8",
'icon-lg': 'size-10', "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, }
); )
function Button({ function Button({
className, className,
variant = 'default', variant = "default",
size = 'default', size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean
}) { }) {
const Comp = asChild ? Slot.Root : 'button'; const Comp = asChild ? Slot.Root : "button"
return ( return (
<Comp <Comp
@@ -58,7 +58,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
); )
} }
export { Button, buttonVariants }; export { Button, buttonVariants }

View File

@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -5,6 +5,12 @@ export interface AssignableTicketUser {
name: string; name: string;
email: string; email: string;
username: string; username: string;
phone_number: string | null;
role: {
id: number;
name: string;
};
} }
export interface AssignableTicketUsersParams extends PaginationParams { export interface AssignableTicketUsersParams extends PaginationParams {
@@ -19,6 +25,7 @@ export interface AssignableTicketUsersResponse {
export interface AssignTicketPayload { export interface AssignTicketPayload {
assigned_to_user_id: number; assigned_to_user_id: number;
defect_class: string; defect_class: string;
due_at: string;
note?: string; note?: string;
} }