Compare commits
47 Commits
5749a54e2d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15890fcfcd | ||
|
|
2f238e19c2 | ||
|
|
944c82730d | ||
|
|
f72c13f29a | ||
|
|
357274683c | ||
|
|
bb7afc5629 | ||
|
|
92fa5a4b49 | ||
|
|
6c1d67c695 | ||
|
|
7e0e271be2 | ||
|
|
4c2fd7bba5 | ||
|
|
c3f3d940e5 | ||
|
|
09cb8efa80 | ||
|
|
f4e505a610 | ||
|
|
5aa3a178b9 | ||
|
|
a113f72842 | ||
|
|
660c551098 | ||
|
|
2e900e395f | ||
|
|
b14a97bc60 | ||
|
|
31022da057 | ||
|
|
4cfa65364e | ||
|
|
401efaecb2 | ||
|
|
fd00e9c57d | ||
|
|
444097814d | ||
|
|
2e2d76c0a8 | ||
|
|
6b9ce6e95b | ||
|
|
ff8126f93b | ||
|
|
3ae3f1d797 | ||
|
|
946c7db96c | ||
|
|
ef9baf33f7 | ||
|
|
3acd9f261b | ||
|
|
47d473add5 | ||
|
|
0b0cd9798c | ||
|
|
066df077b1 | ||
|
|
9f5b202bb2 | ||
|
|
6be5b87e03 | ||
|
|
458af20235 | ||
|
|
26c79037c2 | ||
|
|
d857e7428c | ||
|
|
9ce817a9bb | ||
|
|
9756adfaae | ||
|
|
2e18020e49 | ||
|
|
aad1998e56 | ||
|
|
93591c75e2 | ||
|
|
77434fa815 | ||
|
|
ce7b1fc5d8 | ||
|
|
f5f0fd792a | ||
|
|
6a5e0e65ec |
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* الملف الشخصي
|
||||
*/
|
||||
|
||||
@@ -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,10 +110,20 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
tenantId: user.tenant_id,
|
||||
role: user.role
|
||||
// ── 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,
|
||||
role: user.role
|
||||
};
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload);
|
||||
@@ -152,10 +163,10 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Access Denied');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
tenantId: user.tenant_id,
|
||||
role: user.role
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
tenantId: user.tenant_id,
|
||||
role: user.role
|
||||
};
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* تسجيل خروج
|
||||
*/
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
21
backend/src/modules/dashboard/dashboard.module.ts
Normal file
21
backend/src/modules/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
@@ -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,29 +14,42 @@ 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 },
|
||||
where: { ...filter, status: InvoiceStatus.APPROVED },
|
||||
select: ['tax_amount'],
|
||||
});
|
||||
|
||||
@@ -43,17 +57,20 @@ export class DashboardService {
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
32
backend/src/modules/invoices/bulk-upload.processor.ts
Normal file
32
backend/src/modules/invoices/bulk-upload.processor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
@@ -28,41 +33,53 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
64
backend/src/modules/invoices/public-invoice.controller.ts
Normal file
64
backend/src/modules/invoices/public-invoice.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
backend/src/modules/tax-advisor/tax-advisor.service.ts
Normal file
42
backend/src/modules/tax-advisor/tax-advisor.service.ts
Normal 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 'عذراً، تعذر الحصول على إجابة حالياً. يرجى مراجعة القوانين الرسمية.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin', // مدير المكتب (قادر على الإضافة والتعديل الكامل)
|
||||
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها)
|
||||
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها لكل الشركات)
|
||||
CLIENT = 'client', // عميل (قادر على رفع وإدارة فواتير شركته فقط)
|
||||
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
|
||||
SUPER_ADMIN = 'super_admin', // مدير النظام العام (قادر على رؤية كل شيء)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) غير مدعوم');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
56
db-fix.sh
Normal 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
12
fix-limit.js
Normal 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();
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث سريع..."
|
||||
className="bg-transparent border-none outline-none text-sm w-64 text-slate-900"
|
||||
<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-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>
|
||||
|
||||
@@ -1,42 +1,67 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Premium Sidebar
|
||||
* مُصادَق (Musadaq) — Premium Dark Sidebar
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Building2,
|
||||
Users,
|
||||
Settings,
|
||||
LogOut
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Building2,
|
||||
Users,
|
||||
Settings,
|
||||
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
|
||||
@@ -44,9 +69,9 @@ export const Sidebar = () => {
|
||||
to={item.path}
|
||||
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'
|
||||
isActive
|
||||
? '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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
265
frontend/src/pages/Public/TrojanHorseConverter.tsx
Normal file
265
frontend/src/pages/Public/TrojanHorseConverter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,34 +47,34 @@ 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
|
||||
<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">
|
||||
<AnimatePresence mode="wait">
|
||||
{error && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Premium Register Page
|
||||
* مُصادَق (Musadaq) — Premium Dark Register Page
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -51,32 +51,32 @@ 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
|
||||
<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 ─────────────────────────────────── */}
|
||||
<div className="flex gap-2 mb-8 items-center justify-center">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
<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>
|
||||
@@ -84,7 +84,7 @@ export default function RegisterPage() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 ? (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@@ -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,14 +119,14 @@ 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" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@@ -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>
|
||||
|
||||
345
frontend/src/pages/companies/CompaniesPage.tsx
Normal file
345
frontend/src/pages/companies/CompaniesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +1,234 @@
|
||||
/**
|
||||
* ════════════════════════════════════════════════════════════
|
||||
* مُصادَق (Musadaq) — Dashboard Statistics Components
|
||||
* مُصادَق (Musadaq) — Premium Dark Dashboard Page
|
||||
* ════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
ArrowUpRight
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
TrendingUp,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
275
frontend/src/pages/dashboard/MultiEntityDashboard.tsx
Normal file
275
frontend/src/pages/dashboard/MultiEntityDashboard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
208
frontend/src/pages/dashboard/RiskMonitorPage.tsx
Normal file
208
frontend/src/pages/dashboard/RiskMonitorPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
277
frontend/src/pages/settings/SettingsPage.tsx
Normal file
277
frontend/src/pages/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
279
frontend/src/pages/staff/StaffPage.tsx
Normal file
279
frontend/src/pages/staff/StaffPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
echo "🗄️ Running database migrations..."
|
||||
\$DOCKER_CMD build
|
||||
\$DOCKER_CMD up -d
|
||||
|
||||
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!"
|
||||
|
||||
Reference in New Issue
Block a user