diff --git a/clients/mobile/app/create-task-ai.tsx b/clients/mobile/app/create-task-ai.tsx
index 2eec4f85..0a642e4a 100644
--- a/clients/mobile/app/create-task-ai.tsx
+++ b/clients/mobile/app/create-task-ai.tsx
@@ -5,7 +5,6 @@ import {
TextInput,
Pressable,
ScrollView,
- Modal,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
@@ -13,78 +12,19 @@ import {
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import {
- Sparkles,
- Send,
- ChevronLeft,
- NotepadText,
- Clock4,
- CalendarSync,
- Flag,
- MapPin,
- House,
- ChevronRight,
-} from "lucide-react-native";
+import { Sparkles, Send, ChevronLeft } from "lucide-react-native";
import { useAPIClient } from "@shared/api/client";
import { getConfig } from "@shared/api/config";
+import { REQUESTS_FEED_QUERY_KEY } from "@shared/api/requests";
import type { GenerateRequestResponse, MakeRequest } from "@shared";
import { SkeletonCard } from "@/components/ui/SkeletonCard";
+import {
+ AITaskEditSheet,
+ type GeneratedTask,
+} from "@/components/tasks/ai-task-edit-sheet";
+import { Colors } from "@/constants/theme";
-// Token values for JSX props that can't use className (icon colors, placeholder, etc.)
-const colors = {
- primary: "#15502c",
- textSubtle: "#747474",
- textSecondary: "#5d5d5d",
- strokeSubtle: "#d8d8d8",
- white: "#ffffff",
-} as const;
-
-type ScreenState = "idle" | "loading" | "complete";
-
-type GeneratedTask = {
- name: string;
- request_type?: string;
- scheduled_time?: string;
- reoccurring?: string;
- priority?: string;
- room_id?: string;
- department?: string;
- description?: string;
- user_id?: string;
-};
-
-type TaskFieldRowProps = {
- icon: React.ReactNode;
- label: string;
- value?: string;
-};
-
-function TaskFieldRow({ icon, label, value }: TaskFieldRowProps) {
- return (
-
-
- {icon}
-
- {label}
-
-
-
- {value ? (
-
- {value}
-
- ) : (
- <>
-
- Select...
-
-
- >
- )}
-
-
- );
-}
+type ScreenState = "idle" | "loading";
export default function CreateTaskAIScreen() {
const [query, setQuery] = useState("");
@@ -93,7 +33,7 @@ export default function CreateTaskAIScreen() {
const [generatedTask, setGeneratedTask] = useState(
null,
);
- const [taskSheetVisible, setTaskSheetVisible] = useState(false);
+ const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
const api = useAPIClient();
const queryClient = useQueryClient();
@@ -108,68 +48,57 @@ export default function CreateTaskAIScreen() {
},
onSuccess: (data) => {
const req = data.request;
+
setGeneratedTask({
name: req?.name ?? "Untitled Task",
request_type: req?.request_type,
scheduled_time: req?.scheduled_time,
+ reoccurring: undefined,
priority: req?.priority,
room_id: req?.room_id,
department: req?.department,
description: req?.description,
user_id: req?.user_id,
});
- setScreenState("complete");
- setTaskSheetVisible(true);
+
+ setScreenState("idle");
+ setIsEditSheetOpen(true);
+ },
+ onError: () => {
+ setScreenState("idle");
},
});
const saveMutation = useMutation({
mutationFn: (task: MakeRequest) => api.post("/request", task),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["requests"] });
- router.navigate("/(tabs)/tasks");
+ queryClient.invalidateQueries({ queryKey: REQUESTS_FEED_QUERY_KEY });
+ router.back();
},
});
const handleSend = () => {
if (!query.trim() || generateMutation.isPending) return;
- setSubmittedQuery(query);
+
+ setSubmittedQuery(query.trim());
setQuery("");
setScreenState("loading");
generateMutation.mutate(query.trim());
};
- const handleSaveTask = () => {
- if (!generatedTask) return;
- const { hotelId } = getConfig();
- saveMutation.mutate({
- hotel_id: hotelId,
- name: generatedTask.name,
- request_type: generatedTask.request_type,
- scheduled_time: generatedTask.scheduled_time,
- priority: generatedTask.priority as MakeRequest["priority"],
- room_id: generatedTask.room_id,
- department: generatedTask.department,
- description: generatedTask.description,
- user_id: generatedTask.user_id,
- });
- };
-
- const handleCancelSheet = () => {
- setTaskSheetVisible(false);
- setScreenState("idle");
+ const handleCloseDraft = () => {
+ setIsEditSheetOpen(false);
setGeneratedTask(null);
- setSubmittedQuery("");
+ setScreenState("idle");
};
return (
- {/* Header */}
-
+
router.back()} className="mr-3">
-
+
Task Creation
@@ -179,44 +108,49 @@ export default function CreateTaskAIScreen() {
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={0}
>
- {/* Content area */}
- {/* Submitted query bubble */}
{submittedQuery ? (
-
-
+
+
{submittedQuery}
) : null}
- {/* Loading state */}
{screenState === "loading" && (
<>
-
-
+
+
Creating Task...
>
)}
+
+ {generateMutation.isError && (
+
+ Something went wrong generating the task. Please try again.
+
+ )}
- {/* AI Chat Input */}
-
-
-
-
+
+
+
+
@@ -241,102 +175,13 @@ export default function CreateTaskAIScreen() {
- {/* Task Review Sheet */}
-
-
-
- {/* Drag handle */}
-
-
-
-
-
-
- {/* Title */}
-
-
- {generatedTask?.name}
-
-
-
- {/* Fields */}
-
- }
- label="Task Type"
- value={generatedTask?.request_type}
- />
- }
- label="Deadline"
- value={generatedTask?.scheduled_time}
- />
- }
- label="Reoccurring"
- value={generatedTask?.reoccurring}
- />
- }
- label="Priority"
- value={generatedTask?.priority}
- />
- }
- label="Location"
- value={generatedTask?.room_id}
- />
- }
- label="Department"
- value={generatedTask?.department}
- />
-
- {/* Description */}
-
-
- Description
-
-
- {generatedTask?.description ?? "Empty"}
-
-
-
-
- {/* Actions */}
-
-
- {saveMutation.isPending ? (
-
- ) : (
-
- Save Task
-
- )}
-
-
-
- Cancel
-
-
-
-
-
-
-
-
+ saveMutation.mutate(task)}
+ savePending={saveMutation.isPending}
+ />
);
}
diff --git a/clients/mobile/app/create-task-manual.tsx b/clients/mobile/app/create-task-manual.tsx
index 594ebff0..2396a238 100644
--- a/clients/mobile/app/create-task-manual.tsx
+++ b/clients/mobile/app/create-task-manual.tsx
@@ -2,10 +2,8 @@ import { useState } from "react";
import {
View,
Text,
- TextInput,
Pressable,
ScrollView,
- ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
@@ -13,7 +11,6 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react-native";
-import { Colors } from "@/constants/theme";
import { useAPIClient } from "@shared/api/client";
import { getConfig } from "@shared/api/config";
import { REQUESTS_FEED_QUERY_KEY } from "@shared/api/requests";
@@ -29,6 +26,7 @@ import { DepartmentPicker } from "@/components/tasks/department-picker";
import { DeadlinePicker } from "@/components/tasks/deadline-picker";
import { AssigneePicker } from "@/components/tasks/assignee-picker";
import { RoomPicker } from "@/components/tasks/room-picker";
+import { TaskFormBody } from "@/components/tasks/TaskFormBody";
export default function CreateTaskManualScreen() {
const [taskName, setTaskName] = useState("");
@@ -73,7 +71,6 @@ export default function CreateTaskManualScreen() {
return (
- {/* Header */}
router.back()} className="mr-3">
@@ -93,78 +90,36 @@ export default function CreateTaskManualScreen() {
contentContainerStyle={{ padding: 24, gap: 24 }}
keyboardShouldPersistTaps="handled"
>
- {/* Task Name Input */}
-
-
-
+
+ setPriority(v ?? "medium")}
+ />
- {/* Task Fields */}
-
- setPriority(v ?? "medium")}
- />
+
-
+
-
+
-
-
-
-
- {/* Description */}
-
-
- Description
-
-
-
-
-
- {/* Actions */}
-
-
- {saveMutation.isPending ? (
-
- ) : (
-
- Save Task
-
- )}
-
- router.back()}
- className="border border-primary rounded h-[39px] items-center justify-center"
- >
-
- Cancel
-
-
-
+
+ >
+ }
+ onSave={handleSave}
+ onCancel={() => router.back()}
+ saveDisabled={!taskName.trim()}
+ savePending={saveMutation.isPending}
+ />
diff --git a/clients/mobile/components/tasks/TaskFormBody.tsx b/clients/mobile/components/tasks/TaskFormBody.tsx
new file mode 100644
index 00000000..f61ac8c8
--- /dev/null
+++ b/clients/mobile/components/tasks/TaskFormBody.tsx
@@ -0,0 +1,110 @@
+import {
+ View,
+ Text,
+ TextInput,
+ Pressable,
+ ActivityIndicator,
+} from "react-native";
+import { Colors } from "@/constants/theme";
+
+type TaskFormBodyProps = {
+ title: string;
+ onTitleChange?: (value: string) => void;
+ description: string;
+ onDescriptionChange?: (value: string) => void;
+ fields: React.ReactNode;
+ onSave: () => void;
+ onCancel: () => void;
+ saveLabel?: string;
+ cancelLabel?: string;
+ saveDisabled?: boolean;
+ savePending?: boolean;
+ readOnly?: boolean;
+};
+
+export function TaskFormBody({
+ title,
+ onTitleChange,
+ description,
+ onDescriptionChange,
+ fields,
+ onSave,
+ onCancel,
+ saveLabel = "Save Task",
+ cancelLabel = "Cancel",
+ saveDisabled = false,
+ savePending = false,
+ readOnly = false,
+}: TaskFormBodyProps) {
+ return (
+
+
+ {readOnly ? (
+
+ {title || "Untitled Task"}
+
+ ) : (
+
+ )}
+
+
+
+ {fields}
+
+
+
+ Description
+
+
+ {readOnly ? (
+
+ {description || "Empty"}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {savePending ? (
+
+ ) : (
+
+ {saveLabel}
+
+ )}
+
+
+
+
+ {cancelLabel}
+
+
+
+
+ );
+}
diff --git a/clients/mobile/components/tasks/ai-task-edit-sheet.tsx b/clients/mobile/components/tasks/ai-task-edit-sheet.tsx
new file mode 100644
index 00000000..ae682400
--- /dev/null
+++ b/clients/mobile/components/tasks/ai-task-edit-sheet.tsx
@@ -0,0 +1,189 @@
+import { useEffect, useRef, useState } from "react";
+import { View, ScrollView, Modal } from "react-native";
+import type {
+ Department,
+ MakeRequest,
+ MakeRequestPriority,
+ RoomWithOptionalGuestBooking,
+ User,
+} from "@shared";
+import { useGetDepartments, useGetRoomsId, useGetUsersId } from "@shared";
+import { getConfig } from "@shared/api/config";
+import { TaskFormBody } from "@/components/tasks/TaskFormBody";
+import { PriorityPicker } from "@/components/tasks/priority-picker";
+import { DepartmentPicker } from "@/components/tasks/department-picker";
+import { DeadlinePicker } from "@/components/tasks/deadline-picker";
+import { AssigneePicker } from "@/components/tasks/assignee-picker";
+import { RoomPicker } from "@/components/tasks/room-picker";
+
+export type GeneratedTask = {
+ name: string;
+ request_type?: string;
+ scheduled_time?: string;
+ reoccurring?: string;
+ priority?: string;
+ room_id?: string;
+ department?: string;
+ description?: string;
+ user_id?: string;
+};
+
+type AITaskEditSheetProps = {
+ visible: boolean;
+ task: GeneratedTask | null;
+ onClose: () => void;
+ onSave: (task: MakeRequest) => void;
+ savePending?: boolean;
+ initialDepartment?: Department;
+ initialAssignee?: User;
+ initialRoom?: RoomWithOptionalGuestBooking;
+};
+
+export function AITaskEditSheet({
+ visible,
+ task,
+ onClose,
+ onSave,
+ savePending = false,
+ initialDepartment,
+ initialAssignee,
+ initialRoom,
+}: AITaskEditSheetProps) {
+ const [taskName, setTaskName] = useState("");
+ const [description, setDescription] = useState("");
+ const [priority, setPriority] = useState("medium");
+ const [department, setDepartment] = useState(
+ initialDepartment,
+ );
+ const [deadline, setDeadline] = useState(undefined);
+ const [assignee, setAssignee] = useState(initialAssignee);
+ const [room, setRoom] = useState(
+ initialRoom,
+ );
+
+ const { hotelId } = getConfig();
+
+ const { data: departments } = useGetDepartments(hotelId);
+ const resolvedDepartment = departments?.find(
+ (d) => d.id === task?.department,
+ );
+
+ const { data: resolvedUser } = useGetUsersId(task?.user_id ?? "", {
+ query: { enabled: !!task?.user_id },
+ });
+
+ const { data: resolvedRoom } = useGetRoomsId(task?.room_id ?? "", {
+ query: { enabled: !!task?.room_id },
+ });
+
+ // Track whether we've fully initialized for the current task so re-renders
+ // caused by resolving fetches don't override the user's picker changes.
+ const lastInitTaskRef = useRef(null);
+
+ useEffect(() => {
+ if (!task || !visible) {
+ lastInitTaskRef.current = null;
+ return;
+ }
+ if (lastInitTaskRef.current === task) return;
+
+ setTaskName(task.name ?? "");
+ setDescription(task.description ?? "");
+ setPriority((task.priority as MakeRequestPriority) ?? "medium");
+ setDeadline(
+ task.scheduled_time ? new Date(task.scheduled_time) : undefined,
+ );
+ setDepartment(resolvedDepartment ?? initialDepartment);
+ setAssignee(resolvedUser ?? initialAssignee);
+ setRoom(resolvedRoom ?? initialRoom);
+
+ // Mark fully initialized only once all expected lookups have resolved,
+ // so the effect keeps running until all pickers have their objects.
+ const allReady =
+ (!task.department || resolvedDepartment != null) &&
+ (!task.user_id || resolvedUser != null) &&
+ (!task.room_id || resolvedRoom != null);
+
+ if (allReady) {
+ lastInitTaskRef.current = task;
+ }
+ }, [
+ task,
+ visible,
+ resolvedDepartment,
+ resolvedUser,
+ resolvedRoom,
+ initialDepartment,
+ initialAssignee,
+ initialRoom,
+ ]);
+
+ const handleSave = () => {
+ if (!taskName.trim()) return;
+
+ const { hotelId } = getConfig();
+
+ onSave({
+ hotel_id: hotelId,
+ name: taskName.trim(),
+ description: description.trim() || undefined,
+ priority,
+ department: department?.id ?? task?.department,
+ user_id: assignee?.id ?? task?.user_id,
+ room_id: room?.id ?? task?.room_id,
+ scheduled_time: deadline?.toISOString(),
+ status: "pending",
+ request_type: task?.request_type ?? "general",
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ setPriority(v ?? "medium")}
+ />
+
+
+
+
+
+
+
+
+ >
+ }
+ onSave={handleSave}
+ onCancel={onClose}
+ saveDisabled={!taskName.trim()}
+ savePending={savePending}
+ />
+
+
+
+
+ );
+}
diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts
index ad2502c1..db867dc6 100644
--- a/clients/shared/src/index.ts
+++ b/clients/shared/src/index.ts
@@ -92,6 +92,7 @@ export type {
export {
usePostRooms,
+ useGetRoomsId,
useGetRoomsFloors,
// Hook variants
usePostRoomsHook,