project setup
This commit is contained in:
@@ -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 "{searchQuery}"
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user