This commit is contained in:
2026-01-23 20:35:40 +03:00
parent cf3b0e48ec
commit 66c151653e
137 changed files with 41495 additions and 0 deletions

498
app/routes/expenses.tsx Normal file
View 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>
);
}