uup
This commit is contained in:
498
app/routes/expenses.tsx
Normal file
498
app/routes/expenses.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { useLoaderData, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { requireAuth } from "~/lib/auth-middleware.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import {
|
||||
getExpenses,
|
||||
createExpense,
|
||||
updateExpense,
|
||||
deleteExpense,
|
||||
getExpenseById,
|
||||
getExpenseCategories
|
||||
} from "~/lib/expense-management.server";
|
||||
import { validateExpense } from "~/lib/validation";
|
||||
import { DashboardLayout } from "~/components/layout/DashboardLayout";
|
||||
import { ExpenseForm } from "~/components/expenses/ExpenseForm";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { Modal } from "~/components/ui/Modal";
|
||||
import { DataTable } from "~/components/ui/DataTable";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import { EXPENSE_CATEGORIES, PAGINATION } from "~/lib/constants";
|
||||
import type { Expense } from "@prisma/client";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const user = await requireAuth(request, 2); // Admin level required
|
||||
|
||||
const url = new URL(request.url);
|
||||
const searchQuery = url.searchParams.get("search") || "";
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const category = url.searchParams.get("category") || "";
|
||||
const dateFrom = url.searchParams.get("dateFrom")
|
||||
? new Date(url.searchParams.get("dateFrom")!)
|
||||
: undefined;
|
||||
const dateTo = url.searchParams.get("dateTo")
|
||||
? new Date(url.searchParams.get("dateTo")!)
|
||||
: undefined;
|
||||
|
||||
const { expenses, total, totalPages } = await getExpenses(
|
||||
searchQuery,
|
||||
page,
|
||||
PAGINATION.DEFAULT_PAGE_SIZE,
|
||||
category,
|
||||
dateFrom,
|
||||
dateTo
|
||||
);
|
||||
|
||||
const categories = await getExpenseCategories();
|
||||
|
||||
return json({
|
||||
user,
|
||||
expenses,
|
||||
total,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
searchQuery,
|
||||
category,
|
||||
dateFrom: dateFrom?.toISOString().split('T')[0] || "",
|
||||
dateTo: dateTo?.toISOString().split('T')[0] || "",
|
||||
categories,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
await requireAuth(request, 2); // Admin level required
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("_action") as string;
|
||||
|
||||
switch (action) {
|
||||
case "create": {
|
||||
const expenseData = {
|
||||
description: formData.get("description") as string,
|
||||
category: formData.get("category") as string,
|
||||
amount: parseFloat(formData.get("amount") as string),
|
||||
expenseDate: formData.get("expenseDate") as string,
|
||||
};
|
||||
|
||||
const validation = validateExpense({
|
||||
description: expenseData.description,
|
||||
category: expenseData.category,
|
||||
amount: expenseData.amount,
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
return json({
|
||||
success: false,
|
||||
errors: validation.errors,
|
||||
action: "create"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const expense = await createExpense({
|
||||
description: expenseData.description,
|
||||
category: expenseData.category,
|
||||
amount: expenseData.amount,
|
||||
expenseDate: expenseData.expenseDate ? new Date(expenseData.expenseDate) : undefined,
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
expense,
|
||||
action: "create",
|
||||
message: "تم إنشاء المصروف بنجاح"
|
||||
});
|
||||
} catch (error) {
|
||||
return json({
|
||||
success: false,
|
||||
error: "حدث خطأ أثناء إضافة المصروف",
|
||||
action: "create"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const id = parseInt(formData.get("id") as string);
|
||||
const expenseData = {
|
||||
description: formData.get("description") as string,
|
||||
category: formData.get("category") as string,
|
||||
amount: parseFloat(formData.get("amount") as string),
|
||||
expenseDate: formData.get("expenseDate") as string,
|
||||
};
|
||||
|
||||
const validation = validateExpense({
|
||||
description: expenseData.description,
|
||||
category: expenseData.category,
|
||||
amount: expenseData.amount,
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
return json({
|
||||
success: false,
|
||||
errors: validation.errors,
|
||||
action: "update"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const expense = await updateExpense(id, {
|
||||
description: expenseData.description,
|
||||
category: expenseData.category,
|
||||
amount: expenseData.amount,
|
||||
expenseDate: expenseData.expenseDate ? new Date(expenseData.expenseDate) : undefined,
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
expense,
|
||||
action: "update",
|
||||
message: "تم تحديث المصروف بنجاح"
|
||||
});
|
||||
} catch (error) {
|
||||
return json({
|
||||
success: false,
|
||||
error: "حدث خطأ أثناء تحديث المصروف",
|
||||
action: "update"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const id = parseInt(formData.get("id") as string);
|
||||
|
||||
try {
|
||||
await deleteExpense(id);
|
||||
return json({
|
||||
success: true,
|
||||
action: "delete",
|
||||
message: "تم حذف المصروف بنجاح"
|
||||
});
|
||||
} catch (error) {
|
||||
return json({
|
||||
success: false,
|
||||
error: "حدث خطأ أثناء حذف المصروف",
|
||||
action: "delete"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const id = parseInt(formData.get("id") as string);
|
||||
const expense = await getExpenseById(id);
|
||||
|
||||
if (expense) {
|
||||
return json({
|
||||
success: true,
|
||||
expense,
|
||||
action: "get"
|
||||
});
|
||||
} else {
|
||||
return json({
|
||||
success: false,
|
||||
error: "المصروف غير موجود",
|
||||
action: "get"
|
||||
}, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return json({
|
||||
success: false,
|
||||
error: "إجراء غير صحيح",
|
||||
action: "unknown"
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const { formatCurrency, formatDate } = useSettings();
|
||||
const {
|
||||
user,
|
||||
expenses,
|
||||
total,
|
||||
totalPages,
|
||||
currentPage,
|
||||
searchQuery,
|
||||
category,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
categories
|
||||
} = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedExpense, setSelectedExpense] = useState<Expense | null>(null);
|
||||
const [searchValue, setSearchValue] = useState(searchQuery);
|
||||
|
||||
const isLoading = navigation.state !== "idle";
|
||||
|
||||
// Debounce search value to avoid too many requests
|
||||
const debouncedSearchValue = useDebounce(searchValue, 300);
|
||||
|
||||
// Handle search automatically when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue !== searchQuery) {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (debouncedSearchValue) {
|
||||
newSearchParams.set("search", debouncedSearchValue);
|
||||
} else {
|
||||
newSearchParams.delete("search");
|
||||
}
|
||||
newSearchParams.set("page", "1"); // Reset to first page
|
||||
setSearchParams(newSearchParams);
|
||||
}
|
||||
}, [debouncedSearchValue, searchQuery, searchParams, setSearchParams]);
|
||||
|
||||
// Clear search function
|
||||
const clearSearch = () => {
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
const handleFilter = (filterType: string, value: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set(filterType, value);
|
||||
} else {
|
||||
newParams.delete(filterType);
|
||||
}
|
||||
newParams.set("page", "1");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("page", page.toString());
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// Handle create expense
|
||||
const handleCreateExpense = () => {
|
||||
setSelectedExpense(null);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// Handle edit expense
|
||||
const handleEditExpense = (expense: Expense) => {
|
||||
setSelectedExpense(expense);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Close modals on successful action
|
||||
useEffect(() => {
|
||||
if (actionData?.success && actionData.action === "create") {
|
||||
setShowCreateModal(false);
|
||||
}
|
||||
if (actionData?.success && actionData.action === "update") {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "description",
|
||||
header: "الوصف",
|
||||
render: (expense: Expense) => expense.description,
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
header: "الفئة",
|
||||
render: (expense: Expense) => {
|
||||
const categoryLabel = EXPENSE_CATEGORIES.find(c => c.value === expense.category)?.label;
|
||||
return categoryLabel || expense.category;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: "المبلغ",
|
||||
render: (expense: Expense) => formatCurrency(expense.amount),
|
||||
},
|
||||
{
|
||||
key: "expenseDate",
|
||||
header: "تاريخ المصروف",
|
||||
render: (expense: Expense) => formatDate(expense.expenseDate),
|
||||
},
|
||||
{
|
||||
key: "createdDate",
|
||||
header: "تاريخ الإضافة",
|
||||
render: (expense: Expense) => formatDate(expense.createdDate),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "الإجراءات",
|
||||
render: (expense: Expense) => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditExpense(expense)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Flex justify="between" align="center" className="flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">إدارة المصروفات</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
إجمالي المصروفات: {total}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateExpense}
|
||||
disabled={isLoading}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
إضافة مصروف جديد
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<Flex gap="md" align="center" className="flex-wrap">
|
||||
<div className="flex-1 min-w-64">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="البحث في المصروفات... (البحث تلقائي)"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
startIcon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
endIcon={
|
||||
searchValue && (
|
||||
<div className="pointer-events-auto">
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{(searchQuery || debouncedSearchValue !== searchQuery) && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
{debouncedSearchValue !== searchQuery && (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
جاري البحث...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<select
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={category}
|
||||
onChange={(e) => handleFilter("category", e.target.value)}
|
||||
>
|
||||
<option value="">جميع الفئات</option>
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
placeholder="من تاريخ"
|
||||
value={dateFrom}
|
||||
onChange={(e) => handleFilter("dateFrom", e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
placeholder="إلى تاريخ"
|
||||
value={dateTo}
|
||||
onChange={(e) => handleFilter("dateTo", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Messages */}
|
||||
{actionData?.success && actionData.message && (
|
||||
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
|
||||
{actionData.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
|
||||
{actionData.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expenses Table */}
|
||||
<DataTable
|
||||
data={expenses}
|
||||
columns={columns}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
|
||||
{/* Create Expense Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="إضافة مصروف جديد"
|
||||
>
|
||||
<ExpenseForm
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
errors={actionData?.action === "create" ? actionData.errors : undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Expense Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title="تعديل المصروف"
|
||||
>
|
||||
{selectedExpense && (
|
||||
<ExpenseForm
|
||||
expense={selectedExpense}
|
||||
onCancel={() => setShowEditModal(false)}
|
||||
errors={actionData?.action === "update" ? actionData.errors : undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user