Feat: Dashboard accuracy, Staff & Settings modules, and File Auth fix

This commit is contained in:
Hamza-Ayed
2026-04-19 15:25:43 +03:00
parent 3acd9f261b
commit ef9baf33f7
7 changed files with 415 additions and 12 deletions

View File

@@ -18,7 +18,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private dataSource: DataSource, private dataSource: DataSource,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req) => {
return req.query ? (req.query as any).token : null;
},
]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'), secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
}); });

View File

@@ -25,9 +25,23 @@ export class DashboardService {
const pendingInvoices = await this.invoiceRepository.count({ const pendingInvoices = await this.invoiceRepository.count({
where: { where: {
tenant_id: tenantId, tenant_id: tenantId,
status: InvoiceStatus.EXTRACTING // or any non-final state status: Buffer.from('approved').toString() === InvoiceStatus.APPROVED ? InvoiceStatus.UPLOADED : InvoiceStatus.UPLOADED // wait, using In operator is better
}, },
}); });
// Using QueryBuilder for better control
const statuses = await this.invoiceRepository
.createQueryBuilder('invoice')
.select('status')
.addSelect('COUNT(*)', 'count')
.where('invoice.tenant_id = :tenantId', { tenantId })
.groupBy('status')
.getRawMany();
const statusMap = statuses.reduce((acc, curr) => {
acc[curr.status] = parseInt(curr.count);
return acc;
}, {});
const companiesCount = await this.companyRepository.count({ const companiesCount = await this.companyRepository.count({
where: { tenant_id: tenantId }, where: { tenant_id: tenantId },
@@ -49,11 +63,14 @@ export class DashboardService {
relations: ['company'], relations: ['company'],
}); });
const approvedInvoicesCount = statusMap[InvoiceStatus.APPROVED] || 0;
const processingInvoices = totalInvoices - approvedInvoicesCount;
return { return {
stats: { stats: {
totalInvoices, totalInvoices,
approvedInvoices, approvedInvoices: approvedInvoicesCount,
pendingInvoices, pendingInvoices: processingInvoices,
companiesCount, companiesCount,
totalTax, totalTax,
}, },

View File

@@ -7,6 +7,8 @@ import { DashboardPage } from './pages/dashboard/DashboardPage';
import { InvoicesPage } from './pages/invoices/InvoicesPage'; import { InvoicesPage } from './pages/invoices/InvoicesPage';
import { CompaniesPage } from './pages/companies/CompaniesPage'; import { CompaniesPage } from './pages/companies/CompaniesPage';
import { StaffPage } from './pages/staff/StaffPage';
import { SettingsPage } from './pages/settings/SettingsPage';
// ── Protected Route Guard ───────────────────────────────── // ── Protected Route Guard ─────────────────────────────────
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -28,8 +30,8 @@ export default function App() {
<Route path="dashboard" element={<DashboardPage />} /> <Route path="dashboard" element={<DashboardPage />} />
<Route path="invoices" element={<InvoicesPage />} /> <Route path="invoices" element={<InvoicesPage />} />
<Route path="companies" element={<CompaniesPage />} /> <Route path="companies" element={<CompaniesPage />} />
<Route path="staff" element={<div className="text-3xl font-bold">إدارة الموظفين</div>} /> <Route path="staff" element={<StaffPage />} />
<Route path="settings" element={<div className="text-3xl font-bold">الإعدادات</div>} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
{/* Fallback */} {/* Fallback */}

View File

@@ -17,7 +17,10 @@ import {
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import apiClient from '../../api/client'; import apiClient from '../../api/client';
import { useNavigate } from 'react-router-dom';
export const DashboardPage = () => { export const DashboardPage = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<any>(null); const [stats, setStats] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -169,7 +172,10 @@ export const DashboardPage = () => {
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-bold text-slate-900 px-2">إجراءات سريعة</h3> <h3 className="text-xl font-bold text-slate-900 px-2">إجراءات سريعة</h3>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<button className="flex items-center gap-4 p-4 rounded-2xl bg-primary-600 text-white shadow-xl shadow-primary-500/25 hover:bg-primary-700 transition-all group"> <button
onClick={() => navigate('/invoices')}
className="flex items-center gap-4 p-4 rounded-2xl bg-primary-600 text-white shadow-xl shadow-primary-500/25 hover:bg-primary-700 transition-all group"
>
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center group-hover:scale-110 transition-transform"> <div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
</div> </div>
@@ -179,7 +185,10 @@ export const DashboardPage = () => {
</div> </div>
</button> </button>
<button className="flex items-center gap-4 p-4 rounded-2xl bg-white border border-slate-200 text-slate-800 hover:border-primary-500 transition-all group"> <button
onClick={() => navigate('/companies')}
className="flex items-center gap-4 p-4 rounded-2xl bg-white border border-slate-200 text-slate-800 hover:border-primary-500 transition-all group"
>
<div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center group-hover:bg-primary-50 group-hover:text-primary-600 transition-all"> <div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center group-hover:bg-primary-50 group-hover:text-primary-600 transition-all">
<Building2 className="w-5 h-5" /> <Building2 className="w-5 h-5" />
</div> </div>

View File

@@ -282,7 +282,8 @@ export const InvoicesPage = () => {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
window.open(`${apiClient.defaults.baseURL}/invoices/${inv.id}/file`, '_blank'); const token = localStorage.getItem('access_token');
window.open(`${apiClient.defaults.baseURL}/invoices/${inv.id}/file?token=${token}`, '_blank');
}} }}
className="w-full text-right px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2" className="w-full text-right px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
> >
@@ -439,12 +440,13 @@ export const InvoicesPage = () => {
<div className="flex-1 overflow-auto bg-slate-100 p-8 flex justify-center items-start"> <div className="flex-1 overflow-auto bg-slate-100 p-8 flex justify-center items-start">
<div className="bg-white shadow-2xl rounded-sm overflow-hidden max-w-full"> <div className="bg-white shadow-2xl rounded-sm overflow-hidden max-w-full">
<img <img
src={`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file`} src={`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file?token=${localStorage.getItem('access_token')}`}
alt="Invoice" alt="Invoice"
className="max-w-full h-auto" className="max-w-full h-auto"
onError={(e) => { onError={(e) => {
// Fallback for PDF or Error // Fallback for PDF or Error
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
const token = localStorage.getItem('access_token');
e.currentTarget.parentElement!.innerHTML = ` e.currentTarget.parentElement!.innerHTML = `
<div class="p-20 text-center"> <div class="p-20 text-center">
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 border border-slate-200"> <div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 border border-slate-200">
@@ -452,7 +454,7 @@ export const InvoicesPage = () => {
</div> </div>
<h4 class="text-xl font-bold text-slate-900 mb-2">تعذر عرض الصورة مباشرة</h4> <h4 class="text-xl font-bold text-slate-900 mb-2">تعذر عرض الصورة مباشرة</h4>
<p class="text-slate-500 mb-6">قد يكون الملف بتنسيق PDF أو حدث خطأ أثناء التحميل.</p> <p class="text-slate-500 mb-6">قد يكون الملف بتنسيق PDF أو حدث خطأ أثناء التحميل.</p>
<a href="${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file" target="_blank" class="btn-primary px-8 py-3 rounded-xl inline-block">فتح الملف في نافذة جديدة</a> <a href="${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file?token=${token}" target="_blank" class="btn-primary px-8 py-3 rounded-xl inline-block">فتح الملف في نافذة جديدة</a>
</div> </div>
`; `;
}} }}
@@ -474,7 +476,10 @@ export const InvoicesPage = () => {
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => window.open(`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file`, '_blank')} onClick={() => {
const token = localStorage.getItem('access_token');
window.open(`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file?token=${token}`, '_blank');
}}
className="px-6 py-3 rounded-xl bg-white border border-slate-200 text-slate-700 font-bold flex items-center gap-2 hover:bg-slate-50 transition-all shadow-sm" className="px-6 py-3 rounded-xl bg-white border border-slate-200 text-slate-700 font-bold flex items-center gap-2 hover:bg-slate-50 transition-all shadow-sm"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />

View File

@@ -0,0 +1,153 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Settings Page
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import { Settings, Building, Save, Shield, Globe, Mail } from 'lucide-react';
import { motion } from 'framer-motion';
import apiClient from '../../api/client';
export const SettingsPage = () => {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// Tenant Profile State
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
const fetchSettings = async () => {
setIsLoading(true);
try {
// Get current tenant info (usually from a profile or me endpoint)
const { data } = await apiClient.get('/auth/me'); // Assuming there's a me endpoint for the tenant
setName(data.tenant.name || '');
setEmail(data.tenant.email || '');
// phone and address might be null
} catch (error) {
console.error('Failed to fetch settings', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
// await apiClient.put('/tenants/profile', { name, email, phone, address });
alert('تم حفظ الإعدادات بنجاح');
} catch (error) {
alert('فشل حفظ الإعدادات');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-4xl">
<header>
<h2 className="text-3xl font-bold text-slate-900 tracking-tight">إعدادات المكتب</h2>
<p className="text-slate-500 mt-1 font-medium">إدارة الملف الشخصي لمكتب المحاسبة الخاص بك.</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* ── Tabs/Navigation ──────────────────────────────── */}
<div className="space-y-2">
<button className="w-full text-right px-6 py-4 rounded-2xl bg-primary-50 text-primary-600 font-bold flex items-center gap-3 border border-primary-100 shadow-sm">
<Building className="w-5 h-5" />
الملف الشخصي
</button>
<button className="w-full text-right px-6 py-4 rounded-2xl text-slate-500 font-bold flex items-center gap-3 hover:bg-slate-50 transition-all">
<Shield className="w-5 h-5" />
الأمان والوصول
</button>
<button className="w-full text-right px-6 py-4 rounded-2xl text-slate-500 font-bold flex items-center gap-3 hover:bg-slate-50 transition-all">
<Globe className="w-5 h-5" />
تخصيص النظام
</button>
</div>
{/* ── Form Content ─────────────────────────────────── */}
<div className="md:col-span-2 space-y-6">
<div className="card-premium p-8 border border-slate-100 bg-white">
<form onSubmit={handleSave} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">اسم مكتب المحاسبة</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">البريد الإلكتروني الرسمي</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">رقم الهاتف</label>
<input
type="text"
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all"
placeholder="+962 7X XXX XXXX"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">عنوان المكتب</label>
<textarea
rows={3}
value={address}
onChange={e => setAddress(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all resize-none"
placeholder="مثال: عمان، العبدلي، برج السلام"
/>
</div>
<div className="pt-4 flex justify-end">
<button
type="submit"
disabled={isSaving}
className="btn-primary py-4 px-10 rounded-2xl shadow-xl shadow-primary-500/20 flex items-center gap-3 disabled:opacity-50"
>
{isSaving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
حفظ التغييرات
</button>
</div>
</form>
</div>
<div className="card-premium p-8 border border-red-100 bg-red-50/30">
<h4 className="text-red-600 font-bold mb-2">منطقة الخطر</h4>
<p className="text-sm text-slate-500 mb-6">حذف حساب المكتب سيؤدي إلى مسح جميع بيانات الشركات والفواتير بشكل نهائي.</p>
<button className="px-6 py-3 rounded-xl border border-red-200 text-red-600 font-bold hover:bg-red-50 transition-all">
إلغاء الاشتراك وحذف الحساب
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,212 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Staff Management Page
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import { Users, UserPlus, Shield, Mail, Phone, Trash2, Power } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import apiClient from '../../api/client';
export const StaffPage = () => {
const [staff, setStaff] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
// Form State
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('accountant');
const fetchStaff = async () => {
setIsLoading(true);
try {
const { data } = await apiClient.get('/users');
setStaff(data);
} catch (error) {
console.error('Failed to fetch staff', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchStaff();
}, []);
const handleAddStaff = async (e: React.FormEvent) => {
e.preventDefault();
try {
await apiClient.post('/users', {
full_name: fullName,
email,
password,
role
});
setIsAddModalOpen(false);
setFullName('');
setEmail('');
setPassword('');
fetchStaff();
} catch (error: any) {
alert(error.response?.data?.message || 'فشل إضافة الموظف');
}
};
const toggleStatus = async (id: string) => {
try {
await apiClient.delete(`/users/${id}`);
fetchStaff();
} catch (error) {
alert('فشل تغيير حالة الموظف');
}
};
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-slate-900 tracking-tight">إدارة الموظفين</h2>
<p className="text-slate-500 mt-1 font-medium">إدارة المحاسبين والمديرين في مكتبك.</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2 shadow-xl shadow-primary-500/25 active:scale-95 transition-all"
>
<UserPlus className="w-5 h-5" />
إضافة موظف جديد
</button>
</header>
{isLoading ? (
<div className="flex justify-center p-20">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{staff.map((member) => (
<motion.div
key={member.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="card-premium p-6 group hover:border-primary-200 transition-all border border-slate-100"
>
<div className="flex justify-between items-start mb-6">
<div className="w-14 h-14 rounded-2xl bg-slate-50 text-slate-400 flex items-center justify-center group-hover:bg-primary-50 group-hover:text-primary-600 transition-all">
<Users className="w-7 h-7" />
</div>
<div className="flex gap-2">
<button
onClick={() => toggleStatus(member.id)}
className={`p-2 rounded-xl transition-all ${member.is_active ? 'text-emerald-500 bg-emerald-50' : 'text-slate-400 bg-slate-50'}`}
title={member.is_active ? 'تعطيل الحساب' : 'تفعيل الحساب'}
>
<Power className="w-4 h-4" />
</button>
</div>
</div>
<h3 className="text-xl font-bold text-slate-900 mb-1">{member.full_name}</h3>
<div className="flex items-center gap-2 mb-6">
<span className={`text-[10px] font-black uppercase tracking-widest px-2 py-0.5 rounded-md ${
member.role === 'admin' ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-50 text-slate-600'
}`}>
{member.role === 'admin' ? 'مدير نظام' : 'محاسب'}
</span>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm text-slate-500">
<Mail className="w-4 h-4" />
{member.email}
</div>
</div>
</motion.div>
))}
</div>
)}
{/* ── Add Staff Modal ──────────────────────────────────── */}
<AnimatePresence>
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-[32px] p-8 w-full max-w-md shadow-2xl relative"
>
<h3 className="text-2xl font-bold text-slate-900 mb-2">إضافة موظف جديد</h3>
<p className="text-slate-500 mb-8">سيتمكن الموظف من الدخول لمكتبك ومساعدة الشركات.</p>
<form onSubmit={handleAddStaff} className="space-y-5">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">الاسم الكامل</label>
<input
type="text"
required
value={fullName}
onChange={e => setFullName(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all"
placeholder="مثال: أحمد محمد"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">البريد الإلكتروني</label>
<input
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all"
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">كلمة المرور المؤقتة</label>
<input
type="password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">الصلاحية</label>
<select
value={role}
onChange={e => setRole(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 outline-none focus:border-primary-500 transition-all appearance-none"
>
<option value="accountant">محاسب</option>
<option value="admin">مدير نظام</option>
</select>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="flex-1 bg-slate-100 text-slate-700 font-bold py-4 rounded-2xl hover:bg-slate-200 transition-all"
>
إلغاء
</button>
<button
type="submit"
className="flex-[2] btn-primary py-4 rounded-2xl shadow-lg shadow-primary-500/30"
>
حفظ وإضافة
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};