project setup

This commit is contained in:
Sone
2025-11-02 10:08:56 +01:00
commit 568838003e
174 changed files with 27100 additions and 0 deletions
@@ -0,0 +1,23 @@
import { View } from "react-native";
import { Button } from "@/components/ui/Button";
interface AddTodoControlsProps {
onAddTodo: () => void;
}
export const AddTodoControls = ({ onAddTodo }: AddTodoControlsProps) => {
return (
<View className="flex-row">
<Button
onPress={onAddTodo}
icon="note-plus-outline"
buttonColor="#6750a4"
buttonTextColor="white"
disabled={false}
>
Add Todo
</Button>
</View>
);
};
@@ -0,0 +1,34 @@
import { View } from "react-native";
import { IconButton } from "react-native-paper";
import { Todo } from "@/store/models/Todo.model";
interface AdminTodoActionsProps {
todo: Todo;
onView: (todo: Todo) => void;
onEdit: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
}
export const AdminTodoActions = ({
todo,
onView,
onEdit,
onDelete,
}: AdminTodoActionsProps) => {
return (
<View className="flex-row justify-center">
<IconButton icon="eye-outline" onPress={() => onView(todo)} size={20} />
<IconButton
icon="pencil-outline"
onPress={() => onEdit(todo)}
size={20}
/>
<IconButton
icon="delete-outline"
onPress={() => onDelete(todo)}
size={20}
/>
</View>
);
};
@@ -0,0 +1,63 @@
import { DataTable } from "react-native-paper";
import { Text } from "react-native";
import { SortableHeader } from "@/components/shared/SortableHeader";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
interface AdminTodoTableHeaderProps {
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
}
export const AdminTodoTableHeader = ({
sortColumn,
sortDirection,
onSort,
}: AdminTodoTableHeaderProps) => {
return (
<DataTable.Header>
<SortableHeader
title="Title"
column="title"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Task"
column="task"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Status"
column="status"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
centerAlign
/>
<SortableHeader
title="Priority"
column="priority"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
centerAlign
/>
<SortableHeader
title="Date Created"
column="createdAt"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
centerAlign
/>
<DataTable.Title style={{ justifyContent: "center" }}>
<Text style={{ fontSize: 12, color: "black" }}>Actions</Text>
</DataTable.Title>
</DataTable.Header>
);
};
@@ -0,0 +1,377 @@
import { useEffect, useMemo, useState, useDeferredValue } from "react";
import { View, Text, ScrollView, GestureResponderEvent } from "react-native";
import { useAuthContext } from "@/auth/AuthContext";
import { Todo } from "@/store/models/Todo.model";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { NoData } from "@/components/ui/NoData";
import {
useCreateTodoMutation,
useDeleteTodoMutation,
useGetTodosQuery,
useUpdateTodoMutation,
} from "@/store/api/todosApi";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
import { AdminTodosTableControls } from "@/components/features/todos/AdminTodosTableControls";
import { TodoStatus } from "@/constants/todoStatusEnum";
import { TablePagination } from "@/components/shared/TablePagination";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { MobileAdminTodosTable } from "@/components/features/todos/MobileAdminTodosTable";
import { DesktopAdminTodosTable } from "@/components/features/todos/DesktopAdminTodosTable";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { TodoModal } from "@/components/features/todos/TodoModal";
import { DeleteTodoModal } from "@/components/features/todos/DeleteTodoModal";
import { OperationMode } from "@/constants/operationModeEnum";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface CreateTodoPayload {
title: string;
task: string;
status: TodoStatus;
priority: PriorityStatus;
dueDate: Date;
createdById: string;
assigneeIds: string[];
}
export const AdminTodosTable = ({ initialSearchQuery = "" }: { initialSearchQuery?: string }) => {
const { user } = useAuthContext();
const [page, setPage] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [searchQuery, setSearchQuery] = useState("");
const [selectedStatuses, setSelectedStatuses] = useState<TodoStatus[]>([]);
const [selectedPriorities, setSelectedPriorities] = useState<
PriorityStatus[]
>([]);
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<TableSortDirection>(
TableSortDirection.ASCENDING
);
const deferredSearch = useDeferredValue(searchQuery);
const [debouncedSearch, setDebouncedSearch] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(deferredSearch.trim()), 450);
return () => clearTimeout(t);
}, [deferredSearch]);
// Initialize search from route param if provided
useEffect(() => {
const q = (initialSearchQuery || "").trim();
if (q && q !== searchQuery) {
setSearchQuery(q);
setPage(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSearchQuery]);
const queryArgs = useMemo(() => {
const allowedSorts = [
"title",
"dueDate",
"status",
"priority",
"createdAt",
] as const;
const sortBy = (allowedSorts as readonly string[]).includes(
sortColumn || ""
)
? (sortColumn as (typeof allowedSorts)[number])
: undefined;
const sortDir = sortDirection;
const searchParam =
debouncedSearch && debouncedSearch.length >= 2
? debouncedSearch
: undefined;
return {
page: page + 1,
limit: itemsPerPage,
search: searchParam,
statuses: selectedStatuses.length ? selectedStatuses : undefined,
priorities: selectedPriorities.length ? selectedPriorities : undefined,
sortBy,
sortDir,
};
}, [
page,
itemsPerPage,
debouncedSearch,
selectedStatuses,
selectedPriorities,
sortColumn,
sortDirection,
]);
const {
data: todosPage,
isLoading,
isFetching,
error,
refetch,
} = useGetTodosQuery(queryArgs);
const todos = todosPage?.items ?? [];
const totalItems = todosPage?.total ?? 0;
const [createTodo, { isLoading: isCreatingTodo }] = useCreateTodoMutation();
const [updateTodo, { isLoading: isUpdatingTodo }] = useUpdateTodoMutation();
const [deleteTodo, { isLoading: isDeletingTodo }] = useDeleteTodoMutation();
const [menuVisible, setMenuVisible] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const [addTodoModalVisible, setAddTodoModalVisible] = useState(false);
const [editTodoModalVisible, setEditTodoModalVisible] = useState(false);
const [deleteTodoModalVisible, setDeleteTodoModalVisible] = useState(false);
const [currentTodo, setCurrentTodo] = useState<Todo | null>(null);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
const handleCreateTodo = async (
todo: Partial<Todo> & { assigneeIds?: string[]; userId?: string }
) => {
try {
const unique = (arr: string[]) => Array.from(new Set(arr.filter(Boolean)));
const payload: CreateTodoPayload = {
title: todo.title || "",
task: todo.task || "",
status: todo.status || TodoStatus.NEW,
priority: todo.priority || PriorityStatus.LOW_PRIORITY,
dueDate: todo.dueDate || new Date(),
createdById: user?.id || "",
assigneeIds: unique([...(todo.assigneeIds || []), ...(todo.userId ? [todo.userId] : [])]),
};
await createTodo(payload).unwrap();
showSnackbar("Todo created successfully");
setAddTodoModalVisible(false);
refetch();
} catch {
showSnackbar("Error creating todo");
}
};
const handleUpdateTodo = async (
todo: Partial<Todo> & { assigneeIds?: string[]; userId?: string }
) => {
try {
const body: any = {
...(todo.id ? { id: todo.id } : {}),
...(todo.title ? { title: todo.title } : {}),
...(todo.task ? { task: todo.task } : {}),
...(todo.status ? { status: todo.status } : {}),
...(todo.priority ? { priority: todo.priority } : {}),
...(todo.dueDate ? { dueDate: todo.dueDate } : {}),
};
const combinedAssignees = Array.from(
new Set([...(todo.assigneeIds || []), ...(todo.userId ? [todo.userId] : [])])
);
if (combinedAssignees.length) {
body.assigneeIds = combinedAssignees;
}
await updateTodo(body).unwrap();
showSnackbar("Todo updated successfully");
setEditTodoModalVisible(false);
refetch();
} catch {
showSnackbar("Error updating todo");
}
};
const handleDeleteTodo = async (todo: Todo) => {
try {
await deleteTodo(todo.id).unwrap();
refetch();
showSnackbar(`${todo.title} has been deleted successfully`);
setDeleteTodoModalVisible(false);
} catch {
showSnackbar("Error deleting todo");
}
};
const handleSort = (column: string) => {
setSortColumn(column);
setSortDirection(
sortDirection === TableSortDirection.ASCENDING
? TableSortDirection.DESCENDING
: TableSortDirection.ASCENDING
);
setPage(0);
};
const handleItemsPerPageChange = (value: number) => {
setItemsPerPage(value);
};
const handleSearch = (query: string) => {
setSearchQuery(query);
setPage(0);
};
const handleEdit = (todo: Todo) => {
setCurrentTodo(todo);
setEditTodoModalVisible(true);
};
const openDeleteModal = (todo: Todo) => {
setCurrentTodo(todo);
setDeleteTodoModalVisible(true);
};
const isMobile = useAppSelector(selectIsMobile);
const from = todos.length ? page * itemsPerPage + 1 : 0;
const to = todos.length ? Math.min(from + todos.length - 1, totalItems) : 0;
useEffect(() => {
setPage(0);
}, [itemsPerPage]);
return (
<>
<View className="flex-1">
<Text
className={`text-2xl font-bold ${
isMobile ? "mb-1" : "mb-4"
} text-white`}
>
Todos manager
</Text>
<View
className={`${
isMobile ? "p-1" : "p-5"
} bg-lightGray rounded-lg flex-1`}
>
{isLoading ? (
<View className="flex-1 justify-center items-center">
<LoadingSpinner />
<Text className="mt-2">Loading todos...</Text>
</View>
) : error ? (
<NoData
message="Error loading todos"
onRetry={refetch}
isRetrying={isFetching}
/>
) : (
<View className="flex-1 flex flex-col">
<AdminTodosTableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
selectedStatuses={selectedStatuses}
selectedPriorities={selectedPriorities}
onSearch={handleSearch}
onMenuDismiss={() => setMenuVisible(false)}
onItemsPerPageChange={handleItemsPerPageChange}
onSelectStatuses={setSelectedStatuses}
onSelectPriorities={setSelectedPriorities}
onAddTodo={() => setAddTodoModalVisible(true)}
itemsPerPage={itemsPerPage}
/>
<TodoModal
visible={addTodoModalVisible}
onClose={() => setAddTodoModalVisible(false)}
onSubmit={handleCreateTodo}
isLoading={isCreatingTodo}
mode={OperationMode.ADD}
/>
<TodoModal
visible={editTodoModalVisible}
todo={currentTodo}
onClose={() => setEditTodoModalVisible(false)}
onSubmit={handleUpdateTodo}
isLoading={isUpdatingTodo}
mode={OperationMode.EDIT}
/>
<DeleteTodoModal
visible={deleteTodoModalVisible}
todo={currentTodo}
onDismiss={() => setDeleteTodoModalVisible(false)}
onDelete={handleDeleteTodo}
isLoading={isDeletingTodo}
/>
<View className="flex-1 overflow-hidden">
{searchQuery && todos.length === 0 ? (
<View
className={`flex-1 items-center justify-center ${
isMobile ? "p-1" : "p-5"
}`}
>
<Text className="text-lg text-gray-500">
No todos match your search for &quot;{searchQuery}&quot;
</Text>
</View>
) : searchQuery.length === 0 && todos.length === 0 ? (
<View
className={`flex-1 items-center justify-center ${
isMobile ? "p-1" : "p-5"
}`}
>
<Text className="text-lg text-gray-500">
No todos found
</Text>
</View>
) : isMobile ? (
<View className="flex-1">
<MobileAdminTodosTable
todos={todos}
onEdit={handleEdit}
onDelete={openDeleteModal}
/>
</View>
) : (
<View className="flex-1" style={{ height: 400 }}>
<ScrollView className="flex-1">
<DesktopAdminTodosTable
todos={todos}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={handleEdit}
onDelete={openDeleteModal}
/>
</ScrollView>
</View>
)}
</View>
<TablePagination
page={page}
itemsPerPage={itemsPerPage}
totalItems={totalItems}
from={from}
to={to}
onPageChange={setPage}
onItemsPerPageButtonPress={(event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent;
setMenuAnchor({ x: pageX, y: pageY });
setMenuVisible(true);
}}
/>
</View>
)}
</View>
</View>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};
@@ -0,0 +1,166 @@
import { View } from "react-native";
import { TableControls } from "@/components/shared/TableControls";
import { AddTodoControls } from "@/components/features/todos/AddTodoControls";
import {
CombinedFilterMenu,
CombinedFilterSection,
} from "@/components/shared/filters/CombinedFilterMenu";
import {
SelectedFilterChips,
SelectedChip,
} from "@/components/shared/filters/SelectedFilterChips";
import {
TodoStatus,
TodoStatusLabels,
TodoStatusColors,
} from "@/constants/todoStatusEnum";
import {
PriorityStatus,
PriorityStatusLabels,
} from "@/constants/priorityStatusEnum";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
interface AdminTodoTableControlsProps {
searchQuery: string;
menuVisible: boolean;
menuAnchor: { x: number; y: number };
selectedStatuses: TodoStatus[];
selectedPriorities: PriorityStatus[];
onSearch: (query: string) => void;
onMenuDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
onSelectStatuses: (statuses: TodoStatus[]) => void;
onSelectPriorities: (priorities: PriorityStatus[]) => void;
onAddTodo: () => void;
itemsPerPage: number;
}
export const AdminTodosTableControls = ({
searchQuery,
menuVisible,
menuAnchor,
selectedStatuses = [],
selectedPriorities = [],
onSearch,
onMenuDismiss,
onItemsPerPageChange,
onSelectStatuses,
onSelectPriorities,
onAddTodo,
itemsPerPage = 25,
}: AdminTodoTableControlsProps) => {
const renderFilterComponent = () => {
const sections: CombinedFilterSection[] = [];
const statusOptions = Object.values(TodoStatus).map((status) => ({
value: status,
label: TodoStatusLabels[status],
icon: (
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: TodoStatusColors[status],
}}
/>
),
}));
sections.push({
key: "status",
title: "Status",
options: statusOptions,
selected: selectedStatuses,
onChange: (values) => onSelectStatuses(values as TodoStatus[]),
});
const priorityOptions = Object.values(PriorityStatus).map((priority) => ({
value: priority,
label: PriorityStatusLabels[priority],
icon: <PriorityIndicator priority={priority} />,
}));
sections.push({
key: "priority",
title: "Priority",
options: priorityOptions,
selected: selectedPriorities,
onChange: (values) => onSelectPriorities(values as PriorityStatus[]),
});
const chips: SelectedChip[] = [];
selectedStatuses.forEach((status) =>
chips.push({
key: `status-${status}`,
label: TodoStatusLabels[status],
onRemove: () =>
onSelectStatuses(selectedStatuses.filter((s) => s !== status)),
})
);
selectedPriorities.forEach((priority) =>
chips.push({
key: `priority-${priority}`,
label: PriorityStatusLabels[priority],
onRemove: () =>
onSelectPriorities(selectedPriorities.filter((p) => p !== priority)),
})
);
return (
<CombinedFilterMenu
sections={sections}
showResetButton={true}
onResetAll={() => {
onSelectStatuses([]);
onSelectPriorities([]);
}}
/>
);
};
const renderFilterChips = () => {
const chips: SelectedChip[] = [];
selectedStatuses.forEach((status) =>
chips.push({
key: `status-${status}`,
label: TodoStatusLabels[status],
onRemove: () =>
onSelectStatuses(selectedStatuses.filter((s) => s !== status)),
})
);
selectedPriorities.forEach((priority) =>
chips.push({
key: `priority-${priority}`,
label: PriorityStatusLabels[priority],
onRemove: () =>
onSelectPriorities(selectedPriorities.filter((p) => p !== priority)),
})
);
return chips.length > 0 ? <SelectedFilterChips chips={chips} /> : null;
};
const renderActionButtons = () => <AddTodoControls onAddTodo={onAddTodo} />;
return (
<TableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
searchPlaceholder="Search todos"
onSearch={onSearch}
onMenuDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
itemsPerPage={itemsPerPage}
renderFilterComponent={renderFilterComponent}
renderFilterChips={renderFilterChips}
renderActionButtons={renderActionButtons}
/>
);
};
@@ -0,0 +1,94 @@
import { useState } from "react";
import { View, Text } from "react-native";
import { Dialog, IconButton } from "react-native-paper";
import { DialogContainer } from "@/components/ui/DialogContainer";
import { Todo } from "@/store/models/Todo.model";
import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface DeleteTodoModalProps {
visible: boolean;
todo: Todo | null;
onDismiss: () => void;
onDelete: (todo: Todo) => void;
isLoading?: boolean;
}
export const DeleteTodoModal = ({
visible,
todo,
onDismiss,
onDelete,
isLoading = false,
}: DeleteTodoModalProps) => {
const [error, setError] = useState<string | null>(null);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
if (!todo) return null;
const handleDelete = () => {
try {
setError(null);
onDelete(todo);
} catch {
showSnackbar("Failed to delete todo. Please try again.");
}
};
return (
<>
<DialogContainer visible={visible} onDismiss={onDismiss}>
<Dialog.Title>
<View className="w-full flex-row items-center justify-between">
<View className="flex-1">
<Text className="pr-3">Delete Todo</Text>
</View>
<View style={{ alignSelf: "flex-start" }}>
<IconButton
icon="close"
size={20}
onPress={onDismiss}
disabled={isLoading}
accessibilityLabel="Close dialog"
style={{ margin: 0 }}
/>
</View>
</View>
</Dialog.Title>
<Dialog.Content>
<Text>Are you sure you want to delete {todo.title}?</Text>
{error && <Text className="text-highRisk mt-2">{error}</Text>}
</Dialog.Content>
<Dialog.Actions>
<Button className="!ml-2" onPress={handleDelete} disabled={isLoading}>
Delete
</Button>
</Dialog.Actions>
{isLoading && (
<View
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
pointerEvents="auto"
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
>
<LoadingSpinner size={16} />
</View>
)}
</DialogContainer>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};
@@ -0,0 +1,104 @@
import { useState } from "react";
import { View, Text } from "react-native";
import { DataTable } from "react-native-paper";
import { Todo } from "@/store/models/Todo.model";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
import { AdminTodoTableHeader } from "@/components/features/todos/AdminTodoTableHeader";
import { AdminTodoActions } from "@/components/features/todos/AdminTodoActions";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { StatusTableItem } from "@/components/ui/StatusTableItem";
import { TodoDetailsModal } from "@/components/features/todos/TodoDetailsModal";
import { formatDateMDY, formatTime } from "@/utils/dateUtils";
interface DesktopAdminTodoTableProps {
todos: Todo[];
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
onEdit: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
}
export const DesktopAdminTodosTable = ({
todos,
sortColumn,
sortDirection,
onSort,
onEdit,
onDelete,
}: DesktopAdminTodoTableProps) => {
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
const openDetails = (todo: Todo) => {
setSelectedTodo(todo);
setDetailsOpen(true);
};
const closeDetails = () => setDetailsOpen(false);
return (
<DataTable>
<AdminTodoTableHeader
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
{todos.map((todo) => (
<DataTable.Row key={todo.id}>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink px-1 py-1">
<Text onPress={() => openDetails(todo)} numberOfLines={10}>
{todo.title}
</Text>
</View>
</DataTable.Cell>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink px-1 py-1">
<Text numberOfLines={10}>
{todo.task && todo.task.length > 50
? `${todo.task.slice(0, 50)}...`
: todo.task}
</Text>
</View>
</DataTable.Cell>
<DataTable.Cell
style={{
justifyContent: "center",
}}
>
<StatusTableItem todoStatus={todo.status} />
</DataTable.Cell>
<DataTable.Cell
style={{
justifyContent: "center",
}}
>
<PriorityIndicator priority={todo.priority} />
</DataTable.Cell>
<DataTable.Cell style={{ justifyContent: "center" }}>
<Text className="px-1 py-1">{`${formatDateMDY(
todo.createdAt
)} ${formatTime(todo.createdAt)}`}</Text>
</DataTable.Cell>
<DataTable.Cell style={{ justifyContent: "center" }}>
<AdminTodoActions
todo={todo}
onView={(t) => openDetails(t)}
onEdit={onEdit}
onDelete={onDelete}
/>
</DataTable.Cell>
</DataTable.Row>
))}
{selectedTodo && (
<TodoDetailsModal
visible={detailsOpen}
todo={selectedTodo}
onClose={closeDetails}
/>
)}
</DataTable>
);
};
@@ -0,0 +1,112 @@
import { View, Text, ScrollView } from "react-native";
import { Badge, IconButton } from "react-native-paper";
import { useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { TodoDetailsModal } from "@/components/features/todos/TodoDetailsModal";
import { Todo } from "@/store/models/Todo.model";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { TodoStatusColors } from "@/constants/todoStatusEnum";
import { formatDateMDY, formatTime } from "@/utils/dateUtils";
interface MobileAdminTodoTableProps {
todos: Todo[];
onEdit: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
}
export const MobileAdminTodosTable = ({
todos,
onEdit,
onDelete,
}: MobileAdminTodoTableProps) => {
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
const openDetails = (todo: Todo) => {
setSelectedTodo(todo);
setDetailsOpen(true);
};
const closeDetails = () => setDetailsOpen(false);
return (
<View className="flex-1">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 16 }}
>
{todos.map((todo) => {
const taskPreview =
todo.task && todo.task.length > 50
? `${todo.task.slice(0, 50)}...`
: todo.task;
return (
<View
key={todo.id}
className="mb-3 p-4 bg-white rounded-md shadow-sm"
>
<View className="flex-row justify-between items-center mb-3">
<View className="mr-2">
<Badge
size={16}
style={{ backgroundColor: TodoStatusColors[todo.status] }}
/>
</View>
<Text className="font-bold flex-1 flex-wrap" numberOfLines={0}>
{todo.title}
</Text>
<View className="ml-2 flex-row items-center">
<PriorityIndicator
priority={todo.priority}
size={SizeEnum.Small}
/>
</View>
</View>
<View className="flex-row items-center mb-2">
<Ionicons name="calendar-outline" size={14} color="#6b7280" />
<Text className="text-xs text-gray-500 ml-1">
{`${formatDateMDY(todo.createdAt)} ${formatTime(
todo.createdAt
)}`}
</Text>
</View>
<Text className="text-sm mb-3 text-gray-600" numberOfLines={0}>
{taskPreview}
</Text>
<View className="flex-row justify-end border-t border-gray-200 pt-3">
<IconButton
icon="eye-outline"
size={20}
onPress={() => openDetails(todo)}
/>
<IconButton
icon="pencil-outline"
size={20}
onPress={() => {
onEdit(todo);
}}
/>
<IconButton
icon="delete-outline"
size={20}
onPress={() => {
onDelete(todo);
}}
/>
</View>
</View>
);
})}
</ScrollView>
{selectedTodo && (
<TodoDetailsModal
visible={detailsOpen}
todo={selectedTodo}
onClose={closeDetails}
/>
)}
</View>
);
};
+440
View File
@@ -0,0 +1,440 @@
import { useMemo, useState, useCallback } from "react";
import { View, ScrollView, TextInput, Pressable, Text } from "react-native";
import { Avatar, Button, Menu } from "react-native-paper";
import { Ionicons } from "@expo/vector-icons";
import { formatDistanceToNow, formatDistanceToNowStrict } from "date-fns";
import {
useGetTodoCommentsQuery,
useAddCommentMutation,
useUpdateCommentMutation,
useDeleteCommentMutation,
} from "@/store/api/commentsApi";
import { useAuthContext } from "@/auth/AuthContext";
import { useGetUsersQuery } from "@/store/api/usersApi";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface TodoCommentsProps {
todoId: string;
}
// Helper: compact relative time like "2h ago"
const timeAgo = (dateInput: string | Date | null | undefined) => {
if (!dateInput) return "";
const date = typeof dateInput === "string" ? new Date(dateInput) : dateInput;
try {
const raw = formatDistanceToNowStrict(date, { addSuffix: false });
// raw examples: "2 hours", "1 minute", "less than a minute"
const m = raw.match(
/(\d+)\s+(second|seconds|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)/
);
if (m) {
const n = m[1];
const unit = m[2];
const abbr = unit.startsWith("second")
? "s"
: unit.startsWith("minute")
? "m"
: unit.startsWith("hour")
? "h"
: unit.startsWith("day")
? "d"
: unit.startsWith("week")
? "w"
: unit.startsWith("month")
? "mo"
: "y";
return `${n}${abbr} ago`;
}
// fallback to full format
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return "";
}
};
const LONG_COMMENT_THRESHOLD = 220;
interface TodoCommentsProps {
todoId: string;
}
export const TodoComments = ({ todoId }: TodoCommentsProps) => {
const { user } = useAuthContext();
const {
data: comments,
isLoading,
isError,
refetch,
} = useGetTodoCommentsQuery(todoId);
const [addComment, { isLoading: isAdding }] = useAddCommentMutation();
const [updateComment, { isLoading: isUpdating }] = useUpdateCommentMutation();
const [deleteComment, { isLoading: isDeleting }] = useDeleteCommentMutation();
const [content, setContent] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [menuId, setMenuId] = useState<string | null>(null);
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
const canPost = useMemo(() => !!user, [user]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed) return;
try {
await addComment({ todoId, content: trimmed }).unwrap();
setContent("");
} catch {
showSnackbar("Error creating comment");
}
};
const startEdit = (id: string, current: string) => {
setEditingId(id);
setEditContent(current);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
if (!editingId || !trimmed) return;
try {
await updateComment({
todoId,
commentId: editingId,
content: trimmed,
}).unwrap();
setEditingId(null);
setEditContent("");
} catch {
showSnackbar("Error updating comment");
}
};
const onDelete = async (id: string) => {
try {
// simple confirm for web; on native it won't show but action still works if called
if (typeof window !== "undefined") {
const ok = window.confirm?.("Delete this comment?") ?? true;
if (!ok) return;
}
await deleteComment({ todoId, commentId: id }).unwrap();
} catch {
showSnackbar("Error deleting comment");
}
};
// Mention support
const extractMentionQuery = useCallback((text: string) => {
// find last segment starting with @ that has no whitespace afterwards yet
const match = text.match(/@([\w.-]{1,30})$/);
return match ? match[1] : "";
}, []);
const mentionQuery = extractMentionQuery(editingId ? editContent : content);
const { data: mentionUsers } = useGetUsersQuery(
mentionQuery
? { search: mentionQuery, page: 1, limit: 5, roleType: "admin" }
: undefined,
{ skip: !mentionQuery }
);
const insertMention = (fullName: string) => {
const setter = editingId ? setEditContent : setContent;
const text = editingId ? editContent : content;
// replace trailing @query with @Full Name and a space
const replaced = text.replace(/@([\w.-]{1,30})$/, `@${fullName} `);
setter(replaced);
};
const renderContentWithMentions = (text: string) => {
const parts = text.split(/(@[\w.-]+)/g);
return parts.map((p, i) =>
p.startsWith("@") ? (
<Text key={i} className="text-primaryPurpleDark font-medium">
{p}
</Text>
) : (
<Text key={i}>{p}</Text>
)
);
};
const getCommentAuthorName = (commentUser: {
id: string;
firstName?: string;
lastName?: string;
}) => {
if (!commentUser) return "Unknown User";
if (commentUser.id === user?.id) return "You";
return `${(commentUser.firstName || "").trim()} ${(
commentUser.lastName || ""
).trim()}`.trim();
};
return (
<>
<View className="flex-1">
<Text className="text-lg mb-2">Comments</Text>
{isLoading ? (
<Text>Loading comments...</Text>
) : isError ? (
<View>
<Text className="text-red-500 mb-2">Failed to load comments</Text>
<Button mode="text" onPress={refetch}>
Retry
</Button>
</View>
) : comments && comments.length > 0 ? (
<ScrollView
className="max-h-96"
contentContainerStyle={{ paddingBottom: 8, paddingTop: 4 }}
showsVerticalScrollIndicator
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
scrollEnabled
persistentScrollbar
style={{ maxHeight: 384, minHeight: 0, flexGrow: 0 }}
>
{comments.map((c, idx) => {
const isExpanded = !!expanded[c.id];
const isLong = (c.content?.length || 0) > LONG_COMMENT_THRESHOLD;
const prevUserId = idx > 0 ? comments[idx - 1]?.user?.id : null;
const isNewUserBlock = idx > 0 && prevUserId !== c.user?.id;
return (
<View key={c.id} className="mb-2">
{isNewUserBlock ? (
<View
style={{
height: 1,
backgroundColor: "#D9D9D9",
marginVertical: 8,
}}
/>
) : null}
<View className="py-1">
<View className="flex-row">
{c.user?.imageUrl ? (
<Avatar.Image
source={{ uri: c.user.imageUrl }}
size={32}
style={{ marginRight: 10, marginTop: 0 }}
/>
) : (
<Avatar.Text
label={
(c.user?.firstName?.[0] || "?") +
(c.user?.lastName?.[0] || "").toUpperCase()
}
size={32}
style={{ marginRight: 10, marginTop: 0 }}
/>
)}
<View className="flex-1">
<View
className="flex-row justify-between items-center"
style={{ minHeight: 18, marginBottom: 6 }}
>
<View className="flex-1 pr-2" style={{ minWidth: 0 }}>
<Text
className="text-[13px] font-medium text-gray-900"
numberOfLines={1}
style={{ lineHeight: 18 }}
>
{getCommentAuthorName(c.user)}
</Text>
</View>
<View className="flex-row items-center">
<Text className="text-[11px] text-gray-500">
{timeAgo(c.createdAt)}
</Text>
<View className="ml-2" style={{ flexShrink: 0 }}>
{user?.id === c.user?.id && editingId !== c.id ? (
<Menu
visible={menuId === c.id}
onDismiss={() => setMenuId(null)}
anchor={
<Pressable
onPress={() => setMenuId(c.id)}
style={{
height: 18,
width: 18,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons
name="ellipsis-vertical"
size={16}
color="#555"
/>
</Pressable>
}
>
<Menu.Item
leadingIcon="pencil-outline"
onPress={() => {
setMenuId(null);
startEdit(c.id, c.content);
}}
title="Edit"
/>
<Menu.Item
leadingIcon="delete-outline"
onPress={() => {
setMenuId(null);
onDelete(c.id);
}}
disabled={isDeleting}
title="Delete"
/>
</Menu>
) : null}
</View>
</View>
</View>
{editingId === c.id ? (
<View style={{ marginTop: 0 }}>
<TextInput
className="border border-gray-300 rounded-md p-2 bg-white"
placeholder="Edit your comment..."
placeholderTextColor="#d9d9d9"
value={editContent}
onChangeText={setEditContent}
multiline
/>
{mentionQuery && mentionUsers?.items?.length ? (
<View className="bg-white border border-gray-200 rounded-md mt-1 shadow-sm">
{mentionUsers.items.map((u) => (
<Pressable
key={u.id}
onPress={() =>
insertMention(
`${u.firstName} ${u.lastName}`
)
}
>
<Text className="p-2">
@{u.firstName} {u.lastName}
</Text>
</Pressable>
))}
</View>
) : null}
<View className="flex-row justify-end mt-2">
<Button
compact
mode="contained"
onPress={saveEdit}
disabled={!editContent.trim() || isUpdating}
>
Save
</Button>
<Button
compact
mode="text"
onPress={cancelEdit}
disabled={isUpdating}
>
Cancel
</Button>
</View>
</View>
) : (
<View style={{ marginTop: 0 }}>
<Text
className="text-[13px] text-gray-800"
numberOfLines={isExpanded ? undefined : 4}
>
{renderContentWithMentions(c.content)}
</Text>
{isLong ? (
<Pressable
onPress={() =>
setExpanded((prev) => ({
...prev,
[c.id]: !isExpanded,
}))
}
>
<Text className="mt-1 text-[12px] font-medium text-primaryPurpleDark">
{isExpanded ? "Show less" : "Show more"}
</Text>
</Pressable>
) : null}
</View>
)}
</View>
</View>
</View>
</View>
);
})}
</ScrollView>
) : (
<Text className="text-gray-500 mb-2">No comments yet</Text>
)}
{canPost ? (
<View className="mt-3">
<TextInput
className="border border-gray-300 rounded-md p-2 bg-white"
placeholder="Write a comment..."
placeholderTextColor="#d9d9d9"
value={content}
onChangeText={setContent}
multiline
/>
{mentionQuery && mentionUsers?.items?.length ? (
<View className="bg-white border border-gray-200 rounded-md mt-1 shadow-sm">
{mentionUsers.items.map((u) => (
<Pressable
key={u.id}
onPress={() =>
insertMention(`${u.firstName} ${u.lastName}`)
}
>
<Text className="p-2">
@{u.firstName} {u.lastName}
</Text>
</Pressable>
))}
</View>
) : null}
<View className="flex-row justify-end mt-2">
<Button
mode="contained"
onPress={handleSubmit}
disabled={!content.trim() || isAdding}
>
Post
</Button>
</View>
</View>
) : null}
</View>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};
@@ -0,0 +1,97 @@
import { View, Text } from "react-native";
import { Dialog, IconButton } from "react-native-paper";
import { DialogContainer } from "@/components/ui/DialogContainer";
import { Todo } from "@/store/models/Todo.model";
import { StatusTableItem } from "@/components/ui/StatusTableItem";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { formatDate } from "@/utils/dateUtils";
import { TodoComments } from "@/components/features/todos/TodoComments";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface TodoDetailsModalProps {
visible: boolean;
todo: Todo;
onClose: () => void;
}
export const TodoDetailsModal = ({
visible,
todo,
onClose,
}: TodoDetailsModalProps) => {
const isMobile = useAppSelector(selectIsMobile);
return (
<DialogContainer visible={visible} onDismiss={onClose}>
<Dialog.Title>
<View className="w-full flex-row items-center justify-between">
<View className="flex-1">
<Text className="pr-3">{todo.title}</Text>
</View>
<View
style={{
alignSelf: "flex-start",
alignItems: "center",
flexDirection: "row",
}}
>
<View className="flex-row items-center gap-2">
<StatusTableItem todoStatus={todo.status} />
<PriorityIndicator
priority={todo.priority}
size={SizeEnum.Small}
/>
</View>
<IconButton
icon="close"
size={20}
onPress={onClose}
accessibilityLabel="Close dialog"
style={{ margin: 0 }}
/>
</View>
</View>
</Dialog.Title>
<Dialog.Content>
<View className={isMobile ? "flex-col" : "flex-row"}>
<View className={isMobile ? "w-full" : "w-1/2 pr-2"}>
<View className="mb-2">
<Text className="text-lg">Due: {formatDate(todo.dueDate)}</Text>
</View>
<Text className="text-base">{todo.task}</Text>
</View>
{isMobile ? (
<View
style={{
height: 2,
backgroundColor: "#000000",
marginVertical: 12,
borderRadius: 1,
}}
/>
) : (
<View
style={{
width: 2,
backgroundColor: "#000000",
marginHorizontal: 12,
alignSelf: "stretch",
borderRadius: 1,
}}
/>
)}
<View className={isMobile ? "w-full mt-4" : "w-1/2 pl-4 pr-6"}>
<TodoComments todoId={todo.id} />
</View>
</View>
</Dialog.Content>
</DialogContainer>
);
};
+462
View File
@@ -0,0 +1,462 @@
import { useState, useEffect, useRef } from "react";
import { View, Text, ScrollView, Dimensions } from "react-native";
import { Dialog, TextInput, IconButton } from "react-native-paper";
import { DatePickerInput, TimePickerModal } from "react-native-paper-dates";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Todo } from "@/store/models/Todo.model";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { TodoStatus } from "@/constants/todoStatusEnum";
import { SizeEnum } from "@/constants/sizeEnum";
import { OperationMode } from "@/constants/operationModeEnum";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { DialogContainer } from "@/components/ui/DialogContainer";
import { CustomPicker, PickerItem } from "@/components/ui/CustomPicker";
import {
SearchableDropdown,
DropdownItem,
} from "@/components/ui/SearchableDropdown";
import { useGetUsersQuery } from "@/store/api/usersApi";
import { User } from "@/store/models/User.model";
import { RoleEnum } from "@/constants/roleEnum";
import {
todoSchema,
TodoFormValues,
} from "@/components/features/todos/schemas/todo.schema";
interface TodoModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (
todo: Partial<Todo> & { assigneeIds?: string[]; userId?: string }
) => void;
isLoading: boolean;
todo?: Todo | null;
mode: OperationMode;
}
export const TodoModal = ({
visible,
onClose,
onSubmit,
isLoading,
todo = null,
mode = OperationMode.ADD,
}: TodoModalProps) => {
const todoRef = useRef<Todo | null>(todo);
todoRef.current = todo;
// Helpers to extract existing selections from the todo
const existingSupervisors = (todo?.assignees ?? []).filter(
(u) => u.role === RoleEnum.ADMIN || u.role === RoleEnum.MANAGER
);
const existingUser = (todo?.assignees ?? []).find((u) => u.role === RoleEnum.USER);
const defaultValues: TodoFormValues = {
id: todo?.id,
title: todo?.title ?? "",
task: todo?.task ?? "",
priority: (todo?.priority ?? "LOW_PRIORITY") as PriorityStatus,
status: (todo?.status ?? "NEW") as TodoStatus,
dueDate: todo?.dueDate ? new Date(todo.dueDate) : new Date(),
// Only supervisors (Admin/Manager) go into assigneeIds; regular user goes into userId
assigneeIds: existingSupervisors.map((u) => u.id),
userId: existingUser?.id,
};
const {
control,
handleSubmit,
reset,
watch,
setValue,
formState: { errors, isDirty, isValid },
} = useForm<TodoFormValues>({
resolver: zodResolver(todoSchema),
defaultValues,
mode: "onChange",
shouldUnregister: false,
});
const [showTimePicker, setShowTimePicker] = useState(false);
const windowHeight = Dimensions.get("window").height;
const dialogVerticalMargin = 24;
const dialogMaxHeight = windowHeight - dialogVerticalMargin - 60;
const approxChrome = 140;
const maxDialogContentHeight = Math.max(200, dialogMaxHeight - approxChrome);
const isMobile = useAppSelector(selectIsMobile);
const [adminSearchQuery, setAdminSearchQuery] = useState("");
const [userSearchQuery, setUserSearchQuery] = useState("");
const dueDate = (watch("dueDate") as Date) ?? new Date();
// Helper function to map User to DropdownItem
const mapUserToDropdownItem = (user: User): DropdownItem => ({
id: user.id,
label: `${user.firstName} ${user.lastName}`,
subtitle: user.title,
description: user.email,
imageUrl: user.imageUrl,
initials: `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`,
});
const uniqueById = (items: DropdownItem[]) => {
const seen = new Set<string>();
const out: DropdownItem[] = [];
for (const it of items) {
if (!seen.has(it.id)) {
seen.add(it.id);
out.push(it);
}
}
return out;
};
// Fetch admin users for assignee dropdown
// Only fetch when search query has 2+ characters for better performance
const { data: adminUsersPage } = useGetUsersQuery(
{
roleType: "admin",
page: 1,
limit: 20,
sortBy: "name",
sortDir: "asc" as any,
search: adminSearchQuery.trim(),
},
{ skip: !visible || adminSearchQuery.trim().length < 2 }
);
const adminUsers = adminUsersPage?.items ?? [];
const preselectedSupervisorItems = existingSupervisors.map(mapUserToDropdownItem);
const adminDropdownItems = uniqueById([
...preselectedSupervisorItems,
...adminUsers.map(mapUserToDropdownItem),
]);
// Fetch regular users (non-admin) for user assignment dropdown
// Only fetch when search query has 2+ characters for better performance
const { data: regularUsersPage } = useGetUsersQuery(
{
roleType: "user",
page: 1,
limit: 20,
sortBy: "name",
sortDir: "asc" as any,
search: userSearchQuery.trim(),
},
{ skip: !visible || userSearchQuery.trim().length < 2 }
);
const regularUsers = regularUsersPage?.items ?? [];
const preselectedUserItems = existingUser ? [mapUserToDropdownItem(existingUser)] : [];
const regularUserDropdownItems = uniqueById([
...preselectedUserItems,
...regularUsers.map(mapUserToDropdownItem),
]);
const onTimeConfirm = ({
hours,
minutes,
}: {
hours: number;
minutes: number;
}) => {
setShowTimePicker(false);
const newDate = new Date(dueDate as Date);
newDate.setHours(hours);
newDate.setMinutes(minutes);
setValue("dueDate", newDate, { shouldDirty: true, shouldValidate: true });
};
useEffect(() => {
if (!visible) return;
const t = todoRef.current;
const admins = (t?.assignees ?? []).filter(
(u) => u.role === RoleEnum.ADMIN || u.role === RoleEnum.MANAGER
);
const user = (t?.assignees ?? []).find((u) => u.role === RoleEnum.USER);
const nextDefaults: TodoFormValues = {
id: t?.id,
title: t?.title ?? "",
task: t?.task ?? "",
priority: (t?.priority ?? "LOW_PRIORITY") as PriorityStatus,
status: (t?.status ?? "NEW") as TodoStatus,
dueDate: t?.dueDate ? new Date(t.dueDate) : new Date(),
assigneeIds: admins.map((u) => u.id),
userId: user?.id,
};
reset(nextDefaults, { keepDefaultValues: false });
}, [visible, reset, todo?.id, mode]);
const submit = (vals: TodoFormValues) => {
onSubmit({
...(mode === OperationMode.EDIT && todo ? { id: todo.id } : {}),
title: vals.title.trim(),
task: vals.task.trim(),
priority: vals.priority as PriorityStatus,
status: vals.status as TodoStatus,
dueDate: (vals.dueDate as unknown as Date) ?? new Date(),
assigneeIds: (vals.assigneeIds as string[] | undefined) ?? [],
userId: vals.userId,
});
};
const handleClose = () => {
onClose();
};
return (
<DialogContainer
visible={visible}
onDismiss={handleClose}
dialogStyle={{
alignSelf: "center",
marginVertical: dialogVerticalMargin,
maxHeight: dialogMaxHeight,
}}
>
<Dialog.Title>
<View className="w-full flex-row items-center justify-between">
<View className="flex-1">
<Text className="pr-3">
{mode === OperationMode.ADD ? "Add Todo" : "Edit Todo"}
</Text>
</View>
<View style={{ alignSelf: "flex-start" }}>
<IconButton
icon="close"
size={20}
onPress={handleClose}
disabled={isLoading}
accessibilityLabel="Close dialog"
style={{ margin: 0 }}
/>
</View>
</View>
</Dialog.Title>
<Dialog.Content
style={{ maxHeight: maxDialogContentHeight, overflow: "hidden" }}
>
<ScrollView nestedScrollEnabled>
<View>
<View
className={isMobile ? "flex-col" : "flex-row items-stretch"}
style={{ flex: isMobile ? undefined : 1, minHeight: 0 }}
>
{/* Left column */}
<View className={isMobile ? "w-full" : "w-1/2 pr-3"}>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Title</Text>
<Controller
control={control}
name="title"
render={({ field: { onChange, value } }) => (
<>
<TextInput
className={`border ${
errors.title ? "border-highRisk" : "border-gray-300"
} rounded p-2 text-base bg-white`}
value={value}
onChangeText={onChange}
placeholder="Enter title"
autoCapitalize="none"
placeholderTextColor="#d9d9d9"
/>
{errors.title ? (
<Text className="text-highRisk text-xs mt-1">
{errors.title.message as string}
</Text>
) : null}
</>
)}
/>
</View>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Task</Text>
<Controller
control={control}
name="task"
render={({ field: { onChange, value } }) => (
<>
<TextInput
className={`border ${
errors.task ? "border-highRisk" : "border-gray-300"
} rounded p-2 text-base bg-white`}
value={value}
onChangeText={onChange}
placeholder="Enter task description"
autoCapitalize="sentences"
placeholderTextColor="#d9d9d9"
multiline
numberOfLines={6}
textAlignVertical="top"
style={{ minHeight: 120 }}
/>
{errors.task ? (
<Text className="text-highRisk text-xs mt-1">
{errors.task.message as string}
</Text>
) : null}
</>
)}
/>
</View>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Due date</Text>
<Controller
control={control}
name="dueDate"
render={({ field: { onChange, value } }) => (
<DatePickerInput
locale="en"
label="Select date"
value={value as Date}
onChange={(date) =>
onChange((date as Date) || new Date())
}
inputMode="start"
mode="outlined"
/>
)}
/>
<Button
onPress={() => setShowTimePicker(true)}
buttonColor="white"
buttonTextColor="black"
className="mt-2"
disabled={false}
>
{dueDate?.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Button>
<TimePickerModal
visible={showTimePicker}
onDismiss={() => setShowTimePicker(false)}
onConfirm={onTimeConfirm}
hours={dueDate ? dueDate.getHours() : 0}
minutes={dueDate ? dueDate.getMinutes() : 0}
/>
{errors.dueDate ? (
<Text className="text-highRisk text-xs mt-1">
{errors.dueDate.message as string}
</Text>
) : null}
</View>
</View>
<View
className={isMobile ? "w-full" : "w-1/2 pl-3"}
style={{ flex: isMobile ? undefined : 1, minHeight: 0 }}
>
{/* Admin Assignees Dropdown */}
<Controller
control={control}
name="assigneeIds"
render={({ field: { onChange, value } }) => (
<SearchableDropdown
items={adminDropdownItems}
selectedIds={value || []}
onSelectionChange={onChange}
label="Assign to admins or managers"
placeholder="Start typing to search"
onSearchChange={setAdminSearchQuery}
error={errors.assigneeIds?.message as string}
mode="multi"
/>
)}
/>
{/* User Assignment Dropdown */}
<Controller
control={control}
name="userId"
render={({ field: { onChange, value } }) => (
<SearchableDropdown
items={regularUserDropdownItems}
selectedIds={value}
onSelectionChange={onChange}
label="Connect to user"
placeholder="Start typing to search"
onSearchChange={setUserSearchQuery}
mode="single"
/>
)}
/>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Priority</Text>
<View className="border border-gray-300 rounded overflow-hidden">
<Controller
control={control}
name="priority"
render={({ field: { onChange, value } }) => (
<CustomPicker
selectedValue={value}
onValueChange={(v) => onChange(v)}
className="h-10"
>
<PickerItem label="Low" value="LOW_PRIORITY" />
<PickerItem
label="Moderate"
value="MODERATE_PRIORITY"
/>
<PickerItem label="High" value="HIGH_PRIORITY" />
</CustomPicker>
)}
/>
</View>
</View>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Status</Text>
<View className="border border-gray-300 rounded overflow-hidden">
<Controller
control={control}
name="status"
render={({ field: { onChange, value } }) => (
<CustomPicker
selectedValue={value}
onValueChange={(v) => onChange(v)}
className="h-10"
>
<PickerItem label="New" value="NEW" />
<PickerItem label="In Progress" value="IN_PROGRESS" />
<PickerItem label="Completed" value="COMPLETED" />
<PickerItem label="Archived" value="ARCHIVED" />
</CustomPicker>
)}
/>
</View>
</View>
</View>
</View>
</View>
</ScrollView>
</Dialog.Content>
{isLoading && (
<View
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
pointerEvents="auto"
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
>
<LoadingSpinner size={SizeEnum.Small} />
</View>
)}
<Dialog.Actions>
<Button
onPress={handleSubmit(submit)}
disabled={isLoading || !isValid || !isDirty}
className="!ml-2"
>
{mode === OperationMode.ADD ? "Add" : "Edit"}
</Button>
</Dialog.Actions>
</DialogContainer>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { AdminTodosTable } from "@/components/features/todos/AdminTodosTable";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
export const TodosPage = () => {
const isMobile = useAppSelector(selectIsMobile);
const params = useLocalSearchParams<{ q?: string }>();
const initialSearchQuery = params.q ?? "";
return (
<View className="flex-1 bg-primaryPurpleLight">
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
<AdminTodosTable initialSearchQuery={initialSearchQuery} />
</View>
</View>
);
};
@@ -0,0 +1,19 @@
import { z } from "zod";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { TodoStatus } from "@/constants/todoStatusEnum";
export const todoSchema = z.object({
id: z.string().optional(),
title: z.string().trim().min(1, "Title is required"),
task: z.string().trim().min(1, "Task is required"),
priority: z.enum(PriorityStatus),
status: z.enum(TodoStatus),
dueDate: z.coerce.date(),
assigneeIds: z
.array(z.string())
.min(1, "Assign at least one admin or manager"),
userId: z.string().optional(), // Optional: specific user this todo is assigned to
});
export type TodoFormValues = z.input<typeof todoSchema>;