Compare commits

...

49 Commits

Author SHA1 Message Date
Hamza-Ayed
15890fcfcd 🚀 Fix: Upgrade main account to Global Super Admin and bypass tenant filters 2026-04-22 23:05:46 +03:00
Hamza-Ayed
2f238e19c2 🚀 Feature: Implement Global Super Admin access & bypass tenant filtering 2026-04-22 22:47:53 +03:00
Hamza-Ayed
944c82730d 🚀 Fix: Login case-sensitivity & Restore Super Admin AI metrics access 2026-04-22 22:37:48 +03:00
Hamza-Ayed
f72c13f29a 🚀 Fix: Add missing useAuthStore import to DashboardPage 2026-04-22 18:18:17 +03:00
Hamza-Ayed
357274683c 🚀 Fix: Roles access for Staff/Tax Center and Profile update bug 2026-04-22 18:15:18 +03:00
Hamza-Ayed
bb7afc5629 🚀 Fix: Database schema sync for AI columns and UI contrast improvements 2026-04-22 17:59:44 +03:00
Hamza-Ayed
92fa5a4b49 🚀 Fix: Update AI pricing to Flash-Lite and fix build lint error 2026-04-22 17:51:09 +03:00
Hamza-Ayed
6c1d67c695 🚀 Phase 4: AI Usage Tracking, Security Hardening, and Risk Monitor Dashboard 2026-04-22 17:32:22 +03:00
Hamza-Ayed
7e0e271be2 🚀 Hotfixes: inline PDF preview and instant profile update in UI 2026-04-22 17:15:43 +03:00
Hamza-Ayed
4c2fd7bba5 🚀 Fix: Correct user role value from manager to accountant to match DB enum 2026-04-22 02:37:29 +03:00
Hamza-Ayed
c3f3d940e5 🚀 Phase 3 Complete: Fix staff list, PDF preview, and functional Settings/Profile 2026-04-22 02:31:01 +03:00
Hamza-Ayed
09cb8efa80 🚀 Fix: Restore missing imports and clean up duplicates in InvoicesPage 2026-04-22 02:23:02 +03:00
Hamza-Ayed
f4e505a610 🚀 Fix: Remove remaining unused imports to resolve production build failures 2026-04-22 02:21:54 +03:00
Hamza-Ayed
5aa3a178b9 💎 Complete luxury overhaul: Invoices, Companies, Staff, Settings redesigned & renamed Sidebar 2026-04-22 02:18:41 +03:00
Hamza-Ayed
a113f72842 🚀 Fix: Remove unused import to resolve build failure 2026-04-22 02:03:13 +03:00
Hamza-Ayed
660c551098 💎 Luxury dark redesign + API integration for Trojan Horse and Elite Dashboard 2026-04-22 02:01:08 +03:00
Hamza-Ayed
2e900e395f 🚀 Fix case-sensitive import path for MultiEntityDashboard 2026-04-22 01:45:16 +03:00
Hamza-Ayed
b14a97bc60 🚀 Route Elite Dashboard and Trojan Horse pages 2026-04-22 01:42:52 +03:00
Hamza-Ayed
31022da057 🚀 Apply missing DB schema changes for qr_code and company_id 2026-04-22 01:37:53 +03:00
Hamza-Ayed
4cfa65364e 🚀 Fix verbatimModuleSyntax type import error 2026-04-22 01:31:42 +03:00
Hamza-Ayed
401efaecb2 🛠️ Fix frontend compilation errors 2026-04-22 01:28:42 +03:00
Hamza-Ayed
fd00e9c57d 🛠️ Fix build compilation errors 2026-04-22 01:17:49 +03:00
Hamza-Ayed
444097814d 🚀 Elite Accountant Hub: Foundation & Trojan Horse deployment 2026-04-22 01:05:25 +03:00
Hamza-Ayed
2e2d76c0a8 🚀 Multi-invoice extraction and detailed validation feedback 2026-04-19 16:13:09 +03:00
Hamza-Ayed
6b9ce6e95b 🛡️ Safety: Prevent self-deactivation and fix staff UI 2026-04-19 15:54:50 +03:00
Hamza-Ayed
ff8126f93b 🩹 Fix: Add missing Get import in AuthController 2026-04-19 15:38:34 +03:00
Hamza-Ayed
3ae3f1d797 🚀 Final: Fix stats, staff list, settings profile, and logout redirect 2026-04-19 15:36:45 +03:00
Hamza-Ayed
946c7db96c 🩹 Fix: Remove unused imports causing build failure 2026-04-19 15:30:12 +03:00
Hamza-Ayed
ef9baf33f7 Feat: Dashboard accuracy, Staff & Settings modules, and File Auth fix 2026-04-19 15:25:43 +03:00
Hamza-Ayed
3acd9f261b Feat: Invoice viewing, JoFotara UBL refinements, delete functionality, and tax rate validation 2026-04-19 15:03:39 +03:00
Hamza-Ayed
47d473add5 Fix missing icon imports 2026-04-18 02:26:32 +03:00
Hamza-Ayed
0b0cd9798c Finalize Invoices schema and actions menu 2026-04-18 02:23:33 +03:00
Hamza-Ayed
066df077b1 Fix AI mime-type and enable invoice actions 2026-04-18 02:17:56 +03:00
Hamza-Ayed
9f5b202bb2 Update Gemini model to latest 2026-04-18 02:11:59 +03:00
Hamza-Ayed
6be5b87e03 Relax DB password validation 2026-04-18 02:03:00 +03:00
Hamza-Ayed
458af20235 Disable synchronize and fix DB manually 2026-04-18 01:45:21 +03:00
Hamza-Ayed
26c79037c2 Isolate companies fetch to fix empty dropdown 2026-04-18 01:42:56 +03:00
Hamza-Ayed
d857e7428c Bypass database enum restrictions 2026-04-18 01:38:14 +03:00
Hamza-Ayed
9ce817a9bb Fix subscription enum mismatch 2026-04-18 01:34:03 +03:00
Hamza-Ayed
9756adfaae Fix Dashboard 404, missing Invoices table, and account limits 2026-04-18 01:28:24 +03:00
Hamza-Ayed
2e18020e49 Fix unused imports in Dashboard 2026-04-18 01:22:18 +03:00
Hamza-Ayed
aad1998e56 Live Dashboard and debug logging 2026-04-18 01:04:33 +03:00
Hamza-Ayed
93591c75e2 Implement Invoices fetching and uploading 2026-04-18 00:55:07 +03:00
Hamza-Ayed
77434fa815 Add JoFotara linking modal and fix company limit 2026-04-18 00:47:37 +03:00
Hamza-Ayed
ce7b1fc5d8 Fix company TIN field mapping in payload 2026-04-18 00:41:10 +03:00
Hamza-Ayed
f5f0fd792a Fix unused variable in CompaniesPage 2026-04-18 00:38:12 +03:00
Hamza-Ayed
6a5e0e65ec Add Companies page 2026-04-18 00:34:34 +03:00
Hamza-Ayed
5749a54e2d Fix frontend production API URL 2026-04-18 00:27:55 +03:00
Hamza-Ayed
ecb9d8d7c8 Fix total_tax field name in dashboard service 2026-04-18 00:11:22 +03:00
52 changed files with 3526 additions and 534 deletions

View File

@@ -32,9 +32,9 @@ JWT_REFRESH_EXPIRY=
ENCRYPTION_KEY=
# ── JoFotara ───────────────────────────────────────────────
JOFOTARA_SANDBOX_URL=
JOFOTARA_PROD_URL=
JOFOTARA_ENV=
JOFOTARA_SANDBOX_URL=https://sandbox.jofotara.gov.jo/core/invoices
JOFOTARA_PROD_URL=https://backend.jofotara.gov.jo/core/invoices
JOFOTARA_ENV=production
# ── Gemini AI ──────────────────────────────────────────────
GEMINI_API_KEY=

View File

