car_mms/app/routes/expenses.tsx
2025-09-11 14:22:27 +03:00

498 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}