376 lines
21 KiB
TypeScript
376 lines
21 KiB
TypeScript
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||
import { json } from "@remix-run/node";
|
||
import { useLoaderData, useSearchParams } from "@remix-run/react";
|
||
import { requireAuth } from "~/lib/auth-middleware.server";
|
||
import {
|
||
getFinancialSummary,
|
||
getMonthlyFinancialData,
|
||
getIncomeByMaintenanceType,
|
||
getExpenseBreakdown,
|
||
getTopCustomersByRevenue,
|
||
getFinancialTrends
|
||
} from "~/lib/financial-reporting.server";
|
||
import { DashboardLayout } from "~/components/layout/DashboardLayout";
|
||
import { Button } from "~/components/ui/Button";
|
||
import { Input } from "~/components/ui/Input";
|
||
import { useSettings } from "~/contexts/SettingsContext";
|
||
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
const user = await requireAuth(request, 2); // Admin level required
|
||
|
||
const url = new URL(request.url);
|
||
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;
|
||
|
||
// Get all financial data
|
||
const [
|
||
financialSummary,
|
||
monthlyData,
|
||
incomeByType,
|
||
expenseBreakdown,
|
||
topCustomers,
|
||
trends
|
||
] = await Promise.all([
|
||
getFinancialSummary(dateFrom, dateTo),
|
||
getMonthlyFinancialData(),
|
||
getIncomeByMaintenanceType(dateFrom, dateTo),
|
||
getExpenseBreakdown(dateFrom, dateTo),
|
||
getTopCustomersByRevenue(10, dateFrom, dateTo),
|
||
dateFrom && dateTo ? getFinancialTrends(dateFrom, dateTo) : null,
|
||
]);
|
||
|
||
return json({
|
||
user,
|
||
financialSummary,
|
||
monthlyData,
|
||
incomeByType,
|
||
expenseBreakdown,
|
||
topCustomers,
|
||
trends,
|
||
dateFrom: dateFrom?.toISOString().split('T')[0] || "",
|
||
dateTo: dateTo?.toISOString().split('T')[0] || "",
|
||
});
|
||
}
|
||
|
||
export default function FinancialReportsPage() {
|
||
const { formatCurrency } = useSettings();
|
||
const {
|
||
user,
|
||
financialSummary,
|
||
monthlyData,
|
||
incomeByType,
|
||
expenseBreakdown,
|
||
topCustomers,
|
||
trends,
|
||
dateFrom,
|
||
dateTo
|
||
} = useLoaderData<typeof loader>();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
const handleDateFilter = (type: string, value: string) => {
|
||
const newParams = new URLSearchParams(searchParams);
|
||
if (value) {
|
||
newParams.set(type, value);
|
||
} else {
|
||
newParams.delete(type);
|
||
}
|
||
setSearchParams(newParams);
|
||
};
|
||
|
||
const clearFilters = () => {
|
||
setSearchParams({});
|
||
};
|
||
|
||
return (
|
||
<DashboardLayout user={user}>
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-center">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">التقارير المالية</h1>
|
||
<p className="text-gray-600">تحليل شامل للوضع المالي للمؤسسة</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date Filters */}
|
||
<div className="bg-white p-4 rounded-lg shadow">
|
||
<div className="flex flex-wrap gap-4 items-end">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
من تاريخ
|
||
</label>
|
||
<Input
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={(e) => handleDateFilter("dateFrom", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
إلى تاريخ
|
||
</label>
|
||
<Input
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={(e) => handleDateFilter("dateTo", e.target.value)}
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
onClick={clearFilters}
|
||
>
|
||
مسح الفلاتر
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Financial Summary Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<div className="flex items-center">
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium text-gray-600">إجمالي الإيرادات</p>
|
||
<p className="text-2xl font-bold text-green-600">
|
||
{formatCurrency(financialSummary.totalIncome)}
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
{financialSummary.incomeCount} عملية
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<div className="flex items-center">
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium text-gray-600">إجمالي المصروفات</p>
|
||
<p className="text-2xl font-bold text-red-600">
|
||
{formatCurrency(financialSummary.totalExpenses)}
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
{financialSummary.expenseCount} مصروف
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<div className="flex items-center">
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium text-gray-600">صافي الربح</p>
|
||
<p className={`text-2xl font-bold ${financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||
{formatCurrency(financialSummary.netProfit)}
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
هامش الربح: {financialSummary.profitMargin.toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||
financialSummary.netProfit >= 0 ? 'bg-green-100' : 'bg-red-100'
|
||
}`}>
|
||
<svg className={`w-6 h-6 ${financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<div className="flex items-center">
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium text-gray-600">متوسط الإيراد الشهري</p>
|
||
<p className="text-2xl font-bold text-blue-600">
|
||
{formatCurrency(monthlyData.reduce((sum, month) => sum + month.income, 0) / Math.max(monthlyData.length, 1))}
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
آخر 12 شهر
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Trends (if date range is selected) */}
|
||
{trends && (
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4">مقارنة الفترات</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<div className="text-center">
|
||
<p className="text-sm text-gray-600">نمو الإيرادات</p>
|
||
<p className={`text-2xl font-bold ${trends.trends.incomeGrowth >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||
{trends.trends.incomeGrowth >= 0 ? '+' : ''}{trends.trends.incomeGrowth.toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-sm text-gray-600">نمو المصروفات</p>
|
||
<p className={`text-2xl font-bold ${trends.trends.expenseGrowth <= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||
{trends.trends.expenseGrowth >= 0 ? '+' : ''}{trends.trends.expenseGrowth.toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-sm text-gray-600">نمو الأرباح</p>
|
||
<p className={`text-2xl font-bold ${trends.trends.profitGrowth >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||
{trends.trends.profitGrowth >= 0 ? '+' : ''}{trends.trends.profitGrowth.toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Charts and Breakdowns */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* Income by Maintenance Type */}
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4">الإيرادات حسب نوع الصيانة</h2>
|
||
<div className="space-y-3">
|
||
{incomeByType.map((item, index) => (
|
||
<div key={index} className="flex items-center justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
{item.category}
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
{item.percentage.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||
<div
|
||
className="bg-blue-600 h-2 rounded-full"
|
||
style={{ width: `${item.percentage}%` }}
|
||
></div>
|
||
</div>
|
||
<div className="flex justify-between items-center mt-1">
|
||
<span className="text-sm font-semibold text-gray-900">
|
||
{formatCurrency(item.amount)}
|
||
</span>
|
||
<span className="text-xs text-gray-500">
|
||
{item.count} عملية
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expense Breakdown */}
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4">تفصيل المصروفات</h2>
|
||
<div className="space-y-3">
|
||
{expenseBreakdown.map((item, index) => (
|
||
<div key={index} className="flex items-center justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
{item.category}
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
{item.percentage.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||
<div
|
||
className="bg-red-600 h-2 rounded-full"
|
||
style={{ width: `${item.percentage}%` }}
|
||
></div>
|
||
</div>
|
||
<div className="flex justify-between items-center mt-1">
|
||
<span className="text-sm font-semibold text-gray-900">
|
||
{formatCurrency(item.amount)}
|
||
</span>
|
||
<span className="text-xs text-gray-500">
|
||
{item.count} مصروف
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Top Customers and Monthly Data */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
{/* Top Customers */}
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4">أفضل العملاء</h2>
|
||
<div className="space-y-3">
|
||
{topCustomers.map((customer, index) => (
|
||
<div key={customer.customerId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||
<div className="flex items-center space-x-3 space-x-reverse">
|
||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||
<span className="text-sm font-semibold text-blue-600">
|
||
{index + 1}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-gray-900">{customer.customerName}</p>
|
||
<p className="text-sm text-gray-500">{customer.visitCount} زيارة</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-left">
|
||
<p className="font-semibold text-gray-900">
|
||
{formatCurrency(customer.totalRevenue)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Monthly Performance */}
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4">الأداء الشهري</h2>
|
||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||
{monthlyData.slice(-6).reverse().map((month, index) => (
|
||
<div key={`${month.year}-${month.month}`} className="p-3 bg-gray-50 rounded-lg">
|
||
<div className="flex justify-between items-center mb-2">
|
||
<span className="font-medium text-gray-900">
|
||
{month.month} {month.year}
|
||
</span>
|
||
<span className={`font-semibold ${month.profit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||
{formatCurrency(month.profit)}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-600">الإيرادات: </span>
|
||
<span className="font-medium text-green-600">
|
||
{formatCurrency(month.income)}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-600">المصروفات: </span>
|
||
<span className="font-medium text-red-600">
|
||
{formatCurrency(month.expenses)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DashboardLayout>
|
||
);
|
||
} |