@@ -21,6 +21,7 @@ import { UsersModule } from './modules/users/user.module';
import { CompaniesModule } from './modules/companies/company.module';
import { SubscriptionsModule } from './modules/subscriptions/subscription.module';
import { InvoicesModule } from './modules/invoices/invoice.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
@Module({
@@ -65,6 +66,7 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor
CompaniesModule,
SubscriptionsModule,
InvoicesModule,
DashboardModule,
],
providers: [
// Global Rate Limiting Guard

View File

@@ -24,7 +24,7 @@ export const databaseConfig: TypeOrmModuleAsyncOptions = {
// Entity auto-discovery
autoLoadEntities: true,
// NEVER synchronize — use migrations only
// NEVER synchronize — use migrations or manual SQL only
synchronize: false,
// SSL is not required for internal Docker network

View File

@@ -45,9 +45,9 @@ export const envValidationSchema = Joi.object({
.description('مستخدم قاعدة البيانات'),
DB_PASS: Joi.string()
.min(32)
.min(8)
.required()
.description('كلمة مرور قاعدة البيانات (32 حرف كحد أدنى)'),
.description('كلمة مرور قاعدة البيانات (8 أحرف كحد أدنى)'),
DB_NAME: Joi.string()
.required()
@@ -111,7 +111,7 @@ export const envValidationSchema = Joi.object({
.description('مفتاح Google Gemini API'),
GEMINI_MODEL: Joi.string()
.default('gemini-2.0-flash-lite')
.default('gemini-flash-lite-latest')
.description('نموذج Gemini المستخدم'),
// ── File Storage ───────────────────────────────────────

View File

@@ -6,6 +6,7 @@
import {
Controller,
Get,
Post,
Body,
UseGuards,
@@ -19,6 +20,7 @@ import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthGuard } from '@nestjs/passport';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
@@ -35,7 +37,9 @@ export class AuthController {
/**
* تسجيل الدخول
* Rate limiting: 5 requests per minute
*/
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
@@ -62,6 +66,16 @@ export class AuthController {
return this.authService.logout(user.id);
}
/**
* الملف الشخصي الحالي والبيانات الأساسية
*/
@UseGuards(JwtAuthGuard)
@Get('me')
@HttpCode(HttpStatus.OK)
async me(@CurrentUser() user: any) {
return this.authService.getMe(user.id);
}
/**
* الملف الشخصي
*/

View File

@@ -27,7 +27,7 @@ export class AuthService {
private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource,
) {}
) { }
/**
* تسجيل مستخدم جديد (مدير مكتب)
@@ -60,7 +60,7 @@ export class AuthService {
const subscription = queryRunner.manager.create(Subscription, {
tenant_id: savedTenant.id,
plan: SubscriptionPlan.BASIC,
max_companies: 1,
max_companies: 3, // -1 means unlimited
max_invoices_per_month: 200,
price_jod: 15, // Basic price
status: SubscriptionStatus.ACTIVE,
@@ -100,8 +100,9 @@ export class AuthService {
* تسجيل دخول
*/
async login(dto: LoginDto) {
const normalizedEmail = dto.email.trim().toLowerCase();
const user = await this.dataSource.getRepository(User).findOne({
where: { email: dto.email, is_active: true },
where: { email: normalizedEmail, is_active: true },
select: ['id', 'email', 'password_hash', 'tenant_id', 'role', 'name'],
});
@@ -109,6 +110,16 @@ export class AuthService {
throw new UnauthorizedException('Invalid credentials');
}
// ── Self-Healing: Upgrade old trial accounts to unlimited companies ──
try {
await this.dataSource.query(
'UPDATE subscriptions SET max_companies = -1 WHERE tenant_id = $1 AND max_companies = 1',
[user.tenant_id],
);
} catch (e) {
console.error('Failed to auto-upgrade subscription limit', e);
}
const payload = {
sub: user.id,
tenantId: user.tenant_id,
@@ -169,6 +180,29 @@ export class AuthService {
};
}
/**
* الحصول على بيانات المستخدم والاشتراك الحالي
*/
async getMe(userId: string) {
const user = await this.dataSource.getRepository(User).findOne({
where: { id: userId },
relations: ['tenant'],
});
if (!user) throw new UnauthorizedException();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
tenantId: user.tenant_id,
},
tenant: user.tenant,
};
}
/**
* تسجيل خروج
*/

View File

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

View File

@@ -27,43 +27,43 @@ export class CompaniesController {
constructor(private companiesService: CompaniesService) {}
@Post()
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async create(@CurrentUser() user: any, @Body() dto: any) {
return this.companiesService.create(user.tenantId, dto);
return this.companiesService.create(user, dto);
}
@Get()
async findAll(@CurrentUser() user: any) {
return this.companiesService.findAll(user.tenantId);
return this.companiesService.findAll(user);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
return this.companiesService.findOne(user.tenantId, id);
return this.companiesService.findOne(user, id);
}
@Patch(':id')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async update(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: any,
) {
return this.companiesService.update(user.tenantId, id, dto);
return this.companiesService.update(user, id, dto);
}
/**
* ربط بيانات جو فوترة المشفرة
*/
@Put(':id/jofotara')
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
async setJoFotara(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: { clientId: string; secretKey: string },
) {
return this.companiesService.setJoFotaraCredentials(
user.tenantId,
user,
id,
dto.clientId,
dto.secretKey,

View File

@@ -14,6 +14,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Company } from './entities/company.entity';
import { UserRole } from '../users/enums/role.enum';
import { EncryptionService } from '../../services/encryption/encryption.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
@@ -30,7 +31,8 @@ export class CompaniesService {
/**
* إنشاء شركة جديدة مع التحقق من حدود الاشتراك
*/
async create(tenantId: string, dto: any): Promise<Company> {
async create(user: any, dto: any): Promise<Company> {
const tenantId = user.tenantId;
// 1. Check subscription limits
const canCreate = await this.subscriptionsService.checkCompanyLimit(tenantId);
if (!canCreate) {
@@ -48,9 +50,12 @@ export class CompaniesService {
/**
* قائمة الشركات التابعة للمكتب
*/
async findAll(tenantId: string): Promise<Company[]> {
async findAll(user: any): Promise<Company[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { is_active: true } : { tenant_id: user.tenantId, is_active: true };
return this.companyRepository.find({
where: { tenant_id: tenantId, is_active: true },
where: filter,
order: { created_at: 'DESC' },
});
}
@@ -58,9 +63,12 @@ export class CompaniesService {
/**
* تفاصيل شركة محددة
*/
async findOne(tenantId: string, id: string): Promise<Company> {
async findOne(user: any, id: string): Promise<Company> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const company = await this.companyRepository.findOne({
where: { id, tenant_id: tenantId },
where: filter,
});
if (!company) throw new NotFoundException('Company not found');
return company;
@@ -69,8 +77,8 @@ export class CompaniesService {
/**
* تحديث بيانات شركة
*/
async update(tenantId: string, id: string, dto: any): Promise<Company> {
const company = await this.findOne(tenantId, id);
async update(user: any, id: string, dto: any): Promise<Company> {
const company = await this.findOne(user, id);
Object.assign(company, dto);
return this.companyRepository.save(company);
}
@@ -79,12 +87,12 @@ export class CompaniesService {
* حفظ مفاتيح جو فوترة (مُشفرة)
*/
async setJoFotaraCredentials(
tenantId: string,
user: any,
id: string,
clientId: string,
secretKey: string,
): Promise<void> {
const company = await this.findOne(tenantId, id);
const company = await this.findOne(user, id);
company.jofotara_client_id_encrypted = this.encryptionService.encrypt(clientId);
company.jofotara_secret_key_encrypted = this.encryptionService.encrypt(secretKey);
@@ -95,9 +103,12 @@ export class CompaniesService {
/**
* الحصول على المفاتيح (مفكوك تشفيرها) — للاستخدام الداخلي فقط
*/
async getDecryptedCredentials(tenantId: string, id: string) {
async getDecryptedCredentials(user: any, id: string) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const company = await this.companyRepository.findOne({
where: { id, tenant_id: tenantId },
where: filter,
select: ['jofotara_client_id_encrypted', 'jofotara_secret_key_encrypted'],
});

View File

@@ -52,6 +52,12 @@ export class Company {
@Column({ type: 'varchar', length: 50, nullable: true })
jofotara_income_source_sequence?: string;
@Column({ type: 'text', nullable: true })
certificate_path?: string;
@Column({ type: 'text', nullable: true, select: false })
certificate_password_encrypted?: string;
@Column({ type: 'boolean', default: true })
is_active!: boolean;

View File

@@ -10,6 +10,16 @@ export class DashboardController {
@Get('stats')
async getStats(@CurrentUser() user: any) {
return this.dashboardService.getStats(user.tenantId);
return this.dashboardService.getStats(user);
}
@Get('multi-entity')
async getMultiEntityStats(@CurrentUser() user: any) {
return this.dashboardService.getMultiEntityStats(user);
}
@Get('risk-invoices')
async getRiskInvoices(@CurrentUser() user: any) {
return this.dashboardService.getRiskInvoices(user);
}
}

View File

@@ -0,0 +1,21 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Dashboard Module
* ════════════════════════════════════════════════════════════
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DashboardService } from './dashboard.service';
import { DashboardController } from './dashboard.controller';
import { Invoice } from '../invoices/entities/invoice.entity';
import { Company } from '../companies/entities/company.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Invoice, Company]),
],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Invoice, InvoiceStatus } from '../invoices/entities/invoice.entity';
import { Company } from '../companies/entities/company.entity';
import { UserRole } from '../users/enums/role.enum';
@Injectable()
export class DashboardService {
@@ -13,47 +14,63 @@ export class DashboardService {
private companyRepository: Repository<Company>,
) {}
async getStats(tenantId: string) {
async getStats(user: any) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
const totalInvoices = await this.invoiceRepository.count({
where: { tenant_id: tenantId },
where: filter,
});
const approvedInvoices = await this.invoiceRepository.count({
where: { tenant_id: tenantId, status: InvoiceStatus.APPROVED },
where: { ...filter, status: InvoiceStatus.APPROVED },
});
const pendingInvoices = await this.invoiceRepository.count({
where: {
tenant_id: tenantId,
status: InvoiceStatus.EXTRACTING // or any non-final state
},
});
// Using QueryBuilder for better control
const query = this.invoiceRepository
.createQueryBuilder('invoice')
.select('status')
.addSelect('COUNT(*)', 'count');
if (!isSuperAdmin) {
query.where('invoice.tenant_id = :tenantId', { tenantId: user.tenantId });
}
const statuses = await query.groupBy('status').getRawMany();
const statusMap = statuses.reduce((acc, curr) => {
acc[curr.status] = parseInt(curr.count);
return acc;
}, {});
const companiesCount = await this.companyRepository.count({
where: { tenant_id: tenantId },
where: filter,
});
// Calculate total tax (mock logic for now, should sum up tax fields)
const invoices = await this.invoiceRepository.find({
where: { tenant_id: tenantId, status: InvoiceStatus.APPROVED },
select: ['total_tax'],
where: { ...filter, status: InvoiceStatus.APPROVED },
select: ['tax_amount'],
});
const totalTax = invoices.reduce((sum, inv) => sum + Number(inv.total_tax || 0), 0);
const totalTax = invoices.reduce((sum, inv: any) => sum + Number(inv.tax_amount || 0), 0);
// Get recent activities (last 5 invoices)
const recentInvoices = await this.invoiceRepository.find({
where: { tenant_id: tenantId },
where: filter,
order: { created_at: 'DESC' },
take: 5,
relations: ['company'],
});
const approvedInvoicesCount = statusMap[InvoiceStatus.APPROVED] || 0;
const processingInvoices = totalInvoices - approvedInvoicesCount;
return {
stats: {
totalInvoices,
approvedInvoices,
pendingInvoices,
approvedInvoices: approvedInvoicesCount,
pendingInvoices: processingInvoices,
companiesCount,
totalTax,
},
@@ -66,4 +83,78 @@ export class DashboardService {
})),
};
}
/**
* جلب إحصائيات مجمعة لكل الشركات (Elite Accountant View)
* Get summarized stats for all companies under a tenant
*/
async getMultiEntityStats(user: any) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
const companies = await this.companyRepository.find({
where: filter,
});
const companyStats = await Promise.all(
companies.map(async (company) => {
const stats = await this.invoiceRepository
.createQueryBuilder('invoice')
.select('COUNT(*)', 'total')
.addSelect('SUM(CASE WHEN status = :approved THEN tax_amount ELSE 0 END)', 'totalTax')
.addSelect('COUNT(CASE WHEN status = :failed THEN 1 END)', 'failedCount')
.addSelect('SUM(ai_prompt_tokens)', 'totalPromptTokens')
.addSelect('SUM(ai_completion_tokens)', 'totalCompletionTokens')
.addSelect('SUM(ai_total_cost)', 'totalAiCost')
.where('invoice.company_id = :companyId', {
companyId: company.id,
approved: InvoiceStatus.APPROVED,
failed: InvoiceStatus.VALIDATION_FAILED
})
.getRawOne();
return {
id: company.id,
name: company.name,
taxId: company.tax_identification_number,
totalInvoices: parseInt(stats.total) || 0,
totalTax: parseFloat(stats.totalTax) || 0,
failedCount: parseInt(stats.failedCount) || 0,
aiStats: {
totalTokens: (parseInt(stats.totalPromptTokens) || 0) + (parseInt(stats.totalCompletionTokens) || 0),
totalCost: parseFloat(stats.totalAiCost) || 0,
},
riskScore: this.calculateMockRiskScore(stats), // Placeholder for AI Risk Score
};
}),
);
return companyStats;
}
private calculateMockRiskScore(stats: any) {
// Logic to be replaced by AI Anomaly Detection later
const failedRatio = stats.total > 0 ? (stats.failedCount / stats.total) : 0;
if (failedRatio > 0.2) return 85; // High Risk
if (failedRatio > 0.05) return 45; // Medium Risk
return 12; // Low Risk
}
/**
* جلب الفواتير التي بها مخاطر (فشل التحقق أو مرفوضة) عبر كافة الشركات
*/
async getRiskInvoices(user: any) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
return this.invoiceRepository.find({
where: [
{ ...filter, status: InvoiceStatus.VALIDATION_FAILED },
{ ...filter, status: InvoiceStatus.REJECTED },
],
relations: ['company'],
order: { updated_at: 'DESC' },
take: 50,
});
}
}

View File

@@ -0,0 +1,32 @@
import { Processor, Process, InjectQueue } from '@nestjs/bull';
import { Job, Queue } from 'bull';
import { Logger } from '@nestjs/common';
import { InvoicesService } from './invoice.service';
@Processor('invoice-bulk-queue')
export class BulkUploadProcessor {
private readonly logger = new Logger(BulkUploadProcessor.name);
constructor(
private readonly invoicesService: InvoicesService,
@InjectQueue('invoice-bulk-queue') private readonly bulkQueue: Queue,
) {}
@Process('process-zip')
async handleBulkZip(job: Job<{ filePath: string, companyId: string, tenantId: string }>) {
const { filePath, companyId, tenantId } = job.data;
this.logger.log(`Processing bulk ZIP: ${filePath} for Company: ${companyId}`);
// TODO: Implement ZIP extraction (adm-zip or unzipper)
// TODO: For each file in ZIP:
// 1. Calculate Hash (MD5/SHA256)
// 2. Check Redis SMEMBERS to prevent duplicate processing
// const hash = 'calculated_file_hash';
// const exists = await this.bulkQueue.client.sismember(`company:${companyId}:invoice-hashes`, hash);
// if (exists) return;
// 3. Save hash and trigger individual processing
// await this.bulkQueue.client.sadd(`company:${companyId}:invoice-hashes`, hash);
// await this.invoicesService.processSingleFile(fileInZip, tenantId, companyId);
}
}

View File

@@ -107,9 +107,27 @@ export class Invoice {
@Column({ type: 'text', nullable: true })
original_file_path?: string;
@Column({ type: 'varchar', length: 20, default: 'simplified' })
invoice_category!: string;
@Column({ type: 'jsonb', nullable: true })
validation_errors?: string[];
@Column({ type: 'text', nullable: true })
qr_code?: string;
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
ai_confidence_score?: number;
@Column({ type: 'int', default: 0 })
ai_prompt_tokens!: number;
@Column({ type: 'int', default: 0 })
ai_completion_tokens!: number;
@Column({ type: 'decimal', precision: 10, scale: 6, default: 0 })
ai_total_cost!: number;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;

View File

@@ -1,8 +1,10 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Gemini AI Extraction Service
* مُصادَق (Musadaq) — Gemini AI Extraction Service / خدمة استخراج البيانات
* ════════════════════════════════════════════════════════════
* يقوم باستخراج البيانات من صور/ملفات الفواتير باستخدام Gemini.
* This service extracts financial data from invoice images/PDFs using Gemini AI.
* It ensures unstructured data is converted into UBL 2.1 compliant JSON.
* يقوم باستخراج البيانات المالية من صور/ملفات الفواتير باستخدام ذكاء Gemini الاصطناعي.
* يضمن تحويل البيانات غير المهيكلة إلى JSON مطابق لمعايير UBL 2.1.
* ════════════════════════════════════════════════════════════
*/
@@ -28,7 +30,8 @@ export class GeminiExtractorService {
}
/**
* استخراج البيانات من صورة الفاتورة
* استخراج البيانات من صورة الفاتورة (يدعم فواتير متعددة في ملف واحد)
* Extract accounting data from an invoice file (supports multiple invoices per file)
*/
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
try {
@@ -36,43 +39,78 @@ export class GeminiExtractorService {
const fileData = fs.readFileSync(fullPath);
const prompt = `
You are a Jordanian tax expert. Extract all details from this invoice image for the Jordan National Invoicing System (JoFotara / JoInvoice).
The output MUST be a strict JSON object following this schema:
أنت الآن "مدقق ضريبي أردني خبير" (Expert Jordanian Tax Auditor).
مهمتك هي استخراج البيانات المحاسبية من هذا الملف بدقة متناهية لضمان الامتثال لنظام الفوترة الوطني (JoFotara).
قواعد الاستخراج الاستراتيجية:
1. الشخصية: تعامل مع الملف كمدقق يبحث عن أدق التفاصيل المالية والقانونية.
2. العملة: جميع المبالغ بالدينار الأردني (JOD). التزم بدقة 3 خانات عشرية (مثال: 1.250).
3. الضرائب: حدد نسب الضريبة لكل بند بناءً على القوائم المعتمدة:
- (16%): النسبة العامة (أي سلع غير مذكورة أدناه).
- (10%): حلاوة طحينة، طحينة، أجبان محضرة، أسماك محفوظة، سجق.
- (5%): عبوات الألبان والعلب المعدنية والكرتون المطبوع المخصص للتغليف.
- (4%): كرتون أطباق البيض، القرطاسية، الزي المدرسي، مدافئ الكاز والغاز، البوتاس والفوسفات.
- (2%): الملفوف، الباميا، البازلاء (طازجة أو مبردة).
- (0%): اللحوم، الأسماك، لوازم شبكات الري، آلات الزراعة، نباتات الهندباء.
- (معفى - Exempt): العدس، الشاي، الحليب، السكر، الأرز، الهواتف الخلوية، الكهرباء.
4. الأطراف: استخرج الرقم الضريبي للمورد (Supplier TIN) والرقم الضريبي أو الوطني للمشتري (Buyer TIN/National ID).
5. التصنيف:
- "simplified": إذا كان المشتري فرداً (بدون رقم ضريبي).
- "standard": إذا كان المشتري شركة أو منشأة (يوجد رقم ضريبي).
6. الفواتير المتعددة: إذا احتوى الملف على أكثر من فاتورة منفصلة، استخرج بيانات كل واحدة في عنصر مستقل في مصفوفة "invoices".
7. التجاهل: تجاهل الأختام اليدوية التي تغطي النصوص، وحاول استنتاج النص تحتها برمجياً.
The output MUST be a strict JSON object with this schema:
{
"invoice_number": "string",
"invoice_date": "YYYY-MM-DD",
"invoice_type": "cash" | "credit",
"supplier_name": "string",
"supplier_tin": "string (10 digits)",
"buyer_name": "string (optional)",
"buyer_tin": "string (optional)",
"subtotal": number (before discount and tax),
"discount_total": number (total discount),
"tax_amount": number (total tax),
"grand_total": number (final amount),
"currency_code": "JOD",
"lines": [
"invoices": [
{
"line_number": number,
"description": "string",
"quantity": number,
"unit_price": number,
"discount": number,
"tax_rate": number (e.g. 0.16 for 16%),
"line_total": number (quantity * unit_price - discount)
"invoice_number": "string",
"invoice_date": "YYYY-MM-DD",
"invoice_type": "cash" | "credit",
"ubl_type_code": "388" | "381",
"payment_method_code": "013" | "023",
"invoice_category": "standard" | "simplified",
"supplier_name": "string",
"supplier_tin": "string (10 digits)",
"buyer_name": "string (optional)",
"buyer_tin": "string (optional)",
"buyer_national_id": "string (10 digits, optional)",
"subtotal": number,
"discount_total": number,
"tax_amount": number,
"grand_total": number,
"currency_code": "JOD",
"lines": [
{
"line_number": number,
"description": "string",
"quantity": number,
"unit_price": number,
"discount": number,
"tax_rate": number (e.g. 0.16),
"line_total": number
}
]
}
]
}
Return ONLY the JSON. No markdown formatting. If a field is missing, return null.
Pay close attention to Jordanian Tax Rules (subtotal - discount + tax = grand_total).
Return ONLY the JSON. No markdown formatting.
`;
// Detect MIME type based on extension
const ext = path.extname(filePath).toLowerCase();
let mimeType = 'image/jpeg';
if (ext === '.pdf') mimeType = 'application/pdf';
else if (ext === '.png') mimeType = 'image/png';
else if (ext === '.webp') mimeType = 'image/webp';
const result = await this.model.generateContent([
prompt,
{
inlineData: {
data: fileData.toString('base64'),
mimeType: 'image/jpeg', // Adjusted based on file extension in prod
mimeType: mimeType,
},
},
]);
@@ -81,7 +119,23 @@ export class GeminiExtractorService {
// Clean up markdown if any
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
return JSON.parse(cleanedJson);
const data = JSON.parse(cleanedJson);
// Get usage metadata for token tracking
const usage = result.response.usageMetadata || {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
};
return {
invoices: data.invoices || [],
usage: {
promptTokens: usage.promptTokenCount,
completionTokens: usage.candidatesTokenCount,
totalTokens: usage.totalTokenCount,
},
};
} catch (error: any) {
this.logger.error(`AI Extraction failed: ${error.message}`);
throw new InternalServerErrorException('AI Extraction failed');

View File

@@ -1,6 +1,9 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Controller
* مُصادَق (Musadaq) — Invoices Controller / متحكم الفواتير
* ════════════════════════════════════════════════════════════
* Handles HTTP requests related to invoice management.
* يتعامل مع طلبات HTTP المتعلقة بإدارة الفواتير.
* ════════════════════════════════════════════════════════════
*/
@@ -15,7 +18,9 @@ import {
UploadedFile,
UseInterceptors,
ParseUUIDPipe,
Res,
} from '@nestjs/common';
import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { InvoicesService } from './invoice.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -29,40 +34,52 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
/**
* قائمة جميع الفواتير للمكتب
* List all invoices for the entire accounting office (tenant)
*/
@Get()
async findAllByTenant(@CurrentUser() user: any) {
return this.invoicesService.findAllByTenant(user);
}
/**
* رفع فاتورة لشركة محددة
* Upload an invoice file for a specific company
*/
@Post('upload/:companyId')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
@UseInterceptors(FileInterceptor('file'))
async upload(
@CurrentUser() user: any,
@Param('companyId', ParseUUIDPipe) companyId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.invoicesService.upload(user.tenantId, companyId, file);
return this.invoicesService.upload(user, companyId, file);
}
/**
* قائمة الفواتير لشركة محددة
* List all invoices for a specific company
*/
@Get('company/:companyId')
async findAll(
@CurrentUser() user: any,
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
return this.invoicesService.findAll(user.tenantId, companyId);
return this.invoicesService.findAll(user, companyId);
}
/**
* تفاصيل فاتورة محددة
* Get details of a specific invoice
*/
@Get(':id')
async findOne(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.invoicesService.findOne(user.tenantId, id);
return this.invoicesService.findOne(user, id);
}
/**
@@ -70,10 +87,51 @@ export class InvoicesController {
*/
/**
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
* Submit an invoice to the official JoFotara portal
*/
@Post(':id/submit')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async submit(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.submitToJoFotara(user.tenantId, id);
return this.invoicesService.submitToJoFotara(user, id);
}
/**
* حذف الفاتورة نهائياً
* Permanently delete an invoice
*/
@Post(':id/delete') // Using POST for delete to match frontend request style or use standard DELETE
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async remove(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.remove(user, id);
}
/**
* الحصول على الملف الأصلي للفاتورة
* Download/Stream the original invoice file
*/
@Get(':id/file')
async getFile(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
const streamableFile = await this.invoicesService.getFile(user, id);
// We need to determine the content type to ensure it opens inline in the browser (iframe)
// We can fetch the invoice details first to get the extension.
const invoice = await this.invoicesService.findOne(user, id);
let mimeType = 'application/pdf'; // Default fallback
if (invoice.original_file_path) {
const ext = invoice.original_file_path.split('.').pop()?.toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
else if (ext === 'png') mimeType = 'image/png';
}
res.set({
'Content-Type': mimeType,
'Content-Disposition': 'inline', // This forces the browser to display it instead of downloading
});
return streamableFile;
}
}

View File

@@ -9,6 +9,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { InvoicesService } from './invoice.service';
import { InvoicesController } from './invoice.controller';
import { PublicInvoiceController } from './public-invoice.controller';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import { InvoiceProcessor } from './invoice.processor';
@@ -23,9 +24,10 @@ import { CompaniesModule } from '../companies/company.module';
@Module({
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
BullModule.registerQueue({
name: 'invoice-processing',
}),
BullModule.registerQueue(
{ name: 'invoice-processing' },
{ name: 'invoice-bulk-queue' }
),
forwardRef(() => SubscriptionsModule),
forwardRef(() => CompaniesModule),
TaxValidationModule,
@@ -38,7 +40,7 @@ import { CompaniesModule } from '../companies/company.module';
JoFotaraGatewayService,
LocalStorageService,
],
controllers: [InvoicesController],
controllers: [InvoicesController, PublicInvoiceController],
exports: [InvoicesService],
})
export class InvoicesModule {}

View File

@@ -1,9 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoice Processor (Queue Consumer)
* مُصادَق (Musadaq) — Invoice Processor (Queue Consumer) / معالج الفواتير
* ════════════════════════════════════════════════════════════
* This is the main consumer for the invoice processing queue (Bull).
* It orchestrates AI extraction, tax validation, and database storage.
* المستهلك الرئيسي لطابور معالجة الفواتير (Bull Queue).
* يربط بين الذكاء الاصطناعي، التحقق الضريبي، وتوليد الـ XML.
* يقوم بالتنسيق بين استخراج البيانات بالذكاء الاصطناعي، التحقق الضريبي، وحفظ البيانات.
* ════════════════════════════════════════════════════════════
*/
@@ -50,36 +52,82 @@ export class InvoiceProcessor {
/**
* الخطوة الأولى: استخراج البيانات باستخدام AI
* Step 1: Extract data from the file using Gemini Multimodal AI
*/
@Process('extract-data')
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string }>) {
const { invoiceId, filePath } = job.data;
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string; companyId: string }>) {
const { invoiceId, filePath, tenantId, companyId } = job.data;
const storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
try {
// 1. Update status to EXTRACTING
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
// 2. Extract data via Gemini
const data = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
// 2. Extract data via Gemini (Now returns an object with invoices and usage)
const { invoices: invoicesData, usage } = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
// 3. Save extracted data in a transaction
await this.saveExtractedData(invoiceId, data);
if (!invoicesData || invoicesData.length === 0) {
throw new Error('No invoices found in file');
}
this.logger.log(`Extraction successful for invoice ${invoiceId}`);
// Calculate cost per invoice (pro-rated if multiple invoices in one file)
const costPerInvoice = this.calculateCost(usage) / invoicesData.length;
const promptTokensPerInvoice = Math.floor(usage.promptTokens / invoicesData.length);
const completionTokensPerInvoice = Math.floor(usage.completionTokens / invoicesData.length);
// 3. Process the first invoice (updates the current record)
await this.saveExtractedData(invoiceId, invoicesData[0], {
promptTokens: promptTokensPerInvoice,
completionTokens: completionTokensPerInvoice,
totalCost: costPerInvoice,
});
// 4. If multiple invoices found, create new records for others
if (invoicesData.length > 1) {
this.logger.log(`Found ${invoicesData.length} invoices in file ${filePath}. Creating additional records...`);
for (let i = 1; i < invoicesData.length; i++) {
const newInvoice = this.invoiceRepository.create({
tenant_id: tenantId,
company_id: companyId,
original_file_path: filePath,
status: InvoiceStatus.EXTRACTING,
});
const savedNew = await this.invoiceRepository.save(newInvoice);
await this.saveExtractedData(savedNew.id, invoicesData[i], {
promptTokens: promptTokensPerInvoice,
completionTokens: completionTokensPerInvoice,
totalCost: costPerInvoice,
});
}
}
this.logger.log(`Extraction successful for invoice(s) in ${filePath}`);
} catch (error) {
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATION_FAILED,
// Optional: Save error message in a notes column
});
throw error;
}
}
/**
* حفظ البيانات المستخرجة في قاعدة البيانات
* حساب تكلفة استخدام Gemini بناءً على التسعيرة الحالية
*/
private async saveExtractedData(invoiceId: string, data: any) {
private calculateCost(usage: any): number {
// Gemini 1.5 Flash-Lite Pricing (from user .env):
// Prompt: $0.10 / 1M tokens
// Completion: $0.40 / 1M tokens
const promptCost = (usage.promptTokens / 1000000) * 0.10;
const completionCost = (usage.completionTokens / 1000000) * 0.40;
return promptCost + completionCost;
}
/**
* حفظ البيانات المستخرجة في قاعدة البيانات
* Save the JSON data extracted by AI into the SQL database
*/
private async saveExtractedData(invoiceId: string, data: any, usage?: any) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
@@ -90,16 +138,24 @@ export class InvoiceProcessor {
invoice_number: data.invoice_number,
invoice_date: data.invoice_date,
invoice_type: data.invoice_type || 'cash',
ubl_type_code: data.ubl_type_code || '388',
payment_method_code: data.payment_method_code || '013',
invoice_category: data.invoice_category || 'simplified',
supplier_name: data.supplier_name,
supplier_tin: data.supplier_tin,
buyer_name: data.buyer_name,
buyer_tin: data.buyer_tin,
buyer_national_id: data.buyer_national_id,
subtotal: data.subtotal,
discount_total: data.discount_total,
tax_amount: data.tax_amount,
grand_total: data.grand_total,
currency_code: data.currency_code || 'JOD',
status: InvoiceStatus.EXTRACTED,
// Save AI Usage Metadata
ai_prompt_tokens: usage?.promptTokens || 0,
ai_completion_tokens: usage?.completionTokens || 0,
ai_total_cost: usage?.totalCost || 0,
});
// 2. Clear old lines if any (shouldn't happen on first extract)
@@ -137,6 +193,7 @@ export class InvoiceProcessor {
/**
* الخطوة الثانية: التحقق الضريبي التلقائي
* Step 2: Perform automatic tax rules validation (ISTD Standards)
*/
private async autoValidate(invoiceId: string) {
const invoice = await this.invoiceRepository.findOne({
@@ -149,11 +206,14 @@ export class InvoiceProcessor {
const result = this.taxValidation.validateInvoice(invoice);
if (result.isValid) {
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.VALIDATED });
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATED,
validation_errors: [],
});
} else {
await this.invoiceRepository.update(invoiceId, {
status: InvoiceStatus.VALIDATION_FAILED,
// Optional: Save detailed error list
validation_errors: result.errors,
});
}
}

View File

@@ -1,6 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Service
* مُصادَق (Musadaq) — Invoices Service / خدمة إدارة الفواتير
* ════════════════════════════════════════════════════════════
* This service handles the core logic for invoice management,
* including uploading, retrieval, and integration with JoFotara.
* تقوم هذه الخدمة بمعالجة العمليات الأساسية لإدارة الفواتير،
* بما في ذلك الرفع، الاسترجاع، والربط مع نظام جو فوترة.
* ════════════════════════════════════════════════════════════
*/
@@ -10,12 +15,17 @@ import {
ForbiddenException,
Inject,
forwardRef,
InternalServerErrorException,
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs';
import * as path from 'path';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
import { UserRole } from '../users/enums/role.enum';
import { LocalStorageService } from '../../services/storage/local-storage.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
import { UBLGeneratorService } from './ubl-generator.service';
@@ -40,21 +50,25 @@ export class InvoicesService {
/**
* رفع فاتورة جديدة وبدء المعالجة
* Upload a new invoice and initiate extraction/validation
*/
async upload(
tenantId: string,
user: any,
companyId: string,
file: Express.Multer.File,
): Promise<Invoice> {
const tenantId = user.tenantId;
const canUpload = await this.subscriptionsService.checkInvoiceLimit(tenantId);
if (!canUpload) {
throw new ForbiddenException('Monthly invoice limit reached for your current plan');
}
const company = await this.companiesService.findOne(tenantId, companyId);
const fileName = `${Date.now()}-${file.originalname}`;
const filePath = await this.localStorageService.saveFile(
tenantId,
companyId,
company.name,
fileName,
file.buffer,
);
@@ -81,20 +95,43 @@ export class InvoicesService {
/**
* قائمة الفواتير لشركة محددة
* Find all invoices for a specific company
*/
async findAll(tenantId: string, companyId: string): Promise<Invoice[]> {
async findAll(user: any, companyId: string): Promise<Invoice[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { company_id: companyId } : { tenant_id: user.tenantId, company_id: companyId };
return this.invoiceRepository.find({
where: { tenant_id: tenantId, company_id: companyId },
where: filter,
order: { created_at: 'DESC' },
});
}
/**
* تفاصيل فاتورة
* قائمة جميع الفواتير للمكتب (المستأجر)
* Find all invoices for the entire tenant (accounting office)
*/
async findOne(tenantId: string, id: string): Promise<Invoice> {
async findAllByTenant(user: any): Promise<Invoice[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
return this.invoiceRepository.find({
where: filter,
relations: ['company'],
order: { created_at: 'DESC' },
});
}
/**
* تفاصيل فاتورة محددة مع بنودها
* Get detailed information for a specific invoice including its lines
*/
async findOne(user: any, id: string): Promise<Invoice> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const invoice = await this.invoiceRepository.findOne({
where: { id, tenant_id: tenantId },
where: filter,
relations: ['lines'],
});
if (!invoice) throw new NotFoundException('Invoice not found');
@@ -103,9 +140,10 @@ export class InvoicesService {
/**
* تحديث بيانات الفاتورة يدوياً
* Manually update invoice data (only if not already submitted)
*/
async update(tenantId: string, id: string, updateData: any): Promise<Invoice> {
const invoice = await this.findOne(tenantId, id);
async update(user: any, id: string, updateData: any): Promise<Invoice> {
const invoice = await this.findOne(user, id);
if (invoice.status === InvoiceStatus.APPROVED || invoice.status === InvoiceStatus.SUBMITTING) {
throw new ForbiddenException('Cannot edit already approved or submitted invoice');
@@ -116,17 +154,18 @@ export class InvoicesService {
}
/**
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية (ISTD)
* Submit the validated invoice to the official JoFotara portal
*/
async submitToJoFotara(tenantId: string, id: string): Promise<any> {
const invoice = await this.findOne(tenantId, id);
async submitToJoFotara(user: any, id: string): Promise<any> {
const invoice = await this.findOne(user, id);
if (invoice.status !== InvoiceStatus.VALIDATED && invoice.status !== InvoiceStatus.VALIDATION_FAILED && invoice.status !== InvoiceStatus.EXTRACTED) {
throw new ForbiddenException('Invoice must be validated or extracted before submission');
}
const company = await this.companiesService.findOne(tenantId, invoice.company_id);
const credentials = await this.companiesService.getDecryptedCredentials(tenantId, invoice.company_id);
const company = await this.companiesService.findOne(user, invoice.company_id);
const credentials = await this.companiesService.getDecryptedCredentials(user, invoice.company_id);
const xml = this.ublGenerator.generateXML(invoice, company);
@@ -139,11 +178,51 @@ export class InvoicesService {
credentials.secretKey,
);
await this.invoiceRepository.update(id, { status: InvoiceStatus.APPROVED });
await this.invoiceRepository.update(id, {
status: InvoiceStatus.APPROVED,
qr_code: response.qrCode || response.qr_code || null,
});
return response;
} catch (error) {
await this.invoiceRepository.update(id, { status: InvoiceStatus.VALIDATION_FAILED });
throw error;
}
}
/**
* حذف الفاتورة والملف التابع لها نهائياً
* Permanently delete an invoice and its associated file from storage
*/
async remove(user: any, id: string): Promise<void> {
const invoice = await this.findOne(user, id);
// 1. Delete file if exists
if (invoice.original_file_path) {
await this.localStorageService.deleteFile(invoice.original_file_path);
}
// 2. Delete from DB (lines will be deleted via CASCADE)
await this.invoiceRepository.delete(id);
}
/**
* الحصول على الملف كـ Stream لتحميله أو عرضه
* Get the original invoice file as a streamable file for download/view
*/
async getFile(user: any, id: string): Promise<StreamableFile> {
const invoice = await this.findOne(user, id);
if (!invoice.original_file_path) {
throw new NotFoundException('Invoice file not found');
}
const storageRoot = this.localStorageService['storageRoot']; // Accessing private for path resolve
const fullPath = path.join(storageRoot, invoice.original_file_path);
if (!fs.existsSync(fullPath)) {
throw new NotFoundException('File does not exist on disk');
}
const file = fs.createReadStream(fullPath);
return new StreamableFile(file);
}
}

View File

@@ -1,7 +1,9 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — JoFotara Gateway Service
* مُصادَق (Musadaq) — JoFotara Gateway Service / بوابة جو فوترة
* ════════════════════════════════════════════════════════════
* This service handles communication with the Jordanian National
* Electronic Invoicing System (JoFotara) portal.
* يقوم بالتواصل مع بوابة دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
* يدير عملية تسجيل الفواتير والحصول على الـ Clearance أو الـ Reporting.
* ════════════════════════════════════════════════════════════
@@ -26,6 +28,7 @@ export class JoFotaraGatewayService {
/**
* إرسال الفاتورة إلى بوابة جو فوترة
* Submit an invoice (Base64 XML) to the JoFotara portal
*/
async submitInvoice(
xmlContent: string,
@@ -33,33 +36,54 @@ export class JoFotaraGatewayService {
secretKey: string,
): Promise<any> {
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
const maxRetries = 3;
let attempt = 0;
try {
const response = await axios.post(
`${url}/submit`,
{
while (attempt < maxRetries) {
try {
const payload = {
invoice: Buffer.from(xmlContent).toString('base64'),
},
{
headers: {
'Content-Type': 'application/json',
'X-Client-Id': clientId,
'X-Secret-Key': secretKey,
},
},
);
};
return response.data;
} catch (error: any) {
this.logger.error(`JoFotara API Error: ${error.response?.data || error.message}`);
throw new InternalServerErrorException(
`Failed to submit invoice to JoFotara: ${error.response?.data?.message || 'Unknown Error'}`,
);
this.logger.debug(`Submitting to JoFotara: ${url} (Attempt ${attempt + 1})`);
const response = await axios.post(
url,
payload,
{
headers: {
'Content-Type': 'application/json',
'X-Client-Id': clientId,
'X-Secret-Key': secretKey,
},
timeout: 10000, // 10s timeout
},
);
this.logger.log(`JoFotara Response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
attempt++;
const isTransient = !error.response || (error.response.status >= 500);
if (isTransient && attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000;
this.logger.warn(`Transient error from JoFotara. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
this.logger.error(`JoFotara API Error: ${error.response?.data || error.message}`);
throw new InternalServerErrorException(
`Failed to submit invoice to JoFotara: ${error.response?.data?.message || error.message}`,
);
}
}
}
/**
* التحقق من حالة الفاتورة
* التحقق من حالة الفاتورة باستخدام الـ UUID
* Check the status of a submitted invoice using its UUID
*/
async checkStatus(uuid: string, clientId: string, secretKey: string): Promise<any> {
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;

View File

@@ -0,0 +1,64 @@
import { Controller, Post, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { GeminiExtractorService } from './gemini-extractor.service';
import { UBLGeneratorService } from './ubl-generator.service';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@Controller('public/tools')
export class PublicInvoiceController {
constructor(
private geminiExtractor: GeminiExtractorService,
private ublGenerator: UBLGeneratorService,
) {}
/**
* حصان طروادة (Trojan Horse) - تحويل مجاني بدون تسجيل
* Converts a PDF/Image invoice directly to JoFotara XML
*/
@Post('pdf-to-xml')
@UseInterceptors(FileInterceptor('file'))
async convertPdfToXml(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('الرجاء إرفاق ملف الفاتورة');
}
// Save temporary file
const tempDir = os.tmpdir();
const tempPath = path.join(tempDir, `trojan_${Date.now()}_${file.originalname}`);
fs.writeFileSync(tempPath, file.buffer);
try {
// 1. Extract data using AI (treating as standard company for now)
// Since it's public, we use a generic path
const extractedData = await this.geminiExtractor.extractInvoiceData(tempPath, '');
if (!extractedData || extractedData.length === 0) {
throw new BadRequestException('لم يتم العثور على بيانات صالحة في الفاتورة');
}
const invoiceData = extractedData[0]; // Take the first invoice if multiple
// 2. Generate XML (using generic company info for demo purposes)
const mockCompanyInfo = {
name: invoiceData.supplier_name || 'شركة تجريبية',
tax_identification_number: invoiceData.supplier_tin || '0000000000',
};
const xml = this.ublGenerator.generateXML(invoiceData, mockCompanyInfo);
return {
success: true,
message: 'تم التحويل بنجاح! لطباعة أو إرسال آلاف الفواتير، جرب محاسبي إيليت.',
data: invoiceData,
xmlContent: xml,
};
} finally {
// Cleanup temp file
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
}

View File

@@ -1,7 +1,9 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — UBL 2.1 Generator Service
* مُصادَق (Musadaq) — UBL 2.1 Generator Service / خدمة توليد ملفات UBL
* ════════════════════════════════════════════════════════════
* This service generates XML files compliant with the UBL 2.1 standard
* required by the Jordanian Income and Sales Tax Department (ISTD).
* يقوم بإنشاء ملفات XML المتوافقة مع معيار UBL 2.1 المطلوبة من
* دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
* ════════════════════════════════════════════════════════════
@@ -15,8 +17,14 @@ import { Invoice } from './entities/invoice.entity';
export class UBLGeneratorService {
/**
* توليد UBL 2.1 XML لفاتورة مبيعات
* Generate a UBL 2.1 compliant XML for a sales invoice
*/
generateXML(invoice: Invoice, company: any): string {
generateXML(invoice: any, company: any): string {
const currency = invoice.currency_code || 'JOD';
const invoiceDate = invoice.invoice_date instanceof Date
? invoice.invoice_date.toISOString().split('T')[0]
: (invoice.invoice_date || new Date().toISOString().split('T')[0]);
const doc = create({ version: '1.0', encoding: 'UTF-8' })
.ele('Invoice', {
xmlns: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
@@ -24,11 +32,12 @@ export class UBLGeneratorService {
'xmlns:cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
})
.ele('cbc:UBLVersionID').txt('2.1').up()
.ele('cbc:CustomizationID').txt('TRX-1.0').up()
.ele('cbc:ProfileID').txt('reporting:1.0').up()
.ele('cbc:CustomizationID').txt('urn:www.cenbii.eu:transaction:biitrns010:ver2.0').up()
.ele('cbc:ID').txt(invoice.invoice_number || 'N/A').up()
.ele('cbc:IssueDate').txt(invoice.invoice_date?.toISOString().split('T')[0] || '').up()
.ele('cbc:InvoiceTypeCode').txt(invoice.ubl_type_code).up()
.ele('cbc:DocumentCurrencyCode').txt(invoice.currency_code).up()
.ele('cbc:IssueDate').txt(invoiceDate).up()
.ele('cbc:InvoiceTypeCode').txt(invoice.ubl_type_code || '388').up()
.ele('cbc:DocumentCurrencyCode').txt(currency).up()
// ── AccountingSupplierParty (المُصدر) ───────────────
.ele('cac:AccountingSupplierParty')
@@ -62,28 +71,41 @@ export class UBLGeneratorService {
// ── TaxTotal ───────────────────────────────────────
.ele('cac:TaxTotal')
.ele('cbc:TaxAmount', { currencyID: invoice.currency_code }).txt(invoice.tax_amount.toString()).up()
.ele('cbc:TaxAmount', { currencyID: currency }).txt(invoice.tax_amount.toString()).up()
.up()
// ── LegalMonetaryTotal ─────────────────────────────
.ele('cac:LegalMonetaryTotal')
.ele('cbc:LineExtensionAmount', { currencyID: invoice.currency_code }).txt(invoice.subtotal.toString()).up()
.ele('cbc:TaxExclusiveAmount', { currencyID: invoice.currency_code }).txt(invoice.subtotal.toString()).up()
.ele('cbc:TaxInclusiveAmount', { currencyID: invoice.currency_code }).txt(invoice.grand_total.toString()).up()
.ele('cbc:PayableAmount', { currencyID: invoice.currency_code }).txt(invoice.grand_total.toString()).up()
.ele('cbc:LineExtensionAmount', { currencyID: currency }).txt(invoice.subtotal.toString()).up()
.ele('cbc:TaxExclusiveAmount', { currencyID: currency }).txt(invoice.subtotal.toString()).up()
.ele('cbc:TaxInclusiveAmount', { currencyID: currency }).txt(invoice.grand_total.toString()).up()
.ele('cbc:PayableAmount', { currencyID: currency }).txt(invoice.grand_total.toString()).up()
.up();
// ── InvoiceLines ─────────────────────────────────────
invoice.lines.forEach((line) => {
invoice.lines.forEach((line: any) => {
doc.ele('cac:InvoiceLine')
.ele('cbc:ID').txt(line.line_number.toString()).up()
.ele('cbc:InvoicedQuantity', { unitCode: 'PCE' }).txt(line.quantity.toString()).up()
.ele('cbc:LineExtensionAmount', { currencyID: invoice.currency_code }).txt(line.line_total.toString()).up()
.ele('cbc:LineExtensionAmount', { currencyID: currency }).txt(line.line_total.toString()).up()
.ele('cac:Item')
.ele('cbc:Description').txt(line.description).up()
.up()
.ele('cac:Price')
.ele('cbc:PriceAmount', { currencyID: invoice.currency_code }).txt(line.unit_price.toString()).up()
.ele('cbc:PriceAmount', { currencyID: currency }).txt(line.unit_price.toString()).up()
.up()
.ele('cac:TaxTotal')
.ele('cbc:TaxAmount', { currencyID: currency }).txt((line.line_total * line.tax_rate).toFixed(3)).up()
.ele('cac:TaxSubtotal')
.ele('cbc:TaxableAmount', { currencyID: currency }).txt(line.line_total.toString()).up()
.ele('cbc:TaxAmount', { currencyID: currency }).txt((line.line_total * line.tax_rate).toFixed(3)).up()
.ele('cac:TaxCategory')
.ele('cbc:ID').txt(line.tax_rate > 0 ? 'S' : 'Z').up()
.ele('cbc:Percent').txt((line.tax_rate * 100).toString()).up()
.ele('cac:TaxScheme')
.ele('cbc:ID').txt('VAT').up()
.up()
.up()
.up()
.up();
});

View File

@@ -40,11 +40,8 @@ export class Subscription {
@JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({
type: 'enum',
enum: SubscriptionPlan,
})
plan!: SubscriptionPlan;
@Column({ type: 'varchar', length: 50, default: 'basic' })
plan!: string;
@Column({ type: 'int' })
max_companies!: number;
@@ -55,11 +52,7 @@ export class Subscription {
@Column({ type: 'decimal', precision: 10, scale: 2 })
price_jod!: number;
@Column({
type: 'enum',
enum: ['monthly', 'annual'],
default: 'monthly',
})
@Column({ type: 'varchar', length: 20, default: 'monthly' })
billing_cycle!: string;
@Column({ type: 'timestamp', nullable: true })
@@ -71,12 +64,8 @@ export class Subscription {
@Column({ type: 'int', default: 0 })
invoices_used_this_month!: number;
@Column({
type: 'enum',
enum: SubscriptionStatus,
default: SubscriptionStatus.ACTIVE,
})
status!: SubscriptionStatus;
@Column({ type: 'varchar', length: 50, default: 'active' })
status!: string;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;

View File

@@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
@Injectable()
export class TaxAdvisorService {
private readonly logger = new Logger(TaxAdvisorService.name);
private genAI: GoogleGenerativeAI;
private model: any;
constructor(private configService: ConfigService) {
const apiKey = this.configService.getOrThrow<string>('GEMINI_API_KEY');
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({
model: this.configService.get<string>('GEMINI_MODEL', 'gemini-1.5-flash'),
});
}
/**
* الإجابة على استفسارات القوانين الضريبية (RAG Placeholder)
* Answer tax law queries using a specialized system prompt
*/
async askQuestion(question: string): Promise<string> {
const systemPrompt = `
أنت "مستشار ضريبي أردني ذكي". مرجعيتك هي قانون ضريبة الدخل والمبيعات الأردني وتعديلات 2025.
قواعد أساسية للإجابة:
1. اعتمد على جداول السلع (16%, 10%, 4%, 2%, 0%, معفى).
2. وضح دائماً الفرق بين السلع الصفرية (تسمح بخصم المدخلات) والمعفاة (لا تسمح).
3. التزم بمتطلبات "جو فوترة" بخصوص فواتير الذمم والـ 10,000 دينار.
4. كن دقيقاً، مهنياً، ومختصراً.
`;
try {
const result = await this.model.generateContent([systemPrompt, question]);
return result.response.text();
} catch (error) {
this.logger.error('Tax Advisor query failed', error);
return 'عذراً، تعذر الحصول على إجابة حالياً. يرجى مراجعة القوانين الرسمية.';
}
}
}

View File

@@ -15,6 +15,7 @@ import {
Index,
} from 'typeorm';
import { Tenant } from '../../tenants/entities/tenant.entity';
import { Company } from '../../companies/entities/company.entity';
import { UserRole } from '../enums/role.enum';
@Entity('users')
@@ -45,6 +46,13 @@ export class User {
})
role!: UserRole;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
company_id?: string;
@ManyToOne(() => Company, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'company_id' })
company?: Company;
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
refresh_token_hash?: string;

View File

@@ -6,6 +6,8 @@
export enum UserRole {
ADMIN = 'admin', // مدير المكتب (قادر على الإضافة والتعديل الكامل)
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها)
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها لكل الشركات)
CLIENT = 'client', // عميل (قادر على رفع وإدارة فواتير شركته فقط)
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
SUPER_ADMIN = 'super_admin', // مدير النظام العام (قادر على رؤية كل شيء)
}

View File

@@ -26,24 +26,29 @@ export class UsersController {
constructor(private usersService: UsersService) {}
@Post()
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
async create(@CurrentUser() user: any, @Body() dto: any) {
return this.usersService.create(user.tenantId, dto);
return this.usersService.create(user, dto);
}
@Get()
async findAll(@CurrentUser() user: any) {
return this.usersService.findAll(user.tenantId);
return this.usersService.findAll(user);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
return this.usersService.findOne(user.tenantId, id);
return this.usersService.findOne(user, id);
}
@Delete(':id')
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
async remove(@CurrentUser() user: any, @Param('id') id: string) {
return this.usersService.remove(user.tenantId, id);
return this.usersService.remove(user, id, user.id);
}
@Post('profile')
async updateProfile(@CurrentUser() user: any, @Body() dto: any) {
return this.usersService.update(user.id, dto);
}
}

View File

@@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { UserRole } from './enums/role.enum';
@Injectable()
export class UsersService {
@@ -20,9 +21,11 @@ export class UsersService {
/**
* إضافة مستخدم لمكتب محاسبة
*/
async create(tenantId: string, dto: any): Promise<User> {
async create(user: any, dto: any): Promise<User> {
const tenantId = user.tenantId;
const normalizedEmail = dto.email?.trim().toLowerCase();
const existing = await this.userRepository.findOne({
where: { email: dto.email, tenant_id: tenantId },
where: { email: normalizedEmail, tenant_id: tenantId },
});
if (existing) {
@@ -31,21 +34,25 @@ export class UsersService {
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = this.userRepository.create({
const newUser = this.userRepository.create({
...dto,
email: normalizedEmail,
password_hash: passwordHash,
tenant_id: tenantId,
} as Partial<User>);
return this.userRepository.save(user);
return this.userRepository.save(newUser);
}
/**
* قائمة مستخدمي المكتب
*/
async findAll(tenantId: string): Promise<User[]> {
async findAll(user: any): Promise<User[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { is_active: true } : { tenant_id: user.tenantId, is_active: true };
return this.userRepository.find({
where: { tenant_id: tenantId, is_active: true },
where: filter,
order: { created_at: 'ASC' },
});
}
@@ -53,19 +60,54 @@ export class UsersService {
/**
* تفاصيل مستخدم
*/
async findOne(tenantId: string, id: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id, tenant_id: tenantId },
async findOne(user: any, id: string): Promise<User> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const foundUser = await this.userRepository.findOne({
where: filter,
});
if (!user) throw new NotFoundException('User not found');
return user;
if (!foundUser) throw new NotFoundException('User not found');
return foundUser;
}
/**
* تعطيل مستخدم
*/
async remove(tenantId: string, id: string): Promise<void> {
const user = await this.findOne(tenantId, id);
async remove(user: any, id: string, currentUserId: string): Promise<void> {
if (id === currentUserId) {
throw new ConflictException('لا يمكنك تعطيل حسابك الشخصي');
}
await this.findOne(user, id);
await this.userRepository.update(id, { is_active: false });
}
/**
* تحديث بيانات مستخدم
*/
async update(id: string, dto: any): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) throw new NotFoundException('User not found');
// Hash password if provided
if (dto.password) {
dto.password_hash = await bcrypt.hash(dto.password, 12);
delete dto.password;
}
if (dto.email) {
dto.email = dto.email.trim().toLowerCase();
}
Object.assign(user, dto);
try {
return await this.userRepository.save(user);
} catch (error: any) {
if (error.code === '23505') { // Postgres unique violation code
throw new ConflictException('البريد الإلكتروني مسجل مسبقاً لمستخدم آخر');
}
throw error;
}
}
}

View File

@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
import { Invoice } from '../invoices/entities/invoice.entity';
import { TaxValidationService, ValidationResult } from './tax-validation.service';
@Injectable()
export class JofotaraComplianceService {
private readonly logger = new Logger(JofotaraComplianceService.name);
constructor(private taxValidation: TaxValidationService) {}
/**
* الفحص الشامل للامتثال لمتطلبات جو فوترة
* Comprehensive JOFOTARA compliance check
*/
async checkCompliance(invoice: Invoice, credentials?: any): Promise<ValidationResult> {
const errors: string[] = [];
// 1. Schema Validation (التحقق من اكتمال الحقول الإلزامية)
this.validateSchema(invoice, errors);
// 2. Tax Rules Validation (التحقق من القواعد الحسابية)
const taxResult = this.taxValidation.validateInvoice(invoice);
if (!taxResult.isValid) {
errors.push(...taxResult.errors);
}
// 3. Credential Check (التحقق من وجود الربط مع الضريبة)
if (credentials && (!credentials.clientId || !credentials.secretKey)) {
errors.push('بيانات الربط مع نظام جو فوترة (Credentials) غير مكتملة لهذه الشركة');
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* التحقق من الحقول الإلزامية في صيغة الفاتورة
* Rule 008: Mandatory JOFOTARA Fields
*/
private validateSchema(invoice: Invoice, errors: string[]) {
if (!invoice.invoice_number) errors.push('رقم الفاتورة (Invoice Number) مطلوب');
if (!invoice.invoice_date) errors.push('تاريخ الفاتورة (Invoice Date) مطلوب');
if (!invoice.supplier_tin) errors.push('الرقم الضريبي للمورد (Supplier TIN) مطلوب');
if (invoice.supplier_tin && invoice.supplier_tin.length !== 10) {
errors.push('الرقم الضريبي للمورد يجب أن يتكون من 10 خانات');
}
// Check if lines exist
if (!invoice.lines || invoice.lines.length === 0) {
errors.push('يجب أن تحتوي الفاتورة على بند واحد على الأقل');
}
// Additional JoFotara specific checks
if (!['388', '381'].includes(invoice.ubl_type_code)) {
errors.push('نوع الفاتورة (UBL Type Code) غير مدعوم');
}
}
}

View File

@@ -6,9 +6,10 @@
import { Module } from '@nestjs/common';
import { TaxValidationService } from './tax-validation.service';
import { JofotaraComplianceService } from './jofotara-compliance.service';
@Module({
providers: [TaxValidationService],
exports: [TaxValidationService],
providers: [TaxValidationService, JofotaraComplianceService],
exports: [TaxValidationService, JofotaraComplianceService],
})
export class TaxValidationModule {}

View File

@@ -1,9 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Tax Validation Service
* مُصادَق (Musadaq) — Tax Validation Service / محرك التدقيق الضريبي
* ════════════════════════════════════════════════════════════
* Jordanian Tax Rules Validation Engine (ISTD Rules).
* Ensures calculation accuracy before submission to JoFotara.
* محرك التحقق من القواعد الضريبية الأردنية (ISTD Rules).
* يضمن دقة الحسابات قبل إرسالها إلى "جو فوترة".
* يضمن دقة الحسابات والامتثال القانوني قبل إرسال البيانات إلى "جو فوترة".
* ════════════════════════════════════════════════════════════
*/
@@ -21,7 +23,8 @@ export class TaxValidationService {
private readonly PRECISION = 0.005; // السماحية في الفروقات العشرية البسيطة
/**
* التحقق الشامل من الفاتورة
* التحقق الشامل من الفاتورة وفق القواعد المبرمجة
* Comprehensive invoice validation based on predefined tax rules
*/
validateInvoice(invoice: Invoice): ValidationResult {
const errors: string[] = [];
@@ -38,12 +41,35 @@ export class TaxValidationService {
// 4. Rule 004: التحقق من المجموع النهائي (Grand Total)
this.checkRule004(invoice, errors);
// 5. Rule 005: التحقق من صحة نسب الضريبة الأردنية
this.checkRule005(invoice, errors);
// 6. Rule 006: التحقق من وجود هوية المشتري (للذمم أو المبيعات > 10,000)
this.checkRule006(invoice, errors);
// 7. Rule 007: التحقق من مطابقة مجموع البنود للمجموع النهائي
this.checkRule007(invoice, errors);
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Rule 005: Valid Jordanian Tax Rates (0.16, 0.04, 0.00, etc.)
*/
private checkRule005(invoice: Invoice, errors: string[]) {
// المراجع القانونية: 16% (عامة)، 10%، 5%، 4%، 2% (مخفضة)، 0% (صفرية)
const validRates = [0.16, 0.10, 0.05, 0.04, 0.02, 0.00, 0.01];
invoice.lines.forEach((line) => {
const rate = Number(line.tax_rate);
if (!validRates.includes(rate)) {
errors.push(`خطأ في القاعدة 005: نسبة الضريبة (${rate * 100}%) في البند ${line.line_number} غير مطابقة للنسب المعتمدة`);
}
});
}
/**
* Rule 001: Σ (Quantity * UnitPrice) = Subtotal (Before Tax/Discount)
*/
@@ -53,14 +79,14 @@ export class TaxValidationService {
0,
);
if (Math.abs(calculatedSubtotal - Number(invoice.subtotal)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal}) لا يطابق المجموع الفرعي المسجل (${invoice.subtotal})`);
const diff = Math.abs(calculatedSubtotal - Number(invoice.subtotal));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal.toFixed(3)}) لا يطابق المجموع الفرعي المسجل (${Number(invoice.subtotal).toFixed(3)})`);
}
}
/**
* Rule 002: TaxAmount = (Subtotal - Discount) * TaxRate
* ملاحظة: يجب التحقق لكل بند بشكل منفصل أو للمجموع حسب نوع الفاتورة
* Rule 002: TaxAmount = Σ (LineBeforeTax * TaxRate)
*/
private checkRule002(invoice: Invoice, errors: string[]) {
const calculatedTax = invoice.lines.reduce(
@@ -71,8 +97,9 @@ export class TaxValidationService {
0,
);
if (Math.abs(calculatedTax - Number(invoice.tax_amount)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${invoice.tax_amount})`);
const diff = Math.abs(calculatedTax - Number(invoice.tax_amount));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${Number(invoice.tax_amount).toFixed(3)})`);
}
}
@@ -85,8 +112,9 @@ export class TaxValidationService {
0,
);
if (Math.abs(totalLineDiscounts - Number(invoice.discount_total)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts}) لا يطابق إجمالي الخصم (${invoice.discount_total})`);
const diff = Math.abs(totalLineDiscounts - Number(invoice.discount_total));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts.toFixed(3)}) لا يطابق إجمالي الخصم (${Number(invoice.discount_total).toFixed(3)})`);
}
}
@@ -96,8 +124,44 @@ export class TaxValidationService {
private checkRule004(invoice: Invoice, errors: string[]) {
const calculatedGrandTotal = Number(invoice.subtotal) - Number(invoice.discount_total) + Number(invoice.tax_amount);
if (Math.abs(calculatedGrandTotal - Number(invoice.grand_total)) > this.PRECISION) {
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${invoice.grand_total})`);
const diff = Math.abs(calculatedGrandTotal - Number(invoice.grand_total));
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${Number(invoice.grand_total).toFixed(3)})`);
}
}
/**
* Rule 006: Buyer Identity Requirement
* - Cash Invoices >= 10,000 JOD
* - Credit/Receivable Invoices (All amounts)
*/
private checkRule006(invoice: Invoice, errors: string[]) {
const isHighValue = Number(invoice.grand_total) >= 10000;
const isCredit = invoice.payment_method_code === '023'; // 023 is usually credit in JoFotara
if (isHighValue || isCredit) {
if (!invoice.buyer_tin && !invoice.buyer_national_id) {
const reason = isCredit ? 'فاتورة ذمم (Credit)' : 'فاتورة نقدية تتجاوز 10,000 دينار';
errors.push(`خطأ في القاعدة 006: يجب تزويد الرقم الضريبي أو الوطني للمشتري لأنها ${reason}`);
}
}
}
/**
* Rule 007: Line Totals Integrity
* Ensure Σ (LineTotal) matches Header (Subtotal - DiscountTotal)
*/
private checkRule007(invoice: Invoice, errors: string[]) {
const totalLinesBeforeTax = invoice.lines.reduce(
(sum, line) => sum + Number(line.line_total),
0,
);
const headerBeforeTax = Number(invoice.subtotal) - Number(invoice.discount_total);
const diff = Math.abs(totalLinesBeforeTax - headerBeforeTax);
if (diff > this.PRECISION) {
errors.push(`خطأ في القاعدة 007: مجموع قيم البنود قبل الضريبة (${totalLinesBeforeTax.toFixed(3)}) لا يطابق الإجمالي قبل الضريبة في ترويسة الفاتورة (${headerBeforeTax.toFixed(3)})`);
}
}
}

View File

@@ -1,6 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Local Storage Service
* مُصادَق (Musadaq) — Local Storage Service / خدمة التخزين المحلي
* ════════════════════════════════════════════════════════════
* This service manages saving and deleting files on the local filesystem.
* Files are organized by tenant, company, and date.
* تدير هذه الخدمة عمليات حفظ وحذف الملفات على نظام الملفات المحلي.
* يتم تنظيم الملفات حسب المكتب، الشركة، والتاريخ.
* ════════════════════════════════════════════════════════════
*/
@@ -23,19 +28,24 @@ export class LocalStorageService {
}
/**
* حفظ ملف في التخزين المحلي
* حفظ ملف في التخزين المحلي بتنظيم هيكلي
* Save a file to local storage with a structured directory path
*/
async saveFile(
tenantId: string,
companyId: string,
companyName: string,
fileName: string,
buffer: Buffer,
): Promise<string> {
try {
const now = new Date();
// Safe company name (remove special characters)
const safeCompanyName = companyName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const relativePath = path.join(
tenantId,
companyId,
`${safeCompanyName}_${companyId}`,
'invoices',
now.getFullYear().toString(),
(now.getMonth() + 1).toString(),
@@ -59,7 +69,8 @@ export class LocalStorageService {
}
/**
* حذف ملف
* حذف ملف من التخزين باستخدام مساره النسبي
* Delete a file from storage using its relative path
*/
async deleteFile(filePath: string): Promise<void> {
try {

56
db-fix.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
echo "🛠️ Applying manual schema updates to PostgreSQL..."
DOCKER_CMD="docker compose"
if ! docker compose version > /dev/null 2>&1; then
DOCKER_CMD="docker-compose"
fi
DB_CONTAINER=$($DOCKER_CMD ps -q db)
if [ -z "$DB_CONTAINER" ]; then
echo "❌ Database container is not running!"
exit 1
fi
if [ -f ".env" ]; then
source .env
else
echo "⚠️ .env file not found. Using default values..."
DB_USER="musadaq_user"
DB_NAME="musadaq_db"
fi
echo "📦 Database container found. Executing ALTER statements..."
docker exec -i $DB_CONTAINER psql -U $DB_USER -d $DB_NAME <<EOF
-- Add super_admin to roles enum if it doesn't exist
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = 'users_role_enum' AND e.enumlabel = 'super_admin') THEN
ALTER TYPE "users_role_enum" ADD VALUE 'super_admin';
END IF;
END \$\$;
-- Set the main user as Super Admin
UPDATE "users" SET "role" = 'super_admin' WHERE "email" = 'hamzaayed1@tripz-egypt.com';
-- Add missing columns to User
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "company_id" uuid;
-- Add missing columns to Invoice
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "qr_code" text;
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ai_confidence_score" numeric(4,3);
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ai_prompt_tokens" integer DEFAULT 0;
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ai_completion_tokens" integer DEFAULT 0;
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ai_total_cost" numeric(10,6) DEFAULT 0;
-- Add missing columns to Company
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "certificate_path" varchar(255);
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "certificate_password_encrypted" varchar(500);
EOF
echo "✅ Schema updates applied successfully!"

12
fix-limit.js Normal file
View File

@@ -0,0 +1,12 @@
const { Pool } = require('pg');
const pool = new Pool({
user: 'postgres',
host: '127.0.0.1',
database: 'musadaq',
password: 'postgres_password',
port: 5432,
});
pool.query("UPDATE subscriptions SET max_companies = -1", (err, res) => {
console.log(err ? err : "Updated successfully!");
pool.end();
});

View File

@@ -6,6 +6,14 @@ import RegisterPage from './pages/auth/RegisterPage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { InvoicesPage } from './pages/invoices/InvoicesPage';
import { CompaniesPage } from './pages/companies/CompaniesPage';
import { StaffPage } from './pages/staff/StaffPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { MultiEntityDashboard } from './pages/dashboard/MultiEntityDashboard';
import { RiskMonitorPage } from './pages/dashboard/RiskMonitorPage';
import { TrojanHorseConverter } from './pages/Public/TrojanHorseConverter';
// ── Protected Route Guard ─────────────────────────────────
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -19,15 +27,18 @@ export default function App() {
{/* Public Routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/free-converter" element={<TrojanHorseConverter />} />
{/* Protected Dashboard Routes */}
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="elite-dashboard" element={<MultiEntityDashboard />} />
<Route path="risk-monitor" element={<RiskMonitorPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="companies" element={<div className="text-3xl font-bold">إدارة الشركات</div>} />
<Route path="staff" element={<div className="text-3xl font-bold">إدارة الموظفين</div>} />
<Route path="settings" element={<div className="text-3xl font-bold">الإعدادات</div>} />
<Route path="companies" element={<CompaniesPage />} />
<Route path="staff" element={<StaffPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
{/* Fallback */}

View File

@@ -1,6 +1,6 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Main Layout Shell
* مُصادَق (Musadaq) — Premium Dark Layout Shell
* ════════════════════════════════════════════════════════════
*/
@@ -13,36 +13,36 @@ export const MainLayout = () => {
const user = useAuthStore((state) => state.user);
return (
<div className="flex bg-slate-50 min-h-screen rtl overflow-hidden">
<div className="flex bg-slate-950 min-h-screen rtl overflow-hidden">
{/* ── Desktop Sidebar ───────────────────────────────────── */}
<Sidebar />
<div className="flex-1 flex flex-col h-screen overflow-y-auto">
{/* ── Top Navigation ──────────────────────────────────── */}
<header className="h-16 bg-white/80 backdrop-blur-md sticky top-0 z-30 border-b border-slate-100 px-8 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-4 bg-slate-50 px-4 py-2 rounded-xl group focus-within:ring-2 focus-within:ring-primary-100 transition-all border border-transparent focus-within:border-primary-200">
<Search className="w-4 h-4 text-slate-400" />
<header className="h-16 bg-slate-900/80 backdrop-blur-xl sticky top-0 z-30 border-b border-slate-800/60 px-8 flex items-center justify-between">
<div className="flex items-center gap-4 bg-slate-800/50 px-4 py-2 rounded-xl group focus-within:ring-2 focus-within:ring-emerald-500/20 transition-all border border-slate-700/50 focus-within:border-emerald-500/30">
<Search className="w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="بحث سريع..."
className="bg-transparent border-none outline-none text-sm w-64 text-slate-900"
className="bg-transparent border-none outline-none text-sm w-64 text-slate-300 placeholder-slate-500"
/>
</div>
<div className="flex items-center gap-6">
<button className="p-2 text-slate-400 hover:bg-slate-50 hover:text-primary-600 rounded-xl transition-all relative">
<button className="p-2 text-slate-400 hover:bg-slate-800 hover:text-emerald-400 rounded-xl transition-all relative">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span>
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-emerald-500 rounded-full border-2 border-slate-900"></span>
</button>
<div className="flex items-center gap-3 pl-2 border-r border-slate-100">
<div className="flex items-center gap-3 pl-2 border-r border-slate-800">
<div className="text-left">
<p className="text-sm font-semibold text-slate-900">{user?.name}</p>
<p className="text-[12px] text-slate-500 uppercase tracking-wider font-medium">
<p className="text-sm font-semibold text-white">{user?.name}</p>
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-medium">
{user?.role}
</p>
</div>
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center border-2 border-white shadow-sm ring-1 ring-slate-100">
<div className="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center border-2 border-slate-700 ring-1 ring-slate-700/50">
<User className="text-slate-400 w-5 h-5" />
</div>
</div>

View File

@@ -1,42 +1,67 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Premium Sidebar
* مُصادَق (Musadaq) — Premium Dark Sidebar
* ════════════════════════════════════════════════════════════
*/
import { NavLink } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
FileText,
Building2,
Users,
Settings,
LogOut
LogOut,
Crown,
AlertTriangle
} from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
const menuItems = [
const getMenuItems = (role: string | undefined) => {
const isAdmin = role && ['admin', 'super_admin'].includes(role.toLowerCase());
return [
{ icon: LayoutDashboard, label: 'الرئيسية', path: '/dashboard' },
...(isAdmin ? [
{ icon: Crown, label: 'المركز الضريبي الموحد', path: '/elite-dashboard' },
{ icon: AlertTriangle, label: 'مراقبة المخاطر', path: '/risk-monitor' }
] : []),
{ icon: FileText, label: 'الفواتير', path: '/invoices' },
{ icon: Building2, label: 'الشركات', path: '/companies' },
{ icon: Users, label: 'الموظفون', path: '/staff' },
...(isAdmin ? [
{ icon: Users, label: 'الموظفون', path: '/staff' }
] : []),
{ icon: Settings, label: 'الإعدادات', path: '/settings' },
];
};
export const Sidebar = () => {
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const clearAuth = useAuthStore((state) => state.clearAuth);
const menuItems = getMenuItems(user?.role);
const handleLogout = () => {
clearAuth();
navigate('/login');
};
return (
<aside className="w-64 h-screen glass border-l border-slate-200 sticky top-0 flex flex-col p-4">
<aside className="w-64 h-screen bg-slate-950 border-l border-slate-800/60 sticky top-0 flex flex-col p-4">
{/* Brand */}
<div className="flex items-center gap-3 px-2 py-6">
<div className="w-10 h-10 bg-primary-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/30">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
<FileText className="text-white w-6 h-6" />
</div>
<h1 className="text-xl font-bold bg-gradient-to-br from-slate-900 to-slate-500 bg-clip-text text-transparent">
مُصادَق
</h1>
<div>
<h1 className="text-xl font-bold text-white">
مُصادَق
</h1>
<p className="text-[10px] text-slate-500 font-medium tracking-wider">ELITE ACCOUNTANT HUB</p>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 mt-4 space-y-1">
{menuItems.map((item) => (
<NavLink
@@ -45,8 +70,8 @@ export const Sidebar = () => {
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
isActive
? 'bg-primary-50 text-primary-600 shadow-sm border border-primary-100'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200 border border-transparent'
}`
}
>
@@ -56,10 +81,11 @@ export const Sidebar = () => {
))}
</nav>
<div className="pt-4 border-t border-slate-100">
{/* Logout */}
<div className="pt-4 border-t border-slate-800/60">
<button
onClick={clearAuth}
className="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-red-500 hover:bg-red-50 transition-all group"
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-red-400 hover:bg-red-500/10 transition-all group border border-transparent hover:border-red-500/20"
>
<LogOut className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">تسجيل الخروج</span>

View File

@@ -19,25 +19,25 @@
@layer base {
body {
@apply bg-slate-50 text-slate-900 antialiased;
@apply bg-slate-950 text-slate-300 antialiased;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
h1, h2, h3, h4, h5, h6 {
@apply font-semibold tracking-tight text-slate-900;
@apply font-semibold tracking-tight text-white;
}
}
@layer components {
.glass {
@apply bg-white/70 backdrop-blur-md border border-white/20 shadow-xl;
@apply bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 shadow-xl;
}
.card-premium {
@apply bg-white border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 rounded-xl overflow-hidden;
@apply bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 shadow-sm hover:shadow-md hover:border-slate-700 transition-all duration-200 rounded-xl overflow-hidden;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition-all active:scale-95;
@apply bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm shadow-emerald-500/20 transition-all active:scale-95;
}
}

View File

@@ -0,0 +1,265 @@
import { useState, useRef, type DragEvent } from 'react';
import { UploadCloud, CheckCircle2, ArrowRight, AlertCircle, Loader2, Download } from 'lucide-react';
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || '/api';
export const TrojanHorseConverter = () => {
const [dragActive, setDragActive] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [xmlContent, setXmlContent] = useState<string>('');
const [extractedData, setExtractedData] = useState<any>(null);
const [errorMessage, setErrorMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
setFile(e.dataTransfer.files[0]);
}
};
const handleConvert = async () => {
if (!file) return;
setStatus('uploading');
setErrorMessage('');
try {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post(`${API_URL}/public/tools/pdf-to-xml`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (response.data.success) {
setXmlContent(response.data.xmlContent);
setExtractedData(response.data.data);
setStatus('success');
} else {
setErrorMessage(response.data.message || 'حدث خطأ أثناء التحويل');
setStatus('error');
}
} catch (err: any) {
setErrorMessage(
err.response?.data?.message || 'فشل الاتصال بالخادم. تأكد من رفع ملف صالح.'
);
setStatus('error');
}
};
const handleDownloadXml = () => {
if (!xmlContent) return;
const blob = new Blob([xmlContent], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `invoice_${Date.now()}.xml`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleReset = () => {
setStatus('idle');
setFile(null);
setXmlContent('');
setExtractedData(null);
setErrorMessage('');
};
return (
<div className="min-h-screen bg-slate-950 text-slate-300 font-sans selection:bg-emerald-500/30">
{/* Top Banner */}
<div className="bg-gradient-to-r from-emerald-900/40 to-slate-900 text-center py-3 text-sm text-slate-400 border-b border-slate-800">
هل تدير عشرات الشركات؟{' '}
<Link to="/login" className="text-emerald-400 font-medium hover:text-emerald-300 transition-colors">
اكتشف لوحة تحكم محاسبي إيليت <ArrowRight className="inline w-4 h-4" />
</Link>
</div>
<div className="max-w-4xl mx-auto px-6 py-20">
{/* Header */}
<div className="text-center mb-16">
<div className="inline-block px-4 py-1.5 rounded-full bg-emerald-500/10 text-emerald-400 text-xs font-bold mb-6 border border-emerald-500/20">
أداة مجانية 100% بدون تسجيل
</div>
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight mb-6 text-white">
حوّل فواتيرك إلى صيغة <span className="text-emerald-400">جو فوترة</span> في ثوانٍ
</h1>
<p className="text-lg text-slate-400 max-w-2xl mx-auto leading-relaxed">
قم برفع فاتورة الـ PDF الخاصة بك وسيقوم الذكاء الاصطناعي باستخراج البيانات وتحويلها إلى صيغة XML المتوافقة تماماً مع نظام دائرة ضريبة الدخل والمبيعات الأردنية.
</p>
</div>
{/* Upload Area */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-slate-900/50 backdrop-blur-xl rounded-3xl shadow-2xl p-8 border border-slate-800"
>
{status === 'idle' && (
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-all cursor-pointer ${
dragActive ? 'border-emerald-500 bg-emerald-500/5' : 'border-slate-700 hover:border-slate-600'
}`}
onClick={() => !file && fileInputRef.current?.click()}
>
<UploadCloud className={`w-16 h-16 mx-auto mb-6 transition-colors ${dragActive ? 'text-emerald-400' : 'text-slate-600'}`} />
{file ? (
<div>
<p className="text-lg font-medium text-white mb-2">{file.name}</p>
<p className="text-sm text-slate-500 mb-8">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
<button
onClick={(e) => { e.stopPropagation(); handleConvert(); }}
className="px-8 py-3.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all"
>
بدء التحويل إلى XML
</button>
</div>
) : (
<div>
<p className="text-xl font-medium text-white mb-2">اسحب وأفلت الفاتورة هنا</p>
<p className="text-sm text-slate-500 mb-8">يدعم PDF, PNG, JPG</p>
<span className="px-8 py-3.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 rounded-xl font-bold shadow-lg shadow-emerald-500/30 transition-all inline-block">
اختر ملف
</span>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => e.target.files && setFile(e.target.files[0])}
accept=".pdf,.png,.jpg,.jpeg"
/>
</div>
)}
</div>
)}
{status === 'uploading' && (
<div className="py-20 text-center">
<Loader2 className="w-16 h-16 text-emerald-400 animate-spin mx-auto mb-6" />
<h3 className="text-xl font-medium text-white mb-2">جاري استخراج البيانات...</h3>
<p className="text-slate-400">يقوم الذكاء الاصطناعي بقراءة الفاتورة وتنسيقها. قد يستغرق الأمر عدة ثوانٍ.</p>
</div>
)}
{status === 'error' && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="py-12 text-center"
>
<div className="w-20 h-20 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6 border border-red-500/20">
<AlertCircle className="w-10 h-10 text-red-400" />
</div>
<h3 className="text-2xl font-bold text-white mb-4">حدث خطأ</h3>
<p className="text-slate-400 mb-8 max-w-md mx-auto">{errorMessage}</p>
<button
onClick={handleReset}
className="px-6 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl font-medium border border-slate-700 transition-all"
>
حاول مرة أخرى
</button>
</motion.div>
)}
{status === 'success' && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="py-12 text-center"
>
<div className="w-20 h-20 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto mb-6 border border-emerald-500/20">
<CheckCircle2 className="w-10 h-10 text-emerald-400" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">تم التحويل بنجاح!</h3>
{/* Extracted Data Preview */}
{extractedData && (
<div className="mt-6 mb-8 bg-slate-800/50 rounded-xl p-4 text-right max-w-md mx-auto border border-slate-700/50">
<div className="grid grid-cols-2 gap-3 text-sm">
{extractedData.supplier_name && (
<>
<span className="text-slate-500">المورد:</span>
<span className="text-white font-medium">{extractedData.supplier_name}</span>
</>
)}
{extractedData.invoice_number && (
<>
<span className="text-slate-500">رقم الفاتورة:</span>
<span className="text-white font-medium">{extractedData.invoice_number}</span>
</>
)}
{extractedData.grand_total && (
<>
<span className="text-slate-500">المبلغ الإجمالي:</span>
<span className="text-emerald-400 font-bold">{Number(extractedData.grand_total).toFixed(3)} JOD</span>
</>
)}
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={handleDownloadXml}
className="px-6 py-3.5 bg-emerald-500 hover:bg-emerald-600 text-slate-950 rounded-xl font-bold flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20 transition-all"
>
<Download className="w-5 h-5" /> تحميل ملف XML
</button>
<button
onClick={handleReset}
className="px-6 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl font-medium border border-slate-700 transition-all"
>
تحويل فاتورة أخرى
</button>
</div>
{/* Upsell Banner for Accountants */}
<div className="mt-12 bg-gradient-to-r from-slate-800 to-slate-900 rounded-2xl p-6 text-right flex flex-col md:flex-row items-center justify-between gap-6 border border-slate-700/50">
<div>
<h4 className="text-white font-bold mb-1 flex items-center gap-2">
<span className="text-emerald-400">مُصادَق</span> | هل أنت محاسب؟
</h4>
<p className="text-slate-400 text-sm">توقف عن تحويل الفواتير واحدة تلو الأخرى. جرب لوحة تحكم إيليت وأتمت عملك لـ 50 شركة بنقرة واحدة.</p>
</div>
<Link
to="/register"
className="whitespace-nowrap px-6 py-2.5 bg-emerald-500 text-slate-950 rounded-lg font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-500/20"
>
اكتشف إيليت
</Link>
</div>
</motion.div>
)}
</motion.div>
</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Premium Login Page
* مُصادَق (Musadaq) — Premium Dark Login Page
* ════════════════════════════════════════════════════════════
*/
@@ -10,7 +10,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { motion, AnimatePresence } from 'framer-motion';
import { LogIn, Mail, Lock, AlertCircle, Loader2 } from 'lucide-react';
import { LogIn, Mail, Lock, AlertCircle, Loader2, Zap } from 'lucide-react';
import apiClient from '../../api/client';
import { useAuthStore } from '../../store/authStore';
@@ -47,24 +47,24 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 relative overflow-hidden rtl">
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6 relative overflow-hidden rtl">
{/* ── Background Aesthetics ────────────────────────────────── */}
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-primary-300 rounded-full blur-[120px]" />
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-blue-300 rounded-full blur-[120px]" />
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-emerald-500/5 rounded-full blur-[150px]" />
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-blue-500/5 rounded-full blur-[150px]" />
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md glass p-10 rounded-3xl shadow-2xl relative z-10"
className="w-full max-w-md bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 p-10 rounded-3xl shadow-2xl relative z-10"
>
<div className="text-center mb-10">
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-primary-500/20">
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-emerald-500/20">
<LogIn className="text-white w-8 h-8" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-2">أهلاً بك في مُصادَق</h1>
<p className="text-slate-500">منصة أتمتة الفواتير الضريبية الأردنية</p>
<h1 className="text-3xl font-bold text-white mb-2">أهلاً بك في مُصادَق</h1>
<p className="text-slate-400">منصة أتمتة الفواتير الضريبية الأردنية</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@@ -74,7 +74,7 @@ export default function LoginPage() {
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="bg-red-50 border border-red-100 p-4 rounded-xl flex items-center gap-3 text-red-600 text-sm font-medium"
className="bg-red-500/10 border border-red-500/20 p-4 rounded-xl flex items-center gap-3 text-red-400 text-sm font-medium"
>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
@@ -83,47 +83,58 @@ export default function LoginPage() {
</AnimatePresence>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">البريد الإلكتروني</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">البريد الإلكتروني</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
<input
{...register('email')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 pl-4 pr-11 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="name@company.com"
/>
</div>
{errors.email && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
{errors.email && <p className="text-red-400 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">كلمة المرور</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">كلمة المرور</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
<input
{...register('password')}
type="password"
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 pl-4 pr-11 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="••••••••"
/>
</div>
{errors.password && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
{errors.password && <p className="text-red-400 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full btn-primary h-14 text-lg mt-4 flex items-center justify-center gap-3 shadow-lg shadow-primary-500/25"
className="w-full bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white h-14 text-lg rounded-xl font-bold mt-4 flex items-center justify-center gap-3 shadow-lg shadow-emerald-500/20 transition-all disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : 'تسجيل الدخول'}
</button>
</form>
<div className="mt-8 text-center text-slate-500 text-sm">
<div className="mt-8 text-center text-slate-400 text-sm">
ليس لديك حساب؟{' '}
<Link to="/register" className="text-primary-600 font-bold hover:underline">
<Link to="/register" className="text-emerald-400 font-bold hover:underline">
أنشئ حساباً جديداً
</Link>
</div>
{/* Trojan Horse Marketing Link */}
<div className="mt-6 pt-6 border-t border-slate-800/60 text-center">
<Link
to="/free-converter"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-emerald-500/10 text-emerald-400 rounded-xl font-bold hover:bg-emerald-500/20 transition-colors w-full border border-emerald-500/20"
>
<Zap className="w-4 h-4" />
جرب أداة تحويل فواتير PDF إلى XML مجاناً
</Link>
</div>
</motion.div>
</div>
);

View File

@@ -1,6 +1,6 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Premium Register Page
* مُصادَق (Musadaq) — Premium Dark Register Page
* ════════════════════════════════════════════════════════════
*/
@@ -51,24 +51,24 @@ export default function RegisterPage() {
};
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 relative overflow-hidden rtl font-sans">
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6 relative overflow-hidden rtl font-sans">
{/* ── Background Aesthetics ────────────────────────────────── */}
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-primary-300 rounded-full blur-[120px]" />
<div className="absolute bottom-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-300 rounded-full blur-[120px]" />
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-emerald-500/5 rounded-full blur-[150px]" />
<div className="absolute bottom-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-500/5 rounded-full blur-[150px]" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-lg glass p-10 rounded-3xl shadow-2xl relative z-10"
className="w-full max-w-lg bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 p-10 rounded-3xl shadow-2xl relative z-10"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-primary-500/20">
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-emerald-500/20">
<UserPlus className="text-white w-8 h-8" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-2">إنشاء حساب جديد</h1>
<p className="text-slate-500">ابدأ رحلتك في أتمتة الفواتير الضريبية</p>
<h1 className="text-3xl font-bold text-white mb-2">إنشاء حساب جديد</h1>
<p className="text-slate-400">ابدأ رحلتك في أتمتة الفواتير الضريبية</p>
</div>
{/* ── Progress Indicator ─────────────────────────────────── */}
@@ -76,7 +76,7 @@ export default function RegisterPage() {
{[1, 2].map((i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all duration-300 ${step >= i ? 'w-12 bg-primary-600' : 'w-4 bg-slate-200'}`}
className={`h-1.5 rounded-full transition-all duration-300 ${step >= i ? 'w-12 bg-emerald-500' : 'w-4 bg-slate-700'}`}
/>
))}
</div>
@@ -92,25 +92,25 @@ export default function RegisterPage() {
className="space-y-6"
>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">اسم مكتب المحاسبة</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">اسم مكتب المحاسبة</label>
<div className="relative group">
<Building className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<Building className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
<input
{...register('tenantName')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 pl-4 pr-11 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="شركة الفوترة للمحاسبة"
/>
</div>
{errors.tenantName && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.tenantName.message}</p>}
{errors.tenantName && <p className="text-red-400 text-[12px] mt-1 mr-1">{errors.tenantName.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">رقم الهاتف (اختياري)</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">رقم الهاتف (اختياري)</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
<input
{...register('phone')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 pl-4 pr-11 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="079 XXXXXXX"
/>
</div>
@@ -119,7 +119,7 @@ export default function RegisterPage() {
<button
type="button"
onClick={nextStep}
className="w-full btn-primary h-14 text-lg flex items-center justify-center gap-3"
className="w-full bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white h-14 text-lg rounded-xl font-bold flex items-center justify-center gap-3 shadow-lg shadow-emerald-500/20 transition-all"
>
التالي
<ArrowLeft className="w-5 h-5" />
@@ -134,49 +134,49 @@ export default function RegisterPage() {
className="space-y-6"
>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">الاسم الكامل (للمدير)</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">الاسم الكامل (للمدير)</label>
<div className="relative group">
<input
{...register('name')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 px-4 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 px-4 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="أحمد محمد"
/>
</div>
{errors.name && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.name.message}</p>}
{errors.name && <p className="text-red-400 text-[12px] mt-1 mr-1">{errors.name.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">البريد الإلكتروني</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">البريد الإلكتروني</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
<input
{...register('email')}
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 pl-4 pr-11 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="admin@office.com"
/>
</div>
{errors.email && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
{errors.email && <p className="text-red-400 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">كلمة المرور</label>
<label className="block text-sm font-semibold text-slate-300 mb-2 mr-1">كلمة المرور</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
<input
{...register('password')}
type="password"
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
className="w-full bg-slate-800/50 border border-slate-700/50 rounded-xl py-3 pl-4 pr-11 focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 outline-none transition-all text-white placeholder-slate-500"
placeholder="••••••••"
/>
</div>
{errors.password && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
{errors.password && <p className="text-red-400 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setStep(1)}
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold py-4 rounded-xl transition-all flex items-center justify-center gap-2"
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-4 rounded-xl transition-all flex items-center justify-center gap-2 border border-slate-700/50"
>
<ArrowRight className="w-5 h-5" />
السابق
@@ -184,7 +184,7 @@ export default function RegisterPage() {
<button
type="submit"
disabled={isLoading}
className="flex-[2] btn-primary py-4 flex items-center justify-center gap-2"
className="flex-[2] bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white py-4 rounded-xl font-bold flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20 transition-all disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : 'إنشاء الحساب'}
</button>
@@ -194,9 +194,9 @@ export default function RegisterPage() {
</AnimatePresence>
</form>
<div className="mt-8 text-center text-slate-500 text-sm">
<div className="mt-8 text-center text-slate-400 text-sm">
لديك حساب بالفعل؟{' '}
<Link to="/login" className="text-primary-600 font-bold hover:underline">
<Link to="/login" className="text-emerald-400 font-bold hover:underline">
سجل دخولك من هنا
</Link>
</div>

View File

@@ -0,0 +1,345 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Companies Management Page (Premium Dark)
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import { Building2, Plus, Search, MoreVertical, ShieldCheck, Key, Loader2, X, MapPin, Hash } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import apiClient from '../../api/client';
export const CompaniesPage = () => {
const [companies, setCompanies] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isJoFotaraModalOpen, setIsJoFotaraModalOpen] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<any>(null);
// Form State (New Company)
const [name, setName] = useState('');
const [tin, setTin] = useState('');
const [address, setAddress] = useState('');
const [isCreating, setIsCreating] = useState(false);
// Form State (JoFotara)
const [clientId, setClientId] = useState('');
const [secretKey, setSecretKey] = useState('');
const [isLinking, setIsLinking] = useState(false);
const fetchCompanies = async () => {
try {
const { data } = await apiClient.get('/companies');
setCompanies(data);
} catch (error) {
console.error('Failed to fetch companies', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCompanies();
}, []);
const handleCreateCompany = async (e: React.FormEvent) => {
e.preventDefault();
setIsCreating(true);
try {
await apiClient.post('/companies', {
name,
tax_identification_number: tin,
address
});
setIsAddModalOpen(false);
setName('');
setTin('');
setAddress('');
fetchCompanies();
} catch (error) {
console.error('Failed to create company', error);
alert('حدث خطأ أثناء إضافة الشركة');
} finally {
setIsCreating(false);
}
};
const handleOpenJoFotara = (company: any) => {
setSelectedCompany(company);
setClientId('');
setSecretKey('');
setIsJoFotaraModalOpen(true);
};
const handleSubmitJoFotara = async (e: React.FormEvent) => {
e.preventDefault();
setIsLinking(true);
try {
await apiClient.put(`/companies/${selectedCompany.id}/jofotara`, {
clientId,
secretKey
});
setIsJoFotaraModalOpen(false);
setSelectedCompany(null);
fetchCompanies();
} catch (error) {
console.error('Failed to link JoFotara', error);
alert('حدث خطأ أثناء ربط حساب جو فوترة');
} finally {
setIsLinking(false);
}
};
const filteredCompanies = companies.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.tax_identification_number?.includes(searchTerm)
);
return (
<div className="space-y-8 animate-in fade-in duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black text-white">إدارة الشركات</h2>
<p className="text-slate-300 mt-1">أضف عملائك وشركاتك لربط فواتيرهم بنظام جو فوترة.</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
>
<Plus className="w-5 h-5" />
إضافة شركة جديدة
</button>
</header>
{/* ── Search Bar ──────────────────────────────── */}
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-500" />
<input
type="text"
placeholder="ابحث باسم الشركة أو الرقم الضريبي..."
className="bg-transparent border-none outline-none flex-1 text-slate-200 text-sm placeholder-slate-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* ── Companies Grid ───────────────────────────────────── */}
{isLoading ? (
<div className="flex flex-col items-center justify-center p-20">
<Loader2 className="w-10 h-10 text-emerald-500 animate-spin mb-4" />
<p className="text-slate-500">جاري تحميل الشركات...</p>
</div>
) : filteredCompanies.length === 0 ? (
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-3xl p-20 flex flex-col items-center justify-center text-center">
<Building2 className="w-16 h-16 text-slate-700 mb-6" />
<h3 className="text-xl font-bold text-white mb-2">لا توجد شركات مسجلة</h3>
<p className="text-slate-500 max-w-sm mb-8">ابدأ بإضافة أول شركة لكي تتمكن من رفع فواتيرها ومعالجتها ضريبياً.</p>
<button onClick={() => setIsAddModalOpen(true)} className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl transition-all">
إضافة شركتك الأولى
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredCompanies.map((company, idx) => (
<motion.div
key={company.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 p-6 rounded-2xl hover:border-emerald-500/50 transition-all group relative overflow-hidden"
>
{/* Ambient Glow */}
<div className="absolute top-0 right-0 w-24 h-24 bg-emerald-500/5 rounded-full blur-3xl" />
<div className="flex items-start justify-between mb-6 relative">
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
<Building2 className="w-6 h-6" />
</div>
<button className="p-2 text-slate-500 hover:text-white transition-colors">
<MoreVertical className="w-5 h-5" />
</button>
</div>
<h3 className="text-xl font-black text-white mb-4 group-hover:text-emerald-400 transition-colors">{company.name}</h3>
<div className="space-y-3 mb-6">
<div className="flex items-center gap-3 text-sm text-slate-300">
<Hash className="w-4 h-4 text-slate-500" />
<span>الرقم الضريبي: <span className="text-slate-200 font-mono font-bold tracking-wider">{company.tax_identification_number || '---'}</span></span>
</div>
<div className="flex items-center gap-3 text-sm text-slate-300">
<MapPin className="w-4 h-4 text-slate-500" />
<span className="truncate">{company.address || 'العنوان غير محدد'}</span>
</div>
</div>
<div className="pt-5 border-t border-slate-800/60 flex items-center justify-between relative">
{company.jofotara_client_id ? (
<div className="flex items-center gap-2 text-[10px] font-bold text-emerald-400 uppercase tracking-widest bg-emerald-400/5 px-2 py-1 rounded-md border border-emerald-400/10">
<ShieldCheck className="w-3 h-3" /> مربوط بجو فوترة
</div>
) : (
<div className="flex items-center gap-2 text-[10px] font-bold text-amber-500 uppercase tracking-widest bg-amber-500/5 px-2 py-1 rounded-md border border-amber-500/10">
<Key className="w-3 h-3" /> غير مربوط
</div>
)}
<button
onClick={() => handleOpenJoFotara(company)}
className="text-emerald-500 text-sm font-bold hover:text-emerald-400 hover:underline transition-colors"
>
{company.jofotara_client_id ? 'تحديث الإعدادات' : 'إعداد الربط'}
</button>
</div>
</motion.div>
))}
</div>
)}
{/* ── Add Company Modal ───────────────────────────────── */}
<AnimatePresence>
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-slate-900 border border-slate-800 p-8 w-full max-w-md rounded-3xl shadow-2xl relative"
>
<button onClick={() => setIsAddModalOpen(false)} className="absolute top-6 left-6 text-slate-500 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
<h3 className="text-2xl font-bold text-white mb-6">إضافة شركة جديدة</h3>
<form onSubmit={handleCreateCompany} className="space-y-5">
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">اسم الشركة / العميل *</label>
<input
type="text"
required
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white placeholder-slate-600"
placeholder="مثال: صيدلية النجاح"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">الرقم الضريبي (TIN)</label>
<input
type="text"
value={tin}
onChange={e => setTin(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white font-mono placeholder-slate-600"
placeholder="123456789"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">العنوان</label>
<input
type="text"
value={address}
onChange={e => setAddress(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white placeholder-slate-600"
placeholder="عمان، شارع مكة"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="flex-1 bg-slate-800 text-slate-400 font-bold py-3 rounded-xl hover:bg-slate-700 transition-all"
>
إلغاء
</button>
<button
type="submit"
disabled={isCreating}
className="flex-1 bg-emerald-500 text-slate-950 font-bold py-3 rounded-xl shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2 transition-all"
>
{isCreating && <Loader2 className="w-4 h-4 animate-spin" />}
حفظ الشركة
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* ── JoFotara Link Modal ───────────────────────────────── */}
<AnimatePresence>
{isJoFotaraModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-slate-900 border border-slate-800 p-8 w-full max-w-md rounded-3xl shadow-2xl relative"
>
<button onClick={() => setIsJoFotaraModalOpen(false)} className="absolute top-6 left-6 text-slate-500 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
<ShieldCheck className="w-8 h-8" />
</div>
<div>
<h3 className="text-xl font-bold text-white">ربط "جو فوترة"</h3>
<p className="text-sm text-slate-500">{selectedCompany?.name}</p>
</div>
</div>
<form onSubmit={handleSubmitJoFotara} className="space-y-5">
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">Client ID</label>
<input
type="text"
required
value={clientId}
onChange={e => setClientId(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white font-mono text-sm placeholder-slate-600"
placeholder="أدخل المعرف الخاص بك..."
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">Secret Key</label>
<input
type="password"
required
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white font-mono text-sm placeholder-slate-600"
placeholder="أدخل مفتاح السر..."
/>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 p-4 rounded-xl mb-4">
<p className="text-[10px] text-amber-500 font-bold leading-relaxed">
سيتم تشفير هذه البيانات وتخزينها بأمان. يرجى التأكد من دقة البيانات لضمان نجاح عملية إرسال الفواتير.
</p>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => setIsJoFotaraModalOpen(false)}
className="flex-1 bg-slate-800 text-slate-400 font-bold py-3 rounded-xl hover:bg-slate-700 transition-all"
>
إلغاء
</button>
<button
type="submit"
disabled={isLinking}
className="flex-1 bg-emerald-500 text-slate-950 font-bold py-3 rounded-xl shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2 transition-all"
>
{isLinking && <Loader2 className="w-4 h-4 animate-spin" />}
حفظ وتشفير
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -1,118 +1,234 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Dashboard Statistics Components
* مُصادَق (Musadaq) — Premium Dark Dashboard Page
* ════════════════════════════════════════════════════════════
*/
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import {
FileText,
CheckCircle2,
AlertCircle,
TrendingUp,
Wallet,
ArrowUpRight
ArrowUpRight,
Clock,
CheckCircle2,
Building2,
Loader2,
Upload,
AlertTriangle,
} from 'lucide-react';
const stats = [
{ label: 'إجمالي الفواتير', value: '1,280', icon: FileText, color: 'text-primary-600', bg: 'bg-primary-50', change: '+12%' },
{ label: 'تمت مصادقتها', value: '1,150', icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-50', change: '+18%' },
{ label: 'قيد المراجعة', value: '42', icon: AlertCircle, color: 'text-amber-600', bg: 'bg-amber-50', change: '-5%' },
{ label: 'مجموع الضريبة (JOD)', value: '14,250.000', icon: Wallet, color: 'text-blue-600', bg: 'bg-blue-50', change: '+8%' },
];
import { motion } from 'framer-motion';
import apiClient from '../../api/client';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
export const DashboardPage = () => {
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const [stats, setStats] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchStats = async () => {
try {
const { data } = await apiClient.get('/dashboard/stats');
setStats(data);
} catch (error) {
console.error('Failed to fetch dashboard stats', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
if (isLoading) {
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">لوحة التحكم</h2>
<p className="text-slate-500 mt-1">نظرة عامة على نشاطك الضريبي هذا الشهر.</p>
</div>
<div className="flex gap-3">
<button className="bg-white border border-slate-200 text-slate-700 font-semibold py-2.5 px-6 rounded-xl shadow-sm hover:bg-slate-50 transition-all flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary-500" />
تصدير التقارير
</button>
<button className="btn-primary py-2.5 px-6 rounded-xl flex items-center gap-2 shadow-lg shadow-primary-500/25">
<FileText className="w-5 h-5" />
فاتورة جديدة
</button>
</div>
</header>
{/* ── Stats Grid ────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="card-premium p-6 group cursor-pointer"
>
<div className="flex items-start justify-between mb-4">
<div className={`p-3 rounded-2xl ${stat.bg} ${stat.color} transition-transform group-hover:scale-110 duration-300`}>
<stat.icon className="w-6 h-6" />
</div>
<div className={`flex items-center gap-1 text-[12px] font-bold px-2 py-1 rounded-full ${stat.change.startsWith('+') ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
<ArrowUpRight className="w-3 h-3" />
{stat.change}
</div>
</div>
<p className="text-slate-500 text-sm font-medium">{stat.label}</p>
<h3 className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</h3>
</motion.div>
))}
</div>
{/* ── Main Dashboard Content (Placeholder for Charts/Lists) ── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="card-premium h-[400px] p-6 flex flex-col">
<div className="flex items-center justify-between mb-8">
<h4 className="font-bold text-lg">تحليلات الفوترة الأسبوعية</h4>
<select className="bg-slate-50 border border-slate-100 rounded-lg py-1.5 px-3 text-sm font-medium outline-none">
<option>آخر 7 أيام</option>
<option>آخر 30 يوم</option>
</select>
</div>
<div className="flex-1 bg-slate-50 rounded-2xl border border-dashed border-slate-200 flex items-center justify-center">
<p className="text-slate-400 text-sm font-medium italic">رسم بياني توضيحي (Chart integration goes here)</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="card-premium p-6 bg-primary-600 text-white shadow-xl shadow-primary-500/30">
<h4 className="font-bold text-lg mb-2">استهلاك الاشتراك الحالي</h4>
<p className="text-primary-100 text-sm mb-6">لقد استهلكت 65% من حصتك الشهرية من الفواتير.</p>
<div className="w-full h-3 bg-white/20 rounded-full overflow-hidden mb-6">
<div className="w-2/3 h-full bg-white rounded-full shadow-lg" />
</div>
<button className="w-full bg-white text-primary-600 font-bold py-3 rounded-xl hover:bg-primary-50 transition-all">
ترقية الباقة الآن
</button>
</div>
<div className="card-premium p-6">
<h4 className="font-bold text-lg mb-4">آخر النشاطات</h4>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-3 p-2 hover:bg-slate-50 rounded-xl transition-all cursor-pointer">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-slate-500" />
</div>
<div className="flex-1">
<p className="text-sm font-bold text-slate-800">فاتورة مبيعات #A-2024-001</p>
<p className="text-[12px] text-slate-500">منذ 10 دقائق · تمت المصادقة</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="flex-1 flex flex-col justify-center items-center py-32">
<Loader2 className="w-12 h-12 text-emerald-400 animate-spin mb-4" />
<p className="text-slate-400">جاري تحميل البيانات...</p>
</div>
);
}
const statCards = [
{
title: 'إجمالي الفواتير',
value: stats?.stats?.totalInvoices || 0,
icon: FileText,
gradient: 'from-blue-500 to-blue-600',
glow: 'shadow-blue-500/20',
},
{
title: 'الفواتير المصدقة',
value: stats?.stats?.approvedInvoices || 0,
icon: CheckCircle2,
gradient: 'from-emerald-500 to-emerald-600',
glow: 'shadow-emerald-500/20',
},
{
title: 'إجمالي الشركات',
value: stats?.stats?.companiesCount || 0,
icon: Building2,
gradient: 'from-purple-500 to-purple-600',
glow: 'shadow-purple-500/20',
},
{
title: 'إجمالي الضرائب (JOD)',
value: Number(stats?.stats?.totalTax || 0).toLocaleString('ar-JO', { minimumFractionDigits: 3 }),
icon: TrendingUp,
gradient: 'from-amber-500 to-amber-600',
glow: 'shadow-amber-500/20',
},
];
return (
<div className="space-y-8 animate-in fade-in duration-700">
<header className="flex justify-between items-end">
<div>
<h2 className="text-3xl font-black text-white tracking-tight">لوحة التحكم</h2>
<p className="text-slate-400 mt-1 font-medium">مرحباً بك مجدداً! إليك ملخص نشاط مكتبك اليوم.</p>
</div>
<div className="flex gap-3">
<button className="bg-slate-800/60 border border-slate-700/50 px-5 py-2.5 rounded-xl text-slate-300 font-bold text-sm hover:bg-slate-800 transition-all">
آخر 30 يوم
</button>
</div>
</header>
{/* ── Stats Grid ────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((card, idx) => (
<motion.div
key={card.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 p-6 rounded-2xl group hover:border-slate-700 transition-all relative overflow-hidden"
>
{/* Subtle ambient glow on hover */}
<div className={`absolute -inset-10 opacity-0 group-hover:opacity-10 blur-3xl transition-opacity duration-500 bg-gradient-to-r ${card.gradient}`} />
<div className="relative z-10">
<div className="flex justify-between items-start mb-4">
<div className={`w-12 h-12 bg-gradient-to-br ${card.gradient} rounded-2xl flex items-center justify-center text-white shadow-lg ${card.glow}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="flex items-center gap-1 text-xs font-bold text-emerald-400 bg-emerald-500/10 px-2 py-1 rounded-lg border border-emerald-500/20">
<ArrowUpRight className="w-3 h-3" />
</span>
</div>
<h3 className="text-slate-400 font-bold text-sm mb-1">{card.title}</h3>
<div className="text-2xl font-black text-white tracking-tight">{card.value}</div>
</div>
</motion.div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* ── Recent Activities ────────────────────────────────── */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center justify-between px-2">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-emerald-400" />
آخر الفواتير المرفوعة
</h3>
<button
onClick={() => navigate('/invoices')}
className="text-emerald-400 text-sm font-bold hover:underline"
>
عرض الكل
</button>
</div>
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-2xl overflow-hidden">
{(!stats?.recentActivities || stats.recentActivities.length === 0) ? (
<div className="p-12 text-center text-slate-500 font-medium">
لا توجد نشاطات حديثة بعد.
</div>
) : (
<table className="w-full text-right">
<thead className="bg-slate-800/50 border-b border-slate-700/50">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الفاتورة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الشركة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الحالة</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{stats.recentActivities.map((inv: any) => (
<tr key={inv.id} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 font-bold text-white">{inv.number || 'قيد الاستخراج...'}</td>
<td className="px-6 py-4 text-slate-400">{inv.company || '—'}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-md text-[10px] font-bold ${
inv.status === 'approved'
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
: inv.status === 'validation_failed' || inv.status === 'rejected'
? 'bg-red-500/10 text-red-400 border border-red-500/20'
: 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
}`}>
{inv.status === 'approved' ? 'مصدقة' :
inv.status === 'validation_failed' ? 'مرفوضة' :
inv.status === 'rejected' ? 'مرفوضة' :
'قيد المعالجة'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* ── Quick Actions ────────────────────────────────────── */}
<div className="space-y-4">
<h3 className="text-xl font-bold text-white px-2">إجراءات سريعة</h3>
<div className="grid grid-cols-1 gap-4">
<button
onClick={() => navigate('/invoices')}
className="flex items-center gap-4 p-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-emerald-600 text-white shadow-xl shadow-emerald-500/20 hover:shadow-emerald-500/30 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">
<Upload className="w-5 h-5" />
</div>
<div className="text-right">
<div className="font-bold">رفع فاتورة جديدة</div>
<div className="text-xs text-white/70">معالجة فورية بالذكاء الاصطناعي</div>
</div>
</button>
<button
onClick={() => navigate('/companies')}
className="flex items-center gap-4 p-4 rounded-2xl bg-slate-900/50 border border-slate-800/60 text-slate-300 hover:border-emerald-500/30 transition-all group"
>
<div className="w-10 h-10 rounded-xl bg-slate-800 flex items-center justify-center group-hover:bg-emerald-500/10 group-hover:text-emerald-400 transition-all">
<Building2 className="w-5 h-5" />
</div>
<div className="text-right">
<div className="font-bold text-white">إضافة شركة</div>
<div className="text-xs text-slate-500">تسجيل عميل جديد في المكتب</div>
</div>
</button>
{user?.role === 'admin' && (
<button
onClick={() => navigate('/elite-dashboard')}
className="flex items-center gap-4 p-4 rounded-2xl bg-slate-900/50 border border-slate-800/60 text-slate-300 hover:border-emerald-500/30 transition-all group"
>
<div className="w-10 h-10 rounded-xl bg-slate-800 flex items-center justify-center group-hover:bg-amber-500/10 group-hover:text-amber-400 transition-all">
<AlertTriangle className="w-5 h-5" />
</div>
<div className="text-right">
<div className="font-bold text-white">مراقبة المخاطر</div>
<div className="text-xs text-slate-500">لوحة النخبة ودرجات الخطر الضريبي</div>
</div>
</button>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,275 @@
import { useState, useEffect } from 'react';
import { Building2, TrendingUp, AlertTriangle, ChevronDown, Loader2, RefreshCw, Crown } from 'lucide-react';
import { motion } from 'framer-motion';
import { useAuthStore } from '../../store/authStore';
import apiClient from '../../api/client';
interface CompanyStats {
id: string;
name: string;
taxId: string;
totalInvoices: number;
totalTax: number;
failedCount: number;
riskScore: number;
aiStats: {
totalTokens: number;
totalCost: number;
};
}
const getRiskStatus = (score: number) => {
if (score >= 70) return 'High';
if (score >= 30) return 'Medium';
return 'Low';
};
const RiskGauge = ({ score }: { score: number }) => {
const status = getRiskStatus(score);
const getColor = () => {
if (status === 'High') return 'text-red-500';
if (status === 'Medium') return 'text-orange-500';
return 'text-emerald-500';
};
const getLabel = () => {
if (status === 'High') return 'مرتفع';
if (status === 'Medium') return 'متوسط';
return 'منخفض';
};
return (
<div className="flex flex-col items-center">
<div className="relative w-16 h-16">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
<path
className="text-slate-700"
strokeWidth="3"
stroke="currentColor"
fill="none"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className={getColor()}
strokeWidth="3"
strokeDasharray={`${score}, 100`}
stroke="currentColor"
fill="none"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-lg font-bold text-white">
{score}
</div>
</div>
<span className={`text-xs mt-1 ${getColor()}`}>{getLabel()}</span>
</div>
);
};
const Cpu = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24" height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
<rect x="9" y="9" width="6" height="6"/>
<line x1="9" y1="1" x2="9" y2="4"/>
<line x1="15" y1="1" x2="15" y2="4"/>
<line x1="9" y1="20" x2="9" y2="23"/>
<line x1="15" y1="20" x2="15" y2="23"/>
<line x1="20" y1="9" x2="23" y2="9"/>
<line x1="20" y1="15" x2="23" y2="15"/>
<line x1="1" y1="9" x2="4" y2="9"/>
<line x1="1" y1="15" x2="4" y2="15"/>
</svg>
);
export const MultiEntityDashboard = () => {
const user = useAuthStore((state) => state.user);
const isAdmin = user?.role && ['admin', 'super_admin'].includes(user.role.toLowerCase());
const [companies, setCompanies] = useState<CompanyStats[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const { data } = await apiClient.get('/dashboard/multi-entity');
setCompanies(data);
} catch (err: any) {
console.error('Failed to fetch multi-entity stats', err);
setError('فشل في جلب بيانات الشركات. تأكد من تسجيل الدخول.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
if (isLoading) {
return (
<div className="flex-1 flex flex-col justify-center items-center py-32">
<Loader2 className="w-12 h-12 text-emerald-400 animate-spin mb-4" />
<p className="text-slate-400">جاري تحميل بيانات الشركات...</p>
</div>
);
}
return (
<div className="space-y-8 animate-in fade-in duration-700">
{/* Header */}
<header className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-light tracking-tight text-slate-900 dark:text-white flex items-center gap-3">
<Crown className="w-7 h-7 text-emerald-500" />
<span className="font-semibold text-emerald-500">مُصادَق</span> | لوحة تحكم الشركات
</h2>
<p className="text-slate-300 mt-2 text-sm">نظرة عامة على الموقف الضريبي لجميع عملائك (Elite View)</p>
</div>
<div className="flex gap-4">
<button
onClick={fetchData}
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-white rounded-lg text-sm border border-slate-200 dark:border-slate-700 transition-colors flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" /> تحديث
</button>
<button className="px-4 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-white rounded-lg text-sm border border-slate-200 dark:border-slate-700 transition-colors flex items-center gap-2">
آخر 30 يوم <ChevronDown className="w-4 h-4" />
</button>
</div>
</header>
{/* Error State */}
{error && (
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl p-4 flex items-center gap-3 text-red-600 dark:text-red-400">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium">{error}</span>
<button onClick={fetchData} className="mr-auto text-sm underline hover:no-underline">إعادة المحاولة</button>
</div>
)}
{/* Empty State */}
{!error && companies.length === 0 && (
<div className="text-center py-20">
<Building2 className="w-16 h-16 text-slate-300 dark:text-slate-600 mx-auto mb-6" />
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">لا توجد شركات بعد</h3>
<p className="text-slate-500 mb-6">أضف شركات عملائك لتبدأ بمتابعة الموقف الضريبي لكل شركة.</p>
<button className="px-5 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-medium rounded-lg text-sm shadow-lg shadow-emerald-500/20 transition-all">
+ إضافة شركة جديدة
</button>
</div>
)}
{/* Grid */}
{companies.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{companies.map((company, index) => {
const status = getRiskStatus(company.riskScore);
const approvedEstimate = Math.max(0, company.totalInvoices - company.failedCount);
const approvedPct = company.totalInvoices > 0 ? Math.round((approvedEstimate / company.totalInvoices) * 100) : 0;
const failedPct = company.totalInvoices > 0 ? Math.round((company.failedCount / company.totalInvoices) * 100) : 0;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.08 }}
key={company.id}
className="card-premium p-6 relative overflow-hidden group"
>
{/* AI Usage Badge */}
{isAdmin && (
<div className="absolute top-4 left-4">
<div className="flex items-center gap-1.5 bg-indigo-500/10 backdrop-blur-md px-3 py-1.5 rounded-lg text-xs font-black text-indigo-400 border border-indigo-500/20 shadow-lg shadow-indigo-500/10">
<Cpu className="w-3.5 h-3.5" />
<span>{company.aiStats?.totalTokens > 1000 ? `${(company.aiStats.totalTokens / 1000).toFixed(1)}k` : company.aiStats?.totalTokens || 0} Tokens</span>
</div>
</div>
)}
{/* Ambient glow */}
<div className={`absolute -inset-20 opacity-0 group-hover:opacity-10 blur-3xl transition-opacity duration-500 rounded-full
${status === 'High' ? 'bg-red-500' : status === 'Medium' ? 'bg-orange-500' : 'bg-emerald-500'}
`} />
<div className="relative z-10">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center border border-slate-200 dark:border-slate-700">
<Building2 className="w-5 h-5 text-emerald-500" />
</div>
<div>
<h3 className="text-slate-900 dark:text-white font-medium text-lg">{company.name}</h3>
<p className="text-xs text-slate-300">{company.taxId || '—'}</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<p className="text-xs text-slate-500 mb-1">إجمالي الضريبة</p>
<p className="text-2xl font-light text-slate-900 dark:text-white">
{company.totalTax > 0 ? `${(company.totalTax / 1000).toFixed(1)}k` : '0'}
</p>
<div className="flex items-center gap-1 mt-1 text-xs text-emerald-500">
<TrendingUp className="w-3 h-3" /> JOD
</div>
</div>
<div className="flex justify-end">
<div>
<p className="text-xs text-slate-500 mb-1 text-center">درجة الخطر الضريبي</p>
<RiskGauge score={company.riskScore} />
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-100 dark:border-slate-800/50">
<div className="flex justify-between items-center mb-2">
<p className="text-sm text-slate-600 dark:text-slate-300">{company.totalInvoices} فاتورة</p>
<div className="flex items-center gap-2">
{isAdmin && company.aiStats?.totalCost > 0 && (
<span className="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
${company.aiStats.totalCost.toFixed(3)}
</span>
)}
{company.failedCount > 0 && (
<span className="text-xs text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> {company.failedCount} مرفوضة
</span>
)}
</div>
</div>
{/* Progress Bar */}
<div className="h-1.5 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden flex">
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${approvedPct}%` }}></div>
<div className="h-full bg-red-500 transition-all" style={{ width: `${failedPct}%` }}></div>
</div>
<div className="flex justify-between mt-2 text-[10px] text-slate-400">
<span>ناجحة ({approvedPct}%)</span>
{company.failedCount > 0 && <span className="text-red-400">مرفوضة ({failedPct}%)</span>}
</div>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,208 @@
import { useState, useEffect } from 'react';
import {
AlertTriangle,
ShieldAlert,
FileWarning,
Search,
Filter,
Loader2,
Building2,
Calendar,
ArrowRight
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import apiClient from '../../api/client';
import { Link } from 'react-router-dom';
interface RiskInvoice {
id: string;
invoice_number: string;
invoice_date: string;
status: string;
validation_errors: string[];
grand_total: number;
company: {
name: string;
};
updated_at: string;
}
export const RiskMonitorPage = () => {
const [invoices, setInvoices] = useState<RiskInvoice[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const fetchRisks = async () => {
try {
const { data } = await apiClient.get('/dashboard/risk-invoices');
setInvoices(data);
} catch (err) {
console.error('Failed to fetch risk invoices', err);
} finally {
setIsLoading(false);
}
};
fetchRisks();
}, []);
const filteredInvoices = invoices.filter(inv =>
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.company?.name.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isLoading) {
return (
<div className="flex-1 flex flex-col justify-center items-center py-32">
<Loader2 className="w-12 h-12 text-emerald-400 animate-spin mb-4" />
<p className="text-slate-400">جاري تحليل المخاطر الضريبية...</p>
</div>
);
}
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Header Section */}
<header className="relative p-10 rounded-[2.5rem] bg-slate-900 border border-slate-800 overflow-hidden">
<div className="absolute top-0 right-0 w-1/3 h-full bg-gradient-to-l from-red-500/10 to-transparent pointer-events-none" />
<div className="relative z-10">
<div className="flex items-center gap-4 mb-6">
<div className="w-14 h-14 rounded-2xl bg-red-500/20 flex items-center justify-center border border-red-500/30">
<ShieldAlert className="w-8 h-8 text-red-500" />
</div>
<div>
<h1 className="text-4xl font-black text-white">مراقبة المخاطر</h1>
<p className="text-slate-300 mt-1 font-medium italic">Risk & Anomaly Monitoring Center</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10">
<div className="bg-slate-800/40 p-6 rounded-3xl border border-slate-700/50">
<p className="text-slate-500 text-sm font-bold uppercase tracking-widest mb-2">إجمالي المخاطر</p>
<p className="text-4xl font-black text-white">{invoices.length}</p>
<div className="h-1 w-full bg-red-500/20 rounded-full mt-4">
<div className="h-full bg-red-500 rounded-full" style={{ width: '100%' }} />
</div>
</div>
<div className="bg-slate-800/40 p-6 rounded-3xl border border-slate-700/50">
<p className="text-slate-500 text-sm font-bold uppercase tracking-widest mb-2">فشل التحقق (Validation)</p>
<p className="text-4xl font-black text-white">
{invoices.filter(i => i.status === 'validation_failed').length}
</p>
<div className="h-1 w-full bg-orange-500/20 rounded-full mt-4">
<div className="h-full bg-orange-500 rounded-full" style={{ width: '60%' }} />
</div>
</div>
<div className="bg-slate-800/40 p-6 rounded-3xl border border-slate-700/50">
<p className="text-slate-500 text-sm font-bold uppercase tracking-widest mb-2">مرفوضة من JoFotara</p>
<p className="text-4xl font-black text-white">
{invoices.filter(i => i.status === 'rejected').length}
</p>
<div className="h-1 w-full bg-slate-700 rounded-full mt-4">
<div className="h-full bg-slate-500 rounded-full" style={{ width: '20%' }} />
</div>
</div>
</div>
</div>
</header>
{/* Main Content Card */}
<div className="bg-slate-900/50 border border-slate-800 rounded-[2.5rem] overflow-hidden backdrop-blur-xl">
<div className="p-8 border-b border-slate-800 flex flex-col md:flex-row justify-between items-center gap-6">
<div className="relative w-full md:w-96">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="text"
placeholder="البحث عن شركة أو رقم فاتورة..."
className="w-full bg-slate-800/50 border border-slate-700 rounded-2xl py-3 pl-12 pr-6 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 transition-all font-medium"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-4">
<button className="px-6 py-3 bg-slate-800 text-slate-300 rounded-2xl border border-slate-700 flex items-center gap-2 hover:bg-slate-700 transition-all font-bold">
<Filter className="w-5 h-5" /> تصفية النتائج
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-right border-b border-slate-800">
<th className="px-8 py-6 text-slate-300 font-bold uppercase tracking-tighter text-xs">الشركة والفاتورة</th>
<th className="px-8 py-6 text-slate-300 font-bold uppercase tracking-tighter text-xs">نوع الخطر</th>
<th className="px-8 py-6 text-slate-300 font-bold uppercase tracking-tighter text-xs">التاريخ والقيمة</th>
<th className="px-8 py-6 text-slate-300 font-bold uppercase tracking-tighter text-xs text-left">الإجراء</th>
</tr>
</thead>
<tbody>
<AnimatePresence>
{filteredInvoices.map((inv, index) => (
<motion.tr
key={inv.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="border-b border-slate-800/50 hover:bg-slate-800/20 transition-colors group"
>
<td className="px-8 py-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-800 flex items-center justify-center border border-slate-700">
<Building2 className="w-6 h-6 text-slate-300" />
</div>
<div>
<p className="text-white font-black">{inv.company?.name}</p>
<p className="text-slate-400 text-sm font-bold">#{inv.invoice_number || 'بدون رقم'}</p>
</div>
</div>
</td>
<td className="px-8 py-6">
<div className="space-y-2">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${
inv.status === 'validation_failed' ? 'bg-orange-500/10 text-orange-500 border border-orange-500/20' : 'bg-red-500/10 text-red-500 border border-red-500/20'
}`}>
<AlertTriangle className="w-3 h-3" />
{inv.status === 'validation_failed' ? 'فشل المطابقة' : 'مرفوضة ضريبياً'}
</div>
{inv.validation_errors && inv.validation_errors.length > 0 && (
<p className="text-xs text-slate-400 max-w-xs line-clamp-1 italic">
{inv.validation_errors[0]}
</p>
)}
</div>
</td>
<td className="px-8 py-6">
<div className="flex flex-col">
<span className="text-white font-black">{Number(inv.grand_total).toFixed(3)} JOD</span>
<span className="text-slate-500 text-xs flex items-center gap-1 font-bold">
<Calendar className="w-3 h-3" /> {new Date(inv.invoice_date).toLocaleDateString('ar-JO')}
</span>
</div>
</td>
<td className="px-8 py-6 text-left">
<Link
to={`/invoices`}
className="inline-flex items-center gap-2 text-emerald-500 font-black hover:text-emerald-400 transition-colors group/btn"
>
<span>مراجعة وتعديل</span>
<ArrowRight className="w-4 h-4 transform group-hover/btn:translate-x-1 transition-transform" />
</Link>
</td>
</motion.tr>
))}
</AnimatePresence>
</tbody>
</table>
{filteredInvoices.length === 0 && (
<div className="py-20 text-center">
<FileWarning className="w-16 h-16 text-slate-700 mx-auto mb-4" />
<p className="text-slate-500 font-bold">لا توجد مخاطر مكتشفة حالياً في هذا النطاق.</p>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,11 +1,11 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Management Page
* مُصادَق (Musadaq) — Invoices Management Page (Premium Dark)
* ════════════════════════════════════════════════════════════
*/
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Upload,
Search,
@@ -14,45 +14,146 @@ import {
CheckCircle2,
Clock,
AlertCircle,
MoreVertical,
ChevronLeft,
ChevronRight
ChevronRight,
Building2,
FileText,
Send,
Download,
Trash2,
Loader2,
X
} from 'lucide-react';
const invoices = [
{ id: '1', number: 'INV-2024-001', company: 'شركة الأمل', date: '2024-04-15', total: '150.000', status: 'approved', type: 'cash' },
{ id: '2', number: 'INV-2024-002', company: 'سوبرماركت المدينة', date: '2024-04-16', total: '2,400.000', status: 'validated', type: 'credit' },
{ id: '3', number: 'OCR_PENDING', company: 'مخبز السلام', date: '2024-04-16', total: '0.000', status: 'extracting', type: 'cash' },
{ id: '4', number: 'INV-2024-003', company: 'مكتبة النجاح', date: '2024-04-14', total: '85.250', status: 'validation_failed', type: 'cash' },
];
const StatusBadge = ({ status }: { status: string }) => {
const config: any = {
approved: { color: 'text-emerald-700 bg-emerald-50 border-emerald-100', icon: CheckCircle2, label: 'تم التصديق' },
validated: { color: 'text-blue-700 bg-blue-50 border-blue-100', icon: Clock, label: 'جاهز للإرسال' },
extracting: { color: 'text-amber-700 bg-amber-50 border-amber-100', icon: Clock, label: 'قيد الاستخراج AI' },
validation_failed: { color: 'text-red-700 bg-red-50 border-red-100', icon: AlertCircle, label: 'خطأ في التحقق' },
};
const { color, icon: Icon, label } = config[status] || config.extracting;
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold border ${color}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
);
};
import apiClient from '../../api/client';
export const InvoicesPage = () => {
const [invoices, setInvoices] = useState<any[]>([]);
const [companies, setCompanies] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
// Upload Form State
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// View Modal State
const [viewingInvoice, setViewingInvoice] = useState<any | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState<string | null>(null);
const fetchData = async () => {
setIsLoading(true);
try {
const [compRes, invRes] = await Promise.all([
apiClient.get('/companies').catch(() => ({ data: [] })),
apiClient.get('/invoices').catch(() => ({ data: [] }))
]);
setCompanies(compRes.data);
setInvoices(invRes.data);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCompanyId || !selectedFile) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
try {
await apiClient.post(`/invoices/upload/${selectedCompanyId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setIsUploadModalOpen(false);
setSelectedFile(null);
setSelectedCompanyId('');
fetchData();
} catch (error) {
console.error('Upload failed', error);
alert('حدث خطأ أثناء رفع الفاتورة');
} finally {
setIsUploading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('هل أنت متأكد من حذف هذه الفاتورة نهائياً؟')) return;
setDeleteLoading(id);
try {
await apiClient.post(`/invoices/${id}/delete`);
fetchData();
} catch (error) {
alert('فشل حذف الفاتورة');
} finally {
setDeleteLoading(null);
}
};
const handleSubmitToJoFotara = async (inv: any) => {
setSubmitLoading(inv.id);
try {
await apiClient.post(`/invoices/${inv.id}/submit`);
alert('تم الإرسال لـ جو فوترة بنجاح! 🎉');
fetchData();
} catch (err) {
alert('فشل الإرسال لـ جو فوترة. تأكد من إعدادات الشركة وصحة البيانات.');
} finally {
setSubmitLoading(null);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
}
};
const filteredInvoices = invoices.filter(inv =>
inv.invoice_number?.includes(searchTerm) ||
inv.company?.name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const StatusBadge = ({ invoice }: { invoice: any }) => {
const status = invoice.status;
const config: any = {
approved: { color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', icon: CheckCircle2, label: 'تم التصديق' },
validated: { color: 'text-blue-400 bg-blue-500/10 border-blue-500/20', icon: CheckCircle2, label: 'جاهز للإرسال' },
extracted: { color: 'text-indigo-400 bg-indigo-500/10 border-indigo-500/20', icon: CheckCircle2, label: 'تم الاستخراج' },
uploaded: { color: 'text-amber-400 bg-amber-500/10 border-amber-500/20', icon: Clock, label: 'قيد المعالجة AI' },
extracting: { color: 'text-amber-400 bg-amber-500/10 border-amber-500/20', icon: Clock, label: 'قيد الاستخراج' },
validation_failed: { color: 'text-red-400 bg-red-500/10 border-red-500/20', icon: AlertCircle, label: 'خطأ في التحقق' },
};
const { color, icon: Icon, label } = config[status] || { color: 'text-slate-400 bg-slate-800', icon: Clock, label: status };
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold border ${color} uppercase tracking-tight`}>
<Icon className="w-3 h-3" />
{label}
</span>
);
};
return (
<div className="space-y-8 h-full flex flex-col">
<div className="space-y-8 animate-in fade-in duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-slate-900">إدارة الفواتير</h2>
<p className="text-slate-500 mt-1">عرض، معالجة، وإرسال الفواتير الضريبية لبوابة الضريبة.</p>
<h2 className="text-3xl font-black text-white">إدارة الفواتير</h2>
<p className="text-slate-400 mt-1">عرض، معالجة، وإرسال الفواتير الضريبية لبوابة الضريبة.</p>
</div>
<button 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">
<button
onClick={() => setIsUploadModalOpen(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
>
<Upload className="w-5 h-5" />
رفع فاتورة جديدة
</button>
@@ -60,99 +161,268 @@ export const InvoicesPage = () => {
{/* ── Filter & Search Bar ──────────────────────────────── */}
<div className="flex gap-4">
<div className="flex-1 glass border-slate-200 rounded-2xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-400" />
<div className="flex-1 bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-500" />
<input
type="text"
placeholder="ابحث برقم الفاتورة، اسم الشركة، أو التاريخ..."
className="bg-transparent border-none outline-none flex-1 text-slate-800 text-sm"
className="bg-transparent border-none outline-none flex-1 text-slate-200 text-sm placeholder-slate-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button className="glass border-slate-200 px-6 rounded-2xl flex items-center gap-2 text-slate-600 hover:bg-slate-50 transition-all font-semibold">
<button className="bg-slate-800/60 border border-slate-700/50 px-6 rounded-xl flex items-center gap-2 text-slate-300 hover:bg-slate-800 transition-all font-bold text-sm">
<Filter className="w-4 h-4" />
فلترة متقدمة
فلترة
</button>
</div>
{/* ── Invoices Table ───────────────────────────────────── */}
<div className="flex-1 card-premium overflow-hidden flex flex-col bg-white">
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead className="bg-slate-50/80 border-b border-slate-100">
<tr>
<th className="px-6 py-4 text-sm font-bold text-slate-500">رقم الفاتورة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">الشركة المصدرة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">التاريخ</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">النوع</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">المجموع (JOD)</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500">الحالة</th>
<th className="px-6 py-4 text-sm font-bold text-slate-500 w-20">إجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invoices.map((inv, idx) => (
<motion.tr
key={inv.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="hover:bg-slate-50/50 transition-colors group cursor-pointer"
>
<td className="px-6 py-4 font-bold text-slate-900">{inv.number}</td>
<td className="px-6 py-4 text-slate-600 font-medium">{inv.company}</td>
<td className="px-6 py-4 text-slate-500 text-sm">{inv.date}</td>
<td className="px-6 py-4">
<span className={`text-[11px] font-bold px-2 py-0.5 rounded uppercase tracking-wider ${inv.type === 'cash' ? 'bg-indigo-50 text-indigo-600' : 'bg-orange-50 text-orange-600'}`}>
{inv.type === 'cash' ? 'نقدي' : 'ذمم'}
</span>
</td>
<td className="px-6 py-4 font-mono font-bold text-slate-800">{inv.total}</td>
<td className="px-6 py-4"><StatusBadge status={inv.status} /></td>
<td className="px-6 py-4 text-center">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-all">
<Eye className="w-4 h-4" />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
{/* ── Empty State Mock (Hidden if data exists) ───────────── */}
{invoices.length === 0 && (
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-2xl overflow-hidden min-h-[400px] flex flex-col">
{isLoading ? (
<div className="flex-1 flex flex-col justify-center items-center">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin mb-4" />
<p className="text-slate-500 text-sm">جاري جلب الفواتير...</p>
</div>
) : filteredInvoices.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center">
<div className="w-24 h-24 bg-slate-50 rounded-full flex items-center justify-center mb-6 border border-slate-100">
<Upload className="w-10 h-10 text-slate-300" />
<div className="w-20 h-20 bg-slate-800 rounded-2xl flex items-center justify-center mb-6 border border-slate-700">
<FileText className="w-10 h-10 text-slate-600" />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">لا توجد فواتير بعد</h3>
<h3 className="text-xl font-bold text-white mb-2">لا توجد فواتير بعد</h3>
<p className="text-slate-500 max-w-sm mb-8">ابدأ برفع أول فاتورة ليقوم محرك الذكاء الاصطناعي باستخراج بياناتها ومصادقتها ضريبياً.</p>
<button className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2">
<button onClick={() => setIsUploadModalOpen(true)} className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl transition-all">
ارفع فاتورتك الأولى
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-slate-800/50 border-b border-slate-800/60">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-400">رقم الفاتورة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الشركة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">التاريخ</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400 text-left">المجموع (JOD)</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400">الحالة</th>
<th className="px-6 py-4 text-xs font-bold text-slate-400 text-center">إجراءات</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{filteredInvoices.map((inv, idx) => (
<motion.tr
key={inv.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="hover:bg-slate-800/30 transition-colors group cursor-pointer"
onClick={() => {
setViewingInvoice(inv);
setIsViewModalOpen(true);
}}
>
<td className="px-6 py-4 font-bold text-white">{inv.invoice_number || '---'}</td>
<td className="px-6 py-4 text-slate-400">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-600" />
{inv.company?.name || '---'}
</div>
</td>
<td className="px-6 py-4 text-slate-500 text-sm">
{inv.invoice_date ? new Date(inv.invoice_date).toLocaleDateString('ar-JO') : '---'}
</td>
<td className="px-6 py-4 font-mono font-bold text-emerald-400 text-left">
{Number(inv.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })}
</td>
<td className="px-6 py-4"><StatusBadge invoice={inv} /></td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => { setViewingInvoice(inv); setIsViewModalOpen(true); }}
className="p-2 text-slate-500 hover:text-emerald-400 hover:bg-emerald-500/10 rounded-lg transition-all"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(inv.id)}
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
{deleteLoading === inv.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Pagination ───────────────────────────────────────── */}
<footer className="px-6 py-4 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
<p className="text-sm text-slate-500">عرض 1-10 من أصل 1,280 فاتورة</p>
<div className="flex gap-2">
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
<ChevronRight className="w-5 h-5" />
</button>
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
<ChevronLeft className="w-5 h-5" />
</button>
</div>
</footer>
{!isLoading && filteredInvoices.length > 0 && (
<footer className="px-6 py-4 bg-slate-800/30 border-t border-slate-800/60 flex items-center justify-between mt-auto">
<p className="text-sm text-slate-500">عرض {filteredInvoices.length} فواتير</p>
<div className="flex gap-2">
<button className="p-2 text-slate-500 hover:text-white border border-slate-700 rounded-xl bg-slate-800 transition-all">
<ChevronRight className="w-5 h-5" />
</button>
<button className="p-2 text-slate-500 hover:text-white border border-slate-700 rounded-xl bg-slate-800 transition-all">
<ChevronLeft className="w-5 h-5" />
</button>
</div>
</footer>
)}
</div>
{/* ── Upload Modal ─────────────────────────────────────── */}
<AnimatePresence>
{isUploadModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-slate-900 border border-slate-800 p-8 w-full max-w-xl rounded-3xl shadow-2xl relative"
>
<button onClick={() => setIsUploadModalOpen(false)} className="absolute top-6 left-6 text-slate-500 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
<h3 className="text-2xl font-bold text-white mb-2">رفع فاتورة جديدة</h3>
<p className="text-slate-400 mb-8 text-sm">اختر الشركة وملف الفاتورة (PDF أو صورة) وسيقوم الذكاء الاصطناعي بالباقي.</p>
<form onSubmit={handleUpload} className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-300 mb-2">اختر الشركة</label>
<select
required
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-5 py-4 outline-none focus:border-emerald-500/50 transition-all text-white appearance-none cursor-pointer"
>
<option value="">-- اختر شركة --</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-300 mb-2">ملف الفاتورة</label>
<div className="relative group">
<input
type="file"
required
onChange={handleFileChange}
accept=".pdf,image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div className="border-2 border-dashed border-slate-700 rounded-xl p-10 flex flex-col items-center group-hover:border-emerald-500/50 group-hover:bg-emerald-500/5 transition-all bg-slate-800/50">
<Upload className="w-10 h-10 text-slate-600 mb-4 transition-colors group-hover:text-emerald-500" />
<p className="font-bold text-slate-300 text-center">
{selectedFile ? selectedFile.name : 'اسحب الملف هنا أو انقر للاختيار'}
</p>
<p className="text-xs text-slate-500 mt-2">PDF, JPG, PNG (حد أقصى 10MB)</p>
</div>
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setIsUploadModalOpen(false)}
className="flex-1 bg-slate-800 text-slate-300 font-bold py-4 rounded-xl hover:bg-slate-700 transition-all"
>
إلغاء
</button>
<button
type="submit"
disabled={isUploading || !selectedCompanyId || !selectedFile}
className="flex-[2] bg-emerald-500 text-slate-950 font-bold py-4 rounded-xl shadow-lg shadow-emerald-500/20 disabled:opacity-50 flex items-center justify-center gap-3 transition-all"
>
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Upload className="w-5 h-5" />}
{isUploading ? 'جاري المعالجة...' : 'ابدأ المعالجة الآن'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* ── View Invoice Modal ─────────────────────────────────── */}
<AnimatePresence>
{isViewModalOpen && viewingInvoice && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-slate-900 border border-slate-800 w-full max-w-5xl h-[90vh] rounded-[32px] shadow-2xl flex flex-col overflow-hidden"
>
<header className="px-8 py-6 border-b border-slate-800 flex items-center justify-between">
<div>
<h3 className="text-2xl font-bold text-white">معاينة الفاتورة</h3>
<p className="text-slate-500 text-sm">رقم: {viewingInvoice.invoice_number || '---'} {viewingInvoice.company?.name}</p>
</div>
<button
onClick={() => setIsViewModalOpen(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-800 text-slate-500 hover:text-white transition-all"
>
<X className="w-6 h-6" />
</button>
</header>
<div className="flex-1 overflow-auto bg-slate-950 p-8 flex justify-center items-center">
<div className="w-full h-full max-w-4xl bg-white rounded-xl overflow-hidden shadow-2xl relative">
<iframe
src={`${apiClient.defaults.baseURL}/invoices/${viewingInvoice.id}/file?token=${localStorage.getItem('access_token')}#toolbar=0`}
className="w-full h-full border-none"
title="Invoice Preview"
/>
{/* Fallback overlay in case of loading issues */}
<div className="absolute inset-0 pointer-events-none flex items-center justify-center bg-slate-900/10 backdrop-blur-[2px] opacity-0 hover:opacity-100 transition-opacity">
<p className="bg-slate-900/80 text-white px-4 py-2 rounded-lg text-xs">جاري عرض الفاتورة...</p>
</div>
</div>
</div>
<footer className="px-8 py-6 border-t border-slate-800 bg-slate-900/50 flex items-center justify-between">
<div className="flex items-center gap-8">
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">المجموع الكلي</p>
<p className="text-xl font-black text-emerald-400">{Number(viewingInvoice.grand_total).toLocaleString('en-US', { minimumFractionDigits: 3 })} JOD</p>
</div>
<div className="w-px h-10 bg-slate-800" />
<div>
<p className="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-1">الحالة</p>
<StatusBadge invoice={viewingInvoice} />
</div>
</div>
<div className="flex gap-4">
<button
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-slate-800 border border-slate-700 text-slate-300 font-bold flex items-center gap-2 hover:bg-slate-700 transition-all"
>
<Download className="w-4 h-4" />
تحميل
</button>
<button
onClick={() => handleSubmitToJoFotara(viewingInvoice)}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold px-8 py-3 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all"
>
{submitLoading === viewingInvoice.id ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-4 h-4" />}
إرسال لجو فوترة
</button>
</div>
</footer>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,277 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Settings Page (Premium Dark)
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import {
Settings,
User as UserIcon,
Lock,
Bell,
Shield,
CreditCard,
Save,
Palette,
Moon,
Camera,
Loader2,
CheckCircle2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import apiClient from '../../api/client';
import { useAuthStore } from '../../store/authStore';
export const SettingsPage = () => {
const [activeTab, setActiveTab] = useState('profile');
const [user, setUser] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
// Form State
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
language: 'العربية'
});
const updateUser = useAuthStore((state) => state.updateUser);
useEffect(() => {
const fetchProfile = async () => {
try {
// Get current user from auth state or fetch again
const { data } = await apiClient.get('/auth/me');
setUser(data);
setFormData({
name: data.name || '',
email: data.email || '',
phone: data.phone || '',
language: data.language || 'العربية'
});
} catch (err) {
console.error('Failed to fetch profile', err);
} finally {
setIsLoading(false);
}
};
fetchProfile();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
const { email, ...updateData } = formData;
await apiClient.post('/users/profile', updateData);
updateUser({ name: formData.name });
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 3000);
} catch (err) {
alert('حدث خطأ أثناء حفظ التغييرات');
} finally {
setIsSaving(false);
}
};
const tabs = [
{ id: 'profile', label: 'الملف الشخصي', icon: UserIcon },
{ id: 'security', label: 'الأمان والخصوصية', icon: Lock },
{ id: 'office', label: 'إعدادات المكتب', icon: Settings },
{ id: 'notifications', label: 'التنبيهات', icon: Bell },
{ id: 'appearance', label: 'المظهر والنظام', icon: Palette },
{ id: 'subscription', label: 'الاشتراك والدفع', icon: CreditCard },
];
if (isLoading) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<Loader2 className="w-10 h-10 text-emerald-500 animate-spin" />
</div>
);
}
return (
<div className="space-y-8 animate-in fade-in duration-700 max-w-7xl mx-auto">
<header className="flex items-end justify-between">
<div>
<h2 className="text-4xl font-black text-white tracking-tight">إعدادات النظام</h2>
<p className="text-slate-300 mt-2 text-lg font-medium">إدارة حسابك الشخصي وتخصيص تجربة "مُصادَق" الخاصة بك.</p>
</div>
<AnimatePresence>
{showSuccess && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 px-6 py-3 rounded-2xl flex items-center gap-3 font-bold"
>
<CheckCircle2 className="w-5 h-5" />
تم حفظ التغييرات بنجاح
</motion.div>
)}
</AnimatePresence>
</header>
<div className="flex flex-col lg:flex-row gap-10">
{/* ── Tabs Sidebar ─────────────────────────── */}
<aside className="w-full lg:w-80 space-y-3">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-4 px-7 py-5 rounded-[20px] font-bold transition-all relative group ${
isActive
? 'bg-emerald-500 text-slate-950 shadow-2xl shadow-emerald-500/30 scale-[1.02]'
: 'text-slate-400 hover:bg-slate-800/40 hover:text-slate-200'
}`}
>
<Icon className={`w-6 h-6 ${isActive ? 'text-slate-950' : 'text-slate-500 group-hover:text-slate-300'}`} />
<span className="text-lg">{tab.label}</span>
{isActive && (
<motion.div
layoutId="activeTab"
className="absolute right-0 w-1.5 h-8 bg-slate-950 rounded-l-full"
/>
)}
</button>
);
})}
</aside>
{/* ── Content Area ─────────────────────────── */}
<main className="flex-1 bg-slate-900/40 backdrop-blur-3xl border border-slate-800/50 rounded-[40px] overflow-hidden flex flex-col min-h-[650px] shadow-2xl shadow-black/50">
<div className="p-12 flex-1">
{activeTab === 'profile' && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-12">
{/* Profile Header */}
<div className="flex flex-col md:flex-row items-center gap-10 pb-12 border-b border-slate-800/50">
<div className="relative group">
<div className="w-32 h-32 rounded-[32px] bg-gradient-to-br from-emerald-400 via-emerald-500 to-emerald-600 flex items-center justify-center text-5xl font-black text-slate-950 shadow-2xl shadow-emerald-500/20 group-hover:scale-105 transition-transform duration-500">
{formData.name[0] || 'U'}
</div>
<button className="absolute -bottom-2 -right-2 w-12 h-12 bg-slate-900 border border-slate-700 rounded-2xl flex items-center justify-center text-slate-400 hover:text-emerald-400 hover:border-emerald-500/50 transition-all shadow-xl">
<Camera className="w-6 h-6" />
</button>
</div>
<div className="text-center md:text-right">
<h3 className="text-3xl font-black text-white mb-2">{formData.name || 'مستخدم جديد'}</h3>
<div className="flex flex-wrap items-center gap-3 justify-center md:justify-start">
<span className="bg-emerald-500/10 text-emerald-400 text-xs font-black px-4 py-1.5 rounded-full border border-emerald-500/20 uppercase tracking-widest">
{user?.role === 'admin' ? 'مدير مكتب' : 'محاسب'}
</span>
<span className="text-slate-400 font-bold text-sm"></span>
<span className="text-slate-400 font-bold text-sm">{formData.email}</span>
</div>
</div>
</div>
{/* Form Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-8">
<div className="space-y-3">
<label className="text-sm font-black text-slate-500 uppercase tracking-widest ml-1">الاسم الكامل</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
className="w-full bg-slate-900/60 border border-slate-800 rounded-2xl px-6 py-4 text-white font-bold outline-none focus:border-emerald-500/50 focus:bg-slate-900 transition-all shadow-inner"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-black text-slate-500 uppercase tracking-widest ml-1">البريد الإلكتروني</label>
<input
type="email"
value={formData.email}
onChange={e => setFormData({...formData, email: e.target.value})}
className="w-full bg-slate-900/60 border border-slate-800 rounded-2xl px-6 py-4 text-slate-400 font-bold outline-none cursor-not-allowed opacity-70"
disabled
/>
</div>
<div className="space-y-3">
<label className="text-sm font-black text-slate-500 uppercase tracking-widest ml-1">رقم الهاتف</label>
<input
type="text"
value={formData.phone}
onChange={e => setFormData({...formData, phone: e.target.value})}
className="w-full bg-slate-900/60 border border-slate-800 rounded-2xl px-6 py-4 text-white font-bold outline-none focus:border-emerald-500/50 focus:bg-slate-900 transition-all shadow-inner"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-black text-slate-500 uppercase tracking-widest ml-1">اللغة المفضلة</label>
<div className="relative">
<select
value={formData.language}
onChange={e => setFormData({...formData, language: e.target.value})}
className="w-full bg-slate-900/60 border border-slate-800 rounded-2xl px-6 py-4 text-white font-bold outline-none focus:border-emerald-500/50 focus:bg-slate-900 transition-all appearance-none cursor-pointer"
>
<option>العربية</option>
<option>English</option>
</select>
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none text-slate-600">
<Palette className="w-5 h-5" />
</div>
</div>
</div>
</div>
</motion.div>
)}
{activeTab === 'appearance' && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-10">
<h3 className="text-3xl font-black text-white">المظهر والنظام</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="p-8 rounded-3xl bg-emerald-500/10 border-2 border-emerald-500/40 flex flex-col items-center gap-6 cursor-pointer shadow-2xl shadow-emerald-500/10 transition-all hover:scale-[1.02]">
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center border border-emerald-500/30">
<Moon className="w-10 h-10 text-emerald-500" />
</div>
<div className="text-center">
<span className="font-black text-white text-xl block">الوضع الداكن (Premium)</span>
<span className="text-emerald-500/60 text-sm font-bold">الوضع الافتراضي مفعل الآن</span>
</div>
</div>
<div className="p-8 rounded-3xl bg-slate-800/20 border-2 border-slate-800 flex flex-col items-center gap-6 opacity-40 cursor-not-allowed grayscale">
<div className="w-20 h-20 bg-white rounded-3xl flex items-center justify-center border border-slate-700" />
<div className="text-center">
<span className="font-black text-slate-500 text-xl block">الوضع الفاتح</span>
<span className="text-slate-600 text-sm font-bold">غير متوفر في نسخة النخبة</span>
</div>
</div>
</div>
</motion.div>
)}
{(activeTab !== 'profile' && activeTab !== 'appearance') && (
<div className="flex flex-col items-center justify-center h-full text-center py-20">
<div className="w-24 h-24 bg-slate-800/50 rounded-full flex items-center justify-center mb-8 border border-slate-700/50">
<Shield className="w-12 h-12 text-slate-700" />
</div>
<h3 className="text-2xl font-black text-slate-400">هذه الخيارات قيد التطوير</h3>
<p className="text-slate-600 max-w-sm mt-4 text-lg font-medium">نحن نعمل على بناء أقوى أدوات التحكم والأمان لتناسب احتياجات المحاسب المتميز.</p>
</div>
)}
</div>
<footer className="px-12 py-8 border-t border-slate-800/50 bg-slate-900/60 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<p className="text-sm text-slate-500 font-bold uppercase tracking-widest">تعديل البيانات متاح حالياً</p>
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-black px-12 py-4 rounded-2xl flex items-center gap-3 shadow-2xl shadow-emerald-500/30 transition-all active:scale-95 disabled:opacity-50"
>
{isSaving ? <Loader2 className="w-6 h-6 animate-spin" /> : <Save className="w-6 h-6" />}
<span className="text-lg">حفظ التغييرات</span>
</button>
</footer>
</main>
</div>
</div>
);
};

View File

@@ -0,0 +1,279 @@
/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Staff Management Page (Premium Dark)
* ════════════════════════════════════════════════════════════
*/
import { useState, useEffect } from 'react';
import { Users, UserPlus, Search, Shield, Mail, MoreVertical, Trash2, Loader2, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../../store/authStore';
import apiClient from '../../api/client';
export const StaffPage = () => {
const user = useAuthStore((state) => state.user);
const isAdmin = user?.role && ['admin', 'super_admin'].includes(user.role.toLowerCase());
const [staff, setStaff] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
// Form State
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('accountant');
const [isSubmitting, setIsSubmitting] = useState(false);
const fetchStaff = async () => {
try {
const { data } = await apiClient.get('/users');
setStaff(data);
} catch (error) {
console.error('Failed to fetch staff', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchStaff();
}, []);
const handleCreateStaff = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await apiClient.post('/users', { name, email, password, role });
setIsAddModalOpen(false);
setName('');
setEmail('');
setPassword('');
fetchStaff();
} catch (error) {
console.error('Failed to create staff', error);
alert('حدث خطأ أثناء إضافة الموظف');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('هل أنت متأكد من حذف هذا الموظف؟')) return;
try {
await apiClient.delete(`/users/${id}`);
fetchStaff();
} catch (error) {
alert('فشل حذف الموظف');
}
};
const filteredStaff = staff.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.email.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-8 animate-in fade-in duration-700">
<header className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-black text-white">إدارة الموظفين</h2>
<p className="text-slate-300 mt-1">إدارة فريق العمل المالي لمكتب المحاسبة الخاص بك.</p>
</div>
{isAdmin && (
<button
onClick={() => setIsAddModalOpen(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
>
<UserPlus className="w-5 h-5" />
إضافة موظف جديد
</button>
)}
</header>
{/* ── Search Bar ──────────────────────────────── */}
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-xl px-4 py-3 flex items-center gap-3">
<Search className="w-5 h-5 text-slate-500" />
<input
type="text"
placeholder="ابحث بالاسم أو البريد الإلكتروني..."
className="bg-transparent border-none outline-none flex-1 text-slate-200 text-sm placeholder-slate-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* ── Staff List ───────────────────────────────────── */}
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800/60 rounded-2xl overflow-hidden min-h-[400px] flex flex-col">
{isLoading ? (
<div className="flex-1 flex flex-col justify-center items-center">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin mb-4" />
<p className="text-slate-500">جاري جلب بيانات الفريق...</p>
</div>
) : filteredStaff.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center">
<div className="w-20 h-20 bg-slate-800 rounded-2xl flex items-center justify-center mb-6 border border-slate-700">
<Users className="w-10 h-10 text-slate-600" />
</div>
<h3 className="text-xl font-bold text-white mb-2">لا يوجد موظفون مضافون</h3>
<p className="text-slate-500 max-w-sm mb-8">يمكنك إضافة موظفين لمساعدتك في إدارة ومعالجة فواتير الشركات.</p>
{user?.role === 'admin' ? (
<button onClick={() => setIsAddModalOpen(true)} className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-3 px-8 rounded-xl transition-all">
إضافة أول موظف
</button>
) : (
<p className="text-slate-500 max-w-sm mb-8">ليس لديك صلاحية إضافة موظفين.</p>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-slate-800/50 border-b border-slate-800/60">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-300">الاسم الكامل</th>
<th className="px-6 py-4 text-xs font-bold text-slate-300">البريد الإلكتروني</th>
<th className="px-6 py-4 text-xs font-bold text-slate-300">الدور الوظيفي</th>
{user?.role === 'admin' && <th className="px-6 py-4 text-xs font-bold text-slate-300 text-center">إجراءات</th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{filteredStaff.map((s, idx) => (
<motion.tr
key={s.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="hover:bg-slate-800/30 transition-colors"
>
<td className="px-6 py-4 font-bold text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-700 to-slate-800 flex items-center justify-center text-slate-300 font-black border border-slate-700">
{s.name[0]}
</div>
{s.name}
</div>
</td>
<td className="px-6 py-4 text-slate-300">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-500" />
{s.email}
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-[10px] font-bold border ${
s.role === 'admin'
? 'text-emerald-400 bg-emerald-400/5 border-emerald-400/20'
: 'text-blue-400 bg-blue-400/5 border-blue-400/20'
} uppercase tracking-widest`}>
<Shield className="w-3 h-3" />
{s.role === 'admin' ? 'مدير نظام' : 'محاسب'}
</span>
</td>
{user?.role === 'admin' && (
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-2">
<button className="p-2 text-slate-500 hover:text-white transition-colors">
<MoreVertical className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(s.id)}
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
)}
</motion.tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* ── Add Staff Modal ─────────────────────────────────── */}
<AnimatePresence>
{isAddModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-slate-900 border border-slate-800 p-8 w-full max-w-md rounded-3xl shadow-2xl relative"
>
<button onClick={() => setIsAddModalOpen(false)} className="absolute top-6 left-6 text-slate-500 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
<h3 className="text-2xl font-bold text-white mb-6">إضافة موظف جديد</h3>
<form onSubmit={handleCreateStaff} className="space-y-5">
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">الاسم الكامل</label>
<input
type="text"
required
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white placeholder-slate-600"
placeholder="مثال: أحمد محمد"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">البريد الإلكتروني</label>
<input
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white placeholder-slate-600"
placeholder="ahmed@office.com"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">كلمة المرور المؤقتة</label>
<input
type="password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white placeholder-slate-600"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-400 mb-2">الدور الوظيفي</label>
<select
value={role}
onChange={e => setRole(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 outline-none focus:border-emerald-500/50 transition-all text-white appearance-none cursor-pointer"
>
<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-800 text-slate-400 font-bold py-3 rounded-xl hover:bg-slate-700 transition-all"
>
إلغاء
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-emerald-500 text-slate-950 font-bold py-3 rounded-xl shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2 transition-all"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
حفظ البيانات
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -20,6 +20,7 @@ interface AuthState {
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
clearAuth: () => void;
updateUser: (data: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
@@ -35,6 +36,11 @@ export const useAuthStore = create<AuthState>()(
localStorage.removeItem('access_token');
set({ user: null, isAuthenticated: false });
},
updateUser: (data) => {
set((state) => ({
user: state.user ? { ...state.user, ...data } : null
}));
},
}),
{
name: 'musadaq-auth-storage',

0
old.tsx Normal file
View File

View File

@@ -61,9 +61,13 @@ ssh $SERVER_USER@$SERVER_IP << EOF
\$DOCKER_CMD down --remove-orphans || true
echo "🏗️ Rebuilding and starting production containers using \$DOCKER_CMD..."
\$DOCKER_CMD up -d --build
\$DOCKER_CMD build
\$DOCKER_CMD up -d
echo "🗄️ Running database migrations..."
echo "🗄️ Applying manual database schema fixes..."
bash db-fix.sh
echo "🗄️ Running TypeORM migrations (if any)..."
\$DOCKER_CMD exec -T api npm run migration:run:prod
else
echo "❌ Error: docker-compose.yml not found!"